// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
// Command ats runs the audit trail service: a tamper-evident record of
// who did what across the estate, chained, anchored, and verifiable.
//
// Usage:
//
// ats serve serve the audit trail service
// ats verify replay the chain and cross-check every
// anchored checkpoint
// ats migrate <up|down|status|force>
// manage the trail schema out of band
// ats version print the version of this binary
// ats help [command ...] print help for a command
//
// Configuration binds from ATS_-prefixed environment variables; see
// the config package for the full reference. verify needs only
// ATS_DATABASE_URL, so it runs from an operator's shell without the
// rest of the service configured.
package main
import (
"context"
"fmt"
"os"
"github.com/deep-rent/nexus/dat/pg"
"github.com/deep-rent/nexus/eco/ats"
"github.com/deep-rent/nexus/eco/ats/anchor"
"github.com/deep-rent/nexus/eco/ats/chain"
"github.com/deep-rent/nexus/eco/ats/config"
"github.com/deep-rent/nexus/eco/ats/store"
"github.com/deep-rent/nexus/sec/jose/jwk"
"github.com/deep-rent/nexus/sec/jose/jwt"
"github.com/deep-rent/nexus/sec/vault/source/file"
"github.com/deep-rent/nexus/sys/boot"
"github.com/deep-rent/nexus/sys/cli"
)
// version is the version string stamped at build time:
//
// go build -ldflags "-X main.version=v1.2.3"
//
// Left empty, [cli.Version] falls back to the build information
// recorded by the Go toolchain.
var version string
func main() {
os.Exit(cli.Main(command()))
}
// command assembles the command tree. The root is a pure group:
// serving requires "ats serve", so that starting a long-running
// process is always something the invocation asked for by name.
func command() *cli.Command {
return &cli.Command{
Name: "ats",
Usage: "<command>",
Short: "run the audit trail service",
Long: "Manage, serve, and verify the audit trail service.",
Commands: []*cli.Command{
{
Name: "serve",
Short: "serve the audit trail service",
Run: serve,
},
{
Name: "verify",
Short: "replay the chain and cross-check the checkpoints",
Long: "Replay every entry from genesis, recomputing " +
"the hashes, and compare the chain against every " +
"anchored checkpoint and the recorded head. A " +
"divergence names the sequence and the kind of " +
"rewrite. Needs only ATS_DATABASE_URL.",
Run: verify,
},
boot.Migrations(config.Prefix, store.Open),
cli.ShowVersion(cli.Version(version)),
},
}
}
// serve assembles the service and runs it until termination.
func serve(ctx context.Context, args []string) error {
if len(args) > 0 {
return cli.Usagef("unknown command %q", args[0])
}
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("configuration issue: %w", err)
}
svc, err := ats.New(ctx, cfg, cli.Version(version))
if err != nil {
return err
}
return svc.Run(ctx)
}
// verify replays the chain against the checkpoints. It binds only the
// database section, so the pass runs without the rest of the service
// being configured — which is exactly how an operator investigating a
// suspicion wants to run it.
func verify(ctx context.Context, args []string) error {
if len(args) > 0 {
return cli.Usagef("unknown command %q", args[0])
}
cfg, err := boot.Load[boot.Database](config.Prefix + "DATABASE_")
if err != nil {
return fmt.Errorf("configuration issue: %w", err)
}
if !cfg.Enabled() {
return fmt.Errorf("%sDATABASE_URL is not set", config.Prefix)
}
// The verification keys as well, because a checkpoint's authority
// is its SIGNATURE and not the hash column stored beside it. Both
// live in the same database as the entries, so an attacker who
// rewrites the trail rewrites the column in the same breath — the
// token they cannot forge is the only thing worth comparing
// against. The keys are read from the mounted file rather than
// over the network, so this still runs with the service down.
vcfg, err := boot.Load[config.Vault](config.Prefix + "VAULT_")
if err != nil {
return fmt.Errorf("configuration issue: %w", err)
}
src := file.New(vcfg.File)
raw, err := src.Fetch(ctx)
if err != nil {
return fmt.Errorf(
"failed to read the checkpoint keys at %s: %w",
vcfg.File, err,
)
}
keys, err := src.Build(raw)
if err != nil {
return fmt.Errorf("failed to parse the checkpoint keys: %w", err)
}
verifier := jwt.NewVerifier[*anchor.Claims](
jwk.NewSet(keys...),
)
pool, err := pg.Connect(ctx, cfg.URL)
if err != nil {
return err
}
defer pool.Close()
s := store.New(pool)
out := cli.Stdout(ctx)
// The recorded heads, cross-checked as the replay passes them. A
// checkpoint that disagrees with the replay is the rewrite the
// anchor exists to catch.
//
// These come from the trail's OWN database, which is the same
// place the entries live — so they catch a careless rewrite and
// not a thorough one. An attacker who can drop the append-only
// trigger can also rewrite this table. Settling that needs the
// copy that was placed out of reach: the object-lock bucket, or a
// checkpoint a subscriber kept. The summary below says so rather
// than letting "ok" imply more than was proven.
anchored, err := s.Checkpoints(ctx)
if err != nil {
return fmt.Errorf("failed to read the checkpoints: %w", err)
}
// Every checkpoint is opened before it is trusted. A row whose
// token will not verify, or whose SIGNED claims disagree with the
// plain columns beside them, is itself the rewrite: the columns
// are ordinary attacker-writable data and the signature is not.
marks := make(map[int64][32]byte, len(anchored))
for _, c := range anchored {
seq, mark, err := anchor.Stated(verifier, c)
if err != nil {
return fmt.Errorf("TAMPERED: %w", err)
}
marks[seq] = mark
}
w := chain.NewWalker(chain.Head{})
var n int64
for {
batch, err := s.Entries(ctx, w.Head().Seq, store.VerifyBatch)
if err != nil {
return fmt.Errorf("failed to read the trail: %w", err)
}
if len(batch) == 0 {
break
}
for _, e := range batch {
if err := w.Step(e); err != nil {
return fmt.Errorf("TAMPERED: %w", err)
}
if mark, ok := marks[e.Seq]; ok && mark != e.Chain {
return fmt.Errorf(
"TAMPERED: the chain at %d does not match the "+
"anchored checkpoint — the trail was rewritten "+
"after that head was published", e.Seq,
)
}
n++
}
}
head, err := s.Head(ctx)
if err != nil {
return fmt.Errorf("failed to read the head: %w", err)
}
if err := w.Settle(head); err != nil {
return fmt.Errorf("TAMPERED: %w", err)
}
// A checkpoint standing beyond the head is a trail cut short.
//
// The replay cannot see this on its own: the sequence is dense, so
// a hole in the middle is evidence of a removal — but entries
// deleted off the END leave no hole, and a head rewritten to match
// settles cleanly. The signed statement is what remembers how far
// the trail once reached.
for _, c := range anchored {
if seq, _, err := anchor.Stated(verifier, c); err == nil &&
seq > head.Seq {
return fmt.Errorf(
"TAMPERED: a checkpoint states head %d but the trail "+
"ends at %d — %d entries were removed from the "+
"end after that head was published",
seq, head.Seq, seq-head.Seq,
)
}
}
fmt.Fprintf(out, "entries: %d\n", n)
fmt.Fprintf(out, "checkpoints: %d (signature verified)\n",
len(anchored))
fmt.Fprintf(out, "head: %d\n", head.Seq)
if len(anchored) == 0 && n > 0 {
fmt.Fprint(out,
"warning: no checkpoint at all — this replay proves "+
"internal consistency only, which a thorough rewrite "+
"also has\n")
} else if n > 0 {
fmt.Fprint(out,
"note: these checkpoints were read from this trail's own "+
"database. Their signatures verify, so the rows were "+
"not rewritten — but an attacker holding the signing "+
"key could mint replacements. To settle that, compare "+
"the heads above against a copy placed out of reach: "+
"the object-lock bucket, or one a subscriber kept.\n")
}
fmt.Fprint(out, "ok\n")
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
// Command example is the reference SCHEMA PLUGIN: the example schema
// packaged for the stock dse binary's plugin loader, and the template a
// real deployment copies to ship its models without compiling a binary
// of its own. Build it with:
//
// go build -buildmode=plugin -o schema.so ./cmd/dse/example
//
// and point the service at it with DSE_SCHEMA=/path/to/schema.so. Host
// and plugin must be built from the same module state with the same
// toolchain; see the plug package for the full contract.
package main
import (
"io/fs"
"github.com/deep-rent/nexus/cmd/dse/internal/example"
"github.com/deep-rent/nexus/eco/dse/schema"
)
// Declare implements the required plugin symbol; see plug.SymbolDeclare.
func Declare() *schema.Schema { return example.Declare() }
// Migrations implements the optional plugin symbol carrying the document
// table migrations; see plug.SymbolMigrations.
func Migrations() (string, fs.FS) { return example.Migrations() }
// main is never called: a plugin's exported symbols are its interface,
// but -buildmode=plugin still requires a main package.
func main() {}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package example
import (
"embed"
"io/fs"
"uuid"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/eco/dse/schema"
)
//go:embed migrations
var migrations embed.FS
// Module is the migration stream of the example's document tables.
const Module = "example"
// Migrations exposes the example's document table migrations, applied
// after the engine's bookkeeping stream.
func Migrations() (string, fs.FS) {
sub, err := fs.Sub(migrations, "migrations")
if err != nil {
panic(err)
}
return Module, sub
}
// House is a hierarchy root: its payloads carry the identifying envelope
// (id, user_id, and optional team_id).
type House struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
TeamID uuid.UUID `json:"team_id,omitzero"`
Name string `json:"name"`
Address string `json:"address,omitzero"`
}
// Validate implements the [valid.Validatable] interface.
func (h *House) Validate(v *valid.Validator) {
v.NotBlank("name", h.Name)
v.MaxLen("name", h.Name, 120)
v.MaxLen("address", h.Address, 250)
}
var _ valid.Validatable = (*House)(nil)
// Room is a child of a house; ownership resolves through house_id.
type Room struct {
ID uuid.UUID `json:"id"`
HouseID uuid.UUID `json:"house_id"`
Name string `json:"name"`
}
// Validate implements the [valid.Validatable] interface.
func (r *Room) Validate(v *valid.Validator) {
v.NotBlank("name", r.Name)
v.MaxLen("name", r.Name, 120)
}
var _ valid.Validatable = (*Room)(nil)
// Item is an inventory object inside a room.
type Item struct {
ID uuid.UUID `json:"id"`
RoomID uuid.UUID `json:"room_id"`
Name string `json:"name"`
Count int `json:"count,omitzero"`
}
// Validate implements the [valid.Validatable] interface.
func (i *Item) Validate(v *valid.Validator) {
v.NotBlank("name", i.Name)
v.MaxLen("name", i.Name, 120)
v.Min("count", i.Count, 0)
}
var _ valid.Validatable = (*Item)(nil)
// Defect records damage on an inventory item.
type Defect struct {
ID uuid.UUID `json:"id"`
ItemID uuid.UUID `json:"item_id"`
Note string `json:"note"`
}
// Validate implements the [valid.Validatable] interface.
func (d *Defect) Validate(v *valid.Validator) {
v.NotBlank("note", d.Note)
v.MaxLen("note", d.Note, 2000)
}
var _ valid.Validatable = (*Defect)(nil)
// Declare builds the deployment schema: the model tree, its tables, and
// the attachment slots. The corresponding document table migrations live
// under migrations/ and gate on the engine's bookkeeping stream.
func Declare() *schema.Schema {
s := schema.New()
schema.Model[House](s, "house", schema.Root(),
schema.Table("houses"),
schema.Attachments(
schema.Slot("floor_plan",
schema.Types("image/jpeg", "image/png"),
schema.Max(1)),
schema.Slot("epc", // Energy Performance Certificate
schema.Types("application/pdf"),
schema.Max(1)),
),
)
schema.Model[Room](s, "room", schema.Owner("house", "house_id"),
schema.Table("rooms"),
schema.Attachments(
schema.Slot("tour",
schema.Types("video/mp4"),
schema.Max(1),
schema.MaxSize(256<<20)),
),
)
schema.Model[Item](s, "item", schema.Owner("room", "room_id"),
schema.Table("items"),
)
schema.Model[Defect](s, "defect", schema.Owner("item", "item_id"),
schema.Table("defects"),
schema.Attachments(
schema.Slot("photos",
schema.Types("image/png", "image/jpeg"),
schema.Max(10),
schema.Thumbnail(64<<10, "image/webp")),
schema.Slot("noise",
schema.Types("audio/mp4"),
schema.Max(1),
schema.MaxSize(32<<20)),
),
)
return s
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
// Command dse runs the document sync engine.
//
// The engine's document models are Go code, and a deployment supplies
// them one of two ways: as a schema plugin (.so) built from this
// repository — DSE_SCHEMA names its path, defaulting to "schema.so" in
// the working directory; see the plug package for the contract and the
// example plugin under example/ for the template — or by compiling a
// binary of your own around the service package. This binary carries no
// schema itself: without a plugin it refuses to serve.
//
// Usage:
//
// dse serve serve the document sync engine
// dse schema print and validate the effective schema
// dse schema sql generate document table migrations
// dse migrate <up|down|status|force>
// manage the bookkeeping schema out of band
// dse version print the version of this binary
// dse help [command ...] print help for a command
//
// Configuration binds from DSE_-prefixed environment variables; see the
// config package for the full reference. Note that "dse migrate" covers
// the engine's own bookkeeping stream; a deployment's document tables
// ship their own migrations — through the plugin's Migrations symbol —
// gated on it via "-- requires: dse@1" and applied at startup.
package main
import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"github.com/deep-rent/nexus/eco/dse"
"github.com/deep-rent/nexus/eco/dse/config"
"github.com/deep-rent/nexus/eco/dse/driver/postgres"
"github.com/deep-rent/nexus/eco/dse/plug"
"github.com/deep-rent/nexus/eco/dse/schema"
"github.com/deep-rent/nexus/sys/boot"
"github.com/deep-rent/nexus/sys/cli"
)
// version is the version string stamped at build time:
//
// go build -ldflags "-X main.version=v1.2.3"
//
// Left empty, [cli.Version] falls back to the build information recorded
// by the Go toolchain.
var version string
func main() {
os.Exit(cli.Main(command()))
}
// command assembles the command tree.
//
// The root is a pure group: serving requires "dse serve", so that
// starting a long-running process is always something the invocation
// asked for by name.
func command() *cli.Command {
return &cli.Command{
Name: "dse",
Usage: "<command>",
Short: "run the document sync engine",
Long: "Manage and serve the document sync engine.",
Commands: []*cli.Command{
{
Name: "serve",
Short: "serve the document sync engine",
Run: serve,
},
{
Name: "schema",
Short: "print and validate the effective schema",
Long: "Load the schema the service would serve — the " +
"plugin named by DSE_SCHEMA, or the built-in " +
"reference schema — validate it, and print its " +
"models. Use it to verify a freshly built plugin " +
"loads into this binary before rolling a deployment.",
Run: describe,
Commands: []*cli.Command{sqlCommand()},
},
boot.Migrations(config.Prefix, postgres.Open),
cli.ShowVersion(cli.Version(version)),
},
}
}
// serve assembles the service around the effective schema and runs it
// until termination.
func serve(ctx context.Context, args []string) error {
if len(args) > 0 {
return cli.Usagef("unknown command %q", args[0])
}
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("configuration issue: %w", err)
}
sch, opts, err := load(cfg)
if err != nil {
return err
}
svc, err := dse.New(ctx, cfg, sch, cli.Version(version), opts...)
if err != nil {
return err
}
return svc.Run(ctx)
}
// load resolves the schema plugin named by the configuration. A missing
// file gets a hint rather than the plugin package's opaque "realpath
// failed": forgetting the plugin is the first mistake every new
// deployment makes.
func load(cfg config.Config) (*schema.Schema, []dse.Option, error) {
if _, err := os.Stat(cfg.Schema); errors.Is(err, fs.ErrNotExist) {
return nil, nil, fmt.Errorf(
"no schema plugin at %q: point %sSCHEMA at your deployment's "+
"schema.so (see the example plugin for the template)",
cfg.Schema, config.Prefix,
)
}
return plug.Load(cfg.Schema)
}
// describe prints and validates the effective schema, recovering the
// declaration panics a defective schema raises so they surface as a
// command error rather than a crash.
func describe(ctx context.Context, args []string) (err error) {
if len(args) > 0 {
return cli.Usagef("unknown command %q", args[0])
}
cfg := schemaConfig()
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("schema is defective: %v", r)
}
}()
sch, _, err := load(cfg)
if err != nil {
return err
}
out := cli.Stdout(ctx)
fmt.Fprintf(out, "schema: plugin %s\n", cfg.Schema)
for _, def := range sch.Models() {
role := "root"
if !def.Root {
role = fmt.Sprintf("child of %s via %s", def.Owner, def.OwnerVia)
}
fmt.Fprintf(out, " %-12s %-28s table %s\n", def.Name, role, def.Table)
for _, slot := range def.Slots {
thumb := ""
if slot.Policy.Thumb != nil {
thumb = " +thumbnail"
}
fmt.Fprintf(out, " slot %-12s %v%s\n",
slot.Name, slot.Policy.ContentTypes, thumb)
}
}
fmt.Fprint(out, "ok\n")
return nil
}
// schemaConfig binds just the schema plugin path from the environment,
// honoring the configured default. The schema commands must work without
// the rest of the service being configured (config.Load would demand
// AUTH_ISSUER), and the plugin path is the only input they need.
func schemaConfig() config.Config {
path := os.Getenv(config.Prefix + "SCHEMA")
if path == "" {
path = "schema.so"
}
return config.Config{Schema: path}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package main
import (
"bytes"
"context"
"flag"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"github.com/deep-rent/nexus/eco/dse/driver/postgres"
"github.com/deep-rent/nexus/eco/dse/schema"
"github.com/deep-rent/nexus/sys/cli"
)
// sqlCommand builds the "schema sql" subcommand: it renders the declared
// models' document tables as a versioned migration pair for the
// deployment's own stream. The shape is fully determined by the schema
// (see postgres.DDL), which is what makes --check meaningful: CI
// regenerates and compares, so a declaration change without a shipped
// migration fails the build instead of failing the first sync.
func sqlCommand() *cli.Command {
var (
version int
out string
name string
check bool
)
return &cli.Command{
Name: "sql",
Usage: "[flags] [model ...]",
Short: "generate document table migrations",
Long: "Render the declared models' document tables — all of " +
"them, or just the named ones — as a versioned up/down " +
"migration pair for the deployment's own stream, gated on " +
"the engine's bookkeeping module. The shape is the engine's " +
"column contract: regenerate rather than edit, and put " +
"deployment-specific extensions into later versions. With " +
"-check, compare against the existing pair instead of " +
"writing, so CI catches a schema change that shipped no " +
"migration.",
Flags: func(fs *flag.FlagSet) {
fs.IntVar(&version, "version", 1,
"version of the generated migration pair")
fs.StringVar(&out, "out", "migrations",
"directory the pair is written to")
fs.StringVar(&name, "name", "documents",
"base name of the generated pair")
fs.BoolVar(&check, "check", false,
"compare against the existing pair instead of writing")
},
Run: func(ctx context.Context, args []string) error {
return generate(ctx, args, version, out, name, check)
},
}
}
// generate renders (or verifies) the migration pair.
func generate(
ctx context.Context,
args []string,
version int,
out, name string,
check bool,
) (err error) {
if version < 1 {
return cli.Usagef("version must be positive")
}
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("schema is defective: %v", r)
}
}()
sch, _, err := load(schemaConfig())
if err != nil {
return err
}
return emit(ctx, sch, args, version, out, name, check)
}
// emit renders (or verifies) the migration pair of the given schema.
func emit(
ctx context.Context,
sch *schema.Schema,
args []string,
version int,
out, name string,
check bool,
) error {
models, err := chosen(sch, args)
if err != nil {
return err
}
up, down := render(models)
upFile := filepath.Join(out, fmt.Sprintf("%d_%s.up.sql", version, name))
downFile := filepath.Join(out, fmt.Sprintf("%d_%s.down.sql", version, name))
stdout := cli.Stdout(ctx)
if check {
for file, want := range map[string]string{
upFile: up, downFile: down,
} {
have, err := os.ReadFile(file)
if err != nil {
return fmt.Errorf("check failed: %w", err)
}
if !bytes.Equal(have, []byte(want)) {
return fmt.Errorf(
"%s is out of date with the declared schema; "+
"regenerate it with \"dse schema sql\"", file,
)
}
}
fmt.Fprintln(stdout, "ok")
return nil
}
// Generated SQL is source: it belongs in the tree beside the
// schema it came from, readable by whoever reads the rest.
if err := os.MkdirAll(out, 0o755); err != nil { // #nosec G301
return err
}
for file, content := range map[string]string{
upFile: up, downFile: down,
} {
// #nosec G306 -- generated source, not a secret.
if err := os.WriteFile(file, []byte(content), 0o644); err != nil {
return err
}
fmt.Fprintln(stdout, file)
}
return nil
}
// chosen resolves the requested models in declaration order — owners
// always precede their children there, which keeps generated scripts
// readable top-down.
func chosen(sch *schema.Schema, args []string) ([]schema.Definition, error) {
models := sch.Models()
if len(args) == 0 {
return models, nil
}
for _, arg := range args {
if !slices.ContainsFunc(models, func(d schema.Definition) bool {
return d.Name == arg
}) {
return nil, cli.Usagef("unknown model %q", arg)
}
}
var out []schema.Definition
for _, def := range models {
if slices.Contains(args, def.Name) {
out = append(out, def)
}
}
return out, nil
}
// render assembles the migration pair for the given models.
func render(models []schema.Definition) (up, down string) {
var u strings.Builder
fmt.Fprintf(&u,
`-- Document tables generated by "dse schema sql" from the declared
-- schema. The engine owns this shape: regenerate rather than edit, and
-- put deployment-specific extensions (expression indexes over the
-- payload, generated columns) into later versions of this stream.
--
-- requires: dse@%d
`, postgres.LatestVersion())
var d strings.Builder
for _, def := range models {
table, _ := postgres.DDL(def)
u.WriteString("\n")
u.WriteString(table)
}
for _, def := range slices.Backward(models) {
_, drop := postgres.DDL(def)
d.WriteString(drop)
}
return u.String(), d.String()
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
// Command hds runs the help desk: it serves the ticket API a support
// portal builds on, relays the website's contact form, and notifies
// people when their tickets move.
//
// Usage:
//
// hds serve serve the help desk
// hds migrate <up|down|status|force>
// manage the schema out of band
// hds version print the version of this binary
// hds help [command ...] print help for a command
//
// Configuration binds from HDS_-prefixed environment variables; see
// the config package for the full reference.
package main
import (
"context"
"fmt"
"os"
"github.com/deep-rent/nexus/eco/hds"
"github.com/deep-rent/nexus/eco/hds/config"
"github.com/deep-rent/nexus/eco/hds/store"
"github.com/deep-rent/nexus/sys/boot"
"github.com/deep-rent/nexus/sys/cli"
)
// version is the version string stamped at build time:
//
// go build -ldflags "-X main.version=v1.2.3"
//
// Left empty, [cli.Version] falls back to the build information
// recorded by the Go toolchain.
var version string
func main() {
os.Exit(cli.Main(command()))
}
// command assembles the command tree. The root is a pure group:
// serving requires "hds serve", so that starting a long-running
// process is always something the invocation asked for by name.
func command() *cli.Command {
return &cli.Command{
Name: "hds",
Usage: "<command>",
Short: "run the help desk",
Long: "Manage and serve the help desk.",
Commands: []*cli.Command{
{
Name: "serve",
Short: "serve the help desk",
Run: serve,
},
boot.Migrations(config.Prefix, store.Open),
cli.ShowVersion(cli.Version(version)),
},
}
}
// serve assembles the service and runs it until termination.
func serve(ctx context.Context, args []string) error {
if len(args) > 0 {
return cli.Usagef("unknown command %q", args[0])
}
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("configuration issue: %w", err)
}
svc, err := hds.New(ctx, cfg, cli.Version(version))
if err != nil {
return err
}
return svc.Run(ctx)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
// Command iam runs the IAM provider assembled by [iam].
//
// Usage:
//
// iam serve serve the IAM provider
// iam migrate <up|down|status|force>
// manage the database schema out of band
// iam version print the version of this binary
// iam help [command ...] print help for a command
//
// A command is required: "iam" on its own reports the omission and exits
// with code 2, so a supervisor cannot start the process without naming
// what it should do.
//
// Configuration binds from IAM_-prefixed environment variables; see the
// service package for the full reference. Token signing keys come from an
// OVHcloud KMS domain named by IAM_VAULT_OKMS_ENDPOINT or, absent one,
// from a file managed by the sibling vault command and mounted at
// IAM_VAULT_FILE (defaulting to ./vault.json).
//
// [iam]: github.com/deep-rent/nexus/eco/iam
package main
import (
"context"
"fmt"
"os"
// The embedded tz database backs the user time-zone validation, so a
// container image without zoneinfo files accepts the same names as any
// other deployment.
_ "time/tzdata"
"github.com/deep-rent/nexus/eco/iam"
"github.com/deep-rent/nexus/eco/iam/config"
"github.com/deep-rent/nexus/eco/iam/driver/postgres"
"github.com/deep-rent/nexus/sys/boot"
"github.com/deep-rent/nexus/sys/cli"
)
// version is the version string stamped at build time:
//
// go build -ldflags "-X main.version=v1.2.3"
//
// Left empty, [cli.Version] falls back to the build information recorded
// by the Go toolchain.
var version string
func main() {
os.Exit(cli.Main(command()))
}
// command assembles the command tree of the IAM service.
//
// The root is a pure group: serving requires "iam serve", so that starting
// a long-running process is always something the invocation asked for by
// name. The migrate group manages the database schema out of band.
func command() *cli.Command {
return &cli.Command{
Name: "iam",
Usage: "<command>",
Short: "run the IAM provider",
Long: "Manage and serve the IAM provider.",
Commands: []*cli.Command{
{
Name: "serve",
Short: "serve the IAM provider",
Run: serve,
},
boot.Migrations(config.Prefix, postgres.Open),
cli.ShowVersion(cli.Version(version)),
},
}
}
// serve assembles and runs the provider until termination.
func serve(ctx context.Context, args []string) error {
if len(args) > 0 {
return cli.Usagef("unknown command %q", args[0])
}
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("configuration issue: %w", err)
}
svc, err := iam.New(ctx, cfg, cli.Version(version))
if err != nil {
return err
}
return svc.Run(ctx)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
// Command mma runs the metrics monitoring agent: it sweeps the /metrics
// endpoints named by the targets file, stores the history in
// PostgreSQL with day-partitioned retention, and serves the query API a
// dashboard builds on.
//
// Usage:
//
// mma serve serve the metrics monitoring agent
// mma targets print and validate the targets file
// mma migrate <up|down|status|force>
// manage the history schema out of band
// mma version print the version of this binary
// mma help [command ...] print help for a command
//
// Configuration binds from MMA_-prefixed environment variables; see the
// config package for the full reference. The scrape topology lives in
// the JSON file named by MMA_TARGETS (default "targets.json").
package main
import (
"context"
"fmt"
"os"
"github.com/deep-rent/nexus/eco/mma"
"github.com/deep-rent/nexus/eco/mma/config"
"github.com/deep-rent/nexus/eco/mma/store"
"github.com/deep-rent/nexus/sys/boot"
"github.com/deep-rent/nexus/sys/cli"
)
// version is the version string stamped at build time:
//
// go build -ldflags "-X main.version=v1.2.3"
//
// Left empty, [cli.Version] falls back to the build information
// recorded by the Go toolchain.
var version string
func main() {
os.Exit(cli.Main(command()))
}
// command assembles the command tree. The root is a pure group: serving
// requires "mma serve", so that starting a long-running process is
// always something the invocation asked for by name.
func command() *cli.Command {
return &cli.Command{
Name: "mma",
Usage: "<command>",
Short: "run the metrics monitoring agent",
Long: "Manage and serve the metrics monitoring agent.",
Commands: []*cli.Command{
{
Name: "serve",
Short: "serve the metrics monitoring agent",
Run: serve,
},
{
Name: "targets",
Short: "print and validate the targets file",
Long: "Load the targets file the service would sweep — " +
"the path named by MMA_TARGETS — validate it, and " +
"print its entries. Use it to verify a topology " +
"change before rolling a deployment.",
Run: describe,
},
boot.Migrations(config.Prefix, store.Open),
cli.ShowVersion(cli.Version(version)),
},
}
}
// serve assembles the service around the configured targets and runs it
// until termination.
func serve(ctx context.Context, args []string) error {
if len(args) > 0 {
return cli.Usagef("unknown command %q", args[0])
}
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("configuration issue: %w", err)
}
targets, err := config.LoadTargets(cfg.Targets)
if err != nil {
return err
}
svc, err := mma.New(ctx, cfg, targets, cli.Version(version))
if err != nil {
return err
}
return svc.Run(ctx)
}
// describe prints and validates the targets file.
func describe(ctx context.Context, args []string) error {
if len(args) > 0 {
return cli.Usagef("unknown command %q", args[0])
}
path := os.Getenv(config.Prefix + "TARGETS")
if path == "" {
path = config.DefaultTargets
}
targets, err := config.LoadTargets(path)
if err != nil {
return err
}
out := cli.Stdout(ctx)
fmt.Fprintf(out, "targets: %s\n", path)
for _, t := range targets {
fmt.Fprintf(out, " %-16s %s\n", t.Name, t.URL)
}
fmt.Fprint(out, "ok\n")
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
// Command nds runs the notification delivery service: it owns the
// devices people can be reached on, what they have said about being
// reached, and the machinery that turns a sibling service's event into
// text on a lock screen.
//
// Usage:
//
// nds serve serve the notification service
// nds catalog print and validate the catalog file
// nds migrate <up|down|status|force>
// manage the registry schema out of band
// nds version print the version of this binary
// nds help [command ...] print help for a command
//
// Configuration binds from NDS_-prefixed environment variables; see the
// config package for the full reference. The notification catalog lives
// in the JSON file named by NDS_CATALOG (default "catalog.json").
package main
import (
"context"
"fmt"
"os"
// Quiet hours are evaluated in each phone's own zone, so the
// binary carries the zone database rather than trusting the
// image to have one. Without it every registration naming a zone
// is refused, and a zone stored earlier silently mutes nothing.
"slices"
"strings"
_ "time/tzdata"
"github.com/deep-rent/nexus/eco/nds"
"github.com/deep-rent/nexus/eco/nds/catalog"
"github.com/deep-rent/nexus/eco/nds/config"
"github.com/deep-rent/nexus/eco/nds/store"
"github.com/deep-rent/nexus/sys/boot"
"github.com/deep-rent/nexus/sys/cli"
)
// version is the version string stamped at build time:
//
// go build -ldflags "-X main.version=v1.2.3"
//
// Left empty, [cli.Version] falls back to the build information
// recorded by the Go toolchain.
var version string
func main() {
os.Exit(cli.Main(command()))
}
// command assembles the command tree. The root is a pure group: serving
// requires "nds serve", so that starting a long-running process is
// always something the invocation asked for by name.
func command() *cli.Command {
return &cli.Command{
Name: "nds",
Usage: "<command>",
Short: "run the notification delivery service",
Long: "Manage and serve the notification delivery service.",
Commands: []*cli.Command{
{
Name: "serve",
Short: "serve the notification service",
Run: serve,
},
{
Name: "catalog",
Short: "print and validate the catalog file",
Long: "Load the catalog the service would send — the " +
"path named by NDS_CATALOG — validate it, and " +
"print its categories. Run it before rolling a " +
"catalog change, so a missing translation fails " +
"a deploy rather than a notification.",
Run: describe,
},
boot.Migrations(config.Prefix, store.Open),
cli.ShowVersion(cli.Version(version)),
},
}
}
// serve assembles the service around the configured catalog and runs it
// until termination.
func serve(ctx context.Context, args []string) error {
if len(args) > 0 {
return cli.Usagef("unknown command %q", args[0])
}
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("configuration issue: %w", err)
}
cat, err := catalog.Load(cfg.Catalog)
if err != nil {
return err
}
svc, err := nds.New(ctx, cfg, cat, cli.Version(version))
if err != nil {
return err
}
return svc.Run(ctx)
}
// catalogPath resolves the catalog file the way serve would.
func catalogPath() string {
if path := os.Getenv(config.Prefix + "CATALOG"); path != "" {
return path
}
return config.DefaultCatalog
}
// describe prints and validates the catalog file.
//
// The visibility column is the one worth reading before a deploy: it is
// what decides whether a category's detail reaches a locked phone, and
// it is the setting a new category most easily gets wrong.
func describe(ctx context.Context, args []string) error {
if len(args) > 0 {
return cli.Usagef("unknown command %q", args[0])
}
path := catalogPath()
cat, err := catalog.Load(path)
if err != nil {
return err
}
out := cli.Stdout(ctx)
fmt.Fprintf(out, "catalog: %s\n", path)
fmt.Fprintf(out, "languages: %s\n",
strings.Join(cat.Languages(), ", "))
fmt.Fprint(out, "\n")
categories := cat.Categories()
slices.SortFunc(categories,
func(a, b catalog.Category) int {
return strings.Compare(a.Name, b.Name)
})
for _, c := range categories {
flags := []string{string(c.Visibility)}
if c.Urgent {
flags = append(flags, "urgent")
}
if c.Collapse != "" {
flags = append(flags, "collapse="+c.Collapse)
}
fmt.Fprintf(out, " %-28s %-4s %s\n",
c.Name, c.Choice(), strings.Join(flags, " "))
if len(c.Variables) > 0 {
fmt.Fprintf(out, " takes: %s\n",
strings.Join(c.Variables, ", "))
}
for _, lang := range cat.Languages() {
text, ok := c.Text[lang]
if !ok {
continue
}
fmt.Fprintf(out, " %s: %s — %s\n",
lang, text.Title, text.Body)
if text.Generic != "" {
fmt.Fprintf(out, " %s: locked → %s\n",
lang, text.Generic)
}
}
}
fmt.Fprint(out, "\nok\n")
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
// Command pes runs the purchase and entitlement service: it reconciles
// Stripe, App Store, and Play Store evidence into a PostgreSQL ledger
// and serves the entitlement API applications gate on.
//
// Usage:
//
// pes serve serve the purchase and entitlement service
// pes catalog print and validate the catalog file
// pes rebuild reproject every subject's entitlements
// pes migrate <up|down|status|force>
// manage the ledger schema out of band
// pes version print the version of this binary
// pes help [command ...] print help for a command
//
// Configuration binds from PES_-prefixed environment variables; see the
// config package for the full reference. The product catalog lives in
// the JSON file named by PES_CATALOG (default "catalog.json").
package main
import (
"context"
"fmt"
"maps"
"os"
"slices"
"strings"
"github.com/deep-rent/nexus/dat/pg"
"github.com/deep-rent/nexus/eco/pes"
"github.com/deep-rent/nexus/eco/pes/catalog"
"github.com/deep-rent/nexus/eco/pes/config"
"github.com/deep-rent/nexus/eco/pes/ledger"
"github.com/deep-rent/nexus/eco/pes/reconcile"
"github.com/deep-rent/nexus/sys/boot"
"github.com/deep-rent/nexus/sys/cli"
)
// version is the version string stamped at build time:
//
// go build -ldflags "-X main.version=v1.2.3"
//
// Left empty, [cli.Version] falls back to the build information
// recorded by the Go toolchain.
var version string
func main() {
os.Exit(cli.Main(command()))
}
// command assembles the command tree. The root is a pure group:
// serving requires "pes serve", so that starting a long-running
// process is always something the invocation asked for by name.
func command() *cli.Command {
return &cli.Command{
Name: "pes",
Usage: "<command>",
Short: "run the purchase and entitlement service",
Long: "Manage and serve the purchase and entitlement service.",
Commands: []*cli.Command{
{
Name: "serve",
Short: "serve the purchase and entitlement service",
Run: serve,
},
{
Name: "catalog",
Short: "print and validate the catalog file",
Long: "Load the catalog the service would sell — the " +
"path named by PES_CATALOG — validate it, and " +
"print its products. Use it to verify a catalog " +
"change before rolling a deployment.",
Run: describe,
},
{
Name: "rebuild",
Short: "reproject every subject's entitlements",
Long: "Recompute the entitlement projection of every " +
"subject from the facts on the ledger, against " +
"the catalog named by PES_CATALOG. Run it after " +
"a catalog change that touches feature keys, so " +
"existing purchases pick the change up.",
Run: rebuild,
},
boot.Migrations(config.Prefix, ledger.Open),
cli.ShowVersion(cli.Version(version)),
},
}
}
// serve assembles the service around the configured catalog and runs
// it until termination.
func serve(ctx context.Context, args []string) error {
if len(args) > 0 {
return cli.Usagef("unknown command %q", args[0])
}
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("configuration issue: %w", err)
}
cat, err := catalog.Load(cfg.Catalog)
if err != nil {
return err
}
svc, err := pes.New(ctx, cfg, cat, cli.Version(version))
if err != nil {
return err
}
return svc.Run(ctx)
}
// catalogPath resolves the catalog file the way serve would.
func catalogPath() string {
if path := os.Getenv(config.Prefix + "CATALOG"); path != "" {
return path
}
return config.DefaultCatalog
}
// describe prints and validates the catalog file.
func describe(ctx context.Context, args []string) error {
if len(args) > 0 {
return cli.Usagef("unknown command %q", args[0])
}
path := catalogPath()
cat, err := catalog.Load(path)
if err != nil {
return err
}
products := cat.Products()
slices.SortFunc(products, func(a, b catalog.Product) int {
return strings.Compare(a.ID, b.ID)
})
out := cli.Stdout(ctx)
fmt.Fprintf(out, "catalog: %s\n", path)
for _, p := range products {
fmt.Fprintf(out, " %-20s %-12s tier %d\n", p.ID, p.Kind, p.Tier)
fmt.Fprintf(out, " grants: %s\n",
strings.Join(p.Entitlements, ", "))
for _, provider := range slices.Sorted(maps.Keys(p.SKUs)) {
fmt.Fprintf(out, " %s: %s\n",
provider, strings.Join(p.SKUs[provider], ", "))
}
}
fmt.Fprint(out, "ok\n")
return nil
}
// rebuild reprojects every subject's entitlements from the ledger.
// It binds only the database section and the catalog path, so the
// pass runs without the rest of the service being configured.
func rebuild(ctx context.Context, args []string) error {
if len(args) > 0 {
return cli.Usagef("unknown command %q", args[0])
}
cfg, err := boot.Load[boot.Database](config.Prefix + "DATABASE_")
if err != nil {
return fmt.Errorf("configuration issue: %w", err)
}
if !cfg.Enabled() {
return fmt.Errorf("%sDATABASE_URL is not set", config.Prefix)
}
cat, err := catalog.Load(catalogPath())
if err != nil {
return err
}
pool, err := pg.Connect(ctx, cfg.URL)
if err != nil {
return err
}
defer pool.Close()
engine := reconcile.New(ledger.New(pool), cat, nil)
if err := engine.Rebuild(ctx); err != nil {
return err
}
fmt.Fprint(cli.Stdout(ctx), "ok\n")
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
// Command vault manages signing key files: JSON documents holding the keys
// a service signs tokens with and publishes for verification.
//
// Usage:
//
// vault add -src keys.json [-alg ES256] [-verify]
// vault promote -src keys.json -kid KID
// vault retire -src keys.json -kid KID
// vault remove -src keys.json -kid KID
// vault version
// vault help [command ...]
//
// The add subcommand mints a fresh key for the given JOSE algorithm and
// appends it to the file, creating the file when absent; -verify stages it
// as verification-only. The promote and retire subcommands move a key into
// and out of the signing rotation, and remove deletes it outright.
//
// A verification-only key keeps checking signatures already issued without
// producing new ones, which is what lets a key be replaced without
// invalidating outstanding tokens:
//
// vault add -src keys.json -verify # stage the new key
// vault promote -src keys.json -kid NEW # once readers have picked it up
// vault retire -src keys.json -kid OLD
// vault remove -src keys.json -kid OLD # once its tokens have expired
package main
import (
"context"
"errors"
"flag"
"fmt"
"os"
"strings"
"github.com/deep-rent/nexus/sec/jose/jwa"
"github.com/deep-rent/nexus/sec/jose/jwk"
"github.com/deep-rent/nexus/sec/vault/source/file"
"github.com/deep-rent/nexus/sys/cli"
)
// version is the version string stamped at build time:
//
// go build -ldflags "-X main.version=v1.2.3"
//
// Left empty, [cli.Version] falls back to the build information recorded
// by the Go toolchain.
var version string
func main() {
os.Exit(cli.Main(command()))
}
// command assembles the command tree. The root is a pure group: every
// action edits a key file, so the invocation must name which one.
func command() *cli.Command {
return &cli.Command{
Name: "vault",
Usage: "<command>",
Short: "manage signing key files",
Long: "Manage the JSON files holding the keys a service signs\n" +
"tokens with and publishes for verification.\n" +
"\n" +
"Replacing a key stages the new one, promotes it once readers\n" +
"have picked it up, retires the old one, and removes it once\n" +
"the tokens it signed have expired.",
Commands: []*cli.Command{
add(),
promote(),
retire(),
remove(),
cli.ShowVersion(cli.Version(version)),
},
}
}
// add mints a fresh key and appends it to the file.
func add() *cli.Command {
var (
src string
alg string
verify bool
)
return &cli.Command{
Name: "add",
Usage: "-src FILE [-alg ALG] [-verify]",
Short: "mint a key and append it to the file",
Long: "Mint a fresh key for the given JOSE algorithm and append it\n" +
"to the key file, which is created when absent.\n" +
"\n" +
"With -verify the key is added as verification-only, so it is\n" +
"published without signing anything until promoted.\n" +
"\n" +
"Supported algorithms:\n - " +
strings.Join(jwa.List(), "\n - "),
Flags: func(fs *flag.FlagSet) {
fs.StringVar(&src, "src", "", "path to the key file")
fs.StringVar(&alg, "alg", "ES256", "JOSE algorithm of the new key")
fs.BoolVar(
&verify,
"verify",
false,
"stage the key as verification-only",
)
},
Run: func(ctx context.Context, args []string) error {
if err := expect(args, src); err != nil {
return err
}
if !jwa.Supports(alg) {
return cli.Usagef(
"unsupported algorithm %q; expected one of %s",
alg, strings.Join(jwa.List(), ", "),
)
}
items, err := load(src)
if err != nil {
return err
}
pair, err := jwk.GenerateFor(alg)
if err != nil {
return err
}
item, err := file.NewItem(pair)
if err != nil {
return fmt.Errorf("failed to encode key: %w", err)
}
if verify {
item.Use = file.UseVerify
}
if err := save(src, append(items, item)); err != nil {
return err
}
fmt.Fprintf(
cli.Stdout(ctx),
"added %s key %s\n", item.Alg, item.Kid,
)
return nil
},
}
}
// promote moves a key into the signing rotation by updating the use parameter.
func promote() *cli.Command {
return mark(
"promote",
"move a key into the signing rotation",
file.UseSign,
)
}
// retire makes a key verification-only by updating the use parameter.
func retire() *cli.Command {
return mark(
"retire",
"make a key verification-only",
file.UseVerify,
)
}
// mark sets the use of a named key, backing promote and retire.
func mark(name, short, use string) *cli.Command {
var path, kid string
return &cli.Command{
Name: name,
Usage: "-src FILE -kid KID",
Short: short,
Flags: func(fs *flag.FlagSet) {
fs.StringVar(&path, "src", "", "path to the key file")
fs.StringVar(&kid, "kid", "", "ID of the key to "+name)
},
Run: func(ctx context.Context, args []string) error {
if err := expect(args, path, kid); err != nil {
return err
}
items, err := load(path)
if err != nil {
return err
}
found := false
for i, item := range items {
if item.Kid == kid {
items[i].Use = use
found = true
}
}
if !found {
return fmt.Errorf("no key with ID %q", kid)
}
if err := save(path, items); err != nil {
return err
}
fmt.Fprintf(cli.Stdout(ctx), "marked key %s as %s\n", kid, use)
return nil
},
}
}
// remove deletes a named key from the file.
func remove() *cli.Command {
var path, kid string
return &cli.Command{
Name: "remove",
Usage: "-src FILE -kid KID",
Short: "delete the key with the given ID",
Long: "Delete the key with the given ID.\n" +
"\n" +
"Removing a key revokes it: signatures it produced stop\n" +
"verifying as soon as readers reload the file. Retire it first\n" +
"to let outstanding tokens expire.",
Flags: func(fs *flag.FlagSet) {
fs.StringVar(&path, "src", "", "path to the key file")
fs.StringVar(&kid, "kid", "", "ID of the key to remove")
},
Run: func(ctx context.Context, args []string) error {
if err := expect(args, path, kid); err != nil {
return err
}
items, err := load(path)
if err != nil {
return err
}
kept := make(file.Items, 0, len(items))
for _, item := range items {
if item.Kid != kid {
kept = append(kept, item)
}
}
if len(kept) == len(items) {
return fmt.Errorf("no key with ID %q", kid)
}
if err := save(path, kept); err != nil {
return err
}
fmt.Fprintf(cli.Stdout(ctx), "removed key %s\n", kid)
return nil
},
}
}
// expect rejects leftover positional arguments and unset flags. Every
// subcommand takes its input through flags alone, so a stray argument is
// a mistake worth naming rather than ignoring.
func expect(args []string, path string, kid ...string) error {
if len(args) > 0 {
return cli.Usagef("unexpected argument %q", args[0])
}
if path == "" {
return cli.Usagef("the -src flag is required")
}
if len(kid) > 0 && kid[0] == "" {
return cli.Usagef("the -kid flag is required")
}
return nil
}
// load reads the key file, tolerating its absence so that "add" can
// create it.
func load(path string) (file.Items, error) {
items, err := file.Load(path)
if errors.Is(err, os.ErrNotExist) {
return file.Items{}, nil
}
if err != nil {
return nil, fmt.Errorf("failed to read key file: %w", err)
}
return items, nil
}
// save saves the key file to disk.
func save(path string, items file.Items) error {
if err := file.Save(path, items); err != nil {
return fmt.Errorf("failed to save key file: %w", err)
}
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package bind
import (
"encoding"
"errors"
"fmt"
"net/url"
"reflect"
"sync"
"time"
"github.com/deep-rent/nexus/dat/bind/tag"
"github.com/deep-rent/nexus/std/pointer"
)
// Source provides values for a given key. It natively supports returning
// multiple values (e.g. for HTTP arrays) to correctly parse slices.
type Source interface {
Lookup(key string) ([]string, bool)
}
// Transformer is a function that transforms a struct field name into a key.
type Transformer func(string) string
// resolver resolves reflection metadata for a given type.
type resolver interface {
Resolve(rt reflect.Type) ([]field, error)
}
type defaultResolver struct {
name string
transform Transformer
}
func (r *defaultResolver) Resolve(rt reflect.Type) ([]field, error) {
fields := make([]field, 0, rt.NumField())
for i := range rt.NumField() {
ft := rt.Field(i)
if !ft.IsExported() {
continue
}
val := ft.Tag.Get(r.name)
if val == "-" {
continue
}
flags, err := parse(val)
if err != nil {
return nil, fmt.Errorf("field %q: %w", ft.Name, err)
}
f := field{
Index: i,
Name: ft.Name,
Key: flags.Key,
Flags: flags,
Inline: flags.Inline,
}
// Inlining merges a field's own keys into the enclosing namespace,
// which only has meaning for an embedded struct. Silently treating
// the option as a prefixed nested struct would bind the values under
// keys the author did not ask for.
if f.Inline && !ft.Anonymous {
return nil, fmt.Errorf(
"field %q: option %q requires an embedded field",
ft.Name, "inline",
)
}
if f.Key == "" {
f.Key = r.transform(ft.Name)
}
f.Embedded = isEmbedded(ft)
if f.Inline && !f.Embedded {
return nil, fmt.Errorf(
"field %q: option %q requires a struct field",
ft.Name, "inline",
)
}
fields = append(fields, f)
}
return fields, nil
}
type cachingResolver struct {
cache sync.Map
resolver resolver
}
func (r *cachingResolver) Resolve(rt reflect.Type) ([]field, error) {
if cached, ok := r.cache.Load(rt); ok {
return cached.([]field), nil
}
fields, err := r.resolver.Resolve(rt)
if err != nil {
// A rejected type is not cached: the tags cannot change, so the same
// error is produced again, and caching it would only keep the failure
// alive in memory.
return nil, err
}
r.cache.Store(rt, fields)
return fields, nil
}
// Binder extracts values from a generic key-value source into a struct.
type Binder struct {
resolver resolver
}
// New creates a new Binder using the specified struct tag for metadata parsing.
func New(name string, opts ...Option) *Binder {
cfg := &config{
transform: func(s string) string { return s },
cache: false,
}
for _, opt := range opts {
opt(cfg)
}
var resolver resolver = &defaultResolver{
name: name,
transform: cfg.transform,
}
if cfg.cache {
resolver = &cachingResolver{
resolver: resolver,
}
}
return &Binder{
resolver: resolver,
}
}
// Bind populates the fields of a struct using the provided source. The given
// destination must be a non-nil pointer to a struct.
func (b *Binder) Bind[T any](v *T, prefix string, source Source) error {
if v == nil {
return errors.New(
"expected a non-nil pointer to a struct",
)
}
val := reflect.ValueOf(v).Elem()
if kind := val.Kind(); kind != reflect.Struct {
return fmt.Errorf(
"expected a pointer to a struct, but got pointer to %v", kind,
)
}
_, err := b.process(val, prefix, source)
return err
}
// process populates the given struct value from source, reporting whether
// any field of it, or of a struct nested within it, received a value.
//
// The caller needs that answer to decide whether an absent optional section
// should be materialized; see the nested pointer handling below.
func (b *Binder) process(
rv reflect.Value,
prefix string,
source Source,
) (bool, error) {
fields, err := b.resolver.Resolve(rv.Type())
if err != nil {
return false, err
}
// Field errors are collected rather than returned at the first one, so a
// caller fixing a configuration sees everything that is wrong with it in
// one pass instead of one variable per attempt.
var (
bound bool
errs []error
)
for _, f := range fields {
fv := rv.Field(f.Index)
// Inline struct
if f.Inline {
ok, err := b.nested(fv, prefix, source)
if err != nil {
errs = append(errs, err)
}
bound = bound || ok
continue
}
key := f.Key
// Embedded structured prefix
if f.Embedded {
nested := prefix
if f.Flags.Prefix != nil {
nested += *f.Flags.Prefix
} else {
nested += key + "_"
}
ok, err := b.nested(fv, nested, source)
if err != nil {
errs = append(errs, err)
}
bound = bound || ok
continue
}
// Regular field
key = prefix + key
vals, ok := source.Lookup(key)
// A key reported as present but carrying no values holds nothing to
// assign, so it is treated as absent rather than indexed into.
if len(vals) == 0 {
ok = false
}
if !ok {
switch {
case f.Flags.Default != "":
vals = []string{f.Flags.Default}
case f.Flags.Required:
errs = append(errs, fmt.Errorf(
"required key %q is missing", key,
))
continue
default:
continue
}
}
if err := setValues(fv, vals, f.Flags); err != nil {
errs = append(errs, fmt.Errorf(
"could not set field %q from key %q: %w",
f.Name, key, err,
))
continue
}
bound = true
}
return bound, errors.Join(errs...)
}
// nested processes a struct field, which may be reached through one or more
// pointers.
//
// A nil pointer is populated in place only if the source actually supplies
// something for it. Allocating unconditionally would make an optional section
// indistinguishable from a present but empty one, so that a caller testing
// `cfg.TLS != nil` to decide whether TLS was configured always saw it set.
func (b *Binder) nested(
fv reflect.Value,
prefix string,
source Source,
) (bool, error) {
if fv.Kind() != reflect.Pointer || !fv.IsNil() {
return b.process(pointer.Deref(fv), prefix, source)
}
// Bind into a throwaway of the pointed-to type, so that nothing is
// attached to the target unless it was filled in.
rt := fv.Type()
for rt.Kind() == reflect.Pointer {
rt = rt.Elem()
}
tmp := reflect.New(rt)
bound, err := b.process(tmp.Elem(), prefix, source)
if err != nil || !bound {
return bound, err
}
if !fv.CanSet() {
return bound, nil
}
// Rebuild the pointer chain the field's type calls for.
val := tmp
for depth := fv.Type(); depth.Elem().Kind() == reflect.Pointer; {
depth = depth.Elem()
p := reflect.New(val.Type())
p.Elem().Set(val)
val = p
}
fv.Set(val)
return bound, nil
}
type field struct {
Index int
Name string
Key string
Flags *Flags
Inline bool
Embedded bool
}
// Flags encapsulates the options parsed from a tag.
type Flags struct {
Key string
Prefix *string
Split string
Unit string
Format string
Default string
Inline bool
Required bool
}
func parse(s string) (*Flags, error) {
t := tag.Parse(s)
f := &Flags{Key: t.Name, Split: ","}
seen := make(map[string]bool)
for k, v := range t.Opts() {
if seen[k] {
return nil, fmt.Errorf("duplicate option: %q", k)
}
switch k {
case "format":
f.Format = v
case "prefix":
f.Prefix = &v
case "split":
f.Split = v
case "unit":
f.Unit = v
case "default":
f.Default = v
case "inline":
f.Inline = true
case "required":
f.Required = true
default:
return nil, fmt.Errorf("unknown option: %q", k)
}
seen[k] = true
}
return f, nil
}
// isEmbedded checks if a struct field is a true embedded struct that should
// be processed recursively.
func isEmbedded(f reflect.StructField) bool {
t := f.Type
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return false
}
if t == typeTime || t == typeURL || t == typeLocation {
return false
}
if t.Implements(typeTextUnmarshaler) ||
reflect.PointerTo(t).Implements(typeTextUnmarshaler) {
return false
}
return true
}
var (
typeTime = reflect.TypeFor[time.Time]()
typeDuration = reflect.TypeFor[time.Duration]()
typeLocation = reflect.TypeFor[time.Location]()
typeURL = reflect.TypeFor[url.URL]()
typeTextUnmarshaler = reflect.TypeFor[encoding.TextUnmarshaler]()
)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package bind
import (
"encoding"
"encoding/base32"
"encoding/base64"
"encoding/hex"
"fmt"
"net/url"
"reflect"
"strconv"
"strings"
"time"
"github.com/deep-rent/nexus/std/pointer"
)
// setValues assigns values to a [reflect.Value] based on its type.
func setValues(rv reflect.Value, vals []string, f *Flags) error {
rv = pointer.Deref(rv)
if rv.Kind() == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8 {
return setSlice(rv, vals, f)
}
v := vals[0] // Primitive types only take the first value
switch rv.Type() {
case typeTime:
return setTime(rv, v, f)
case typeDuration:
return setDuration(rv, v, f)
case typeLocation:
return setLocation(rv, v)
case typeURL:
return setURL(rv, v)
}
if u, ok := asTextUnmarshaler(rv); ok {
// Use the standard unmarshaler if available.
return u.UnmarshalText([]byte(v))
}
return setOther(rv, v, f)
}
// setOther handles all "regular" (primitive and []byte) types by delegating to
// the appropriate parsing logic based on the reflective kind.
func setOther(rv reflect.Value, v string, f *Flags) error {
switch kind := rv.Kind(); kind {
case reflect.Slice:
return setBytes(rv, v, f)
case reflect.Bool:
b, err := strconv.ParseBool(v)
if err != nil {
return fmt.Errorf("%q is not a bool", v)
}
rv.SetBool(b)
case reflect.String:
rv.SetString(v)
case
reflect.Int,
reflect.Int8,
reflect.Int16,
reflect.Int32,
reflect.Int64:
b := rv.Type().Bits()
i, err := strconv.ParseInt(v, 10, b)
if err != nil {
return fmt.Errorf("%q is not an int%d", v, b)
}
rv.SetInt(i)
case
reflect.Uint,
reflect.Uint8,
reflect.Uint16,
reflect.Uint32,
reflect.Uint64:
b := rv.Type().Bits()
u, err := strconv.ParseUint(v, 10, b)
if err != nil {
return fmt.Errorf("%q is not a uint%d", v, b)
}
rv.SetUint(u)
case reflect.Float32, reflect.Float64:
b := rv.Type().Bits()
fval, err := strconv.ParseFloat(v, b)
if err != nil {
return fmt.Errorf("%q is not a float%d", v, b)
}
rv.SetFloat(fval)
case reflect.Complex64, reflect.Complex128:
b := rv.Type().Bits()
c, err := strconv.ParseComplex(v, b)
if err != nil {
return fmt.Errorf("%q is not a complex%d", v, b)
}
rv.SetComplex(c)
default:
return fmt.Errorf("unsupported type: %s", kind)
}
return nil
}
// setTime parses and sets a [time.Time] value based on the provided format and
// unit options.
func setTime(rv reflect.Value, v string, f *Flags) error {
var t time.Time
var err error
switch format := f.Format; format {
case "unix":
var i int64
i, err = strconv.ParseInt(v, 10, 64)
if err == nil {
switch unit := f.Unit; unit {
case "s", "":
t = time.Unix(i, 0)
case "ms":
t = time.UnixMilli(i)
case "us", "μs":
t = time.UnixMicro(i)
default:
err = fmt.Errorf("invalid time unit: %q", unit)
}
}
case "dateTime":
t, err = time.Parse(time.DateTime, v)
case "date":
t, err = time.Parse(time.DateOnly, v)
case "time":
t, err = time.Parse(time.TimeOnly, v)
case "":
format = time.RFC3339
fallthrough
default:
t, err = time.Parse(format, v)
}
if err != nil {
return err
}
rv.Set(reflect.ValueOf(t))
return nil
}
// setDuration parses and sets a [time.Duration] value based on the provided
// unit option.
func setDuration(rv reflect.Value, v string, f *Flags) error {
var d time.Duration
var err error
if unit := f.Unit; unit == "" {
d, err = time.ParseDuration(v)
} else {
var i int64
i, err = strconv.ParseInt(v, 10, 64)
if err == nil {
switch unit {
case "ns":
d = time.Duration(i)
case "us", "μs":
d = time.Duration(i) * time.Microsecond
case "ms":
d = time.Duration(i) * time.Millisecond
case "s":
d = time.Duration(i) * time.Second
case "m":
d = time.Duration(i) * time.Minute
case "h":
d = time.Duration(i) * time.Hour
default:
err = fmt.Errorf("invalid duration unit: %q", unit)
}
}
}
if err != nil {
return err
}
rv.SetInt(int64(d))
return nil
}
// setLocation parses and sets a [time.Location] value.
func setLocation(rv reflect.Value, v string) error {
loc, err := time.LoadLocation(v)
if err != nil {
return err
}
rv.Set(reflect.ValueOf(*loc))
return nil
}
// setURL parses and sets a [url.URL] value.
func setURL(rv reflect.Value, v string) error {
u, err := url.Parse(v)
if err != nil {
return err
}
rv.Set(reflect.ValueOf(*u))
return nil
}
// setBytes parses and sets a []byte slice value, supporting special
// encoding formats like hex, base32, and base64.
func setBytes(rv reflect.Value, v string, f *Flags) error {
var b []byte
var err error
switch f.Format {
case "":
b = []byte(v)
case "hex":
b, err = hex.DecodeString(v)
case "base32":
b, err = base32.StdEncoding.DecodeString(v)
case "base64":
b, err = base64.StdEncoding.DecodeString(v)
default:
return fmt.Errorf("unsupported format for []byte: %q", f.Format)
}
if err != nil {
return err
}
rv.SetBytes(b)
return nil
}
// setSlice parses and sets a slice value by parsing each element. If exactly
// one value is provided and a delimiter (split flag) is present, it will
// optionally split that single string to maintain backwards compatibility
// with environment variable formats.
func setSlice(rv reflect.Value, vals []string, f *Flags) error {
if len(vals) == 1 && f.Split != "" {
vals = strings.Split(vals[0], f.Split)
}
if len(vals) == 1 && vals[0] == "" {
rv.Set(reflect.MakeSlice(rv.Type(), 0, 0))
return nil
}
slice := reflect.MakeSlice(rv.Type(), len(vals), len(vals))
for i, part := range vals {
if err := setValues(slice.Index(i), []string{part}, f); err != nil {
return fmt.Errorf(
"failed to parse slice element at index %d: %w", i, err,
)
}
}
rv.Set(slice)
return nil
}
// asTextUnmarshaler checks if the given [reflect.Value] implements the
// [encoding.TextUnmarshaler] interface.
func asTextUnmarshaler(rv reflect.Value) (encoding.TextUnmarshaler, bool) {
if rv.Type().Implements(typeTextUnmarshaler) {
if rv.Kind() == reflect.Pointer && rv.IsNil() {
pointer.Alloc(rv)
}
return rv.Interface().(encoding.TextUnmarshaler), true
}
if rv.CanAddr() && rv.Addr().Type().Implements(typeTextUnmarshaler) {
return rv.Addr().Interface().(encoding.TextUnmarshaler), true
}
return nil, false
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package bind
type config struct {
transform Transformer
cache bool
}
// Option configures a Binder.
type Option func(*config)
// WithTransformer sets the name transformation function.
func WithTransformer(t Transformer) Option {
return func(c *config) {
if t != nil {
c.transform = t
}
}
}
// WithCache enables or disables metadata caching.
func WithCache(enable bool) Option {
return func(c *config) {
c.cache = enable
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package tag
import (
"iter"
"strings"
"unicode"
"github.com/deep-rent/nexus/std/quote"
)
// Tag represents a parsed struct tag, separating the primary name from the
// additional options.
type Tag struct {
// Name is the primary identifier of the tag (the part before the first
// comma).
Name string
// opts is the raw string containing the remaining comma-separated options.
opts string
}
// Opts returns an iterator sequence over the tag's options.
//
// Each element yielded is a key-value pair. If an option does not have an
// explicit value (e.g., "omitempty"), the value string will be empty. Keys and
// values are trimmed of surrounding whitespace. Values that were quoted in the
// source string (e.g., `key:"value"`) will have the quotes removed via
// [quote.Remove].
//
// Commas inside quoted values are preserved and not treated as option
// separators (e.g., `key:"val1,val2"` is treated as one option).
func (t *Tag) Opts() iter.Seq2[string, string] {
return func(yield func(string, string) bool) {
rest := t.opts
// Scan through the rest of the string until it's completely consumed.
for rest != "" {
// Trim leading space from the rest of the string.
rest = strings.TrimLeftFunc(rest, unicode.IsSpace)
if rest == "" {
break
}
// Find the end of the current option part by finding the next
// comma that is not inside quotes.
end := -1
inQuote := false
var q rune
scan:
for i, r := range rest {
switch {
case r == q:
inQuote = false
q = 0
case !inQuote && r == '"' || r == '\'':
inQuote = true
q = r
case !inQuote && r == ',':
end = i
break scan
}
}
var part string
if end == -1 {
// This is the last option part.
part = rest
rest = ""
} else {
part = rest[:end]
rest = rest[end+1:]
}
// Now, parse the individual part (e.g., "default:'foo,bar'").
k, v, found := strings.Cut(part, ":")
k = strings.TrimRightFunc(k, unicode.IsSpace)
if found {
// Trim the surrounding whitespace before removing the quotes,
// so that " 'a b' " loses only the outer padding and keeps the
// spaces the quotes were placed around.
v = quote.Remove(strings.TrimFunc(v, unicode.IsSpace))
}
if !yield(k, v) {
return
}
}
}
}
// Parse takes a raw tag string and separates it into the primary name and the
// options string.
//
// For a string like `json:opt1,opt2:val`, it identifies the content before the
// first comma as the [Tag.Name].
func Parse(s string) *Tag {
name, opts, _ := strings.Cut(s, ",")
return &Tag{
Name: name,
opts: opts,
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package cache
import (
"context"
"errors"
"io"
"net/http"
"sync"
"time"
"github.com/deep-rent/nexus/net/header"
"github.com/deep-rent/nexus/net/transport"
"github.com/deep-rent/nexus/std/backoff"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/std/jitter"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/metrics"
"github.com/deep-rent/nexus/sys/schedule"
)
// Mapper is a function that parses a response's raw response body into the
// target type T. It is responsible for decoding the data (e.g., from JSON or
// XML) and returning the structured result. An error should be returned if
// parsing fails fatally, which leaves the previously cached value in place and
// schedules a retry. For warnings or debug information, invoke the logger
// contained in the [Response]. If the mapping takes a considerable amount of
// time, it should generally respect the context contained in the [Response].
type Mapper[T any] func(r *Response) (T, error)
// Response provides contextual information to a [Mapper] function, including
// the response body, request context, and a logger.
type Response struct {
// Body is the raw response payload to be mapped.
Body []byte
// Ctx is the context controlling the HTTP exchange.
Ctx context.Context
// Logger is the logger instance inherited from the [Controller].
Logger *log.Logger
}
// Controller manages the lifecycle of a cached resource. It implements
// [schedule.Tick], allowing it to be run by a scheduler to periodically
// refresh the resource from a URL.
type Controller[T any] interface {
schedule.Tick
// Get retrieves the currently cached resource. The boolean return value is
// true if the cache has been successfully populated at least once. Once
// populated, the cache retains the last known good value even if later
// refreshes fail.
Get() (T, bool)
// Ready returns a channel that is closed once the resource has been
// fetched and mapped successfully for the first time. This allows
// consumers to block until the cache is warmed up. When the channel is
// closed, [Controller.Get] is guaranteed to report a value.
Ready() <-chan struct{}
}
// NewController creates and configures a new cache [Controller].
//
// It requires a URL for the resource to fetch and a [Mapper] function to parse
// the response. Fetching uses [transport.DefaultClient] unless [WithClient]
// overrides it.
//
// It panics if the given URL is empty or the given mapper is nil. A
// syntactically invalid URL is not rejected here; it surfaces as a logged
// error on the first refresh.
func NewController[T any](
url string,
mapper Mapper[T],
opts ...Option,
) Controller[T] {
if url == "" {
panic("URL must not be empty")
}
if mapper == nil {
panic("mapper is required")
}
cfg := config{
minInterval: DefaultMinInterval,
maxInterval: DefaultMaxInterval,
logger: log.Discard(),
client: transport.DefaultClient,
now: clock.System,
}
for _, opt := range opts {
opt(&cfg)
}
// A ceiling below the floor would make the clamp order-dependent.
cfg.maxInterval = max(cfg.maxInterval, cfg.minInterval)
if cfg.registry == nil {
cfg.registry = metrics.DefaultRegistry
}
if cfg.backoff == nil {
// Retries escalate up to the regular refresh interval, so a resource
// that stays broken is not polled more often than a healthy one.
cfg.backoff = backoff.New(
backoff.WithMinDelay(min(DefaultRetryDelay, cfg.minInterval)),
backoff.WithMaxDelay(cfg.minInterval),
)
}
return &controller[T]{
url: url,
mapper: mapper,
client: cfg.client,
minInterval: cfg.minInterval,
maxInterval: cfg.maxInterval,
backoff: cfg.backoff,
jitter: jitter.New(cfg.jitter, nil),
logger: cfg.logger,
now: cfg.now,
stats: newStats(cfg.registry, url),
readyChan: make(chan struct{}),
}
}
// controller is the internal implementation of the [Controller] interface.
type controller[T any] struct {
url string // endpoint from which the resource is fetched
mapper Mapper[T] // parses the raw body into T
client *http.Client // HTTP client used for fetching
minInterval time.Duration // minimum wait between successful refreshes
maxInterval time.Duration // maximum wait between refreshes
backoff backoff.Strategy // delays between failed refreshes
jitter *jitter.Jitter // scatters the refresh interval
logger *log.Logger // destination for internal logs
now clock.Clock // clock used to interpret date headers
stats stats // counts refresh cycles by outcome
readyOnce sync.Once // ensures the ready channel is closed only once
readyChan chan struct{} // closed upon the first successful fetch
mu sync.RWMutex // guards the fields below
resource T // most recently parsed resource
ok bool // whether resource has been populated
failures int // consecutive failed refreshes
etag string // ETag of the last successful response
lastModified string // Last-Modified of the last successful response
}
// Get retrieves the currently cached resource.
func (c *controller[T]) Get() (T, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
return c.resource, c.ok
}
// Ready returns a channel that is closed when the cache is first populated.
func (c *controller[T]) Ready() <-chan struct{} {
return c.readyChan
}
// ready ensures the ready channel is closed exactly once.
func (c *controller[T]) ready() {
c.readyOnce.Do(func() { close(c.readyChan) })
}
// Run executes a single fetch-and-cache cycle. It implements the
// [schedule.Tick] interface. It handles conditional requests, response
// parsing, and caching, and returns the duration to wait before the next run.
func (c *controller[T]) Run(ctx context.Context) time.Duration {
c.logger.Debug(ctx, "Fetching resource")
res, err := c.fetch(ctx)
if err != nil {
// A canceled context means the scheduler is shutting down, which is
// not a failure of the resource.
if !errors.Is(err, context.Canceled) {
c.logger.Error(ctx,
"Failed to fetch resource",
log.Error(err),
)
}
return c.retry(ctx)
}
defer c.close(res)
switch code := res.StatusCode; code {
case http.StatusNotModified:
return c.unchanged(ctx, res)
case http.StatusOK:
return c.update(ctx, res)
default:
c.logger.Error(ctx,
"Received an unexpected HTTP status code",
log.Int("status", code),
)
return c.retry(ctx)
}
}
// fetch issues a conditional GET for the resource.
func (c *controller[T]) fetch(ctx context.Context) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.url, nil)
if err != nil {
return nil, err
}
// Add conditional headers if we have them from a previous response.
c.mu.RLock()
etag, lastModified := c.etag, c.lastModified
c.mu.RUnlock()
if etag != "" {
req.Header.Set("If-None-Match", etag)
}
if lastModified != "" {
req.Header.Set("If-Modified-Since", lastModified)
}
return c.client.Do(req)
}
// unchanged handles a 304 response, retaining the currently cached value.
func (c *controller[T]) unchanged(
ctx context.Context,
res *http.Response,
) time.Duration {
c.mu.RLock()
etag, ok := c.etag, c.ok
c.mu.RUnlock()
// A 304 without a cached value means our validators are out of step with
// the server, so they are dropped to force an unconditional refetch.
if !ok {
c.logger.Warn(ctx,
"Resource reported unchanged but nothing is cached",
)
c.mu.Lock()
c.etag, c.lastModified = "", ""
c.mu.Unlock()
return c.retry(ctx)
}
c.logger.Debug(ctx,
"Resource unchanged",
log.String("etag", etag),
)
c.stats.unchanged.Inc()
return c.refresh(res.Header)
}
// update handles a 200 response, replacing the cached value.
func (c *controller[T]) update(
ctx context.Context,
res *http.Response,
) time.Duration {
body, err := io.ReadAll(res.Body)
if err != nil {
c.logger.Error(ctx,
"Failed to read response body",
log.Error(err),
)
return c.retry(ctx)
}
resource, err := c.mapper(&Response{
Body: body,
Ctx: ctx,
Logger: c.logger,
})
if err != nil {
c.logger.Error(ctx,
"Couldn't parse response body",
log.Error(err),
)
return c.retry(ctx)
}
c.mu.Lock()
c.resource = resource
c.etag = header.ETag(res.Header)
c.lastModified = res.Header.Get("Last-Modified")
c.ok = true
c.failures = 0
c.mu.Unlock()
c.logger.Info(ctx, "Resource updated successfully")
c.stats.updated.Inc()
// Signalled only once a value is actually available, so that consumers
// blocked on Ready are guaranteed a hit from Get.
c.ready()
return c.refresh(res.Header)
}
// close releases the response body.
func (c *controller[T]) close(res *http.Response) {
if err := res.Body.Close(); err != nil {
c.logger.Warn(res.Request.Context(),
"Failed to close response body",
log.Error(err),
)
}
}
// refresh calculates the duration until the next fetch based on caching
// headers, clamped by the configured min/max intervals and optionally
// scattered by jitter.
func (c *controller[T]) refresh(h http.Header) time.Duration {
c.mu.Lock()
c.failures = 0
c.mu.Unlock()
d := header.Lifetime(h, c.now)
d = min(max(d, c.minInterval), c.maxInterval)
return c.jitter.Apply(d)
}
// retry records a failed refresh and returns the delay before the next
// attempt, which grows with the number of consecutive failures. It is the
// single sink for every failure path, so it also counts the cycle as an
// error.
func (c *controller[T]) retry(ctx context.Context) time.Duration {
c.stats.failed.Inc()
c.mu.Lock()
c.failures++
n := c.failures
c.mu.Unlock()
d := c.backoff.Delay(n)
c.logger.Debug(ctx,
"Scheduling a retry",
log.Int("failures", n),
log.Duration("delay", d),
)
return d
}
var _ Controller[any] = (*controller[any])(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package cache
import (
"github.com/deep-rent/nexus/sys/metrics"
)
// Refreshes is the name of the counter recording refresh cycles, tagged
// with the resource URL and an outcome of "updated", "unchanged", or
// "error".
const Refreshes = "cache_refreshes_total"
// stats holds the per-outcome refresh counters, resolved once at
// construction.
type stats struct {
updated *metrics.Counter
unchanged *metrics.Counter
failed *metrics.Counter
}
// newStats resolves the refresh counters from the given registry.
func newStats(reg *metrics.Registry, url string) stats {
outcome := func(o string) *metrics.Counter {
return reg.Counter(Refreshes,
metrics.T("url", url),
metrics.T("outcome", o),
)
}
return stats{
updated: outcome("updated"),
unchanged: outcome("unchanged"),
failed: outcome("error"),
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package cache
import (
"net/http"
"time"
"github.com/deep-rent/nexus/std/backoff"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/metrics"
)
const (
// DefaultMinInterval is the default lower bound for the refresh interval.
DefaultMinInterval = 15 * time.Minute
// DefaultMaxInterval is the default upper bound for the refresh interval.
DefaultMaxInterval = 24 * time.Hour
// DefaultRetryDelay is the default delay before the first retry after a
// failed refresh. Subsequent failures back off exponentially, up to the
// configured minimum interval.
DefaultRetryDelay = 5 * time.Second
)
// config holds the internal configuration for the cache controller.
type config struct {
minInterval time.Duration // floor for refresh delays
maxInterval time.Duration // ceiling for refresh delays
jitter float64 // fraction of the interval subject to jitter
backoff backoff.Strategy // delays between failed refreshes
logger *log.Logger // destination for internal logs
client *http.Client // HTTP client used for fetching
now clock.Clock // clock used to interpret date headers
registry *metrics.Registry // records the refresh counter
}
// Option is a function that configures the cache [Controller].
type Option func(*config)
// WithClient sets the [http.Client] used to fetch the resource. Defaults to
// [transport.DefaultClient]. Nil values are ignored.
//
// The controller reads the response body in full, so the client is
// responsible for bounding its size. [transport.DefaultClient] does this;
// a client assembled elsewhere may not.
func WithClient(client *http.Client) Option {
return func(c *config) {
if client != nil {
c.client = client
}
}
}
// WithMinInterval sets the minimum duration between successful refreshes. The
// refresh delay, typically determined by caching headers, will not be shorter
// than this. It also serves as the ceiling for the retry backoff, so that a
// resource that keeps failing is not polled more often than a healthy one.
//
// Values of zero or less are ignored, and [DefaultMinInterval] is used
// instead.
func WithMinInterval(d time.Duration) Option {
return func(c *config) {
if d > 0 {
c.minInterval = d
}
}
}
// WithMaxInterval sets the maximum duration between refreshes. The refresh
// delay will not be longer than this value. If it is shorter than the minimum
// interval, the minimum interval takes precedence.
//
// Values of zero or less are ignored, and [DefaultMaxInterval] is used
// instead.
func WithMaxInterval(d time.Duration) Option {
return func(c *config) {
if d > 0 {
c.maxInterval = d
}
}
}
// WithJitterAmount scatters the refresh interval by a random fraction between
// 0 and 1, where 0 means no jitter and 1 means full jitter. The given number
// is capped to that range. If not customized, no jitter is applied.
//
// Jitter matters when many instances cache the same resource: without it, they
// tend to align on a shared expiry and refresh in lockstep, hitting the origin
// all at once. Since jitter only ever shortens an interval, an interval drawn
// this way may fall below the configured minimum.
func WithJitterAmount(p float64) Option {
return func(c *config) {
c.jitter = min(1, max(0, p))
}
}
// WithBackoff sets the strategy that determines how long to wait after a
// failed refresh. Consecutive failures are counted, and the count resets as
// soon as a refresh succeeds.
//
// If not provided, an exponential strategy with jitter is used, starting at
// [DefaultRetryDelay] and capped at the configured minimum interval. A nil
// value is ignored.
func WithBackoff(strategy backoff.Strategy) Option {
return func(c *config) {
if strategy != nil {
c.backoff = strategy
}
}
}
// WithLogger provides a custom [log.Logger] for the controller. If not
// provided, logging is disabled. A nil value is ignored.
func WithLogger(logger *log.Logger) Option {
return func(c *config) {
if logger != nil {
c.logger = logger
}
}
}
// WithRegistry sets the registry receiving the [Refreshes] counter, which
// counts refresh cycles by outcome ("updated", "unchanged", or "error") per
// resource URL. It defaults to [metrics.DefaultRegistry]. A nil value is
// ignored.
func WithRegistry(reg *metrics.Registry) Option {
return func(c *config) {
if reg != nil {
c.registry = reg
}
}
}
// WithClock provides a custom time source used to interpret the date-based
// caching headers, primarily for testing. If not provided, [clock.System] is
// used.
// A nil value is ignored.
func WithClock(now clock.Clock) Option {
return func(c *config) {
if now != nil {
c.now = now
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package migrate
import (
"context"
"errors"
"fmt"
"strconv"
"github.com/deep-rent/nexus/sys/cli"
)
// Opener lazily connects a [Migrator] for one command invocation. It is
// called only when a subcommand actually needs the database, so printing
// help never opens a connection. The returned close function releases the
// underlying resources, typically the database handle; it may be nil when
// there is nothing to release.
type Opener func(ctx context.Context) (
m *Migrator,
close func() error,
err error,
)
// Command builds the "migrate" command group that service binaries hang
// under their root command:
//
// migrate up apply all pending migrations
// migrate down revert the most recent migration
// migrate status print the schema version and pending migrations
// migrate force <version> set the schema version after manual repair
//
// The migrator is obtained from open per invocation and released
// afterward. The returned command is a plain value; callers may adjust its
// fields, for example to append connection details to Long, before wiring
// it into their tree.
func Command(open Opener) *cli.Command {
return &cli.Command{
Name: "migrate",
Usage: "<up|down|status|force>",
Short: "manage the database schema",
Long: "Manage the database schema out of band, for operators who " +
"migrate before rolling the deployment rather than at startup.",
Commands: []*cli.Command{
{
Name: "up",
Short: "apply all pending migrations",
Run: run(open, func(ctx context.Context, m *Migrator) error {
if err := m.Up(ctx); err != nil {
return fmt.Errorf("migration failed: %w", err)
}
fmt.Fprintln(cli.Stdout(ctx), "ok")
return nil
}),
},
{
Name: "down",
Short: "revert the most recent migration",
Run: run(open, func(ctx context.Context, m *Migrator) error {
if err := m.Down(ctx); err != nil {
return fmt.Errorf("migration failed: %w", err)
}
fmt.Fprintln(cli.Stdout(ctx), "ok")
return nil
}),
},
{
Name: "status",
Short: "print the schema version and pending migrations",
Run: run(open, status),
},
{
Name: "force",
Usage: "<version>",
Short: "set the schema version after manual repair",
Long: "Set the schema version and clear the dirty flag " +
"without running any migrations; records above the " +
"target are discarded. Use this to recover from a " +
"failed migration after restoring a consistent " +
"database state by hand.",
Run: func(ctx context.Context, args []string) error {
if len(args) == 0 {
return cli.Usagef("missing version argument")
}
version, err := strconv.ParseInt(args[0], 10, 64)
if err != nil || version < 0 {
return cli.Usagef("invalid version %q", args[0])
}
return run(open, func(
ctx context.Context, m *Migrator,
) error {
if err := m.Force(ctx, version); err != nil {
return fmt.Errorf("force failed: %w", err)
}
fmt.Fprintf(
cli.Stdout(ctx),
"version forced to %d\n",
version,
)
return nil
})(ctx, args[1:])
},
},
},
}
}
// run adapts the given function to a [cli.Command] Run function that rejects
// positional arguments, opens the migrator, and releases it when the function
// returns.
func run(
open Opener,
exec func(ctx context.Context, m *Migrator) error,
) func(ctx context.Context, args []string) error {
return func(ctx context.Context, args []string) (err error) {
if len(args) > 0 {
return cli.Usagef("unexpected argument %q", args[0])
}
m, release, err := open(ctx)
if err != nil {
return err
}
defer func() {
if release != nil {
err = errors.Join(err, release())
}
}()
return exec(ctx, m)
}
}
// status prints the applied schema version and any pending migrations.
func status(ctx context.Context, m *Migrator) error {
w := cli.Stdout(ctx)
record, found, err := m.Version(ctx)
if err != nil {
return fmt.Errorf("failed to read version: %w", err)
}
if !found {
fmt.Fprintln(w, "version: none")
} else {
fmt.Fprintf(
w,
"version: %d (dirty: %t)\n",
record.Version,
record.Dirty,
)
}
pending, err := m.Pending(ctx)
if err != nil {
return fmt.Errorf("failed to list pending migrations: %w", err)
}
for _, p := range pending {
fmt.Fprintf(w, "pending: %d %s\n", p.Version, p.Description)
}
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package mock
import (
"cmp"
"context"
"errors"
"slices"
"sync"
"github.com/deep-rent/nexus/dat/migrate"
"github.com/deep-rent/nexus/dat/migrate/schema"
)
// key identifies a record in the simulated tracking table.
type key struct {
// module names the migration stream the record belongs to.
module string
// version is the record's sequence number within its module.
version int64
}
// Driver is an in-memory implementation of [migrate.Driver].
//
// It is safe for concurrent use and allows injecting errors for every operation
// to test the Migrator's error handling and rollback logic.
type Driver struct {
// mu protects the internal state of the mock driver.
mu sync.Mutex
// records stores the simulated migration state, keyed by module and
// version like a real tracking table.
records map[key]migrate.Record
// IsLocked indicates if the mock advisory lock is currently held.
IsLocked bool
// IsInit indicates if the tracking table initialization was called.
IsInit bool
// ParserFunc allows injecting a custom statement parser.
ParserFunc schema.Parser
// InitErr is returned by the Init method if non-nil.
InitErr error
// LockErr is returned by the Lock method if non-nil.
LockErr error
// UnlockErr is returned by the Unlock method if non-nil.
UnlockErr error
// AppliedErr is returned by the Applied method if non-nil.
AppliedErr error
// ForceErr is returned by the Force method if non-nil.
ForceErr error
// ExecuteErr is returned by the Execute method if non-nil.
ExecuteErr error
}
// New creates a new in-memory [Driver] with an empty state.
func New() *Driver {
return &Driver{
records: make(map[key]migrate.Record),
// Provide a dummy parser that just returns the raw script as a single
// statement.
ParserFunc: func(script []byte) []string {
if len(script) == 0 {
return nil
}
return []string{string(script)}
},
}
}
// Set writes a [migrate.Record] to the in-memory table, keyed by the
// record's module and version.
func (d *Driver) Set(rec migrate.Record) {
d.mu.Lock()
defer d.mu.Unlock()
d.records[key{module: rec.Module, version: rec.Version}] = rec
}
// Get reads a module's [migrate.Record] from the in-memory table.
func (d *Driver) Get(module string, version int64) (migrate.Record, bool) {
d.mu.Lock()
defer d.mu.Unlock()
rec, ok := d.records[key{module: module, version: version}]
return rec, ok
}
// State returns a copy of the module's records, indexed by version.
func (d *Driver) State(module string) map[int64]migrate.Record {
d.mu.Lock()
defer d.mu.Unlock()
out := make(map[int64]migrate.Record)
for k, rec := range d.records {
if k.module == module {
out[k.version] = rec
}
}
return out
}
// Parser returns the injected [Driver.ParserFunc].
func (d *Driver) Parser() schema.Parser {
return d.ParserFunc
}
// Init simulates creating the tracking table.
func (d *Driver) Init(_ context.Context) error {
d.mu.Lock()
defer d.mu.Unlock()
if d.InitErr != nil {
return d.InitErr
}
d.IsInit = true
return nil
}
// Lock simulates acquiring an exclusive distributed lock.
func (d *Driver) Lock(_ context.Context) error {
d.mu.Lock()
defer d.mu.Unlock()
if d.LockErr != nil {
return d.LockErr
}
if d.IsLocked {
return errors.New("already locked")
}
d.IsLocked = true
return nil
}
// Unlock simulates releasing the exclusive lock.
func (d *Driver) Unlock(_ context.Context) error {
d.mu.Lock()
defer d.mu.Unlock()
if d.UnlockErr != nil {
return d.UnlockErr
}
if !d.IsLocked {
return errors.New("not locked")
}
d.IsLocked = false
return nil
}
// Applied returns all successfully applied migration records across every
// module, sorted by module and version.
func (d *Driver) Applied(_ context.Context) ([]migrate.Record, error) {
d.mu.Lock()
defer d.mu.Unlock()
if d.AppliedErr != nil {
return nil, d.AppliedErr
}
out := make([]migrate.Record, 0, len(d.records))
for _, r := range d.records {
out = append(out, r)
}
slices.SortFunc(out, func(a, b migrate.Record) int {
if n := cmp.Compare(a.Module, b.Module); n != 0 {
return n
}
return cmp.Compare(a.Version, b.Version)
})
return out, nil
}
// Force manually sets the given module's version.
//
// It clears the dirty flag for that version and removes any of the module's
// records greater than it, leaving other modules untouched.
func (d *Driver) Force(
_ context.Context,
module string,
version int64,
) error {
d.mu.Lock()
defer d.mu.Unlock()
if d.ForceErr != nil {
return d.ForceErr
}
target := key{module: module, version: version}
if rec, ok := d.records[target]; ok {
rec.Dirty = false
d.records[target] = rec
}
for k := range d.records {
if k.module == module && k.version > version {
delete(d.records, k)
}
}
return nil
}
// Execute simulates running a migration script.
//
// If [Driver.ExecuteErr] is set, it simulates a failure: transactional
// scripts roll back cleanly and leave no dirty state, while
// non-transactional scripts leave the target version dirty, mirroring the
// behavior of real drivers.
func (d *Driver) Execute(
_ context.Context,
script migrate.ParsedScript,
) error {
d.mu.Lock()
defer d.mu.Unlock()
k := key{module: script.Module, version: script.Version}
if d.ExecuteErr != nil {
if script.Tx {
// A failed transactional migration rolls back and removes its
// dirty marker again.
return d.ExecuteErr
}
// Simulate the dirty state left behind by a failed non-transactional
// migration.
switch script.Direction {
case migrate.Up:
d.records[k] = migrate.Record{
Module: script.Module,
Version: script.Version,
Checksum: script.Checksum,
Dirty: true,
Requires: script.Requires,
}
case migrate.Down:
if rec, ok := d.records[k]; ok {
rec.Dirty = true
d.records[k] = rec
}
}
return d.ExecuteErr
}
// Simulate successful execution.
switch script.Direction {
case migrate.Up:
d.records[k] = migrate.Record{
Module: script.Module,
Version: script.Version,
Checksum: script.Checksum,
Dirty: false,
Requires: script.Requires,
}
case migrate.Down:
delete(d.records, k)
}
return nil
}
var _ migrate.Driver = (*Driver)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package postgres
import (
"context"
"database/sql"
"fmt"
// Registers the pgx driver under the "pgx" name for [Open].
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/deep-rent/nexus/dat/migrate"
)
// Open connects a [migrate.Migrator] to the PostgreSQL database at the given
// URL, speaking [database/sql] through the pgx driver. This driver is
// installed with its defaults; the given options carry what the calling
// service names — at least its module and migration source, which
// [migrate.New] requires:
//
// m, close, err := postgres.Open(url,
// migrate.WithModule("foobar"),
// migrate.WithSource(src),
// )
//
// The returned close function releases the database handle once the
// migration commands are done. The return values line up with
// [migrate.Opener], so an opener that resolves its URL from configuration
// ends in a tail call to Open; when the URL is known at wiring time,
// [Opener] does the wrapping instead.
//
// A driver needing more than the defaults — schema, table, lock or
// statement timeouts — is wired by hand: open the handle, then combine
// [New] with [migrate.WithDriver].
func Open(
url string,
opts ...migrate.Option,
) (*migrate.Migrator, func() error, error) {
db, err := sql.Open("pgx", url)
if err != nil {
return nil, nil, fmt.Errorf("failed to open database: %w", err)
}
opts = append([]migrate.Option{
migrate.WithDriver(New(db)),
}, opts...)
m := migrate.New(opts...)
return m, db.Close, nil
}
// Opener adapts [Open] to the [migrate.Opener] contract for services whose
// database URL is known when the command tree is wired:
//
// cmd.Commands = append(cmd.Commands, migrate.Command(postgres.Opener(url)))
//
// A service that resolves its configuration lazily writes its own opener
// and tail-calls [Open], so that printing help works unconfigured.
func Opener(url string, opts ...migrate.Option) migrate.Opener {
return func(context.Context) (*migrate.Migrator, func() error, error) {
return Open(url, opts...)
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package postgres
import (
"time"
"github.com/deep-rent/nexus/sys/log"
)
const (
// DefaultTable is the default name for the migration tracking table.
DefaultTable = "migrations"
// DefaultSchema is the default PostgreSQL schema where the tracking table
// resides.
DefaultSchema = "public"
)
// config holds the internal configuration options for the PostgreSQL driver.
type config struct {
// table is the name of the migration tracking table.
table string
// schema is the PostgreSQL schema containing the tracking table.
schema string
// lockID is an optional fixed identifier for advisory locks.
lockID *int64
// lockTimeout is the maximum wait time for acquiring the advisory lock.
lockTimeout time.Duration
// stmtTimeout is the maximum execution time for a single SQL statement.
stmtTimeout time.Duration
// logger is the structured logger for driver activity.
logger *log.Logger
}
// Option configures a PostgreSQL [Driver] instance.
type Option func(*config)
// WithTable sets a custom name for the migration tracking table.
//
// Empty string values are ignored.
func WithTable(name string) Option {
return func(c *config) {
if name != "" {
c.table = name
}
}
}
// WithSchema sets a custom database schema for the tracking table.
//
// Empty string values are ignored.
func WithSchema(name string) Option {
return func(c *config) {
if name != "" {
c.schema = name
}
}
}
// WithLockID sets a static identifier for the PostgreSQL advisory lock.
//
// If not provided, the identifier is derived from the schema and table name,
// so all migrator instances targeting the same tracking table contend for the
// same lock. Provide an explicit identifier to coordinate with external
// tooling or to avoid collisions with other advisory lock users.
func WithLockID(id int64) Option {
return func(c *config) {
c.lockID = &id
}
}
// WithLockTimeout sets the maximum duration to wait for the advisory lock.
//
// If 0, it waits indefinitely (the default behavior).
func WithLockTimeout(timeout time.Duration) Option {
return func(c *config) {
c.lockTimeout = timeout
}
}
// WithStatementTimeout sets a maximum duration for individual SQL statements.
//
// If 0, no timeout is applied (the default behavior).
func WithStatementTimeout(timeout time.Duration) Option {
return func(c *config) {
c.stmtTimeout = timeout
}
}
// WithLogger injects a structured logger to record driver operations.
//
// Nil values are ignored; without a logger, logging is disabled.
func WithLogger(logger *log.Logger) Option {
return func(c *config) {
if logger != nil {
c.logger = logger
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
// Queries concatenate only the quoted identifier from quote.Ident; all
// values are passed as bind parameters:
//gosec:disable G202 -- identifiers are escaped, values are parameterized
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"hash/fnv"
"io"
"strings"
"time"
"github.com/deep-rent/nexus/dat/migrate"
"github.com/deep-rent/nexus/dat/migrate/schema"
"github.com/deep-rent/nexus/std/quote"
"github.com/deep-rent/nexus/sys/log"
)
// Driver implements the [migrate.Driver] interface for PostgreSQL.
//
// It manages the database connection, distributed locks, and the execution of
// migration statements.
type Driver struct {
// db is the underlying database connection pool.
db *sql.DB
// lock is a dedicated connection held while the advisory lock is active.
lock *sql.Conn
// table is the unquoted name of the tracking table.
table string
// schema is the unquoted database schema containing the table.
schema string
// ident is the precomputed, safely quoted schema and table identifier.
ident string
// lockID is the identifier used for pg_advisory_lock.
lockID int64
// lockTimeout is the maximum wait time for lock acquisition.
lockTimeout time.Duration
// stmtTimeout is the maximum duration for a single statement.
stmtTimeout time.Duration
// logger records driver operations.
logger *log.Logger
}
// New creates a new PostgreSQL migration driver.
//
// It uses the provided database connection and options. Unless overridden via
// [WithLockID], the advisory lock identifier is derived deterministically from
// the schema and table name, so that concurrent migrator instances targeting
// the same tracking table mutually exclude each other.
func New(db *sql.DB, opts ...Option) *Driver {
cfg := &config{
table: DefaultTable,
schema: DefaultSchema,
logger: log.Discard(),
}
for _, opt := range opts {
opt(cfg)
}
d := &Driver{
db: db,
table: cfg.table,
schema: cfg.schema,
ident: quote.Ident(cfg.schema, cfg.table),
lockTimeout: cfg.lockTimeout,
stmtTimeout: cfg.stmtTimeout,
logger: cfg.logger,
}
if cfg.lockID != nil {
d.lockID = *cfg.lockID
} else {
d.lockID = deriveLockID(cfg.schema, cfg.table)
}
return d
}
// deriveLockID hashes the schema and table name into a stable, non-negative
// advisory lock identifier.
//
// Deriving the identifier from the tracking table location guarantees that
// every migrator instance pointed at the same table competes for the same
// lock, while migrators using different tables remain independent.
func deriveLockID(schema, table string) int64 {
h := fnv.New64a()
_, _ = io.WriteString(h, schema)
_, _ = io.WriteString(h, ".")
_, _ = io.WriteString(h, table)
return int64(h.Sum64() & 0x7FFFFFFFFFFFFFFF)
}
// LockID returns the advisory lock identifier used by this driver.
func (d *Driver) LockID() int64 {
return d.lockID
}
// Parser returns [schema.Postgres], the PostgreSQL-specific statement
// parser.
//
// It safely splits scripts while ignoring semicolons inside string literals,
// comments, and dollar-quoted blocks.
func (*Driver) Parser() schema.Parser {
return schema.Postgres
}
// Lock acquires an exclusive distributed lock using pg_advisory_lock.
//
// This prevents multiple migrator instances from running concurrently on the
// same database. It holds a dedicated connection for the duration of the lock
// and respects the configured lock timeout.
func (d *Driver) Lock(ctx context.Context) error {
if d.lock != nil {
return errors.New("already locked")
}
d.logger.Debug(ctx, "Acquiring advisory lock", log.Int64("id", d.lockID))
conn, err := d.db.Conn(ctx)
if err != nil {
return fmt.Errorf("failed to acquire database connection: %w", err)
}
// Apply the configured lock timeout, if any.
if d.lockTimeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, d.lockTimeout)
defer cancel()
}
if _, err := conn.ExecContext(
ctx,
"SELECT pg_advisory_lock($1)",
d.lockID,
); err != nil {
if e := conn.Close(); e != nil {
d.logger.Error(ctx,
"Failed to close connection after lock failure",
log.Error(e),
)
}
return fmt.Errorf("failed to acquire advisory lock: %w", err)
}
d.lock = conn
d.logger.Info(ctx, "Advisory lock acquired")
return nil
}
// Unlock releases the advisory lock acquired by [Driver.Lock] via
// pg_advisory_unlock, and returns the dedicated connection to the pool.
func (d *Driver) Unlock(ctx context.Context) error {
if d.lock == nil {
return errors.New("not locked")
}
d.logger.Debug(ctx, "Releasing advisory lock", log.Int64("id", d.lockID))
_, err := d.lock.ExecContext(
ctx,
"SELECT pg_advisory_unlock($1)",
d.lockID,
)
e := d.lock.Close()
d.lock = nil
if err != nil {
return fmt.Errorf("failed to release advisory lock: %w", err)
}
d.logger.Info(ctx, "Advisory lock released")
return e
}
// Init ensures that the tracking table exists in the target schema.
//
// It creates the table with columns for module, version, checksum, dirty
// state, requirements, and application timestamp if it is not already
// present. Records are keyed by module and version, so every module sharing
// the database tracks its own version sequence in the same table.
func (d *Driver) Init(ctx context.Context) error {
d.logger.Debug(ctx,
"Initializing migration table if missing",
log.String("name", d.table),
log.String("schema", d.schema),
)
// The checksum and requires defaults only matter for rows inserted by
// hand outside the driver: every path the driver itself writes through
// always supplies both. Applied rejects a checksum that is still at its
// empty default length instead of accepting it silently.
query := fmt.Sprintf(`
CREATE TABLE IF NOT EXISTS %s (
module TEXT NOT NULL,
version BIGINT NOT NULL,
checksum BYTEA NOT NULL DEFAULT '\x',
dirty BOOLEAN NOT NULL DEFAULT false,
requires TEXT NOT NULL DEFAULT '',
applied_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (module, version)
);`, d.ident)
_, err := d.db.ExecContext(ctx, query)
return err
}
// Applied retrieves all successfully applied migration records across every
// module.
//
// The records are ordered by module and version in ascending order.
func (d *Driver) Applied(ctx context.Context) ([]migrate.Record, error) {
d.logger.Debug(ctx, "Fetching applied migrations")
query := "SELECT module, version, checksum, dirty, requires FROM " +
d.ident + " ORDER BY module ASC, version ASC"
rows, err := d.db.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer func() {
if e := rows.Close(); e != nil {
d.logger.Error(ctx, "Failed to close rows", log.Error(e))
}
}()
var records []migrate.Record
for rows.Next() {
var rec migrate.Record
var checksum []byte
var requires string
if err := rows.Scan(
&rec.Module,
&rec.Version,
&checksum,
&rec.Dirty,
&requires,
); err != nil {
return nil, err
}
// Reject corrupt rows instead of silently zero-padding, which would
// surface much later as a confusing checksum mismatch.
if len(checksum) != len(rec.Checksum) {
return nil, fmt.Errorf(
"corrupt checksum for version %d: got %d bytes, want %d",
rec.Version, len(checksum), len(rec.Checksum),
)
}
copy(rec.Checksum[:], checksum)
rec.Requires, err = decodeRequires(requires)
if err != nil {
return nil, fmt.Errorf(
"corrupt requirements for version %d: %w", rec.Version, err,
)
}
records = append(records, rec)
}
if err := rows.Err(); err != nil {
return nil, err
}
return records, nil
}
// encodeRequires renders requirements as a comma-separated list of
// "module@version" tokens for storage in the tracking table.
func encodeRequires(requires []migrate.Requirement) string {
tokens := make([]string, len(requires))
for i, r := range requires {
tokens[i] = r.String()
}
return strings.Join(tokens, ",")
}
// decodeRequires parses the comma-separated storage format written by
// encodeRequires.
func decodeRequires(s string) ([]migrate.Requirement, error) {
if s == "" {
return nil, nil
}
tokens := strings.Split(s, ",")
requires := make([]migrate.Requirement, len(tokens))
for i, token := range tokens {
r, err := migrate.ParseRequirement(token)
if err != nil {
return nil, err
}
requires[i] = r
}
return requires, nil
}
// withTx is an internal helper that manages serializable transactions.
func (d *Driver) withTx(ctx context.Context, fn func(tx *sql.Tx) error) error {
// Serializable is the strongest isolation level PostgreSQL offers. The
// advisory lock already keeps other migrator instances out, but this
// still protects a transactional script from being interleaved with
// unrelated application traffic that happens to touch the same rows
// while the migration runs.
tx, err := d.db.BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelSerializable,
})
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer func() {
if e := tx.Rollback(); e != nil && !errors.Is(e, sql.ErrTxDone) {
d.logger.Error(ctx,
"Failed to rollback transaction",
log.Error(e),
)
}
}()
if err := fn(tx); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}
// Force manually sets the given module to the specified version.
//
// It clears the dirty flag for the target version and deletes any of the
// module's migration records with a version strictly greater than the
// target; records of other modules are left untouched. This is typically
// used to recover from a dirty database state after human intervention.
func (d *Driver) Force(
ctx context.Context,
module string,
version int64,
) error {
d.logger.Info(
ctx,
"Forcing database version",
log.String("module", module),
log.Int64("version", version),
)
return d.withTx(ctx, func(tx *sql.Tx) error {
queryUpdate := "UPDATE " + d.ident +
" SET dirty = false WHERE module = $1 AND version = $2"
if _, err := tx.ExecContext(
ctx, queryUpdate, module, version,
); err != nil {
return fmt.Errorf("failed to clear dirty flag: %w", err)
}
queryDelete := "DELETE FROM " + d.ident +
" WHERE module = $1 AND version > $2"
if _, err := tx.ExecContext(
ctx, queryDelete, module, version,
); err != nil {
return fmt.Errorf("failed to delete newer versions: %w", err)
}
return nil
})
}
// Execute runs the provided migration script against the database.
//
// It marks the version as dirty, executes the statements, and clears the dirty
// state upon success. If execution fails, the database remains marked as dirty
// to prevent further automated migrations.
func (d *Driver) Execute(
ctx context.Context,
script migrate.ParsedScript,
) error {
d.logger.Info(ctx,
"Executing migration",
log.String("module", script.Module),
log.Int64("version", script.Version),
log.String("direction", script.Direction.String()),
)
if err := d.setDirty(ctx, script); err != nil {
return fmt.Errorf("failed to mark migration as dirty: %w", err)
}
if script.Tx {
d.logger.Debug(ctx, "Running migration in transaction")
err := d.withTx(ctx, func(tx *sql.Tx) error {
return d.execAll(ctx, tx, script.Statements)
})
if err != nil {
// The transaction rolled back, so the schema is unchanged; undo
// the dirty marker to spare the operator a manual Force. If the
// cleanup itself fails, the marker stays and blocks further runs,
// which is the safe direction to err in.
if e := d.undo(ctx, script); e != nil {
d.logger.Error(ctx,
"Failed to undo dirty marker after rollback",
log.Error(e),
)
}
return err
}
} else {
d.logger.Debug(ctx, "Running migration without transaction")
if err := d.execAll(ctx, d.db, script.Statements); err != nil {
return err
}
}
if err := d.setClean(ctx, script); err != nil {
return fmt.Errorf("failed to clear dirty state: %w", err)
}
return nil
}
// runner is an interface satisfied by both [*sql.DB] and [*sql.Tx].
type runner interface {
// ExecContext executes a query without returning any rows.
ExecContext(
ctx context.Context,
query string,
args ...any,
) (sql.Result, error)
}
// execAll iterates through SQL statements and runs them sequentially.
func (d *Driver) execAll(
ctx context.Context,
run runner,
statements []string,
) error {
for i, stmt := range statements {
d.logger.Debug(ctx, "Executing statement", log.Int("index", i+1))
if err := d.execOne(ctx, run, stmt); err != nil {
return fmt.Errorf("statement %d failed: %w", i+1, err)
}
}
return nil
}
// execOne isolates the execution of a single statement.
func (d *Driver) execOne(ctx context.Context, run runner, stmt string) error {
if d.stmtTimeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, d.stmtTimeout)
defer cancel()
}
_, err := run.ExecContext(ctx, stmt)
return err
}
// setDirty records a migration attempt in the tracking table.
//
// For upward migrations, it inserts or updates a row, persisting the
// declared requirements alongside it. For downward migrations, it updates
// the existing row.
func (d *Driver) setDirty(
ctx context.Context,
script migrate.ParsedScript,
) error {
d.logger.Debug(ctx,
"Marking migration as dirty",
log.Int64("version", script.Version),
)
switch script.Direction {
case migrate.Up:
query := "INSERT INTO " + d.ident +
" (module, version, checksum, dirty, requires) " +
"VALUES ($1, $2, $3, true, $4) " +
"ON CONFLICT (module, version) DO UPDATE " +
"SET dirty = true, checksum = EXCLUDED.checksum, " +
"requires = EXCLUDED.requires"
if _, err := d.db.ExecContext(
ctx,
query,
script.Module,
script.Version,
script.Checksum[:],
encodeRequires(script.Requires),
); err != nil {
return fmt.Errorf("failed to mark migration as dirty: %w", err)
}
case migrate.Down:
query := "UPDATE " + d.ident +
" SET dirty = true WHERE module = $1 AND version = $2"
if _, err := d.db.ExecContext(
ctx,
query,
script.Module,
script.Version,
); err != nil {
return fmt.Errorf("failed to mark migration as dirty: %w", err)
}
}
return nil
}
// undo reverts the dirty marker after a transactional migration failed and
// rolled back cleanly.
//
// For upward migrations, it deletes the record inserted for the attempt. For
// downward migrations, it restores the existing record to a clean state. It
// runs on a fresh context so cleanup still succeeds when the migration failed
// due to cancellation of the original context.
func (d *Driver) undo(
ctx context.Context,
script migrate.ParsedScript,
) error {
d.logger.Debug(
ctx,
"Undoing dirty marker after rollback",
log.Int64("version", script.Version),
)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
switch script.Direction {
case migrate.Up:
query := "DELETE FROM " + d.ident +
" WHERE module = $1 AND version = $2 AND dirty"
if _, err := d.db.ExecContext(
ctx, query, script.Module, script.Version,
); err != nil {
return fmt.Errorf("failed to delete dirty record: %w", err)
}
case migrate.Down:
query := "UPDATE " + d.ident +
" SET dirty = false WHERE module = $1 AND version = $2"
if _, err := d.db.ExecContext(
ctx, query, script.Module, script.Version,
); err != nil {
return fmt.Errorf("failed to restore clean state: %w", err)
}
}
return nil
}
// setClean finalizes a successful migration by removing the dirty state.
//
// For upward migrations, it sets dirty to false. For downward migrations, it
// removes the version record entirely.
func (d *Driver) setClean(
ctx context.Context,
script migrate.ParsedScript,
) error {
d.logger.Debug(ctx,
"Clearing dirty state",
log.Int64("version", script.Version),
)
switch script.Direction {
case migrate.Up:
query := "UPDATE " + d.ident +
" SET dirty = false WHERE module = $1 AND version = $2"
if _, err := d.db.ExecContext(
ctx,
query,
script.Module,
script.Version,
); err != nil {
return fmt.Errorf("failed to clear dirty state: %w", err)
}
case migrate.Down:
query := "DELETE FROM " + d.ident +
" WHERE module = $1 AND version = $2"
if _, err := d.db.ExecContext(
ctx,
query,
script.Module,
script.Version,
); err != nil {
return fmt.Errorf("failed to remove migration record: %w", err)
}
}
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package migrate
import (
"cmp"
"context"
"crypto/sha256"
"fmt"
"slices"
"time"
"github.com/deep-rent/nexus/dat/migrate/schema"
"github.com/deep-rent/nexus/sys/log"
)
// Direction signals whether a migration is being applied or reverted.
type Direction int
const (
// Up indicates a migration that applies changes to the database schema.
Up Direction = iota
// Down indicates a migration that undoes changes made by an upward
// migration.
Down
)
// unlockTimeout bounds the deferred lock release so that a wedged database
// cannot hang the migrator after the actual work has finished.
const unlockTimeout = 15 * time.Second
// String implements [fmt.Stringer].
func (d Direction) String() string {
switch d {
case Up:
return "up"
case Down:
return "down"
default:
return "unknown"
}
}
// Record represents a successfully applied migration stored in the database.
type Record struct {
// Module names the migration stream the record belongs to.
Module string
// Version is the unique sequence number of the applied migration within
// its module.
Version int64
// Checksum is the SHA-256 hash of the migration's content, used to detect
// tampering or accidental modification of historical migration files.
Checksum [32]byte
// Dirty indicates if the migration failed mid-execution, leaving the
// database in a potentially inconsistent state that requires manual
// intervention.
Dirty bool
// Requires lists the cross-module requirements the migration declared
// when it was applied. Persisting them lets any migrator refuse to revert
// a required migration without having the dependent module's source files
// at hand.
Requires []Requirement
}
// Driver is the interface that database-specific backends must implement.
//
// It abstracts all direct database interactions, locking mechanisms, and state
// tracking away from the core migration logic. A single tracking table holds
// the records of every module sharing the database, keyed by module and
// version.
type Driver interface {
// Parser returns a database-specific statement parser to safely split raw
// SQL scripts into individual executable statements.
Parser() schema.Parser
// Init ensures the migration tracking table exists in the database.
Init(ctx context.Context) error
// Lock acquires an exclusive, distributed lock to prevent concurrent
// migrator instances from causing race conditions. The lock covers the
// whole tracking table: migrators of different modules exclude each other
// as well, because they read each other's records for requirement checks.
Lock(ctx context.Context) error
// Unlock releases the exclusive distributed lock.
Unlock(ctx context.Context) error
// Applied returns all successfully applied migration records from the
// tracking table across every module, ordered by version in ascending
// order within each module.
Applied(ctx context.Context) ([]Record, error)
// Force sets the given module to the specified version and clears the
// dirty state, effectively ignoring any of the module's migrations past
// that point. Records of other modules are left untouched.
Force(ctx context.Context, module string, version int64) error
// Execute runs the parsed migration statements and records the state update
// within the tracking table.
Execute(ctx context.Context, script ParsedScript) error
}
// Source provides migrations from an external system (e.g., filesystem).
type Source interface {
// List returns a list of all available migration files, respecting the
// provided context. The migrator will handle hashing the content and
// sorting the results appropriately.
List(ctx context.Context) ([]SourceScript, error)
}
// SourceScript represents an unhashed migration script retrieved from a
// [Source].
type SourceScript struct {
// Version is the unique sequence number of the migration.
Version int64
// Description is a human-readable summary of the migration's intent.
Description string
// Direction indicates whether this script applies (Up) or reverts (Down)
// changes.
Direction Direction
// Path is the location identifier of the script within the source.
Path string
// Content contains the raw, unparsed SQL script.
Content []byte
// Tx specifies whether the script should be executed within a transaction.
Tx bool
}
// ParsedScript holds the parameters required to execute a migration.
type ParsedScript struct {
// Module names the migration stream the script belongs to.
Module string
// Version is the unique sequence number of the migration within its
// module.
Version int64
// Direction indicates whether this script applies (Up) or reverts (Down)
// changes.
Direction Direction
// Checksum is the cryptographic hash of the original script content.
Checksum [32]byte
// Statements contains the individually parsed SQL statements ready for
// execution.
Statements []string
// Tx specifies whether the statements should be executed within a
// transaction.
Tx bool
// Requires lists the cross-module requirements declared by the script.
// It is only populated for upward migrations; drivers persist it
// alongside the record.
Requires []Requirement
}
// Migration represents a fully parsed and hashed migration file.
type Migration struct {
// Version is the unique sequence number of the migration.
Version int64
// Description is a human-readable description of the migration.
Description string
// Direction indicates whether this script applies (Up) or reverts (Down)
// changes.
Direction Direction
// Path is the location identifier of the script within the source from
// which the migration stems.
Path string
// Checksum is the SHA-256 hash of the content.
Checksum [32]byte
// Content is the raw SQL content of the migration, which can contain
// multiple statements.
Content []byte
// Tx indicates whether to run all statements together in a transaction.
Tx bool
// Requires lists the cross-module requirements declared in the leading
// comment block of the script. The checksum covers the declaring
// directives, so requirements of applied migrations cannot change
// unnoticed.
Requires []Requirement
}
// Compare returns an integer comparing two migrations to establish a strict
// ordering.
//
// The result is 0 when the two are equal, -1 when the receiver sorts before
// the given migration, and +1 when it sorts after. Migrations are ordered
// primarily by version in ascending order. If two migrations share the same
// version, they are secondarily ordered by direction (so "up" comes before
// "down") to guarantee deterministic sorting.
func (m Migration) Compare(other Migration) int {
if n := cmp.Compare(m.Version, other.Version); n != 0 {
return n
}
return cmp.Compare(m.Direction, other.Direction)
}
// Migrator orchestrates the execution of database migrations for one module.
type Migrator struct {
// module is the name of the migration stream the migrator owns.
module string
// source is the provider for migration files.
source Source
// driver is the backend database implementation.
driver Driver
// dryRun determines if execution should be skipped.
dryRun bool
// strict determines if out-of-order pending migrations are rejected.
strict bool
// logger is the structured logger for migration events.
logger *log.Logger
}
// New creates a new [Migrator] instance.
//
// It panics if the required dependencies ([WithModule], [WithSource], and
// [WithDriver]) are not provided, or if the module name is invalid.
func New(opts ...Option) *Migrator {
m := &Migrator{
logger: log.Discard(),
}
for _, opt := range opts {
opt(m)
}
if m.module == "" {
panic("module is required")
}
if !ValidModule(m.module) {
panic(fmt.Sprintf("invalid module name %q", m.module))
}
if m.source == nil {
panic("source is required")
}
if m.driver == nil {
panic("driver is required")
}
return m
}
// lock is a helper that acquires the driver lock and initializes tracking.
//
// It ensures the tracking table is initialized, executes the provided
// function, and guarantees the lock is released afterward.
func (m *Migrator) lock(
ctx context.Context,
fn func(context.Context) error,
) error {
if err := m.driver.Lock(ctx); err != nil {
return fmt.Errorf("failed to acquire lock: %w", err)
}
defer func() {
// Release on a fresh context so the lock is returned even when the
// caller's context is already canceled, but bound the attempt so a
// wedged database cannot hang the process indefinitely.
c, cancel := context.WithTimeout(
context.WithoutCancel(ctx),
unlockTimeout,
)
defer cancel()
if err := m.driver.Unlock(c); err != nil {
m.logger.Error(c, "Failed to release lock", log.Error(err))
}
}()
if err := m.driver.Init(ctx); err != nil {
return fmt.Errorf("failed to initialize driver: %w", err)
}
return fn(ctx)
}
// files fetches migrations from the source and prepares them.
//
// It calculates cryptographic checksums, extracts requirement directives,
// maps them to domain objects, and strictly sorts them. It returns an error
// if two migrations share the same version and direction, since applying
// such duplicates would silently corrupt the tracking state.
func (m *Migrator) files(ctx context.Context) ([]Migration, error) {
files, err := m.source.List(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list source files: %w", err)
}
migrations := make([]Migration, 0, len(files))
for _, raw := range files {
requires, err := parseRequires(raw.Content)
if err != nil {
return nil, fmt.Errorf(
"invalid requires directive in %q: %w", raw.Path, err,
)
}
if len(requires) > 0 && raw.Direction == Down {
return nil, fmt.Errorf(
"down migration %q declares a requirement: directives are "+
"only valid in up migrations",
raw.Path,
)
}
for _, req := range requires {
if req.Module == m.module {
return nil, fmt.Errorf(
"migration %q requires its own module (%s): order within "+
"a module follows from versions",
raw.Path, req,
)
}
}
migrations = append(migrations, Migration{
Version: raw.Version,
Description: raw.Description,
Direction: raw.Direction,
Path: raw.Path,
Checksum: sha256.Sum256(raw.Content),
Content: raw.Content,
Tx: raw.Tx,
Requires: requires,
})
}
slices.SortFunc(migrations, Migration.Compare)
// After sorting, duplicates of (version, direction) are adjacent.
for i := 1; i < len(migrations); i++ {
prev, curr := migrations[i-1], migrations[i]
if prev.Version == curr.Version && prev.Direction == curr.Direction {
return nil, fmt.Errorf(
"duplicate %s migration for version %d: %q and %q",
curr.Direction, curr.Version, prev.Path, curr.Path,
)
}
}
return migrations, nil
}
// filter is a helper to fetch either pending or applied migrations.
//
// It ensures the tracking table exists first, so read-only queries also work
// against a pristine database.
func (m *Migrator) filter(ctx context.Context, up bool) ([]Migration, error) {
if err := m.driver.Init(ctx); err != nil {
return nil, fmt.Errorf("failed to initialize driver: %w", err)
}
mine, _, files, err := m.load(ctx)
if err != nil {
return nil, err
}
applied := toLookup(mine)
out := make([]Migration, 0, len(files))
for _, f := range files {
if f.Direction == Up && applied[f.Version] == up {
out = append(out, f)
}
}
return out, nil
}
// Up applies all pending migrations in ascending order.
//
// It acquires an exclusive database lock before delegating to the internal
// up implementation.
func (m *Migrator) Up(ctx context.Context) error {
return m.lock(ctx, m.up)
}
// up is the internal implementation of [Migrator.Up].
//
// It determines which migrations are pending and executes them sequentially.
// It assumes the caller has already acquired the necessary database locks.
func (m *Migrator) up(ctx context.Context) error {
// 1. Identify which migrations have not yet been applied.
mine, others, files, err := m.load(ctx)
if err != nil {
return err
}
applied := toLookup(mine)
pending := make([]Migration, 0, len(files))
for _, f := range files {
if f.Direction == Up && !applied[f.Version] {
pending = append(pending, f)
}
}
// 2. In strict mode, refuse to apply migrations that sort below the
// current database version.
if m.strict {
if err := checkOrder(mine, pending); err != nil {
return err
}
}
// 3. Verify cross-module requirements before mutating anything.
if err := checkRequires(pending, others); err != nil {
return err
}
// 4. Fast-path return if the database is already fully up to date.
if len(pending) == 0 {
m.logger.Info(ctx, "Migrations are up to date")
return nil
}
m.logger.Info(ctx,
"Applying pending migrations",
log.Int("count", len(pending)),
)
// 5. Execute each pending migration in strict ascending order.
// If any single migration fails, the loop halts immediately to prevent
// cascading errors and leaves the database in a dirty state for review.
for _, p := range pending {
if err := m.run(ctx, p); err != nil {
return err
}
}
m.logger.Info(ctx, "All migrations applied successfully")
return nil
}
// Down reverts the most recently applied migration.
//
// It acquires an exclusive database lock before delegating to the internal
// down implementation.
func (m *Migrator) Down(ctx context.Context) error {
return m.lock(ctx, m.down)
}
// down is the internal implementation of [Migrator.Down].
//
// It identifies the most recently applied migration, locates its corresponding
// down script from the source, and executes it. It assumes the caller has
// already acquired the database lock.
func (m *Migrator) down(ctx context.Context) error {
// 1. Fetch the applied records and verified source files in one pass.
mine, others, files, err := m.load(ctx)
if err != nil {
return err
}
// 2. Fast-path return if the module has no applied migrations.
if len(mine) == 0 {
m.logger.Info(ctx, "No applied migrations to revert")
return nil
}
// 3. Isolate the target version to rollback (the last one applied).
last := mine[len(mine)-1]
// 4. Locate the matching down script; error out if the database claims a
// version is applied, but the source lacks the file to safely revert it.
f, ok := toDowns(files)[last.Version]
if !ok {
return fmt.Errorf(
"down migration file not found for version %d",
last.Version,
)
}
// 5. Refuse the rollback while another module still requires the version.
if err := checkDependents(m.module, []Migration{f}, others); err != nil {
return err
}
// 6. Execute the rollback script.
if err := m.run(ctx, f); err != nil {
return err
}
m.logger.Info(ctx,
"Migration reverted successfully",
log.Int64("version", f.Version),
)
return nil
}
// Force manually sets the module's version and clears the dirty flag.
//
// It should be used to resolve a dirty state after human intervention.
func (m *Migrator) Force(ctx context.Context, version int64) error {
fn := func(c context.Context) error {
if err := m.driver.Force(c, m.module, version); err != nil {
return fmt.Errorf("failed to force version: %w", err)
}
m.logger.Info(c,
"Successfully forced migration version",
log.Int64("version", version),
)
return nil
}
return m.lock(ctx, fn)
}
// MigrateTo applies or reverts migrations to reach the target version.
func (m *Migrator) MigrateTo(ctx context.Context, target int64) error {
fn := func(c context.Context) error {
mine, others, files, err := m.load(c)
if err != nil {
return err
}
applied := toLookup(mine)
downs := toDowns(files)
// Plan the reverts: applied migrations strictly greater than the
// target version, in descending order. Surfacing a missing down file
// here keeps the database untouched on error.
reverts := make([]Migration, 0, len(mine))
for _, record := range slices.Backward(mine) {
v := record.Version
if v <= target {
continue
}
f, ok := downs[v]
if !ok {
return fmt.Errorf(
"down migration file not found for version %d", v,
)
}
reverts = append(reverts, f)
}
// Plan the applies: pending migrations less than or equal to the
// target version, in ascending order.
pending := make([]Migration, 0, len(files))
for _, f := range files {
if f.Direction == Up && f.Version <= target && !applied[f.Version] {
pending = append(pending, f)
}
}
// In strict mode, fail fast if reaching the target would apply a
// migration below the version the database will sit at afterward.
if m.strict {
kept := mine
for len(kept) > 0 && kept[len(kept)-1].Version > target {
kept = kept[:len(kept)-1]
}
if err := checkOrder(kept, pending); err != nil {
return err
}
}
// Verify cross-module constraints before mutating anything.
if err := checkDependents(m.module, reverts, others); err != nil {
return err
}
if err := checkRequires(pending, others); err != nil {
return err
}
for _, f := range reverts {
if err := m.run(c, f); err != nil {
return err
}
}
for _, f := range pending {
if err := m.run(c, f); err != nil {
return err
}
}
return nil
}
return m.lock(ctx, fn)
}
// Pending returns a list of "Up" migrations that have not yet been applied.
//
// It initializes the tracking table if necessary, so it can be called against
// a pristine database before any migration has run.
func (m *Migrator) Pending(ctx context.Context) ([]Migration, error) {
return m.filter(ctx, false)
}
// Applied returns a list of "Up" migrations that have already been executed.
//
// It initializes the tracking table if necessary, so it can be called against
// a pristine database before any migration has run.
func (m *Migrator) Applied(ctx context.Context) ([]Migration, error) {
return m.filter(ctx, true)
}
// Steps applies or reverts up to the given number of migrations.
//
// A positive count applies at most that many pending migrations in
// ascending version order; a negative count reverts at most that many
// applied migrations (by absolute value) in descending order. If fewer
// migrations are available than requested, all of them are processed
// without error. A count of zero is a no-op.
//
// It acquires an exclusive database lock for the duration of the operation.
func (m *Migrator) Steps(ctx context.Context, n int) error {
if n == 0 {
return nil
}
return m.lock(ctx, func(c context.Context) error {
return m.steps(c, n)
})
}
// steps is the internal implementation of [Migrator.Steps].
//
// It assumes the caller has already acquired the database lock.
func (m *Migrator) steps(ctx context.Context, n int) error {
mine, others, files, err := m.load(ctx)
if err != nil {
return err
}
applied := toLookup(mine)
if n > 0 {
pending := make([]Migration, 0, len(files))
for _, f := range files {
if f.Direction == Up && !applied[f.Version] {
pending = append(pending, f)
}
}
if m.strict {
if err := checkOrder(mine, pending); err != nil {
return err
}
}
if len(pending) > n {
pending = pending[:n]
}
if err := checkRequires(pending, others); err != nil {
return err
}
for _, p := range pending {
if err := m.run(ctx, p); err != nil {
return err
}
}
m.logger.Info(ctx,
"Applied migration steps",
log.Int("count", len(pending)),
)
return nil
}
// Plan the reverts before executing so a missing down file or a foreign
// dependent leaves the database untouched.
downs := toDowns(files)
reverts := make([]Migration, 0, len(mine))
for i := len(mine) - 1; i >= 0 && len(reverts) < -n; i-- {
v := mine[i].Version
f, ok := downs[v]
if !ok {
return fmt.Errorf(
"down migration file not found for version %d", v,
)
}
reverts = append(reverts, f)
}
if err := checkDependents(m.module, reverts, others); err != nil {
return err
}
for _, f := range reverts {
if err := m.run(ctx, f); err != nil {
return err
}
}
m.logger.Info(ctx,
"Reverted migration steps",
log.Int("count", len(reverts)),
)
return nil
}
// Version reports the newest applied migration record of the module.
//
// It initializes the tracking table if necessary, so it can be called against
// a pristine database. Unlike [Migrator.Applied], it does not verify source
// files or reject dirty state, making it suitable for inspecting a database
// after a failed migration. The boolean return is false when no migration has
// been applied yet.
func (m *Migrator) Version(ctx context.Context) (Record, bool, error) {
if err := m.driver.Init(ctx); err != nil {
return Record{}, false, fmt.Errorf(
"failed to initialize driver: %w", err,
)
}
records, err := m.driver.Applied(ctx)
if err != nil {
return Record{}, false, fmt.Errorf(
"failed to get applied versions: %w", err,
)
}
// Records are ascending within each module, so the last match wins.
var newest Record
found := false
for _, r := range records {
if r.Module == m.module {
newest = r
found = true
}
}
return newest, found, nil
}
// run reads the migration payload and executes it via the driver.
//
// If dry run is enabled, it logs the statements and skips execution.
func (m *Migrator) run(ctx context.Context, migration Migration) error {
m.logger.Info(ctx,
"Running migration",
log.Int64("version", migration.Version),
log.String("description", migration.Description),
log.String("direction", migration.Direction.String()),
)
parse := m.driver.Parser()
stmts := parse(migration.Content)
if m.dryRun {
m.logger.Info(ctx,
"Dry run: skipping execution",
log.Int("statements", len(stmts)),
)
for i, stmt := range stmts {
m.logger.Debug(ctx,
"Dry run statement",
log.Int("index", i+1),
log.String("query", stmt),
)
}
return nil
}
err := m.driver.Execute(ctx, ParsedScript{
Module: m.module,
Version: migration.Version,
Direction: migration.Direction,
Checksum: migration.Checksum,
Statements: stmts,
Tx: migration.Tx,
Requires: migration.Requires,
})
if err != nil {
err = fmt.Errorf("migration %d failed: %w", migration.Version, err)
m.logger.Error(ctx, "Migration failed", log.Error(err))
return err
}
m.logger.Info(ctx,
"Migration completed",
log.Int64("version", migration.Version),
)
return nil
}
// load loads applied records and available files.
//
// Applied records are split into those belonging to the migrator's own module
// and those tracked by other modules. Dirty state, missing files, and
// checksum mismatches are only verified for the own module: foreign source
// files are not available to this migrator, and a foreign incident must not
// block an unrelated module. Foreign records participate in requirement
// checks instead, where a dirty or absent requirement is rejected.
func (m *Migrator) load(
ctx context.Context,
) (mine, others []Record, files []Migration, err error) {
files, err = m.files(ctx)
if err != nil {
return nil, nil, nil, err
}
applied, err := m.driver.Applied(ctx)
if err != nil {
return nil, nil, nil, fmt.Errorf(
"failed to get applied versions: %w", err,
)
}
for _, a := range applied {
if a.Module == m.module {
mine = append(mine, a)
} else {
others = append(others, a)
}
}
ups := make(map[int64]Migration, len(files))
for _, f := range files {
if f.Direction == Up {
ups[f.Version] = f
}
}
for _, a := range mine {
if a.Dirty {
return nil, nil, nil, fmt.Errorf(
"database is dirty at version %d; manual intervention required",
a.Version,
)
}
f, ok := ups[a.Version]
if !ok {
return nil, nil, nil, fmt.Errorf(
"applied migration %d is missing from source files",
a.Version,
)
}
if a.Checksum != f.Checksum {
return nil, nil, nil, fmt.Errorf(
"checksum mismatch for migration %d: "+
"database has %x, file has %x",
a.Version,
a.Checksum,
f.Checksum,
)
}
}
return mine, others, files, nil
}
// toLookup converts a slice of migration records to a map for quick lookups.
func toLookup(records []Record) map[int64]bool {
applied := make(map[int64]bool, len(records))
for _, r := range records {
applied[r.Version] = true
}
return applied
}
// checkOrder rejects pending migrations that sort below the newest applied
// record.
//
// The records must be sorted by version in ascending order. It returns an
// error naming the offending file so operators can renumber or explicitly
// disable strict ordering.
func checkOrder(records []Record, pending []Migration) error {
if len(records) == 0 {
return nil
}
newest := records[len(records)-1].Version
for _, p := range pending {
if p.Version < newest {
return fmt.Errorf(
"out-of-order migration %d (%s): database is already at "+
"version %d",
p.Version, p.Path, newest,
)
}
}
return nil
}
// checkRequires verifies that every requirement declared by the pending
// migrations is satisfied by the applied records of other modules.
//
// A requirement is satisfied when the required migration is applied and
// clean. The check runs before any migration executes, so an unmet
// requirement leaves the database untouched.
func checkRequires(pending []Migration, others []Record) error {
dirty := make(map[Requirement]bool, len(others))
for _, r := range others {
dirty[Requirement{Module: r.Module, Version: r.Version}] = r.Dirty
}
for _, p := range pending {
for _, req := range p.Requires {
isDirty, applied := dirty[req]
if !applied {
return fmt.Errorf(
"migration %d (%s) requires %s, which has not been "+
"applied: migrate module %q first",
p.Version, p.Path, req, req.Module,
)
}
if isDirty {
return fmt.Errorf(
"migration %d (%s) requires %s, which is dirty; "+
"manual intervention required",
p.Version, p.Path, req,
)
}
}
}
return nil
}
// checkDependents refuses to revert migrations that applied migrations of
// other modules declared as requirements.
//
// It is the mirror image of checkRequires: requirements are persisted with
// each record, so the check needs no access to the dependent module's source
// files.
func checkDependents(
module string,
reverts []Migration,
others []Record,
) error {
if len(reverts) == 0 {
return nil
}
versions := make(map[int64]bool, len(reverts))
for _, f := range reverts {
versions[f.Version] = true
}
for _, r := range others {
for _, req := range r.Requires {
if req.Module == module && versions[req.Version] {
return fmt.Errorf(
"cannot revert migration %d: applied migration %s@%d "+
"requires it",
req.Version, r.Module, r.Version,
)
}
}
}
return nil
}
// toDowns indexes the down migrations among the given files by version.
func toDowns(files []Migration) map[int64]Migration {
downs := make(map[int64]Migration)
for _, f := range files {
if f.Direction == Down {
downs[f.Version] = f
}
}
return downs
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package migrate
import (
"github.com/deep-rent/nexus/sys/log"
)
// Option configures a [Migrator] instance.
type Option func(*Migrator)
// WithModule sets the name of the migration stream the migrator owns.
//
// Each service ships its migrations as a named module, typically the service
// name (e.g. "iam"). Versions are scoped to the module, so services sharing
// one database keep independent version sequences. The name must satisfy
// [ValidModule].
//
// This option is mandatory.
func WithModule(name string) Option {
return func(m *Migrator) {
m.module = name
}
}
// WithSource sets the migration source.
//
// This option is mandatory.
func WithSource(source Source) Option {
return func(m *Migrator) {
m.source = source
}
}
// WithDriver sets the database driver.
//
// This option is mandatory.
func WithDriver(driver Driver) Option {
return func(m *Migrator) {
m.driver = driver
}
}
// WithDryRun enables a mode where the [Migrator] computes checksums and logs.
//
// It logs the parsed statements without executing them against the database.
func WithDryRun(enabled bool) Option {
return func(m *Migrator) {
m.dryRun = enabled
}
}
// WithStrictOrder makes the [Migrator] reject out-of-order migrations.
//
// When enabled, applying a pending migration whose version is lower than the
// highest already-applied version returns an error instead of silently
// executing it after its successors. Such gaps typically appear when branches
// with independently numbered migrations are merged. The default is lenient:
// out-of-order migrations are applied in ascending version order.
func WithStrictOrder(enabled bool) Option {
return func(m *Migrator) {
m.strict = enabled
}
}
// WithLogger sets the logger for the migrator.
//
// A nil value will be ignored.
func WithLogger(logger *log.Logger) Option {
return func(m *Migrator) {
if logger != nil {
m.logger = logger
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package migrate
import (
"bytes"
"fmt"
"strconv"
"strings"
)
// Requirement pins a migration of another module that must already be
// applied before the migration declaring it may run.
//
// Requirements are the only coupling between modules: an up migration that
// depends on another service's schema (for example, a foreign key onto a
// table that service owns) declares the exact migration which creates that
// schema. The migrator refuses to apply the declaring migration until the
// required one is applied and clean, and refuses to revert the required one
// while the declaring migration remains applied.
type Requirement struct {
// Module names the migration stream the required migration belongs to.
Module string
// Version is the sequence number of the required migration within its
// module.
Version int64
}
// String renders the requirement in the canonical "module@version" notation.
func (r Requirement) String() string {
return fmt.Sprintf("%s@%d", r.Module, r.Version)
}
// ParseRequirement parses the "module@version" notation used by requires
// directives and by drivers persisting requirements.
func ParseRequirement(s string) (Requirement, error) {
module, version, found := strings.Cut(s, "@")
if !found {
return Requirement{}, fmt.Errorf("missing @ separator in %q", s)
}
if !ValidModule(module) {
return Requirement{}, fmt.Errorf("invalid module name %q", module)
}
// The bit size of 63 rejects values that would overflow the signed
// BIGINT column used by database drivers to track applied versions.
v, err := strconv.ParseUint(version, 10, 63)
if err != nil {
return Requirement{}, fmt.Errorf("invalid version in %q", s)
}
return Requirement{Module: module, Version: int64(v)}, nil
}
// ValidModule reports whether name is a valid module name.
//
// A module name starts with a lowercase ASCII letter followed by lowercase
// letters, digits, or underscores. The restrictive grammar keeps names
// unambiguous in the "module@version" notation and safe to embed in
// identifiers and log output.
func ValidModule(name string) bool {
for i := 0; i < len(name); i++ {
switch c := name[i]; {
case 'a' <= c && c <= 'z':
case i > 0 && ('0' <= c && c <= '9' || c == '_'):
default:
return false
}
}
return name != ""
}
// requiresDirective is the comment keyword that declares a [Requirement]
// inside a migration script.
const requiresDirective = "requires:"
// parseRequires extracts requirement directives from the leading comment
// block of a migration script.
//
// A directive is a line comment of the form "-- requires: module@version",
// one requirement per line. Scanning stops at the first line that is neither
// blank nor a line comment, so directives buried below SQL statements are
// ignored rather than silently honored. Duplicate declarations are rejected.
func parseRequires(content []byte) ([]Requirement, error) {
var requires []Requirement
seen := make(map[Requirement]bool)
for line := range bytes.Lines(content) {
line = bytes.TrimSpace(line)
if len(line) == 0 {
continue
}
comment, found := bytes.CutPrefix(line, []byte("--"))
if !found {
break // The first SQL statement ends the header block.
}
text := strings.TrimSpace(string(comment))
value, found := strings.CutPrefix(text, requiresDirective)
if !found {
continue // An ordinary comment.
}
req, err := ParseRequirement(strings.TrimSpace(value))
if err != nil {
return nil, err
}
if seen[req] {
return nil, fmt.Errorf("duplicate requirement %s", req)
}
seen[req] = true
requires = append(requires, req)
}
return requires, nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package schema
import (
"bytes"
"github.com/deep-rent/nexus/std/ascii"
)
// Parser is a function that splits a raw SQL script into a slice of individual
// executable statements.
//
// Implementations should handle database-specific syntax rules to ensure
// statements are not prematurely split when terminators appear inside quotes or
// comments.
type Parser func(script []byte) []string
// Postgres is a [Parser] implementation tailored for PostgreSQL scripts.
//
// It safely splits the script by semicolons (';'), while strictly ignoring
// semicolons that appear within:
// - Single-line comments ("-- ...")
// - Multi-line block comments ("/* ... */"), supporting nested blocks.
// - Single-quoted string literals ('...')
// - Double-quoted identifiers ("...")
// - PostgreSQL dollar-quoted strings ($tag$...$tag$).
//
// To optimize performance, it uses [bytes] package operations to fast-forward
// through recognized blocks and pre-allocates the statement slice based on
// semicolon frequency.
func Postgres(script []byte) []string {
script = bytes.TrimSpace(script)
// Early return for empty or whitespace-only scripts
if len(script) == 0 {
return nil
}
// Pre-allocate slice based on semicolon count to minimize allocations
n := bytes.Count(script, []byte{';'})
p := &postgres{
script: script,
stmts: make([]string, 0, n+1),
}
return p.parse()
}
// postgres holds the internal state machine for parsing a PostgreSQL script.
type postgres struct {
// script is the raw SQL script being parsed.
script []byte
// i is the current cursor position (byte index) in the script.
i int
// start is the byte index where the current statement begins.
start int
// stmts is the collected slice of individual statements.
stmts []string
// inSingleQuotes is true if the cursor is within a single-quoted string.
inSingleQuotes bool
// inDoubleQuotes is true if the cursor is within a double-quoted string.
inDoubleQuotes bool
// inComment is true if the cursor is within a single-line comment.
inComment bool
// depth tracks the nesting depth of multi-line block comments.
depth int
// tag is the active dollar-quote tag (e.g., "$BODY$") when inside one.
tag []byte
}
// parse iterates through the script, updating the state machine
// and splitting statements when a valid, top-level semicolon is encountered.
func (p *postgres) parse() []string {
n := len(p.script)
for p.i < n {
// 1. Prioritize state checks and fast-forward using bytes operations
switch {
case p.inComment:
idx := bytes.IndexByte(p.script[p.i:], '\n')
if idx == -1 {
p.i = n // EOF
break
}
p.i += idx + 1
p.inComment = false
continue
case p.depth > 0:
// Fast-forward to next possible block comment boundary
idx := bytes.IndexAny(p.script[p.i:], "/*")
if idx == -1 {
p.i = n // EOF
break
}
p.i += idx
c := p.script[p.i]
if c == '/' && p.i+1 < n && p.script[p.i+1] == '*' {
p.depth++
p.i++
} else if c == '*' && p.i+1 < n && p.script[p.i+1] == '/' {
p.depth--
p.i++
}
p.i++
continue
case len(p.tag) != 0:
// Fast-forward to the exact matching dollar-tag
idx := bytes.Index(p.script[p.i:], p.tag)
if idx == -1 {
p.i = n // EOF
break
}
p.i += idx + len(p.tag)
p.tag = nil
continue
case p.inSingleQuotes:
idx := bytes.IndexByte(p.script[p.i:], '\'')
if idx == -1 {
p.i = n // EOF
break
}
p.i += idx
if p.i+1 < n && p.script[p.i+1] == '\'' {
p.i += 2 // Skip escaped quote
} else {
p.inSingleQuotes = false
p.i++
}
continue
case p.inDoubleQuotes:
idx := bytes.IndexByte(p.script[p.i:], '"')
if idx == -1 {
p.i = n // EOF
break
}
p.i += idx
if p.i+1 < n && p.script[p.i+1] == '"' {
p.i += 2 // Skip escaped quote
} else {
p.inDoubleQuotes = false
p.i++
}
continue
}
// 2. We are in normal SQL text. Fast-forward to the next relevant
// character.
idx := bytes.IndexAny(p.script[p.i:], "-/$'\";")
if idx == -1 {
p.i = n // No more special characters, jump to end
break
}
p.i += idx
c := p.script[p.i]
// 3. Isolated value-based switch for compiler optimization
switch c {
case '-':
if p.i+1 < n && p.script[p.i+1] == '-' {
p.inComment = true
p.i++
}
case '/':
if p.i+1 < n && p.script[p.i+1] == '*' {
p.depth++
p.i++
}
case '$':
p.dollar(n)
case '\'':
p.inSingleQuotes = true
case '"':
p.inDoubleQuotes = true
case ';':
p.flush()
}
p.i++
}
// Add the final statement if the script does not end with a semicolon.
p.flush()
return p.stmts
}
// dollar scans ahead to parse a PostgreSQL dollar-quote tag (e.g., "$tag$").
//
// It uses [ascii.IsWord] to validate the characters within the tag. If a valid
// tag is found, the state is updated to track this [postgres.tag] block.
func (p *postgres) dollar(n int) {
end := -1
for j := p.i + 1; j < n; j++ {
nc := p.script[j]
if nc == '$' {
end = j
break
}
if !ascii.IsWord(nc) {
break
}
}
if end != -1 {
p.tag = p.script[p.i : end+1]
p.i = end
}
}
// flush extracts the current statement from the script buffer.
//
// It trims surrounding whitespace using [bytes.TrimSpace]. If the extracted
// statement is not empty, it is appended to the results list. It then advances
// the [postgres.start] pointer.
func (p *postgres) flush() {
if p.start >= len(p.script) {
return
}
if stmt := bytes.TrimSpace(p.script[p.start:p.i]); len(stmt) != 0 {
p.stmts = append(p.stmts, string(stmt))
}
p.start = p.i + 1
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package file
import (
"context"
"errors"
"fmt"
"io/fs"
"strconv"
"strings"
"github.com/deep-rent/nexus/dat/migrate"
"github.com/deep-rent/nexus/sys/log"
)
// Errors explaining why the [Source.Parse] method has failed:
var (
// ErrExtension is returned when a filename does not end with the configured
// file extension.
ErrExtension = errors.New("extension mismatch")
// ErrMissingDirection is returned when a filename lacks the dot separator
// preceding the direction segment.
ErrMissingDirection = errors.New("missing direction segment")
// ErrIllegalDirection is returned when the direction segment is neither
// "up" nor "down".
ErrIllegalDirection = errors.New("illegal direction")
// ErrMissingSeparator is returned when a filename lacks the underscore
// separating the version from the description.
ErrMissingSeparator = errors.New("missing underscore separator")
// ErrInvalidDescription is returned when the description segment of the
// filename is empty.
ErrInvalidDescription = errors.New("invalid description")
// ErrInvalidVersion is returned when the version segment is empty or cannot
// be parsed into an unsigned integer.
ErrInvalidVersion = errors.New("invalid version")
)
// Source implements the [migrate.Source] interface for an [fs.FS].
//
// It scans the file system to discover and parse migration files.
type Source struct {
// dir is the filesystem containing the migration scripts.
dir fs.FS
// ext is the file extension used to filter relevant scripts.
ext string
// logger is the logger used for debugging missed conventions.
logger *log.Logger
}
// New creates a new [Source] instance that reads from the provided [fs.FS].
//
// Options can be provided to customize behavior, such as changing the expected
// file extension.
func New(dir fs.FS, opts ...Option) *Source {
cfg := &config{
ext: DefaultExtension,
logger: log.Discard(),
}
for _, opt := range opts {
opt(cfg)
}
return &Source{
dir: dir,
ext: cfg.ext,
logger: cfg.logger,
}
}
// Directory returns the underlying file system used by the source.
func (s *Source) Directory() fs.FS {
return s.dir
}
// Extension returns the configured file extension used to identify
// migration scripts.
func (s *Source) Extension() string {
return s.ext
}
// Parse extracts metadata from a given filename.
//
// It extracts the version, description, execution direction, and transaction
// flag. It returns an error if the filename does not match the strict
// <version>_<description>.<direction>[_notx]<extension> format.
func (s *Source) Parse(name string) (
version int64,
desc string,
direction migrate.Direction,
tx bool,
err error,
) {
// Default to transactional execution unless explicitly disabled.
tx = true
// Strip the configured file extension (e.g., ".sql").
base, found := strings.CutSuffix(name, s.ext)
if !found {
return 0, "", 0, false, ErrExtension
}
// Locate the dot that separates the version/description from the direction.
dot := strings.LastIndexByte(base, '.')
if dot <= 0 {
return 0, "", 0, false, ErrMissingDirection
}
// Extract the direction segment (e.g., "up", "down", or "up_notx").
s2 := base[dot+1:]
// Check for the "_notx" suffix to determine if transactions should be
// disabled.
if disabled, found := strings.CutSuffix(s2, "_notx"); found {
tx = false
s2 = disabled
}
// Map the direction string to the internal direction type.
switch s2 {
case "up":
direction = migrate.Up
case "down":
direction = migrate.Down
default:
return 0, "", 0, false, ErrIllegalDirection
}
// Move the cursor back to the prefix (version and description).
base = base[:dot]
// Split the remaining string into the version and the description.
// We expect the first underscore to be the separator.
s0, s1, found := strings.Cut(base, "_")
if !found {
return 0, "", 0, false, ErrMissingSeparator
}
// Ensure neither the version nor the description segments are empty
// strings.
if s0 == "" {
return 0, "", 0, false, ErrInvalidVersion
}
if s1 == "" {
return 0, "", 0, false, ErrInvalidDescription
}
// Parse the version segment into a non-negative integer. The bit size of
// 63 rejects values that would overflow the signed BIGINT column used by
// database drivers to track applied versions.
v, e := strconv.ParseUint(s0, 10, 63)
if e != nil {
return 0, "", 0, false, ErrInvalidVersion
}
// Finalize the version and sanitize the description by restoring spaces.
version = int64(v)
desc = strings.ReplaceAll(s1, "_", " ")
return version, desc, direction, tx, nil
}
// List reads the underlying file system and returns all valid migrations.
//
// It parses all files matching the configured extension. Files that do not
// match the naming convention are skipped and logged at the debug level.
func (s *Source) List(ctx context.Context) ([]migrate.SourceScript, error) {
var scripts []migrate.SourceScript
fn := func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
name := d.Name()
version, desc, direction, tx, skipped := s.Parse(name)
if skipped != nil {
s.logger.Debug(ctx,
"Skipping file in migration directory",
log.String("name", name),
log.String("reason", skipped.Error()),
)
return nil // Ignore files that don't match the naming convention
}
content, err := fs.ReadFile(s.dir, path)
if err != nil {
return fmt.Errorf("failed to read migration file %q: %w", path, err)
}
scripts = append(scripts, migrate.SourceScript{
Version: version,
Description: desc,
Direction: direction,
Path: path,
Content: content,
Tx: tx,
})
return nil
}
err := fs.WalkDir(s.dir, ".", fn)
if err != nil {
return nil, fmt.Errorf(
"failed to traverse migration directory: %w",
err,
)
}
return scripts, nil
}
var _ migrate.Source = (*Source)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package file
import (
"strings"
"github.com/deep-rent/nexus/sys/log"
)
const (
// DefaultExtension is the default file extension used when searching for
// migration scripts in the file system.
DefaultExtension = ".sql"
)
// config holds the internal configuration options for the file source.
type config struct {
// ext is the file extension to filter for.
ext string
// logger is the structured logger for reporting skipped files.
logger *log.Logger
}
// Option configures a [Source] instance.
type Option func(*config)
// WithExtension sets a custom file extension for migration files.
//
// It automatically prepends a leading dot if one is missing. Empty string
// values are ignored.
func WithExtension(ext string) Option {
return func(c *config) {
if ext == "" {
return
}
if !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
c.ext = ext
}
}
// WithLogger injects a structured logger to record skipped files.
//
// Nil values are ignored; without a logger, logging is disabled.
func WithLogger(logger *log.Logger) Option {
return func(c *config) {
if logger != nil {
c.logger = logger
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package mock
import (
"context"
"github.com/deep-rent/nexus/dat/migrate"
)
// Source is an in-memory implementation of [migrate.Source].
//
// It allows you to pre-define the list of scripts and optionally inject an
// error to test failure paths.
type Source struct {
// Scripts contains the pre-defined migration scripts and should be treated
// as read-only after initialization.
Scripts []migrate.SourceScript
// ListErr is an injectable error used to test failure paths during the List
// operation.
ListErr error
}
// New creates a new in-memory [Source] with the provided scripts.
func New(scripts ...migrate.SourceScript) *Source {
return &Source{
Scripts: scripts,
}
}
// List returns the pre-configured scripts or the injected [Source.ListErr].
func (s *Source) List(_ context.Context) ([]migrate.SourceScript, error) {
if s.ListErr != nil {
return nil, s.ListErr
}
// Return a copy to prevent accidental mutation by the caller.
out := make([]migrate.SourceScript, len(s.Scripts))
copy(out, s.Scripts)
return out, nil
}
var _ migrate.Source = (*Source)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package page
import (
"github.com/deep-rent/nexus/dat/valid"
)
// Page size bounds. A caller may ask for fewer records than [MaxSize],
// never for more: an unbounded page is a denial of service against the
// database behind it. Nor for fewer than [MinSize], since a page holding
// no records answers nothing while costing a round trip.
const (
// DefaultSize is the page size applied when none is requested.
DefaultSize = 20
// MinSize is the smallest page a caller may ask for.
MinSize = 1
// MaxSize is the largest page a caller may ask for.
MaxSize = 100
)
// Params is the page a caller asked for, bound straight off the query
// string. Both parameters are optional; the zero value asks for the first
// page at the default size.
//
// GET /documents?page=2&page_size=50
//
// Params is a request as received, so its values may be out of range;
// [Params.Window] resolves them. A listing that is also searched or sorted
// binds through a [dat/search.Schema] instead, whose query embeds this
// type.
//
// [dat/search.Schema]: github.com/deep-rent/nexus/dat/search#Schema
type Params struct {
// Page is the zero-based page index: page 0 is the first page.
// Negative values address the first page.
Page int `query:"page"`
// Size caps the page. Zero requests no particular size and yields
// [DefaultSize]; anything else is clamped to [MinSize, MaxSize].
Size int `query:"page_size"`
}
// Validate implements the [valid.Validatable] interface, so that a caller
// who paged past the bounds hears about it instead of silently receiving
// a different page. An embedding type overriding this method must call it.
//
// A zero size reads as no size at all — the field cannot tell an omitted
// parameter from an explicit zero — so it passes and picks up
// [DefaultSize]. Any other size must fall within [MinSize, MaxSize].
func (p *Params) Validate(v *valid.Validator) {
v.Min("page", p.Page, 0)
if p.Size != 0 {
v.Between("page_size", p.Size, MinSize, MaxSize)
}
}
// Window resolves the params into the record window they address, clamping
// every out-of-range value. This is the only place defaults are applied:
// what comes back is already valid, so a store may use it directly.
func (p Params) Window() Window {
size := p.Size
switch {
case size == 0:
size = DefaultSize
case size < MinSize:
size = MinSize
case size > MaxSize:
size = MaxSize
}
return Window{
Offset: max(p.Page, 0) * size,
Limit: size,
}
}
// Window is the stretch of records one page covers: how many to skip and
// how many to take. It is what a store executes — the shape an OFFSET and
// a LIMIT want — and it is only ever produced by [Params.Window], so its
// values are known to be in range.
type Window struct {
// Offset is the number of records preceding this page.
Offset int
// Limit is the number of records the page holds at most.
Limit int
}
// Index returns the zero-based index of the page this window addresses.
func (w Window) Index() int {
if w.Limit <= 0 {
return 0
}
return w.Offset / w.Limit
}
// Beyond reports whether the window starts past the end of a listing of
// total records. A store checks it after counting the matches and returns
// an empty page without asking its backend to walk an offset it would
// discard.
func (w Window) Beyond(total int) bool {
return w.Offset >= total
}
// Page is one page of a listing, carrying the totals a caller needs to
// render a pager. It is the JSON shape the management APIs return.
type Page[T any] struct {
// Items are the records on this page, never null: an empty page
// serializes as an empty array.
Items []T `json:"items"`
// Index is the zero-based index of this page.
Index int `json:"page"`
// Size is the page size the listing was taken at, not the number of
// items on this page — the last page is usually shorter.
Size int `json:"page_size"`
// Total is the number of records matching the query across all pages.
Total int `json:"total"`
}
// Of assembles a page of the items taken through the window, out of total
// matching records. The reported index and size are the ones the window
// actually applied, not the ones a caller asked for.
func Of[T any](items []T, w Window, total int) Page[T] {
if items == nil {
items = []T{}
}
return Page[T]{
Items: items,
Index: w.Index(),
Size: w.Limit,
Total: total,
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package pg
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// LifetimeJitterDivisor sets the default connection-lifetime jitter to a
// tenth of the maximum connection lifetime, so several replicas started
// together do not recycle their connections in lockstep.
const LifetimeJitterDivisor = 10
// Connect opens a native connection pool against the given connection
// string. Pool sizing and lifetimes follow its pool_* parameters (see
// [pgxpool.ParseConfig]), except that an unset lifetime jitter defaults to
// the fraction [LifetimeJitterDivisor] describes.
//
// Every connection is registered with this package's types
// ([RegisterTypes]), which is what makes the zero UUID and SQL NULL the
// same value. A pool built without that registration would store and read
// owners differently, so build one through here rather than by hand.
//
// The pool connects lazily; reachability is the caller's to verify with
// [pgxpool.Pool.Ping].
func Connect(ctx context.Context, url string) (*pgxpool.Pool, error) {
pc, err := pgxpool.ParseConfig(url)
if err != nil {
return nil, fmt.Errorf("failed to parse database URL: %w", err)
}
if pc.MaxConnLifetimeJitter == 0 {
pc.MaxConnLifetimeJitter = pc.MaxConnLifetime / LifetimeJitterDivisor
}
pc.AfterConnect = func(_ context.Context, conn *pgx.Conn) error {
RegisterTypes(conn.TypeMap())
return nil
}
pool, err := pgxpool.NewWithConfig(ctx, pc)
if err != nil {
return nil, fmt.Errorf("failed to open database pool: %w", err)
}
return pool, nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package pg
import (
"fmt"
"time"
"uuid"
"github.com/jackc/pgx/v5/pgtype"
)
// RegisterTypes teaches a connection's type map the conventions this
// repository's drivers share: a timestamptz codec that scans in UTC, and a
// UUID codec that treats the zero UUID and SQL NULL as the same value. See
// the package documentation for the reasoning; [Connect] wires it as the
// pool's AfterConnect hook.
func RegisterTypes(m *pgtype.Map) {
m.RegisterType(&pgtype.Type{
Name: "timestamptz",
OID: pgtype.TimestamptzOID,
Codec: &pgtype.TimestamptzCodec{ScanLocation: time.UTC},
})
m.RegisterType(&pgtype.Type{
Name: "uuid",
OID: pgtype.UUIDOID,
Codec: nullableUUID{},
})
}
// nullableUUID plans [uuid.UUID] values and targets itself, mapping the
// zero UUID to NULL in both directions. Anything else — a [pgtype.UUID],
// a string, a pointer — falls through to the stock codec, so the
// registration narrows nothing.
type nullableUUID struct{ pgtype.UUIDCodec }
// PlanEncode implements [pgtype.Codec].
func (c nullableUUID) PlanEncode(
m *pgtype.Map,
oid uint32,
format int16,
value any,
) pgtype.EncodePlan {
if _, ok := value.(uuid.UUID); ok {
switch format {
case pgtype.BinaryFormatCode:
return encodeUUIDBinary{}
case pgtype.TextFormatCode:
return encodeUUIDText{}
}
}
return c.UUIDCodec.PlanEncode(m, oid, format, value)
}
// PlanScan implements [pgtype.Codec].
func (c nullableUUID) PlanScan(
m *pgtype.Map,
oid uint32,
format int16,
target any,
) pgtype.ScanPlan {
if _, ok := target.(*uuid.UUID); ok {
switch format {
case pgtype.BinaryFormatCode:
return scanUUIDBinary{}
case pgtype.TextFormatCode:
return scanUUIDText{}
}
}
return c.UUIDCodec.PlanScan(m, oid, format, target)
}
var _ pgtype.Codec = nullableUUID{}
// encodeUUIDBinary writes the sixteen raw bytes, or NULL for the zero
// UUID. A nil buffer is how pgx spells NULL to the wire.
type encodeUUIDBinary struct{}
func (encodeUUIDBinary) Encode(value any, buf []byte) ([]byte, error) {
id := value.(uuid.UUID)
if id == uuid.Nil() {
return nil, nil
}
return append(buf, id[:]...), nil
}
var _ pgtype.EncodePlan = encodeUUIDBinary{}
// encodeUUIDText writes the canonical hyphenated form, or NULL for the
// zero UUID.
type encodeUUIDText struct{}
func (encodeUUIDText) Encode(value any, buf []byte) ([]byte, error) {
id := value.(uuid.UUID)
if id == uuid.Nil() {
return nil, nil
}
return id.AppendText(buf)
}
var _ pgtype.EncodePlan = encodeUUIDText{}
// scanUUIDBinary reads the sixteen raw bytes, with NULL yielding the zero
// UUID.
type scanUUIDBinary struct{}
func (scanUUIDBinary) Scan(src []byte, dst any) error {
id := dst.(*uuid.UUID)
if src == nil {
*id = uuid.Nil()
return nil
}
if len(src) != len(*id) {
return fmt.Errorf("invalid length for UUID: %d", len(src))
}
copy(id[:], src)
return nil
}
// scanUUIDText reads the hyphenated form, with NULL yielding the zero
// UUID.
type scanUUIDText struct{}
func (scanUUIDText) Scan(src []byte, dst any) error {
id := dst.(*uuid.UUID)
if src == nil {
*id = uuid.Nil()
return nil
}
return id.UnmarshalText(src)
}
var (
_ pgtype.ScanPlan = scanUUIDBinary{}
_ pgtype.ScanPlan = scanUUIDText{}
)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package search
import (
"fmt"
"net/http"
"github.com/deep-rent/nexus/net/router"
)
// Bind parses the search parameters off the request's query string via
// [Schema.Parse]. A failure comes back as a 400 [*router.Error] carrying
// the per-parameter violations, following the contract of
// [router.Exchange.BindQuery], so a handler simply returns it:
//
// q, err := Search.Bind(e)
// if err != nil {
// return err
// }
func (s Schema) Bind(e *router.Exchange) (Query, error) {
q, err := s.Parse(e.R.URL.Query())
if err != nil {
return Query{}, &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: fmt.Sprintf(
"query violates %d constraints", err.Size(),
),
Context: err,
}
}
return q, nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package search
import (
"fmt"
"maps"
"net/url"
"slices"
"strconv"
"strings"
"time"
"github.com/deep-rent/nexus/dat/valid"
)
// Reserved query parameters, which therefore cannot name a filterable
// field. A field colliding with one is still reachable through [ParamFilter].
const (
// ParamText carries the free-text term.
ParamText = "q"
// ParamSort carries the ordering.
ParamSort = "sort"
// ParamFilter carries a "field:operator:operand" triple, for a field
// whose name is reserved.
ParamFilter = "filter"
// ParamPage carries the zero-based page index.
ParamPage = "page"
// ParamSize carries the page size.
ParamSize = "page_size"
)
// reserved lists the parameters that never name a field.
var reserved = []string{
ParamText, ParamSort, ParamFilter, ParamPage, ParamSize,
}
// Parse builds a [Query] from raw query parameters, strictly: a sort key,
// operator, or operand outside the schema's vocabulary is refused rather
// than quietly dropped, so a caller with a typo learns about it. Parameters
// naming no declared field are ignored, since a query string carries more
// than one listing's business.
//
// The returned query is normalized. On failure the error maps each
// offending parameter to its violations.
//
// Inside a handler, prefer [Schema.Bind], which wraps the failure into the
// HTTP error contract.
func (s Schema) Parse(values url.Values) (Query, valid.Error) {
v := valid.New()
q := Query{Text: strings.TrimSpace(values.Get(ParamText))}
v.MaxLen(ParamText, q.Text, MaxText)
q.Page = number(v, values, ParamPage)
q.Size = number(v, values, ParamSize)
q.Validate(v)
q.Sort = s.sorts(v, values.Get(ParamSort))
q.Filters = s.filters(v, values)
if err := v.Error(); err != nil {
return Query{}, err
}
return s.Normalize(q), nil
}
// sorts parses the ordering: a comma-separated list of fields, each
// optionally prefixed with a direction.
func (s Schema) sorts(v *valid.Validator, raw string) []Sort {
if strings.TrimSpace(raw) == "" {
return nil
}
var out []Sort
for term := range strings.SplitSeq(raw, ",") {
// A literal "+" arrives as a space, having been decoded as one, so
// trimming is what makes the ascending prefix work at all.
term = strings.TrimSpace(term)
desc := false
switch {
case strings.HasPrefix(term, "-"):
desc, term = true, term[1:]
case strings.HasPrefix(term, "+"):
term = term[1:]
}
switch {
case term == "":
v.Fail(ParamSort, "names an empty field")
case !slices.Contains(s.Sorts, term):
v.Fail(ParamSort, fmt.Sprintf("cannot sort by %q", term))
case slices.ContainsFunc(out, func(o Sort) bool {
return o.Field == term
}):
v.Fail(ParamSort, fmt.Sprintf("repeats the field %q", term))
default:
out = append(out, Sort{Field: term, Desc: desc})
}
}
return out
}
// filters parses every constraint: the declared fields addressed by their
// own parameter, plus the triples carried by ParamFilter.
func (s Schema) filters(v *valid.Validator, values url.Values) []Constraint {
var out []Constraint
// Ranging over the schema rather than the parameters keeps the result
// independent of query-string order, and skips the unrelated
// parameters a URL collects.
for _, name := range slices.Sorted(maps.Keys(s.Fields)) {
if slices.Contains(reserved, name) {
// The field is unreachable under its own name; ParamFilter is
// how a caller addresses it. Refusing here would reject a
// legitimate schema at request time rather than at review.
continue
}
for _, raw := range values[name] {
if c, ok := s.constraint(v, name, name, raw); ok {
out = append(out, c)
}
}
}
for _, raw := range values[ParamFilter] {
name, rest, ok := strings.Cut(raw, ":")
if !ok {
v.Fail(ParamFilter, fmt.Sprintf(
"%q is not field:operator:operand", raw,
))
continue
}
if c, ok := s.constraint(v, ParamFilter, name, rest); ok {
out = append(out, c)
}
}
return out
}
// constraint parses one "operator:operand" value against the named field,
// recording violations under the parameter that carried it.
func (s Schema) constraint(
v *valid.Validator,
param, name, raw string,
) (Constraint, bool) {
f, ok := s.Fields[name]
if !ok {
// Only reachable through ParamFilter, which names its field
// explicitly: the caller meant to filter on something this listing
// does not offer.
v.Fail(param, fmt.Sprintf("unknown field %q", name))
return Constraint{}, false
}
prefix, operand, ok := strings.Cut(raw, ":")
if !ok {
v.Fail(param, fmt.Sprintf(
"%q is missing an operator, as in %q", raw, "eq:"+raw,
))
return Constraint{}, false
}
op := Operator(prefix)
switch {
case !slices.Contains(Operators, op):
v.Fail(param, fmt.Sprintf("unknown operator %q", prefix))
return Constraint{}, false
case !f.accepts(op):
// The operator exists but this field will not take it, which is a
// different mistake from misspelling one and reads as such.
v.Fail(param, fmt.Sprintf(
"operator %q does not apply to %s fields", prefix, f.Kind,
))
return Constraint{}, false
}
c := Constraint{Field: name, Op: op, Kind: f.Kind}
if op != In && op != Nin {
val, ok := convert(v, param, f, operand)
if !ok {
return Constraint{}, false
}
c.Values = []any{val}
return c, true
}
// The list operators take at least one operand, so an empty list is a
// caller error rather than a constraint matching nothing.
if operand == "" {
v.Fail(param, fmt.Sprintf("operator %q needs a list", prefix))
return Constraint{}, false
}
for item := range strings.SplitSeq(operand, ",") {
val, ok := convert(v, param, f, item)
if !ok {
return Constraint{}, false
}
c.Values = append(c.Values, val)
}
return c, true
}
// convert turns one operand into a value of the field's kind.
func convert(
v *valid.Validator,
param string,
f Field,
raw string,
) (any, bool) {
raw = strings.TrimSpace(raw)
fail := func() (any, bool) {
v.Fail(param, fmt.Sprintf("%q is not a valid %s", raw, f.Kind))
return nil, false
}
switch f.Kind {
case Int:
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return fail()
}
return n, true
case Float:
x, err := strconv.ParseFloat(raw, 64)
if err != nil {
return fail()
}
return x, true
case Bool:
b, err := strconv.ParseBool(raw)
if err != nil {
return fail()
}
return b, true
case Time:
if t, err := time.Parse(time.RFC3339, raw); err == nil {
return t, true
}
t, err := time.Parse(time.DateOnly, raw)
if err != nil {
return fail()
}
return t, true
default:
if len(f.Values) > 0 && !slices.Contains(f.Values, raw) {
v.Fail(param, fmt.Sprintf(
"%q is not one of %s", raw, strings.Join(f.Values, ", "),
))
return nil, false
}
return raw, true
}
}
// number reads an integer parameter, recording a violation for a value
// that is not a whole number. Absent reads as zero, which the page bounds
// take as "use the default" — as does an explicit zero, the two being
// indistinguishable once bound.
func number(v *valid.Validator, values url.Values, name string) int {
raw := values.Get(name)
if raw == "" {
return 0
}
n, err := strconv.Atoi(raw)
if err != nil {
v.Fail(name, "must be a whole number")
return 0
}
return n
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package search
import (
"slices"
"strings"
"time"
"github.com/deep-rent/nexus/dat/page"
"github.com/deep-rent/nexus/std/text"
)
// MaxText bounds the free-text term. No store matches on more than a
// handful of characters in practice, so a longer one is a caller building
// a pattern for the database to chew on rather than a search.
const MaxText = 128
// Kind classifies the values a field accepts, deciding both how an operand
// is parsed off the wire and which operators apply to it.
type Kind uint8
const (
// String is free-form text, compared literally or by pattern.
String Kind = iota
// Int is a whole number, parsed into an int64.
Int
// Float is a decimal number, parsed into a float64.
Float
// Bool is "true" or "false" (and the 1/0, t/f spellings
// [strconv.ParseBool] accepts), parsed into a bool.
Bool
// Time is an instant in RFC 3339, or a bare "2006-01-02" date read as
// midnight UTC, parsed into a [time.Time].
Time
)
// String implements [fmt.Stringer].
func (k Kind) String() string {
switch k {
case Int:
return "integer"
case Float:
return "number"
case Bool:
return "boolean"
case Time:
return "timestamp"
default:
return "string"
}
}
// Operator is the comparison a filter applies, written as the prefix of a
// parameter value: "price=gte:50" holds the operator [Gte].
type Operator string
const (
// Eq keeps records whose field equals the operand.
Eq Operator = "eq"
// Ne keeps records whose field differs from the operand.
Ne Operator = "ne"
// Gt keeps records whose field is greater than the operand.
Gt Operator = "gt"
// Gte keeps records whose field is greater than or equal to it.
Gte Operator = "gte"
// Lt keeps records whose field is less than the operand.
Lt Operator = "lt"
// Lte keeps records whose field is less than or equal to it.
Lte Operator = "lte"
// In keeps records whose field equals one of a comma-separated list.
// An operand containing a comma cannot be written this way; use
// repeated [Eq] parameters, or [Ne], for such a value.
In Operator = "in"
// Nin keeps records whose field equals none of that list.
Nin Operator = "nin"
// Like keeps records whose field matches a pattern, where "*" stands
// for any run of characters and "?" for a single one. String fields
// only; see [Constraint.Pattern].
Like Operator = "like"
)
// Operators lists every operator, most specific first for no reason
// beyond reading order. It exists so that a misspelled operator can be
// reported as unknown rather than as inapplicable.
var Operators = []Operator{Eq, Ne, Gt, Gte, Lt, Lte, In, Nin, Like}
// operators lists the operators each kind accepts. Ordering beyond
// equality needs an ordered domain, and patterns need text.
var operators = map[Kind][]Operator{
String: {Eq, Ne, In, Nin, Like},
Int: {Eq, Ne, Gt, Gte, Lt, Lte, In, Nin},
Float: {Eq, Ne, Gt, Gte, Lt, Lte, In, Nin},
Bool: {Eq, Ne},
Time: {Eq, Ne, Gt, Gte, Lt, Lte, In, Nin},
}
// Field declares one filterable field of a listing.
type Field struct {
// Kind is the value domain of the field. The zero value is [String].
Kind Kind
// Ops restricts the operators the field accepts. Empty allows every
// operator its kind supports.
Ops []Operator
// Values closes a [String] field over a fixed set, refusing operands
// outside it — an enumeration. Empty accepts any text.
Values []string
}
// accepts reports whether the field admits the operator.
func (f Field) accepts(op Operator) bool {
if !slices.Contains(operators[f.Kind], op) {
return false
}
return len(f.Ops) == 0 || slices.Contains(f.Ops, op)
}
// Sort is one key of an ordering: a field and a direction.
type Sort struct {
// Field is the field to sort by, one of the schema's Sorts.
Field string
// Desc sorts from the largest value down rather than the smallest up.
Desc bool
}
// String renders the sort in wire form: "+field" or "-field".
func (s Sort) String() string {
if s.Desc {
return "-" + s.Field
}
return "+" + s.Field
}
// Constraint is one parsed filter: a field, an operator, and the operands
// the operator compares against — one for most, a list for [In] and [Nin].
// Operands are already converted to the field's kind, so a store binds
// [Constraint.Values] straight into a query.
type Constraint struct {
// Field is the field being constrained.
Field string
// Op is the comparison to apply.
Op Operator
// Kind is the value domain the operands were parsed into.
Kind Kind
// Values holds the operands: exactly one, except under [In] and [Nin],
// which take at least one.
Values []any
}
// Value returns the sole operand, or the first under [In] and [Nin]. It
// returns nil for a constraint carrying none, which parsing never produces.
func (c Constraint) Value() any {
if len(c.Values) == 0 {
return nil
}
return c.Values[0]
}
// Text returns the operand of a [String] constraint, empty otherwise.
func (c Constraint) Text() string {
s, _ := c.Value().(string)
return s
}
// Int returns the operand of an [Int] constraint, zero otherwise.
func (c Constraint) Int() int64 {
n, _ := c.Value().(int64)
return n
}
// Float returns the operand of a [Float] constraint, zero otherwise.
func (c Constraint) Float() float64 {
f, _ := c.Value().(float64)
return f
}
// Bool returns the operand of a [Bool] constraint, false otherwise.
func (c Constraint) Bool() bool {
b, _ := c.Value().(bool)
return b
}
// Time returns the operand of a [Time] constraint, the zero instant
// otherwise.
func (c Constraint) Time() time.Time {
t, _ := c.Value().(time.Time)
return t
}
// Pattern renders a [Like] operand as a SQL LIKE pattern: "*" becomes "%"
// and "?" becomes "_", while a literal %, _ or escape character in the
// operand is escaped so it matches itself. Pair it with an ESCAPE clause
// naming the same character; backslash is the usual choice, and the one
// PostgreSQL assumes by default.
func (c Constraint) Pattern(escape byte) string {
raw := c.Text()
var sb strings.Builder
sb.Grow(len(raw))
for i := range len(raw) {
switch b := raw[i]; b {
case '*':
sb.WriteByte('%')
case '?':
sb.WriteByte('_')
case '%', '_', escape:
sb.WriteByte(escape)
sb.WriteByte(b)
default:
sb.WriteByte(b)
}
}
return sb.String()
}
// Escape neutralizes the LIKE wildcards in a literal string, so that a %
// or _ the user typed matches itself instead of anything. The escape
// character is escaped too, and must be named in an ESCAPE clause on the
// comparison — [Compiler.Contains] emits one.
//
// Unlike [Constraint.Pattern] it assigns no meaning to * and ?: the input
// is a literal, not a glob. It is what a free-text term needs, which a
// store matches as a plain substring.
func Escape(s string, escape byte) string {
if !strings.ContainsAny(s, string([]byte{'%', '_', escape})) {
return s
}
var sb strings.Builder
sb.Grow(len(s) + 4)
for i := range len(s) {
switch b := s[i]; b {
case '%', '_', escape:
sb.WriteByte(escape)
sb.WriteByte(b)
default:
sb.WriteByte(b)
}
}
return sb.String()
}
// Query addresses one page of a searchable listing: what a store executes.
// The zero value asks for the first page of everything at the defaults of
// the schema it is normalized against.
//
// A Query built in Go rather than parsed off a request may name anything;
// stores clamp it with [Schema.Normalize] before use.
//
// Do not bind a Query with [router.Exchange.BindQuery]: it would pick up
// the embedded page parameters and silently ignore every filter and sort,
// which no struct tag can express. [Schema.Bind] is the way in.
type Query struct {
page.Params
// Text is the free-text term, matched against whichever fields the
// store considers searchable. Empty matches every record.
Text string
// Sort orders the listing, most significant key first. Empty leaves
// the order to the schema, and then to the store.
Sort []Sort
// Filters are the constraints the records must satisfy, conjoined.
Filters []Constraint
}
// Filter returns the first constraint on the field, and whether there is
// one. Use [Query.FilterAll] where a field may carry several, as the two
// ends of a range do.
func (q Query) Filter(field string) (Constraint, bool) {
for _, c := range q.Filters {
if c.Field == field {
return c, true
}
}
return Constraint{}, false
}
// FilterAll returns every constraint on the field, in the order they were
// written.
func (q Query) FilterAll(field string) []Constraint {
var out []Constraint
for _, c := range q.Filters {
if c.Field == field {
out = append(out, c)
}
}
return out
}
// Schema declares the searchable surface of one listing. It is static
// declarative data, built as a literal next to the store it describes, and
// shared by the endpoint that parses requests against it and the store
// that trusts a [Query] normalized by it.
type Schema struct {
// Sorts lists the fields the listing may be sorted by. Empty means the
// listing is not sortable and every requested sort is refused.
Sorts []string
// Order is the ordering applied when a query requests none. Empty
// leaves the order to the store.
Order []Sort
// Fields declares the filterable fields by name. A parameter naming
// anything else is ignored, since a query string carries more than
// this listing's business.
Fields map[string]Field
}
// Normalize returns the query clamped to the schema: page bounds resolved,
// the free-text term trimmed and bounded, sorts and filters over fields the
// schema does not declare dropped, along with operators their fields do not
// accept. Stores call it before building a query, so that a field name can
// never be anything but one the schema declared — the reason a store may
// map it onto a column without escaping it.
//
// Normalize checks names and operators, not operands: a [Query] parsed off
// the wire has already had its operands converted and validated, and one
// built in Go is the programmer's own business.
func (s Schema) Normalize(q Query) Query {
q.Text = text.Fit(q.Text, MaxText)
q.Sort = slices.DeleteFunc(slices.Clone(q.Sort), func(o Sort) bool {
return !slices.Contains(s.Sorts, o.Field)
})
if len(q.Sort) == 0 {
q.Sort = slices.Clone(s.Order)
}
q.Filters = slices.DeleteFunc(
slices.Clone(q.Filters),
func(c Constraint) bool {
f, ok := s.Fields[c.Field]
return !ok || !f.accepts(c.Op) || len(c.Values) == 0
},
)
return q
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package search
import (
"strconv"
"strings"
)
// DefaultEscape is the character [Compiler] escapes LIKE wildcards with
// unless told otherwise. Backslash is what PostgreSQL assumes by default.
const DefaultEscape = '\\'
// Compiler renders a [Query] as SQL fragments. It maps the schema's field
// names onto the columns behind them, which is the only place the two
// vocabularies meet: a name absent from Columns contributes nothing, so a
// caller-supplied string can never reach the statement.
//
// The fragments are ANSI SQL and carry no leading keyword, leaving a store
// free to splice them into a larger statement:
//
// where, args := c.Where(q, 1)
// rows, err := db.QueryContext(ctx, `
// SELECT `+columns+` FROM products
// WHERE `+where+`
// `+c.OrderBy(q, "id")+`
// LIMIT $`+..., args...)
type Compiler struct {
// Columns maps each field of the schema onto its column. Fields it
// omits are silently skipped, so a store may expose fewer columns than
// the schema declares.
Columns map[string]string
// Placeholder renders the n-th bind parameter, 1-based. Defaults to
// PostgreSQL's "$n"; pass a function returning "?" for the drivers
// that count positionally.
Placeholder func(n int) string
// Escape is the character LIKE patterns escape wildcards with, named
// in the ESCAPE clause the compiler emits. Defaults to
// [DefaultEscape].
//
// Backslash is the natural choice under standard SQL, where it is an
// ordinary character inside a string literal. On a database that
// treats it as an escape within literals — MySQL, or PostgreSQL with
// standard_conforming_strings off — the emitted ESCAPE '\' would not
// terminate; pick a character such as '!' there.
Escape byte
}
// escape returns the configured LIKE escape character.
func (c Compiler) escape() byte {
if c.Escape != 0 {
return c.Escape
}
return DefaultEscape
}
// placeholder renders the n-th bind parameter.
func (c Compiler) placeholder(n int) string {
if c.Placeholder == nil {
return "$" + strconv.Itoa(n)
}
return c.Placeholder(n)
}
// Where renders the query's filters as a boolean expression, along with the
// arguments its placeholders bind. Numbering starts at next, so a store
// that has already bound arguments of its own passes the count after them.
//
// Filters are conjoined. A query carrying none yields "TRUE", which keeps
// the caller's WHERE clause well-formed without a special case.
//
// Note that [Ne] and [Nin] compile to SQL's own <> and NOT IN, which are
// unknown rather than true for a NULL column, so records whose field is
// null satisfy neither. Give a nullable column a default, or filter it
// through a predicate of the store's own, where that is the wrong answer.
func (c Compiler) Where(q Query, next int) (string, []any) {
var sb strings.Builder
var args []any
for _, f := range q.Filters {
column, ok := c.Columns[f.Field]
// A constraint carrying no operand would compile to a comparison
// against NULL, which silently matches nothing. Parsing never
// produces one and Normalize drops them, so this only catches a
// hand-built query that skipped both.
if !ok || len(f.Values) == 0 {
continue
}
if sb.Len() > 0 {
sb.WriteString(" AND ")
}
sb.WriteString(column)
switch f.Op {
case In, Nin:
if f.Op == Nin {
sb.WriteString(" NOT")
}
sb.WriteString(" IN (")
for i, v := range f.Values {
if i > 0 {
sb.WriteString(", ")
}
sb.WriteString(c.placeholder(next))
args = append(args, v)
next++
}
sb.WriteString(")")
case Like:
esc := c.escape()
sb.WriteString(" LIKE ")
sb.WriteString(c.placeholder(next))
sb.WriteString(" ESCAPE '")
sb.WriteByte(esc)
sb.WriteString("'")
args = append(args, f.Pattern(esc))
next++
default:
sb.WriteString(" ")
sb.WriteString(comparison(f.Op))
sb.WriteString(" ")
sb.WriteString(c.placeholder(next))
args = append(args, f.Value())
next++
}
}
if sb.Len() == 0 {
return "TRUE", nil
}
return sb.String(), args
}
// comparison renders a scalar operator. It is only reached for the
// operators the default branch of Where handles, all of which map onto a
// SQL comparison.
func comparison(op Operator) string {
switch op {
case Ne:
return "<>"
case Gt:
return ">"
case Gte:
return ">="
case Lt:
return "<"
case Lte:
return "<="
default:
return "="
}
}
// Term prepares a free-text term for binding against the predicate
// [Compiler.Contains] renders, neutralizing the LIKE wildcards in it with
// the compiler's own escape character.
//
// Pair the two: preparing a term by hand risks escaping with a different
// character than the ESCAPE clause names, and the mismatch is silent — a
// term containing % or _ then matches the wrong records rather than
// failing.
func (c Compiler) Term(s string) string {
return Escape(s, c.escape())
}
// Contains renders a predicate matching the free-text term bound at
// placeholder n as a substring of any of the given columns, with the
// ESCAPE clause the compiler's escape character calls for. Bind the term
// through [Compiler.Term].
//
// An empty term matches every record, per [Query.Text], so the predicate
// stays well-formed for a query that carries none. Without columns it
// yields "TRUE", matching how [Compiler.Where] handles an empty query.
//
// Columns are named by the store rather than looked up in Columns: which
// columns a free-text term covers is the store's business — one listing
// searches a name, another a name and an address — and unlike a filter
// field, the term's reach is not part of the schema's vocabulary. Pass
// literal column names only; a value that reached this from a request
// would be spliced into the statement.
func (c Compiler) Contains(n int, columns ...string) string {
if len(columns) == 0 {
return "TRUE"
}
p := c.placeholder(n)
var sb strings.Builder
sb.WriteString("(")
sb.WriteString(p)
sb.WriteString(" = ''")
for _, column := range columns {
sb.WriteString(" OR ")
sb.WriteString(column)
sb.WriteString(" LIKE '%' || ")
sb.WriteString(p)
sb.WriteString(" || '%' ESCAPE '")
sb.WriteByte(c.escape())
sb.WriteString("'")
}
sb.WriteString(")")
return sb.String()
}
// OrderBy renders the query's ordering as an ORDER BY clause, breaking ties
// on the given column — pass a unique one, typically the row identifier, so
// that records sharing a sort value keep a stable relative order across
// pages instead of drifting between requests.
//
// It returns the empty string when the query names no ordering the columns
// cover and no tiebreaker is given, leaving the order to the database.
func (c Compiler) OrderBy(q Query, tie string) string {
var sb strings.Builder
desc := false
for _, o := range q.Sort {
column, ok := c.Columns[o.Field]
if !ok {
continue
}
if sb.Len() > 0 {
sb.WriteString(", ")
}
sb.WriteString(column)
if o.Desc {
sb.WriteString(" DESC")
} else {
sb.WriteString(" ASC")
}
desc = o.Desc
}
if tie != "" {
if sb.Len() > 0 {
sb.WriteString(", ")
}
// The tiebreaker follows the direction of the least significant
// key, so that paging forward walks the listing in one direction
// throughout.
sb.WriteString(tie)
if desc {
sb.WriteString(" DESC")
} else {
sb.WriteString(" ASC")
}
}
if sb.Len() == 0 {
return ""
}
return "ORDER BY " + sb.String()
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package valid
// The ISO code tables consulted by [Country2], [Country3], [CountryN],
// and [Currency]. Each is a sorted run of fixed-width codes, one space
// apart, searched by [assigned] — a binary search over a constant needs
// no map, no init, and no allocation.
//
// # Refreshing
//
// The codes change rarely, and never quietly: a country is admitted or
// renamed, a currency is redenominated or replaced by the euro. When
// that happens, edit the table by hand and let the tests catch a slip —
// they assert that every table is sorted, uniform, and unique, which is
// what the search depends on.
//
// The country tables were taken from ISO 3166-1 as of 2026-08-12 and
// hold the 249 officially assigned entries. Codes that ISO withdrew
// (AN, CS, NT, SU, YU), exceptionally reserved codes that were never
// assigned (AC, CP, DG, EA, EZ, IC, TA, UN and their kin), and aliases
// that are not codes at all (UK for GB) are all absent — accepting them
// would mean storing a country nobody can look up.
//
// The currency table was taken from the ISO 4217 maintenance agency's
// list published 2026-01-01, which carries 178 active codes. Fourteen
// are omitted here because they name no currency: the metals (XAU, XAG,
// XPT, XPD), the bond-market units (XBA, XBB, XBC, XBD), the units of
// account (XAD, XDR, XSU, XUA), and the placeholders (XTS for testing,
// XXX for "no currency"). The X codes that DO name a currency — XAF and
// XOF for the two CFA francs, XPF for the CFP franc, XCD for the East
// Caribbean dollar, XCG for the Caribbean guilder — are kept, since
// some twenty countries spend them.
// countryAlpha2 holds the ISO 3166-1 alpha-2 codes.
const countryAlpha2 = "" +
"AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE " +
"BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ CA CC CD " +
"CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM " +
"DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR GA GB GD GE GF " +
"GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU " +
"ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG KH KI KM KN " +
"KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME " +
"MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ NA " +
"NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM " +
"PN PR PS PT PW PY QA RE RO RS RU RW SA SB SC SD SE SG SH SI " +
"SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK " +
"TL TM TN TO TR TT TV TW TZ UA UG UM US UY UZ VA VC VE VG VI " +
"VN VU WF WS YE YT ZA ZM ZW "
// countryAlpha3 holds the ISO 3166-1 alpha-3 codes.
const countryAlpha3 = "" +
"ABW AFG AGO AIA ALA ALB AND ARE ARG ARM ASM ATA ATF ATG AUS " +
"AUT AZE BDI BEL BEN BES BFA BGD BGR BHR BHS BIH BLM BLR BLZ " +
"BMU BOL BRA BRB BRN BTN BVT BWA CAF CAN CCK CHE CHL CHN CIV " +
"CMR COD COG COK COL COM CPV CRI CUB CUW CXR CYM CYP CZE DEU " +
"DJI DMA DNK DOM DZA ECU EGY ERI ESH ESP EST ETH FIN FJI FLK " +
"FRA FRO FSM GAB GBR GEO GGY GHA GIB GIN GLP GMB GNB GNQ GRC " +
"GRD GRL GTM GUF GUM GUY HKG HMD HND HRV HTI HUN IDN IMN IND " +
"IOT IRL IRN IRQ ISL ISR ITA JAM JEY JOR JPN KAZ KEN KGZ KHM " +
"KIR KNA KOR KWT LAO LBN LBR LBY LCA LIE LKA LSO LTU LUX LVA " +
"MAC MAF MAR MCO MDA MDG MDV MEX MHL MKD MLI MLT MMR MNE MNG " +
"MNP MOZ MRT MSR MTQ MUS MWI MYS MYT NAM NCL NER NFK NGA NIC " +
"NIU NLD NOR NPL NRU NZL OMN PAK PAN PCN PER PHL PLW PNG POL " +
"PRI PRK PRT PRY PSE PYF QAT REU ROU RUS RWA SAU SDN SEN SGP " +
"SGS SHN SJM SLB SLE SLV SMR SOM SPM SRB SSD STP SUR SVK SVN " +
"SWE SWZ SXM SYC SYR TCA TCD TGO THA TJK TKL TKM TLS TON TTO " +
"TUN TUR TUV TWN TZA UGA UKR UMI URY USA UZB VAT VCT VEN VGB " +
"VIR VNM VUT WLF WSM YEM ZAF ZMB ZWE "
// countryNumeric holds the ISO 3166-1 numeric codes.
const countryNumeric = "" +
"004 008 010 012 016 020 024 028 031 032 036 040 044 048 050 " +
"051 052 056 060 064 068 070 072 074 076 084 086 090 092 096 " +
"100 104 108 112 116 120 124 132 136 140 144 148 152 156 158 " +
"162 166 170 174 175 178 180 184 188 191 192 196 203 204 208 " +
"212 214 218 222 226 231 232 233 234 238 239 242 246 248 250 " +
"254 258 260 262 266 268 270 275 276 288 292 296 300 304 308 " +
"312 316 320 324 328 332 334 336 340 344 348 352 356 360 364 " +
"368 372 376 380 384 388 392 398 400 404 408 410 414 417 418 " +
"422 426 428 430 434 438 440 442 446 450 454 458 462 466 470 " +
"474 478 480 484 492 496 498 499 500 504 508 512 516 520 524 " +
"528 531 533 534 535 540 548 554 558 562 566 570 574 578 580 " +
"581 583 584 585 586 591 598 600 604 608 612 616 620 624 626 " +
"630 634 638 642 643 646 652 654 659 660 662 663 666 670 674 " +
"678 682 686 688 690 694 702 703 704 705 706 710 716 724 728 " +
"729 732 740 744 748 752 756 760 762 764 768 772 776 780 784 " +
"788 792 795 796 798 800 804 807 818 826 831 832 833 834 840 " +
"850 854 858 860 862 876 882 887 894 "
// currencyCodes holds the ISO 4217 codes that name a currency.
const currencyCodes = "" +
"AED AFN ALL AMD AOA ARS AUD AWG AZN BAM BBD BDT BHD BIF BMD " +
"BND BOB BOV BRL BSD BTN BWP BYN BZD CAD CDF CHE CHF CHW CLF " +
"CLP CNY COP COU CRC CUP CVE CZK DJF DKK DOP DZD EGP ERN ETB " +
"EUR FJD FKP GBP GEL GHS GIP GMD GNF GTQ GYD HKD HNL HTG HUF " +
"IDR ILS INR IQD IRR ISK JMD JOD JPY KES KGS KHR KMF KPW KRW " +
"KWD KYD KZT LAK LBP LKR LRD LSL LYD MAD MDL MGA MKD MMK MNT " +
"MOP MRU MUR MVR MWK MXN MXV MYR MZN NAD NGN NIO NOK NPR NZD " +
"OMR PAB PEN PGK PHP PKR PLN PYG QAR RON RSD RUB RWF SAR SBD " +
"SCR SDG SEK SGD SHP SLE SOS SRD SSP STN SVC SYP SZL THB TJS " +
"TMT TND TOP TRY TTD TWD TZS UAH UGX USD USN UYI UYU UYW UZS " +
"VED VES VND VUV WST XAF XCD XCG XOF XPF YER ZAR ZMW ZWG "
// assigned reports whether the table holds the code s. The table is a
// sorted run of codes of s's own width, each followed by one space, so
// entry i starts at i*(len(s)+1) and the search needs no scan for a
// separator.
//
// A code of a width the table does not hold can never match, which is
// what lets the shape checks in front of each caller stay the only
// length test.
func assigned(table, s string) bool {
// An empty code would compare equal to every zero-width slice the
// search cut, and match a table it is not in.
if s == "" {
return false
}
stride := len(s) + 1
lo, hi := 0, len(table)/stride
for lo < hi {
mid := int(uint(lo+hi) >> 1)
switch code := table[mid*stride : mid*stride+len(s)]; {
case code < s:
lo = mid + 1
case code > s:
hi = mid
default:
return true
}
}
return false
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package valid
import (
"encoding/json/jsontext"
"mime"
"net"
"net/netip"
"net/url"
"strings"
"sync"
"time"
"golang.org/x/mod/semver"
"github.com/deep-rent/nexus/std/ascii"
)
// CIDR checks if the string is a valid Classless Inter-Domain Routing (CIDR)
// block. A valid CIDR block is an IP address followed by a slash and a
// prefix length. The address may carry host bits set beyond the prefix, so
// "192.168.1.55/24" passes even though it does not name a network.
func CIDR(s string) bool {
_, err := netip.ParsePrefix(s)
return err == nil
}
// CIDRv4 checks if the string is a valid IPv4 CIDR block.
func CIDRv4(s string) bool {
p, err := netip.ParsePrefix(s)
return err == nil && p.Addr().Is4()
}
// CIDRv6 checks if the string is a valid IPv6 CIDR block.
func CIDRv6(s string) bool {
p, err := netip.ParsePrefix(s)
return err == nil && p.Addr().Is6()
}
// Hostname checks if the string is a valid hostname according to RFC 952 and
// RFC 1123. The hostname must be at most 253 characters long.
func Hostname(s string) bool {
return len(s) != 0 && len(s) <= 253 && rxHostname.MatchString(s)
}
// Port checks if the number represents a valid network port number.
// Port numbers must be between 1 and 65535 inclusive.
func Port(n int) bool {
return n > 0 && n <= 65535
}
// IP checks if the string is a valid IP address (either IPv4 or IPv6).
func IP(s string) bool {
_, err := netip.ParseAddr(s)
return err == nil
}
// IPv4 checks if the string is a valid IPv4 address.
func IPv4(s string) bool {
addr, err := netip.ParseAddr(s)
return err == nil && addr.Is4()
}
// IPv6 checks if the string is a valid IPv6 address.
func IPv6(s string) bool {
addr, err := netip.ParseAddr(s)
return err == nil && addr.Is6()
}
// FQDN checks if the string is a Fully Qualified Domain Name (FQDN).
// An FQDN must have at least one valid top-level domain. It allows for an
// optional trailing dot.
func FQDN(s string) bool {
return len(s) != 0 && len(s) <= 253 && rxFQDN.MatchString(s)
}
// URI checks if the string is an absolute URI or an absolute path, the two
// forms that may appear as an HTTP request target. Relative references like
// "./page" are rejected.
func URI(s string) bool {
_, err := url.ParseRequestURI(s)
return err == nil
}
// URL checks if the string is a valid URL with a scheme and host.
func URL(s string) bool {
u, err := url.ParseRequestURI(s)
return err == nil && u.Scheme != "" && u.Host != ""
}
// URN checks if the string is a valid URN (Uniform Resource Name) according to
// RFC 2141.
func URN(s string) bool {
return rxURN.MatchString(s)
}
// Alpha checks if the string contains only alphabetical characters (a-z, A-Z).
// An empty string returns true.
func Alpha(s string) bool {
return ascii.All(s, ascii.IsAlpha)
}
// AlphaNum checks if the string contains only alphanumeric characters (a-z,
// A-Z, 0-9). An empty string returns true.
func AlphaNum(s string) bool {
return ascii.All(s, ascii.IsAlphaNum)
}
// ASCII checks if the string contains only ASCII characters.
// An empty string returns true.
func ASCII(s string) bool {
return ascii.All(s, ascii.IsASCII)
}
// Slug checks if the string is a valid URL slug.
// A slug consists of lowercase letters, numbers, and hyphens, and cannot
// start or end with a hyphen or contain consecutive hyphens. Empty strings will
// be rejected.
func Slug(s string) bool {
if s == "" || s[0] == '-' || s[len(s)-1] == '-' {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
if ascii.IsLower(c) || ascii.IsDigit(c) {
continue
}
if c == '-' {
if s[i-1] == '-' {
return false
}
continue
}
return false
}
return true
}
// Topic checks if the string is a valid topic name. A topic consists of
// lowercase letters, digits, and underscores grouped into dot-separated
// segments, as event topics and job kinds are conventionally written:
// "user.deleted", "pdf.render". No segment may be empty, so a leading,
// trailing, or doubled dot is rejected, and so are empty strings.
//
// The shape is deliberately narrow: a topic ends up in table columns,
// metric tags, and URLs, where mixed case and punctuation invite
// mismatches that are hard to see.
func Topic(s string) bool {
if s == "" {
return false
}
segment := 0
for i := range len(s) {
c := s[i]
switch {
case ascii.IsLower(c), ascii.IsDigit(c), c == '_':
segment++
case c == '.':
if segment == 0 {
return false
}
segment = 0
default:
return false
}
}
return segment > 0
}
// Upper checks if the string contains only uppercase characters (A-Z).
// An empty string returns true.
func Upper(s string) bool {
return ascii.All(s, ascii.IsUpper)
}
// Lower checks if the string contains only lowercase characters (a-z).
// An empty string returns true.
func Lower(s string) bool {
return ascii.All(s, ascii.IsLower)
}
// Base64 checks if the string is a valid Base64 encoded string.
// It allows standard padding characters. An empty string returns true.
func Base64(s string) bool {
return rxBase64.MatchString(s)
}
// Base64URL checks if the string is a valid Base64URL encoded string.
// Padding characters are supported but optional. An empty string returns true.
func Base64URL(s string) bool {
return rxBase64URL.MatchString(s)
}
// MAC checks if the string is a valid IEEE 802 MAC address.
func MAC(s string) bool {
_, err := net.ParseMAC(s)
return err == nil
}
// Lang checks if the string is a well-formed BCP 47 language tag per the
// RFC 5646 grammar. It does not consult the IANA subtag registry, and the
// grandfathered registrations the RFC carries for legacy tags are not
// recognized.
func Lang(s string) bool {
return rxBCP47.MatchString(s)
}
// tzCache remembers zone names that resolved against the tz database, since
// [time.LoadLocation] reads and parses the zone file on every call. Only
// hits are cached, so the map stays bounded by the size of the database.
var tzCache sync.Map
// Timezone checks if the string is a valid IANA Time Zone Database name
// such as "Europe/Berlin" or "UTC". The check consults the tz database
// available to the process: the system's copy, or the embedded one when the
// program imports [time/tzdata]. The empty string and the special name
// "Local" are rejected, since neither names a concrete zone. Names that
// resolve are cached, so repeated checks skip the database.
func Timezone(s string) bool {
if s == "" || s == "Local" {
return false
}
if _, ok := tzCache.Load(s); ok {
return true
}
if _, err := time.LoadLocation(s); err != nil {
return false
}
tzCache.Store(s, struct{}{})
return true
}
// JSON checks if the string is a valid JSON document.
// It performs the check efficiently.
func JSON(s string) bool {
return jsontext.Value(s).IsValid()
}
// MIME checks if the string is a valid Media Type (MIME type) according to
// RFC 2045 and RFC 2046.
func MIME(s string) bool {
t, _, err := mime.ParseMediaType(s)
return err == nil && strings.Contains(t, "/")
}
// CreditCard checks if the string is a valid credit card number using the Luhn
// algorithm. It ignores whitespace and hyphens before calculating the checksum.
func CreditCard(s string) bool {
var (
sum int
cnt int
alt bool
)
for i := len(s) - 1; i >= 0; i-- {
c := s[i]
if c == ' ' || c == '-' {
continue
}
if c < '0' || c > '9' {
return false
}
n := int(c - '0')
if alt {
n *= 2
if n > 9 {
n -= 9
}
}
sum += n
cnt++
alt = !alt
}
return cnt >= 13 && cnt <= 19 && sum%10 == 0
}
// Email checks if the string is a valid email address according to the W3C
// HTML5 specification.
func Email(s string) bool {
return rxEmail.MatchString(s)
}
// Hex checks if the string is a valid hexadecimal number.
// The string may optionally be prefixed with "0x" or "0X".
func Hex(s string) bool {
if len(s) > 2 && s[0] == '0' && (s[1] == 'x' || s[1] == 'X') {
s = s[2:]
}
return ascii.All(s, ascii.IsHex)
}
// HexColor checks if the string is a valid hex color code.
// The string may optionally be prefixed with "#". It must be exactly 3, 4, 6,
// or 8 hexadecimal characters long, covering the RGB, RGBA, RRGGBB, and
// RRGGBBAA notations.
func HexColor(s string) bool {
if len(s) > 0 && s[0] == '#' {
s = s[1:]
}
switch len(s) {
case 3, 4, 6, 8:
return ascii.All(s, ascii.IsHex)
default:
return false
}
}
// ISSN checks if the string is a valid International Standard Serial Number
// (ISSN).
func ISSN(s string) bool {
if len(s) != 9 || s[4] != '-' {
return false
}
var sum int
weight := 8
for i := range 8 {
if i == 4 {
continue
}
c := s[i]
if !ascii.IsDigit(c) {
return false
}
sum += int(c-'0') * weight
weight--
}
switch c := s[8]; {
case c == 'X':
sum += 10
case ascii.IsDigit(c):
sum += int(c - '0')
default:
return false
}
return sum%11 == 0
}
// ISBN10 checks if the string is a valid ISBN-10.
// It strips hyphens before validation.
func ISBN10(s string) bool {
var n int
var sum int
for i := 0; i < len(s); i++ {
c := s[i]
if c == '-' {
continue
}
if n == 9 && c == 'X' {
sum += 10 * (10 - n)
n++
continue
}
if !ascii.IsDigit(c) {
return false
}
sum += int(c-'0') * (10 - n)
n++
}
return n == 10 && sum%11 == 0
}
// ISBN13 checks if the string is a valid ISBN-13.
// It strips hyphens before validation.
func ISBN13(s string) bool {
var n int
var sum int
for i := 0; i < len(s); i++ {
c := s[i]
if c == '-' {
continue
}
if !ascii.IsDigit(c) {
return false
}
v := int(c - '0')
if n%2 == 0 {
sum += v
} else {
sum += v * 3
}
n++
}
return n == 13 && sum%10 == 0
}
// ISBN checks if the string is a valid ISBN (10 or 13).
func ISBN(s string) bool {
return ISBN10(s) || ISBN13(s)
}
// Country2 checks if the string is an ISO 3166-1 alpha-2 country code
// (e.g., "US"): two uppercase ASCII letters naming an officially
// assigned entry. Codes of the right shape that ISO never assigned
// ("XX"), withdrew ("AN", "SU"), or only reserved ("UK", which is not a
// country code — "GB" is) are rejected.
func Country2(s string) bool {
return len(s) == 2 && ascii.All(s, ascii.IsUpper) &&
assigned(countryAlpha2, s)
}
// Country3 checks if the string is an ISO 3166-1 alpha-3 country code
// (e.g., "USA"): three uppercase ASCII letters naming an officially
// assigned entry. Unassigned and withdrawn codes are rejected, as in
// [Country2].
func Country3(s string) bool {
return len(s) == 3 && ascii.All(s, ascii.IsUpper) &&
assigned(countryAlpha3, s)
}
// CountryN checks if the string is an ISO 3166-1 numeric country code
// (e.g., "840"): three ASCII digits naming an officially assigned
// entry, leading zeros included ("004", not "4"). Unassigned and
// withdrawn codes are rejected, as in [Country2].
func CountryN(s string) bool {
return len(s) == 3 && ascii.All(s, ascii.IsDigit) &&
assigned(countryNumeric, s)
}
// Currency checks if the string is an ISO 4217 currency code (e.g.,
// "EUR", "USD"): three uppercase ASCII letters naming a currency in
// current use. Codes withdrawn on redenomination or on joining the euro
// ("DEM", "HRK", "VEF") are rejected, and so are the ISO 4217 entries
// that name something other than a currency — the precious metals, the
// bond-market units, the units of account, and the "XXX" placeholder.
// The X codes that DO name a currency ("XAF", "XCD", "XCG", "XOF",
// "XPF") are accepted; see the table's own documentation.
func Currency(s string) bool {
return len(s) == 3 && ascii.All(s, ascii.IsUpper) &&
assigned(currencyCodes, s)
}
// UUID checks if the string is a Version 4 or 7 UUID as defined in RFC
// 4122 and RFC 9562, written in the canonical 8-4-4-4-12 hyphenated form.
// Hex digits may be either case; the braced, URN, and compact forms are
// rejected.
func UUID(s string) bool {
if len(s) != 36 {
return false
}
for i := 0; i < len(s); i++ {
switch i {
case 8, 13, 18, 23:
if s[i] != '-' {
return false
}
default:
if !ascii.IsHex(s[i]) {
return false
}
}
}
if s[14] != '4' && s[14] != '7' {
return false
}
// The variant nibble must read 10xx binary: 8, 9, a, or b.
switch s[19] {
case '8', '9', 'a', 'b', 'A', 'B':
return true
default:
return false
}
}
// Lat checks if the number is a valid latitude coordinate (-90 to 90).
func Lat(f float64) bool {
return f >= -90 && f <= 90
}
// Lon checks if the number is a valid longitude coordinate (-180 to 180).
func Lon(f float64) bool {
return f >= -180 && f <= 180
}
// MD5 checks if the string is a valid MD5 hash (32 hex characters).
func MD5(s string) bool {
return isHash(s, 32)
}
// SHA256 checks if the string is a valid SHA256 hash (64 hex characters).
func SHA256(s string) bool {
return isHash(s, 64)
}
// SHA384 checks if the string is a valid SHA384 hash (96 hex characters).
func SHA384(s string) bool {
return isHash(s, 96)
}
// SHA512 checks if the string is a valid SHA512 hash (128 hex characters).
func SHA512(s string) bool {
return isHash(s, 128)
}
// SemVer checks if the string is a valid Semantic Versioning 2.0.0 string.
// Note that the "v" prefix is mandatory.
func SemVer(s string) bool {
return semver.IsValid(s)
}
// Phone checks if the string is a valid E.164 formatted phone number.
// The string must start with a '+' and be followed by 2 to 15 digits.
func Phone(s string) bool {
if len(s) < 3 || len(s) > 16 || s[0] != '+' || s[1] < '1' || s[1] > '9' {
return false
}
for i := 2; i < len(s); i++ {
if !ascii.IsDigit(s[i]) {
return false
}
}
return true
}
// BIC checks if the string is a valid Business Identifier Code (ISO 9362).
func BIC(s string) bool {
return rxBIC.MatchString(s)
}
// IBAN checks if the string is a valid International Bank Account Number.
// It ignores spaces and performs the modulo 97 check.
func IBAN(s string) bool {
var b [34]byte
var n int
for i := 0; i < len(s); i++ {
c := s[i]
if c == ' ' {
continue
}
if n >= 34 || !ascii.IsAlphaNum(c) {
return false
}
b[n] = c
n++
}
if n < 15 {
return false
}
if !ascii.IsAlpha(b[0]) ||
!ascii.IsAlpha(b[1]) ||
!ascii.IsDigit(b[2]) ||
!ascii.IsDigit(b[3]) {
return false
}
var rem int
// Modulo 97 check: move first 4 characters to the end.
for i := 4; i < n; i++ {
rem = mod97(rem, b[i])
}
for i := range 4 {
rem = mod97(rem, b[i])
}
return rem == 1
}
// isHash reports whether the string has the specified length and consists
// entirely of hexadecimal characters.
func isHash(s string, n int) bool {
return len(s) == n && ascii.All(s, ascii.IsHex)
}
// mod97 updates the running remainder for a large numeric string using the
// modulo 97 operation. A letter is treated as a two-digit number (A=10, ...,
// Z=35) per the ISO 13616 standard for IBANs.
func mod97(rem int, c byte) int {
var n, k int
if ascii.IsDigit(c) {
n = rem * 10
k = int(c - '0')
} else {
n = rem * 100
k = int(ascii.Upper(c) - 'A' + 10)
}
return (n + k) % 97
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package valid
import (
"fmt"
"maps"
"reflect"
"regexp"
"slices"
"strings"
"time"
"unicode/utf8"
)
// Violation is a single constraint failure. Code identifies the violated
// constraint in a stable, machine-readable form, Text carries the English
// message rendered by the check, and Args holds the parameters of the
// constraint (such as the bound of a length check), so API clients can
// compose their own message without parsing Text.
type Violation struct {
Code Code `json:"code"`
Text string `json:"text"`
Args map[string]any `json:"args,omitzero"`
}
// Error represents a collection of validation violations mapped by their
// corresponding field paths in dot notation. It naturally serializes to
// JSON, making it ideal for API error responses.
type Error map[string][]Violation
// Single builds an [Error] carrying one [CodeCustom] violation, a
// convenience for constructing ad-hoc errors outside a [Validator].
func Single(field, msg string) Error {
return Error{field: {{Code: CodeCustom, Text: msg}}}
}
// Size counts the total number of individual constraint violations.
func (e Error) Size() int {
n := 0
for _, msgs := range e {
n += len(msgs)
}
return n
}
// Error implements the [error] interface, providing a consolidated string
// representation of all validation failures. Fields are listed in
// lexicographic path order, so the same violations always render the same
// message.
func (e Error) Error() string {
if len(e) == 0 {
return "validation failed"
}
var sb strings.Builder
sb.WriteString("validation failed: ")
for i, path := range slices.Sorted(maps.Keys(e)) {
if i > 0 {
sb.WriteString("; ")
}
sb.WriteString(path)
sb.WriteString(": ")
for j, viol := range e[path] {
if j > 0 {
sb.WriteString(", ")
}
sb.WriteString(viol.Text)
}
}
return sb.String()
}
// Validatable describes a structure that can self-validate using a [Validator].
// It is typically implemented by API DTOs and request payloads.
type Validatable interface {
// Validate executes validation logic on the object using the provided
// [Validator]. It records any detected failures in the validator.
Validate(v *Validator)
}
// Test validates a single [Validatable] instance or a slice of them.
// It returns a composite error if any validation checks fail, or nil if
// all checks pass.
//
// Every failure is an [Error], but the declared type is the plain error
// interface: a caller that returns the result unconditionally from a
// function returning error would otherwise hand back a non-nil interface
// wrapping a nil map. Reach for the concrete type with [errors.AsType]
// when a caller inspects the individual violations.
func Test(target any) error {
if t, ok := target.(Validatable); ok && !isNil(t) {
v := New()
t.Validate(v)
// Error returns the concrete Error type, so handing it straight
// to the interface would produce a non-nil error for input that
// is in fact valid.
err := v.Error()
if err == nil {
return nil
}
return err
}
rt := reflect.TypeOf(target)
if rt != nil && rt.Kind() == reflect.Slice {
v := New()
v.Each("", target)
if err := v.Error(); err != nil {
return err
}
}
return nil
}
// Each validates every element in a slice that implements the [Validatable]
// interface.
// It returns a composite error if any element fails validation, or nil if
// all elements are valid. Like [Test], failures are of type [Error] behind
// the plain error interface.
func Each(target any) error {
v := New()
v.Each("", target)
// See Test: the concrete return type must not reach the interface
// while it is nil.
err := v.Error()
if err == nil {
return nil
}
return err
}
// Validator orchestrates the validation of fields, builds dot-notation paths
// for nested structures, and aggregates error messages.
//
// By package convention, every string check passes the empty string, so a
// field is optional by default: an absent value satisfies its format and
// length constraints without a guard at the call site. Require presence
// explicitly with [Validator.NotEmpty] or [Validator.NotBlank].
type Validator struct {
errs Error
path string
}
// New creates and returns a new empty [Validator].
func New() *Validator {
return &Validator{
errs: make(Error),
}
}
// Error returns the composite validation error if any checks failed, or nil
// if all checks passed.
func (v *Validator) Error() Error {
if len(v.errs) == 0 {
return nil
}
return v.errs
}
// Fail records an explicit error message against the given field. The
// violation carries [CodeCustom]; use [Validator.Report] to attach a
// specific code.
func (v *Validator) Fail(field, msg string) {
v.Report(field, Violation{Code: CodeCustom, Text: msg})
}
// Report records a fully specified [Violation] against the given field.
func (v *Validator) Report(field string, viol Violation) {
if v.errs == nil {
v.errs = make(Error)
}
p := v.join(field)
v.errs[p] = append(v.errs[p], viol)
}
// fail is the shorthand the built-in checks use to record their violations.
func (v *Validator) fail(
field string,
code Code,
text string,
args map[string]any,
) {
v.Report(field, Violation{Code: code, Text: text, Args: args})
}
// Test dives into a nested [Validatable] struct. It appends the field name
// to the current path, seamlessly propagating any validation errors using dot
// notation (e.g., "user.address" or "items[0].name"). A nil target is
// skipped, including a typed nil wrapped in the interface, so optional
// nested structs need no guard at the call site.
func (v *Validator) Test(field string, target Validatable) {
if target == nil || isNil(target) {
return
}
if v.errs == nil {
v.errs = make(Error)
}
sub := &Validator{
errs: v.errs,
path: v.join(field),
}
target.Validate(sub)
}
// Each iterates over a slice and validates each element that implements the
// [Validatable] interface. It automatically manages array indexing in the
// dot-notation path (e.g., "items[0]", "items[1]").
func (v *Validator) Each(field string, slice any) {
rv := reflect.ValueOf(slice)
if rv.Kind() != reflect.Slice {
return
}
p := v.join(field)
for i := 0; i < rv.Len(); i++ {
val := rv.Index(i)
var target Validatable
// Safely unwind interfaces and nested pointers (read-only).
for {
k := val.Kind()
if (k == reflect.Pointer || k == reflect.Interface) && val.IsNil() {
break
}
if t, ok := val.Interface().(Validatable); ok {
target = t
break
}
if k == reflect.Pointer || k == reflect.Interface {
val = val.Elem()
continue
}
if val.CanAddr() {
if t, ok := val.Addr().Interface().(Validatable); ok {
target = t
break
}
}
break
}
if target != nil {
if v.errs == nil {
v.errs = make(Error)
}
sub := &Validator{
errs: v.errs,
path: fmt.Sprintf("%s[%d]", p, i),
}
target.Validate(sub)
}
}
}
// isNil reports whether the value boxed in target is nil, catching the
// typed nil that survives a plain interface comparison.
func isNil(target Validatable) bool {
switch rv := reflect.ValueOf(target); rv.Kind() {
case reflect.Pointer, reflect.Map, reflect.Slice, reflect.Chan,
reflect.Func:
return rv.IsNil()
default:
return false
}
}
// join constructs the dot-notation path, escaping any literal dots in the
// field name. If the field is empty, it returns the current path unchanged.
func (v *Validator) join(field string) string {
if field == "" {
return v.path
}
field = strings.ReplaceAll(field, ".", "\\.")
if v.path == "" {
return field
}
return v.path + "." + field
}
// ----------------------------------------------------------------------------
// Comparison-based Checks
// ----------------------------------------------------------------------------
// number covers the built-in numeric types and any type whose underlying
// type is one of them.
type number interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
~float32 | ~float64
}
// Min asserts that a numeric value is at least the given minimum.
func (v *Validator) Min[T number](field string, val, min T) {
if val < min {
v.fail(
field,
CodeMin,
fmt.Sprintf("must be at least %v", min),
map[string]any{"min": min},
)
}
}
// Max asserts that a numeric value is at most the given maximum.
func (v *Validator) Max[T number](field string, val, max T) {
if val > max {
v.fail(
field,
CodeMax,
fmt.Sprintf("must be at most %v", max),
map[string]any{"max": max},
)
}
}
// Between asserts that a numeric value is between min and max inclusive.
func (v *Validator) Between[T number](field string, val, min, max T) {
if val < min || val > max {
v.fail(
field,
CodeBetween,
fmt.Sprintf("must be between %v and %v", min, max),
map[string]any{"min": min, "max": max},
)
}
}
// String lengths are counted in characters (UTF-8 runes), not bytes, so a
// bound means the same thing to the user typing the value, to the database
// column behind it — SQL CHARACTER VARYING counts characters too — and to
// the message reporting a violation. Invalid UTF-8 counts each malformed
// byte as one character, matching [utf8.RuneCountInString].
// MinLen asserts that a non-empty string is at least min characters long.
// It counts runes, not bytes.
func (v *Validator) MinLen(field, val string, min int) {
if val == "" {
return
}
if utf8.RuneCountInString(val) < min {
v.fail(
field,
CodeMinLen,
fmt.Sprintf("must be at least %d characters", min),
map[string]any{"min": min},
)
}
}
// MaxLen asserts that a string is at most max characters long.
// It counts runes, not bytes.
func (v *Validator) MaxLen(field, val string, max int) {
if utf8.RuneCountInString(val) > max {
v.fail(
field,
CodeMaxLen,
fmt.Sprintf("must be at most %d characters", max),
map[string]any{"max": max},
)
}
}
// Len asserts that a non-empty string is exactly n characters long.
// It counts runes, not bytes.
func (v *Validator) Len(field, val string, n int) {
if val == "" {
return
}
if utf8.RuneCountInString(val) != n {
v.fail(
field,
CodeLen,
fmt.Sprintf("must be exactly %d characters", n),
map[string]any{"length": n},
)
}
}
// MinSize asserts that the size of a slice or map is at least min.
//
// Example:
//
// MinSize("items", len(items), 5)
func (v *Validator) MinSize(field string, size, min int) {
if size < min {
v.fail(
field,
CodeMinSize,
fmt.Sprintf("size must be at least %d", min),
map[string]any{"min": min},
)
}
}
// MaxSize asserts that the size of a slice or map is at most max.
//
// Example:
//
// MaxSize("items", len(items), 5)
func (v *Validator) MaxSize(field string, size, max int) {
if size > max {
v.fail(
field,
CodeMaxSize,
fmt.Sprintf("size must be at most %d", max),
map[string]any{"max": max},
)
}
}
// Size asserts that the size of a slice or map is exactly the given size.
//
// Example:
//
// Size("items", len(items), 5)
func (v *Validator) Size(field string, size, n int) {
if size != n {
v.fail(
field,
CodeSize,
fmt.Sprintf("size must be exactly %d", n),
map[string]any{"size": n},
)
}
}
// Unique asserts that all elements in a string slice are unique.
func (v *Validator) Unique(field string, slice []string) {
if len(slice) < 2 {
return
}
seen := make(map[string]bool, len(slice))
for _, val := range slice {
if seen[val] {
v.fail(field, CodeUnique, "must contain unique items", nil)
return
}
seen[val] = true
}
}
// Whitelist asserts that a value exactly matches one of the allowed options.
// The failure message names the options, so keep the list short enough to
// read in an API response.
func (v *Validator) Whitelist[T comparable](field string, val T, list ...T) {
if !slices.Contains(list, val) {
v.fail(
field,
CodeWhitelist,
"must be one of: "+enumerate(list),
map[string]any{"options": list},
)
}
}
// Blacklist asserts that a value does not match any of the denied options.
func (v *Validator) Blacklist[T comparable](field string, val T, list ...T) {
if slices.Contains(list, val) {
v.fail(field, CodeBlacklist,
"must not be one of the denied values", nil)
}
}
// enumerate renders the options of a [Validator.Whitelist] violation as a
// comma-separated list.
func enumerate[T any](list []T) string {
var sb strings.Builder
for i, val := range list {
if i > 0 {
sb.WriteString(", ")
}
fmt.Fprintf(&sb, "%v", val)
}
return sb.String()
}
// NotEmpty asserts that a string is not empty.
func (v *Validator) NotEmpty(field, val string) {
if val == "" {
v.fail(field, CodeNotEmpty, "must not be empty", nil)
}
}
// NotBlank asserts that a string is not blank (contains at least one
// non-whitespace character).
func (v *Validator) NotBlank(field, val string) {
if strings.TrimSpace(val) == "" {
v.fail(field, CodeNotBlank, "must not be blank", nil)
}
}
// Prefix asserts that a non-empty string starts with a specific prefix.
func (v *Validator) Prefix(field, val, prefix string) {
if val == "" {
return
}
if !strings.HasPrefix(val, prefix) {
v.fail(
field,
CodePrefix,
fmt.Sprintf("must start with %q", prefix),
map[string]any{"prefix": prefix},
)
}
}
// Suffix asserts that a non-empty string ends with a specific suffix.
func (v *Validator) Suffix(field, val, suffix string) {
if val == "" {
return
}
if !strings.HasSuffix(val, suffix) {
v.fail(
field,
CodeSuffix,
fmt.Sprintf("must end with %q", suffix),
map[string]any{"suffix": suffix},
)
}
}
// Contains asserts that a non-empty string contains a specific substring.
func (v *Validator) Contains(field, val, sub string) {
if val == "" {
return
}
if !strings.Contains(val, sub) {
v.fail(
field,
CodeContains,
fmt.Sprintf("must contain %q", sub),
map[string]any{"substring": sub},
)
}
}
// Match asserts that a non-empty string matches a regular expression.
func (v *Validator) Match(field, val string, rx *regexp.Regexp) {
if val == "" {
return
}
if !rx.MatchString(val) {
v.fail(
field,
CodeMatch,
"must match the pattern "+rx.String(),
map[string]any{"pattern": rx.String()},
)
}
}
// Before asserts that a time is before a specific threshold.
func (v *Validator) Before(field string, val, max time.Time) {
if !val.Before(max) {
ts := max.Format(time.RFC3339)
v.fail(
field,
CodeBefore,
"must be before "+ts,
map[string]any{"max": ts},
)
}
}
// After asserts that a time is after a specific threshold.
func (v *Validator) After(field string, val, min time.Time) {
if !val.After(min) {
ts := min.Format(time.RFC3339)
v.fail(
field,
CodeAfter,
"must be after "+ts,
map[string]any{"min": ts},
)
}
}
// ----------------------------------------------------------------------------
// Standard Format Checks
// ----------------------------------------------------------------------------
// CIDR ensures that the given string, if non-empty, satisfies [CIDR].
func (v *Validator) CIDR(field, val string) {
if val == "" {
return
}
if !CIDR(val) {
v.fail(field, CodeCIDR, "must be a valid CIDR", nil)
}
}
// CIDRv4 ensures that the given string, if non-empty, satisfies [CIDRv4].
func (v *Validator) CIDRv4(field, val string) {
if val == "" {
return
}
if !CIDRv4(val) {
v.fail(field, CodeCIDRv4, "must be a valid IPv4 CIDR", nil)
}
}
// CIDRv6 ensures that the given string, if non-empty, satisfies [CIDRv6].
func (v *Validator) CIDRv6(field, val string) {
if val == "" {
return
}
if !CIDRv6(val) {
v.fail(field, CodeCIDRv6, "must be a valid IPv6 CIDR", nil)
}
}
// Hostname ensures that the given string, if non-empty, satisfies [Hostname].
func (v *Validator) Hostname(field, val string) {
if val == "" {
return
}
if !Hostname(val) {
v.fail(field, CodeHostname, "must be a valid hostname", nil)
}
}
// Port ensures that the given value satisfies [Port].
func (v *Validator) Port(field string, val int) {
if !Port(val) {
v.fail(field, CodePort, "must be a valid port number", nil)
}
}
// IP ensures that the given string, if non-empty, satisfies [IP].
func (v *Validator) IP(field, val string) {
if val == "" {
return
}
if !IP(val) {
v.fail(field, CodeIP, "must be a valid IP address", nil)
}
}
// IPv4 ensures that the given string, if non-empty, satisfies [IPv4].
func (v *Validator) IPv4(field, val string) {
if val == "" {
return
}
if !IPv4(val) {
v.fail(field, CodeIPv4, "must be a valid IPv4 address", nil)
}
}
// IPv6 ensures that the given string, if non-empty, satisfies [IPv6].
func (v *Validator) IPv6(field, val string) {
if val == "" {
return
}
if !IPv6(val) {
v.fail(field, CodeIPv6, "must be a valid IPv6 address", nil)
}
}
// FQDN ensures that the given string, if non-empty, satisfies [FQDN].
func (v *Validator) FQDN(field, val string) {
if val == "" {
return
}
if !FQDN(val) {
v.fail(field, CodeFQDN, "must be a valid FQDN", nil)
}
}
// URI ensures that the given string, if non-empty, satisfies [URI].
func (v *Validator) URI(field, val string) {
if val == "" {
return
}
if !URI(val) {
v.fail(field, CodeURI, "must be a valid URI", nil)
}
}
// URL ensures that the given string, if non-empty, satisfies [URL].
func (v *Validator) URL(field, val string) {
if val == "" {
return
}
if !URL(val) {
v.fail(field, CodeURL, "must be a valid URL", nil)
}
}
// URN ensures that the given string, if non-empty, satisfies [URN].
func (v *Validator) URN(field, val string) {
if val == "" {
return
}
if !URN(val) {
v.fail(field, CodeURN, "must be a valid URN", nil)
}
}
// Alpha ensures that the given string, if non-empty, satisfies [Alpha].
func (v *Validator) Alpha(field, val string) {
if val == "" {
return
}
if !Alpha(val) {
v.fail(field, CodeAlpha,
"must contain only alphabetical characters", nil)
}
}
// AlphaNum ensures that the given string, if non-empty, satisfies [AlphaNum].
func (v *Validator) AlphaNum(field, val string) {
if val == "" {
return
}
if !AlphaNum(val) {
v.fail(field, CodeAlphaNum,
"must contain only alphanumeric characters", nil)
}
}
// ASCII ensures that the given string, if non-empty, satisfies [ASCII].
func (v *Validator) ASCII(field, val string) {
if val == "" {
return
}
if !ASCII(val) {
v.fail(field, CodeASCII, "must contain only ASCII characters", nil)
}
}
// Slug ensures that the given string, if non-empty, satisfies [Slug].
func (v *Validator) Slug(field, val string) {
if val == "" {
return
}
if !Slug(val) {
v.fail(field, CodeSlug, "must be a valid slug", nil)
}
}
// Upper ensures that the given string, if non-empty, satisfies [Upper].
func (v *Validator) Upper(field, val string) {
if val == "" {
return
}
if !Upper(val) {
v.fail(field, CodeUpper, "must contain only uppercase characters", nil)
}
}
// Lower ensures that the given string, if non-empty, satisfies [Lower].
func (v *Validator) Lower(field, val string) {
if val == "" {
return
}
if !Lower(val) {
v.fail(field, CodeLower, "must contain only lowercase characters", nil)
}
}
// Base64 ensures that the given string, if non-empty, satisfies [Base64].
func (v *Validator) Base64(field, val string) {
if val == "" {
return
}
if !Base64(val) {
v.fail(field, CodeBase64, "must be a valid Base64 string", nil)
}
}
// Base64URL ensures that the given string, if non-empty, satisfies [Base64URL].
func (v *Validator) Base64URL(field, val string) {
if val == "" {
return
}
if !Base64URL(val) {
v.fail(field, CodeBase64URL, "must be a valid Base64URL string", nil)
}
}
// MAC ensures that the given string, if non-empty, satisfies [MAC].
func (v *Validator) MAC(field, val string) {
if val == "" {
return
}
if !MAC(val) {
v.fail(field, CodeMAC, "must be a valid MAC address", nil)
}
}
// Lang ensures that the given string, if non-empty, satisfies [Lang].
func (v *Validator) Lang(field, val string) {
if val == "" {
return
}
if !Lang(val) {
v.fail(field, CodeLang, "must be a valid BCP 47 language tag", nil)
}
}
// Timezone ensures that the given string, if non-empty, satisfies [Timezone].
func (v *Validator) Timezone(field, val string) {
if val == "" {
return
}
if !Timezone(val) {
v.fail(field, CodeTimezone, "must be a valid IANA time zone name", nil)
}
}
// JSON ensures that the given string, if non-empty, satisfies [JSON].
func (v *Validator) JSON(field, val string) {
if val == "" {
return
}
if !JSON(val) {
v.fail(field, CodeJSON, "must be a valid JSON document", nil)
}
}
// MIME ensures that the given string, if non-empty, satisfies [MIME].
func (v *Validator) MIME(field, val string) {
if val == "" {
return
}
if !MIME(val) {
v.fail(field, CodeMIME, "must be a valid MIME type", nil)
}
}
// CreditCard ensures that the given string, if non-empty, satisfies
// [CreditCard].
func (v *Validator) CreditCard(field, val string) {
if val == "" {
return
}
if !CreditCard(val) {
v.fail(field, CodeCreditCard,
"must be a valid credit card number", nil)
}
}
// Email ensures that the given string, if non-empty, satisfies [Email].
func (v *Validator) Email(field, val string) {
if val == "" {
return
}
if !Email(val) {
v.fail(field, CodeEmail, "must be a valid email address", nil)
}
}
// Hex ensures that the given string, if non-empty, satisfies [Hex].
func (v *Validator) Hex(field, val string) {
if val == "" {
return
}
if !Hex(val) {
v.fail(field, CodeHex, "must be a valid hexadecimal number", nil)
}
}
// HexColor ensures that the given string, if non-empty, satisfies [HexColor].
func (v *Validator) HexColor(field, val string) {
if val == "" {
return
}
if !HexColor(val) {
v.fail(field, CodeHexColor, "must be a valid hex color code", nil)
}
}
// ISSN ensures that the given string, if non-empty, satisfies [ISSN].
func (v *Validator) ISSN(field, val string) {
if val == "" {
return
}
if !ISSN(val) {
v.fail(field, CodeISSN, "must be a valid ISSN", nil)
}
}
// ISBN10 ensures that the given string, if non-empty, satisfies [ISBN10].
func (v *Validator) ISBN10(field, val string) {
if val == "" {
return
}
if !ISBN10(val) {
v.fail(field, CodeISBN10, "must be a valid ISBN-10", nil)
}
}
// ISBN13 ensures that the given string, if non-empty, satisfies [ISBN13].
func (v *Validator) ISBN13(field, val string) {
if val == "" {
return
}
if !ISBN13(val) {
v.fail(field, CodeISBN13, "must be a valid ISBN-13", nil)
}
}
// ISBN ensures that the given string, if non-empty, satisfies [ISBN].
func (v *Validator) ISBN(field, val string) {
if val == "" {
return
}
if !ISBN(val) {
v.fail(field, CodeISBN, "must be a valid ISBN", nil)
}
}
// Country2 ensures that the given string, if non-empty, satisfies [Country2].
func (v *Validator) Country2(field, val string) {
if val == "" {
return
}
if !Country2(val) {
v.fail(field, CodeCountry2,
"must be a valid ISO 3166-1 alpha-2 code", nil)
}
}
// Country3 ensures that the given string, if non-empty, satisfies [Country3].
func (v *Validator) Country3(field, val string) {
if val == "" {
return
}
if !Country3(val) {
v.fail(field, CodeCountry3,
"must be a valid ISO 3166-1 alpha-3 code", nil)
}
}
// CountryN ensures that the given string, if non-empty, satisfies [CountryN].
func (v *Validator) CountryN(field, val string) {
if val == "" {
return
}
if !CountryN(val) {
v.fail(field, CodeCountryN,
"must be a valid ISO 3166-1 numeric code", nil)
}
}
// Currency ensures that the given string, if non-empty, satisfies [Currency].
func (v *Validator) Currency(field, val string) {
if val == "" {
return
}
if !Currency(val) {
v.fail(field, CodeCurrency,
"must be a valid ISO 4217 currency code", nil)
}
}
// UUID ensures that the given string, if non-empty, satisfies [UUID].
func (v *Validator) UUID(field, val string) {
if val == "" {
return
}
if !UUID(val) {
v.fail(field, CodeUUID, "must be a valid UUID", nil)
}
}
// Lat ensures that the given value satisfies [Lat].
func (v *Validator) Lat(field string, val float64) {
if !Lat(val) {
v.fail(field, CodeLat, "must be a valid latitude", nil)
}
}
// Lon ensures that the given value satisfies [Lon].
func (v *Validator) Lon(field string, val float64) {
if !Lon(val) {
v.fail(field, CodeLon, "must be a valid longitude", nil)
}
}
// MD5 ensures that the given string, if non-empty, satisfies [MD5].
func (v *Validator) MD5(field, val string) {
if val == "" {
return
}
if !MD5(val) {
v.fail(field, CodeMD5, "must be a valid MD5 hash", nil)
}
}
// SHA256 ensures that the given string, if non-empty, satisfies [SHA256].
func (v *Validator) SHA256(field, val string) {
if val == "" {
return
}
if !SHA256(val) {
v.fail(field, CodeSHA256, "must be a valid SHA256 hash", nil)
}
}
// SHA384 ensures that the given string, if non-empty, satisfies [SHA384].
func (v *Validator) SHA384(field, val string) {
if val == "" {
return
}
if !SHA384(val) {
v.fail(field, CodeSHA384, "must be a valid SHA384 hash", nil)
}
}
// SHA512 ensures that the given string, if non-empty, satisfies [SHA512].
func (v *Validator) SHA512(field, val string) {
if val == "" {
return
}
if !SHA512(val) {
v.fail(field, CodeSHA512, "must be a valid SHA512 hash", nil)
}
}
// SemVer ensures that the given string, if non-empty, satisfies [SemVer].
func (v *Validator) SemVer(field, val string) {
if val == "" {
return
}
if !SemVer(val) {
v.fail(field, CodeSemVer, "must be a valid semantic version", nil)
}
}
// Phone ensures that the given string, if non-empty, satisfies [Phone].
func (v *Validator) Phone(field, val string) {
if val == "" {
return
}
if !Phone(val) {
v.fail(field, CodePhone, "must be a valid E.164 phone number", nil)
}
}
// BIC ensures that the given string, if non-empty, satisfies [BIC].
func (v *Validator) BIC(field, val string) {
if val == "" {
return
}
if !BIC(val) {
v.fail(field, CodeBIC, "must be a valid BIC", nil)
}
}
// IBAN ensures that the given string, if non-empty, satisfies [IBAN].
func (v *Validator) IBAN(field, val string) {
if val == "" {
return
}
if !IBAN(val) {
v.fail(field, CodeIBAN, "must be a valid IBAN", nil)
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package anchor
import (
"context"
"encoding/base64"
"encoding/json/v2"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/deep-rent/nexus/eco/ats/chain"
"github.com/deep-rent/nexus/eco/ats/store"
"github.com/deep-rent/nexus/net/notify/hook"
"github.com/deep-rent/nexus/net/s3"
"github.com/deep-rent/nexus/sec/jose/jwt"
"github.com/deep-rent/nexus/sec/vault"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/metrics"
)
// TopicSealed announces a freshly anchored head. The payload carries
// the full signed token; see the package documentation for why this is
// deliberately not thin.
const TopicSealed = "ats.checkpoint.sealed"
// PresignTTL bounds how long a minted upload grant stays usable; the
// PUT follows immediately, so this is generous.
const PresignTTL = 5 * time.Minute
// Metrics this package publishes.
const (
// MetricCheckpoints counts minted checkpoints.
MetricCheckpoints = "ats_checkpoints_total"
// MetricAnchorFailures counts anchor writes that did not land,
// tagged by target ("bucket", "hook"). A steady rate here means
// the head is not leaving the building.
MetricAnchorFailures = "ats_anchor_failures_total"
// MetricHead gauges the trail's head sequence — the entry count,
// since the sequence is dense.
MetricHead = "ats_head_seq"
// MetricAnchored gauges the highest head that has actually LEFT
// the building. It is the one to alert on:
//
// ats_head_seq - ats_anchored_seq > 0 for 3 * the interval
//
// A failure counter cannot answer this. It only moves when a
// target refuses a write, and the ways anchoring stops quietly —
// a signing key that no longer signs, a head that cannot be read,
// a webhook engine with no subscriber behind it — never reach a
// target at all. This gauge falls behind for every one of them.
MetricAnchored = "ats_anchored_seq"
)
// Claims is the checkpoint statement: the reserved JWT claims plus the
// head it covers. Seq rides as a string because JSON numbers lose
// precision past 2^53, and a trail is allowed to dream.
type Claims struct {
jwt.Reserved
// Seq is the head's sequence number, in decimal.
Seq string `json:"ats_seq"`
// Head is the head's chain hash, base64url.
Head string `json:"ats_head"`
}
// Stated decodes the head a checkpoint asserts. It is the accessor a
// verifier wants: the sequence and the chain hash as they were SIGNED,
// rather than as the columns beside the token happen to record them.
func (c *Claims) Stated() (seq int64, head [32]byte, err error) {
seq, err = strconv.ParseInt(c.Seq, 10, 64)
if err != nil {
return 0, head, fmt.Errorf(
"checkpoint states an unreadable sequence %q: %w",
c.Seq, err,
)
}
raw, err := base64.RawURLEncoding.DecodeString(c.Head)
if err != nil {
return 0, head, fmt.Errorf(
"checkpoint states an unreadable head: %w", err,
)
}
if len(raw) != len(head) {
return 0, head, fmt.Errorf(
"checkpoint states a %d-byte head; want %d",
len(raw), len(head),
)
}
copy(head[:], raw)
return seq, head, nil
}
// Stated opens one checkpoint and returns the head it STATES — the
// only part of a checkpoint an attacker without the signing key cannot
// rewrite.
//
// The sequence and hash stored in the columns beside a token are
// ordinary data in the same database as the entries themselves, so a
// rewrite reaches them as easily as it reaches the trail. Comparing a
// replay against those columns proves only that the attacker was
// consistent. The signature is what they cannot produce, so a row that
// disagrees with the statement it carries is itself the evidence.
func Stated(
verifier jwt.Verifier[*Claims],
c store.Checkpoint,
) (seq int64, head [32]byte, err error) {
claims, err := verifier.Verify([]byte(c.Token))
if err != nil {
return 0, head, fmt.Errorf(
"the checkpoint at %d does not verify against the "+
"signing keys: %w", c.Seq, err,
)
}
if seq, head, err = claims.Stated(); err != nil {
return 0, head, fmt.Errorf(
"the checkpoint at %d states a head that cannot be "+
"read: %w", c.Seq, err,
)
}
if seq != c.Seq || head != c.Hash {
return 0, head, fmt.Errorf(
"the checkpoint row at %d disagrees with the statement "+
"it carries, which says %d — the row was rewritten "+
"and the signature was not", c.Seq, seq,
)
}
return seq, head, nil
}
// Publisher is the webhook seam, satisfied by [hook.Engine].
type Publisher interface {
Emit(ctx context.Context, event hook.Event) (int, error)
}
// Config bundles the collaborators of an [Anchor].
type Config struct {
// Store holds the trail. Required.
Store *store.Store
// Keys signs the checkpoints. Required.
Keys vault.Vault
// Issuer is the iss claim of every checkpoint, conventionally the
// service's public URL. Required.
Issuer string
// Bucket is the object-lock bucket checkpoints are written to.
// Optional; nil skips the target.
Bucket *s3.Bucket
// Prefix is prepended to every bucket key.
Prefix string
// Hooks publishes [TopicSealed]. Optional; nil skips the target.
Hooks Publisher
// Client carries the bucket PUT. Defaults to
// [http.DefaultClient].
Client *http.Client
// Logger receives diagnostics. Defaults to [log.Discard].
Logger *log.Logger
// Registry receives the instruments. Defaults to
// [metrics.DefaultRegistry].
Registry *metrics.Registry
// Clock is the time source. Defaults to [clock.System].
Clock clock.Clock
}
// Anchor mints and places checkpoints. Create instances with [New];
// safe for concurrent use, though one scheduled runner is the intended
// shape.
type Anchor struct {
cfg Config
mu sync.Mutex
anchored int64 // the checkpoint seq both targets last accepted
}
// New assembles an [Anchor]. It panics if a required collaborator is
// missing, since startup misconfiguration is a programmer error.
func New(cfg Config) *Anchor {
switch {
case cfg.Store == nil:
panic("store is required")
case cfg.Keys == nil:
panic("vault is required")
case cfg.Issuer == "":
panic("issuer is required")
}
if cfg.Client == nil {
cfg.Client = http.DefaultClient
}
if cfg.Logger == nil {
cfg.Logger = log.Discard()
}
if cfg.Registry == nil {
cfg.Registry = metrics.DefaultRegistry
}
if cfg.Clock == nil {
cfg.Clock = clock.System
}
return &Anchor{cfg: cfg}
}
// Anchored reports whether at least one target is configured. The
// service warns at startup when none is: a chain anchored nowhere is a
// decoration.
func (a *Anchor) Anchored() bool {
return a.cfg.Bucket != nil || a.cfg.Hooks != nil
}
// Seal is the scheduled task: mint a checkpoint when the head has
// advanced past the last one, then push the newest checkpoint to every
// target that has not yet accepted it. Failures warn and retry on the
// next tick — the local row is the record of intent, and a target that
// was down catches up when it returns.
func (a *Anchor) Seal(ctx context.Context) {
head, err := a.cfg.Store.Head(ctx)
if err != nil {
a.cfg.Logger.Error(ctx, "Could not read the trail head",
log.Error(err))
return
}
a.cfg.Registry.Gauge(MetricHead).Set(float64(head.Seq))
if head.Seq == 0 {
return // nothing recorded yet, nothing to state
}
latest, ok, err := a.cfg.Store.LatestCheckpoint(ctx)
if err != nil {
a.cfg.Registry.Counter(MetricAnchorFailures,
metrics.T("target", "store"),
).Inc()
a.cfg.Logger.Error(ctx, "Could not read the last checkpoint",
log.Error(err))
return
}
// A checkpoint standing beyond the head is not "already anchored",
// it is a row that should not exist. The append-only trigger
// covers UPDATE and DELETE, so an INSERT needs no trigger drop at
// all — and one row claiming a far-future sequence would make
// every later tick believe its work was done, silently, for the
// life of the deployment. The head would never be stated again.
if ok && latest.Seq > head.Seq {
a.cfg.Registry.Counter(MetricAnchorFailures,
metrics.T("target", "mint"),
).Inc()
a.cfg.Logger.Error(ctx,
"A checkpoint stands beyond the head; refusing to treat "+
"the trail as anchored",
log.Int64("checkpoint", latest.Seq),
log.Int64("head", head.Seq),
)
return
}
if !ok || latest.Seq < head.Seq {
if latest, err = a.mint(ctx, head); err != nil {
// Counted, not merely logged. This is where a vault that
// stopped signing shows up, and it never reaches a target.
a.cfg.Registry.Counter(MetricAnchorFailures,
metrics.T("target", "mint"),
).Inc()
a.cfg.Logger.Error(ctx, "Could not mint a checkpoint",
log.Error(err))
return
}
}
a.place(ctx, latest)
}
// mint signs the head and records the checkpoint beside the trail.
func (a *Anchor) mint(
ctx context.Context,
head chain.Head,
) (store.Checkpoint, error) {
now := a.cfg.Clock()
claims := Claims{
Reserved: jwt.Reserved{
Iss: a.cfg.Issuer,
Iat: now,
},
Seq: strconv.FormatInt(head.Seq, 10),
Head: base64.RawURLEncoding.EncodeToString(head.Hash[:]),
}
token, err := jwt.Sign(ctx, a.cfg.Keys.Next(), claims)
if err != nil {
return store.Checkpoint{}, fmt.Errorf(
"failed to sign the head: %w", err,
)
}
c := store.Checkpoint{
Seq: head.Seq,
Hash: head.Hash,
Token: string(token),
CreatedAt: now,
}
if err := a.cfg.Store.SaveCheckpoint(ctx, c); err != nil {
return store.Checkpoint{}, fmt.Errorf(
"failed to record the checkpoint: %w", err,
)
}
a.cfg.Registry.Counter(MetricCheckpoints).Inc()
a.cfg.Logger.Info(ctx, "Sealed a checkpoint",
log.Int64("seq", c.Seq))
return c, nil
}
// place pushes the checkpoint to every configured target, remembering
// success so a tick with nothing new pushes nothing twice.
func (a *Anchor) place(ctx context.Context, c store.Checkpoint) {
a.mu.Lock()
done := a.anchored >= c.Seq
a.mu.Unlock()
if done || !a.Anchored() {
return
}
landed := true
if a.cfg.Bucket != nil {
if err := a.put(ctx, c); err != nil {
landed = false
a.fail(ctx, "bucket", c, err)
}
}
if a.cfg.Hooks != nil {
if err := a.publish(ctx, c); err != nil {
landed = false
a.fail(ctx, "hook", c, err)
}
}
if landed {
a.mu.Lock()
a.anchored = c.Seq
a.mu.Unlock()
a.cfg.Registry.Gauge(MetricAnchored).Set(float64(c.Seq))
}
}
// fail records one target refusing an anchor write.
func (a *Anchor) fail(
ctx context.Context,
target string,
c store.Checkpoint,
err error,
) {
a.cfg.Registry.Counter(MetricAnchorFailures,
metrics.T("target", target),
).Inc()
a.cfg.Logger.Warn(ctx, "An anchor write did not land; the head "+
"has not left the building",
log.String("target", target),
log.Int64("seq", c.Seq),
log.Error(err),
)
}
// scrubbed strips a URL — and with it any presigned query — from a
// transport error, keeping the cause so callers can still test it with
// [errors.Is].
func scrubbed(err error) error {
var ue *url.Error
if errors.As(err, &ue) {
return fmt.Errorf("%s: %w", ue.Op, ue.Err)
}
return err
}
// put writes the checkpoint to the bucket, keyed by its sequence so an
// object-lock bucket naturally refuses a rewrite.
func (a *Anchor) put(ctx context.Context, c store.Checkpoint) error {
key := path(a.cfg.Prefix, c.Seq)
url, err := a.cfg.Bucket.Presign(
http.MethodPut, key, PresignTTL, nil,
)
if err != nil {
return fmt.Errorf("failed to presign the anchor write: %w", err)
}
req, err := http.NewRequestWithContext(
ctx, http.MethodPut, url, strings.NewReader(c.Token),
)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/jwt")
res, err := a.cfg.Client.Do(req)
if err != nil {
// Never the wrapped *url.Error: a presigned URL carries
// X-Amz-Credential and X-Amz-Signature in its query, and
// url.Error renders the URL whole. Those would then sit in a
// log store read by more people than hold the S3 credential.
return fmt.Errorf(
"failed to reach the bucket for %s: %w",
key, scrubbed(err),
)
}
defer res.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(res.Body, 1<<16))
if res.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("the bucket answered %d", res.StatusCode)
}
return nil
}
// path renders a checkpoint's bucket key.
func path(prefix string, seq int64) string {
name := "checkpoint-" + strconv.FormatInt(seq, 10) + ".jwt"
if prefix == "" {
return name
}
return strings.TrimSuffix(prefix, "/") + "/" + name
}
// sealed is the webhook payload: the head, and the full signed token a
// subscriber archives without ever having to ask this service again.
type sealed struct {
Seq int64 `json:"seq"`
Head string `json:"head"`
Token string `json:"token"`
}
// publish announces the checkpoint on [TopicSealed].
func (a *Anchor) publish(ctx context.Context, c store.Checkpoint) error {
data, err := json.Marshal(sealed{
Seq: c.Seq,
Head: base64.RawURLEncoding.EncodeToString(c.Hash[:]),
Token: c.Token,
})
if err != nil {
return err
}
fanned, err := a.cfg.Hooks.Emit(ctx, hook.Event{
Topic: TopicSealed,
Data: data,
At: c.CreatedAt,
})
if err != nil {
return err
}
// Nobody subscribed is not a delivery. The engine answers a topic
// with no endpoint behind it with (0, nil), and taking that for
// success meant a deployment whose only target was the webhook
// reported a healthy anchor forever while nothing ever left the
// building — the startup warning suppressed too, since a
// configured engine looks like a configured target.
if fanned == 0 {
return errors.New(
"no subscriber is registered for " + TopicSealed +
"; the head was signed but went nowhere",
)
}
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package api
import (
"encoding/base64"
"errors"
"net/http"
"strconv"
"time"
"uuid"
"github.com/deep-rent/nexus/eco/ats/record"
"github.com/deep-rent/nexus/eco/ats/store"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/sec/auth"
"github.com/deep-rent/nexus/std/clock"
)
// The permissions this API demands. Scopes and permissions share a
// namespace, so a machine client carries them as vetted scopes.
const (
// PermissionAppend records entries. Granted to no role at all: a
// person's token that could append could manufacture history.
PermissionAppend = "ats:append"
// PermissionRead pages the trail.
PermissionRead = "ats:read"
)
// ReasonForeignOrigin refuses an assertion claiming an origin the
// calling client is not bound to.
const ReasonForeignOrigin router.Reason = "foreign_origin"
// Permissions lists every permission of this API.
var Permissions = []string{PermissionAppend, PermissionRead}
// Paths this API mounts at.
const (
// PathEntries carries both the append and the listing.
PathEntries = "/entries"
// PathHead serves the running head.
PathHead = "/head"
)
// Listing bounds.
const (
// DefaultLimit is the page size when the query names none.
DefaultLimit = 100
// MaxLimit caps one page. An auditor exporting the trail pages;
// verification replays through the store, not this surface.
MaxLimit = 1000
)
// Config bundles the collaborators of a [Server].
type Config struct {
// Store holds the trail. Required.
Store *store.Store
// Clock is the time source. Defaults to [clock.System].
Clock clock.Clock
// Origins reports the origin a client may claim, and whether the
// deployment binds origins at all. Satisfied by [config.Config].
//
// Origin is otherwise whatever the caller writes in the body, and
// it is half the idempotency namespace — so any holder of the
// append scope could claim another service's name, and could
// pre-register a key that service will later use. The genuine
// assertion then matches the squatted digest, is answered 200
// "duplicate", and is never recorded, while the producer's client
// settles the job cleanly. Nothing anywhere says history has a
// hole. Nil leaves origins unbound.
//
// [config.Config]: github.com/deep-rent/nexus/eco/ats/config#Config
Origins Origins
}
// Origins binds a producer's client identifier to the origin it may
// claim.
type Origins interface {
// Origin returns the origin bound to the client, and whether the
// deployment binds origins at all.
Origin(client uuid.UUID) (string, bool)
}
// Server implements the trail API. Create instances with [New] and
// attach the routes with [Server.MountAppend] and [Server.MountRead].
type Server struct {
cfg Config
}
// New assembles a [Server]. It panics if a required [Config] field is
// missing, since startup misconfiguration is a programmer error.
func New(cfg Config) *Server {
if cfg.Store == nil {
panic("store is required")
}
if cfg.Clock == nil {
cfg.Clock = clock.System
}
return &Server{cfg: cfg}
}
// MountAppend registers the write surface; guard it with
// [PermissionAppend].
func (s *Server) MountAppend(r *router.Router, mws ...router.Middleware) {
r.Group("", mws...).HandleFunc(
http.MethodPost, PathEntries, s.append,
)
}
// MountRead registers the read surface; guard it with
// [PermissionRead].
func (s *Server) MountRead(r *router.Router, mws ...router.Middleware) {
g := r.Group("", mws...)
g.HandleFunc(http.MethodGet, PathEntries, s.list)
g.HandleFunc(http.MethodGet, PathHead, s.head)
}
// client resolves the calling machine — the provenance every entry
// records. That the caller IS a machine is [auth.Machine]'s business,
// enforced at the mount; a subject that is not an identifier records as
// zero, since provenance is best-effort naming rather than a check.
func client(e *router.Exchange) uuid.UUID {
if claims, ok := auth.Must(e).(*auth.Claims); ok {
if id, err := uuid.Parse(claims.Sub); err == nil {
return id
}
}
return uuid.Nil()
}
// receipt is the append answer.
type receipt struct {
// Seq is where the entry sits — or already sat.
Seq int64 `json:"seq"`
// Chain is the entry's chain hash, base64url: the producer's own
// proof-of-record, worth logging on its side.
Chain string `json:"chain"`
// Duplicate reports a replayed idempotency key; the rest of the
// receipt describes the entry already recorded.
Duplicate bool `json:"duplicate,omitzero"`
}
// append serves "POST /entries".
func (s *Server) append(e *router.Exchange) error {
var a record.Assertion
if err := e.BindJSON(&a); err != nil {
return err
}
caller := client(e)
if s.cfg.Origins != nil {
if bound, enforced := s.cfg.Origins.Origin(
caller,
); enforced && bound != a.Origin {
// Deliberately says what was expected rather than what was
// claimed: a producer misconfigured against the deployment
// needs to know which name is its own, and a caller trying
// somebody else's learns nothing it did not already bring.
return &router.Error{
Status: http.StatusForbidden,
Reason: ReasonForeignOrigin,
Description: "this client asserts as " +
strconv.Quote(bound),
}
}
}
entry, freshly, err := s.cfg.Store.Append(
e.Context(), caller, a, s.cfg.Clock(),
)
if err != nil {
return fail(err)
}
e.NoStore()
code := http.StatusCreated
if !freshly {
code = http.StatusOK
}
return e.JSON(code, receipt{
Seq: entry.Seq,
Chain: base64.RawURLEncoding.EncodeToString(entry.Chain[:]),
Duplicate: !freshly,
})
}
// fail maps a domain refusal onto a status.
func fail(err error) error {
switch {
case err == nil:
return nil
case errors.Is(err, record.ErrOrigin),
errors.Is(err, record.ErrAction),
errors.Is(err, record.ErrKey),
errors.Is(err, record.ErrDigest),
errors.Is(err, record.ErrOccurred):
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: err.Error(),
}
}
return err
}
// entry is the read shape: the record, its evidence, and the opened
// identities — or the honest word "erased" where a key is gone.
type entry struct {
Seq int64 `json:"seq"`
Origin string `json:"origin"`
Client uuid.UUID `json:"client"`
Action string `json:"action"`
Actor uuid.UUID `json:"actor,omitzero"`
ActorErased bool `json:"actor_erased,omitzero"`
Subject uuid.UUID `json:"subject,omitzero"`
SubjectErased bool `json:"subject_erased,omitzero"`
OccurredAt time.Time `json:"occurred_at"`
RecordedAt time.Time `json:"recorded_at"`
Digest []byte `json:"digest,omitzero"`
Leaf string `json:"leaf"`
Chain string `json:"chain"`
}
// list serves "GET /entries": a page of the trail after a cursor,
// ascending — the export an auditor walks.
func (s *Server) list(e *router.Exchange) error {
q := e.Query()
after := int64(0)
if raw := q.Get("after"); raw != "" {
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil || n < 0 {
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: "after must be a sequence number",
}
}
after = n
}
limit := DefaultLimit
if raw := q.Get("limit"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n < 1 {
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: "limit must be a positive integer",
}
}
limit = min(n, MaxLimit)
}
entries, err := s.cfg.Store.Entries(e.Context(), after, limit)
if err != nil {
return err
}
identities, err := s.cfg.Store.Resolve(e.Context(), entries)
if err != nil {
return err
}
out := make([]entry, 0, len(entries))
for i, rec := range entries {
out = append(out, entry{
Seq: rec.Seq,
Origin: rec.Origin,
Client: rec.Client,
Action: rec.Action,
Actor: identities[i].Actor,
ActorErased: identities[i].ActorErased,
Subject: identities[i].Subject,
SubjectErased: identities[i].SubjectErased,
OccurredAt: rec.OccurredAt,
RecordedAt: rec.RecordedAt,
Digest: rec.Digest,
Leaf: base64.RawURLEncoding.EncodeToString(
rec.Leaf[:],
),
Chain: base64.RawURLEncoding.EncodeToString(
rec.Chain[:],
),
})
}
e.NoStore()
return e.JSON(http.StatusOK, map[string]any{"entries": out})
}
// head serves "GET /head": where the trail stands, and the newest
// anchored statement of it.
func (s *Server) head(e *router.Exchange) error {
head, err := s.cfg.Store.Head(e.Context())
if err != nil {
return err
}
out := map[string]any{
"seq": head.Seq,
"hash": base64.RawURLEncoding.EncodeToString(head.Hash[:]),
}
if latest, ok, err := s.cfg.Store.LatestCheckpoint(
e.Context(),
); err != nil {
return err
} else if ok {
out["checkpoint"] = map[string]any{
"seq": latest.Seq,
"token": latest.Token,
"created_at": latest.CreatedAt,
}
}
e.NoStore()
return e.JSON(http.StatusOK, out)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package chain
import (
"crypto/sha256"
"errors"
"fmt"
"github.com/deep-rent/nexus/eco/ats/record"
)
// The domain-separation prefixes, per RFC 6962. A hash's first input
// byte says what kind of node it is, so no two kinds can collide.
const (
// PrefixLeaf marks the hash of one entry's canonical bytes.
PrefixLeaf = 0x00
// PrefixNode is reserved for the interior nodes of a future Merkle
// layer and appears in no hash today.
PrefixNode = 0x01
// PrefixLink marks a chain link binding a leaf to the running head.
PrefixLink = 0x02
)
// Genesis is the head before the first entry: thirty-two zero bytes.
var Genesis [32]byte
// Errors a [Walker] reports, each wrapped with the sequence it names.
var (
// ErrGap reports a hole in the dense sequence — an entry was
// removed, or the stream skipped one.
ErrGap = errors.New("sequence gap")
// ErrLeaf reports stored canonical bytes that no longer hash to
// the stored leaf — the entry was edited in place.
ErrLeaf = errors.New("leaf mismatch")
// ErrLink reports a stored chain hash that does not follow from
// the previous head — the chain was spliced.
ErrLink = errors.New("link mismatch")
// ErrHead reports a trail head that does not match the last
// verified entry.
ErrHead = errors.New("head mismatch")
)
// Leaf hashes one entry's canonical bytes.
func Leaf(e record.Entry) [32]byte {
h := sha256.New()
h.Write([]byte{PrefixLeaf})
h.Write(e.Encode())
var out [32]byte
copy(out[:], h.Sum(nil))
return out
}
// Link binds a leaf to the running head.
func Link(prev, leaf [32]byte) [32]byte {
h := sha256.New()
h.Write([]byte{PrefixLink})
h.Write(prev[:])
h.Write(leaf[:])
var out [32]byte
copy(out[:], h.Sum(nil))
return out
}
// Head is the trail's running state: how many entries, and the hash
// that binds them all.
type Head struct {
// Seq is the last entry's sequence number; zero before the first.
Seq int64
// Hash is the last chain hash; [Genesis] before the first.
Hash [32]byte
}
// Extend computes the entry's hashes over the given head, returning
// the entry with Leaf and Chain filled and the head that follows it.
// It is what the store calls under its append lock.
func Extend(head Head, e record.Entry) (record.Entry, Head) {
e.Seq = head.Seq + 1
e.Leaf = Leaf(e)
e.Chain = Link(head.Hash, e.Leaf)
return e, Head{Seq: e.Seq, Hash: e.Chain}
}
// Walker replays stored entries against the chain rules and reports
// the first divergence. Feed it entries in ascending order via
// [Walker.Step]; its zero value starts at [Genesis].
type Walker struct {
head Head
}
// NewWalker starts a replay from a known head — a checkpoint, or
// [Head]{} for the genesis.
func NewWalker(from Head) *Walker {
return &Walker{head: from}
}
// Step verifies one stored entry and advances. The error names the
// sequence and the kind of divergence, which is the finding an
// operator acts on.
func (w *Walker) Step(e record.Entry) error {
if e.Seq != w.head.Seq+1 {
return fmt.Errorf(
"%w: entry %d follows %d", ErrGap, e.Seq, w.head.Seq,
)
}
if leaf := Leaf(e); leaf != e.Leaf {
return fmt.Errorf("%w: entry %d", ErrLeaf, e.Seq)
}
if link := Link(w.head.Hash, e.Leaf); link != e.Chain {
return fmt.Errorf("%w: entry %d", ErrLink, e.Seq)
}
w.head = Head{Seq: e.Seq, Hash: e.Chain}
return nil
}
// Head is where the replay stands.
func (w *Walker) Head() Head { return w.head }
// Settle checks the replay against the trail's recorded head, which is
// the final step of a full verification.
func (w *Walker) Settle(head Head) error {
if w.head != head {
return fmt.Errorf(
"%w: replayed to %d, the trail claims %d",
ErrHead, w.head.Seq, head.Seq,
)
}
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package config
import (
"fmt"
"strings"
"time"
"uuid"
"github.com/deep-rent/nexus/sys/boot"
"github.com/deep-rent/nexus/sys/env"
)
// Prefix namespaces every environment variable of the service.
const Prefix = "ATS_"
// Config declares the deployment configuration of the audit trail
// service. Bind it from the environment with [Load].
type Config struct {
// Core is the listen addresses, CORS origins, log level, and
// connection timeouts every service carries.
boot.Core `env:",inline"`
// Database configures the PostgreSQL connection holding the trail.
// Required: a trail without one has nowhere to keep an entry.
Database boot.Database `env:",prefix:DATABASE_"`
// Auth declares the identity provider whose tokens the API
// accepts.
Auth boot.Auth `env:",prefix:AUTH_"`
// Vault configures the signing keys behind the checkpoints. The
// public halves are served as this service's own JWKS, so anyone
// holding a checkpoint can verify it.
Vault Vault `env:",prefix:VAULT_"`
// Anchor configures where the signed heads go. A deployment that
// configures NO target is warned at startup: a chain anchored
// nowhere is a decoration.
Anchor Anchor `env:",prefix:ANCHOR_"`
// Origins binds each producer's client identifier to the origin it
// may claim, as "<client-uuid>=<origin>" pairs.
//
// Without it origin is whatever the caller writes in the body,
// and origin is half the idempotency namespace — so any holder of
// the append scope can claim another service's name, and worse,
// can pre-register a key that service will later use. The real
// assertion then arrives, matches the squatted digest, is answered
// 200 "duplicate", and is never recorded. The producer's client
// settles the job cleanly, so nothing anywhere says history has a
// hole. Binding the two closes both.
//
// Empty leaves origins unbound; the service warns.
Origins []string
// origins is Origins parsed, filled by [Load].
origins map[uuid.UUID]string
// Shred seals the per-subject data keys at rest.
Shred Shred `env:",prefix:SHRED_"`
// Hook configures the webhook engine carrying
// ats.checkpoint.sealed to subscribers.
Hook boot.Sender `env:",prefix:HOOK_"`
// Intake configures the webhook receiver through which the
// identity service announces deleted principals, so their data
// keys are destroyed.
Intake boot.Intake `env:",prefix:INTAKE_"`
}
// Vault configures the signing keys behind the checkpoints; see the
// identity service's section of the same name, whose format this one
// shares.
type Vault struct {
// File is the path to the signing key JSON file in the
// [file.Items] format, typically a mounted Kubernetes Secret.
//
// [file.Items]: github.com/deep-rent/nexus/sec/vault/source/file#Items
File string `env:",default:'./vault.json'"`
}
// Anchor configures where the signed heads go.
type Anchor struct {
// Issuer is the iss claim of every checkpoint, conventionally this
// deployment's public URL for the service. It is what a verifier
// checks the statement against.
Issuer string `env:",default:ats"`
// Interval is how often the head is sealed and placed. It bounds
// how stale a rewrite can stay hidden.
Interval time.Duration `env:",default:15m"`
// Bucket configures the object-lock bucket checkpoints are written
// to; see [Bucket]. Empty skips the target.
Bucket Bucket `env:",prefix:BUCKET_"`
// Prefix is prepended to every bucket key, so deployments can
// share a bucket.
Prefix string `env:",default:ats"`
}
// Bucket configures the object storage behind the anchor. Enable
// object lock on the bucket itself — WORM is a bucket setting, and
// this service deliberately cannot tell.
type Bucket struct {
// AccessKey and SecretKey are the S3 credentials.
AccessKey string `env:"ACCESS_KEY"`
SecretKey string `env:"SECRET_KEY"`
// Region is the provider region the credentials sign for.
Region string
// URL is the bucket's base URL — virtual-hosted or path style.
URL string
}
// Enabled reports whether the bucket target is configured.
func (c Bucket) Enabled() bool {
return c.AccessKey != "" && c.SecretKey != "" && c.URL != ""
}
// Shred configures the sealing of per-subject data keys at rest.
//
// Without a key the data keys are stored raw: shredding still works —
// the row is gone either way — but a database backup taken BEFORE an
// erasure still opens the identities it holds. The service warns.
type Shred struct {
// Keys seal the per-subject data keys at rest. Without them a
// backup taken BEFORE an erasure keeps opening the identities it
// holds, even though the erasure itself still works.
boot.Keys `env:",inline"`
}
// MinAnchorInterval is the tightest allowed anchoring cadence. Every
// tick signs a checkpoint and places it, so a cadence below this buys
// no evidence the next tick would not carry — and an interval of zero
// would place continuously, against a bucket that charges per request.
const MinAnchorInterval = time.Minute
// Origin returns the origin a client may claim, and whether the
// deployment binds origins at all. An unbound deployment lets a
// producer name itself.
func (c Config) Origin(client uuid.UUID) (string, bool) {
if len(c.origins) == 0 {
return "", false
}
return c.origins[client], true
}
// Load binds the configuration from the environment, reporting every
// binding problem at once.
func Load(opts ...env.Option) (Config, error) {
cfg, err := boot.Load[Config](Prefix, opts...)
if err != nil {
return cfg, err
}
if !cfg.Database.Enabled() {
// Named rather than left to the runtime's generic refusal, so
// the message says which variable to set.
return cfg, fmt.Errorf("%sDATABASE_URL is not set", Prefix)
}
if cfg.Anchor.Interval < MinAnchorInterval {
return cfg, fmt.Errorf(
"anchor interval %v is below the %v floor",
cfg.Anchor.Interval, MinAnchorInterval,
)
}
// A bucket named in part is a typo, not a decision. Read as
// "no bucket" it would be indistinguishable from a deployment that
// deliberately anchors elsewhere — and where the webhook engine is
// also absent, the trail would then be anchored nowhere, which
// costs this service the only defence it has against a thorough
// rewrite. The refusal is louder than the warning that would
// otherwise be the only sign.
cfg.origins = make(map[uuid.UUID]string, len(cfg.Origins))
for _, pair := range cfg.Origins {
client, origin, ok := strings.Cut(pair, "=")
if !ok {
return cfg, fmt.Errorf(
"%sORIGINS wants <client-uuid>=<origin> pairs; got %q",
Prefix, pair,
)
}
id, err := uuid.Parse(strings.TrimSpace(client))
if err != nil {
return cfg, fmt.Errorf(
"%sORIGINS names %q, which is not a client "+
"identifier: %w", Prefix, client, err,
)
}
origin = strings.TrimSpace(origin)
if origin == "" {
return cfg, fmt.Errorf(
"%sORIGINS binds %s to no origin", Prefix, id,
)
}
cfg.origins[id] = origin
}
b := cfg.Anchor.Bucket
if !b.Enabled() &&
(b.AccessKey != "" || b.SecretKey != "" || b.URL != "") {
return cfg, fmt.Errorf(
"%sANCHOR_BUCKET_ACCESS_KEY, _SECRET_KEY and _URL must be "+
"configured together", Prefix,
)
}
return cfg, nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package record
import (
"crypto/sha256"
"encoding/binary"
"errors"
"hash"
"time"
"uuid"
"github.com/deep-rent/nexus/dat/valid"
)
// Version is the canonical encoding's version byte. The encoding is
// frozen: a change to it is a NEW version, never an edit, because a
// shifted byte invalidates every hash ever written.
const Version = 0x01
// Bounds on what one entry may carry.
const (
// MaxOriginLength caps the asserting service's name.
MaxOriginLength = 64
// MaxActionLength caps an action, which follows the webhook topics'
// grammar.
MaxActionLength = 200
// MaxKeyLength caps a producer's idempotency key.
MaxKeyLength = 128
// DigestSize is the exact size of a detail digest: SHA-256, or
// nothing.
DigestSize = sha256.Size
)
// Errors reported for an assertion this package refuses.
var (
// ErrOrigin reports an assertion naming no valid origin.
ErrOrigin = errors.New("an assertion needs an origin")
// ErrAction reports an action outside the topic grammar.
ErrAction = errors.New("invalid audit action")
// ErrKey reports a missing or oversized idempotency key.
ErrKey = errors.New("an assertion needs an idempotency key")
// ErrDigest reports a detail digest that is not SHA-256 sized.
ErrDigest = errors.New("a detail digest is 32 bytes or absent")
// ErrOccurred reports an assertion that does not say when.
ErrOccurred = errors.New("an assertion needs an occurrence time")
)
// Entry is one recorded act, as the trail stores and hashes it.
//
// Actor and Subject hold sealed bytes — identity as ciphertext — and
// ActorKey and SubjectKey name the data keys that sealed them. The key
// identifiers are random rather than the identities they stand for, so
// destroying a key severs the linkage too; see the store.
type Entry struct {
// Seq is the entry's dense position in the trail, assigned by the
// store under the head lock. A gap in the sequence is itself
// evidence of a removal.
Seq int64
// Origin names the asserting service ("iam"), as the producer
// claims it.
Origin string
// Client is the authenticated machine client that made the
// assertion — the claim's provenance, taken from the token rather
// than the body.
Client uuid.UUID
// Action says what happened, in the webhook topics' svc.noun.verb
// grammar; where a public topic exists, the action IS the topic.
Action string
// Actor is the sealed identity of who did it; nil for none (a
// system action).
Actor []byte
// ActorKey names the data key sealing Actor; zero for none.
ActorKey uuid.UUID
// Subject is the sealed identity of whom or what it was done to;
// nil for none.
Subject []byte
// SubjectKey names the data key sealing Subject; zero for none.
SubjectKey uuid.UUID
// OccurredAt is when the producer says it happened — a claim
// inside the entry, as distinct from RecordedAt.
OccurredAt time.Time
// RecordedAt is when this trail sequenced it.
RecordedAt time.Time
// Digest is the SHA-256 of whatever the producer holds as the full
// story, or nil. The trail never stores the story; it stores
// enough for the producer to later prove the story it holds is the
// one it asserted.
Digest []byte
// KeyDigest is SHA-256 over the origin and the producer's
// idempotency key; see the package documentation.
KeyDigest [32]byte
// Leaf and Chain are the entry's hashes, computed by [chain] and
// stored beside it so verification can name exactly where a replay
// diverges. They are not part of the canonical encoding — they are
// derived from it.
//
// [chain]: github.com/deep-rent/nexus/eco/ats/chain
Leaf [32]byte
Chain [32]byte
}
// Truncate normalizes a time to what the canonical encoding renders
// and the database roundtrips: UTC, microsecond precision.
func Truncate(t time.Time) time.Time {
return t.UTC().Truncate(time.Microsecond)
}
// KeyDigest renders a producer's idempotency key as the digest the
// trail stores; see the package documentation for why the raw key
// never lands.
//
// The digest is KEYED, and it has to be. An idempotency key names its
// subject — the estate's own producer builds
// "iam.user.disabled:<uuid>:<nanoseconds>" — and an unkeyed hash of
// that is a commitment anyone can test a guess against. The other
// components are plaintext columns of the same row, and occurred_at
// truncates to the microsecond, so the only unknown left is the
// sub-microsecond remainder: a THOUSAND candidates per subject. That
// is not a search, it is a lookup, and it survives the shred that was
// supposed to make the subject unreadable.
//
// Under a key the guess cannot be tested at all, so a stolen database
// reverses nothing. The key belongs with the one that seals the data
// keys: both are what a backup must not carry, and neither is any use
// without the other.
//
// mac must be a MAC keyed for this trail; see [Keyer]. A nil one
// digests unkeyed, which is what a deployment that has configured no
// key gets — it still dedups, and the service warns.
func KeyDigest(mac Keyer, origin, key string) [32]byte {
h := sha256.New()
if mac != nil {
h = mac.MAC()
}
// Length-prefixed rather than separated. A separator only frames
// unambiguously while the fields cannot contain it, and nothing
// stops a key from carrying a NUL: ("a\x00b", "c") and
// ("a", "b\x00c") would otherwise digest alike, and a collision
// here is an entry the trail accepts as a duplicate and never
// records — the one loss the chain cannot show, since a record
// that was never written leaves no gap.
writeField(h, origin)
writeField(h, key)
var out [32]byte
copy(out[:], h.Sum(nil))
return out
}
// Keyer supplies the MAC that keys an idempotency digest. It is
// satisfied by [sec/seal.Keyring] and by anything else holding a
// symmetric key for this trail.
type Keyer interface {
// MAC returns a fresh keyed hash. Implementations must return the
// same key every time: a digest computed under one key does not
// match one computed under another, and the trail looks up by it.
MAC() hash.Hash
}
// writeField appends one length-prefixed field to a hash.
func writeField(h hash.Hash, field string) {
var n [binary.MaxVarintLen64]byte
_, _ = h.Write(n[:binary.PutUvarint(n[:], uint64(len(field)))])
_, _ = h.Write([]byte(field))
}
// Encode renders the entry in its canonical binary form: the version
// byte, then every field length-prefixed in declared order. Seq, Leaf,
// and Chain are deliberately absent — position is bound by the chain,
// and the hashes are derived from these bytes rather than part of
// them.
func (e Entry) Encode() []byte {
// A typical entry is well under 512 bytes.
out := make([]byte, 0, 512)
out = append(out, Version)
out = lp(out, []byte(e.Origin))
out = lp(out, e.Client[:])
out = lp(out, []byte(e.Action))
out = lp(out, e.ActorKey[:])
out = lp(out, e.Actor)
out = lp(out, e.SubjectKey[:])
out = lp(out, e.Subject)
out = lp(out, micros(e.OccurredAt))
out = lp(out, micros(e.RecordedAt))
out = lp(out, e.Digest)
out = lp(out, e.KeyDigest[:])
return out
}
// lp appends one length-prefixed field. The length is a uvarint, so
// the encoding is self-delimiting and a nil field is one zero byte.
func lp(out, field []byte) []byte {
out = binary.AppendUvarint(out, uint64(len(field)))
return append(out, field...)
}
// micros renders a time as eight big-endian bytes of its microsecond
// count, in UTC. The zero time renders as zero.
func micros(t time.Time) []byte {
var out [8]byte
if !t.IsZero() {
binary.BigEndian.PutUint64(out[:], uint64(t.UTC().UnixMicro()))
}
return out[:]
}
// Assertion is what a producer submits: the act in the clear, before
// the store seals its identities.
type Assertion struct {
// Origin names the asserting service. Required.
Origin string `json:"origin"`
// Action says what happened, in the topic grammar. Required.
Action string `json:"action"`
// Actor is who did it; zero for a system action.
Actor uuid.UUID `json:"actor,omitzero"`
// Subject is whom or what it was done to; zero for none.
Subject uuid.UUID `json:"subject,omitzero"`
// OccurredAt is when the producer says it happened. Required: the
// producer was there, and the trail's own clock only says when the
// news arrived.
OccurredAt time.Time `json:"occurred_at"`
// Digest is the SHA-256 of the producer-side detail, or nil.
Digest []byte `json:"digest,omitzero"`
// Key names this occurrence — not this KIND of occurrence — so an
// at-least-once retry records nothing twice while a genuine
// repetition records again. Required.
Key string `json:"key"`
}
// Validate implements the [valid.Validatable] interface.
func (a *Assertion) Validate(v *valid.Validator) {
v.NotBlank("origin", a.Origin)
v.MaxLen("origin", a.Origin, MaxOriginLength)
v.Slug("origin", a.Origin)
v.NotBlank("action", a.Action)
v.MaxLen("action", a.Action, MaxActionLength)
if a.Action != "" && !valid.Topic(a.Action) {
v.Fail("action", "must follow the svc.noun.verb topic grammar")
}
v.NotBlank("key", a.Key)
v.MaxLen("key", a.Key, MaxKeyLength)
if len(a.Digest) != 0 {
v.Size("digest", len(a.Digest), DigestSize)
}
if a.OccurredAt.IsZero() {
v.Fail("occurred_at", "an assertion needs an occurrence time")
}
}
// Check reports whether the assertion is well-formed, for the paths
// that are not behind a [valid.Validator].
func (a *Assertion) Check() error {
switch {
case a.Origin == "" || len(a.Origin) > MaxOriginLength ||
!valid.Slug(a.Origin):
return ErrOrigin
case a.Action == "" || len(a.Action) > MaxActionLength ||
!valid.Topic(a.Action):
return ErrAction
case a.Key == "" || len(a.Key) > MaxKeyLength:
return ErrKey
case len(a.Digest) != 0 && len(a.Digest) != DigestSize:
return ErrDigest
case a.OccurredAt.IsZero():
return ErrOccurred
}
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package ats
import (
"context"
"fmt"
"net/http"
"time"
"uuid"
"github.com/deep-rent/nexus/eco/ats/anchor"
"github.com/deep-rent/nexus/eco/ats/api"
"github.com/deep-rent/nexus/eco/ats/config"
"github.com/deep-rent/nexus/eco/ats/record"
"github.com/deep-rent/nexus/eco/ats/store"
"github.com/deep-rent/nexus/eco/identity"
"github.com/deep-rent/nexus/net/aws4"
"github.com/deep-rent/nexus/net/notify/hook"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/net/s3"
"github.com/deep-rent/nexus/sec/auth"
"github.com/deep-rent/nexus/sec/vault"
"github.com/deep-rent/nexus/sec/vault/source/file"
"github.com/deep-rent/nexus/std/rotor"
"github.com/deep-rent/nexus/sys/boot"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/schedule"
)
// Paths the service mounts beside the API.
const (
// PathHooks is where the identity service's webhook deliveries
// arrive; see the deployment README for the registration recipe.
PathHooks = "/hooks/iam"
// PathJWKS serves the checkpoint verification keys. Public by
// nature: the whole point is that anyone holding a checkpoint can
// check the signature.
PathJWKS = "/jwks.json"
)
// Tuning constants of the assembled service. They are deliberately not
// configuration: each is a property of this service's own shape rather
// than of the deployment around it.
const (
// MaxBodySize caps a request body at 64 KiB. An assertion is a
// handful of identifiers and a digest, so nothing legitimate comes
// close.
MaxBodySize = 64 << 10
)
// Service is the fully assembled audit trail. Create instances with
// [New], serve them with [Service.Run], or embed [Service.Handler]
// into a custom server.
type Service struct {
cfg config.Config
rt *boot.Runtime
logger *log.Logger
store *store.Store
anchor *anchor.Anchor
}
// New assembles the service from its configuration. It returns an
// error for unusable external inputs — an unreachable database,
// unreadable signing keys, a half-configured target.
//
// The version identifies this build in the User-Agent of every
// outbound request; resolve it via [cli.Version].
//
// [cli.Version]: github.com/deep-rent/nexus/sys/cli#Version
func New(
ctx context.Context,
cfg config.Config,
version string,
) (*Service, error) {
rt, err := boot.New(ctx, boot.Spec{
Name: "ats",
Version: version,
Core: cfg.Core,
Database: &cfg.Database,
Auth: &cfg.Auth,
Sender: &cfg.Hook, // carries only ats.checkpoint.sealed
}, boot.WithMaxBody(MaxBodySize))
if err != nil {
return nil, err
}
s := &Service{cfg: cfg, rt: rt, logger: rt.Logger()}
// The data keys behind the sealed identities. Without a sealing
// key, shredding still works — the row is gone either way — but a
// backup taken before an erasure still opens what it holds.
opts := []store.Option{}
if cfg.Shred.Sealed() {
ring, err := cfg.Shred.Keyring()
if err != nil {
return nil, fmt.Errorf(
"failed to load the shred sealing keys: %w", err,
)
}
opts = append(opts, store.WithSealer(ring))
} else {
s.logger.Warn(ctx,
"Data keys are stored unsealed; set ATS_SHRED_KEY so a "+
"database backup taken before an erasure does not keep "+
"opening the identities it holds",
)
}
s.store = store.New(rt.Pool(), opts...)
rt.Migrate(store.Migrator)
// The signing keys behind the checkpoints. The watcher is a
// schedule.Tick, so a key rotation is an edit to the mounted file
// that every replica converges on — no restart involved.
keys, err := file.Watch(cfg.Vault.File, rotor.Sequential,
vault.WithLogger(s.logger.Child("vault")),
)
if err != nil {
return nil, fmt.Errorf(
"failed to load the checkpoint signing keys: %w", err,
)
}
rt.Tick("vault", keys)
var bucket *s3.Bucket
if c := cfg.Anchor.Bucket; c.Enabled() {
bucket = s3.New(c.URL, aws4.New(aws4.Credentials{
AccessKey: c.AccessKey,
SecretKey: c.SecretKey,
}, c.Region), s3.WithClient(rt.Client()))
}
// The assignment guards the typed-nil trap: a nil *hook.Engine
// stuffed straight into the interface would read as a configured
// target that fails every publish.
var hooks anchor.Publisher
if h := rt.Hooks(); h != nil {
hooks = h
}
s.anchor = anchor.New(anchor.Config{
Store: s.store,
Keys: keys,
Issuer: cfg.Anchor.Issuer,
Bucket: bucket,
Prefix: cfg.Anchor.Prefix,
Hooks: hooks,
Client: rt.Client(),
Logger: s.logger.Child("anchor"),
})
if !s.anchor.Anchored() {
s.logger.Warn(ctx,
"No anchor target configured; a chain anchored nowhere is "+
"a decoration — configure the bucket, the webhook "+
"engine, or both",
)
}
rt.Every("anchor", cfg.Anchor.Interval,
schedule.TaskFn(s.anchor.Seal))
r := rt.Router()
guard := rt.Guard()
server := api.New(api.Config{Store: s.store, Origins: cfg})
if _, bound := cfg.Origin(uuid.Nil()); !bound {
s.logger.Warn(ctx,
"No origin bindings; a producer names itself, so any "+
"holder of the append scope can assert as another "+
"service — and can squat the idempotency key that "+
"service will later use, which answers the real "+
"assertion 200 duplicate and records nothing",
)
}
// Both surfaces are machine-only, each behind its own scope that
// no role grants: a person's token must neither write history nor
// browse it.
server.MountAppend(r, guard.Secure(
auth.Machine(), auth.Grants{}.Require(api.PermissionAppend),
))
server.MountRead(r, guard.Secure(
auth.Machine(), auth.Grants{}.Require(api.PermissionRead),
))
// The verification keys, public by nature.
r.Handle(http.MethodGet, PathJWKS, vault.Handler(keys))
// The identity service announces deleted principals; their data
// keys are destroyed in response. The receiver authenticates by
// signature, not bearer.
if cfg.Intake.Enabled() {
rcv, err := rt.Receiver(cfg.Intake)
if err != nil {
return nil, err
}
rcv.
On(identity.TopicUserDeleted, s.shred(identity.Event.User)).
On(identity.TopicTeamDissolved, s.shred(identity.Event.Team)).
Mount(r, PathHooks)
} else {
s.logger.Warn(ctx,
"No identity webhook secret; erased principals keep "+
"readable identities in the trail until Shred is "+
"called by hand",
)
}
s.logger.Info(ctx, "Assembled ATS service",
log.Bool("sealed", s.store.Sealed()),
log.Bool("bucket", bucket != nil),
log.Bool("hooks", rt.Hooks() != nil),
log.String("issuer", cfg.Auth.Issuer),
log.Duration("anchor", cfg.Anchor.Interval),
)
return s, nil
}
// shred builds the intake handler destroying one kind of principal's
// data key on the identifier its events carry.
//
// A failure answers 5xx so the sender retries, which is the right
// trade: the alternative is an erased person whose identity stays
// readable in the trail.
func (s *Service) shred(
pick func(identity.Event) (uuid.UUID, bool),
) hook.Accept {
return func(e *router.Exchange, d hook.Delivery) error {
ev, err := identity.Decode(d)
if err != nil {
return err
}
id, ok := pick(ev)
if !ok {
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: "the event names no principal",
}
}
gone, err := s.store.Shred(e.Context(), id)
if err != nil {
return fmt.Errorf("failed to shred a data key: %w", err)
}
if !gone {
return nil // Already erased; a redelivery costs nothing.
}
s.logger.Info(e.Context(), "Shredded a data key",
log.UUID("owner", id))
// The destruction goes into the trail. It is irreversible and
// it is the one act this service performs on its own account,
// so a trail that records everyone else's acts and not this
// one could not answer when an identity stopped being
// readable, or on whose say-so. The entry names the owner in
// the subject, which costs nothing now: the key that would
// have made it readable is already gone, so it records as
// erased — which is precisely the fact being asserted.
if _, _, err := s.store.Append(e.Context(), uuid.Nil(),
record.Assertion{
Origin: OriginSelf,
Action: ActionShredded,
Subject: id,
OccurredAt: time.Now().UTC(),
Key: "shred:" + id.String(),
}, time.Now().UTC(),
); err != nil {
// The key is already destroyed, so refusing here would
// have the sender retry a shred that cannot happen twice
// and never record it either. The erasure stands; the
// missing entry is loud in the log.
s.logger.Error(e.Context(),
"Shredded a data key but could not record it",
log.UUID("owner", id), log.Error(err))
}
return nil
}
}
// The trail's vocabulary for what it does on its own account.
const (
// OriginSelf is the origin of an entry this service asserts about
// itself rather than on a producer's behalf.
OriginSelf = "ats"
// ActionShredded records a data key destroyed in response to an
// erasure. The subject is the owner whose identity stopped being
// readable, which the entry itself reports as erased — the key is
// gone by the time it is written.
ActionShredded = "ats.key.shredded"
)
// Handler returns the assembled HTTP handler, for embedding the API
// into a custom server.
func (s *Service) Handler() http.Handler { return s.rt.Handler() }
// Run serves the trail until the context is canceled or a termination
// signal arrives.
func (s *Service) Run(ctx context.Context) error { return s.rt.Run(ctx) }
// Store is the trail's persistence, for a host embedding this service
// rather than running it.
func (s *Service) Store() *store.Store { return s.store }
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package store
import (
"context"
"crypto/rand"
"database/sql"
"embed"
"errors"
"fmt"
"io/fs"
"time"
"uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/deep-rent/nexus/dat/migrate"
driver "github.com/deep-rent/nexus/dat/migrate/driver/postgres"
source "github.com/deep-rent/nexus/dat/migrate/source/file"
"github.com/deep-rent/nexus/eco/ats/chain"
"github.com/deep-rent/nexus/eco/ats/record"
"github.com/deep-rent/nexus/sec/seal"
)
//go:embed migrations
var migrations embed.FS
// Module is the migration stream the trail schema lives in.
const Module = "ats"
// Migrations exposes the embedded schema migrations for [migrate]
// paired with its PostgreSQL driver.
func Migrations() fs.FS {
sub, err := fs.Sub(migrations, "migrations")
if err != nil {
// The subdirectory is embedded at compile time; failing to open
// it is a build defect, not a runtime condition.
panic(err)
}
return sub
}
// Migrator wires a [migrate.Migrator] for the trail schema over an
// existing database handle.
func Migrator(db *sql.DB, opts ...migrate.Option) *migrate.Migrator {
return migrate.New(append([]migrate.Option{
migrate.WithModule(Module),
migrate.WithSource(source.New(Migrations())),
migrate.WithDriver(driver.New(db)),
}, opts...)...)
}
// Open connects a migrator for the trail schema to the database at
// url, for commands that only run migrations. The returned close
// function releases the connection.
func Open(
url string,
opts ...migrate.Option,
) (*migrate.Migrator, func() error, error) {
return driver.Open(url, append([]migrate.Option{
migrate.WithModule(Module),
migrate.WithSource(source.New(Migrations())),
}, opts...)...)
}
// The AAD labels binding sealed fields to their meaning. Row integrity
// is the chain's job, not the seal's; the labels only stop a ciphertext
// from being read back as the wrong field.
const (
aadActor = "ats:actor"
aadSubject = "ats:subject"
)
// ErrTampered reports a verification that found the trail rewritten.
// Everything wrapped around it names the sequence and the kind of
// divergence; see [chain].
var ErrTampered = errors.New("the audit trail does not verify")
// ErrShredded reports an owner whose data key has been destroyed. It
// is not a failure: an assertion naming an erased principal is
// recorded with the identity left out, which is the only honest thing
// the trail can say about somebody it has been told to forget.
var ErrShredded = errors.New("the owner has been erased")
// Option configures a [Store].
type Option func(*Store)
// WithSealer encrypts the per-owner data keys at rest under the given
// keyring. Without one they are stored raw, which is a test-rig
// setting; see the package documentation. A nil keyring is ignored.
func WithSealer(ring *seal.Keyring) Option {
return func(s *Store) {
if ring != nil {
s.ring = ring
}
}
}
// Store persists the trail. It is safe for concurrent use and carries
// no logger: every operation returns its error, and narrating outcomes
// is its callers' business.
type Store struct {
pool *pgxpool.Pool
ring *seal.Keyring
}
// New creates a [Store] over the given connection pool. It panics on a
// nil pool (programmer error).
func New(pool *pgxpool.Pool, opts ...Option) *Store {
if pool == nil {
panic("pool is required")
}
s := &Store{pool: pool}
for _, opt := range opts {
opt(s)
}
return s
}
// keyer returns the ring as a [record.Keyer], or a genuinely nil one
// where no ring is configured.
//
// Assigning s.ring straight into the interface would not do: a nil
// *seal.Keyring inside an interface is not a nil interface, so the
// callee's nil check passes and the call panics. The same trap the
// service guards at the anchor's publisher seam.
func (s *Store) keyer() record.Keyer {
if s.ring == nil {
return nil
}
return s.ring
}
// Sealed reports whether the data keys are encrypted at rest.
func (s *Store) Sealed() bool { return s.ring != nil }
// Append records one assertion: it locks the head, assigns the next
// dense sequence number, seals the identities, extends the chain, and
// writes the entry and the new head in one transaction.
//
// The boolean reports whether the entry is fresh. A repeated key —
// the producer's at-least-once retry — returns the entry already
// recorded, so both calls agree about what the trail holds.
func (s *Store) Append(
ctx context.Context,
client uuid.UUID,
a record.Assertion,
now time.Time,
) (record.Entry, bool, error) {
if err := a.Check(); err != nil {
return record.Entry{}, false, err
}
var (
out record.Entry
fresh bool
)
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
head, err := lockHead(ctx, tx)
if err != nil {
return err
}
// The head lock serializes appends, so the dedup check cannot
// race a concurrent duplicate.
digest := record.KeyDigest(s.keyer(), a.Origin, a.Key)
held, ok, err := entryByKey(ctx, tx, digest)
if err != nil {
return err
}
if ok {
out, fresh = held, false
return nil
}
e := record.Entry{
Origin: a.Origin,
Client: client,
Action: a.Action,
OccurredAt: record.Truncate(a.OccurredAt),
RecordedAt: record.Truncate(now),
Digest: a.Digest,
KeyDigest: digest,
}
if e.Actor, e.ActorKey, err = s.sealIdentity(
ctx, tx, a.Actor, aadActor, e.RecordedAt,
); err != nil {
return err
}
if e.Subject, e.SubjectKey, err = s.sealIdentity(
ctx, tx, a.Subject, aadSubject, e.RecordedAt,
); err != nil {
return err
}
e, next := chain.Extend(head, e)
if err := insertEntry(ctx, tx, e); err != nil {
return err
}
if err := saveHead(ctx, tx, next); err != nil {
return err
}
out, fresh = e, true
return nil
})
if err != nil {
return record.Entry{}, false, err
}
return out, fresh, nil
}
// sealIdentity encrypts one identity under its owner's data key,
// minting the key on first sight. A zero identity seals nothing.
func (s *Store) sealIdentity(
ctx context.Context,
tx pgx.Tx,
owner uuid.UUID,
aad string,
now time.Time,
) ([]byte, uuid.UUID, error) {
if owner == uuid.Nil() {
return nil, uuid.Nil(), nil
}
id, key, err := s.dataKey(ctx, tx, owner, now)
if errors.Is(err, ErrShredded) {
// Recorded without the identity. The act still belongs in the
// trail — that somebody was disabled is history — but naming
// whom would undo the erasure, so the entry names nobody and
// reads exactly like one whose key was destroyed after the
// fact, which is what happened.
return nil, uuid.Nil(), nil
}
if err != nil {
return nil, uuid.Nil(), err
}
ring, err := seal.NewKeyring(key)
if err != nil {
return nil, uuid.Nil(), fmt.Errorf(
"failed to build a data keyring: %w", err,
)
}
sealed, err := ring.Seal(owner[:], []byte(aad))
if err != nil {
return nil, uuid.Nil(), fmt.Errorf(
"failed to seal an identity: %w", err,
)
}
return sealed, id, nil
}
// dataKey returns the owner's data key, minting one on first sight.
// The identifier is random rather than derived, so a dangling
// reference after a shred names nobody.
func (s *Store) dataKey(
ctx context.Context,
tx pgx.Tx,
owner uuid.UUID,
now time.Time,
) (uuid.UUID, seal.Key, error) {
// FOR KEY SHARE, so a Shred committing in parallel orders against
// this read rather than slipping between it and the insert.
const get = `SELECT id, key FROM data_keys
WHERE owner = $1 FOR KEY SHARE`
var (
id uuid.UUID
stored []byte
)
err := tx.QueryRow(ctx, get, owner).Scan(&id, &stored)
switch {
case errors.Is(err, pgx.ErrNoRows):
return s.mintDataKey(ctx, tx, owner, now)
case err != nil:
return uuid.Nil(), seal.Key{}, err
case stored == nil:
// The owner was erased. Minting a fresh key here is what let a
// late append seal the identity back in after the erasure had
// been reported complete, so nothing is sealed: the entry
// names nobody and reads as erased, which is the truth.
return uuid.Nil(), seal.Key{}, ErrShredded
}
material, err := s.openKey(owner, stored)
if err != nil {
return uuid.Nil(), seal.Key{}, err
}
return id, seal.Key{ID: id.String(), Material: material}, nil
}
// mintDataKey creates the owner's data key: 32 random bytes, sealed
// under the service keyring where one is configured.
func (s *Store) mintDataKey(
ctx context.Context,
tx pgx.Tx,
owner uuid.UUID,
now time.Time,
) (uuid.UUID, seal.Key, error) {
// Random, not v7: a UUIDv7 embeds its creation time, and this
// identifier outlives the shred that destroys the key it names.
// It would otherwise disclose, to within a fraction of a
// microsecond, when an erased principal was first recorded — and
// it is baked into the leaf hash, so it could never be scrubbed.
id := uuid.NewV4()
key, err := seal.GenerateKey(id.String(), rand.Reader)
if err != nil {
return uuid.Nil(), seal.Key{}, fmt.Errorf(
"failed to mint a data key: %w", err,
)
}
stored := key.Material
if s.ring != nil {
if stored, err = s.ring.Seal(
key.Material, owner[:],
); err != nil {
return uuid.Nil(), seal.Key{}, fmt.Errorf(
"failed to seal a data key: %w", err,
)
}
}
const put = `INSERT INTO data_keys (id, owner, key, created_at)
VALUES ($1, $2, $3, $4)`
if _, err := tx.Exec(ctx, put, id, owner, stored, now); err != nil {
return uuid.Nil(), seal.Key{}, err
}
return id, key, nil
}
// openKey decrypts stored data-key material.
func (s *Store) openKey(
owner uuid.UUID,
stored []byte,
) ([]byte, error) {
if s.ring == nil {
return stored, nil
}
out, err := s.ring.Open(stored, owner[:])
if err != nil {
return nil, fmt.Errorf("failed to open a data key: %w", err)
}
return out, nil
}
// Shred destroys an owner's data key, reporting whether one existed.
// Every entry sealed under it stands and verifies; the identity inside
// becomes unrecoverable, and the dangling key identifier names nobody.
// Idempotent: a redelivered erasure event shreds nothing twice.
func (s *Store) Shred(
ctx context.Context,
owner uuid.UUID,
) (bool, error) {
// The row stays, its material gone. Deleting it outright left
// nothing to distinguish "erased" from "never seen", so a later
// append minted a new key and undid the erasure.
const q = `UPDATE data_keys SET key = NULL,
shredded_at = CURRENT_TIMESTAMP
WHERE owner = $1 AND key IS NOT NULL`
tag, err := s.pool.Exec(ctx, q, owner)
if err != nil {
return false, err
}
return tag.RowsAffected() > 0, nil
}
// lockHead reads the running head under FOR UPDATE — the append
// serialization point.
func lockHead(ctx context.Context, tx pgx.Tx) (chain.Head, error) {
const q = `SELECT seq, hash FROM trail_head WHERE id FOR UPDATE`
var (
head chain.Head
raw []byte
)
if err := tx.QueryRow(ctx, q).Scan(&head.Seq, &raw); err != nil {
return chain.Head{}, fmt.Errorf(
"failed to lock the trail head: %w", err,
)
}
copy(head.Hash[:], raw)
return head, nil
}
// saveHead advances the running head. The caller holds the lock.
func saveHead(ctx context.Context, tx pgx.Tx, head chain.Head) error {
const q = `UPDATE trail_head SET seq = $1, hash = $2 WHERE id`
_, err := tx.Exec(ctx, q, head.Seq, head.Hash[:])
return err
}
// Head reads the running head without locking it.
func (s *Store) Head(ctx context.Context) (chain.Head, error) {
const q = `SELECT seq, hash FROM trail_head WHERE id`
var (
head chain.Head
raw []byte
)
if err := s.pool.QueryRow(ctx, q).Scan(&head.Seq, &raw); err != nil {
return chain.Head{}, err
}
copy(head.Hash[:], raw)
return head, nil
}
// entryColumns is the read shape of an entry, in scanEntry order.
const entryColumns = `seq, origin, client, action,
COALESCE(actor, ''::bytea),
COALESCE(actor_key, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(subject, ''::bytea),
COALESCE(subject_key, '00000000-0000-0000-0000-000000000000'::uuid),
occurred_at, recorded_at,
COALESCE(digest, ''::bytea), key_digest, leaf, chain`
// scanEntry reads one entry row in entryColumns order.
func scanEntry(row pgx.Row) (record.Entry, error) {
var (
e record.Entry
keyDigest []byte
leaf, chainHash []byte
occurred, recorded time.Time
)
err := row.Scan(
&e.Seq, &e.Origin, &e.Client, &e.Action,
&e.Actor, &e.ActorKey, &e.Subject, &e.SubjectKey,
&occurred, &recorded, &e.Digest, &keyDigest,
&leaf, &chainHash,
)
if err != nil {
return record.Entry{}, err
}
// Empty sentinels collapse back to nil, so the canonical bytes a
// verifier recomputes match the ones that were hashed.
if len(e.Actor) == 0 {
e.Actor = nil
}
if len(e.Subject) == 0 {
e.Subject = nil
}
if len(e.Digest) == 0 {
e.Digest = nil
}
e.OccurredAt = record.Truncate(occurred)
e.RecordedAt = record.Truncate(recorded)
copy(e.KeyDigest[:], keyDigest)
copy(e.Leaf[:], leaf)
copy(e.Chain[:], chainHash)
return e, nil
}
// insertEntry writes one hashed entry.
func insertEntry(ctx context.Context, tx pgx.Tx, e record.Entry) error {
const q = `INSERT INTO entries (
seq, origin, client, action, actor, actor_key,
subject, subject_key, occurred_at, recorded_at,
digest, key_digest, leaf, chain
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`
_, err := tx.Exec(ctx, q,
e.Seq, e.Origin, e.Client, e.Action,
nullable(e.Actor), nullableID(e.ActorKey),
nullable(e.Subject), nullableID(e.SubjectKey),
e.OccurredAt, e.RecordedAt,
nullable(e.Digest), e.KeyDigest[:], e.Leaf[:], e.Chain[:],
)
return err
}
// nullable renders an absent byte field as NULL.
func nullable(b []byte) any {
if len(b) == 0 {
return nil
}
return b
}
// nullableID renders a zero identifier as NULL.
func nullableID(id uuid.UUID) any {
if id == uuid.Nil() {
return nil
}
return id
}
// entryByKey answers the dedup lookup. The caller holds the head lock.
func entryByKey(
ctx context.Context,
tx pgx.Tx,
digest [32]byte,
) (record.Entry, bool, error) {
const q = `SELECT ` + entryColumns + `
FROM entries WHERE key_digest = $1`
e, err := scanEntry(tx.QueryRow(ctx, q, digest[:]))
if errors.Is(err, pgx.ErrNoRows) {
return record.Entry{}, false, nil
}
if err != nil {
return record.Entry{}, false, err
}
return e, true, nil
}
// Entries reads a page of the trail after the given sequence number,
// ascending — the shape both the API listing and the verification
// replay consume.
func (s *Store) Entries(
ctx context.Context,
after int64,
limit int,
) ([]record.Entry, error) {
const q = `SELECT ` + entryColumns + `
FROM entries WHERE seq > $1 ORDER BY seq LIMIT $2`
rows, err := s.pool.Query(ctx, q, after, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []record.Entry{}
for rows.Next() {
e, err := scanEntry(rows)
if err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package store
import (
"context"
"errors"
"fmt"
"time"
"uuid"
"github.com/jackc/pgx/v5"
"github.com/deep-rent/nexus/eco/ats/chain"
"github.com/deep-rent/nexus/eco/ats/record"
"github.com/deep-rent/nexus/sec/seal"
)
// VerifyBatch is how many entries one verification round trip reads.
const VerifyBatch = 1000
// Identity is one entry's identity fields, opened where the data keys
// survive.
type Identity struct {
// Actor is who did it; zero when the entry named none OR the key
// is gone — Erased tells the two apart.
Actor uuid.UUID
// ActorErased reports a sealed actor whose key was destroyed: the
// entry names somebody, and that somebody is no longer
// recoverable.
ActorErased bool
// Subject and SubjectErased mirror the actor.
Subject uuid.UUID
SubjectErased bool
}
// Resolve opens the identity fields of the given entries, loading each
// referenced data key once. Entries whose keys were shredded resolve
// as erased rather than failing: an erased identity is an answer, not
// an error.
func (s *Store) Resolve(
ctx context.Context,
entries []record.Entry,
) ([]Identity, error) {
ids := make(map[uuid.UUID]bool)
for _, e := range entries {
if e.ActorKey != uuid.Nil() {
ids[e.ActorKey] = true
}
if e.SubjectKey != uuid.Nil() {
ids[e.SubjectKey] = true
}
}
keys, err := s.loadKeys(ctx, ids)
if err != nil {
return nil, err
}
out := make([]Identity, len(entries))
for i, e := range entries {
var id Identity
id.Actor, id.ActorErased, err = open(
keys, e.ActorKey, e.Actor, aadActor,
)
if err != nil {
return nil, fmt.Errorf("entry %d: %w", e.Seq, err)
}
id.Subject, id.SubjectErased, err = open(
keys, e.SubjectKey, e.Subject, aadSubject,
)
if err != nil {
return nil, fmt.Errorf("entry %d: %w", e.Seq, err)
}
out[i] = id
}
return out, nil
}
// loadKeys reads and opens the named data keys, keyed by their random
// identifiers. Shredded keys are simply absent.
func (s *Store) loadKeys(
ctx context.Context,
ids map[uuid.UUID]bool,
) (map[uuid.UUID]seal.Key, error) {
out := make(map[uuid.UUID]seal.Key, len(ids))
if len(ids) == 0 {
return out, nil
}
wanted := make([]uuid.UUID, 0, len(ids))
for id := range ids {
wanted = append(wanted, id)
}
const q = `SELECT id, owner, key FROM data_keys WHERE id = ANY($1)`
rows, err := s.pool.Query(ctx, q, wanted)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var (
id, owner uuid.UUID
stored []byte
)
if err := rows.Scan(&id, &owner, &stored); err != nil {
return nil, err
}
if stored == nil {
// Shredded: the row survives as the tombstone that stops a
// late append from minting a replacement, but there is no
// material left. Absent from the map is exactly how open
// reports an erased identity.
continue
}
material, err := s.openKey(owner, stored)
if err != nil {
// A key this ring cannot open — a shred key rotated
// without the displaced one being carried into the
// retired set — must not take the page down with it.
// Failing here made one botched rotation a permanent 500
// on every page that key touched, with no cursor able to
// step past it, so the trail became unreadable through its
// only read surface.
//
// It reads as erased instead, which is what it is from
// here: an identity sealed under a key nobody holds cannot
// be recovered, and that is the same answer a destroyed
// key gives. The difference matters to the operator rather
// than the reader, and belongs in a startup check that the
// ring opens what is stored — not in a 500 per page.
continue
}
out[id] = seal.Key{ID: id.String(), Material: material}
}
return out, rows.Err()
}
// open decrypts one sealed identity field under its data key.
func open(
keys map[uuid.UUID]seal.Key,
keyID uuid.UUID,
sealed []byte,
aad string,
) (uuid.UUID, bool, error) {
if keyID == uuid.Nil() {
return uuid.Nil(), false, nil // the entry named nobody
}
key, ok := keys[keyID]
if !ok {
return uuid.Nil(), true, nil // shredded
}
ring, err := seal.NewKeyring(key)
if err != nil {
return uuid.Nil(), false, err
}
raw, err := ring.Open(sealed, []byte(aad))
if err != nil {
return uuid.Nil(), false, fmt.Errorf(
"failed to open an identity: %w", err,
)
}
if len(raw) != 16 {
return uuid.Nil(), false, errors.New(
"an identity is not an identifier",
)
}
var id uuid.UUID
copy(id[:], raw)
return id, false, nil
}
// Verify replays the whole chain from the given head and settles the
// replay against the trail's recorded head. It returns where the
// replay ended and how many entries it covered; a divergence wraps
// [ErrTampered] and names the sequence and kind.
//
// Pass [chain.Head]{} to replay from genesis, or an anchored
// checkpoint to verify the suffix it does not cover.
func (s *Store) Verify(
ctx context.Context,
from chain.Head,
) (chain.Head, int64, error) {
w := chain.NewWalker(from)
var n int64
for {
batch, err := s.Entries(ctx, w.Head().Seq, VerifyBatch)
if err != nil {
return w.Head(), n, err
}
if len(batch) == 0 {
break
}
for _, e := range batch {
if err := w.Step(e); err != nil {
return w.Head(), n, fmt.Errorf(
"%w: %w", ErrTampered, err,
)
}
n++
}
}
head, err := s.Head(ctx)
if err != nil {
return w.Head(), n, err
}
if err := w.Settle(head); err != nil {
return w.Head(), n, fmt.Errorf("%w: %w", ErrTampered, err)
}
return w.Head(), n, nil
}
// Checkpoint is one anchored head, as it was published.
type Checkpoint struct {
// Seq and Hash are the head the checkpoint covers.
Seq int64
Hash [32]byte
// Token is the signed statement, exactly as published.
Token string
// CreatedAt is when it was minted.
CreatedAt time.Time
}
// SaveCheckpoint records an anchored head beside the trail.
func (s *Store) SaveCheckpoint(
ctx context.Context,
c Checkpoint,
) error {
const q = `INSERT INTO checkpoints (seq, hash, token, created_at)
VALUES ($1, $2, $3, $4)`
_, err := s.pool.Exec(ctx, q, c.Seq, c.Hash[:], c.Token, c.CreatedAt)
return err
}
// LatestCheckpoint reads the newest anchored head, reporting whether
// one exists.
func (s *Store) LatestCheckpoint(
ctx context.Context,
) (Checkpoint, bool, error) {
const q = `SELECT seq, hash, token, created_at
FROM checkpoints ORDER BY seq DESC LIMIT 1`
c, err := scanCheckpoint(s.pool.QueryRow(ctx, q))
if errors.Is(err, pgx.ErrNoRows) {
return Checkpoint{}, false, nil
}
if err != nil {
return Checkpoint{}, false, err
}
return c, true, nil
}
// Checkpoints reads every anchored head, ascending — what a full
// verification cross-checks the replay against.
func (s *Store) Checkpoints(ctx context.Context) ([]Checkpoint, error) {
const q = `SELECT seq, hash, token, created_at
FROM checkpoints ORDER BY seq`
rows, err := s.pool.Query(ctx, q)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Checkpoint{}
for rows.Next() {
c, err := scanCheckpoint(rows)
if err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// scanCheckpoint reads one checkpoint row.
func scanCheckpoint(row pgx.Row) (Checkpoint, error) {
var (
c Checkpoint
raw []byte
)
err := row.Scan(&c.Seq, &raw, &c.Token, &c.CreatedAt)
if err != nil {
return Checkpoint{}, err
}
copy(c.Hash[:], raw)
return c, nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package attest
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"uuid"
"github.com/deep-rent/nexus/eco/client"
"github.com/deep-rent/nexus/sec/token"
"github.com/deep-rent/nexus/sec/token/oauth"
"github.com/deep-rent/nexus/std/clock"
)
// PathEntries is where the trail accepts an assertion, relative to its
// base URL.
const PathEntries = "/entries"
// PermissionAppend is the scope a caller's machine token must carry,
// spelled out here so an operator registering a client can read it
// from the package that calls it.
const PermissionAppend = "ats:append"
// Errors a caller is expected to tell apart.
var (
// ErrRefused reports an assertion the trail would not take: a
// malformed action, a missing key, an oversized field. No retry
// mends it.
ErrRefused = errors.New("assertion refused")
// ErrUnauthorized reports credentials the trail would not accept —
// an operator's problem rather than a transient one.
ErrUnauthorized = errors.New("attestation credentials refused")
)
// Request is one assertion: who did what, to whom, when, under which
// occurrence key. It carries no story — see the package documentation.
type Request struct {
// Action says what happened, in the topic grammar; where a public
// webhook topic exists, use it verbatim. Required.
Action string `json:"action"`
// Actor is who did it; zero for a system action.
Actor uuid.UUID `json:"actor,omitzero"`
// Subject is whom or what it was done to; zero for none.
Subject uuid.UUID `json:"subject,omitzero"`
// OccurredAt is when it happened. The zero value lets [Publisher]
// stamp the moment of the call, which is right only when the call
// is made where the act happened.
OccurredAt time.Time `json:"occurred_at"`
// Digest is the SHA-256 of the caller-side detail, or nil.
Digest []byte `json:"digest,omitzero"`
// Key names this occurrence. Required; see the package
// documentation.
Key string `json:"key"`
}
// Receipt is where an assertion landed.
type Receipt struct {
// Seq is the entry's position in the trail.
Seq int64 `json:"seq"`
// Chain is the entry's chain hash, base64url — the caller's own
// proof-of-record, worth logging.
Chain string `json:"chain"`
// Duplicate reports a replayed key; the rest describes the entry
// already recorded.
Duplicate bool `json:"duplicate,omitzero"`
}
// Option configures a [Publisher].
type Option func(*Publisher)
// WithClient sets the HTTP client used to reach the trail. A nil
// client is ignored.
func WithClient(c *http.Client) Option {
return func(p *Publisher) {
if c != nil {
p.http = c
}
}
}
// WithClock injects the time source stamping an assertion whose
// occurrence time the caller left zero. A nil clock is ignored.
func WithClock(now clock.Clock) Option {
return func(p *Publisher) {
if now != nil {
p.now = now
}
}
}
// Publisher asserts acts to the audit trail. Safe for concurrent use.
type Publisher struct {
peer *client.Client
origin string
http *http.Client
now clock.Clock
}
// New builds a publisher against the trail at base, asserting as
// origin, authenticating with tokens minted by src.
func New(
base, origin string,
src *token.Source,
opts ...Option,
) *Publisher {
if origin == "" {
panic("an origin is required")
}
p := &Publisher{origin: origin, now: clock.System}
for _, opt := range opts {
opt(p)
}
// Built last, so an option supplying the HTTP client is honoured.
// A missing base or source panics here, in [client.New].
p.peer = client.New(base, src, client.WithHTTP(p.http))
return p
}
// wire is the append body: the request plus the origin the publisher
// was built with.
type wire struct {
Request
Origin string `json:"origin"`
}
// Record asserts one act.
//
// The error is [ErrRefused] or [ErrUnauthorized] for the failures no
// retry will mend, and an ordinary error for the ones a caller's own
// queue should try again; [Permanent] tells them apart.
func (p *Publisher) Record(
ctx context.Context,
req Request,
) (Receipt, error) {
if req.OccurredAt.IsZero() {
req.OccurredAt = p.now()
}
var out Receipt
err := p.peer.Do(ctx, client.Call{
Method: http.MethodPost,
Path: PathEntries,
Body: wire{Request: req, Origin: p.origin},
Into: &out,
// A fresh record answers 201 and a replayed key 200; both are
// the trail telling us where the assertion stands.
Accept: []int{http.StatusCreated, http.StatusOK},
})
if err != nil {
return Receipt{}, classify(err)
}
return out, nil
}
// classify maps a refusal onto this contract's vocabulary. Anything
// that is not a refusal — a transport failure, an unmintable token —
// passes through as itself, and is worth another attempt.
func classify(err error) error {
var fault *client.APIError
if !errors.As(err, &fault) {
return fmt.Errorf("failed to reach the audit trail: %w", err)
}
switch fault.Status {
case http.StatusUnauthorized, http.StatusForbidden:
return fmt.Errorf("%w: %s", ErrUnauthorized, fault.Description)
case http.StatusBadRequest, http.StatusUnprocessableEntity:
return fmt.Errorf("%w: %s", ErrRefused, fault.Description)
}
return fmt.Errorf(
"the audit trail answered %d: %s",
fault.Status, fault.Description,
)
}
// Permanent reports whether an error from [Publisher.Record] is one no
// retry will mend. Everything else deserves the caller's full retry
// budget: a dead-lettered attestation is an operator's signal that
// history has a hole.
func Permanent(err error) bool {
return errors.Is(err, ErrRefused) || errors.Is(err, ErrUnauthorized)
}
// Config declares how a service reaches the audit trail.
//
// No field carries a required tag: whether a deployment may run
// without attestation is the SERVICE's judgment rather than the
// section's — the same division [identity.Config] draws.
//
// [identity.Config]: github.com/deep-rent/nexus/eco/identity#Config
type Config struct {
// URL is the trail's base URL. Empty leaves acts unattested; see
// [Config.Enabled].
URL string
// Origin is the name this service asserts under; conventionally
// its own short name.
Origin string
// Credentials are this service's own machine credentials; the token
// endpoint is the IDENTITY service's, since that is who mints.
client.Credentials `env:",inline"`
// Scope is what the minted token asks for.
Scope string `env:",default:'ats:append'"`
}
// Enabled reports whether an audit trail is configured.
func (c Config) Enabled() bool {
return c.URL != "" && c.Origin != "" && c.Complete()
}
// Open builds a publisher from its configuration. It panics on a
// configuration [Config.Enabled] rejects — check first, and decide
// there whether the absence is fatal.
func Open(cfg Config, client *http.Client, opts ...Option) *Publisher {
if !cfg.Enabled() {
panic("trail URL, origin, token URL and credentials are required")
}
return New(cfg.URL, cfg.Origin, oauth.ClientCredentials(oauth.Client{
Endpoint: cfg.TokenURL,
ID: cfg.ClientID,
Secret: cfg.ClientSecret,
Scope: cfg.Scope,
HTTP: client,
}), append([]Option{WithClient(client)}, opts...)...)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package client
import (
"bytes"
"context"
"encoding/json/v2"
"fmt"
"io"
"net/http"
"strings"
"github.com/deep-rent/nexus/sec/token"
"github.com/deep-rent/nexus/sec/token/oauth"
)
// MaxBody caps a response read. Every answer in this tier is a small
// JSON document — a receipt, a page of people — so this is a guard
// against a peer behaving badly rather than a working limit.
const MaxBody = 1 << 20
// Credentials are the machine credentials a service reaches a sibling
// with: the client-credentials grant, and the endpoint that mints it.
//
// Embed it in a service's own configuration section, which adds the
// base URL and whatever else that client needs, and decides what
// complete means:
//
// type Config struct {
// URL string
// client.Credentials `env:",inline"`
// }
type Credentials struct {
// TokenURL is where the client-credentials grant is exchanged.
// This is the IDENTITY service's token endpoint, which is not
// generally the service being called.
TokenURL string `env:"TOKEN_URL"`
// ClientID and ClientSecret are the calling service's own machine
// credentials, registered as an OAuth client at the identity
// service.
ClientID string `env:"CLIENT_ID"`
ClientSecret string `env:"CLIENT_SECRET"`
}
// Note that the scope is deliberately absent. Every consumer defaults
// it to the one permission its contract needs, and an env default is
// per-field, so the tag cannot be shared — see [Credentials.Source].
// Complete reports whether the credentials are wholly configured. A
// consumer's own Enabled decides what else its client needs.
func (c Credentials) Complete() bool {
return c.TokenURL != "" && c.ClientID != "" && c.ClientSecret != ""
}
// Source mints access tokens through the client-credentials grant,
// cached and refreshed just before they expire.
//
// The scope is passed rather than held: a machine client acts on its
// vetted scopes alone, and which permission that is belongs to the
// contract being called rather than to the credentials.
func (c Credentials) Source(
scope string,
http *http.Client,
opts ...token.Option,
) *token.Source {
return oauth.ClientCredentials(oauth.Client{
Endpoint: c.TokenURL,
ID: c.ClientID,
Secret: c.ClientSecret,
Scope: scope,
HTTP: http,
}, opts...)
}
// APIError is a refusal from the peer: the status, and the problem
// shape the routers in this repository answer with. The name matches
// [push.APIError], which is the same idea one tier down.
//
// [push.APIError]: github.com/deep-rent/nexus/net/notify/push#APIError
//
// Callers map it onto their own vocabulary rather than surfacing it
// raw, since what is permanent and what is worth retrying is a property
// of the contract rather than of the transport.
type APIError struct {
// Status is the HTTP status the peer answered.
Status int
// Reason is the machine-readable reason code, empty when the peer
// sent none.
Reason string
// Description is the human-readable detail, falling back to the
// response body when the peer sent no problem document.
Description string
}
// Error implements the [error] interface.
func (f *APIError) Error() string {
return fmt.Sprintf("peer answered %d: %s", f.Status, f.Description)
}
// Option configures a [Client].
type Option func(*Client)
// WithHTTP sets the HTTP client the calls travel on. A nil client is
// ignored.
func WithHTTP(h *http.Client) Option {
return func(c *Client) {
if h != nil {
c.http = h
}
}
}
// Client performs authenticated JSON round trips against one peer.
// Safe for concurrent use.
type Client struct {
base string
tokens *token.Source
http *http.Client
}
// New builds a client against the peer at base, authenticating with
// tokens minted by src. It panics on a missing base or source, since
// assembling a client that can reach nothing is a programmer error.
func New(base string, src *token.Source, opts ...Option) *Client {
switch {
case base == "":
panic("peer base URL is required")
case src == nil:
panic("token source is required")
}
c := &Client{
base: strings.TrimSuffix(base, "/"),
tokens: src,
http: http.DefaultClient,
}
for _, opt := range opts {
opt(c)
}
return c
}
// Call is one round trip.
type Call struct {
// Method is the HTTP method. Empty means GET.
Method string
// Path is the endpoint, relative to the peer's base URL.
Path string
// Body is marshaled as the JSON request body; nil sends none.
Body any
// Into receives the decoded answer; nil discards it.
Into any
// Accept lists the statuses that count as success. Empty accepts
// 200 alone.
Accept []int
}
// Do performs one call, returning a [*APIError] for any status the call
// did not accept and an ordinary error for anything that went wrong
// before the peer answered.
func (c *Client) Do(ctx context.Context, call Call) error {
var body io.Reader
if call.Body != nil {
raw, err := json.Marshal(call.Body)
if err != nil {
return fmt.Errorf("failed to encode the request: %w", err)
}
body = bytes.NewReader(raw)
}
bearer, err := c.tokens.Get(ctx)
if err != nil {
return fmt.Errorf("failed to mint a token: %w", err)
}
method := call.Method
if method == "" {
method = http.MethodGet
}
req, err := http.NewRequestWithContext(
ctx, method, c.base+call.Path, body,
)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+bearer)
if call.Body != nil {
req.Header.Set("Content-Type", "application/json")
}
res, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("failed to reach the peer: %w", err)
}
defer res.Body.Close()
answer, err := io.ReadAll(io.LimitReader(res.Body, MaxBody))
if err != nil {
return fmt.Errorf("failed to read the answer: %w", err)
}
if !accepted(res.StatusCode, call.Accept) {
return apiError(res.StatusCode, answer)
}
if call.Into == nil {
return nil
}
if err := json.Unmarshal(answer, call.Into); err != nil {
return fmt.Errorf("failed to parse the answer: %w", err)
}
return nil
}
// accepted reports whether the status is one the call wanted.
func accepted(status int, want []int) bool {
if len(want) == 0 {
return status == http.StatusOK
}
for _, s := range want {
if status == s {
return true
}
}
return false
}
// fault reads the peer's problem document, falling back to the raw body
// where it sent none.
func apiError(status int, body []byte) *APIError {
var problem struct {
Reason string `json:"reason"`
Description string `json:"description"`
}
_ = json.Unmarshal(body, &problem)
detail := problem.Description
if detail == "" {
detail = strings.TrimSpace(string(body))
}
return &APIError{
Status: status,
Reason: problem.Reason,
Description: detail,
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package blob
import (
"context"
"errors"
"net/http"
"time"
"uuid"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/eco/dse/diff"
"github.com/deep-rent/nexus/eco/dse/hlc"
"github.com/deep-rent/nexus/net/s3"
)
// Model is the reserved name of the file document model. The schema layer
// registers it with [diff.PolyOwner], anchored to every model declaring
// attachment slots, so a file inherits ownership, team moves, and share
// visibility from its anchor like any other child document.
const Model = "file"
// Reserved names of the file payload's anchor envelope. They double as the
// [diff.PolyOwner] discriminator and reference fields.
const (
// FieldAnchorType is the payload field naming the anchor's model.
FieldAnchorType = "anchor_type"
// FieldAnchorID is the payload field referencing the anchor document.
FieldAnchorID = "anchor_id"
)
// MaxKeyLength bounds an object key at 256 characters, matching the ledger
// column behind it. Minted keys stay well below; the bound exists so the
// schema is constrained, not to make room scarce.
const MaxKeyLength = 256
// Variant distinguishes the objects a single file document may carry: the
// verified original and, for slots that declare one, a client-generated
// thumbnail. A variant is part of every grant, ledger entry, and endpoint
// path, and each variant runs the same grant/confirm lifecycle.
type Variant string
const (
// Original is the file's primary object. The value is spelled out —
// never the empty string — so ledger rows, grants, and metric tags
// stay self-describing.
Original Variant = "original"
// Thumb is the client-generated thumbnail derived from the original.
Thumb Variant = "thumb"
)
var (
// ErrNotFound reports a file document that is absent, deleted, or not
// visible to the caller (indistinguishable by design).
ErrNotFound = errors.New("file document not found")
// ErrUnknownSlot reports a file whose anchor model declares no slot
// under the file's slot name — a schema mismatch between the document
// and the compiled service.
ErrUnknownSlot = errors.New("unknown attachment slot")
// ErrNoThumbnail reports a thumbnail operation on a slot that declares
// no thumbnail policy.
ErrNoThumbnail = errors.New("slot declares no thumbnail")
// ErrUploaded reports a grant or confirmation for a variant that is
// already verified: objects are immutable, so replacing content means
// deleting the file document and creating a fresh one.
ErrUploaded = errors.New("file is already uploaded")
// ErrNotReady reports a download (or thumbnail operation) before the
// required variant was verified.
ErrNotReady = errors.New("file is not uploaded yet")
// ErrNoPending reports a confirmation with no grant to confirm: none
// was issued, it expired and was swept, or it was already confirmed.
ErrNoPending = errors.New("no pending upload")
// ErrNotUploaded reports a confirmation whose object never arrived:
// the grant existed but nothing sits at its key. The grant is consumed;
// the client must request a fresh one.
ErrNotUploaded = errors.New("no object was uploaded")
// ErrTooLarge reports an uploaded object exceeding the slot's size
// policy. The object is deleted before this is returned.
ErrTooLarge = errors.New("uploaded object is too large")
// ErrBadType reports an uploaded object outside the slot's content
// type whitelist. The object is deleted before this is returned.
ErrBadType = errors.New("uploaded object has a disallowed content type")
// ErrChecksum reports an uploaded object whose size or content hash
// does not match what the file document announced. The object is
// deleted before this is returned, and the file document is marked
// corrupted for every device to see; a fresh grant and upload clear
// the mark. With the announcement signed into the upload grant the
// store refuses mismatching bytes outright, so reaching this error
// means the store is not enforcing signed checksum headers — or the
// announcement changed while the upload was in flight.
ErrChecksum = errors.New("uploaded object does not match its announcement")
// ErrQuota reports an owner at their storage limit: the grant is
// refused, or the confirmed object would exceed the limit (then it is
// deleted before this is returned).
ErrQuota = errors.New("storage quota exceeded")
)
// File is the canonical payload contract of the reserved file model. The
// anchor envelope and slot come from the client and are immutable in
// practice (the object they authorize is); Name is free client metadata.
// SHA256 and Size ANNOUNCE the original's content: they are required at
// registration, the upload grant pins the object to them, and they
// freeze once the original is uploaded — the object is immutable, so its
// announcement is too. Uploaded, ContentType, Thumbnail, and Corrupted
// are SERVER-AUTHORITATIVE: the file handler ignores whatever a client
// sends for them and re-emits the verified values into every payload it
// serves, so no device can forge a verification.
type File struct {
// ID is the document identifier (UUIDv7).
ID uuid.UUID `json:"id"`
// AnchorType names the model of the document this file attaches to.
AnchorType string `json:"anchor_type"`
// AnchorID references the anchor document.
AnchorID uuid.UUID `json:"anchor_id"`
// Slot names the attachment point on the anchor's model.
Slot string `json:"slot"`
// Name is a client-side display name, free-form.
Name string `json:"name,omitzero"`
// SHA256 announces the original's content hash (64 hex characters).
// The store verifies the arriving bytes against it during the upload
// and the confirmation compares the stored digest against it, so a
// verified file provably holds the announced content — and every
// device can check a download against the synced value. Required;
// immutable once uploaded.
SHA256 string `json:"sha256"`
// Size announces the original's size in bytes, enforced exactly like
// the checksum. Required and positive; immutable once uploaded.
Size int64 `json:"size"`
// Uploaded reports whether the original passed verification.
// Server-authoritative.
Uploaded bool `json:"uploaded"`
// ContentType is the original's verified content type.
// Server-authoritative.
ContentType string `json:"content_type,omitzero"`
// Thumbnail reports whether a thumbnail passed verification.
// Server-authoritative.
Thumbnail bool `json:"thumbnail,omitzero"`
// Corrupted reports that the last confirmation found a stored object
// whose size or digest did not match the announcement. The object was
// discarded; a fresh grant and upload clear the mark.
// Server-authoritative.
Corrupted bool `json:"corrupted,omitzero"`
}
// Validate implements the [valid.Validatable] interface. The typed decode
// already enforces UUID format; the server-authoritative fields are not
// validated, since the handler discards whatever a client sends for them.
func (f *File) Validate(v *valid.Validator) {
if f.AnchorID == uuid.Nil() {
v.Fail(FieldAnchorID, "must not be empty")
}
v.NotBlank(FieldAnchorType, f.AnchorType)
v.NotBlank("slot", f.Slot)
v.MaxLen("name", f.Name, 255)
v.NotBlank("sha256", f.SHA256)
v.SHA256("sha256", f.SHA256)
v.Min("size", f.Size, 1)
}
var _ valid.Validatable = (*File)(nil)
// Row is the server's view of a stored file document: its identity within
// the ownership hierarchy plus the verification state of its variants.
type Row struct {
// ID is the document identifier.
ID uuid.UUID
// UserID and TeamID carry the denormalized root identity, exactly as
// on any child document row; a zero TeamID denotes a personal
// document.
UserID uuid.UUID
TeamID uuid.UUID
// AnchorType and AnchorID locate the document the file attaches to.
AnchorType string
AnchorID uuid.UUID
// Slot names the attachment point on the anchor's model.
Slot string
// SHA256 and Size carry the announced content: what the original must
// hash to and measure once uploaded. After verification they are also
// what the stored object provably holds.
SHA256 string
Size int64
// Uploaded and ContentType describe the verified original, and
// Corrupted a failed verification; see [File].
Uploaded bool
ContentType string
Corrupted bool
// Thumb and ThumbSize describe the verified thumbnail.
Thumb bool
ThumbSize int64
}
// Owner returns the quota scope the row's bytes count against: the team
// for team documents, the owning user otherwise.
func (r *Row) Owner() Owner {
if r.TeamID != uuid.Nil() {
return Owner{Kind: KindTeam, ID: r.TeamID}
}
return Owner{Kind: KindUser, ID: r.UserID}
}
// Scope returns the row's owner as a sync scope, for fencing backend
// writes to the row through [Store.Mutate].
func (r *Row) Scope() diff.Scope {
s := diff.Scope{UserID: r.UserID}
if r.TeamID != uuid.Nil() {
s.Teams = []uuid.UUID{r.TeamID}
}
return s
}
// Kind names the kind of principal a storage quota applies to.
type Kind string
const (
// KindUser scopes a quota to one user's personal documents.
KindUser Kind = "user"
// KindTeam scopes a quota to one team's documents.
KindTeam Kind = "team"
)
// Owner is one quota scope: verified bytes of personal documents count
// against their owning user, verified bytes of team documents against the
// team.
type Owner struct {
// Kind states whether ID names a user or a team.
Kind Kind `json:"kind"`
// ID identifies the principal within its kind.
ID uuid.UUID `json:"id"`
}
// Quota resolves the storage limit of an owner in bytes; zero or negative
// means unlimited. Implementations bridge to wherever entitlements live —
// a plans table, a billing service — and must be safe for concurrent use.
// When the lookup fails, the manager refuses fresh grants but still
// confirms in-flight uploads, so a flaky entitlement source never bricks
// an upload already under way.
type Quota interface {
// Limit returns the owner's storage limit in bytes; <= 0 is unlimited.
Limit(ctx context.Context, owner Owner) (int64, error)
}
// QuotaFunc adapts a function to the [Quota] interface.
type QuotaFunc func(ctx context.Context, owner Owner) (int64, error)
// Limit implements the [Quota] interface.
func (f QuotaFunc) Limit(ctx context.Context, owner Owner) (int64, error) {
return f(ctx, owner)
}
// FlatQuota is a [Quota] applying one fixed limit per owner kind; a zero
// field means unlimited for that kind.
type FlatQuota struct {
// User is the per-user limit in bytes for personal documents.
User int64
// Team is the per-team limit in bytes.
Team int64
}
// Limit implements the [Quota] interface.
func (q FlatQuota) Limit(_ context.Context, owner Owner) (int64, error) {
if owner.Kind == KindTeam {
return q.Team, nil
}
return q.User, nil
}
var _ Quota = FlatQuota{}
// Slot is the attachment policy of one slot on one anchor model: which
// content types it accepts, how large an object may be, how many files may
// occupy it, and whether it carries a thumbnail.
type Slot struct {
// ContentTypes whitelists the content types an original may carry.
ContentTypes []string
// MaxSize caps an original's size in bytes; <= 0 applies the manager
// default.
MaxSize int64
// MaxCount caps how many live files may occupy the slot per anchor
// document; <= 0 is unlimited. Enforced transactionally at sync
// ingestion through the file handler's [diff.Vetter].
MaxCount int
// Thumb declares the thumbnail policy; nil means the slot carries no
// thumbnails.
Thumb *ThumbPolicy
}
// ThumbPolicy bounds a slot's client-generated thumbnail.
type ThumbPolicy struct {
// ContentTypes whitelists the thumbnail's content types.
ContentTypes []string
// MaxSize caps the thumbnail's size in bytes; <= 0 applies the
// manager default.
MaxSize int64
}
// Policies resolves the slot policy for a given anchor model and slot
// name. The schema layer implements it from the declared models; [Slots]
// is a ready-made literal implementation.
type Policies interface {
// Slot returns the policy of the named slot on the named anchor
// model, and whether it exists.
Slot(anchor, slot string) (Slot, bool)
}
// Slots is a literal [Policies] implementation: anchor model -> slot name
// -> policy.
type Slots map[string]map[string]Slot
// Slot implements the [Policies] interface.
func (s Slots) Slot(anchor, slot string) (Slot, bool) {
p, ok := s[anchor][slot]
return p, ok
}
var _ Policies = Slots{}
// State is the lifecycle state of one ledger entry. Every object key ever
// minted has exactly one entry, written in the same transaction as the
// state it reflects, so the ledger is the one honest inventory of the
// bucket: nothing is deleted from storage except through it, and nothing
// in storage outlives it.
type State string
const (
// StatePending marks a granted key whose upload is not verified yet.
// Entries carry an expiry; the sweep evicts them once it passes.
StatePending State = "pending"
// StateLive marks the verified object behind a file document variant.
StateLive State = "live"
// StateOrphaned marks an object whose file document is gone; the
// sweep evicts it.
StateOrphaned State = "orphaned"
)
// Entry is one ledger row: an object key and the state of the object
// behind it.
type Entry struct {
// Key is the object key within the bucket.
Key string
// FileID is the file document the key was minted for.
FileID uuid.UUID
// Variant is the file variant the key belongs to.
Variant Variant
// State is the entry's lifecycle state.
State State
// ExpiresAt is when a pending entry becomes sweepable; zero for other
// states.
ExpiresAt time.Time
}
// Pending is one outstanding upload grant: the key a variant may
// currently be uploading to. At most one exists per (file, variant) — a
// fresh grant replaces it — and none of it is secret, since the presigned
// URL itself never persists.
type Pending struct {
// FileID is the file document the grant belongs to.
FileID uuid.UUID
// Variant is the file variant being uploaded.
Variant Variant
// Key is the object key the grant may upload to.
Key string
// ExpiresAt is when the grant becomes sweepable. It covers the
// confirmation window, not just the upload itself.
ExpiresAt time.Time
}
// Store is the persistence contract of the blob engine, implemented by
// the drivers alongside the file document table. The type parameter
// erases the backend's transaction handle; the transactional runners are
// shared with the sync store so a confirmation flips the document and its
// ledger entry in ONE fenced transaction.
//
// Errors are reserved for storage failures. Implementations must be safe
// for concurrent use.
type Store[Tx any] interface {
// Exec runs fn within a single transaction, committing on nil and
// rolling back on error.
Exec(ctx context.Context, fn func(ctx context.Context, tx Tx) error) error
// Mutate runs fn within a single transaction holding the exclusive
// advisory locks of the given scope, fencing the write against
// concurrent syncs exactly like any backend-initiated write.
Mutate(
ctx context.Context,
scope diff.Scope,
fn func(ctx context.Context, tx Tx) error,
) error
// Writable returns the file row if the scope may write it — its own
// and its teams' documents — or nil. Share-granted visibility does
// NOT confer write access.
Writable(ctx context.Context, tx Tx, scope diff.Scope, id uuid.UUID) (
*Row, error)
// Readable returns the file row if the scope may read it: writable
// rows plus foreign personal documents shared with the scope's teams.
Readable(ctx context.Context, tx Tx, scope diff.Scope, id uuid.UUID) (
*Row, error)
// Verify records the given variant as verified on the file row —
// original: uploaded, size, content type, and a cleared corrupted
// mark; thumbnail: presence and size — stamps the row with the given
// timestamp, and re-enters it into the patch feed under a fresh
// sequence value. It reports false when the row no longer exists.
// Callers must hold the row's scope locks.
Verify(
ctx context.Context,
tx Tx,
id uuid.UUID,
variant Variant,
size int64,
contentType string,
at hlc.Time,
) (bool, error)
// Corrupt marks the file row corrupted — a confirmation found a
// stored object that does not match the announced size or checksum —
// stamps the row, and re-enters it into the patch feed, so every
// device learns the verdict. It reports false when the row no longer
// exists. Callers must hold the row's scope locks; a later successful
// verification clears the mark.
Corrupt(ctx context.Context, tx Tx, id uuid.UUID, at hlc.Time) (
bool, error)
// Usage returns the owner's verified bytes: the sum of all verified
// variant sizes across the owner's live file rows. It serves the
// quota checks, which always judge a single owner.
Usage(ctx context.Context, tx Tx, owner Owner) (int64, error)
// Usages returns the verified bytes of several owners in one round
// trip; owners without files are omitted. It serves the accounting
// reads, which enumerate a whole scope at once.
Usages(ctx context.Context, tx Tx, owners []Owner) (
map[Owner]int64, error)
// Grant replaces the variant's pending upload, returning the key of
// the one it displaced, or "" when there was none.
Grant(ctx context.Context, tx Tx, p Pending) (displaced string, err error)
// Claim atomically removes and returns the variant's pending upload,
// or nil when none exists or the grant's expiry lies at or before the
// given instant — an expired grant belongs to the sweep, and honoring
// it would race the eviction of its object. The winner of a
// concurrent claim owns the uploaded object.
Claim(
ctx context.Context,
tx Tx,
id uuid.UUID,
variant Variant,
now time.Time,
) (*Pending, error)
// PruneGrants removes grant rows whose expiry lies at or before the
// given instant, returning how many were removed. Object safety does
// not depend on it — the ledger governs eviction — it merely keeps
// the grants table from accumulating husks.
PruneGrants(ctx context.Context, tx Tx, now time.Time) (int64, error)
// Record inserts a fresh ledger entry.
Record(ctx context.Context, tx Tx, e Entry) error
// Mark transitions the given keys to the given state, returning how
// many entries it actually touched. Callers transitioning a specific
// entry must assert the count: a miss means the sweep purged the
// entry — and its object — underneath them.
Mark(ctx context.Context, tx Tx, keys []string, s State) (int64, error)
// Purge removes the given ledger entries.
Purge(ctx context.Context, tx Tx, keys []string) error
// Key returns the live object key of the given file variant, if one
// exists.
Key(ctx context.Context, tx Tx, id uuid.UUID, variant Variant) (
string, bool, error)
// Doomed returns up to limit entries awaiting eviction: orphaned
// entries and pending entries whose expiry lies at or before the
// given instant.
Doomed(ctx context.Context, tx Tx, now time.Time, limit int) (
[]Entry, error)
// Strays returns up to limit live entries whose file document no
// longer exists — the reconciliation backstop for deletions that
// bypassed the handlers.
Strays(ctx context.Context, tx Tx, limit int) ([]Entry, error)
}
// Storage is the narrow seam onto the object store, satisfied by
// [s3.Bucket]. The manager grants uploads, verifies what landed, and
// evicts what no longer belongs — nothing else.
type Storage interface {
// Presign returns a URL granting the given method on the given object
// until the expiry elapses; signed headers, if any, become
// constraints of the grant the store enforces on the request.
Presign(method, key string, expires time.Duration, signed http.Header) (
string, error)
// Head reports the stored object's metadata, or nil when the object
// does not exist.
Head(ctx context.Context, key string) (*s3.Object, error)
// Delete removes the given objects, reporting the per-key outcome.
Delete(ctx context.Context, keys []string) (s3.DeleteResult, error)
}
// EventKind names a lifecycle event published by the manager.
type EventKind string
const (
// EventUploaded marks a fresh original verified and distributed.
EventUploaded EventKind = "file_uploaded"
// EventThumbnail marks a thumbnail verified and distributed.
EventThumbnail EventKind = "thumbnail_uploaded"
)
// Event is a lifecycle notification delivered to the [Observer]
// registered with [WithObserver].
type Event struct {
// Kind states what happened.
Kind EventKind
// FileID identifies the file document.
FileID uuid.UUID
// Owner is the quota scope the object counts against.
Owner Owner
// AnchorType, AnchorID, and Slot locate the attachment point.
AnchorType string
AnchorID uuid.UUID
Slot string
// Key is the verified object's key.
Key string
// Size is the verified object's size in bytes.
Size int64
// ContentType is the verified object's content type.
ContentType string
// At is when the event occurred.
At time.Time
}
// Observer receives lifecycle events. It runs synchronously on the
// request that produced the event, so it must stay cheap and must not
// block; hand events to a bus or queue for anything heavier.
type Observer func(Event)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package blob
import (
"context"
"errors"
"net/http"
"uuid"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/eco/dse/diff"
"github.com/deep-rent/nexus/net/router"
)
// Error reasons emitted by the blob endpoints, complementing the reasons
// defined by the router and auth packages.
const (
// ReasonUploadNotPending indicates a confirmation with no upload
// grant behind it: none was requested, it lapsed, or it was already
// confirmed.
ReasonUploadNotPending router.Reason = "upload_not_pending"
// ReasonUploadMissing indicates a confirmation whose object never
// arrived at the granted URL. The grant is consumed; request a fresh
// one.
ReasonUploadMissing router.Reason = "upload_missing"
// ReasonUploadTooLarge indicates a file beyond the slot's size cap:
// an announced size refused at the grant (before any byte moved), or
// an uploaded object refused — and discarded — at the confirmation.
ReasonUploadTooLarge router.Reason = "upload_too_large"
// ReasonUploadBadType indicates an uploaded object outside the
// content type whitelist the grant announced. The upload was
// discarded.
ReasonUploadBadType router.Reason = "upload_bad_type"
// ReasonUploadChecksum indicates an uploaded object contradicting
// the size or checksum its file document announced. The object was
// discarded and the document marked corrupted — a verdict every
// device sees in the payload — until a fresh grant and an upload
// matching the announcement clear it.
ReasonUploadChecksum router.Reason = "upload_checksum_mismatch"
// ReasonAlreadyUploaded indicates a grant or confirmation for a
// variant that is already verified: objects are immutable, so
// replacing content means a fresh file document.
ReasonAlreadyUploaded router.Reason = "already_uploaded"
// ReasonNotUploaded indicates a download — or a thumbnail grant —
// before the required variant was verified.
ReasonNotUploaded router.Reason = "not_uploaded"
// ReasonNoThumbnail indicates a thumbnail operation on a slot that
// declares no thumbnail, or a download of a thumbnail that was never
// provided.
ReasonNoThumbnail router.Reason = "no_thumbnail"
// ReasonUnknownSlot indicates a file document whose anchor model
// declares no slot under the file's slot name — a schema mismatch
// between client and server.
ReasonUnknownSlot router.Reason = "unknown_slot"
// ReasonQuotaExceeded indicates an owner at their storage limit. A
// rejected confirmation discarded the upload.
ReasonQuotaExceeded router.Reason = "quota_exceeded"
)
// Lifecycle is the manager capability the HTTP layer builds on. It is
// implemented by [Manager] and decouples the endpoints from the storage
// transaction type.
type Lifecycle interface {
// Upload grants a direct upload for the given file variant.
Upload(ctx context.Context, scope diff.Scope, id uuid.UUID, v Variant) (
Grant, error)
// Confirm verifies the variant's pending upload and distributes it.
Confirm(ctx context.Context, scope diff.Scope, id uuid.UUID, v Variant) (
Stat, error)
// Download issues a short-lived link for the verified variant.
Download(ctx context.Context, scope diff.Scope, id uuid.UUID, v Variant) (
Link, error)
// Report returns the storage accounting of every owner in the scope.
Report(ctx context.Context, scope diff.Scope) ([]Usage, error)
// Audit returns one owner's storage accounting regardless of any
// caller scope, for the admin read surface.
Audit(ctx context.Context, owner Owner) (Usage, error)
}
// UploadEndpoint builds the upload grant handler for the given variant,
// serving "POST /files/{id}/upload" (and its thumbnail sibling). Requests
// must carry claims verified by an auth guard, exactly like the sync
// endpoint.
func UploadEndpoint(l Lifecycle, v Variant) router.HandlerFunc {
if l == nil {
panic("lifecycle is required")
}
return func(e *router.Exchange) error {
scope, id, err := file(e)
if err != nil {
return err
}
grant, err := l.Upload(e.Context(), scope, id, v)
if err != nil {
return translate(err)
}
return e.JSON(http.StatusOK, grant)
}
}
// ConfirmEndpoint builds the confirmation handler for the given variant,
// serving "POST /files/{id}/confirm" (and its thumbnail sibling).
func ConfirmEndpoint(l Lifecycle, v Variant) router.HandlerFunc {
if l == nil {
panic("lifecycle is required")
}
return func(e *router.Exchange) error {
scope, id, err := file(e)
if err != nil {
return err
}
stat, err := l.Confirm(e.Context(), scope, id, v)
if err != nil {
return translate(err)
}
return e.JSON(http.StatusOK, stat)
}
}
// DownloadEndpoint builds the download link handler for the given
// variant, serving "GET /files/{id}/download" (and its thumbnail
// sibling). It answers a JSON link rather than a redirect, so clients can
// schedule the actual transfer.
func DownloadEndpoint(l Lifecycle, v Variant) router.HandlerFunc {
if l == nil {
panic("lifecycle is required")
}
return func(e *router.Exchange) error {
scope, id, err := file(e)
if err != nil {
return err
}
link, err := l.Download(e.Context(), scope, id, v)
if err != nil {
return translate(err)
}
return e.JSON(http.StatusOK, link)
}
}
// Mount registers the blob endpoints following the mount convention of
// this framework:
//
// POST /files/{id}/upload grant an original upload
// POST /files/{id}/confirm verify and distribute it
// GET /files/{id}/download link to the verified original
// POST /files/{id}/thumbnail/upload grant a thumbnail upload
// POST /files/{id}/thumbnail/confirm verify and distribute it
// GET /files/{id}/thumbnail/download link to the verified thumbnail
//
// Storage accounting is not served here: [Lifecycle.Report] feeds the
// service's stats endpoint, which combines it with document counts.
// Pass the auth guard (and any additional route middleware) as mws. The
// sync engine's document endpoint ("GET /{type}/{id}") does not collide:
// these paths carry a third segment.
func Mount(r *router.Router, l Lifecycle, mws ...router.Middleware) {
r.HandleFunc(http.MethodPost,
"/files/{id}/upload", UploadEndpoint(l, Original), mws...)
r.HandleFunc(http.MethodPost,
"/files/{id}/confirm", ConfirmEndpoint(l, Original), mws...)
r.HandleFunc(http.MethodGet,
"/files/{id}/download", DownloadEndpoint(l, Original), mws...)
r.HandleFunc(http.MethodPost,
"/files/{id}/thumbnail/upload", UploadEndpoint(l, Thumb), mws...)
r.HandleFunc(http.MethodPost,
"/files/{id}/thumbnail/confirm", ConfirmEndpoint(l, Thumb), mws...)
r.HandleFunc(http.MethodGet,
"/files/{id}/thumbnail/download", DownloadEndpoint(l, Thumb), mws...)
}
// file extracts the authorization scope and the file document ID from the
// request.
func file(e *router.Exchange) (diff.Scope, uuid.UUID, error) {
scope, err := diff.ScopeFrom(e)
if err != nil {
return diff.Scope{}, uuid.Nil(), err
}
id, err := uuid.Parse(e.Param("id"))
if err != nil {
return diff.Scope{}, uuid.Nil(), &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: "file ID is not a valid UUID",
Context: valid.Single("id", "must be a valid UUID"),
}
}
return scope, id, nil
}
// translate maps the manager's typed errors onto HTTP error responses:
// lifecycle refusals become 409s, policy refusals 400s, missing documents
// 404s, and quota exhaustion 403 — the caller is authenticated and the
// request well-formed; capacity is what is lacking.
func translate(err error) error {
switch {
case errors.Is(err, ErrNotFound):
return &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "the requested file does not exist",
Cause: err,
}
case errors.Is(err, ErrUnknownSlot):
return &router.Error{
Status: http.StatusNotFound,
Reason: ReasonUnknownSlot,
Description: "the file names an unknown attachment slot",
Cause: err,
}
case errors.Is(err, ErrNoThumbnail):
return &router.Error{
Status: http.StatusNotFound,
Reason: ReasonNoThumbnail,
Description: "the slot carries no thumbnail",
Cause: err,
}
case errors.Is(err, ErrUploaded):
return &router.Error{
Status: http.StatusConflict,
Reason: ReasonAlreadyUploaded,
Description: "the file is already uploaded and immutable; " +
"replace it with a fresh file document",
Cause: err,
}
case errors.Is(err, ErrNotReady):
return &router.Error{
Status: http.StatusConflict,
Reason: ReasonNotUploaded,
Description: "the file is not uploaded yet",
Cause: err,
}
case errors.Is(err, ErrNoPending):
return &router.Error{
Status: http.StatusConflict,
Reason: ReasonUploadNotPending,
Description: "no upload is pending confirmation",
Cause: err,
}
case errors.Is(err, ErrNotUploaded):
return &router.Error{
Status: http.StatusConflict,
Reason: ReasonUploadMissing,
Description: "no object arrived at the granted URL",
Cause: err,
}
case errors.Is(err, ErrTooLarge):
return &router.Error{
Status: http.StatusBadRequest,
Reason: ReasonUploadTooLarge,
Description: "the file exceeds the slot's size cap; " +
"any uploaded object was discarded",
Cause: err,
}
case errors.Is(err, ErrBadType):
return &router.Error{
Status: http.StatusBadRequest,
Reason: ReasonUploadBadType,
Description: "the uploaded object has a disallowed content " +
"type; it was discarded",
Cause: err,
}
case errors.Is(err, ErrChecksum):
return &router.Error{
Status: http.StatusBadRequest,
Reason: ReasonUploadChecksum,
Description: "the uploaded object does not match the announced " +
"size or checksum; it was discarded and the document " +
"marked corrupted — re-grant and upload the announced " +
"content to clear the mark",
Cause: err,
}
case errors.Is(err, ErrQuota):
return &router.Error{
Status: http.StatusForbidden,
Reason: ReasonQuotaExceeded,
Description: "the storage quota cannot fit the file",
Cause: err,
}
case errors.Is(err, diff.ErrConflict):
return &router.Error{
Status: http.StatusConflict,
Reason: diff.ReasonConflict,
Description: "a concurrent change interfered; " +
"request a fresh grant and retry",
Cause: err,
}
}
return err
}
var _ Lifecycle = (*Manager[any])(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package blob
import (
"context"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"uuid"
"github.com/deep-rent/nexus/eco/dse/diff"
"github.com/deep-rent/nexus/eco/dse/hlc"
"github.com/deep-rent/nexus/net/s3"
"github.com/deep-rent/nexus/std/ascii"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/metrics"
)
// Defaults of the policy knobs left unset in [Config] and [Slot].
const (
// DefaultMaxSize caps an original at 32 MiB when its slot names no
// bound of its own.
DefaultMaxSize = 32 << 20
// DefaultThumbMaxSize caps a thumbnail at 256 KiB when its policy
// names no bound of its own. Thumbnails render at preview sizes;
// anything larger is a mistake.
DefaultThumbMaxSize = 256 << 10
// DefaultGrantTTL bounds the upload itself: the presigned PUT lapses
// this long after the grant.
DefaultGrantTTL = time.Hour
// DefaultConfirmWindow bounds the whole grant lifecycle: an upload
// not confirmed within it becomes sweepable. It exceeds the grant
// TTL, so a client that uploaded at the last moment still has time
// to confirm.
DefaultConfirmWindow = 4 * time.Hour
// DefaultDownloadTTL bounds a presigned download link. Links are
// cheap to re-request, so they stay short-lived.
DefaultDownloadTTL = 5 * time.Minute
// SweepLimit bounds how many doomed objects one sweep pass evicts. A
// pass runs on a schedule, so a backlog larger than this drains over
// successive runs rather than in one giant batch.
SweepLimit = 256
)
// Config bundles the construction parameters of a [Manager].
type Config[Tx any] struct {
// Store is the persistence seam, implemented by the driver alongside
// the file document table. Required.
Store Store[Tx]
// Storage is the object store files live in. Required.
Storage Storage
// Policies resolves attachment slot policies. Required.
Policies Policies
// Stamp mints HLC timestamps for the verification writes; wire it to
// the sync engine's clock ([diff.Engine.Now]). Required.
Stamp func() hlc.Time
// Quota resolves per-owner storage limits. Nil means unlimited.
Quota Quota
// Prefix is prepended to every minted object key, so several
// deployments can share one bucket. Optional; no trailing slash.
Prefix string
// GrantTTL bounds the presigned upload. Defaults to
// [DefaultGrantTTL].
GrantTTL time.Duration
// ConfirmWindow bounds the grant lifecycle; see
// [DefaultConfirmWindow].
ConfirmWindow time.Duration
// DownloadTTL bounds presigned download links. Defaults to
// [DefaultDownloadTTL].
DownloadTTL time.Duration
}
// Grant is one issued upload permission, everything a client needs to
// perform and understand the upload.
type Grant struct {
// URL is the presigned PUT target. It must be used byte-for-byte.
URL string `json:"url"`
// Method is the HTTP method the URL grants, always PUT.
Method string `json:"method"`
// Headers are request headers signed into the URL: the store refuses
// an upload not carrying each one with exactly this value. An
// original's grant pins the announced checksum and size here —
// x-amz-checksum-sha256 must be set on the PUT explicitly, while
// Content-Length follows from uploading the right file, since HTTP
// stacks derive it from the body (browsers refuse to let scripts set
// it at all). Empty for thumbnails, which announce nothing.
Headers map[string]string `json:"headers,omitzero"`
// MaxSize is the size cap the confirmation will enforce, in bytes.
// It already folds in the owner's remaining quota, so an honest
// client can check its file before uploading. An original is pinned
// tighter still: exactly its announced size.
MaxSize int64 `json:"max_size"`
// ContentTypes are the content types the confirmation will accept.
ContentTypes []string `json:"content_types"`
// ExpiresAt is when the upload URL lapses.
ExpiresAt time.Time `json:"expires_at"`
}
// Link is one issued download permission.
type Link struct {
// URL is the presigned GET target.
URL string `json:"url"`
// ExpiresAt is when the link lapses. Links are cheap; re-request
// rather than hoard.
ExpiresAt time.Time `json:"expires_at"`
}
// Stat reports what a confirmation verified.
type Stat struct {
// Size is the verified object's size in bytes.
Size int64 `json:"size"`
// ContentType is the verified object's content type.
ContentType string `json:"content_type"`
}
// Usage reports one owner's storage accounting.
type Usage struct {
// Owner is the quota scope.
Owner Owner `json:"owner"`
// Used is the sum of verified bytes.
Used int64 `json:"used"`
// Limit is the owner's limit in bytes; 0 means unlimited.
Limit int64 `json:"limit,omitzero"`
}
// Manager runs the file object lifecycle: it grants direct uploads
// against slot policies and quotas, verifies what actually landed before
// any device learns of it, serves short-lived download links under sync
// visibility rules, and evicts what no longer belongs. It is stateless
// beyond its collaborators and safe for concurrent use.
type Manager[Tx any] struct {
cfg Config[Tx]
now clock.Clock
logger *log.Logger
observer Observer
reg *metrics.Registry
// The janitorial counters. Three of them are standing invariants: a
// growing eviction-failure count means storage is refusing deletes
// (and leaking bytes), strays should never appear while every
// deletion runs through the handlers, and corruption should never
// appear while the store enforces the checksum headers signed into
// every upload grant.
swept *metrics.Counter
sweepFailed *metrics.Counter
strays *metrics.Counter
corrupted *metrics.Counter
}
// New creates a [Manager] from the given configuration. It panics on a
// missing store, storage, policy resolver, or stamp source, since those
// are startup configuration errors.
func New[Tx any](cfg Config[Tx], opts ...Option[Tx]) *Manager[Tx] {
switch {
case cfg.Store == nil:
panic("store is required")
case cfg.Storage == nil:
panic("storage is required")
case cfg.Policies == nil:
panic("policies are required")
case cfg.Stamp == nil:
panic("stamp source is required")
}
// A minted key is the prefix plus "files/", two UUIDs, and a variant
// suffix — just short of 96 characters. Bounding the prefix here keeps
// every key inside [MaxKeyLength], so the ledger column can never
// truncate what the bucket stores.
if len(cfg.Prefix) > MaxKeyLength-96 {
panic(fmt.Sprintf(
"key prefix exceeds %d characters", MaxKeyLength-96,
))
}
if cfg.GrantTTL <= 0 {
cfg.GrantTTL = DefaultGrantTTL
}
if cfg.ConfirmWindow <= cfg.GrantTTL {
cfg.ConfirmWindow = max(DefaultConfirmWindow, 2*cfg.GrantTTL)
}
if cfg.DownloadTTL <= 0 {
cfg.DownloadTTL = DefaultDownloadTTL
}
m := &Manager[Tx]{
cfg: cfg,
now: clock.System,
logger: log.Discard(),
reg: metrics.DefaultRegistry,
}
for _, opt := range opts {
opt(m)
}
m.swept = m.reg.Counter("dse_blob_swept_total")
m.sweepFailed = m.reg.Counter("dse_blob_sweep_failures_total")
m.strays = m.reg.Counter("dse_blob_strays_total")
m.corrupted = m.reg.Counter("dse_blob_corrupted_total")
return m
}
// errMoved signals that the file row's identity drifted between the
// pre-read and the lock acquisition; the confirmation retries once with
// the fresh identity.
var errMoved = errors.New("file identity drifted")
// errDrifted signals that the announcement changed while a confirmation
// was in flight: the object was verified against what WAS announced, not
// what is — the same contradiction as any integrity failure, and settled
// the same way (discard, corrupted verdict, [ErrChecksum]).
var errDrifted = errors.New("announcement drifted")
// policy resolves the slot policy governing the given row and variant,
// with defaults applied.
func (m *Manager[Tx]) policy(row *Row, variant Variant) (
types []string, maxSize int64, err error,
) {
slot, ok := m.cfg.Policies.Slot(row.AnchorType, row.Slot)
if !ok {
return nil, 0, ErrUnknownSlot
}
if variant == Thumb {
if slot.Thumb == nil {
return nil, 0, ErrNoThumbnail
}
types, maxSize = slot.Thumb.ContentTypes, slot.Thumb.MaxSize
if maxSize <= 0 {
maxSize = DefaultThumbMaxSize
}
return types, maxSize, nil
}
types, maxSize = slot.ContentTypes, slot.MaxSize
if maxSize <= 0 {
maxSize = DefaultMaxSize
}
return types, maxSize, nil
}
// verified reports whether the given variant of the row already passed
// verification.
func verified(row *Row, variant Variant) bool {
if variant == Thumb {
return row.Thumb
}
return row.Uploaded
}
// key mints a fresh object key for the given file variant. The grant ID
// is fresh per upload, so a verified object is never overwritten in
// place: whatever sits at a live key stays byte-for-byte what was
// verified.
func (m *Manager[Tx]) key(fileID uuid.UUID, variant Variant) string {
key := fmt.Sprintf("files/%s/%s", fileID, uuid.NewV7())
if variant != Original {
key += "." + string(variant)
}
if m.cfg.Prefix != "" {
key = strings.TrimSuffix(m.cfg.Prefix, "/") + "/" + key
}
return key
}
// limit resolves the owner's storage limit; 0 means unlimited.
func (m *Manager[Tx]) limit(ctx context.Context, owner Owner) (int64, error) {
if m.cfg.Quota == nil {
return 0, nil
}
n, err := m.cfg.Quota.Limit(ctx, owner)
if err != nil {
return 0, fmt.Errorf("failed to resolve storage limit: %w", err)
}
return max(n, 0), nil
}
// Upload grants a direct upload for the given file variant: it verifies
// the caller may write the document, checks the slot policy and the
// owner's quota, mints a fresh object key, presigns a PUT on it, and
// records the grant — replacing any previous one for the variant. The
// displaced grant's object, if its upload ever happened, stays behind
// under its pending ledger entry until the sweep evicts it: deleting it
// here would bypass the ledger and could destroy an object a concurrent
// confirmation is in the middle of taking live.
//
// An original's grant is pinned to the document's announcement: the
// checksum and size are signed into the URL as request headers, so the
// store itself hashes the arriving bytes and refuses any body that does
// not match — mismatching content never lands, and the announced size is
// judged against policy and quota exactly, before any byte moves. The
// returned [Grant] carries the policy the confirmation will enforce,
// including those [Grant.Headers]. Nothing becomes visible to other
// devices until [Manager.Confirm] verifies the upload; a verified
// variant refuses further grants ([ErrUploaded]), since objects are
// immutable — replacing content means a fresh file document.
func (m *Manager[Tx]) Upload(
ctx context.Context,
scope diff.Scope,
fileID uuid.UUID,
variant Variant,
) (Grant, error) {
var (
types []string
maxSize int64
announced *Row
key string
displaced string
)
now := m.now()
err := m.cfg.Store.Exec(ctx, func(ctx context.Context, tx Tx) error {
row, err := m.cfg.Store.Writable(ctx, tx, scope, fileID)
if err != nil {
return err
}
if row == nil {
return ErrNotFound
}
if verified(row, variant) {
return ErrUploaded
}
if variant == Thumb && !row.Uploaded {
// A thumbnail derives from its original; granting one for
// nothing is a protocol smell.
return ErrNotReady
}
types, maxSize, err = m.policy(row, variant)
if err != nil {
return err
}
if variant == Original {
// Screening requires the announcement, so an empty one marks
// a row that never passed it — refuse rather than grant an
// upload nothing could pin.
if row.SHA256 == "" || row.Size <= 0 {
return errors.New("file announces no checksum")
}
// The announced size is judged up front: a file that can
// never pass the confirmation deserves no grant.
if row.Size > maxSize {
return ErrTooLarge
}
announced = row
}
// The quota gate: exact for an original, whose size is announced;
// advisory for a thumbnail, tightening the announced cap to what
// still fits. The confirmation re-checks authoritatively under
// the scope locks.
limit, err := m.limit(ctx, row.Owner())
if err != nil {
return err
}
if limit > 0 {
used, err := m.cfg.Store.Usage(ctx, tx, row.Owner())
if err != nil {
return err
}
remaining := limit - used
if remaining <= 0 || variant == Original && row.Size > remaining {
return ErrQuota
}
maxSize = min(maxSize, remaining)
}
key = m.key(fileID, variant)
displaced, err = m.cfg.Store.Grant(ctx, tx, Pending{
FileID: fileID,
Variant: variant,
Key: key,
ExpiresAt: now.Add(m.cfg.ConfirmWindow),
})
if err != nil {
return err
}
return m.cfg.Store.Record(ctx, tx, Entry{
Key: key,
FileID: fileID,
Variant: variant,
State: StatePending,
ExpiresAt: now.Add(m.cfg.ConfirmWindow),
})
})
if err != nil {
return Grant{}, err
}
if displaced != "" {
m.logger.Debug(ctx, "Displaced pending upload",
log.String("key", displaced))
}
var pinned http.Header
var headers map[string]string
if announced != nil {
headers, pinned, err = pin(announced)
if err != nil {
return Grant{}, err
}
maxSize = announced.Size
}
signed, err := m.cfg.Storage.Presign(
http.MethodPut, key, m.cfg.GrantTTL, pinned,
)
if err != nil {
// The recorded grant stays behind as a husk; a retry displaces
// it and the sweep would reap it regardless.
return Grant{}, fmt.Errorf("failed to presign upload: %w", err)
}
return Grant{
URL: signed,
Method: http.MethodPut,
Headers: headers,
MaxSize: maxSize,
ContentTypes: types,
ExpiresAt: now.Add(m.cfg.GrantTTL),
}, nil
}
// pin renders a row's announcement as the upload's signed headers: the
// checksum in the base64 form of the S3 protocol, and the exact content
// length. Returned twice — as the client-facing map the grant carries and
// as the header set handed to the signer — so the two can never drift.
func pin(row *Row) (map[string]string, http.Header, error) {
sum, err := hex.DecodeString(row.SHA256)
if err != nil {
// Screening admits only hex, so this marks a corrupted store.
return nil, nil, fmt.Errorf("announced checksum is not hex: %w", err)
}
headers := map[string]string{
s3.HeaderChecksumSHA256: base64.StdEncoding.EncodeToString(sum),
"Content-Length": strconv.FormatInt(row.Size, 10),
}
pinned := make(http.Header, len(headers))
for name, value := range headers {
pinned.Set(name, value)
}
return headers, pinned, nil
}
// Confirm turns the variant's pending upload into its verified object: it
// claims the grant, verifies the uploaded object against the slot policy,
// the announcement, and the owner's quota, then — in one transaction
// fenced by the scope's advisory locks — flips the file document's
// server-authoritative state, stamps it with a fresh engine timestamp,
// and re-enters it into the patch feed, so every other device learns of
// the upload through the normal sync protocol.
//
// A confirmation is a claim and the verification is the proof: an object
// that never arrived, breaks policy, fails its announcement, busts the
// quota, or belongs to a vanished document never becomes visible, and in
// all but the first case it is deleted on the spot. An original whose
// stored size or provider-attested digest contradicts the announcement
// is additionally marked corrupted on the document itself ([ErrChecksum]
// and the Corrupted payload field), a verdict every device sees; with
// the announcement signed into the grant that should never occur, so the
// dse_blob_corrupted_total counter doubles as a store-misbehavior alarm.
// The claim is atomic, so of two racing confirmations — or a
// confirmation racing the sweep — exactly one owns the object.
func (m *Manager[Tx]) Confirm(
ctx context.Context,
scope diff.Scope,
fileID uuid.UUID,
variant Variant,
) (Stat, error) {
var (
row *Row
pending *Pending
)
err := m.cfg.Store.Exec(ctx, func(ctx context.Context, tx Tx) error {
var err error
row, err = m.cfg.Store.Writable(ctx, tx, scope, fileID)
if err != nil {
return err
}
if row == nil {
return ErrNotFound
}
pending, err = m.cfg.Store.Claim(ctx, tx, fileID, variant, m.now())
if err != nil {
return err
}
if pending == nil {
return ErrNoPending
}
return nil
})
if err != nil {
return Stat{}, err
}
// The HEAD round-trip runs outside any transaction: network under an
// advisory lock would stall every concurrent sync of the scope.
obj, err := m.cfg.Storage.Head(ctx, pending.Key)
if err != nil {
return Stat{}, fmt.Errorf("failed to verify upload: %w", err)
}
if obj == nil {
// The grant is consumed and its ledger entry expires into the
// sweep; the client must request a fresh grant.
return Stat{}, ErrNotUploaded
}
types, maxSize, err := m.policy(row, variant)
if err != nil {
m.discard(ctx, pending.Key)
return Stat{}, err
}
// Integrity is judged first for an original, so a store that stopped
// enforcing the signed announcement surfaces as corruption, not as a
// policy refusal. The grant made the store verify the bytes on
// arrival; where the store also attests the stored digest, the
// comparison here re-proves it end to end. The verdict lands on the
// document itself, so every device sees it — not just the one that
// happened to confirm.
if variant == Original && (obj.Size != row.Size ||
obj.SHA256 != "" && !ascii.EqualFold(obj.SHA256, row.SHA256)) {
m.discard(ctx, pending.Key)
m.corrupt(ctx, scope, row, fileID)
return Stat{}, ErrChecksum
}
if obj.Size <= 0 || obj.Size > maxSize {
m.discard(ctx, pending.Key)
return Stat{}, ErrTooLarge
}
if !allowed(obj.ContentType, types) {
m.discard(ctx, pending.Key)
return Stat{}, ErrBadType
}
// The verification write runs under the ROW's advisory locks — the
// owner's user key plus the team, NOT the caller's scope: the owner's
// feed serves the row through the user arm even when a teammate
// confirms, so the flip must fence the owner exactly like the
// engine's own writes do. The flip takes a fresh stamp and sequence
// so the feed distributes it, and the quota check serializes against
// concurrent confirms of the same owner. The row may move between the
// pre-read and the locks; one retry with the fresh identity covers
// the drift.
//
// The announcement the object was verified against is pinned from
// the pre-read and re-asserted under the locks: an unverified row's
// announcement is still freely editable, so a concurrent sync push
// during the storage round-trip could otherwise slip a NEW checksum
// under a verification of the OLD content — flipping uploaded=true
// onto a document whose frozen announcement contradicts its object,
// with no verdict anywhere.
announcedSum, announcedSize := row.SHA256, row.Size
for attempt := 0; ; attempt++ {
var moved *Row
err = m.cfg.Store.Mutate(ctx, row.Scope(), func(
ctx context.Context, tx Tx,
) error {
fresh, err := m.cfg.Store.Writable(ctx, tx, scope, fileID)
if err != nil {
return err
}
if fresh == nil {
return ErrNotFound
}
if fresh.UserID != row.UserID || fresh.TeamID != row.TeamID {
// The row's audience is not the one we locked.
moved = fresh
return errMoved
}
if verified(fresh, variant) {
return ErrUploaded
}
if variant == Original && (fresh.Size != announcedSize ||
!ascii.EqualFold(fresh.SHA256, announcedSum)) {
return errDrifted
}
limit, err := m.limit(ctx, fresh.Owner())
if err != nil {
return err
}
if limit > 0 {
used, err := m.cfg.Store.Usage(ctx, tx, fresh.Owner())
if err != nil {
return err
}
if used+obj.Size > limit {
return ErrQuota
}
}
// The ledger transition comes first and is asserted: a miss
// means the sweep purged the entry — and with it the object —
// between the claim and this transaction, and verifying then
// would distribute an uploaded=true nothing can serve.
n, err := m.cfg.Store.Mark(
ctx, tx, []string{pending.Key}, StateLive,
)
if err != nil {
return err
}
if n != 1 {
return ErrNotUploaded
}
ok, err := m.cfg.Store.Verify(ctx, tx,
fileID, variant, obj.Size, obj.ContentType, m.cfg.Stamp(),
)
if err != nil {
return err
}
if !ok {
return ErrNotFound
}
return nil
})
if errors.Is(err, errMoved) {
if attempt == 0 && moved != nil {
row = moved
continue
}
err = diff.ErrConflict
}
break
}
if err != nil {
m.discard(ctx, pending.Key)
if errors.Is(err, errDrifted) {
m.corrupt(ctx, scope, row, fileID)
err = ErrChecksum
}
return Stat{}, err
}
kind := EventUploaded
if variant == Thumb {
kind = EventThumbnail
}
m.publish(Event{
Kind: kind,
FileID: fileID,
Owner: row.Owner(),
AnchorType: row.AnchorType,
AnchorID: row.AnchorID,
Slot: row.Slot,
Key: pending.Key,
Size: obj.Size,
ContentType: obj.ContentType,
At: m.now(),
})
return Stat{Size: obj.Size, ContentType: obj.ContentType}, nil
}
// Download issues a short-lived presigned link for the given verified
// variant. Read visibility follows the sync feed: the caller's own
// documents, their teams' documents, and foreign personal documents
// shared with any of their teams.
func (m *Manager[Tx]) Download(
ctx context.Context,
scope diff.Scope,
fileID uuid.UUID,
variant Variant,
) (Link, error) {
var key string
err := m.cfg.Store.Exec(ctx, func(ctx context.Context, tx Tx) error {
row, err := m.cfg.Store.Readable(ctx, tx, scope, fileID)
if err != nil {
return err
}
if row == nil {
return ErrNotFound
}
if !verified(row, variant) {
if variant == Thumb && row.Uploaded {
return ErrNoThumbnail
}
return ErrNotReady
}
var ok bool
key, ok, err = m.cfg.Store.Key(ctx, tx, fileID, variant)
if err != nil {
return err
}
if !ok {
// A verified flag without a live ledger entry means the
// reconciliation orphaned the object underneath a stale row —
// possible only through out-of-band deletions.
return ErrNotFound
}
return nil
})
if err != nil {
return Link{}, err
}
signed, err := m.cfg.Storage.Presign(
http.MethodGet, key, m.cfg.DownloadTTL, nil,
)
if err != nil {
return Link{}, fmt.Errorf("failed to presign download: %w", err)
}
return Link{
URL: signed,
ExpiresAt: m.now().Add(m.cfg.DownloadTTL),
}, nil
}
// Report returns the storage accounting of every owner in the scope: the
// user's personal usage first, then one entry per team, so clients can
// render storage meters without arithmetic of their own.
func (m *Manager[Tx]) Report(
ctx context.Context,
scope diff.Scope,
) ([]Usage, error) {
owners := make([]Owner, 0, len(scope.Teams)+1)
owners = append(owners, Owner{Kind: KindUser, ID: scope.UserID})
for _, team := range scope.Teams {
owners = append(owners, Owner{Kind: KindTeam, ID: team})
}
var used map[Owner]int64
err := m.cfg.Store.Exec(ctx, func(ctx context.Context, tx Tx) error {
var err error
used, err = m.cfg.Store.Usages(ctx, tx, owners)
return err
})
if err != nil {
return nil, err
}
out := make([]Usage, len(owners))
for i, owner := range owners {
limit, err := m.limit(ctx, owner)
if err != nil {
return nil, err
}
out[i] = Usage{Owner: owner, Used: used[owner], Limit: limit}
}
return out, nil
}
// Audit returns one owner's storage accounting regardless of any
// caller scope. It backs the admin read surface; the user-facing
// accounting goes through [Manager.Report], which enumerates the
// caller's own scope.
func (m *Manager[Tx]) Audit(
ctx context.Context,
owner Owner,
) (Usage, error) {
var used int64
err := m.cfg.Store.Exec(ctx, func(ctx context.Context, tx Tx) error {
var err error
used, err = m.cfg.Store.Usage(ctx, tx, owner)
return err
})
if err != nil {
return Usage{}, err
}
limit, err := m.limit(ctx, owner)
if err != nil {
return Usage{}, err
}
return Usage{Owner: owner, Used: used, Limit: limit}, nil
}
// Sweep evicts doomed objects — expired pending uploads and orphaned
// objects of deleted file documents — and prunes expired grant rows.
// Ledger entries leave only after their object is confirmed gone, so a
// failed eviction retries on the next pass. Failures are logged and
// swallowed; the task is safe to schedule fire-and-forget.
//
// It satisfies [schedule.TaskFn]; dispatch it on the service's scheduler:
//
// sched.Dispatch(schedule.Named(
// "dse.blobs",
// schedule.Every(interval, schedule.TaskFn(m.Sweep)),
// ))
//
// [schedule.TaskFn]: github.com/deep-rent/nexus/sys/schedule#TaskFn
func (m *Manager[Tx]) Sweep(ctx context.Context) {
now := m.now()
var doomed []Entry
err := m.cfg.Store.Exec(ctx, func(ctx context.Context, tx Tx) error {
var err error
doomed, err = m.cfg.Store.Doomed(ctx, tx, now, SweepLimit)
if err != nil {
return err
}
_, err = m.cfg.Store.PruneGrants(ctx, tx, now)
return err
})
if err != nil {
m.logger.Error(ctx, "Failed to list doomed objects", log.Error(err))
return
}
if len(doomed) == 0 {
return
}
keys := make([]string, len(doomed))
for i, e := range doomed {
keys[i] = e.Key
}
verdict, err := m.cfg.Storage.Delete(ctx, keys)
if err != nil {
m.sweepFailed.Add(uint64(len(keys)))
m.logger.Error(ctx, "Failed to evict doomed objects",
log.Int("keys", len(keys)), log.Error(err))
return
}
m.sweepFailed.Add(uint64(len(verdict.Errors)))
for _, e := range verdict.Errors {
m.logger.Warn(ctx, "Doomed object was not evicted",
log.String("key", e.Key), log.Error(e))
}
if len(verdict.Deleted) == 0 {
return
}
err = m.cfg.Store.Exec(ctx, func(ctx context.Context, tx Tx) error {
return m.cfg.Store.Purge(ctx, tx, verdict.Deleted)
})
if err != nil {
// The entries survive and the next pass re-deletes the already
// gone objects, which the store treats as success.
m.logger.Error(ctx, "Failed to purge swept entries", log.Error(err))
return
}
m.swept.Add(uint64(len(verdict.Deleted)))
m.logger.Debug(ctx, "Swept doomed objects",
log.Int("objects", len(verdict.Deleted)))
}
// Reconcile orphans stray ledger entries: live objects whose file
// document no longer exists because a deletion bypassed the handlers.
// The next sweep evicts them. In a healthy deployment every deletion runs
// through the handlers (which orphan in-transaction), so this is a cheap
// backstop, not a load-bearing path. It satisfies [schedule.TaskFn] like
// [Manager.Sweep].
func (m *Manager[Tx]) Reconcile(ctx context.Context) {
var strays []Entry
err := m.cfg.Store.Exec(ctx, func(ctx context.Context, tx Tx) error {
var err error
strays, err = m.cfg.Store.Strays(ctx, tx, SweepLimit)
if err != nil {
return err
}
if len(strays) == 0 {
return nil
}
keys := make([]string, len(strays))
for i, e := range strays {
keys[i] = e.Key
}
_, err = m.cfg.Store.Mark(ctx, tx, keys, StateOrphaned)
return err
})
if err != nil {
m.logger.Error(ctx, "Failed to reconcile stray objects",
log.Error(err))
return
}
if len(strays) > 0 {
m.strays.Add(uint64(len(strays)))
m.logger.Warn(ctx, "Orphaned stray objects",
log.Int("objects", len(strays)))
}
}
// corrupt marks the row corrupted under its scope locks — the verdict of
// a failed integrity check, distributed through the patch feed exactly
// like the verification it replaces. It is best-effort: the object is
// already discarded and the confirmation already failing, so a mark that
// cannot be written costs visibility, never safety. The row may have
// moved audiences since the pre-read; one retry with the fresh identity
// covers the drift, mirroring the verification write.
func (m *Manager[Tx]) corrupt(
ctx context.Context,
scope diff.Scope,
row *Row,
fileID uuid.UUID,
) {
m.corrupted.Add(1)
m.logger.Warn(ctx, "Uploaded object does not match its announcement",
log.UUID("file", fileID))
for attempt := 0; ; attempt++ {
var moved *Row
err := m.cfg.Store.Mutate(ctx, row.Scope(), func(
ctx context.Context, tx Tx,
) error {
fresh, err := m.cfg.Store.Writable(ctx, tx, scope, fileID)
if err != nil {
return err
}
if fresh == nil {
return nil // the document is gone; nothing to mark
}
if fresh.UserID != row.UserID || fresh.TeamID != row.TeamID {
moved = fresh
return errMoved
}
_, err = m.cfg.Store.Corrupt(ctx, tx, fileID, m.cfg.Stamp())
return err
})
if errors.Is(err, errMoved) && attempt == 0 && moved != nil {
row = moved
continue
}
if err != nil {
m.logger.Error(ctx, "Failed to mark file corrupted",
log.UUID("file", fileID), log.Error(err))
}
return
}
}
// discard evicts a policy-violating upload and, on success, removes its
// ledger entry; a failed eviction leaves the entry pending for the sweep.
func (m *Manager[Tx]) discard(ctx context.Context, key string) {
if !m.evict(ctx, key) {
return
}
err := m.cfg.Store.Exec(ctx, func(ctx context.Context, tx Tx) error {
return m.cfg.Store.Purge(ctx, tx, []string{key})
})
if err != nil {
m.logger.Warn(ctx, "Failed to purge discarded entry",
log.String("key", key), log.Error(err))
}
}
// evict best-effort deletes one object, reporting success. Eviction
// failures leave an unreferenced object behind, which costs storage but
// breaks nothing — the ledger entry keeps it on the sweep's docket.
func (m *Manager[Tx]) evict(ctx context.Context, key string) bool {
if key == "" {
return false
}
verdict, err := m.cfg.Storage.Delete(ctx, []string{key})
if err == nil && len(verdict.Errors) > 0 {
err = verdict.Errors[0]
}
if err != nil {
m.logger.Warn(ctx, "Failed to evict object",
log.String("key", key), log.Error(err))
return false
}
return true
}
// allowed reports whether the declared content type passes the
// whitelist. The comparison folds case, since media types are
// case-insensitive; an empty whitelist rejects everything, since a slot
// without declared types is a schema defect better caught loudly.
func allowed(contentType string, types []string) bool {
for _, t := range types {
if ascii.EqualFold(contentType, t) {
return true
}
}
return false
}
// publish hands the event to the observer, if any.
func (m *Manager[Tx]) publish(e Event) {
if m.observer != nil {
m.observer(e)
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package blob
import (
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/metrics"
)
// Option configures a [Manager].
type Option[Tx any] func(*Manager[Tx])
// WithLogger injects a structured logger to record lifecycle diagnostics.
// Nil values are ignored; without a logger, logging is disabled.
func WithLogger[Tx any](logger *log.Logger) Option[Tx] {
return func(m *Manager[Tx]) {
if logger != nil {
m.logger = logger
}
}
}
// WithClock injects the wall clock, which is primarily useful for
// testing. A nil clock is ignored.
func WithClock[Tx any](c clock.Clock) Option[Tx] {
return func(m *Manager[Tx]) {
if c != nil {
m.now = c
}
}
}
// WithObserver registers the observer receiving lifecycle events. A nil
// observer is ignored.
func WithObserver[Tx any](o Observer) Option[Tx] {
return func(m *Manager[Tx]) {
if o != nil {
m.observer = o
}
}
}
// WithRegistry sets the registry receiving the janitorial counters. It
// defaults to [metrics.DefaultRegistry]; a nil value is ignored.
func WithRegistry[Tx any](reg *metrics.Registry) Option[Tx] {
return func(m *Manager[Tx]) {
if reg != nil {
m.reg = reg
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package config
import (
"time"
"github.com/deep-rent/nexus/sys/boot"
"github.com/deep-rent/nexus/sys/env"
)
// Prefix namespaces every environment variable of the service.
const Prefix = "DSE_"
// Config declares the deployment configuration of a compiled document
// sync service. Bind it from the environment with [Load].
type Config struct {
// Core is the listen addresses, CORS origins, log level, and
// connection timeouts every service carries.
boot.Core `env:",inline"`
// Schema is the path to the schema plugin (.so) declaring the
// deployment's document models; see the plug package for the
// contract and its build constraints. The stock dse binary serves
// nothing without one.
Schema string `env:",default:'schema.so'"`
// Database configures the PostgreSQL connection. Empty selects the
// in-memory mock driver: every record is lost on restart, and file
// attachments are unavailable — strictly a local-development
// convenience.
Database boot.Database `env:",prefix:DATABASE_"`
// Auth declares the identity provider whose tokens this service
// accepts.
Auth boot.Auth `env:",prefix:AUTH_"`
// Storage configures the S3-compatible object store behind file
// attachments. Without it, the service syncs documents but mounts no
// file endpoints.
Storage Storage `env:",prefix:STORAGE_"`
// Quota applies flat per-owner storage limits. Compiled deployments
// wanting entitlement-driven limits inject their own [blob.Quota]
// through the service options instead.
Quota Quota `env:",prefix:QUOTA_"`
// Retention overrides the sync store's retention windows.
Retention Retention `env:",prefix:RETENTION_"`
// Rate bounds each authenticated user's request rate on the document
// endpoints. Zero disables the meter.
Rate Rate `env:",prefix:RATE_"`
// Intake configures the webhook receiver at /hooks/iam. Register
// that path with the identity service's management API, subscribed
// to "iam.user.deleted" and "iam.team.dissolved", and put the
// show-once secret the registration returns here. Like the
// offboarding endpoints the receiver needs the PostgreSQL driver;
// the mock assembly mounts nothing.
Intake boot.Intake `env:",prefix:INTAKE_"`
}
// Storage configures the S3-compatible object store behind file
// attachments.
type Storage struct {
// AccessKey is the S3 access key. Empty disables file attachments.
AccessKey string
// SecretKey is the S3 secret key.
SecretKey string
// Region is the provider region the credentials sign for.
Region string
// Bucket is the bucket's base URL — virtual-hosted or path style.
Bucket string
// Prefix is prepended to every object key, so several deployments
// can share one bucket.
Prefix string
// GrantTTL bounds how long a presigned upload URL stays usable.
// Zero keeps the blob engine's default; raise it for deployments
// whose clients upload over slow links.
GrantTTL time.Duration
// ConfirmWindow bounds the whole upload-grant lifecycle: an upload
// not confirmed within it becomes sweepable. It must exceed the
// grant TTL; the blob engine enforces that on assembly. Zero keeps
// the default.
ConfirmWindow time.Duration
// DownloadTTL bounds presigned download links. Links are cheap to
// re-request, so they should stay short-lived. Zero keeps the
// default.
DownloadTTL time.Duration
}
// Enabled reports whether object storage is configured.
func (c Storage) Enabled() bool {
return c.AccessKey != "" && c.SecretKey != "" &&
c.Region != "" && c.Bucket != ""
}
// Quota applies flat per-owner storage limits; zero means unlimited.
type Quota struct {
// UserBytes caps each user's personal verified bytes.
UserBytes int64
// TeamBytes caps each team's verified bytes.
TeamBytes int64
}
// Enabled reports whether any flat limit is configured.
func (c Quota) Enabled() bool { return c.UserBytes > 0 || c.TeamBytes > 0 }
// Retention overrides the sync store's retention windows; zero keeps the
// driver defaults. The tombstone window bounds how long a device may
// stay offline without a forced resync — and, together with the mutation
// window, how long offboarded identifiers linger before erasure
// completes.
type Retention struct {
// Mutations is the age above which mutation deduplication records
// are pruned.
Mutations time.Duration
// Tombstones is the age above which tombstones are pruned, advancing
// the retention floor.
Tombstones time.Duration
}
// Rate bounds each authenticated user's request rate on the document
// endpoints — sync, files, and stats. It protects the process from a
// hot client, per replica; it is not a precise global quota. The admin
// surface is never metered.
type Rate struct {
// PerSecond is the sustained per-user request rate. Zero disables
// the meter.
PerSecond float64
// Burst is the instantaneous allowance beyond the sustained rate;
// zero scales it with the rate.
Burst int
}
// Enabled reports whether the meter is configured.
func (c Rate) Enabled() bool { return c.PerSecond > 0 }
// Load binds a [Config] from the environment under [Prefix].
func Load(opts ...env.Option) (Config, error) {
return boot.Load[Config](Prefix, opts...)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package diff
import (
"context"
"encoding/json/jsontext"
"slices"
"uuid"
"github.com/deep-rent/nexus/eco/dse/hlc"
)
// Scope is the authorization boundary of a single sync call: the
// authenticated user and the teams they belong to, as typed UUIDs taken
// from the verified token.
type Scope struct {
// UserID identifies the authenticated user.
UserID uuid.UUID
// Teams lists the identifiers of all teams the user is a member of.
Teams []uuid.UUID
}
// Allows reports whether a document owned by the given user and assigned to
// the given team (empty for personal documents) is directly accessible
// within the scope. Grant-based visibility of foreign personal documents is
// evaluated by the store, not here.
func (s Scope) Allows(userID, teamID uuid.UUID) bool {
if userID == s.UserID {
return true
}
return teamID != uuid.Nil() && slices.Contains(s.Teams, teamID)
}
// Meta is the identifying envelope of a root document payload. Child
// documents carry only their ID; their ownership is inferred from the parent
// chain.
type Meta struct {
// ID is the document identifier (UUIDv7).
ID uuid.UUID `json:"id"`
// UserID is the immutable owner of the document.
UserID uuid.UUID `json:"user_id"`
// TeamID optionally assigns the document to a team. The zero UUID
// denotes a personal document with no team; identifiers are never
// zero, so the zero value is an unambiguous sentinel.
TeamID uuid.UUID `json:"team_id,omitzero"`
}
// Op is a single compacted, validated, and authorized operation passed to a
// [Handler].
type Op struct {
// Meta identifies the document. For child types, UserID and TeamID hold
// the identity resolved from the root document.
Meta Meta
// Action is the kind of mutation to apply.
Action Action
// Time is the HLC timestamp deciding last-write-wins.
Time hlc.Time
// Data is the full document payload; nil for deletes.
Data jsontext.Value
}
// Window bounds a feed scan: rows with Since < seq < Until, capped at Limit.
type Window struct {
// Since is the exclusive lower sequence bound.
Since int64
// Until is the exclusive upper sequence bound.
Until int64
// Limit caps the number of returned rows.
Limit int
}
// Version is one row of feed output prior to patch assembly.
type Version struct {
// ID is the document identifier.
ID uuid.UUID
// Seq is the storage sequence at which this version was recorded.
Seq int64
// Time is the HLC timestamp of this version, driving client-side
// last-write-wins application.
Time hlc.Time
// Deleted marks tombstones.
Deleted bool
// Data is the full document payload; nil when Deleted.
Data jsontext.Value
}
// Handler applies and reads changes for one document model. Implementations
// must enforce row-level last-write-wins, honor tombstones, and verify that
// existing rows targeted by an operation lie within the caller's scope (see
// the reference implementation in driver/postgres).
type Handler[Tx any] interface {
// Upsert applies create-or-replace operations in bulk.
Upsert(ctx context.Context, tx Tx, scope Scope, ops []Op) error
// Delete removes documents and records tombstones in bulk.
Delete(ctx context.Context, tx Tx, scope Scope, ops []Op) error
// Fetch returns live versions and tombstones visible to the scope
// within the window, in ascending sequence order.
Fetch(ctx context.Context, tx Tx, scope Scope, w Window) ([]Version, error)
// Resolve returns the identifying envelope of the given live documents,
// keyed by ID; absent documents are omitted. For child types, UserID
// and TeamID carry the denormalized root identity.
Resolve(
ctx context.Context,
tx Tx,
ids []uuid.UUID,
) (map[uuid.UUID]Meta, error)
}
// Reader is an optional interface a [Handler] may implement to serve point
// reads of individual documents. Models whose handlers implement it are
// retrievable through [Engine.Get] and the single-document HTTP endpoint;
// models whose handlers do not are write- and feed-only. All bundled
// handlers (the reference driver/postgres tables, the share handlers, and
// the mock driver) implement it.
type Reader[Tx any] interface {
// Read returns the live version of the given document if it is visible
// to the scope, applying the same visibility rules as [Handler.Fetch]:
// the caller's own documents, their teams' documents, and foreign
// personal documents shared with any of their teams. Absent, deleted,
// and out-of-scope documents uniformly report ok == false, so callers
// cannot distinguish foreign documents from missing ones.
Read(ctx context.Context, tx Tx, scope Scope, id uuid.UUID) (
v Version, ok bool, err error)
}
// Applied is one operation a sync actually applied: a fresh, winning,
// resolved change as it was handed to its model's handler.
type Applied struct {
// Model names the document model the operation applied to.
Model string
// Op is the applied operation.
Op Op
}
// Observer receives the applied operations of one successful sync, invoked
// AFTER the transaction committed — an aborted request never publishes.
// It runs synchronously on the syncing request, so it must stay cheap and
// must not block; hand events to a bus or queue for anything heavier.
// Derived state maintained by an observer is eventually consistent by
// construction: a crash between commit and observer loses the
// notification.
type Observer func(ctx context.Context, scope Scope, applied []Applied)
// Observers chains observers into one, invoked in order, tolerating
// nils. It collapses to nil when none survive, so callers can wire the
// result unconditionally — the engine ignores a nil observer.
func Observers(observers ...Observer) Observer {
var live []Observer
for _, o := range observers {
if o != nil {
live = append(live, o)
}
}
if len(live) == 0 {
return nil
}
return func(ctx context.Context, scope Scope, applied []Applied) {
for _, o := range live {
o(ctx, scope, applied)
}
}
}
// Vetter is an optional interface a [Handler] may implement to reject
// individual operations that need storage state to judge — an attachment
// slot at capacity, say. The engine invokes it inside the request's
// transaction, under the scope's advisory locks, on exactly the fresh
// upserts it is about to apply; replayed and skipped operations are never
// vetted, so a change that once applied cannot be retroactively rejected
// on replay.
//
// Rejections are keyed by document ID and abort the whole request
// (atomicity rolls the transaction back, claims included). The error
// return is reserved for operational failures.
type Vetter[Tx any] interface {
// Vet judges the given upsert operations, returning a cause for every
// operation to reject.
Vet(ctx context.Context, tx Tx, scope Scope, ops []Op) (
map[uuid.UUID]Cause, error)
}
// Describer is an optional interface a [Handler] may implement to declare
// the structural expectations it was built with. When a registered handler
// implements it, [New] cross-checks those expectations against the
// registry and panics on any mismatch — catching, at startup, the class of
// bug where a handler and its registry entry are configured with different
// model names or parent references (which would otherwise silently break
// the patch feed). The reference driver/postgres.Table implements it.
type Describer interface {
// Model reports the model name the handler was built for. It must equal
// the name the handler is registered under.
Model() string
// Parent reports the ownership parent field the handler persists a
// child's reference from, or ("", false) for a root handler. The field
// must equal the one declared with [Owner].
Parent() (via string, ok bool)
}
// Store provides the shared transactional machinery the engine builds on.
// Implementations must guarantee that sequence values are strictly monotonic
// and that Lock serializes all writers and readers sharing a key.
type Store[Tx any] interface {
// Exec runs fn within a single transaction, committing on nil and
// rolling back on error.
Exec(ctx context.Context, fn func(ctx context.Context, tx Tx) error) error
// Lock acquires transaction-scoped advisory locks: shared for keys the
// request only reads (feed visibility), exclusive for keys it writes.
// Writers and readers of a key serialize; concurrent readers do not.
// Implementations must acquire all keys in one global sort order,
// regardless of mode, to stay deadlock-free.
Lock(ctx context.Context, tx Tx, shared, exclusive []uuid.UUID) error
// Floor returns the minimum valid cursor. Requests starting below it
// must trigger a full resync.
Floor(ctx context.Context, tx Tx) (int64, error)
// Barrier consumes and returns the next sequence value, fencing the
// caller's own writes off from the feed scan.
Barrier(ctx context.Context, tx Tx) (int64, error)
// Watermark returns the highest sequence value assigned so far.
Watermark(ctx context.Context, tx Tx) (int64, error)
// Claim records the given mutation IDs and returns the subset that was
// not seen before.
Claim(
ctx context.Context,
tx Tx,
userID uuid.UUID,
ids []uuid.UUID,
) ([]uuid.UUID, error)
// Grants returns, for each of the given owners, the identifiers of
// the teams currently granted access to their personal documents. The
// engine folds these into the lock set whenever a request writes
// personal documents, keeping grant-based readers inside the lock
// fence.
Grants(
ctx context.Context,
tx Tx,
owners []uuid.UUID,
) (map[uuid.UUID][]uuid.UUID, error)
}
// Prefilter is an optional fast-path duplicate filter (e.g. backed by
// Valkey) consulted before the transaction. The transactional [Store.Claim]
// remains the source of truth; a prefilter only reduces wasted work.
type Prefilter interface {
// Filter returns the subset of the given identifiers that are possibly
// new. False positives are acceptable; false negatives are not.
Filter(ctx context.Context, ids []uuid.UUID) ([]uuid.UUID, error)
// Mark records the given identifiers as processed. It is called after a
// successful commit and is best-effort.
Mark(ctx context.Context, ids []uuid.UUID) error
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package diff
import (
"context"
"errors"
"net/http"
"strconv"
"uuid"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/net/header"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/sec/auth"
)
// Error reasons emitted by the sync endpoint, complementing the reasons
// defined by the router and auth packages. The per-change rejection codes
// accompanying ReasonChangesRejected are documented on the [Code]
// constants, including the appropriate client reaction for each.
const (
// ReasonChangesRejected indicates that one or more changes were
// rejected; the response context maps each rejected mutation ID to its
// [Cause]. No change from the request was applied.
ReasonChangesRejected router.Reason = "changes_rejected"
// ReasonConflict indicates a concurrent ownership change; the client
// should retry the identical request.
ReasonConflict router.Reason = "conflict_retry"
// ReasonResyncRequired indicates a cursor below the retention floor;
// the client must restart from cursor zero.
ReasonResyncRequired router.Reason = "resync_required"
// ReasonTooManyChanges indicates a change set above the configured
// maximum size; the client should split its queue into smaller batches.
ReasonTooManyChanges router.Reason = "too_many_changes"
// ReasonUnknownModel indicates that the requested document type is not
// served by this endpoint.
ReasonUnknownModel router.Reason = "unknown_model"
)
// Syncer is the engine capability the HTTP layer builds on. It is
// implemented by [Engine] and decouples the endpoint from the storage
// transaction type.
type Syncer interface {
// Sync ingests a change set and compiles the missed patch feed.
Sync(ctx context.Context, scope Scope, req *Request) (*Response, error)
}
// Getter is the engine capability behind the single-document endpoint. It
// is implemented by [Engine] and decouples the endpoint from the storage
// transaction type.
type Getter interface {
// Get returns the live version of a single document by model name and
// ID, subject to the scope's visibility.
Get(ctx context.Context, scope Scope, model string, id uuid.UUID) (
*Document, error)
}
// Endpoint builds the unified sync handler around the engine. Requests
// must carry claims verified and injected by an [auth.Guard] middleware:
//
// guard := auth.NewGuard(verifier)
// r.HandleFunc(http.MethodPost, "/sync", diff.Endpoint(engine),
// guard.Secure())
//
// The subject claim identifies the syncing user and the "teams" claim
// (via [auth.Access.Memberships]) carries their team memberships.
func Endpoint(s Syncer) router.HandlerFunc {
if s == nil {
panic("syncer is required")
}
return func(e *router.Exchange) error {
scope, err := ScopeFrom(e)
if err != nil {
return err
}
var req Request
if err := e.BindJSON(&req); err != nil {
return err
}
resp, err := s.Sync(e.Context(), scope, &req)
if err != nil {
return translate(err)
}
return e.JSON(http.StatusOK, resp)
}
}
// DocumentEndpoint builds the single-document retrieval handler around the
// engine. It serves "GET /{type}/{id}" requests, returning the live version
// of one document subject to the caller's visibility, and shares the
// authentication requirements of [Endpoint]:
//
// guard := auth.NewGuard(verifier)
// get := diff.DocumentEndpoint(engine)
// r.HandleFunc(http.MethodGet, "/{type}/{id}", get, guard.Secure())
//
// The {type} parameter names a registered document model and {id} the
// document identifier. Absent, deleted, and out-of-scope documents
// uniformly yield 404 so callers cannot probe foreign document IDs.
//
// Responses carry a strong ETag derived from the document's HLC timestamp,
// and requests may revalidate with If-None-Match: an unchanged document
// answers 304 Not Modified without a body, so integrators can poll cheaply.
func DocumentEndpoint(g Getter) router.HandlerFunc {
if g == nil {
panic("getter is required")
}
return func(e *router.Exchange) error {
scope, err := ScopeFrom(e)
if err != nil {
return err
}
id, err := uuid.Parse(e.Param("id"))
if err != nil {
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: "document ID is not a valid UUID",
Context: valid.Single("id", "must be a valid UUID"),
}
}
doc, err := g.Get(e.Context(), scope, e.Param("type"), id)
if err != nil {
return translate(err)
}
// The HLC timestamp uniquely versions a document, so it doubles as
// a strong entity tag: every applied write carries a strictly
// greater stamp than the version it replaced (including
// resurrections, which must beat the tombstone), and visibility-only
// re-sequencing (team-move cascades, grant touches) never alters the
// payload. Caching is private (visibility is per-user) and no-cache
// (revalidate on every use), which is exactly the ETag polling loop.
etag := header.Quote(strconv.FormatInt(int64(doc.Time), 10))
e.SetHeader("ETag", etag)
e.SetHeader("Cache-Control", "private, no-cache")
if header.MatchETag(e.GetHeader("If-None-Match"), etag) {
e.Status(http.StatusNotModified)
return nil
}
return e.JSON(http.StatusOK, doc)
}
}
// ScopeFrom extracts the authorization scope from the request's verified
// claims: the subject identifies the acting user and the "teams" claim (via
// [auth.Access.Memberships]) carries their team memberships. It rejects
// unauthenticated requests and machine tokens. Sibling endpoints operating
// on synced documents — the blob engine's upload and download surface
// above all — share it so every surface authorizes identically.
func ScopeFrom(e *router.Exchange) (Scope, error) {
claims, ok := auth.From(e)
if !ok {
return Scope{}, &router.Error{
Status: http.StatusUnauthorized,
Reason: auth.ReasonMissingToken,
Description: "this endpoint requires authentication",
}
}
if !claims.Delegated() {
return Scope{}, &router.Error{
Status: http.StatusForbidden,
Reason: auth.ReasonDelegationRequired,
Description: "this endpoint serves end users; " +
"machine tokens cannot access documents",
}
}
// The raw sub claim is an opaque string; UserID performs the UUID
// parse and returns the zero value for anything that is not a
// well-formed, delegated user identifier.
sub := claims.UserID()
if sub == uuid.Nil() {
return Scope{}, &router.Error{
Status: http.StatusUnauthorized,
Reason: auth.ReasonInvalidToken,
Description: "token subject is not a user identifier",
}
}
return Scope{UserID: sub, Teams: claims.Memberships()}, nil
}
// Mount registers the sync endpoint as "POST /sync" following the mount
// convention of this framework. When s also implements [Getter] (as
// [Engine] does), the single-document endpoint is registered as
// "GET /{type}/{id}" alongside it. Pass the auth guard (and any additional
// route middleware) as mws. For custom patterns, register [Endpoint] and
// [DocumentEndpoint] with [router.Router.HandleFunc] directly.
//
// Note that "GET /{type}/{id}" is a root-level wildcard: it matches EVERY
// two-segment GET on the router. The router resolves a literal segment
// ahead of a parameter, so explicit sibling routes (say, "GET /teams/{id}")
// keep winning no matter the registration order — but any two-segment GET
// no other route claims reaches the document handler and answers 404 with
// reason "unknown_model" instead of the router's plain 404. Mount the
// router under a path prefix (or register [DocumentEndpoint] on a custom
// path) if that catch-all behavior is undesirable.
func Mount(
r *router.Router,
s Syncer,
mws ...router.Middleware,
) {
r.HandleFunc(http.MethodPost, "/sync", Endpoint(s), mws...)
if g, ok := s.(Getter); ok {
r.HandleFunc(
http.MethodGet,
"/{type}/{id}",
DocumentEndpoint(g),
mws...,
)
}
}
// translate maps the engine's typed errors onto HTTP error responses.
func translate(err error) error {
if rejected, ok := errors.AsType[*Error](err); ok {
status := http.StatusBadRequest
if rejected.Forbidden() {
status = http.StatusForbidden
}
return &router.Error{
Status: status,
Reason: ReasonChangesRejected,
Description: "some changes were rejected; " +
"no change from this request was applied",
Context: rejected.Causes,
Cause: err,
}
}
if rerr, ok := errors.AsType[*ResyncError](err); ok {
return &router.Error{
Status: http.StatusGone,
Reason: ReasonResyncRequired,
Description: "cursor is too old; restart from cursor zero",
Context: map[string]any{"floor": rerr.Floor},
Cause: err,
}
}
if errors.Is(err, ErrConflict) {
return &router.Error{
Status: http.StatusConflict,
Reason: ReasonConflict,
Description: "a concurrent change interfered; retry the sync",
Cause: err,
}
}
if errors.Is(err, ErrTooManyChanges) {
return &router.Error{
Status: http.StatusRequestEntityTooLarge,
Reason: ReasonTooManyChanges,
Description: "change set exceeds the maximum size",
Cause: err,
}
}
// Unsupported models deliberately read as unknown: from the API
// consumer's perspective, a type without point reads is simply not
// served by this endpoint.
if errors.Is(err, ErrUnknownModel) || errors.Is(err, ErrUnsupportedModel) {
return &router.Error{
Status: http.StatusNotFound,
Reason: ReasonUnknownModel,
Description: "the requested document type has not been recognized",
Cause: err,
}
}
if errors.Is(err, ErrNotFound) {
return &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "the requested document does not exist",
Cause: err,
}
}
return err
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package diff
import (
"cmp"
"context"
"encoding/json/jsontext"
"encoding/json/v2"
"errors"
"fmt"
"slices"
"uuid"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/eco/dse/hlc"
"github.com/deep-rent/nexus/sys/log"
)
// Engine orchestrates the bidirectional sync pipeline: it ingests change
// sets and compiles patch feeds within a single transaction per request.
type Engine[Tx any] struct {
store Store[Tx]
reg *Registry[Tx]
cfg config
}
// New creates a sync engine around the given store and model registry.
// It panics if store or registry is nil, no models are registered,
// or their declared relationships are cyclic or dangling (programmer
// error).
func New[Tx any](
store Store[Tx],
reg *Registry[Tx],
opts ...Option,
) *Engine[Tx] {
if store == nil {
panic("store is required")
}
if reg == nil || len(reg.entries) == 0 {
panic("registry with at least one model is required")
}
reg.order() // surface cycles and dangling references now
reg.verify() // surface handler/registry misconfiguration now
cfg := config{
logger: log.Discard(),
clock: hlc.New(nil),
maxChanges: DefaultMaxChanges,
maxLimit: DefaultMaxPatches,
defLimit: DefaultLimit,
}
for _, opt := range opts {
opt(&cfg)
}
return &Engine[Tx]{store: store, reg: reg, cfg: cfg}
}
// Now returns a fresh timestamp from the engine's clock. Backend-initiated
// writes to synced tables must stamp their rows with it.
func (e *Engine[Tx]) Now() hlc.Time {
return e.cfg.clock.Now()
}
// Get returns the live version of a single document by model name and ID,
// applying the same visibility rules as the patch feed: the caller's own
// documents, their teams' documents, and foreign personal documents shared
// with any of their teams. It returns [ErrUnknownModel] for unregistered
// models, [ErrUnsupportedModel] when the model's handler does not implement
// [Reader], and [ErrNotFound] when the document is absent, deleted, or not
// visible to the scope (indistinguishable by design).
//
// Unlike Sync, Get acquires no advisory locks: the scope locks exist to
// keep the barrier/scan window of cursor pagination sound, and a point read
// carries no cursor. A read-committed snapshot of the single row is all it
// needs.
func (e *Engine[Tx]) Get(
ctx context.Context,
scope Scope,
model string,
id uuid.UUID,
) (*Document, error) {
entry, known := e.reg.lookup(model)
if !known {
return nil, ErrUnknownModel
}
reader, ok := entry.handler.(Reader[Tx])
if !ok {
return nil, ErrUnsupportedModel
}
var doc *Document
err := e.store.Exec(ctx, func(ctx context.Context, tx Tx) error {
v, found, err := reader.Read(ctx, tx, scope, id)
if err != nil {
return err
}
if !found {
return ErrNotFound
}
doc = &Document{Model: model, Time: Stamp(v.Time), Data: v.Data}
return nil
})
if err != nil {
return nil, err
}
return doc, nil
}
// change extends [Op] with ingestion bookkeeping.
type change struct {
Op
model string
mutation uuid.UUID // client-assigned mutation id
owner string // ownership parent model (child types only)
parent uuid.UUID // referenced ownership parent (child types only)
child bool // identity derives from the parent chain
resolved bool // identity in Meta is final
adopted bool // identity was adopted from the stored row
stored *Meta // identity of the targeted stored row, if any
}
// errRetry signals that the lock set drifted between resolution and lock
// acquisition and the transaction must be retried.
var errRetry = errors.New("lock set drifted")
// Sync ingests the request's change set and compiles the patch feed the
// client missed, both within a single transaction. The returned error is
// one of the typed errors in this package (see endpoint.go for their HTTP
// mapping), or an operational error from the store.
func (e *Engine[Tx]) Sync(
ctx context.Context,
scope Scope,
req *Request,
) (*Response, error) {
limit := req.Limit
if limit <= 0 {
limit = e.cfg.defLimit
}
limit = min(limit, e.cfg.maxLimit)
if len(req.Changes) > e.cfg.maxChanges {
return nil, ErrTooManyChanges
}
changes, err := e.screen(scope, req.Changes)
if err != nil {
return nil, err
}
ids := make([]uuid.UUID, 0, len(changes))
for _, c := range changes {
ids = append(ids, c.mutation)
}
if e.cfg.prefilter != nil && len(ids) > 0 {
fresh, err := e.cfg.prefilter.Filter(ctx, ids)
if err == nil {
keep := make(map[uuid.UUID]struct{}, len(fresh))
for _, id := range fresh {
keep[id] = struct{}{}
}
changes = slices.DeleteFunc(changes, func(c *change) bool {
_, ok := keep[c.mutation]
return !ok
})
ids = fresh
} else {
e.cfg.logger.Warn(ctx, "Prefilter failed", log.Error(err))
}
}
winners := compact(changes)
// The lock set may depend on document rows (child ownership chains), so
// resolution and locking race against concurrent ownership changes:
// resolve, lock, then re-verify; on drift, retry once with the union of
// both lock sets before giving up.
var resp *Response
var applied []Applied
extra := make(map[uuid.UUID]struct{})
for attempt := 0; ; attempt++ {
err := e.store.Exec(ctx, func(ctx context.Context, tx Tx) error {
shared, exclusive, owners, err := e.assemble(
ctx, tx, scope, winners, extra, true,
)
if err != nil {
return err
}
if err := e.store.Lock(ctx, tx, shared, exclusive); err != nil {
return err
}
// Re-resolve under the locks; any WRITE key outside the held
// exclusive set means a concurrent ownership change slipped in
// between resolution and locking. The verify pass re-derives the
// mutable inputs (stored identities and child ownership chains)
// that could still point outside the held set, but skips the
// grant resolution: for owners already known to pass 1, the held
// keys freeze their grants (see assemble).
_, verify, reowned, err := e.assemble(
ctx, tx, scope, winners, extra, false,
)
if err != nil {
return err
}
held := make(map[uuid.UUID]struct{}, len(exclusive))
for _, k := range exclusive {
held[k] = struct{}{}
}
for _, k := range verify {
if _, ok := held[k]; !ok {
extra[k] = struct{}{}
return errRetry
}
}
// Key membership alone misses one drift: a row moving from a
// team to PERSONAL between the passes promotes its user to a
// grant owner whose key pass 1 already held for another reason —
// no unheld key appears, yet the owner's granted teams were
// never resolved into the lock set. Owner STATUS must therefore
// drift-check too; the retry re-runs pass 1, which then fetches
// the new owner's grants.
for owner := range reowned {
if _, ok := owners[owner]; !ok {
return errRetry
}
}
resp, applied, err = e.sync(
ctx, tx, scope, req, winners, ids, limit,
)
return err
})
if errors.Is(err, errRetry) && attempt == 0 {
continue
}
if errors.Is(err, errRetry) {
return nil, ErrConflict
}
if err != nil {
return nil, err
}
break
}
if e.cfg.prefilter != nil && len(ids) > 0 {
if err := e.cfg.prefilter.Mark(ctx, ids); err != nil {
e.cfg.logger.Warn(ctx, "Prefilter mark failed", log.Error(err))
}
}
// The transaction is committed. Note that "applied" carries the
// operations as handed to the handlers: an individual row-level LWW
// loss inside a handler is not reflected, so observers deriving state
// from payloads must treat them as at-least-attempted, not as the
// stored outcome.
if e.cfg.observer != nil && len(applied) > 0 {
e.cfg.observer(ctx, scope, applied)
}
// Debug, deliberately: at production poll rates this would be the
// loudest line in the log, and the domain metrics already carry the
// counts. Anomalies surface through their own Warn lines.
e.cfg.logger.Debug(ctx, "Sync completed",
log.UUID("user", scope.UserID),
log.Int("received", len(req.Changes)),
log.Int("applied", len(applied)),
log.Int("patches", len(resp.Patches)),
log.Bool("more", resp.More),
)
return resp, nil
}
// screen validates and authorizes the raw changes without touching storage.
// It decodes each payload envelope, enforces scope on root identities,
// validates document payloads, and merges every timestamp into the engine
// clock. All rejections are collected into a single [Error], keyed by
// mutation ID, so the client can repair its queue in one pass.
func (e *Engine[Tx]) screen(
scope Scope,
raw []Change,
) ([]*change, error) {
rejected := &Error{}
// Mutation IDs must be unique within a request: idempotent dedup keys on
// the mutation ID, so a reused ID would let two documents share one
// dedup record. A duplicate is a client contract violation.
seen := make(map[uuid.UUID]struct{}, len(raw))
changes := make([]*change, 0, len(raw))
for i := range raw {
in := &raw[i]
// Structural sanity first, so every per-change failure funnels
// through the same per-mutation rejection format.
if in.ID == uuid.Nil() || in.ID[6]>>4 != 7 {
rejected.reject(in.ID, Cause{
Code: CodeInvalid,
Fields: valid.Single("id", "must be a valid UUIDv7"),
})
continue
}
if _, dup := seen[in.ID]; dup {
rejected.reject(in.ID, Cause{
Code: CodeInvalid,
Fields: valid.Single("id", "must be unique within the request"),
})
continue
}
seen[in.ID] = struct{}{}
if in.Action != ActionUpsert && in.Action != ActionDelete {
rejected.reject(in.ID, Cause{
Code: CodeInvalid,
Fields: valid.Single("action", "must be one of upsert, delete"),
})
continue
}
if len(in.Data) == 0 {
rejected.reject(in.ID, Cause{
Code: CodeInvalid,
Fields: valid.Single("data", "must not be empty"),
})
continue
}
if in.Time == 0 || in.Time > hlc.Max {
rejected.reject(in.ID, Cause{
Code: CodeInvalid,
Fields: valid.Single("time", "must be between 1 and 2^53 - 1"),
})
continue
}
entry, known := e.reg.lookup(in.Model)
if !known {
rejected.reject(in.ID, Cause{Code: CodeUnknownModel})
continue
}
c := &change{
Action: in.Action,
Time: hlc.Time(in.Time),
model: in.Model,
mutation: in.ID,
}
if in.Action == ActionUpsert {
c.Data = in.Data
}
if entry.root {
var meta Meta
if err := json.Unmarshal(in.Data, &meta); err != nil ||
meta.ID == uuid.Nil() {
rejected.reject(
in.ID,
Cause{
Code: CodeInvalid,
Fields: valid.Single(
"data",
"must carry a valid document envelope",
),
},
)
continue
}
// The typed decode already enforces UUID format; only a
// missing owner remains to reject.
if meta.UserID == uuid.Nil() {
rejected.reject(
in.ID,
Cause{
Code: CodeInvalid,
Fields: valid.Single("data", "owner must not be empty"),
},
)
continue
}
c.Meta = meta
c.resolved = true
// Payload identity is never trusted: it must lie inside the
// caller's scope, and shares may only be issued by their owner.
if !scope.Allows(meta.UserID, meta.TeamID) ||
(in.Model == ModelShare && meta.UserID != scope.UserID) {
rejected.reject(in.ID, Cause{Code: CodeForbidden})
continue
}
} else {
id, parent, owner, err := envelope(
in.Data, entry.ownerVia, entry.ownerType,
)
if err != nil {
rejected.reject(
in.ID,
Cause{
Code: CodeInvalid,
Fields: valid.Single(
"data",
"must carry a valid document envelope",
),
},
)
continue
}
c.Meta.ID = id
c.parent = parent
c.child = true
if entry.poly() {
// The discriminator decides which handler resolves the
// parent. An upsert must name an allowed model; a delete
// with a useless discriminator degrades to a never-seen
// child delete, resolved from the stored row instead.
switch {
case slices.Contains(entry.owners, owner):
c.owner = owner
case in.Action == ActionUpsert:
rejected.reject(
in.ID,
Cause{
Code: CodeInvalid,
Fields: valid.Single("data", fmt.Sprintf(
"must name an allowed parent model via %q",
entry.ownerType,
)),
},
)
continue
default:
c.parent = uuid.Nil()
}
} else {
c.owner = entry.owner
}
if in.Action == ActionUpsert && c.parent == uuid.Nil() {
rejected.reject(
in.ID,
Cause{
Code: CodeInvalid,
Fields: valid.Single("data", fmt.Sprintf(
"must reference a parent document via %q",
entry.ownerVia,
)),
},
)
continue
}
}
if in.Action == ActionUpsert {
if verr := entry.check(in.Data); verr != nil {
rejected.reject(in.ID, Cause{Code: CodeInvalid, Fields: verr})
continue
}
}
if _, err := e.cfg.clock.Update(hlc.Time(in.Time)); err != nil {
// Both clock failures are attributable to this one change's
// timestamp, so they degrade to a per-mutation rejection rather
// than failing the whole request: drift means the stamp is too
// far ahead, overflow means too many same-second mutations.
rejected.reject(in.ID, Cause{Code: CodeDrift})
continue
}
changes = append(changes, c)
}
if err := rejected.or(); err != nil {
return nil, err
}
return changes, nil
}
// envelope extracts the document ID, the optional ownership parent
// reference, and — when typeField is non-empty (polymorphic children) —
// the parent model name from a child payload.
func envelope(
data jsontext.Value,
via, typeField string,
) (id, parent uuid.UUID, owner string, err error) {
var fields map[string]jsontext.Value
if err := json.Unmarshal(data, &fields); err != nil {
return uuid.Nil(), uuid.Nil(), "", err
}
if raw, ok := fields["id"]; ok {
if err := json.Unmarshal(raw, &id); err != nil {
return uuid.Nil(), uuid.Nil(), "", err
}
}
if id == uuid.Nil() {
return uuid.Nil(), uuid.Nil(), "", errors.New("missing document id")
}
if raw, ok := fields[via]; ok && string(raw) != "null" {
if err := json.Unmarshal(raw, &parent); err != nil {
return uuid.Nil(), uuid.Nil(), "", err
}
}
if typeField != "" {
if raw, ok := fields[typeField]; ok && string(raw) != "null" {
if err := json.Unmarshal(raw, &owner); err != nil {
return uuid.Nil(), uuid.Nil(), "", err
}
}
}
return id, parent, owner, nil
}
// compact reduces the change set to one winning operation per document:
// the one carrying the highest (time, mutation id) pair. Losing mutations
// are still claimed for idempotency, but row-level last-write-wins makes
// their intermediate states unobservable, so they are never applied.
func compact(changes []*change) []*change {
type key struct {
model string
id uuid.UUID
}
best := make(map[key]*change, len(changes))
for _, c := range changes {
k := key{model: c.model, id: c.Meta.ID}
cur, exists := best[k]
if !exists || c.Time > cur.Time ||
(c.Time == cur.Time && c.mutation.Compare(cur.mutation) > 0) {
best[k] = c
}
}
winners := make([]*change, 0, len(best))
for _, c := range changes { // preserve request order for determinism
if best[key{model: c.model, id: c.Meta.ID}] == c {
winners = append(winners, c)
}
}
return winners
}
// assemble computes the lock set of the request. Shared keys cover what the
// request only reads (the caller's scope, for feed visibility); exclusive
// keys cover everything it writes: payload identities, stored identities of
// targeted rows (a delete or team move must fence the row's PREVIOUS
// audience too), resolved child roots, and — for writes touching personal
// documents — the teams currently granted by the owning user, keeping
// grant-based readers inside the fence. Extra keys carry over from a
// drifted previous attempt.
func (e *Engine[Tx]) assemble(
ctx context.Context,
tx Tx,
scope Scope,
winners []*change,
extra map[uuid.UUID]struct{},
grants bool,
) (shared, exclusive []uuid.UUID, owned map[uuid.UUID]struct{}, err error) {
if err := e.resolve(ctx, tx, winners); err != nil {
return nil, nil, nil, err
}
write := make(map[uuid.UUID]struct{})
owners := make(
map[uuid.UUID]struct{},
) // owners whose personal docs are written
include := func(userID, teamID uuid.UUID) {
write[userID] = struct{}{}
if teamID != uuid.Nil() {
write[teamID] = struct{}{}
} else {
owners[userID] = struct{}{}
}
}
for _, c := range winners {
if c.resolved {
include(c.Meta.UserID, c.Meta.TeamID)
}
if c.stored != nil {
include(c.stored.UserID, c.stored.TeamID)
}
// A landing grant re-sequences the owner's personal documents, so
// share writes fence like personal-document writes of the owner.
if c.model == ModelShare && c.resolved {
owners[c.Meta.UserID] = struct{}{}
}
}
// Personal documents may be visible to teams through live grants; their
// members' feeds fence on the team key, so writers must hold it too.
//
// The verify pass (grants == false) skips this resolution entirely. Every
// owner in this set writes a personal document, so it is already in the
// exclusive set the pass-1 lock holds; a concurrent grant change for such
// an owner would itself need that owner's exclusive key and therefore
// cannot land under our lock, freezing the owner's grants for the txn.
// The grant-derived team keys resolved by pass 1 thus stay complete and
// valid, and re-deriving them here would only repeat the shares scan. Any
// owner that newly appears under the lock is caught by its own key in the
// drift check below, before its grants could ever matter.
if grants && len(owners) > 0 {
ids := make([]uuid.UUID, 0, len(owners))
for owner := range owners {
ids = append(ids, owner)
}
slices.SortFunc(ids, func(a, b uuid.UUID) int { return a.Compare(b) })
granted, err := e.store.Grants(ctx, tx, ids)
if err != nil {
return nil, nil, nil, err
}
for _, teams := range granted {
for _, team := range teams {
write[team] = struct{}{}
}
}
}
for k := range extra {
write[k] = struct{}{}
}
read := make(map[uuid.UUID]struct{})
if _, ok := write[scope.UserID]; !ok {
read[scope.UserID] = struct{}{}
}
for _, team := range scope.Teams {
if _, ok := write[team]; !ok {
read[team] = struct{}{}
}
}
shared = make([]uuid.UUID, 0, len(read))
for k := range read {
shared = append(shared, k)
}
exclusive = make([]uuid.UUID, 0, len(write))
for k := range write {
exclusive = append(exclusive, k)
}
compare := func(a, b uuid.UUID) int { return a.Compare(b) }
slices.SortFunc(shared, compare)
slices.SortFunc(exclusive, compare)
return shared, exclusive, owners, nil
}
// resolve walks child ownership chains until every change carries its root
// identity: in-batch parents are chased transitively, everything else is
// looked up through the parent type's handler.
func (e *Engine[Tx]) resolve(
ctx context.Context,
tx Tx,
winners []*change,
) error {
// Stored and child-derived identities come from mutable rows and may
// drift between attempts, so they are re-derived on every call; root
// payload identities are immutable and stay final.
for _, c := range winners {
c.stored = nil
if c.child {
c.resolved = false
c.adopted = false
c.Meta.UserID = uuid.Nil()
c.Meta.TeamID = uuid.Nil()
}
}
// Resolve the stored identity of every targeted row. Deletes and team
// moves must fence the row's previous audience, so its current identity
// belongs to the lock set even when the payload says otherwise.
targets := make(map[string][]uuid.UUID)
for _, c := range winners {
targets[c.model] = append(targets[c.model], c.Meta.ID)
}
for model, ids := range targets {
entry, _ := e.reg.lookup(model)
metas, err := entry.handler.Resolve(ctx, tx, ids)
if err != nil {
return err
}
for _, c := range winners {
if c.model != model {
continue
}
if meta, ok := metas[c.Meta.ID]; ok {
c.stored = &meta
}
}
}
// Index in-batch upserts so children can inherit identity from parents
// created in the same request.
batch := make(map[string]map[uuid.UUID]*change)
for _, c := range winners {
if c.Action != ActionUpsert {
continue
}
if batch[c.model] == nil {
batch[c.model] = make(map[uuid.UUID]*change)
}
batch[c.model][c.Meta.ID] = c
}
// Chains are acyclic and shallow (validated at registration), so a
// bounded number of passes settles every change: each pass resolves
// children whose parent identity is already known and batch-fetches the
// rest from storage, level by level. The parent model comes from the
// change itself — static for [Owner] children, payload-resolved for
// [PolyOwner] ones.
for range len(e.reg.entries) + 1 {
progress := false
pending := make(map[string][]uuid.UUID) // parent type -> parent ids
for _, c := range winners {
if c.resolved {
continue
}
if c.Action == ActionDelete && c.parent == uuid.Nil() {
continue // never-seen child delete: dropped later
}
if p, ok := batch[c.owner][c.parent]; ok {
if p.resolved {
c.Meta.UserID = p.Meta.UserID
c.Meta.TeamID = p.Meta.TeamID
c.resolved = true
progress = true
}
continue // parent resolves in a later pass
}
pending[c.owner] = append(pending[c.owner], c.parent)
}
if len(pending) == 0 {
// No storage lookups queued — but an in-batch chain may still
// be settling one level per pass (a child whose parent resolved
// only this pass, in-batch chains deeper than one level). Only
// a pass that neither fetched nor resolved anything is truly
// done; breaking on an empty fetch set alone would falsely
// orphan deep chains whose children precede their parents in
// the request.
if !progress {
break
}
continue
}
found := make(map[string]map[uuid.UUID]Meta, len(pending))
for model, ids := range pending {
entry, ok := e.reg.lookup(model)
if !ok {
continue
}
// Siblings share parents, so the batch may carry duplicates.
slices.SortFunc(ids, func(a, b uuid.UUID) int {
return a.Compare(b)
})
metas, err := entry.handler.Resolve(ctx, tx, slices.Compact(ids))
if err != nil {
return err
}
found[model] = metas
}
for _, c := range winners {
if c.resolved {
continue
}
if c.Action == ActionDelete && c.parent == uuid.Nil() {
continue
}
if meta, ok := found[c.owner][c.parent]; ok {
c.Meta.UserID = meta.UserID
c.Meta.TeamID = meta.TeamID
c.resolved = true
}
}
}
// A child delete the chase could not settle — no parent reference, a
// dangling one, or a useless discriminator — falls back to the stored
// row's identity: the row provably exists, and the delete targets IT,
// not the parent chain the payload happens to describe. The adoption
// is marked so authorization can degrade an out-of-scope target to a
// silent drop instead of a rejection (see [Engine.sync]).
for _, c := range winners {
if c.resolved || !c.child || c.Action != ActionDelete {
continue
}
if c.stored != nil {
c.Meta.UserID = c.stored.UserID
c.Meta.TeamID = c.stored.TeamID
c.resolved = true
c.adopted = true
}
}
// Upserts must resolve; deletes of never-seen children are dropped
// silently later. An unresolvable upsert means the referenced parent
// document does not exist.
rejected := &Error{}
for _, c := range winners {
if !c.resolved && c.Action == ActionUpsert {
rejected.reject(c.mutation, Cause{Code: CodeOrphaned})
}
}
return rejected.or()
}
// sync runs the transactional core: claim, apply, and feed. Alongside the
// response it returns the operations it actually applied, for post-commit
// observation.
func (e *Engine[Tx]) sync(
ctx context.Context,
tx Tx,
scope Scope,
req *Request,
winners []*change,
ids []uuid.UUID,
limit int,
) (*Response, []Applied, error) {
floor, err := e.store.Floor(ctx, tx)
if err != nil {
return nil, nil, err
}
if req.Since > 0 && int64(req.Since) < floor {
return nil, nil, &ResyncError{Floor: Cursor(floor)}
}
// Authorize resolved child identities: the root a child hangs off must
// itself be accessible to the caller. A delete whose identity was
// ADOPTED from a foreign stored row degrades to a silent drop instead:
// the caller never named that identity, rejecting would turn the
// delete path into an existence oracle on foreign IDs (see
// [CodeTaken]), and a delete that does not apply is a no-op for the
// deleter either way.
rejected := &Error{}
for _, c := range winners {
if c.resolved && !scope.Allows(c.Meta.UserID, c.Meta.TeamID) {
if c.adopted && c.Action == ActionDelete {
c.resolved = false
continue
}
rejected.reject(c.mutation, Cause{Code: CodeForbidden})
}
}
if err := rejected.or(); err != nil {
return nil, nil, err
}
// Claim every mutation id; only winners whose own claim is fresh are
// applied. Replayed requests thereby degrade to pure feed queries.
claimed, err := e.store.Claim(ctx, tx, scope.UserID, ids)
if err != nil {
return nil, nil, err
}
fresh := make(map[uuid.UUID]struct{}, len(claimed))
for _, id := range claimed {
fresh[id] = struct{}{}
}
// Reject FRESH upserts whose stored row lies outside the caller's
// scope: the ID is occupied by a document the caller cannot write (see
// [CodeTaken]). Without this, the handler's out-of-scope guard would
// skip the write SILENTLY — the worst outcome in a sync protocol,
// since the client believes its change landed. The check must follow
// the claim: a REPLAYED mutation may have applied before the document
// moved beyond the caller's reach, and retroactively rejecting it
// would break "resending a change set is always safe" (the rejection's
// rollback undoes the claims, so a repaired resend is never mistaken
// for a replay). Deletes stay silent by design: rejecting them would
// turn the delete path into an existence oracle, and a delete that
// does not apply is a no-op for the deleter either way.
taken := &Error{}
for _, c := range winners {
if c.Action != ActionUpsert || c.stored == nil ||
scope.Allows(c.stored.UserID, c.stored.TeamID) {
continue
}
if _, ok := fresh[c.mutation]; !ok {
continue
}
if _, dup := rejected.Causes[c.mutation]; !dup {
taken.reject(c.mutation, Cause{Code: CodeTaken})
}
}
if len(taken.Causes) > 0 {
// Healthy deployments never see this: a spike means a defective
// client ID generator or someone probing with leaked IDs.
e.cfg.logger.Warn(ctx, "Rejected writes to occupied identities",
log.UUID("user", scope.UserID),
log.Int("count", len(taken.Causes)),
)
return nil, nil, taken
}
upserts := make(map[string][]Op)
deletes := make(map[string][]Op)
var applied []Applied
for _, c := range winners {
if _, ok := fresh[c.mutation]; !ok {
continue
}
if !c.resolved {
continue // delete of a never-seen child document
}
switch c.Action {
case ActionUpsert:
upserts[c.model] = append(upserts[c.model], c.Op)
case ActionDelete:
deletes[c.model] = append(deletes[c.model], c.Op)
default:
continue
}
applied = append(applied, Applied{Model: c.model, Op: c.Op})
}
writes := len(applied) > 0
// Storage-side vetting: handlers implementing [Vetter] judge the fresh
// upserts under the held locks, before any sequence value is consumed.
// A rejection aborts the whole request; the rollback undoes the claims
// above, so a repaired resend is not mistaken for a replay.
vetted := &Error{}
for model, ops := range upserts {
entry, _ := e.reg.lookup(model)
vetter, ok := entry.handler.(Vetter[Tx])
if !ok {
continue
}
causes, err := vetter.Vet(ctx, tx, scope, ops)
if err != nil {
return nil, nil, err
}
if len(causes) == 0 {
continue
}
for _, c := range winners {
if c.model != model {
continue
}
if cause, hit := causes[c.Meta.ID]; hit {
vetted.reject(c.mutation, cause)
}
}
}
if err := vetted.or(); err != nil {
return nil, nil, err
}
// The feed window ceiling. When this request applies at least one write,
// the Barrier consumes one sequence value up front: every row this
// request writes then takes a sequence strictly above it, so scanning
// below it fences the request's own writes out of its feed. When there
// is genuinely nothing to write (an empty push, or a pure replay whose
// claims all lost), that fence is moot, so we spend no sequence value
// and use the Watermark (the highest sequence assigned so far) as the
// ceiling instead — a no-op poll must not advance the global sequence.
// The exclusive upper bound is watermark + 1 so the scan still includes
// the row sitting at the watermark. Concurrent in-scope writers hold the
// scope keys we lock, so no visible row can appear past the watermark
// while we read.
var barrier int64
if writes {
barrier, err = e.store.Barrier(ctx, tx)
if err != nil {
return nil, nil, err
}
} else {
mark, err := e.store.Watermark(ctx, tx)
if err != nil {
return nil, nil, err
}
// The steady-state poll: nothing pushed and the cursor sits at (or
// past) the watermark, so the window (since, mark+1) is provably
// empty — the scope locks guarantee no visible row appears past the
// watermark while we hold them. Skip the per-model scans entirely;
// this is the overwhelmingly common request in an idle fleet. The
// closing floor re-check is moot too: pruning only ever removes
// tombstones the cursor has already passed (seq <= watermark <=
// since), so this page cannot have missed one.
if req.Since > 0 && int64(req.Since) >= mark {
return &Response{
Patches: make([]Patch, 0),
Next: max(req.Since, Cursor(mark)),
}, applied, nil
}
barrier = mark + 1
}
order := e.reg.order()
// Parents before children for upserts, children before parents for
// deletes: mirrors client-side foreign key constraints.
for _, model := range order {
if ops := upserts[model]; len(ops) > 0 {
entry, _ := e.reg.lookup(model)
if err := entry.handler.Upsert(ctx, tx, scope, ops); err != nil {
return nil, nil, err
}
}
}
for _, model := range slices.Backward(order) {
if ops := deletes[model]; len(ops) > 0 {
entry, _ := e.reg.lookup(model)
if err := entry.handler.Delete(ctx, tx, scope, ops); err != nil {
return nil, nil, err
}
}
}
resp, err := e.feed(ctx, tx, scope, req.Since, barrier, limit)
if err != nil {
return nil, nil, err
}
// Tombstone pruning runs outside the advisory locks and may advance the
// floor while this transaction scans (read committed: every statement
// sees a fresh snapshot). Re-checking after the scan guarantees the page
// missed no pruned deletion: had pruning removed a tombstone from the
// window, the floor now lies above since.
floor, err = e.store.Floor(ctx, tx)
if err != nil {
return nil, nil, err
}
if req.Since > 0 && int64(req.Since) < floor {
return nil, nil, &ResyncError{Floor: Cursor(floor)}
}
return resp, applied, nil
}
// version tags a fetched row with its model during feed assembly.
type version struct {
Version
model string
}
// feed compiles the patch feed for the window (since, barrier).
func (e *Engine[Tx]) feed(
ctx context.Context,
tx Tx,
scope Scope,
since Cursor,
barrier int64,
limit int,
) (*Response, error) {
order := e.reg.order()
// Each model contributes at most limit+1 rows; after every fetch the
// merged set is pruned back to the limit+1 lowest sequences and the
// window ceiling tightened, so memory stays bounded at ~2x limit and
// later scans shrink. Rows above the (limit+1)-th sequence can never
// appear in this page nor affect More.
var merged []version
until := barrier
for _, model := range order {
entry, _ := e.reg.lookup(model)
rows, err := entry.handler.Fetch(ctx, tx, scope, Window{
Since: int64(since),
Until: until,
Limit: limit + 1,
})
if err != nil {
return nil, err
}
for _, row := range rows {
merged = append(merged, version{Version: row, model: model})
}
if len(merged) > limit+1 {
slices.SortFunc(merged, func(a, b version) int {
return cmp.Compare(a.Seq, b.Seq)
})
merged = merged[:limit+1]
until = merged[limit].Seq + 1
}
}
slices.SortFunc(merged, func(a, b version) int {
return cmp.Compare(a.Seq, b.Seq)
})
more := len(merged) > limit
if more {
merged = merged[:limit]
}
var next Cursor
if more {
next = Cursor(merged[len(merged)-1].Seq)
} else {
mark, err := e.store.Watermark(ctx, tx)
if err != nil {
return nil, err
}
next = max(since, Cursor(mark))
}
// Group rows into one patch per type, emitted in dependency order.
// Clients apply updates in patch order and deletes in reverse patch
// order to respect their local foreign keys.
grouped := make(map[string]*Patch)
for _, row := range merged {
p, exists := grouped[row.model]
if !exists {
p = &Patch{ID: uuid.NewV7(), Model: row.model}
grouped[row.model] = p
}
if row.Deleted {
p.Delete = append(p.Delete, Deletion{
ID: row.ID,
Time: Stamp(row.Time),
})
} else {
p.Update = append(p.Update, Row{
Time: Stamp(row.Time),
Data: row.Data,
})
}
}
patches := make([]Patch, 0, len(grouped))
for _, model := range order {
if p, exists := grouped[model]; exists {
patches = append(patches, *p)
}
}
return &Response{Patches: patches, Next: next, More: more}, nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package diff
import (
"errors"
"fmt"
"uuid"
"github.com/deep-rent/nexus/dat/valid"
)
// ErrTooManyChanges is returned when a request exceeds the configured
// maximum change set size. Clients should split their pending queue into
// smaller batches and sync each in turn.
var ErrTooManyChanges = errors.New("change set exceeds maximum size")
// ErrConflict is returned when a concurrent ownership change interfered
// with the sync. The condition is transient; clients should simply retry
// the identical request (idempotency makes this safe).
var ErrConflict = errors.New("concurrent ownership change, retry sync")
// ErrUnknownModel is returned by [Engine.Get] when the requested model name
// is not registered.
var ErrUnknownModel = errors.New("unknown document model")
// ErrUnsupportedModel is returned by [Engine.Get] when the requested
// model's handler does not implement [Reader] and therefore cannot serve
// point reads.
var ErrUnsupportedModel = errors.New("model does not support point reads")
// ErrNotFound is returned by [Engine.Get] when the requested document does
// not exist or is not visible to the caller. The two cases are deliberately
// indistinguishable so callers cannot probe for the existence of foreign
// documents.
var ErrNotFound = errors.New("document not found")
// Code classifies why an individual change was rejected. Every code implies
// a specific client reaction, documented on the respective constant.
type Code string
const (
// CodeUnknownModel marks changes referencing a model the server does
// not know. This indicates a schema mismatch between client and server;
// the client should keep the change queued and prompt for an app
// update, or drop it if the model was intentionally removed.
CodeUnknownModel Code = "unknown_model"
// CodeInvalid marks changes whose payload failed validation (malformed
// envelope, missing fields, or model-specific rules; details in
// [Cause.Fields]). Retrying unchanged will fail forever: the client
// must repair the document locally or drop the change.
CodeInvalid Code = "invalid"
// CodeForbidden marks changes touching documents outside the caller's
// scope. The client should drop the change, refresh its token (team
// memberships may have changed), and perform a full resync if the
// mismatch persists.
CodeForbidden Code = "forbidden"
// CodeDrift marks changes the server clock could not accept: a timestamp
// too far in the future (usually a wrong device clock), or too many
// mutations sharing one second (logical counter exhaustion). The client
// should re-stamp its pending changes with fresh HLC timestamps after
// correcting the clock, then retry.
CodeDrift Code = "drift"
// CodeOrphaned marks child changes referencing a parent document that
// does not exist. The client should push the parent first (fix queue
// ordering) or drop the change if the parent was deleted meanwhile.
CodeOrphaned Code = "orphaned"
// CodeQuota marks changes a handler's storage-side policy rejected —
// typically an attachment slot at capacity (see [Vetter]). Retrying
// unchanged fails until capacity frees; the client should surface the
// condition to the user and drop or defer the change.
CodeQuota Code = "quota"
// CodeTaken marks upserts whose document ID is already occupied by a
// document the caller cannot write: another scope claimed the ID first
// (an ID collision — virtually always a defective client ID generator,
// occasionally a squatted leak), the document moved beyond the caller's
// reach (a team it no longer belongs to), or the caller only reads it
// through a share grant. The server cannot tell these apart, and none
// of them can ever succeed unchanged: the client should re-key the
// document — mint a fresh ID, rewrite local references, resend — when
// the content is its own work, or drop the change when it was editing
// somebody else's document. Deletes targeting such rows are silently
// skipped instead, so the rejection cannot be used to probe foreign
// IDs the caller does not already hold.
CodeTaken Code = "taken"
)
// Cause explains the rejection of a single change.
type Cause struct {
// Code classifies the rejection.
Code Code `json:"code"`
// Fields details validation failures per document field. It is only
// populated for [CodeInvalid].
Fields valid.Error `json:"fields,omitzero"`
}
// Error reports rejected changes of a sync request, keyed by mutation ID.
// Requests are atomic: if any change is rejected, no change from the
// request is applied. Causes are collected per pipeline stage, so repairing
// one round of causes may surface further rejections on the next attempt.
type Error struct {
// Causes maps each rejected mutation ID to the reason for rejection.
Causes map[uuid.UUID]Cause
}
func (e *Error) Error() string {
return fmt.Sprintf("%d changes rejected", len(e.Causes))
}
// Forbidden reports whether any change was rejected as [CodeForbidden],
// which upgrades the HTTP response from 400 to 403.
func (e *Error) Forbidden() bool {
for _, cause := range e.Causes {
if cause.Code == CodeForbidden {
return true
}
}
return false
}
// reject records a rejection cause, initializing the map on first use.
func (e *Error) reject(id uuid.UUID, cause Cause) {
if e.Causes == nil {
e.Causes = make(map[uuid.UUID]Cause)
}
e.Causes[id] = cause
}
// or returns nil (as error) when no change was rejected, and e otherwise.
// It avoids the classic non-nil interface around a nil pointer.
func (e *Error) or() error {
if len(e.Causes) == 0 {
return nil
}
return e
}
// ResyncError is returned when the requested cursor predates the retention
// floor. The client must clear its cursor and perform a full resync from
// zero; locally queued mutations are preserved and re-pushed as usual.
type ResyncError struct {
// Floor is the minimum valid cursor.
Floor Cursor
}
func (*ResyncError) Error() string {
return "cursor predates the retention floor, full resync required"
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package diff
import (
"github.com/deep-rent/nexus/eco/dse/hlc"
"github.com/deep-rent/nexus/sys/log"
)
// Default engine limits.
const (
// DefaultMaxChanges caps the number of changes accepted per request.
DefaultMaxChanges = 500
// DefaultMaxPatches caps the requestable patch feed page size.
DefaultMaxPatches = 1000
// DefaultLimit is the feed page size applied when the request omits one.
DefaultLimit = 200
)
// config holds configuration options for the [Engine].
type config struct {
logger *log.Logger
clock *hlc.Clock
prefilter Prefilter
observer Observer
maxChanges int
maxLimit int
defLimit int
}
// Option is a functional option for configuring the [Engine].
type Option func(*config)
// WithLogger sets the logger used for structured sync diagnostics.
// If not provided, logging is disabled. A nil logger is ignored.
func WithLogger(logger *log.Logger) Option {
return func(c *config) {
if logger != nil {
c.logger = logger
}
}
}
// WithClock injects the Hybrid Logical Clock instance, which is primarily
// useful for testing. A nil clock is ignored.
func WithClock(clock *hlc.Clock) Option {
return func(c *config) {
if clock != nil {
c.clock = clock
}
}
}
// WithPrefilter installs an optional fast-path duplicate filter consulted
// before the transaction. A nil prefilter is ignored.
func WithPrefilter(p Prefilter) Option {
return func(c *config) {
if p != nil {
c.prefilter = p
}
}
}
// WithObserver registers the observer receiving the applied operations of
// every successful sync, after the transaction committed. A nil observer
// is ignored.
func WithObserver(o Observer) Option {
return func(c *config) {
if o != nil {
c.observer = o
}
}
}
// WithMaxChanges overrides [DefaultMaxChanges]. Non-positive values are
// ignored.
func WithMaxChanges(n int) Option {
return func(c *config) {
if n > 0 {
c.maxChanges = n
}
}
}
// WithMaxPatches overrides [DefaultMaxPatches]. Non-positive values are
// ignored.
func WithMaxPatches(n int) Option {
return func(c *config) {
if n > 0 {
c.maxLimit = n
}
}
}
// WithDefaultLimit overrides [DefaultLimit]. Non-positive values are
// ignored.
func WithDefaultLimit(n int) Option {
return func(c *config) {
if n > 0 {
c.defLimit = n
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package diff
import (
"encoding/json/jsontext"
"encoding/json/v2"
"errors"
"fmt"
"slices"
"uuid"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/std/graph"
)
// ModelShare is the reserved model implementing personal-document grants.
// Register a store-backed handler for it via [Registry.RegisterShares] to
// enable sharing.
const ModelShare = "share"
// modelConfig holds the structural constraints of one registered model.
type modelConfig struct {
parents []string
root bool
owner string // ownership parent model; empty for roots
ownerVia string // JSON field referencing the ownership parent
owners []string // allowed parent models (polymorphic children)
ownerType string // JSON field naming the parent model (polymorphic)
}
// poly reports whether the model resolves its ownership parent per
// document through a discriminator field.
func (c *modelConfig) poly() bool { return len(c.owners) > 0 }
// Constraint declares a structural property of a registered model, such as
// its position in the ownership hierarchy or its foreign key dependencies.
type Constraint func(*modelConfig)
// Parents declares that the model references the given parent models
// through client-side foreign keys. Parents are upserted before, and
// deleted after, this model in the patch feed.
func Parents(models ...string) Constraint {
return func(c *modelConfig) {
c.parents = append(c.parents, models...)
}
}
// Root marks the model as a hierarchy root: its payloads carry the
// identifying [Meta] envelope (id, user_id, and optional team_id).
func Root() Constraint {
return func(c *modelConfig) {
c.root = true
}
}
// Owner marks the model as a child owned by the given parent model,
// referenced from the child payload by the given JSON field name.
// Ownership chains resolve transitively to a root. The parent is implicitly
// part of the dependency graph, as if declared with [Parents].
func Owner(parent, via string) Constraint {
return func(c *modelConfig) {
c.owner = parent
c.ownerVia = via
}
}
// PolyOwner marks the model as a child owned by ONE of several parent
// models, resolved per document: the payload field named by typeField
// carries the parent's model name and the field named by via carries the
// parent's ID. Ownership chains resolve transitively to a root, exactly as
// with [Owner]. Every allowed parent joins the dependency graph, so the
// model sorts after all of them in the patch feed.
//
// The reserved file model of the blob engine anchors to arbitrary
// document models this way; the constraint is public because nothing
// about it is file-specific.
func PolyOwner(typeField, via string, allowed ...string) Constraint {
return func(c *modelConfig) {
c.ownerType = typeField
c.ownerVia = via
c.owners = append(c.owners, allowed...)
}
}
// entry describes one registered model.
type entry[Tx any] struct {
modelConfig
name string
handler Handler[Tx]
check func(data jsontext.Value) valid.Error
}
// Registry maps model names to their handlers and structural constraints.
// Populate it with [Registry.Register] and pass it to [New].
type Registry[Tx any] struct {
entries map[string]*entry[Tx]
sorted []string // memoized topological order (parents first)
}
// NewRegistry initializes an empty model registry.
func NewRegistry[Tx any]() *Registry[Tx] {
return &Registry[Tx]{entries: make(map[string]*entry[Tx])}
}
// Register binds a model name to its handler. The type parameter T is the
// document model; incoming payloads are unmarshaled into T and, if T
// implements [valid.Validatable], validated before ingestion. Exactly one
// of [Root] or [Owner] must be provided.
//
// Register panics if the name is empty, reserved, or already taken
// (programmer error).
func (r *Registry[Tx]) Register[T any](
name string,
h Handler[Tx],
constraints ...Constraint,
) {
if name == "" {
panic("model name is required")
}
if name == ModelShare {
panic("model name is reserved")
}
register[Tx, T](r, name, h, constraints...)
}
// RegisterShares enables personal-document grants by binding the reserved
// [ModelShare] model to a store-backed handler (see
// driver/postgres.Store.Shares).
func (r *Registry[Tx]) RegisterShares(h Handler[Tx]) {
register[Tx, Share](r, ModelShare, h, Root())
}
// RegisterRaw binds a model name to its handler with a prebuilt payload
// check instead of a type parameter. It exists for driver-agnostic
// schema materialization, where the payload type was captured at
// declaration time and the transaction type only becomes known when a
// driver is chosen; [Registry.Register] remains the primary API. A nil
// check accepts every well-formed payload.
//
// Unlike Register, RegisterRaw does not guard the reserved model names:
// the schema layer registers the reserved file model through it.
func (r *Registry[Tx]) RegisterRaw(
name string,
h Handler[Tx],
check func(data jsontext.Value) valid.Error,
constraints ...Constraint,
) {
if name == "" {
panic("model name is required")
}
registerChecked(r, name, h, check, constraints...)
}
// Share is the document model of the reserved [ModelShare] entity: it
// grants a team access to the owner's personal documents.
type Share struct {
// ID is the share identifier (UUIDv7).
ID uuid.UUID `json:"id"`
// UserID is the granting owner; it must equal the authenticated user.
UserID uuid.UUID `json:"user_id"`
// TeamID is the team being granted access.
TeamID uuid.UUID `json:"team_id"`
}
// Validate implements the [valid.Validatable] interface. The typed decode
// already enforces UUID format; only missing values remain to reject.
func (s *Share) Validate(v *valid.Validator) {
if s.UserID == uuid.Nil() {
v.Fail("user_id", "must not be empty")
}
if s.TeamID == uuid.Nil() {
v.Fail("team_id", "must not be empty")
}
}
var _ valid.Validatable = (*Share)(nil)
func register[Tx, T any](
r *Registry[Tx],
name string,
h Handler[Tx],
constraints ...Constraint,
) {
registerChecked(r, name, h, func(data jsontext.Value) valid.Error {
var v T
if err := json.Unmarshal(data, &v); err != nil {
return valid.Single("data", "must be a well-formed document")
}
verr, _ := errors.AsType[valid.Error](valid.Test(&v))
return verr
}, constraints...)
}
func registerChecked[Tx any](
r *Registry[Tx],
name string,
h Handler[Tx],
check func(data jsontext.Value) valid.Error,
constraints ...Constraint,
) {
if h == nil {
panic("handler is required")
}
if _, exists := r.entries[name]; exists {
panic(fmt.Sprintf("model %q is already registered", name))
}
if check == nil {
check = func(jsontext.Value) valid.Error { return nil }
}
e := &entry[Tx]{name: name, handler: h}
for _, constrain := range constraints {
constrain(&e.modelConfig)
}
modes := 0
for _, set := range []bool{e.root, e.owner != "", e.poly()} {
if set {
modes++
}
}
if modes != 1 {
panic(fmt.Sprintf(
"model %q needs exactly one of Root, Owner, or PolyOwner", name,
))
}
if e.poly() && (e.ownerType == "" || e.ownerVia == "") {
panic(fmt.Sprintf(
"model %q needs both discriminator and reference fields", name,
))
}
if e.owner == name || slices.Contains(e.owners, name) {
panic(fmt.Sprintf("model %q cannot own itself", name))
}
if slices.Contains(e.parents, name) {
panic(fmt.Sprintf("model %q cannot be its own parent", name))
}
e.check = check
r.entries[name] = e
r.sorted = nil
}
// order returns the canonical topological order of all registered models
// (parents first). It memoizes its result and panics on dependency cycles
// or references to unregistered models (programmer error, surfaced by
// [New]).
func (r *Registry[Tx]) order() []string {
if r.sorted != nil {
return r.sorted
}
g := graph.New[string]()
for name, e := range r.entries {
g.AddNode(name)
refs := slices.Clone(e.parents)
if e.owner != "" {
refs = append(refs, e.owner)
}
refs = append(refs, e.owners...)
for _, parent := range refs {
if _, exists := r.entries[parent]; !exists {
panic(fmt.Sprintf(
"model %q references unregistered model %q",
name, parent,
))
}
if parent != name {
g.AddEdge(name, parent)
}
}
}
sorted, err := g.Sort()
if err != nil {
panic(err)
}
r.sorted = sorted
return sorted
}
// Models returns all registered model names in their canonical topological
// order (parents first). It panics on dependency cycles or references to
// unregistered models.
func (r *Registry[Tx]) Models() []string {
return slices.Clone(r.order())
}
// verify cross-checks each handler that implements [Describer] against its
// registry entry, panicking on a mismatch. This catches, at construction time,
// a handler configured with a different model name or parent reference than the
// entry it is registered under — a misconfiguration that would otherwise
// silently corrupt the patch feed.
func (r *Registry[Tx]) verify() {
for name, e := range r.entries {
d, ok := e.handler.(Describer)
if !ok {
continue
}
if got := d.Model(); got != name {
panic(fmt.Sprintf(
"handler for %q reports model %q; names must match",
name, got,
))
}
via, hasParent := d.Parent()
if hasParent != (e.owner != "" || e.poly()) || via != e.ownerVia {
panic(fmt.Sprintf(
"handler for %q references parent field %q; "+
"registry declares %q",
name, via, e.ownerVia,
))
}
}
}
// lookup returns the entry registered under the given model name.
func (r *Registry[Tx]) lookup(name string) (*entry[Tx], bool) {
e, ok := r.entries[name]
return e, ok
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package diff
import (
"encoding/json/jsontext"
"uuid"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/eco/dse/hlc"
)
// Stamp is a Hybrid Logical Clock timestamp as it travels over the wire.
// By construction it never exceeds 2^53 - 1, so it serializes as a plain
// JSON integer that survives IEEE 754 doubles and can be stored as a Long
// (or SQLite INTEGER) on the client.
type Stamp = hlc.Time
// Cursor is an opaque feed position. Clients persist the value returned in
// [Response.Next] and echo it back verbatim as [Request.Since] on their next
// sync. Zero means "from the beginning" and triggers a full resync.
type Cursor int64
// Action describes the kind of mutation a [Change] applies.
type Action string
const (
// ActionUpsert creates or fully replaces a document.
ActionUpsert Action = "upsert"
// ActionDelete removes a document, leaving a tombstone behind.
ActionDelete Action = "delete"
)
// Change is a single client-side mutation submitted for ingestion.
type Change struct {
// ID is the client-assigned mutation identifier used for idempotent
// deduplication. It must be a UUIDv7.
ID uuid.UUID `json:"id"`
// Action specifies whether the document is upserted or deleted.
Action Action `json:"action"`
// Model names the registered document model the change applies to.
Model string `json:"type"`
// Data carries the document payload. Upserts provide the full document;
// deletes provide at least the identifying envelope.
Data jsontext.Value `json:"data,omitzero"`
// Time is the HLC timestamp of the mutation on the producing device.
Time Stamp `json:"time"`
}
// Request is the unified sync payload: it pushes the client's pending
// changes and requests the patch feed accumulated since its last sync.
type Request struct {
// Since is the cursor returned by the previous sync, or zero on first
// contact.
Since Cursor `json:"since"`
// Limit caps the number of documents returned in the patch feed.
// Zero applies the server default.
Limit int `json:"limit,omitempty"`
// Changes lists the client's pending mutations, oldest first.
Changes []Change `json:"changes,omitempty"`
}
// Validate implements the [valid.Validatable] interface. Only the request
// envelope is validated here; per-change problems are reported through the
// unified [Error] with per-mutation causes instead.
func (r *Request) Validate(v *valid.Validator) {
v.Min("since", int64(r.Since), 0)
v.Min("limit", r.Limit, 0)
}
var _ valid.Validatable = (*Request)(nil)
// Row is one document version delivered in a patch. Clients apply it with
// last-write-wins semantics: an incoming row wins against any local state
// whose timestamp is less than or equal to the row's time.
type Row struct {
// Time is the HLC timestamp of this document version.
Time Stamp `json:"time"`
// Data is the full document payload.
Data jsontext.Value `json:"data"`
}
// Deletion is one removed (or no longer visible) document delivered in a
// patch. Clients delete the document unless they hold a strictly newer
// version: at equal timestamps, an update in the same page wins over the
// deletion.
type Deletion struct {
// ID is the identifier of the removed document.
ID uuid.UUID `json:"id"`
// Time is the HLC timestamp of the removal.
Time Stamp `json:"time"`
}
// Patch groups the feed output for a single document model.
type Patch struct {
// ID identifies this patch envelope. It carries no synchronization
// semantics and exists purely as a tracing key for client pipelines.
ID uuid.UUID `json:"id"`
// Model names the document model all entries in this patch belong to.
Model string `json:"type"`
// Delete lists removed documents. Patches are emitted in an order safe
// for client-side foreign keys: deletions arrive children-first.
Delete []Deletion `json:"delete,omitempty"`
// Update lists full document versions to be upserted, parents-first.
Update []Row `json:"update,omitempty"`
}
// Document is a single document version as returned by the single-document
// endpoint. The document identifier travels inside Data, like in a patch
// [Row].
type Document struct {
// Model names the registered document model the document belongs to.
Model string `json:"type"`
// Time is the HLC timestamp of this document version.
Time Stamp `json:"time"`
// Data is the full document payload.
Data jsontext.Value `json:"data"`
}
// Response is the outcome of a sync round-trip.
type Response struct {
// Patches contains the missed changes in order of application.
Patches []Patch `json:"patches"`
// Next is the cursor to persist and send as "since" on the next sync.
Next Cursor `json:"next"`
// More reports whether additional patches are pending beyond the
// requested limit. If true, the client should sync again immediately,
// starting from Next.
More bool `json:"more"`
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package drivertest
import (
"context"
"encoding/json/jsontext"
"encoding/json/v2"
"fmt"
"testing"
"uuid"
"github.com/deep-rent/nexus/eco/dse/diff"
)
// Caps advertises the optional reference-driver behaviors a [Target]
// implements beyond the documented [diff.Handler] contract. Scenarios that
// depend on a capability are skipped on backends that lack it.
type Caps struct {
// DepartureTombstones reports whether a team move records a move
// tombstone for the departed audience (the deletion the old team
// receives). The postgres reference driver does; the mock driver, which
// models neither cascades nor departure tombstones, does not.
DepartureTombstones bool
}
// Target captures everything a scenario needs to drive one backend: the
// shared store, a root handler, a child handler (parented to the root), the
// shares handler, a transaction runner, and seeders for the user and team
// rows a real driver references by foreign key. The type parameter erases the
// backend's transaction handle.
type Target[Tx any] struct {
// Store is the shared transactional machinery under test.
Store diff.Store[Tx]
// Root handles a root document model (id, user_id, team_id in payload).
Root diff.Handler[Tx]
// Child handles a model owned by Root; its payload carries the parent
// reference field named by ChildRef.
Child diff.Handler[Tx]
// Shares handles the reserved share model.
Shares diff.Handler[Tx]
// ChildRef is the JSON payload field (and, for postgres, column) through
// which a child references its parent root.
ChildRef string
// InTx runs fn inside one committed transaction, failing the test on
// error.
InTx func(t *testing.T, fn func(ctx context.Context, tx Tx) error)
// SeedUser registers a user and returns its id, satisfying the foreign
// keys of a real driver. It is a no-op allocator on the mock.
SeedUser func(t *testing.T) uuid.UUID
// SeedTeam registers a team and returns its id.
SeedTeam func(t *testing.T) uuid.UUID
// Caps advertises optional reference-driver behaviors.
Caps Caps
}
// RunEquivalence runs every shared scenario against the backend produced by
// the given constructor. It is invoked once per scenario so backends that
// prefer isolation may return fresh state each time; backends sharing a
// container may return the same handlers with fresh seeders. Scenarios seed
// their own users, teams, and document ids, so a shared store stays isolated.
func RunEquivalence[Tx any](
t *testing.T,
newTarget func(t *testing.T) Target[Tx],
) {
for _, sc := range scenarios[Tx]() {
t.Run(sc.name, func(t *testing.T) {
tg := newTarget(t)
if sc.departure && !tg.Caps.DepartureTombstones {
t.Skip("backend does not implement departure tombstones")
}
sc.run(t, &harness[Tx]{t: t, tg: tg})
})
}
}
// scenario is one shared behavior exercised against a backend.
type scenario[Tx any] struct {
// name identifies the scenario as a subtest.
name string
// departure marks scenarios that require the DepartureTombstones
// capability.
departure bool
// run drives the scenario through the harness.
run func(t *testing.T, h *harness[Tx])
}
// harness wraps a [Target] with the operation runners and fetch-based
// assertions the scenarios share. State is observed exclusively through the
// [diff.Handler] and [diff.Store] interfaces, so both backends are held to
// the same surface.
type harness[Tx any] struct {
t *testing.T
tg Target[Tx]
}
// user registers a fresh user and returns its id.
func (h *harness[Tx]) user() uuid.UUID { return h.tg.SeedUser(h.t) }
// team registers a fresh team and returns its id.
func (h *harness[Tx]) team() uuid.UUID { return h.tg.SeedTeam(h.t) }
// scope builds an authorization scope from a user and its teams.
func scope(user uuid.UUID, teams ...uuid.UUID) diff.Scope {
return diff.Scope{UserID: user, Teams: teams}
}
// upsert applies the given operations through the handler in one transaction.
func (h *harness[Tx]) upsert(
hd diff.Handler[Tx],
s diff.Scope,
ops ...diff.Op,
) {
h.t.Helper()
h.tg.InTx(h.t, func(ctx context.Context, tx Tx) error {
return hd.Upsert(ctx, tx, s, ops)
})
}
// remove applies the given delete operations through the handler.
func (h *harness[Tx]) remove(
hd diff.Handler[Tx],
s diff.Scope,
ops ...diff.Op,
) {
h.t.Helper()
h.tg.InTx(h.t, func(ctx context.Context, tx Tx) error {
return hd.Delete(ctx, tx, s, ops)
})
}
// fetch reads the versions visible to the scope within the window.
func (h *harness[Tx]) fetch(
hd diff.Handler[Tx],
s diff.Scope,
w diff.Window,
) []diff.Version {
h.t.Helper()
var out []diff.Version
h.tg.InTx(h.t, func(ctx context.Context, tx Tx) error {
var err error
out, err = hd.Fetch(ctx, tx, s, w)
return err
})
return out
}
// fetchAll reads every version visible to the scope.
func (h *harness[Tx]) fetchAll(
hd diff.Handler[Tx],
s diff.Scope,
) []diff.Version {
return h.fetch(hd, s, diff.Window{Since: 0, Until: 1 << 60, Limit: 1000})
}
// resolve returns the identifying envelope of the given live document.
func (h *harness[Tx]) resolve(
hd diff.Handler[Tx],
id uuid.UUID,
) (diff.Meta, bool) {
h.t.Helper()
var metas map[uuid.UUID]diff.Meta
h.tg.InTx(h.t, func(ctx context.Context, tx Tx) error {
var err error
metas, err = hd.Resolve(ctx, tx, []uuid.UUID{id})
return err
})
meta, ok := metas[id]
return meta, ok
}
// read performs a point read through the handler's [diff.Reader]
// implementation, failing the test if the backend does not provide one.
func (h *harness[Tx]) read(
hd diff.Handler[Tx],
s diff.Scope,
id uuid.UUID,
) (diff.Version, bool) {
h.t.Helper()
reader, ok := hd.(diff.Reader[Tx])
if !ok {
h.t.Fatalf("handler %T does not implement diff.Reader", hd)
}
var out diff.Version
var found bool
h.tg.InTx(h.t, func(ctx context.Context, tx Tx) error {
var err error
out, found, err = reader.Read(ctx, tx, s, id)
return err
})
return out, found
}
// find returns the single version of the given document visible to the
// scope, if any.
func (h *harness[Tx]) find(
hd diff.Handler[Tx],
s diff.Scope,
id uuid.UUID,
) (diff.Version, bool) {
h.t.Helper()
for _, v := range h.fetchAll(hd, s) {
if v.ID == id {
return v, true
}
}
return diff.Version{}, false
}
// wantLive asserts that the given document is live to the scope at the
// given timestamp and payload marker, and returns the version.
func (h *harness[Tx]) wantLive(
hd diff.Handler[Tx],
s diff.Scope,
id uuid.UUID,
time diff.Stamp,
mark int,
) diff.Version {
h.t.Helper()
v, ok := h.find(hd, s, id)
if !ok {
h.t.Fatalf("id %v: got absent; want live", id)
}
if v.Deleted {
h.t.Fatalf("id %v: got tombstone; want live", id)
}
if v.Time != time {
h.t.Errorf("id %v: got time %d; want %d", id, v.Time, time)
}
if got := marker(h.t, v.Data); got != mark {
h.t.Errorf("id %v: got marker %d; want %d", id, got, mark)
}
return v
}
// wantDead asserts that the given document is a tombstone to the scope at
// the given timestamp, and returns the version.
func (h *harness[Tx]) wantDead(
hd diff.Handler[Tx],
s diff.Scope,
id uuid.UUID,
time diff.Stamp,
) diff.Version {
h.t.Helper()
v, ok := h.find(hd, s, id)
if !ok {
h.t.Fatalf("id %v: got absent; want tombstone", id)
}
if !v.Deleted {
h.t.Fatalf("id %v: got live; want tombstone", id)
}
if v.Time != time {
h.t.Errorf("id %v: got tombstone time %d; want %d", id, v.Time, time)
}
if len(v.Data) != 0 {
h.t.Errorf("id %v: got tombstone payload %s; want none", id, v.Data)
}
return v
}
// wantAbsent asserts that the given document is neither live nor
// tombstoned to the scope.
func (h *harness[Tx]) wantAbsent(
hd diff.Handler[Tx],
s diff.Scope,
id uuid.UUID,
) {
h.t.Helper()
if v, ok := h.find(hd, s, id); ok {
h.t.Errorf(
"id %v: got version (deleted %t); want absent",
id,
v.Deleted,
)
}
}
// upsertOp builds a root or child upsert operation with a payload carrying
// the given marker.
func upsertOp(
id uuid.UUID,
owner uuid.UUID,
team uuid.UUID,
time diff.Stamp,
data string,
) diff.Op {
return diff.Op{
Meta: diff.Meta{ID: id, UserID: owner, TeamID: team},
Action: diff.ActionUpsert,
Time: time,
Data: jsontext.Value(data),
}
}
// deleteOp builds a delete operation.
func deleteOp(
id uuid.UUID,
owner uuid.UUID,
team uuid.UUID,
time diff.Stamp,
) diff.Op {
return diff.Op{
Meta: diff.Meta{ID: id, UserID: owner, TeamID: team},
Action: diff.ActionDelete,
Time: time,
}
}
// doc renders a root payload carrying an integer marker used to prove which
// write won a conflict.
func doc(id uuid.UUID, mark int) string {
return fmt.Sprintf(`{"id":%q,"v":%d}`, id.String(), mark)
}
// childDoc renders a child payload carrying the parent reference under the
// given field name plus an integer marker.
func childDoc(id, parent uuid.UUID, ref string, mark int) string {
return fmt.Sprintf(`{"id":%q,%q:%q,"v":%d}`,
id.String(), ref, parent.String(), mark)
}
// marker extracts the integer payload marker, failing the test on malformed
// data. Backends normalize JSON differently (the postgres jsonb round-trip
// reorders keys and reformats whitespace), so scenarios compare this parsed
// field rather than the raw bytes.
func marker(t *testing.T, data jsontext.Value) int {
t.Helper()
var m struct {
V int `json:"v"`
}
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf(
"unmarshal payload: should not have returned an error: %v",
err,
)
}
return m.V
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package drivertest
import (
"testing"
"uuid"
"github.com/deep-rent/nexus/eco/dse/diff"
)
// scenarios returns the shared behavior suite. Adding an entry covers both
// backends at once. Every assertion observes state through Fetch and Resolve,
// and compares HLC timestamps (deterministic) rather than sequence values
// (monotonic but backend-specific).
func scenarios[Tx any]() []scenario[Tx] {
return []scenario[Tx]{
{name: "lww", run: scLWW[Tx]},
{name: "tombstone_lifecycle", run: scTombstone[Tx]},
{name: "hijack_guard", run: scHijack[Tx]},
{name: "owner_immutable", run: scOwnerImmutable[Tx]},
{name: "fetch_window", run: scFetchWindow[Tx]},
{name: "grant_visibility", run: scGrantVisibility[Tx]},
{name: "child_lww", run: scChild[Tx]},
{name: "team_move_departure", departure: true, run: scDeparture[Tx]},
{name: "point_read", run: scRead[Tx]},
}
}
// scLWW covers row-level last-write-wins: a newer upsert wins, an older one
// is skipped, and an equal-timestamp upsert keeps the existing row.
func scLWW[Tx any](_ *testing.T, h *harness[Tx]) {
owner := h.user()
s := scope(owner)
id := uuid.NewV7()
h.upsert(h.tg.Root, s, upsertOp(id, owner, uuid.Nil(), 100, doc(id, 1)))
h.wantLive(h.tg.Root, s, id, 100, 1)
// A stale upsert loses: the row keeps its timestamp and payload.
h.upsert(h.tg.Root, s, upsertOp(id, owner, uuid.Nil(), 50, doc(id, 9)))
h.wantLive(h.tg.Root, s, id, 100, 1)
// An equal-timestamp upsert keeps the existing row.
h.upsert(h.tg.Root, s, upsertOp(id, owner, uuid.Nil(), 100, doc(id, 9)))
h.wantLive(h.tg.Root, s, id, 100, 1)
// A strictly newer upsert wins.
h.upsert(h.tg.Root, s, upsertOp(id, owner, uuid.Nil(), 200, doc(id, 2)))
h.wantLive(h.tg.Root, s, id, 200, 2)
}
// scTombstone covers the tombstone lifecycle: a delete leaves a tombstone, a
// stale delete is a no-op, a stale upsert cannot resurrect, a newer upsert
// resurrects and clears the tombstone, and a delete of an absent document
// tombstones the payload identity.
func scTombstone[Tx any](t *testing.T, h *harness[Tx]) {
owner := h.user()
s := scope(owner)
id := uuid.NewV7()
h.upsert(h.tg.Root, s, upsertOp(id, owner, uuid.Nil(), 100, doc(id, 1)))
// A newer delete wins and leaves a tombstone carrying its timestamp.
h.remove(h.tg.Root, s, deleteOp(id, owner, uuid.Nil(), 200))
h.wantDead(h.tg.Root, s, id, 200)
// A stale delete of the tombstoned document is a no-op.
h.remove(h.tg.Root, s, deleteOp(id, owner, uuid.Nil(), 150))
h.wantDead(h.tg.Root, s, id, 200)
// A stale upsert cannot resurrect.
h.upsert(h.tg.Root, s, upsertOp(id, owner, uuid.Nil(), 150, doc(id, 9)))
h.wantDead(h.tg.Root, s, id, 200)
// A newer upsert resurrects the document and clears the tombstone.
h.upsert(h.tg.Root, s, upsertOp(id, owner, uuid.Nil(), 300, doc(id, 5)))
h.wantLive(h.tg.Root, s, id, 300, 5)
// Deleting an absent document tombstones the payload identity.
absent := uuid.NewV7()
h.remove(h.tg.Root, s, deleteOp(absent, owner, uuid.Nil(), 40))
h.wantDead(h.tg.Root, s, absent, 40)
if meta, ok := h.resolve(h.tg.Root, absent); ok {
t.Errorf("id %v: got resolved live %v; want absent", absent, meta)
}
}
// scHijack covers the hijack guard: an out-of-scope caller cannot overwrite
// or delete an existing row, even with a newer timestamp and a forged payload
// identity.
func scHijack[Tx any](_ *testing.T, h *harness[Tx]) {
owner := h.user()
attacker := h.user()
id := uuid.NewV7()
h.upsert(
h.tg.Root,
scope(owner),
upsertOp(id, owner, uuid.Nil(), 100, doc(id, 1)),
)
// A forged upsert under the attacker's scope leaves the row untouched.
h.upsert(
h.tg.Root,
scope(attacker),
upsertOp(id, owner, uuid.Nil(), 200, doc(id, 9)),
)
h.wantLive(h.tg.Root, scope(owner), id, 100, 1)
// A foreign delete cannot remove the row nor tombstone it.
h.remove(h.tg.Root, scope(attacker), deleteOp(id, owner, uuid.Nil(), 300))
h.wantLive(h.tg.Root, scope(owner), id, 100, 1)
}
// scOwnerImmutable covers owner immutability: a team member may update a team
// document, but the owner never yields to the payload identity.
func scOwnerImmutable[Tx any](t *testing.T, h *harness[Tx]) {
owner := h.user()
member := h.user()
team := h.team()
id := uuid.NewV7()
h.upsert(h.tg.Root, scope(owner, team),
upsertOp(id, owner, team, 100, doc(id, 1)))
// The member's update applies, but the forged owner is ignored.
h.upsert(h.tg.Root, scope(member, team),
upsertOp(id, member, team, 200, doc(id, 2)))
h.wantLive(h.tg.Root, scope(owner, team), id, 200, 2)
meta, ok := h.resolve(h.tg.Root, id)
if !ok {
t.Fatalf("id %v: should have resolved", id)
}
if meta.UserID != owner {
t.Errorf("got owner %q; want %q (immutable)", meta.UserID, owner)
}
}
// scFetchWindow covers the feed scan: exclusive sequence bounds, ascending
// order, the limit cap, interleaved tombstones, and populated timestamps on
// both live and tombstone rows. It asserts on order and set membership, never
// on absolute sequence values, which differ between backends.
func scFetchWindow[Tx any](t *testing.T, h *harness[Tx]) {
owner := h.user()
s := scope(owner)
ids := make([]uuid.UUID, 5)
for i := range ids {
ids[i] = uuid.NewV7()
h.upsert(
h.tg.Root,
s,
upsertOp(
ids[i],
owner,
uuid.Nil(),
diff.Stamp(100+i),
doc(ids[i], i),
),
)
}
// The full scan returns all five rows in ascending sequence order with
// their HLC timestamps intact.
all := h.fetchAll(h.tg.Root, s)
if got, want := len(all), 5; got != want {
t.Fatalf("full scan: got %d versions; want %d", got, want)
}
for i, v := range all {
if v.ID != ids[i] {
t.Errorf("full scan at %d: got id %v; want %v", i, v.ID, ids[i])
}
if want := diff.Stamp(100 + i); v.Time != want {
t.Errorf("full scan at %d: got time %d; want %d", i, v.Time, want)
}
if i > 0 && all[i-1].Seq >= v.Seq {
t.Errorf("full scan at %d: seq %d not after %d",
i, v.Seq, all[i-1].Seq)
}
}
// Both window bounds are exclusive: bounding by the first and last
// observed sequence values drops both ends.
mid := h.fetch(h.tg.Root, s, diff.Window{
Since: all[0].Seq,
Until: all[4].Seq,
Limit: 1000,
})
if got := versionIDs(mid); !equal(got, ids[1:4]) {
t.Errorf("bounded scan: got ids %v; want %v", got, ids[1:4])
}
// The limit caps the page to the lowest sequence values.
limited := h.fetch(
h.tg.Root,
s,
diff.Window{Since: 0, Until: 1 << 60, Limit: 2},
)
if got := versionIDs(limited); !equal(got, ids[:2]) {
t.Errorf("limited scan: got ids %v; want %v", got, ids[:2])
}
// A tombstone interleaves at the end and carries its own timestamp.
gone := uuid.NewV7()
h.remove(h.tg.Root, s, deleteOp(gone, owner, uuid.Nil(), 200))
all = h.fetchAll(h.tg.Root, s)
if got, want := len(all), 6; got != want {
t.Fatalf("after delete: got %d versions; want %d", got, want)
}
last := all[len(all)-1]
if last.ID != gone || !last.Deleted || last.Time != 200 {
t.Errorf("got last version %v (deleted %t, time %d);"+
" want tombstone %v at time 200",
last.ID, last.Deleted, last.Time, gone)
}
}
// scGrantVisibility covers grant-based visibility: a personal document is
// invisible to a team until a share grants it, visible after, and hidden
// again once the share is deleted.
func scGrantVisibility[Tx any](_ *testing.T, h *harness[Tx]) {
owner := h.user()
team := h.team()
member := h.user()
ownerScope := scope(owner)
memberScope := scope(member, team)
id := uuid.NewV7()
h.upsert(
h.tg.Root,
ownerScope,
upsertOp(id, owner, uuid.Nil(), 100, doc(id, 1)),
)
// Before the grant, the personal document is invisible to the team.
h.wantAbsent(h.tg.Root, memberScope, id)
// The grant exposes the owner's personal document to the team.
grant := uuid.NewV7()
h.upsert(h.tg.Shares, ownerScope, upsertOp(grant, owner, team, 110, "{}"))
h.wantLive(h.tg.Root, memberScope, id, 100, 1)
// Revoking the grant hides the document again.
h.remove(h.tg.Shares, ownerScope, deleteOp(grant, owner, team, 120))
h.wantAbsent(h.tg.Root, memberScope, id)
}
// scChild exercises the child handler and its parent linkage: a child model
// honors the same last-write-wins and fetch semantics as a root, and resolves
// to the denormalized owner identity carried on its operations.
func scChild[Tx any](t *testing.T, h *harness[Tx]) {
owner := h.user()
s := scope(owner)
parent := uuid.NewV7()
id := uuid.NewV7()
h.upsert(
h.tg.Child,
s,
upsertOp(
id,
owner,
uuid.Nil(),
100,
childDoc(id, parent, h.tg.ChildRef, 1),
),
)
h.wantLive(h.tg.Child, s, id, 100, 1)
// A newer upsert wins on the child, exactly as on the root.
h.upsert(
h.tg.Child,
s,
upsertOp(
id,
owner,
uuid.Nil(),
200,
childDoc(id, parent, h.tg.ChildRef, 2),
),
)
h.wantLive(h.tg.Child, s, id, 200, 2)
meta, ok := h.resolve(h.tg.Child, id)
if !ok {
t.Fatalf("id %v: child should have resolved", id)
}
if meta.UserID != owner {
t.Errorf("got child owner %q; want %q", meta.UserID, owner)
}
}
// scDeparture covers the team-move departure tombstone: moving a root's team
// leaves the old team a deletion for that id, while the live row survives
// under the new team. Only backends advertising DepartureTombstones run this.
func scDeparture[Tx any](_ *testing.T, h *harness[Tx]) {
owner := h.user()
teamA := h.team()
teamB := h.team()
writer := scope(owner, teamA, teamB)
// Strangers observing each team's feed (reads need no seeded user).
oldAudience := scope(uuid.NewV7(), teamA)
newAudience := scope(uuid.NewV7(), teamB)
id := uuid.NewV7()
h.upsert(h.tg.Root, writer, upsertOp(id, owner, teamA, 100, doc(id, 1)))
h.wantLive(h.tg.Root, oldAudience, id, 100, 1)
// Moving the root to team B leaves team A a move tombstone at the move's
// timestamp, while team B and the owner see the live row.
h.upsert(h.tg.Root, writer, upsertOp(id, owner, teamB, 200, doc(id, 2)))
h.wantDead(h.tg.Root, oldAudience, id, 200)
h.wantLive(h.tg.Root, newAudience, id, 200, 2)
h.wantLive(h.tg.Root, scope(owner), id, 200, 2)
}
// scRead covers point reads: a document is readable by its owner and team
// members, foreign personal documents surface only through live grants, and
// absent, deleted, and out-of-scope documents uniformly read as missing.
func scRead[Tx any](t *testing.T, h *harness[Tx]) {
owner := h.user()
team := h.team()
member := h.user()
stranger := h.user()
ownerScope := scope(owner)
memberScope := scope(member, team)
// The owner reads their own personal document; a stranger cannot, and
// cannot distinguish it from a missing one.
personal := uuid.NewV7()
h.upsert(h.tg.Root, ownerScope,
upsertOp(personal, owner, uuid.Nil(), 100, doc(personal, 1)))
v, ok := h.read(h.tg.Root, ownerScope, personal)
if !ok {
t.Fatalf("id %v: owner read got absent; want live", personal)
}
if v.Time != 100 {
t.Errorf("id %v: got time %d; want 100", personal, v.Time)
}
if got := marker(t, v.Data); got != 1 {
t.Errorf("id %v: got marker %d; want 1", personal, got)
}
if _, ok := h.read(h.tg.Root, scope(stranger), personal); ok {
t.Errorf("id %v: stranger read got live; want absent", personal)
}
// Team documents are readable by team members.
shared := uuid.NewV7()
h.upsert(h.tg.Root, scope(owner, team),
upsertOp(shared, owner, team, 110, doc(shared, 2)))
if _, ok := h.read(h.tg.Root, memberScope, shared); !ok {
t.Errorf("id %v: member read got absent; want live", shared)
}
// A grant exposes the owner's personal document to team members;
// revoking it hides the document again.
grant := uuid.NewV7()
h.upsert(h.tg.Shares, ownerScope, upsertOp(grant, owner, team, 120, "{}"))
if _, ok := h.read(h.tg.Root, memberScope, personal); !ok {
t.Errorf("id %v: granted read got absent; want live", personal)
}
if _, ok := h.read(h.tg.Shares, memberScope, grant); !ok {
t.Errorf("grant %v: member read got absent; want live", grant)
}
h.remove(h.tg.Shares, ownerScope, deleteOp(grant, owner, team, 130))
if _, ok := h.read(h.tg.Root, memberScope, personal); ok {
t.Errorf("id %v: revoked read got live; want absent", personal)
}
// Deleted documents read as missing, not as tombstones.
h.remove(h.tg.Root, ownerScope, deleteOp(personal, owner, uuid.Nil(), 200))
if _, ok := h.read(h.tg.Root, ownerScope, personal); ok {
t.Errorf("id %v: deleted read got live; want absent", personal)
}
// Absent ids read as missing.
if _, ok := h.read(h.tg.Root, ownerScope, uuid.NewV7()); ok {
t.Error("absent id: read got live; want absent")
}
}
// versionIDs projects versions onto their document ids, preserving order.
func versionIDs(vs []diff.Version) []uuid.UUID {
out := make([]uuid.UUID, len(vs))
for i, v := range vs {
out[i] = v.ID
}
return out
}
// equal reports whether two id slices match element for element.
func equal(a, b []uuid.UUID) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package mock
import (
"cmp"
"context"
"encoding/json/jsontext"
"slices"
"sync"
"uuid"
"github.com/deep-rent/nexus/eco/dse/diff"
"github.com/deep-rent/nexus/eco/dse/hlc"
)
// Tx is the no-op transaction handle of the mock driver. All state lives in
// the [Store] and its handlers, guarded by a shared mutex.
type Tx struct{}
// Store is an in-memory implementation of [diff.Store]. The zero value is
// not usable; construct instances with [New]. Exported fields allow error
// injection and introspection in tests.
type Store struct {
mu sync.Mutex
seq int64
floor int64
claimed map[uuid.UUID]struct{}
handlers []*Handler
// Granted maps document owners to the teams granted access to their
// personal documents. It emulates the driver-side share lookup; the
// shares handler writes through to it, and tests may also populate it
// directly.
Granted map[uuid.UUID][]uuid.UUID
// Locked records the deduplicated, sorted union of shared and
// exclusive keys of every Lock call.
Locked [][]uuid.UUID
// Exclusive records the deduplicated, sorted exclusive keys of every
// Lock call.
Exclusive [][]uuid.UUID
// Touched records every owner whose personal documents were
// re-sequenced by a landing share grant.
Touched []uuid.UUID
// Error injection: when set, the corresponding method fails.
ErrExec error
ErrLock error
ErrFloor error
ErrBarrier error
ErrWatermark error
ErrClaim error
ErrGrants error
ErrTouch error
// OnLock, if set, is invoked at the end of every [Store.Lock] call. It
// lets a test simulate a concurrent ownership change landing during the
// engine's resolve/lock/verify window, exercising the drift-retry path.
OnLock func()
}
// New initializes an empty in-memory store.
func New() *Store {
return &Store{
claimed: make(map[uuid.UUID]struct{}),
Granted: make(map[uuid.UUID][]uuid.UUID),
}
}
// SetFloor sets the retention floor returned by [Store.Floor].
func (s *Store) SetFloor(floor int64) {
s.mu.Lock()
defer s.mu.Unlock()
s.floor = floor
}
// Exec implements the [diff.Store] interface.
func (s *Store) Exec(
ctx context.Context,
fn func(ctx context.Context, tx *Tx) error,
) error {
if s.ErrExec != nil {
return s.ErrExec
}
return fn(ctx, &Tx{})
}
// Lock implements the [diff.Store] interface.
func (s *Store) Lock(
_ context.Context,
_ *Tx,
shared, exclusive []uuid.UUID,
) error {
if s.ErrLock != nil {
return s.ErrLock
}
compare := func(a, b uuid.UUID) int { return a.Compare(b) }
all := slices.Concat(shared, exclusive)
slices.SortFunc(all, compare)
all = slices.Compact(all)
write := slices.Clone(exclusive)
slices.SortFunc(write, compare)
write = slices.Compact(write)
s.mu.Lock()
s.Locked = append(s.Locked, all)
s.Exclusive = append(s.Exclusive, write)
hook := s.OnLock
s.mu.Unlock()
if hook != nil {
hook()
}
return nil
}
// Floor implements the [diff.Store] interface.
func (s *Store) Floor(_ context.Context, _ *Tx) (int64, error) {
if s.ErrFloor != nil {
return 0, s.ErrFloor
}
s.mu.Lock()
defer s.mu.Unlock()
return s.floor, nil
}
// Barrier implements the [diff.Store] interface.
func (s *Store) Barrier(_ context.Context, _ *Tx) (int64, error) {
if s.ErrBarrier != nil {
return 0, s.ErrBarrier
}
s.mu.Lock()
defer s.mu.Unlock()
s.seq++
return s.seq, nil
}
// Watermark implements the [diff.Store] interface.
func (s *Store) Watermark(_ context.Context, _ *Tx) (int64, error) {
if s.ErrWatermark != nil {
return 0, s.ErrWatermark
}
s.mu.Lock()
defer s.mu.Unlock()
return s.seq, nil
}
// Claim implements the [diff.Store] interface.
func (s *Store) Claim(
_ context.Context,
_ *Tx,
_ uuid.UUID,
ids []uuid.UUID,
) ([]uuid.UUID, error) {
if s.ErrClaim != nil {
return nil, s.ErrClaim
}
s.mu.Lock()
defer s.mu.Unlock()
var fresh []uuid.UUID
for _, id := range ids {
if _, seen := s.claimed[id]; !seen {
s.claimed[id] = struct{}{}
fresh = append(fresh, id)
}
}
return fresh, nil
}
// Grants implements the [diff.Store] interface.
func (s *Store) Grants(
_ context.Context,
_ *Tx,
owners []uuid.UUID,
) (map[uuid.UUID][]uuid.UUID, error) {
if s.ErrGrants != nil {
return nil, s.ErrGrants
}
s.mu.Lock()
defer s.mu.Unlock()
out := make(map[uuid.UUID][]uuid.UUID, len(owners))
for _, owner := range owners {
if teams := s.Granted[owner]; len(teams) > 0 {
out[owner] = slices.Clone(teams)
}
}
return out, nil
}
// touch re-sequences all personal documents of the given owner across
// every registered handler. The shares handler invokes it when a grant
// lands.
func (s *Store) touch(ownerID uuid.UUID) error {
if s.ErrTouch != nil {
return s.ErrTouch
}
s.mu.Lock()
defer s.mu.Unlock()
s.Touched = append(s.Touched, ownerID)
for _, h := range s.handlers {
for _, r := range h.rows {
if r.meta.UserID == ownerID && r.meta.TeamID == uuid.Nil() {
s.seq++
r.seq = s.seq
}
}
}
return nil
}
// next returns a fresh sequence value.
func (s *Store) next() int64 {
s.mu.Lock()
defer s.mu.Unlock()
s.seq++
return s.seq
}
// grants reports whether a personal document of the given owner is visible
// to any of the given teams.
func (s *Store) granted(owner uuid.UUID, teams []uuid.UUID) bool {
s.mu.Lock()
defer s.mu.Unlock()
for _, team := range s.Granted[owner] {
if slices.Contains(teams, team) {
return true
}
}
return false
}
// row is one live document version.
type row struct {
meta diff.Meta
hlc hlc.Time
seq int64
data jsontext.Value
}
// Handler is an in-memory implementation of [diff.Handler] with row-level
// last-write-wins, tombstones, and scope guards. Construct instances with
// [NewHandler].
type Handler struct {
store *Store
rows map[uuid.UUID]*row
tombs map[uuid.UUID]*row // data is nil; hlc records the delete time
// Calls records the order of Upsert/Delete/Fetch invocations as
// "upsert"/"delete"/"fetch" strings for order assertions.
Calls []string
// Error injection: when set, the corresponding method fails.
ErrUpsert error
ErrDelete error
ErrFetch error
ErrRead error
// OnFetch, if set, is invoked at the start of every [Handler.Fetch]
// call. It lets a test simulate a concurrent tombstone prune advancing
// the retention floor mid-scan, exercising the post-feed resync recheck.
OnFetch func()
}
// NewHandler initializes an in-memory handler and registers it with the
// store so grant-triggered re-sequencing can reach its rows.
func NewHandler(s *Store) *Handler {
if s == nil {
panic("store is required")
}
h := &Handler{
store: s,
rows: make(map[uuid.UUID]*row),
tombs: make(map[uuid.UUID]*row),
}
s.mu.Lock()
s.handlers = append(s.handlers, h)
s.mu.Unlock()
return h
}
// Upsert implements the [diff.Handler] interface.
func (h *Handler) Upsert(
_ context.Context,
_ *Tx,
scope diff.Scope,
ops []diff.Op,
) error {
h.Calls = append(h.Calls, "upsert")
if h.ErrUpsert != nil {
return h.ErrUpsert
}
for _, op := range ops {
id := op.Meta.ID
// A live tombstone beats stale upserts; newer upserts resurrect.
if ts, dead := h.tombs[id]; dead {
if op.Time <= ts.hlc {
continue
}
delete(h.tombs, id)
}
if cur, exists := h.rows[id]; exists {
// Row-level last-write-wins with hijack guard: the existing
// row must be inside the caller's scope, and the owner is
// immutable. Note: unlike the reference driver, this flat
// handler models neither team-move departure tombstones nor
// parent/child cascades (see driver/drivertest capabilities).
if op.Time <= cur.hlc {
continue
}
if !scope.Allows(cur.meta.UserID, cur.meta.TeamID) {
continue
}
cur.meta.TeamID = op.Meta.TeamID
cur.hlc = op.Time
cur.seq = h.store.next()
cur.data = op.Data
continue
}
h.rows[id] = &row{
meta: op.Meta,
hlc: op.Time,
seq: h.store.next(),
data: op.Data,
}
}
return nil
}
// Delete implements the [diff.Handler] interface.
func (h *Handler) Delete(
_ context.Context,
_ *Tx,
scope diff.Scope,
ops []diff.Op,
) error {
h.Calls = append(h.Calls, "delete")
if h.ErrDelete != nil {
return h.ErrDelete
}
for _, op := range ops {
id := op.Meta.ID
meta := op.Meta
if cur, exists := h.rows[id]; exists {
// Stale deletes and out-of-scope rows are silently skipped.
if op.Time <= cur.hlc {
continue
}
if !scope.Allows(cur.meta.UserID, cur.meta.TeamID) {
continue
}
meta = cur.meta
delete(h.rows, id)
}
if ts, dead := h.tombs[id]; dead && op.Time <= ts.hlc {
continue
}
h.tombs[id] = &row{meta: meta, hlc: op.Time, seq: h.store.next()}
}
return nil
}
// Fetch implements the [diff.Handler] interface.
func (h *Handler) Fetch(
_ context.Context,
_ *Tx,
scope diff.Scope,
w diff.Window,
) ([]diff.Version, error) {
h.Calls = append(h.Calls, "fetch")
if h.OnFetch != nil {
h.OnFetch()
}
if h.ErrFetch != nil {
return nil, h.ErrFetch
}
visible := func(meta diff.Meta) bool {
if scope.Allows(meta.UserID, meta.TeamID) {
return true
}
return meta.TeamID == uuid.Nil() &&
h.store.granted(meta.UserID, scope.Teams)
}
var out []diff.Version
for id, r := range h.rows {
if r.seq > w.Since && r.seq < w.Until && visible(r.meta) {
out = append(out, diff.Version{
ID: id,
Seq: r.seq,
Time: r.hlc,
Data: r.data,
})
}
}
for id, ts := range h.tombs {
if ts.seq > w.Since && ts.seq < w.Until && visible(ts.meta) {
out = append(out, diff.Version{
ID: id,
Seq: ts.seq,
Time: ts.hlc,
Deleted: true,
})
}
}
slices.SortFunc(out, func(a, b diff.Version) int {
return cmp.Compare(a.Seq, b.Seq)
})
if w.Limit > 0 && len(out) > w.Limit {
out = out[:w.Limit]
}
return out, nil
}
// Read implements the [diff.Reader] interface with the same visibility
// rules as [Handler.Fetch]. Absent, deleted, and out-of-scope documents
// uniformly report ok == false.
func (h *Handler) Read(
_ context.Context,
_ *Tx,
scope diff.Scope,
id uuid.UUID,
) (diff.Version, bool, error) {
h.Calls = append(h.Calls, "read")
if h.ErrRead != nil {
return diff.Version{}, false, h.ErrRead
}
r, exists := h.rows[id]
if !exists {
return diff.Version{}, false, nil
}
visible := scope.Allows(r.meta.UserID, r.meta.TeamID) ||
(r.meta.TeamID == uuid.Nil() &&
h.store.granted(r.meta.UserID, scope.Teams))
if !visible {
return diff.Version{}, false, nil
}
return diff.Version{
ID: id,
Seq: r.seq,
Time: r.hlc,
Data: r.data,
}, true, nil
}
// Resolve implements the [diff.Handler] interface.
func (h *Handler) Resolve(
_ context.Context,
_ *Tx,
ids []uuid.UUID,
) (map[uuid.UUID]diff.Meta, error) {
out := make(map[uuid.UUID]diff.Meta)
for _, id := range ids {
if r, exists := h.rows[id]; exists {
out[id] = r.meta
}
}
return out, nil
}
// Rows returns a snapshot of all live rows keyed by document ID, exposing
// sequence and payload for state assertions in tests.
func (h *Handler) Rows() map[uuid.UUID]diff.Version {
out := make(map[uuid.UUID]diff.Version, len(h.rows))
for id, r := range h.rows {
out[id] = diff.Version{ID: id, Seq: r.seq, Data: r.data}
}
return out
}
// Tombstones returns a snapshot of all tombstoned document IDs.
func (h *Handler) Tombstones() []uuid.UUID {
out := make([]uuid.UUID, 0, len(h.tombs))
for id := range h.tombs {
out = append(out, id)
}
return out
}
var (
_ diff.Store[*Tx] = (*Store)(nil)
_ diff.Handler[*Tx] = (*Handler)(nil)
_ diff.Reader[*Tx] = (*Handler)(nil)
)
// Shares is the in-memory handler for the reserved share model. It applies
// last-write-wins like [Handler], writes grants through to
// [Store.Granted], and re-sequences the owner's personal documents when a
// grant lands. Construct instances with [NewShares].
type Shares struct {
*Handler
}
// NewShares initializes the shares handler backed by the given store.
func NewShares(s *Store) *Shares {
return &Shares{Handler: NewHandler(s)}
}
// Upsert implements the [diff.Handler] interface.
func (h *Shares) Upsert(
ctx context.Context,
tx *Tx,
scope diff.Scope,
ops []diff.Op,
) error {
before := len(h.rows)
changed := make(map[uuid.UUID]hlc.Time, len(ops))
for _, op := range ops {
if r, ok := h.rows[op.Meta.ID]; ok {
changed[op.Meta.ID] = r.hlc
}
}
if err := h.Handler.Upsert(ctx, tx, scope, ops); err != nil {
return err
}
landed := len(h.rows) > before
for id, prev := range changed {
if r, ok := h.rows[id]; ok && r.hlc != prev {
landed = true
}
}
h.sync()
// A landing grant re-feeds the owner's personal documents to the newly
// granted team members.
if landed {
return h.store.touch(scope.UserID)
}
return nil
}
// Delete implements the [diff.Handler] interface.
func (h *Shares) Delete(
ctx context.Context,
tx *Tx,
scope diff.Scope,
ops []diff.Op,
) error {
if err := h.Handler.Delete(ctx, tx, scope, ops); err != nil {
return err
}
h.sync()
return nil
}
// sync rebuilds the store's grant lookup from the live share rows.
func (h *Shares) sync() {
h.store.mu.Lock()
defer h.store.mu.Unlock()
clear(h.store.Granted)
for _, r := range h.rows {
if r.meta.TeamID != uuid.Nil() {
h.store.Granted[r.meta.UserID] = append(
h.store.Granted[r.meta.UserID], r.meta.TeamID,
)
}
}
}
var _ diff.Handler[*Tx] = (*Shares)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package postgres
import (
"fmt"
"io/fs"
"regexp"
"strconv"
"strings"
"github.com/deep-rent/nexus/eco/dse/schema"
)
// DDL renders the document table of one declared model: the CREATE TABLE
// and index statements for the up direction, and the DROP TABLE for the
// down direction. The shape is the engine's column contract as
// documented on [Table] — roots carry user_id/team_id, children the
// denormalized root identity plus the parent reference column named in
// the declaration — so the output is fully determined by the
// declaration: generating and hand-writing cannot disagree, which is the
// point. Deployment-specific extensions (expression indexes over the
// payload, generated columns) belong in later migration versions, not in
// edits to the generated shape.
func DDL(def schema.Definition) (up, down string) {
type column struct {
name string
spec string
}
var cols []column
userCol, teamCol := "user_id", "team_id"
if !def.Root {
userCol, teamCol = "root_user_id", "root_team_id"
}
cols = append(cols,
column{"id", "UUID PRIMARY KEY"},
column{userCol, "UUID NOT NULL"},
column{teamCol, "UUID"},
)
if !def.Root {
cols = append(cols, column{def.OwnerVia, "UUID NOT NULL"})
}
cols = append(cols,
column{"hlc", "BIGINT NOT NULL"},
column{"seq", "BIGINT NOT NULL"},
column{"data", "JSONB NOT NULL"},
)
width := 0
for _, c := range cols {
width = max(width, len(c.name))
}
var b strings.Builder
fmt.Fprintf(&b, "CREATE TABLE %s (\n", def.Table)
for i, c := range cols {
sep := ","
if i == len(cols)-1 {
sep = ""
}
fmt.Fprintf(&b, " %-*s %s%s\n", width, c.name, c.spec, sep)
}
b.WriteString(");\n")
// One index per visibility branch of the feed scan, exactly as the
// engine's fetch query expects them.
fmt.Fprintf(&b, "CREATE INDEX %s_user_seq ON %s (%s, seq);\n",
def.Table, def.Table, userCol)
fmt.Fprintf(&b, "CREATE INDEX %s_team_seq ON %s (%s, seq)\n"+
" WHERE %s IS NOT NULL;\n",
def.Table, def.Table, teamCol, teamCol)
fmt.Fprintf(&b, "CREATE INDEX %s_personal_seq ON %s (%s, seq)\n"+
" WHERE %s IS NULL;\n",
def.Table, def.Table, userCol, teamCol)
if !def.Root {
// The parent reference index backing the cascade walks.
base := strings.TrimSuffix(def.OwnerVia, "_id")
if base == "" {
base = def.OwnerVia
}
fmt.Fprintf(&b, "CREATE INDEX %s_%s ON %s (%s);\n",
def.Table, base, def.Table, def.OwnerVia)
}
return b.String(), fmt.Sprintf("DROP TABLE %s;\n", def.Table)
}
// upVersion matches the version prefix of an up migration file name.
var upVersion = regexp.MustCompile(`^(\d+)_.+\.up\.sql$`)
// LatestVersion returns the highest version of the embedded bookkeeping
// migration stream — the version a deployment's document migrations gate
// on via "-- requires: dse@N".
func LatestVersion() int64 {
entries, err := fs.ReadDir(Migrations(), ".")
if err != nil {
// The embedded stream is read at compile time; failing to list it
// is a build defect, not a runtime condition.
panic(err)
}
var latest int64
for _, e := range entries {
m := upVersion.FindStringSubmatch(e.Name())
if m == nil {
continue
}
v, err := strconv.ParseInt(m[1], 10, 64)
if err != nil {
continue
}
latest = max(latest, v)
}
return latest
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
// Explicitly allow SQL string concatenation:
// #nosec G202
package postgres
import (
"context"
"encoding/json/v2"
"errors"
"fmt"
"time"
"uuid"
"github.com/jackc/pgx/v5"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/eco/dse/blob"
"github.com/deep-rent/nexus/eco/dse/diff"
"github.com/deep-rent/nexus/eco/dse/hlc"
"github.com/deep-rent/nexus/std/ascii"
"github.com/deep-rent/nexus/std/quote"
)
// Default names of the file engine's tables.
const (
// DefaultFilesTable backs the reserved file document model.
DefaultFilesTable = "document_files"
// DefaultGrantsTable holds the pending upload grants.
DefaultGrantsTable = "document_file_grants"
// DefaultObjectsTable is the object ledger.
DefaultObjectsTable = "document_file_objects"
)
// serverFields are the payload fields the file handler owns: whatever a
// client sends for them is stripped on ingestion and the stored columns
// are re-merged into every payload served. The announced sha256 and size
// are deliberately NOT among them — they are client statements the blob
// engine holds the upload against, living in the payload like any other
// field (immutable once uploaded, enforced by [Files.Vet]).
const serverFields = "'uploaded' - 'content_type' - 'thumbnail' - 'corrupted'"
// Files is the driver side of the blob engine: the [diff.Handler] of the
// reserved file model over a single PostgreSQL table, plus the
// [blob.Store] persistence contract (pending grants and the object
// ledger).
//
// The handler behaves like any child [Table] — row-level last-write-wins,
// tombstones, scope guards, team move handling — with three deviations:
// the verification columns are SERVER-AUTHORITATIVE (client payloads
// cannot touch them), slot policies are enforced transactionally at
// ingestion through [diff.Vetter], and deletions orphan the documents'
// ledger entries so the sweep evicts their objects.
//
// Attach the handler to every anchor table with [Files.AttachTo], so
// anchor deletions and team moves cascade into the file rows. Cascaded
// deletions leave their ledger entries to the reconciliation pass
// ([blob.Manager.Reconcile]) rather than orphaning in-transaction; direct
// deletions orphan immediately.
type Files struct {
*Table
policies blob.Policies
grants string // precomputed, safely quoted identifier
objects string // precomputed, safely quoted identifier
// Precomputed SQL statements.
writableSQL string
readableSQL string
orphanSQL string
straysSQL string
}
// fileConfig holds the internal configuration options of [Store.Files].
type fileConfig struct {
schema string
table string
grants string
objects string
}
// FileOption configures the [Files] handler.
type FileOption func(*fileConfig)
// WithFilesSchema sets a custom database schema for the file engine's
// tables, overriding the store's default. Empty values are ignored.
func WithFilesSchema(name string) FileOption {
return func(c *fileConfig) {
if name != "" {
c.schema = name
}
}
}
// WithFilesTable sets a custom name for the file document table. Empty
// values are ignored.
func WithFilesTable(name string) FileOption {
return func(c *fileConfig) {
if name != "" {
c.table = name
}
}
}
// WithGrantsTable sets a custom name for the pending grants table. Empty
// values are ignored.
func WithGrantsTable(name string) FileOption {
return func(c *fileConfig) {
if name != "" {
c.grants = name
}
}
}
// WithObjectsTable sets a custom name for the object ledger table. Empty
// values are ignored.
func WithObjectsTable(name string) FileOption {
return func(c *fileConfig) {
if name != "" {
c.objects = name
}
}
}
// Files creates the handler and persistence seam of the reserved file
// model. The policies decide which attachment slots exist and how many
// files may occupy them; register the result under [blob.Model] with
// [diff.PolyOwner] and attach it to every anchor table.
//
// It panics on a nil policy resolver or a duplicate table registration
// (programmer error). Like [NewTable], it is not safe for concurrent
// use; register during startup.
func (s *Store) Files(policies blob.Policies, opts ...FileOption) *Files {
if policies == nil {
panic("policies are required")
}
cfg := &fileConfig{
schema: s.schema,
table: DefaultFilesTable,
grants: DefaultGrantsTable,
objects: DefaultObjectsTable,
}
for _, opt := range opts {
opt(cfg)
}
if _, exists := s.tables[cfg.table]; exists {
panic(fmt.Sprintf("table %q is already registered", cfg.table))
}
t := &Table{
store: s,
model: blob.Model,
name: cfg.table,
ident: quote.Ident(cfg.schema, cfg.table),
child: true,
ref: blob.FieldAnchorID,
}
t.userCol, t.teamCol = "root_user_id", "root_team_id"
// Serve the stored payload with the verification columns merged in;
// what a client sent for them never survived ingestion. The announced
// size serves straight from the payload — the size column is the
// verified accounting copy, zero until the upload confirms.
t.dataExpr = "(data || jsonb_build_object(" +
"'uploaded', uploaded, 'content_type', content_type," +
" 'thumbnail', thumb, 'corrupted', corrupted))"
t.buildSQL()
f := &Files{
Table: t,
policies: policies,
grants: quote.Ident(cfg.schema, cfg.grants),
objects: quote.Ident(cfg.schema, cfg.objects),
}
f.buildSQL()
// The generic child upsert does not know the anchor columns or the
// server-authoritative stripping, so the file table swaps in its own
// statement; Table.Upsert supplies the surrounding flow (snapshot,
// move tombstones) unchanged.
t.upsertSQL = f.upsertSQL()
// Registration keeps the table on the store's Touch path (personal
// file rows re-feed on share grants like any other document).
s.tables[cfg.table] = t
s.order = append(s.order, t)
return f
}
// AttachTo hangs the file table off the given anchor table for the
// cascade walks: deleting an anchor (or moving it between teams)
// cascades into the files anchored to it, exactly like a declared child
// table. Call it for every anchor model in the schema.
func (f *Files) AttachTo(anchor *Table) {
if anchor == nil {
panic("anchor table is required")
}
anchor.adopt(f.Table, " AND c.anchor_type = "+quote.Literal(anchor.model))
}
// buildSQL precomputes the file-specific statements.
func (f *Files) buildSQL() {
// The announced checksum and size come from the payload — the size
// COLUMN is the verified accounting copy and stays out of the row
// view, so the blob engine always judges against the announcement.
row := "SELECT id, " + f.userCol + "," +
" COALESCE(" + f.teamCol + ", " + zeroUUID + ")," +
" anchor_type, anchor_id, slot," +
" COALESCE(data ->> 'sha256', '')," +
" COALESCE((data ->> 'size')::bigint, 0)," +
" uploaded, content_type, corrupted, thumb, thumb_size" +
" FROM " + f.ident + " WHERE id = $1::uuid"
f.writableSQL = row +
" AND (" + f.userCol + " = $2::uuid" +
" OR " + f.teamCol + " = ANY($3::uuid[]))"
f.readableSQL = row +
" AND (" + f.userCol + " = $2::uuid" +
" OR " + f.teamCol + " = ANY($3::uuid[])" +
" OR (" + f.teamCol + " IS NULL AND " + f.userCol + " IN (" +
"SELECT user_id FROM " + f.store.shares +
" WHERE team_id = ANY($3::uuid[]))))"
// Orphaning is guarded on the file row being gone: a stale delete
// leaves the row (and its live object) untouched.
f.orphanSQL = "UPDATE " + f.objects + " o" +
" SET state = 'orphaned', expires_at = NULL" +
" WHERE o.file_id = ANY($1::uuid[])" +
" AND o.state <> 'orphaned'" +
" AND NOT EXISTS (" +
" SELECT 1 FROM " + f.ident + " d WHERE d.id = o.file_id)"
f.straysSQL = "SELECT o.key, o.file_id, o.variant, o.state" +
" FROM " + f.objects + " o" +
" WHERE o.state = 'live' AND NOT EXISTS (" +
" SELECT 1 FROM " + f.ident + " d WHERE d.id = o.file_id)" +
" LIMIT $1"
}
// upsertSQL renders the file table's upsert: the generic child statement
// with the anchor columns extracted from the payload, the
// server-authoritative fields stripped from the stored data, and the
// verification columns left untouched on conflict. Parameters match
// [Table.Upsert]: $1..$5 the operation arrays, $6 the model, $7/$8 the
// scope.
func (f *Files) upsertSQL() string {
s := f.store
tomb := s.tombstones
strip := "a.data - " + serverFields
return "WITH incoming AS (" +
" SELECT t.id, t.user_id," +
" nullif(t.team_id, " + zeroUUID + ") AS team_id," +
" t.hlc, t.data" +
" FROM unnest($1::uuid[], $2::uuid[], $3::uuid[], $4::bigint[]," +
" $5::jsonb[]) AS t(id, user_id, team_id, hlc, data)" +
"), alive AS (" +
" SELECT i.* FROM incoming i" +
" LEFT JOIN " + tomb + " ts ON ts.type = $6::text AND ts.id = i.id" +
" LEFT JOIN " + f.ident + " r ON r.id = i.id" +
" WHERE ts.id IS NULL OR (i.hlc > ts.hlc" +
" AND (r.id IS NOT NULL" +
" OR ts.user_id = $7::uuid OR ts.team_id = ANY($8::uuid[])))" +
"), cleared AS (" +
" DELETE FROM " + tomb + " ts USING alive a" +
" WHERE ts.type = $6::text AND ts.id = a.id" +
" AND (ts.user_id = $7::uuid OR ts.team_id = ANY($8::uuid[]))" +
" AND NOT EXISTS (" +
" SELECT 1 FROM " + f.ident + " r WHERE r.id = a.id" +
")" +
") INSERT INTO " + f.ident + " AS d" +
" (id, " + f.userCol + ", " + f.teamCol + "," +
" anchor_type, anchor_id, slot, hlc, seq, data)" +
" SELECT a.id, a.user_id, a.team_id," +
" a.data ->> 'anchor_type', (a.data ->> 'anchor_id')::uuid," +
" a.data ->> 'slot', a.hlc, " + s.nextval + ", " + strip +
" FROM alive a" +
" ON CONFLICT (id) DO UPDATE SET" +
" " + f.teamCol + " = EXCLUDED." + f.teamCol + "," +
" anchor_type = EXCLUDED.anchor_type," +
" anchor_id = EXCLUDED.anchor_id," +
" slot = EXCLUDED.slot," +
" hlc = EXCLUDED.hlc, seq = EXCLUDED.seq, data = EXCLUDED.data" +
" WHERE EXCLUDED.hlc > d.hlc" +
" AND (d." + f.userCol + " = $7::uuid" +
" OR d." + f.teamCol + " = ANY($8::uuid[]))" +
" AND d." + f.userCol + " = EXCLUDED." + f.userCol +
" RETURNING d.id, d." + f.userCol + "," +
" COALESCE(d." + f.teamCol + ", " + zeroUUID + "), d.hlc"
}
// Delete implements the [diff.Handler] interface: the generic removal
// plus in-transaction orphaning of the deleted documents' ledger
// entries, so the sweep evicts their objects without waiting for the
// reconciliation pass.
func (f *Files) Delete(
ctx context.Context,
tx pgx.Tx,
scope diff.Scope,
ops []diff.Op,
) error {
if err := f.Table.Delete(ctx, tx, scope, ops); err != nil {
return err
}
return f.orphan(ctx, tx, ops)
}
// Bury is the backend-write counterpart of [Files.Delete]; see
// [Table.Bury].
func (f *Files) Bury(
ctx context.Context,
tx pgx.Tx,
ops []diff.Op,
) error {
if err := f.Table.Bury(ctx, tx, ops); err != nil {
return err
}
return f.orphan(ctx, tx, ops)
}
// orphan marks the ledger entries of the given documents orphaned,
// where the document row is actually gone.
func (f *Files) orphan(
ctx context.Context,
tx pgx.Tx,
ops []diff.Op,
) error {
ids := make([]uuid.UUID, len(ops))
for i, op := range ops {
ids[i] = op.Meta.ID
}
if _, err := tx.Exec(ctx, f.orphanSQL, ids); err != nil {
return fmt.Errorf("failed to orphan ledger entries: %w", err)
}
return nil
}
// Vet implements the [diff.Vetter] interface: it rejects upserts naming
// a slot the anchor's model does not declare or announcing a size beyond
// the slot's cap, freezes the announced content of uploaded files, and
// enforces the slots' count limits transactionally — the engine holds
// the scope's exclusive locks, so neither the counts nor the frozen
// announcements can be raced.
func (f *Files) Vet(
ctx context.Context,
tx pgx.Tx,
_ diff.Scope,
ops []diff.Op,
) (map[uuid.UUID]diff.Cause, error) {
causes := make(map[uuid.UUID]diff.Cause)
type spot struct {
anchor uuid.UUID
slot string
}
type payload struct {
file blob.File
op diff.Op
maxSize int64
}
var parsed []payload
capped := make(map[spot]int) // slots with a count limit, by capacity
ids := make([]uuid.UUID, 0, len(ops))
anchors := make([]uuid.UUID, 0, len(ops))
for _, op := range ops {
var file blob.File
if err := json.Unmarshal(op.Data, &file); err != nil {
// The payload passed screening, so this cannot happen short
// of a programming error; reject rather than crash.
causes[op.Meta.ID] = diff.Cause{Code: diff.CodeInvalid}
continue
}
policy, known := f.policies.Slot(file.AnchorType, file.Slot)
if !known {
causes[op.Meta.ID] = diff.Cause{
Code: diff.CodeInvalid,
Fields: valid.Single("slot", fmt.Sprintf(
"model %q declares no slot %q",
file.AnchorType, file.Slot,
)),
}
continue
}
maxSize := policy.MaxSize
if maxSize <= 0 {
maxSize = blob.DefaultMaxSize
}
parsed = append(parsed, payload{file: file, op: op, maxSize: maxSize})
ids = append(ids, op.Meta.ID)
if policy.MaxCount > 0 {
capped[spot{anchor: file.AnchorID, slot: file.Slot}] = policy.MaxCount
anchors = append(anchors, file.AnchorID)
}
}
if len(parsed) == 0 {
return causes, nil
}
// The batch ids' current rows serve two judgments: the count caps
// need to know which seats they hold, and the announcement freeze
// needs their stored content statement.
type seat struct {
sp spot
hlc int64
uploaded bool
sha256 string
size int64
}
seats := "SELECT id, anchor_id, slot, hlc, uploaded," +
" COALESCE(data ->> 'sha256', '')," +
" COALESCE((data ->> 'size')::bigint, 0)" +
" FROM " + f.ident + " WHERE id = ANY($1::uuid[])"
rows, err := tx.Query(ctx, seats, ids)
if err != nil {
return nil, fmt.Errorf("failed to read slot residents: %w", err)
}
resident := make(map[uuid.UUID]seat)
for rows.Next() {
var id uuid.UUID
var st seat
if err := rows.Scan(
&id, &st.sp.anchor, &st.sp.slot, &st.hlc,
&st.uploaded, &st.sha256, &st.size,
); err != nil {
rows.Close()
return nil, fmt.Errorf("failed to scan slot resident: %w", err)
}
resident[id] = st
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to read slot residents: %w", err)
}
// Ops that lose row-level last-write-wins (a stale timestamp from
// another device) never apply, so they are neither judged nor
// re-seated — the row itself keeps whatever seat it has. For the ops
// that will apply, the announcement freezes with the object: once
// the original is uploaded, sha256 and size describe verified
// content and may not drift from it. An announcement still governing
// a FUTURE upload must fit the slot's size cap; a frozen one is
// deliberately exempt — the policy judges uploads to come, and
// re-judging verified content would make a later policy tightening
// reject every edit of an existing document forever.
stale := make(map[uuid.UUID]bool, len(parsed))
for _, p := range parsed {
r, resides := resident[p.op.Meta.ID]
if resides && int64(p.op.Time) <= r.hlc {
stale[p.op.Meta.ID] = true
continue
}
if resides && r.uploaded {
if p.file.Size != r.size ||
!ascii.EqualFold(p.file.SHA256, r.sha256) {
causes[p.op.Meta.ID] = diff.Cause{
Code: diff.CodeInvalid,
Fields: valid.Single("sha256",
"announced content is immutable once uploaded"),
}
}
continue
}
if p.file.Size > p.maxSize {
causes[p.op.Meta.ID] = diff.Cause{
Code: diff.CodeInvalid,
Fields: valid.Single("size", fmt.Sprintf(
"must not exceed %d bytes", p.maxSize,
)),
}
}
}
if len(capped) == 0 {
return causes, nil
}
// Count the slots' current occupants, excluding the batch's own ids —
// residents are seated separately below.
query := "SELECT anchor_id, slot, count(*) FROM " + f.ident +
" WHERE anchor_id = ANY($1::uuid[])" +
" AND NOT (id = ANY($2::uuid[]))" +
" GROUP BY anchor_id, slot"
rows, err = tx.Query(ctx, query, anchors, ids)
if err != nil {
return nil, fmt.Errorf("failed to count slot occupants: %w", err)
}
taken := make(map[spot]int)
for rows.Next() {
var sp spot
var n int
if err := rows.Scan(&sp.anchor, &sp.slot, &n); err != nil {
rows.Close()
return nil, fmt.Errorf("failed to scan slot count: %w", err)
}
taken[sp] = n
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to count slot occupants: %w", err)
}
// Seat the rows that will still be there after the batch applies:
// stale rows keep their seats exactly like non-batch occupants, and
// fresh updates staying in their slot keep theirs. Newcomers — fresh
// files and fresh moves into another slot — then fill the remaining
// room in operation order; whatever overflows is rejected. The whole
// request aborts on any rejection, so the order merely decides which
// mutations the client is told to drop.
for _, p := range parsed {
r, resides := resident[p.op.Meta.ID]
if stale[p.op.Meta.ID] {
if _, ok := capped[r.sp]; ok {
taken[r.sp]++
}
continue
}
sp := spot{anchor: p.file.AnchorID, slot: p.file.Slot}
if _, ok := capped[sp]; ok && resides && r.sp == sp {
taken[sp]++
}
}
for _, p := range parsed {
if stale[p.op.Meta.ID] {
continue // will not apply; nothing to judge
}
if _, rejected := causes[p.op.Meta.ID]; rejected {
continue // already refused; claims no room
}
sp := spot{anchor: p.file.AnchorID, slot: p.file.Slot}
limit, ok := capped[sp]
if !ok {
continue
}
if r, resides := resident[p.op.Meta.ID]; resides && r.sp == sp {
continue // seated above
}
if taken[sp] >= limit {
causes[p.op.Meta.ID] = diff.Cause{
Code: diff.CodeQuota,
Fields: valid.Single("slot", fmt.Sprintf(
"slot %q holds at most %d files", p.file.Slot, limit,
)),
}
continue
}
taken[sp]++
}
return causes, nil
}
// Exec implements the [blob.Store] interface; see [Store.Exec].
func (f *Files) Exec(
ctx context.Context,
fn func(ctx context.Context, tx pgx.Tx) error,
) error {
return f.store.Exec(ctx, fn)
}
// Mutate implements the [blob.Store] interface; see [Store.Mutate].
func (f *Files) Mutate(
ctx context.Context,
scope diff.Scope,
fn func(ctx context.Context, tx pgx.Tx) error,
) error {
return f.store.Mutate(ctx, scope, fn)
}
// Writable implements the [blob.Store] interface.
func (f *Files) Writable(
ctx context.Context,
tx pgx.Tx,
scope diff.Scope,
id uuid.UUID,
) (*blob.Row, error) {
return f.row(ctx, tx, f.writableSQL, id, scope)
}
// Readable implements the [blob.Store] interface.
func (f *Files) Readable(
ctx context.Context,
tx pgx.Tx,
scope diff.Scope,
id uuid.UUID,
) (*blob.Row, error) {
return f.row(ctx, tx, f.readableSQL, id, scope)
}
// row runs one of the visibility lookups.
func (*Files) row(
ctx context.Context,
tx pgx.Tx,
query string,
id uuid.UUID,
scope diff.Scope,
) (*blob.Row, error) {
var r blob.Row
err := tx.QueryRow(ctx, query, id, scope.UserID, scope.Teams).Scan(
&r.ID, &r.UserID, &r.TeamID,
&r.AnchorType, &r.AnchorID, &r.Slot, &r.SHA256, &r.Size,
&r.Uploaded, &r.ContentType, &r.Corrupted, &r.Thumb, &r.ThumbSize,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to read file row: %w", err)
}
return &r, nil
}
// Verify implements the [blob.Store] interface.
func (f *Files) Verify(
ctx context.Context,
tx pgx.Tx,
id uuid.UUID,
variant blob.Variant,
size int64,
contentType string,
at hlc.Time,
) (bool, error) {
var query string
var args []any
if variant == blob.Thumb {
query = "UPDATE " + f.ident + " SET thumb = TRUE, thumb_size = $2," +
" hlc = $3, seq = " + f.store.nextval +
" WHERE id = $1::uuid"
args = []any{id, size, int64(at)}
} else {
query = "UPDATE " + f.ident + " SET uploaded = TRUE, size = $2," +
" content_type = $3, corrupted = FALSE," +
" hlc = $4, seq = " + f.store.nextval +
" WHERE id = $1::uuid"
args = []any{id, size, contentType, int64(at)}
}
res, err := tx.Exec(ctx, query, args...)
if err != nil {
return false, fmt.Errorf("failed to verify file: %w", err)
}
return res.RowsAffected() > 0, nil
}
// Corrupt implements the [blob.Store] interface.
func (f *Files) Corrupt(
ctx context.Context,
tx pgx.Tx,
id uuid.UUID,
at hlc.Time,
) (bool, error) {
query := "UPDATE " + f.ident + " SET corrupted = TRUE," +
" hlc = $2, seq = " + f.store.nextval +
" WHERE id = $1::uuid"
res, err := tx.Exec(ctx, query, id, int64(at))
if err != nil {
return false, fmt.Errorf("failed to mark file corrupted: %w", err)
}
return res.RowsAffected() > 0, nil
}
// Usage implements the [blob.Store] interface.
func (f *Files) Usage(
ctx context.Context,
tx pgx.Tx,
owner blob.Owner,
) (int64, error) {
var query string
if owner.Kind == blob.KindTeam {
query = "SELECT COALESCE(SUM(size + thumb_size), 0) FROM " +
f.ident + " WHERE " + f.teamCol + " = $1::uuid"
} else {
query = "SELECT COALESCE(SUM(size + thumb_size), 0) FROM " +
f.ident + " WHERE " + f.userCol + " = $1::uuid" +
" AND " + f.teamCol + " IS NULL"
}
var sum int64
if err := tx.QueryRow(ctx, query, owner.ID).Scan(&sum); err != nil {
return 0, fmt.Errorf("failed to sum storage usage: %w", err)
}
return sum, nil
}
// Usages implements the [blob.Store] interface: one grouped query sums
// the verified bytes of every requested owner at once.
func (f *Files) Usages(
ctx context.Context,
tx pgx.Tx,
owners []blob.Owner,
) (map[blob.Owner]int64, error) {
var users, teams []uuid.UUID
for _, owner := range owners {
if owner.Kind == blob.KindTeam {
teams = append(teams, owner.ID)
} else {
users = append(users, owner.ID)
}
}
// Grouping by both identity columns keeps personal buckets (team
// NULL, scanned as the zero UUID) apart from team buckets.
query := "SELECT " + f.userCol + ", " + f.teamCol +
", COALESCE(SUM(size + thumb_size), 0) FROM " + f.ident +
" WHERE (" + f.userCol + " = ANY($1::uuid[])" +
" AND " + f.teamCol + " IS NULL)" +
" OR " + f.teamCol + " = ANY($2::uuid[])" +
" GROUP BY " + f.userCol + ", " + f.teamCol
rows, err := tx.Query(ctx, query, users, teams)
if err != nil {
return nil, fmt.Errorf("failed to sum storage usage: %w", err)
}
defer rows.Close()
out := make(map[blob.Owner]int64, len(owners))
for rows.Next() {
var (
user, team uuid.UUID
sum int64
)
if err := rows.Scan(&user, &team, &sum); err != nil {
return nil, fmt.Errorf("failed to sum storage usage: %w", err)
}
owner := blob.Owner{Kind: blob.KindUser, ID: user}
if team != uuid.Nil() {
owner = blob.Owner{Kind: blob.KindTeam, ID: team}
}
out[owner] += sum
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to sum storage usage: %w", err)
}
return out, nil
}
// Grant implements the [blob.Store] interface.
func (f *Files) Grant(
ctx context.Context,
tx pgx.Tx,
p blob.Pending,
) (string, error) {
// The old CTE reads the statement-start snapshot, so it captures the
// key the upsert displaces.
query := "WITH old AS (" +
" SELECT key FROM " + f.grants +
" WHERE file_id = $1::uuid AND variant = $2::text" +
"), up AS (" +
" INSERT INTO " + f.grants + " (file_id, variant, key, expires_at)" +
" VALUES ($1::uuid, $2::text, $3::text, $4::timestamptz)" +
" ON CONFLICT (file_id, variant) DO UPDATE SET" +
" key = EXCLUDED.key, expires_at = EXCLUDED.expires_at" +
") SELECT COALESCE((SELECT key FROM old), '')"
var displaced string
err := tx.QueryRow(ctx, query,
p.FileID, string(p.Variant), p.Key, p.ExpiresAt,
).Scan(&displaced)
if err != nil {
return "", fmt.Errorf("failed to record upload grant: %w", err)
}
return displaced, nil
}
// Claim implements the [blob.Store] interface. Expired grants are left
// untouched: they belong to the sweep, and honoring one would race the
// eviction of its object.
func (f *Files) Claim(
ctx context.Context,
tx pgx.Tx,
id uuid.UUID,
variant blob.Variant,
now time.Time,
) (*blob.Pending, error) {
query := "DELETE FROM " + f.grants +
" WHERE file_id = $1::uuid AND variant = $2::text" +
" AND expires_at > $3::timestamptz" +
" RETURNING key, expires_at"
p := blob.Pending{FileID: id, Variant: variant}
err := tx.QueryRow(ctx, query, id, string(variant), now).Scan(
&p.Key, &p.ExpiresAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to claim upload grant: %w", err)
}
return &p, nil
}
// PruneGrants implements the [blob.Store] interface.
func (f *Files) PruneGrants(
ctx context.Context,
tx pgx.Tx,
now time.Time,
) (int64, error) {
query := "DELETE FROM " + f.grants + " WHERE expires_at <= $1::timestamptz"
res, err := tx.Exec(ctx, query, now)
if err != nil {
return 0, fmt.Errorf("failed to prune upload grants: %w", err)
}
return res.RowsAffected(), nil
}
// Record implements the [blob.Store] interface.
func (f *Files) Record(ctx context.Context, tx pgx.Tx, e blob.Entry) error {
query := "INSERT INTO " + f.objects +
" (key, file_id, variant, state, expires_at)" +
" VALUES ($1::text, $2::uuid, $3::text, $4::text, $5::timestamptz)"
var expires *time.Time
if !e.ExpiresAt.IsZero() {
expires = &e.ExpiresAt
}
if _, err := tx.Exec(ctx, query,
e.Key, e.FileID, string(e.Variant), string(e.State), expires,
); err != nil {
return fmt.Errorf("failed to record ledger entry: %w", err)
}
return nil
}
// Mark implements the [blob.Store] interface.
func (f *Files) Mark(
ctx context.Context,
tx pgx.Tx,
keys []string,
s blob.State,
) (int64, error) {
query := "UPDATE " + f.objects + " SET state = $2::text," +
" expires_at = CASE WHEN $2::text = 'pending'" +
" THEN expires_at ELSE NULL END" +
" WHERE key = ANY($1::text[])"
res, err := tx.Exec(ctx, query, keys, string(s))
if err != nil {
return 0, fmt.Errorf("failed to mark ledger entries: %w", err)
}
return res.RowsAffected(), nil
}
// Purge implements the [blob.Store] interface.
func (f *Files) Purge(ctx context.Context, tx pgx.Tx, keys []string) error {
query := "DELETE FROM " + f.objects + " WHERE key = ANY($1::text[])"
if _, err := tx.Exec(ctx, query, keys); err != nil {
return fmt.Errorf("failed to purge ledger entries: %w", err)
}
return nil
}
// Key implements the [blob.Store] interface.
func (f *Files) Key(
ctx context.Context,
tx pgx.Tx,
id uuid.UUID,
variant blob.Variant,
) (string, bool, error) {
query := "SELECT key FROM " + f.objects +
" WHERE file_id = $1::uuid AND variant = $2::text" +
" AND state = 'live' LIMIT 1"
var key string
err := tx.QueryRow(ctx, query, id, string(variant)).Scan(&key)
if errors.Is(err, pgx.ErrNoRows) {
return "", false, nil
}
if err != nil {
return "", false, fmt.Errorf("failed to locate live object: %w", err)
}
return key, true, nil
}
// Doomed implements the [blob.Store] interface.
func (f *Files) Doomed(
ctx context.Context,
tx pgx.Tx,
now time.Time,
limit int,
) ([]blob.Entry, error) {
query := "SELECT key, file_id, variant, state FROM " + f.objects +
" WHERE (state = 'pending' AND expires_at <= $1::timestamptz)" +
" OR state = 'orphaned' LIMIT $2"
rows, err := tx.Query(ctx, query, now, limit)
if err != nil {
return nil, fmt.Errorf("failed to list doomed objects: %w", err)
}
return entries(rows)
}
// Strays implements the [blob.Store] interface.
func (f *Files) Strays(
ctx context.Context,
tx pgx.Tx,
limit int,
) ([]blob.Entry, error) {
rows, err := tx.Query(ctx, f.straysSQL, limit)
if err != nil {
return nil, fmt.Errorf("failed to list stray objects: %w", err)
}
return entries(rows)
}
// entries scans ledger rows of the shape (key, file_id, variant, state).
func entries(rows pgx.Rows) ([]blob.Entry, error) {
defer rows.Close()
var out []blob.Entry
for rows.Next() {
var e blob.Entry
var variant, state string
if err := rows.Scan(&e.Key, &e.FileID, &variant, &state); err != nil {
return nil, fmt.Errorf("failed to scan ledger entry: %w", err)
}
e.Variant = blob.Variant(variant)
e.State = blob.State(state)
out = append(out, e)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to read ledger entries: %w", err)
}
return out, nil
}
var (
_ diff.Handler[pgx.Tx] = (*Files)(nil)
_ diff.Reader[pgx.Tx] = (*Files)(nil)
_ diff.Vetter[pgx.Tx] = (*Files)(nil)
_ diff.Describer = (*Files)(nil)
_ blob.Store[pgx.Tx] = (*Files)(nil)
)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package postgres
import (
"time"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/metrics"
)
// Default names for the store's bookkeeping objects.
const (
// DefaultSchema is the default PostgreSQL schema.
DefaultSchema = "public"
// DefaultMutationsTable is the default name of the mutation
// deduplication table.
DefaultMutationsTable = "document_mutations"
// DefaultTombstonesTable is the default name of the tombstone table.
DefaultTombstonesTable = "document_tombstones"
// DefaultStateTable is the default name of the state table holding the
// retention floor.
DefaultStateTable = "document_state"
// DefaultSharesTable is the default name of the share grants table.
DefaultSharesTable = "document_shares"
// DefaultSequence is the default name of the global feed sequence.
DefaultSequence = "document_seq"
)
// Default retention windows enforced by [Retention].
const (
// DefaultMutationRetention is the default age above which mutation
// deduplication records are pruned.
DefaultMutationRetention = 30 * 24 * time.Hour
// DefaultTombstoneRetention is the default age above which tombstones
// are pruned, advancing the retention floor.
DefaultTombstoneRetention = 90 * 24 * time.Hour
)
// config holds the internal configuration options for the PostgreSQL store.
type config struct {
// schema is the PostgreSQL schema containing the bookkeeping objects.
schema string
// mutations is the name of the mutation deduplication table.
mutations string
// tombstones is the name of the tombstone table.
tombstones string
// state is the name of the state table.
state string
// shares is the name of the share grants table.
shares string
// sequence is the name of the global feed sequence.
sequence string
// logger is the structured logger for store activity.
logger *log.Logger
}
// Option configures a PostgreSQL [Store] instance.
type Option func(*config)
// WithSchema sets a custom database schema for the bookkeeping objects.
//
// The reference SQL files under migrations/ only cover the default names;
// adjust the application's schema migrations accordingly.
//
// Empty string values are ignored.
func WithSchema(name string) Option {
return func(c *config) {
if name != "" {
c.schema = name
}
}
}
// WithMutationsTable sets a custom name for the mutation deduplication
// table.
//
// The reference SQL files under migrations/ only cover the default names;
// adjust the application's schema migrations accordingly.
//
// Empty string values are ignored.
func WithMutationsTable(name string) Option {
return func(c *config) {
if name != "" {
c.mutations = name
}
}
}
// WithTombstonesTable sets a custom name for the tombstone table.
//
// The reference SQL files under migrations/ only cover the default names;
// adjust the application's schema migrations accordingly.
//
// Empty string values are ignored.
func WithTombstonesTable(name string) Option {
return func(c *config) {
if name != "" {
c.tombstones = name
}
}
}
// WithStateTable sets a custom name for the state table.
//
// The reference SQL files under migrations/ only cover the default names;
// adjust the application's schema migrations accordingly.
//
// Empty string values are ignored.
func WithStateTable(name string) Option {
return func(c *config) {
if name != "" {
c.state = name
}
}
}
// WithSharesTable sets a custom name for the share grants table.
//
// The reference SQL files under migrations/ only cover the default names;
// adjust the application's schema migrations accordingly.
//
// Empty string values are ignored.
func WithSharesTable(name string) Option {
return func(c *config) {
if name != "" {
c.shares = name
}
}
}
// WithSequence sets a custom name for the global feed sequence.
//
// The reference SQL files under migrations/ only cover the default names;
// adjust the application's schema migrations accordingly.
//
// Empty string values are ignored.
func WithSequence(name string) Option {
return func(c *config) {
if name != "" {
c.sequence = name
}
}
}
// WithLogger injects a structured logger to record store operations.
//
// Nil values are ignored; without a logger, logging is disabled.
func WithLogger(logger *log.Logger) Option {
return func(c *config) {
if logger != nil {
c.logger = logger
}
}
}
// tableConfig holds the internal configuration options of a [Table].
type tableConfig struct {
// schema overrides the store's default schema for this table.
schema string
// parent is the table name of the ownership parent (child tables only).
parent string
// ref is the column and JSON field referencing the parent row.
ref string
}
// TableOption configures a single [Table] registration.
type TableOption func(*tableConfig)
// WithTableSchema sets a custom database schema for this table, overriding
// the store's default.
//
// Empty string values are ignored.
func WithTableSchema(name string) TableOption {
return func(c *tableConfig) {
if name != "" {
c.schema = name
}
}
}
// WithParent marks the table as a child of the given parent table (by its
// table name, which must already be registered with the store). The second
// argument names both the child column and the JSON payload field that
// reference the parent row's id; by convention they must be equal.
//
// Empty string values are ignored.
func WithParent(parent, ref string) TableOption {
return func(c *tableConfig) {
if parent != "" && ref != "" {
c.parent = parent
c.ref = ref
}
}
}
// RetentionOption configures a [Retention] task.
type RetentionOption func(*Retention)
// WithMutationRetention overrides [DefaultMutationRetention]. The window
// bounds idempotent deduplication: a client replaying a change set older
// than the window re-applies it. This is harmless to state (last-write-wins
// skips equal timestamps on upserts and deletes alike), so the window
// merely needs to exceed the longest realistic retry horizon.
//
// Non-positive values are ignored.
func WithMutationRetention(d time.Duration) RetentionOption {
return func(r *Retention) {
if d > 0 {
r.mutations = d
}
}
}
// WithRetentionRegistry sets the registry receiving the retention
// counters and the floor gauge. It defaults to
// [metrics.DefaultRegistry]; a nil value is ignored.
func WithRetentionRegistry(reg *metrics.Registry) RetentionOption {
return func(r *Retention) {
if reg != nil {
r.reg = reg
}
}
}
// WithTombstoneRetention overrides [DefaultTombstoneRetention]. The window
// bounds how long a device may stay offline without losing its cursor:
// pruning advances the retention floor, and clients whose cursor predates
// it are forced into a full resync from zero.
//
// Non-positive values are ignored.
func WithTombstoneRetention(d time.Duration) RetentionOption {
return func(r *Retention) {
if d > 0 {
r.tombstones = d
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
// Explicitly allow SQL string concatenation:
// #nosec G202
package postgres
import (
"context"
"crypto/sha256"
"database/sql"
"embed"
"encoding/binary"
"encoding/json/jsontext"
"errors"
"fmt"
"io/fs"
"slices"
"strconv"
"strings"
"time"
"uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/deep-rent/nexus/dat/migrate"
driver "github.com/deep-rent/nexus/dat/migrate/driver/postgres"
source "github.com/deep-rent/nexus/dat/migrate/source/file"
"github.com/deep-rent/nexus/eco/dse/diff"
"github.com/deep-rent/nexus/eco/dse/hlc"
"github.com/deep-rent/nexus/std/quote"
"github.com/deep-rent/nexus/sys/log"
)
//go:embed migrations
var migrations embed.FS
// Module is the migration stream this driver's bookkeeping schema lives
// in. Application-owned document table migrations gate on it via
// "-- requires: dse@N".
const Module = "dse"
// Migrations exposes the embedded schema migrations for [migrate] paired
// with its PostgreSQL driver. They cover the default object names in the
// connection's default schema; deployments renaming tables through the
// store options own the corresponding migrations themselves.
func Migrations() fs.FS {
sub, err := fs.Sub(migrations, "migrations")
if err != nil {
// The subdirectory is embedded at compile time; failing to open it
// is a build defect, not a runtime condition.
panic(err)
}
return sub
}
// Migrator wires a [migrate.Migrator] for this schema over an existing
// database handle — typically the [database/sql] view of the service's
// pool via [stdlib.OpenDBFromPool]. The module, the embedded source, and
// the driver are this schema's to declare; opts carry what the caller
// legitimately varies, such as a logger.
//
// [stdlib.OpenDBFromPool]: github.com/jackc/pgx/v5/stdlib#OpenDBFromPool
func Migrator(db *sql.DB, opts ...migrate.Option) *migrate.Migrator {
return migrate.New(append([]migrate.Option{
migrate.WithModule(Module),
migrate.WithSource(source.New(Migrations())),
migrate.WithDriver(driver.New(db)),
}, opts...)...)
}
// Open connects a migrator for this schema to the database at url, for
// the commands that only run migrations and have no use for a native
// pool. As with [Migrator], the schema names its own module and source;
// opts carry the rest. The returned close function releases the
// underlying handle, which carries none of the type registrations the
// stores rely on — harmless for the migrator, because DDL names no UUID
// of its own.
func Open(
url string,
opts ...migrate.Option,
) (*migrate.Migrator, func() error, error) {
return driver.Open(url, append([]migrate.Option{
migrate.WithModule(Module),
migrate.WithSource(source.New(Migrations())),
}, opts...)...)
}
// zeroUUID is the SQL literal of the zero UUID, the sentinel for personal
// documents: NULL team columns read as the zero UUID and vice versa.
const zeroUUID = "'00000000-0000-0000-0000-000000000000'::uuid"
// Store implements the [diff.Store] interface for PostgreSQL.
//
// It provides the shared transactional machinery of the sync engine and
// acts as the registration hub for the [Table] handlers of the individual
// models. Table registration via [NewTable] is not safe for concurrent
// use; register all tables during startup, before serving.
//
// The bookkeeping schema must be provisioned before the store is used;
// [Migrator] applies the embedded migration stream.
type Store struct {
pool *pgxpool.Pool // underlying database connection pool
schema string // unquoted default schema
mutations string // precomputed, safely quoted identifier
tombstones string // precomputed, safely quoted identifier
state string // precomputed, safely quoted identifier
shares string // precomputed, safely quoted identifier
sequence string // precomputed, safely quoted identifier
nextval string // nextval() expression of the feed sequence
logger *log.Logger // records store operations
tables map[string]*Table // indexes all registered tables by their name
order []*Table // lists tables in registration order
// Note: Parent tables always precede their children.
}
// New creates a new PostgreSQL sync store around the given connection pool
// and options. Build the pool through [pg.Connect] so the type
// registrations the statements rely on are installed on every connection.
// It panics if the given pool is nil (programmer error).
//
// [pg.Connect]: github.com/deep-rent/nexus/dat/pg#Connect
func New(pool *pgxpool.Pool, opts ...Option) *Store {
if pool == nil {
panic("pool is required")
}
cfg := &config{
schema: DefaultSchema,
mutations: DefaultMutationsTable,
tombstones: DefaultTombstonesTable,
state: DefaultStateTable,
shares: DefaultSharesTable,
sequence: DefaultSequence,
logger: log.Discard(),
}
for _, opt := range opts {
opt(cfg)
}
s := &Store{
pool: pool,
schema: cfg.schema,
mutations: quote.Ident(cfg.schema, cfg.mutations),
tombstones: quote.Ident(cfg.schema, cfg.tombstones),
state: quote.Ident(cfg.schema, cfg.state),
shares: quote.Ident(cfg.schema, cfg.shares),
sequence: quote.Ident(cfg.schema, cfg.sequence),
logger: cfg.logger,
tables: make(map[string]*Table),
}
s.nextval = "nextval(" + quote.Literal(s.sequence) + ")"
return s
}
// Exec implements the [diff.Store] interface. It runs the given callback
// within a single read-committed transaction, committing on nil and rolling
// back on error.
func (s *Store) Exec(
ctx context.Context,
fn func(ctx context.Context, tx pgx.Tx) error,
) error {
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{
IsoLevel: pgx.ReadCommitted,
})
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer func() {
e := tx.Rollback(ctx)
if e != nil && !errors.Is(e, pgx.ErrTxClosed) {
s.logger.Error(ctx,
"Failed to rollback transaction",
log.Error(e),
)
}
}()
if err := fn(ctx, tx); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}
// Lock implements the [diff.Store] interface. It acquires transaction-
// scoped advisory locks on the given keys: shared locks for keys the
// request only reads, exclusive locks for keys it writes. A key listed in
// both sets is locked exclusively. All keys are deduplicated and acquired
// in one global ascending order, regardless of mode, so concurrent callers
// never deadlock.
func (*Store) Lock(
ctx context.Context,
tx pgx.Tx,
shared, exclusive []uuid.UUID,
) error {
// Fold both sets into one mode map; exclusive wins on overlap.
modes := make(map[int64]bool, len(shared)+len(exclusive))
for _, key := range shared {
k := lockKey(key)
if _, exists := modes[k]; !exists {
modes[k] = false
}
}
for _, key := range exclusive {
modes[lockKey(key)] = true
}
if len(modes) == 0 {
return nil
}
keys := make([]int64, 0, len(modes))
for k := range modes {
keys = append(keys, k)
}
slices.Sort(keys)
excl := make([]bool, len(keys))
for i, k := range keys {
excl[i] = modes[k]
}
// Single round trip. The ORDER BY inside the subquery both sorts the
// keys server-side and, because a sort clause blocks subquery pull-up,
// forces the planner to keep the subquery as a separate scan node: the
// volatile lock functions in the outer target list are then evaluated
// row by row in ascending key order. This is the shape the PostgreSQL
// documentation prescribes for set-oriented advisory locking. CASE
// evaluates only the selected branch, so each key is locked in exactly
// one mode.
query := "SELECT CASE WHEN k.excl" +
" THEN pg_advisory_xact_lock(k.key) IS NOT NULL" +
" ELSE pg_advisory_xact_lock_shared(k.key) IS NOT NULL END" +
" FROM (SELECT t.key, t.excl" +
" FROM unnest($1::bigint[], $2::boolean[]) AS t(key, excl)" +
" ORDER BY t.key) k"
if _, err := tx.Exec(ctx, query, keys, excl); err != nil {
return fmt.Errorf("failed to acquire advisory locks: %w", err)
}
return nil
}
// lockKey derives a positive 64-bit advisory lock key from an opaque scope
// identifier.
func lockKey(key uuid.UUID) int64 {
h := sha256.New()
h.Write([]byte("diff:scope:"))
h.Write(key[:])
sum := h.Sum(nil)
return int64(binary.BigEndian.Uint64(sum[:8]) & 0x7FFFFFFFFFFFFFFF)
}
// Floor implements the [diff.Store] interface. It returns the retention
// floor, or 0 if the state row is missing.
func (s *Store) Floor(ctx context.Context, tx pgx.Tx) (int64, error) {
query := "SELECT seq FROM " + s.state + " WHERE key = 'floor'"
var seq int64
err := tx.QueryRow(ctx, query).Scan(&seq)
if errors.Is(err, pgx.ErrNoRows) {
return 0, nil
}
if err != nil {
return 0, fmt.Errorf("failed to read retention floor: %w", err)
}
return seq, nil
}
// Barrier implements the [diff.Store] interface. It consumes and returns
// the next feed sequence value.
func (s *Store) Barrier(ctx context.Context, tx pgx.Tx) (int64, error) {
var seq int64
err := tx.QueryRow(ctx, "SELECT "+s.nextval).Scan(&seq)
if err != nil {
return 0, fmt.Errorf("failed to advance sequence: %w", err)
}
return seq, nil
}
// Watermark implements the [diff.Store] interface. It returns the highest
// sequence value assigned so far, or 0 if the sequence was never advanced.
func (s *Store) Watermark(ctx context.Context, tx pgx.Tx) (int64, error) {
query := "SELECT CASE WHEN is_called THEN last_value" +
" ELSE last_value - 1 END FROM " + s.sequence
var seq int64
if err := tx.QueryRow(ctx, query).Scan(&seq); err != nil {
return 0, fmt.Errorf("failed to read sequence watermark: %w", err)
}
return seq, nil
}
// Claim implements the [diff.Store] interface. It records the given
// mutation IDs and returns the subset that was not seen before.
func (s *Store) Claim(
ctx context.Context,
tx pgx.Tx,
userID uuid.UUID,
ids []uuid.UUID,
) ([]uuid.UUID, error) {
if len(ids) == 0 {
return nil, nil
}
query := "INSERT INTO " + s.mutations + " (id, user_id)" +
" SELECT unnest($1::uuid[]), $2::uuid" +
" ON CONFLICT (id) DO NOTHING RETURNING id"
rows, err := tx.Query(ctx, query, ids, userID)
if err != nil {
return nil, fmt.Errorf("failed to claim mutations: %w", err)
}
defer rows.Close()
var claimed []uuid.UUID
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("failed to scan claimed mutation: %w", err)
}
claimed = append(claimed, id)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to read claimed mutations: %w", err)
}
return claimed, nil
}
// Grants implements the [diff.Store] interface. It returns, for each of
// the given owners, the identifiers of the teams currently granted access
// to their personal documents. Owners without live grants are omitted.
func (s *Store) Grants(
ctx context.Context,
tx pgx.Tx,
owners []uuid.UUID,
) (map[uuid.UUID][]uuid.UUID, error) {
out := make(map[uuid.UUID][]uuid.UUID, len(owners))
if len(owners) == 0 {
return out, nil
}
query := "SELECT user_id, team_id FROM " + s.shares +
" WHERE user_id = ANY($1::uuid[])"
rows, err := tx.Query(ctx, query, owners)
if err != nil {
return nil, fmt.Errorf("failed to read grants: %w", err)
}
defer rows.Close()
for rows.Next() {
var owner, team uuid.UUID
if err := rows.Scan(&owner, &team); err != nil {
return nil, fmt.Errorf("failed to scan grant: %w", err)
}
out[owner] = append(out[owner], team)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to read grants: %w", err)
}
return out, nil
}
// Touch re-sequences all personal documents (and their descendants) of the
// given owner across every registered table so they re-enter the patch
// feed. The built-in [Store.Shares] handler invokes it whenever a grant
// lands; backend flows may call it to re-feed an owner's personal documents
// to newly granted teams. Callers must hold the owner's scope locks (see
// [Store.Mutate]).
func (s *Store) Touch(
ctx context.Context,
tx pgx.Tx,
ownerID uuid.UUID,
) error {
for _, t := range s.order {
query := "UPDATE " + t.ident + " SET seq = " + s.nextval +
" WHERE " + t.userCol + " = $1::uuid" +
" AND " + t.teamCol + " IS NULL"
if _, err := tx.Exec(ctx, query, ownerID); err != nil {
return fmt.Errorf("failed to touch table %q: %w", t.name, err)
}
}
return nil
}
// Mutate runs the given callback within a single transaction after
// acquiring the exclusive advisory locks of the given scope (its user and
// all its teams).
//
// IMPORTANT: Every backend-initiated write to synced tables MUST go through
// Mutate (or acquire the equivalent locks via [Store.Lock]). Writing to a
// synced table without holding the scope locks races against concurrent
// sync transactions and can assign sequence values that a client's feed
// scan silently skips, permanently desynchronizing that client.
//
// A compliant backend write stamps the rows with a fresh engine timestamp
// and re-enters them into the patch feed via [Table.Reseq]; backend
// deletions go through [Table.Bury]:
//
// now := engine.Now()
// err := store.Mutate(ctx, scope, func(ctx context.Context, tx pgx.Tx) error
//
// {
// if _, err := tx.Exec(ctx,
// "UPDATE assets SET data = $2, hlc = $3 WHERE id = $1",
// id, data, int64(now),
// ); err != nil {
// return err
// }
// return assets.Reseq(ctx, tx, []uuid.UUID{id})
// })
//
// Flows inserting rows directly may instead allocate sequence values
// themselves: [Store.Barrier] doubles as a seq allocator, returning one
// fresh feed sequence value per call.
func (s *Store) Mutate(
ctx context.Context,
scope diff.Scope,
fn func(ctx context.Context, tx pgx.Tx) error,
) error {
return s.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
keys := make([]uuid.UUID, 0, len(scope.Teams)+1)
keys = append(keys, scope.UserID)
keys = append(keys, scope.Teams...)
// Personal documents of the acting user may be visible to teams
// through live grants; those teams' members read under a shared
// lock on the team key, so a backend write must hold it exclusively
// too. This mirrors the engine's fence (see Engine.assemble).
grants, err := s.Grants(ctx, tx, []uuid.UUID{scope.UserID})
if err != nil {
return err
}
keys = append(keys, grants[scope.UserID]...)
if err := s.Lock(ctx, tx, nil, keys); err != nil {
return err
}
return fn(ctx, tx)
})
}
// errDrift signals that the grant set changed between its snapshot and the
// lock acquisition and the offboarding transaction must be retried.
var errDrift = errors.New("grant set drifted")
// OffboardTeam buries every share that grants the given team access to
// owners' personal documents. Each grant is removed and tombstoned so the
// team's members receive a share deletion on their next sync and their
// clients purge the shared documents (see the client contract). It returns
// the number of grants buried.
//
// OffboardTeam is safe against concurrent grant writes: it locks the team
// key and every granting owner in one batch, re-verifies the grant set
// under the locks, and retries once when a grant landed in between. If the
// set drifts again, it returns an error wrapping [diff.ErrConflict]; the
// condition is transient, and the call can simply be repeated.
//
// Call it before deleting a team row: document_shares references teams with
// ON DELETE RESTRICT precisely so a team cannot be dropped while grants —
// and the deletions its members are owed — still reference it. Stamp the
// tombstones with a fresh timestamp from the engine clock ([Engine.Now]).
//
// Team removal is a lifecycle, not an instant: after offboarding, the
// grant tombstones still reference the team so its members can fetch the
// deletions, and they age out through [Store.PruneTombstones]. The team
// row itself lives with the identity service — this schema carries no
// foreign keys onto it — so nothing here blocks its removal; run the
// offboarding first so the deletions reach the feed.
//
// It does not touch documents assigned directly to the team (team_id
// equal to teamID); bury those with [Store.PurgeTeam].
func (s *Store) OffboardTeam(
ctx context.Context,
teamID uuid.UUID,
at hlc.Time,
) (int64, error) {
var buried int64
// Owner keys accumulate across attempts, mirroring the engine's
// resolve/lock/verify pattern: all advisory locks must be taken in one
// sorted batch (incremental acquisition would break the global lock
// order and risk deadlock), so a grant landing between the snapshot and
// the lock forces a retry with the union of both lock sets.
owners := make(map[uuid.UUID]struct{})
for attempt := 0; ; attempt++ {
err := s.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
// Seed the lock set from an unlocked snapshot on the first
// attempt; later attempts already carry the drifted set.
if attempt == 0 {
seed, err := s.grantOwners(ctx, tx, teamID)
if err != nil {
return err
}
for _, owner := range seed {
owners[owner] = struct{}{}
}
}
// Every owner that granted this team access appears in a
// tombstone and has their grant visibility reset, so the lock
// set is the team key (whose members read the tombstones under
// a shared lock) plus each granting owner (whose own feed
// serves the share row).
keys := make([]uuid.UUID, 0, len(owners)+1)
keys = append(keys, teamID)
for owner := range owners {
keys = append(keys, owner)
}
if err := s.Lock(ctx, tx, nil, keys); err != nil {
return err
}
// Re-verify under the locks: holding the team key exclusively
// blocks any further grant for this team (share writes fence on
// the team key), so a snapshot that matches the held set is
// final. Any owner that slipped in between joins the next
// attempt's lock set.
current, err := s.grantOwners(ctx, tx, teamID)
if err != nil {
return err
}
drifted := false
for _, owner := range current {
if _, held := owners[owner]; !held {
owners[owner] = struct{}{}
drifted = true
}
}
if drifted {
return errDrift
}
return s.buryGrants(ctx, tx, teamID, at, &buried)
})
if errors.Is(err, errDrift) && attempt == 0 {
continue
}
if errors.Is(err, errDrift) {
return 0, fmt.Errorf("offboard team: %w", diff.ErrConflict)
}
return buried, err
}
}
// Census counts the live documents per model for a whole scope at once:
// the user's personal documents plus every team in one grouped query per
// table. The result maps each owner — the zero UUID for the personal
// scope, the team ID otherwise — to its per-model counts; owners without
// documents and models without documents are omitted. It backs the stats
// endpoint; visibility-wise it reveals nothing a member's own feed does
// not, since both enumerate the same rows.
func (s *Store) Census(
ctx context.Context,
tx pgx.Tx,
userID uuid.UUID,
teams []uuid.UUID,
) (map[uuid.UUID]map[string]int64, error) {
out := make(map[uuid.UUID]map[string]int64, len(teams)+1)
for _, t := range s.order {
// Grouping by the team column folds the personal rows (team NULL,
// scanned as the zero UUID) and each team into one row apiece.
query := "SELECT " + t.teamCol + ", count(*) FROM " + t.ident +
" WHERE (" + t.userCol + " = $1::uuid" +
" AND " + t.teamCol + " IS NULL)" +
" OR " + t.teamCol + " = ANY($2::uuid[])" +
" GROUP BY " + t.teamCol
rows, err := tx.Query(ctx, query, userID, teams)
if err != nil {
return nil, fmt.Errorf(
"failed to count documents of table %q: %w", t.name, err,
)
}
for rows.Next() {
var (
owner uuid.UUID
n int64
)
if err := rows.Scan(&owner, &n); err != nil {
rows.Close()
return nil, fmt.Errorf(
"failed to count documents of table %q: %w", t.name, err,
)
}
if n == 0 {
continue
}
counts, ok := out[owner]
if !ok {
counts = make(map[string]int64, len(s.order))
out[owner] = counts
}
counts[t.model] = n
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, fmt.Errorf(
"failed to count documents of table %q: %w", t.name, err,
)
}
}
return out, nil
}
// OffboardUser erases a user's personal estate, the explicit flow behind
// the admin offboarding endpoint (this schema carries no foreign keys to
// the identity service's tables, so nothing cascades on its own). In one
// transaction holding the user's scope locks, it:
//
// 1. buries every share grant the user issued, so recipient teams
// receive the deletions and purge the shared documents;
// 2. tombstones all the user's personal documents across every
// registered table, children first;
// 3. deletes the user's mutation deduplication records.
//
// Team documents the user owns are deliberately untouched: they belong
// to the team. Tombstones and any remaining traces age out through the
// retention windows, which completes the erasure; the blob engine's
// reconciliation pass orphans the ledger entries of deleted personal
// file documents, and the sweep evicts their objects.
//
// Stamp the call with a fresh timestamp from the engine clock
// ([diff.Engine.Now]). It returns the number of buried documents and
// grants, is idempotent, and retries once when the user's grant set
// drifts concurrently (afterwards it reports [diff.ErrConflict]; simply
// repeat the call).
//
// [diff.Engine.Now]: github.com/deep-rent/nexus/eco/dse/diff#Engine.Now
func (s *Store) OffboardUser(
ctx context.Context,
userID uuid.UUID,
at hlc.Time,
) (int64, error) {
var buried int64
// The lock set is the user key plus every team the user granted
// access to; the same snapshot/lock/verify dance as OffboardTeam,
// since all advisory locks must be taken in one sorted batch.
teams := make(map[uuid.UUID]struct{})
for attempt := 0; ; attempt++ {
buried = 0
err := s.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
if attempt == 0 {
grants, err := s.Grants(ctx, tx, []uuid.UUID{userID})
if err != nil {
return err
}
for _, team := range grants[userID] {
teams[team] = struct{}{}
}
}
keys := make([]uuid.UUID, 0, len(teams)+1)
keys = append(keys, userID)
for team := range teams {
keys = append(keys, team)
}
if err := s.Lock(ctx, tx, nil, keys); err != nil {
return err
}
// Holding the user key exclusively blocks further share
// writes by this user (share writes fence on the owner key),
// so a snapshot matching the held set is final.
grants, err := s.Grants(ctx, tx, []uuid.UUID{userID})
if err != nil {
return err
}
drifted := false
for _, team := range grants[userID] {
if _, held := teams[team]; !held {
teams[team] = struct{}{}
drifted = true
}
}
if drifted {
return errDrift
}
n, err := s.buryShares(ctx, tx,
"user_id = $1::uuid", userID, at)
if err != nil {
return err
}
buried += n
n, err = s.buryDocs(ctx, tx, at, func(t *Table) string {
return "d." + t.userCol + " = $2::uuid" +
" AND d." + t.teamCol + " IS NULL"
}, userID)
if err != nil {
return err
}
buried += n
_, err = tx.Exec(ctx, "DELETE FROM "+s.mutations+
" WHERE user_id = $1::uuid", userID)
if err != nil {
return fmt.Errorf(
"failed to delete mutation records: %w", err,
)
}
return nil
})
if errors.Is(err, errDrift) && attempt == 0 {
continue
}
if errors.Is(err, errDrift) {
return 0, fmt.Errorf("offboard user: %w", diff.ErrConflict)
}
return buried, err
}
}
// PurgeTeam tombstones every document assigned to the given team across
// all registered tables, children first. It complements
// [Store.OffboardTeam], which buries the team's share grants: run both —
// grants first — before removing a team for good. Stamp the call with a
// fresh timestamp from the engine clock; the call is idempotent and
// returns the number of buried documents.
//
// The lock set is the team key plus every OWNER of a team document: an
// owner's feed serves their rows through the user arm regardless of team,
// so the tombstones must fence each owner exactly like the engine's own
// writes do. Owners are snapshotted, locked, and re-verified — holding
// the team key exclusively blocks any write that would assign a document
// to the team, freezing the set — with one retry when a document landed
// in between (afterwards it reports [diff.ErrConflict]; simply repeat
// the call).
func (s *Store) PurgeTeam(
ctx context.Context,
teamID uuid.UUID,
at hlc.Time,
) (int64, error) {
var buried int64
owners := make(map[uuid.UUID]struct{})
for attempt := 0; ; attempt++ {
buried = 0
err := s.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
if attempt == 0 {
seed, err := s.docOwners(ctx, tx, teamID)
if err != nil {
return err
}
for _, owner := range seed {
owners[owner] = struct{}{}
}
}
keys := make([]uuid.UUID, 0, len(owners)+1)
keys = append(keys, teamID)
for owner := range owners {
keys = append(keys, owner)
}
if err := s.Lock(ctx, tx, nil, keys); err != nil {
return err
}
current, err := s.docOwners(ctx, tx, teamID)
if err != nil {
return err
}
drifted := false
for _, owner := range current {
if _, held := owners[owner]; !held {
owners[owner] = struct{}{}
drifted = true
}
}
if drifted {
return errDrift
}
buried, err = s.buryDocs(ctx, tx, at, func(t *Table) string {
return "d." + t.teamCol + " = $2::uuid"
}, teamID)
return err
})
if errors.Is(err, errDrift) && attempt == 0 {
continue
}
if errors.Is(err, errDrift) {
return 0, fmt.Errorf("purge team: %w", diff.ErrConflict)
}
return buried, err
}
}
// docOwners returns the distinct owners of the documents currently
// assigned to the given team, across all registered tables.
func (s *Store) docOwners(
ctx context.Context,
tx pgx.Tx,
teamID uuid.UUID,
) ([]uuid.UUID, error) {
seen := make(map[uuid.UUID]struct{})
for _, t := range s.order {
query := "SELECT DISTINCT " + t.userCol + " FROM " + t.ident +
" WHERE " + t.teamCol + " = $1::uuid"
rows, err := tx.Query(ctx, query, teamID)
if err != nil {
return nil, fmt.Errorf(
"failed to list owners of table %q: %w", t.name, err,
)
}
for rows.Next() {
var owner uuid.UUID
if err := rows.Scan(&owner); err != nil {
rows.Close()
return nil, fmt.Errorf(
"failed to scan document owner: %w", err,
)
}
seen[owner] = struct{}{}
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, fmt.Errorf(
"failed to list owners of table %q: %w", t.name, err,
)
}
}
out := make([]uuid.UUID, 0, len(seen))
for owner := range seen {
out = append(out, owner)
}
return out, nil
}
// buryDocs deletes and tombstones the rows matching the per-table
// condition (aliased d, argument $2) across every registered table,
// children first, under the given timestamp. Callers must hold the
// affected scope locks.
func (s *Store) buryDocs(
ctx context.Context,
tx pgx.Tx,
at hlc.Time,
cond func(t *Table) string,
arg any,
) (int64, error) {
var total int64
for _, t := range slices.Backward(s.order) {
query := "WITH doomed AS (" +
" DELETE FROM " + t.ident + " d" +
" WHERE " + cond(t) +
" RETURNING d.id, d." + t.userCol + " AS user_id," +
" d." + t.teamCol + " AS team_id" +
") INSERT INTO " + s.tombstones + " AS ts" +
" (type, id, user_id, team_id, hlc, seq)" +
" SELECT " + quote.Literal(t.model) +
", id, user_id, team_id, $1, " + s.nextval +
" FROM doomed" +
" ON CONFLICT (type, id) DO UPDATE SET" +
" user_id = EXCLUDED.user_id, team_id = EXCLUDED.team_id," +
" hlc = EXCLUDED.hlc, seq = EXCLUDED.seq" +
" WHERE EXCLUDED.hlc > ts.hlc"
res, err := tx.Exec(ctx, query, int64(at), arg)
if err != nil {
return 0, fmt.Errorf(
"failed to bury documents of table %q: %w", t.name, err,
)
}
total += res.RowsAffected()
}
return total, nil
}
// buryShares deletes and tombstones the share grants matching the given
// condition (argument $1) under the given timestamp. Callers must hold
// the affected scope locks.
func (s *Store) buryShares(
ctx context.Context,
tx pgx.Tx,
cond string,
arg any,
at hlc.Time,
) (int64, error) {
query := "WITH removed AS (" +
" DELETE FROM " + s.shares + " WHERE " + cond +
" RETURNING id, user_id, team_id" +
") INSERT INTO " + s.tombstones + " AS ts" +
" (type, id, user_id, team_id, hlc, seq)" +
" SELECT " + quote.Literal(diff.ModelShare) +
", id, user_id, team_id, $2, " + s.nextval +
" FROM removed" +
" ON CONFLICT (type, id) DO UPDATE SET" +
" user_id = EXCLUDED.user_id, team_id = EXCLUDED.team_id," +
" hlc = EXCLUDED.hlc, seq = EXCLUDED.seq" +
" WHERE EXCLUDED.hlc > ts.hlc"
res, err := tx.Exec(ctx, query, arg, int64(at))
if err != nil {
return 0, fmt.Errorf("failed to bury share grants: %w", err)
}
return res.RowsAffected(), nil
}
// grantOwners returns the owners currently granting the given team access
// to their personal documents.
func (s *Store) grantOwners(
ctx context.Context,
tx pgx.Tx,
teamID uuid.UUID,
) ([]uuid.UUID, error) {
rows, err := tx.Query(ctx,
"SELECT user_id FROM "+s.shares+
" WHERE team_id = $1::uuid", teamID)
if err != nil {
return nil, fmt.Errorf("failed to list team grants: %w", err)
}
defer rows.Close()
var owners []uuid.UUID
for rows.Next() {
var owner uuid.UUID
if err := rows.Scan(&owner); err != nil {
return nil, fmt.Errorf("failed to scan grant owner: %w", err)
}
owners = append(owners, owner)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to list team grants: %w", err)
}
return owners, nil
}
// buryGrants removes and tombstones every grant of the given team, storing
// the number of buried grants in the given counter. Callers must hold the
// team key and every granting owner's key exclusively.
func (s *Store) buryGrants(
ctx context.Context,
tx pgx.Tx,
teamID uuid.UUID,
at hlc.Time,
buried *int64,
) error {
query := "WITH removed AS (" +
" DELETE FROM " + s.shares + " WHERE team_id = $1::uuid" +
" RETURNING id, user_id, team_id" +
") INSERT INTO " + s.tombstones + " AS ts" +
" (type, id, user_id, team_id, hlc, seq)" +
" SELECT $2::text, id, user_id, team_id, $3, " + s.nextval +
" FROM removed" +
" ON CONFLICT (type, id) DO UPDATE SET" +
" user_id = EXCLUDED.user_id, team_id = EXCLUDED.team_id," +
" hlc = EXCLUDED.hlc, seq = EXCLUDED.seq" +
" WHERE EXCLUDED.hlc > ts.hlc"
res, err := tx.Exec(ctx, query,
teamID, diff.ModelShare, int64(at))
if err != nil {
return fmt.Errorf("failed to bury team grants: %w", err)
}
*buried = res.RowsAffected()
return nil
}
// PruneMutations deletes mutation records older than the given age and
// returns the number of rows removed. Run it periodically; the retention
// period bounds the window during which replayed mutations deduplicate.
func (s *Store) PruneMutations(
ctx context.Context,
olderThan time.Duration,
) (int64, error) {
query := "DELETE FROM " + s.mutations +
" WHERE applied_at < now() - make_interval(secs => $1)"
res, err := s.pool.Exec(ctx, query, olderThan.Seconds())
if err != nil {
return 0, fmt.Errorf("failed to prune mutations: %w", err)
}
return res.RowsAffected(), nil
}
// PruneTombstones deletes tombstones older than the given age, advances the
// retention floor past the highest pruned sequence value, and returns the
// number of rows removed. Clients whose cursor predates the new floor are
// forced into a full resync.
func (s *Store) PruneTombstones(
ctx context.Context,
olderThan time.Duration,
) (int64, error) {
var pruned int64
err := s.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
query := "WITH pruned AS (DELETE FROM " + s.tombstones +
" WHERE deleted_at < now() - make_interval(secs => $1)" +
" RETURNING seq)" +
" SELECT count(*), coalesce(max(seq), 0) FROM pruned"
var peak int64
err := tx.QueryRow(ctx, query, olderThan.Seconds()).
Scan(&pruned, &peak)
if err != nil {
return fmt.Errorf("failed to prune tombstones: %w", err)
}
if pruned == 0 {
return nil
}
query = "UPDATE " + s.state +
" SET seq = GREATEST(seq, $1) WHERE key = 'floor'"
if _, err := tx.Exec(ctx, query, peak); err != nil {
return fmt.Errorf("failed to advance retention floor: %w", err)
}
return nil
})
if err != nil {
return 0, err
}
return pruned, nil
}
// Shares returns the built-in handler for the reserved "share" model,
// backed by the share grants table. Register it via
// [diff.Registry.RegisterShares].
func (s *Store) Shares() diff.Handler[pgx.Tx] {
return &shares{store: s}
}
// shares implements [diff.Handler] for the reserved "share" model. A share
// is a root document {id, user_id, team_id} granting a team access to the
// owner's personal documents; at most one live grant exists per (user_id,
// team_id) pair.
type shares struct {
store *Store
}
// Upsert implements the [diff.Handler] interface with row-level
// last-write-wins. Only the owner of a grant may mutate it. A newer grant
// for an already granted (user, team) pair supersedes the older duplicate:
// the duplicate is removed and tombstoned so clients converge on a single
// grant row. When a grant's team changes, the previously granted team
// receives a move tombstone carrying the old identity, and whenever a grant
// lands (its insert or update was actually applied), the owner's personal
// documents are re-fed to the granted teams via [Store.Touch].
func (h *shares) Upsert(
ctx context.Context,
tx pgx.Tx,
scope diff.Scope,
ops []diff.Op,
) error {
if len(ops) == 0 {
return nil
}
s := h.store
// Fold intra-batch duplicates for the same (user, team) pair: only the
// newest grant per pair is applied, the losers are tombstoned under the
// winner's timestamp, exactly as if they had landed first and been
// superseded in a later request.
type pair struct{ user, team uuid.UUID }
best := make(map[pair]diff.Op, len(ops))
for _, op := range ops {
if op.Meta.TeamID == uuid.Nil() {
return errors.New("share is missing team_id")
}
k := pair{user: op.Meta.UserID, team: op.Meta.TeamID}
cur, exists := best[k]
if !exists || op.Time > cur.Time || (op.Time == cur.Time &&
op.Meta.ID.Compare(cur.Meta.ID) > 0) {
best[k] = op
}
}
var wins []diff.Op
var losers []move
for _, op := range ops {
w := best[pair{user: op.Meta.UserID, team: op.Meta.TeamID}]
if w.Meta.ID == op.Meta.ID {
wins = append(wins, op)
} else {
losers = append(losers, move{
id: op.Meta.ID,
user: op.Meta.UserID,
team: op.Meta.TeamID,
hlc: int64(w.Time),
})
}
}
n := len(wins)
ids := make([]uuid.UUID, n)
users := make([]uuid.UUID, n)
teams := make([]uuid.UUID, n)
hlcs := make([]int64, n)
for i, op := range wins {
ids[i] = op.Meta.ID
users[i] = op.Meta.UserID
teams[i] = op.Meta.TeamID
hlcs[i] = int64(op.Time)
}
// Snapshot the current team assignments so team moves can be detected
// after the upsert.
before, err := h.snapshot(ctx, tx, ids)
if err != nil {
return err
}
// Remove older duplicate grants for the same (user, team) pair and
// tombstone them under the incoming timestamp. This runs as its own
// statement so the upsert's duplicate check below observes the
// post-supersede state.
supersede := "WITH incoming AS (" +
" SELECT t.id, t.user_id, t.team_id, t.hlc" +
" FROM unnest($1::uuid[], $2::uuid[], $3::uuid[], $4::bigint[])" +
" AS t(id, user_id, team_id, hlc)" +
"), stale AS (" +
" DELETE FROM " + s.shares + " s USING incoming i" +
" WHERE s.user_id = i.user_id AND s.team_id = i.team_id" +
" AND s.id <> i.id AND s.hlc < i.hlc" +
" AND s.user_id = $5::uuid" +
" RETURNING s.id, s.user_id, s.team_id, i.hlc" +
") INSERT INTO " + s.tombstones + " AS ts" +
" (type, id, user_id, team_id, hlc, seq)" +
" SELECT $6::text, id, user_id, team_id, hlc, " + s.nextval +
" FROM stale" +
" ON CONFLICT (type, id) DO UPDATE SET" +
" user_id = EXCLUDED.user_id, team_id = EXCLUDED.team_id," +
" hlc = EXCLUDED.hlc, seq = EXCLUDED.seq" +
" WHERE EXCLUDED.hlc > ts.hlc"
// Last-write-wins upsert honoring tombstones. A tombstone may only be
// bypassed (resurrection) when its identity lies within the caller's
// scope or the grant is still alive; clearing it additionally requires
// the grant to be dead, so departure tombstones of live grants survive
// for late syncers. The whole operation is suppressed while a surviving
// duplicate grant still holds the (user, team) pair, and the conflict
// update only applies when the caller owns the existing row.
upsert := "WITH incoming AS (" +
" SELECT t.id, t.user_id, t.team_id, t.hlc" +
" FROM unnest($1::uuid[], $2::uuid[], $3::uuid[], $4::bigint[])" +
" AS t(id, user_id, team_id, hlc)" +
"), alive AS (" +
" SELECT i.* FROM incoming i" +
" LEFT JOIN " + s.tombstones + " ts" +
" ON ts.type = $6::text AND ts.id = i.id" +
" LEFT JOIN " + s.shares + " r ON r.id = i.id" +
" WHERE (ts.id IS NULL OR (i.hlc > ts.hlc" +
" AND (r.id IS NOT NULL" +
" OR ts.user_id = $5::uuid OR ts.team_id = ANY($7::uuid[]))))" +
" AND NOT EXISTS (" +
" SELECT 1 FROM " + s.shares + " x" +
" WHERE x.user_id = i.user_id AND x.team_id = i.team_id" +
" AND x.id <> i.id" +
")" +
"), cleared AS (" +
" DELETE FROM " + s.tombstones + " ts USING alive a" +
" WHERE ts.type = $6::text AND ts.id = a.id" +
" AND (ts.user_id = $5::uuid OR ts.team_id = ANY($7::uuid[]))" +
" AND NOT EXISTS (" +
" SELECT 1 FROM " + s.shares + " r WHERE r.id = a.id" +
")" +
") INSERT INTO " + s.shares + " AS s" +
" (id, user_id, team_id, hlc, seq)" +
" SELECT a.id, a.user_id, a.team_id, a.hlc, " + s.nextval +
" FROM alive a" +
" ON CONFLICT (id) DO UPDATE SET" +
" team_id = EXCLUDED.team_id, hlc = EXCLUDED.hlc," +
" seq = EXCLUDED.seq" +
" WHERE EXCLUDED.hlc > s.hlc AND s.user_id = $5::uuid" +
" RETURNING s.id, s.user_id, s.team_id, s.hlc"
if _, err := tx.Exec(ctx, supersede,
ids, users, teams, hlcs, scope.UserID, diff.ModelShare,
); err != nil {
return fmt.Errorf("failed to supersede duplicate shares: %w", err)
}
rows, err := tx.Query(ctx, upsert,
ids, users, teams, hlcs, scope.UserID, diff.ModelShare, scope.Teams,
)
if err != nil {
return fmt.Errorf("failed to upsert shares: %w", err)
}
landed, err := stamps(rows)
if err != nil {
return err
}
// The old team of a moved grant must receive the grant's removal: write
// a move tombstone carrying the grant's previous identity.
moves := slices.Clone(losers)
for _, l := range landed {
old, existed := before[l.id]
if existed && old != l.team {
moves = append(moves, move{
id: l.id,
user: l.user,
team: old, // never zero: team_id is NOT NULL
hlc: l.hlc,
})
}
}
if err := s.entomb(ctx, tx, diff.ModelShare, moves); err != nil {
return err
}
// A grant re-feeds the owner's personal documents only when it genuinely
// WIDENS visibility — a brand-new grant id (a fresh insert) or a grant
// whose team changed (a move exposing a NEW team). A landed grant that
// merely refreshed an existing (id, team) pair under a newer timestamp
// grants no new audience, so the full owner-wide re-seq of Touch would
// be pure write amplification, re-delivering every personal document to
// teams that already had it. Skip it in that case.
//
// This never under-touches: any team newly gaining access does so via a
// fresh grant id or a team change, both caught below. (Superseding a
// duplicate grant under a new id still touches — the pair was already
// visible, so this over-touches, but it is safe.)
if len(landed) > 0 {
for _, l := range landed {
old, existed := before[l.id]
if !existed || old != l.team {
return s.Touch(ctx, tx, scope.UserID)
}
}
}
return nil
}
// snapshot returns the current team assignment of the given grants.
func (h *shares) snapshot(
ctx context.Context,
tx pgx.Tx,
ids []uuid.UUID,
) (map[uuid.UUID]uuid.UUID, error) {
s := h.store
query := "SELECT id, team_id FROM " + s.shares +
" WHERE id = ANY($1::uuid[])"
rows, err := tx.Query(ctx, query, ids)
if err != nil {
return nil, fmt.Errorf("failed to snapshot shares: %w", err)
}
states, err := scanStates(rows)
if err != nil {
return nil, err
}
out := make(map[uuid.UUID]uuid.UUID, len(states))
for _, st := range states {
out[st.id] = st.team
}
return out, nil
}
// Delete implements the [diff.Handler] interface. Only the owner of a grant
// may revoke it; stale deletes are skipped, and deletes of absent grants
// tombstone the payload identity.
func (h *shares) Delete(
ctx context.Context,
tx pgx.Tx,
scope diff.Scope,
ops []diff.Op,
) error {
if len(ops) == 0 {
return nil
}
s := h.store
n := len(ops)
ids := make([]uuid.UUID, n)
hlcs := make([]int64, n)
users := make([]uuid.UUID, n)
teams := make([]uuid.UUID, n)
for i, op := range ops {
ids[i] = op.Meta.ID
hlcs[i] = int64(op.Time)
users[i] = op.Meta.UserID
teams[i] = op.Meta.TeamID
}
query := "WITH incoming AS (" +
" SELECT t.id, t.hlc, t.user_id," +
" nullif(t.team_id, " + zeroUUID + ") AS team_id" +
" FROM unnest($1::uuid[], $2::bigint[], $3::uuid[], $4::uuid[])" +
" AS t(id, hlc, user_id, team_id)" +
"), victims AS (" +
" DELETE FROM " + s.shares + " s USING incoming i" +
" WHERE s.id = i.id AND s.hlc < i.hlc AND s.user_id = $5::uuid" +
" RETURNING s.id, s.user_id, s.team_id, i.hlc" +
"), scoped AS (" +
" SELECT id, user_id, team_id, hlc FROM victims" +
" UNION ALL" +
" SELECT i.id, i.user_id, i.team_id, i.hlc FROM incoming i" +
" WHERE NOT EXISTS (" +
" SELECT 1 FROM " + s.shares + " s WHERE s.id = i.id" +
")" +
") INSERT INTO " + s.tombstones + " AS ts" +
" (type, id, user_id, team_id, hlc, seq)" +
" SELECT $6::text, id, user_id, team_id, hlc, " + s.nextval +
" FROM scoped" +
" ON CONFLICT (type, id) DO UPDATE SET" +
" user_id = EXCLUDED.user_id, team_id = EXCLUDED.team_id," +
" hlc = EXCLUDED.hlc, seq = EXCLUDED.seq" +
" WHERE EXCLUDED.hlc > ts.hlc"
if _, err := tx.Exec(
ctx,
query,
ids,
hlcs,
users,
teams,
scope.UserID,
diff.ModelShare,
); err != nil {
return fmt.Errorf("failed to delete shares: %w", err)
}
return nil
}
// Fetch implements the [diff.Handler] interface. Grants are visible to
// their owner and to members of the granted team; payloads are
// reconstructed from the row columns.
//
// The team-visibility branch is expanded into ONE indexable arm per team
// (team_id = $k rather than team_id = ANY($teams)), so each arm streams
// from the (team_id, seq) index already in sequence order and the planner
// can MergeAppend the arms under ORDER BY seq LIMIT and stop early, instead
// of sorting the whole window on every page. Team counts are small, so the
// query shape (rebuilt per call from the team count) stays cheap.
//
// Parameter layout: $1 user, $2 since, $3 until, $4 model, $5 limit, and
// $6.. the individual team keys.
func (h *shares) Fetch(
ctx context.Context,
tx pgx.Tx,
scope diff.Scope,
w diff.Window,
) ([]diff.Version, error) {
s := h.store
n := len(scope.Teams)
data := "jsonb_build_object(" +
"'id', id, 'user_id', user_id, 'team_id', team_id" +
") AS data"
live := func(cond string) string {
return "SELECT id, seq, hlc, FALSE AS deleted, " + data +
" FROM " + s.shares +
" WHERE " + cond + " AND seq > $2 AND seq < $3"
}
dead := func(cond string) string {
return "SELECT id, seq, hlc, TRUE, NULL::jsonb" +
" FROM " + s.tombstones +
" WHERE type = $4::text AND " + cond +
" AND seq > $2 AND seq < $3"
}
var b strings.Builder
b.WriteString("(")
b.WriteString(live("user_id = $1::uuid"))
for i := range n {
p := "$" + strconv.Itoa(6+i)
b.WriteString(" UNION ALL " +
live("team_id = "+p+"::uuid AND user_id <> $1::uuid"))
}
b.WriteString(" UNION ALL " + dead("user_id = $1::uuid"))
for i := range n {
p := "$" + strconv.Itoa(6+i)
b.WriteString(" UNION ALL " +
dead("team_id = "+p+"::uuid AND user_id <> $1::uuid"))
}
b.WriteString(") ORDER BY seq LIMIT $5")
args := make([]any, 0, 5+n)
args = append(
args,
scope.UserID,
w.Since,
w.Until,
diff.ModelShare,
w.Limit,
)
for _, team := range scope.Teams {
args = append(args, team)
}
rows, err := tx.Query(ctx, b.String(), args...)
if err != nil {
return nil, fmt.Errorf("failed to fetch shares: %w", err)
}
return collect(rows)
}
// Read implements the [diff.Reader] interface. A grant is visible to its
// owner and to members of the granted team; the payload is reconstructed
// from the row columns, like in [shares.Fetch].
func (h *shares) Read(
ctx context.Context,
tx pgx.Tx,
scope diff.Scope,
id uuid.UUID,
) (diff.Version, bool, error) {
s := h.store
query := "SELECT seq, hlc, jsonb_build_object(" +
"'id', id, 'user_id', user_id, 'team_id', team_id)" +
" FROM " + s.shares + " WHERE id = $1::uuid" +
" AND (user_id = $2::uuid OR team_id = ANY($3::uuid[]))"
v := diff.Version{ID: id}
var ts int64
var data []byte
err := tx.QueryRow(ctx, query,
id, scope.UserID, scope.Teams,
).Scan(&v.Seq, &ts, &data)
if errors.Is(err, pgx.ErrNoRows) {
return diff.Version{}, false, nil
}
if err != nil {
return diff.Version{}, false, fmt.Errorf(
"failed to read share: %w", err,
)
}
v.Time = hlc.Time(ts)
v.Data = jsontext.Value(data)
return v, true, nil
}
// Resolve implements the [diff.Handler] interface.
func (h *shares) Resolve(
ctx context.Context,
tx pgx.Tx,
ids []uuid.UUID,
) (map[uuid.UUID]diff.Meta, error) {
s := h.store
query := "SELECT id, user_id, team_id FROM " +
s.shares + " WHERE id = ANY($1::uuid[])"
return resolve(ctx, tx, query, ids)
}
// move is one departed-audience tombstone entry: the identity a document
// carried before it moved (or, for superseded grants, before it was
// replaced), together with the timestamp of the displacing write.
type move struct {
id uuid.UUID
user uuid.UUID
team uuid.UUID // zero for personal documents
hlc int64
}
// entomb records move tombstones for the given model: each entry buries the
// document's previous identity under the displacing timestamp and a fresh
// sequence value, so the departed audience receives a deletion. Members of
// the new audience that see the corresponding update in the same page keep
// the document (equal-time updates beat deletions in the client contract).
func (s *Store) entomb(
ctx context.Context,
tx pgx.Tx,
model string,
moves []move,
) error {
if len(moves) == 0 {
return nil
}
n := len(moves)
ids := make([]uuid.UUID, n)
users := make([]uuid.UUID, n)
teams := make([]uuid.UUID, n)
hlcs := make([]int64, n)
for i, m := range moves {
ids[i] = m.id
users[i] = m.user
teams[i] = m.team
hlcs[i] = m.hlc
}
query := "INSERT INTO " + s.tombstones + " AS ts" +
" (type, id, user_id, team_id, hlc, seq)" +
" SELECT $1::text, m.id, m.user_id," +
" nullif(m.team_id, " + zeroUUID + ")," +
" m.hlc, " + s.nextval +
" FROM unnest($2::uuid[], $3::uuid[], $4::uuid[], $5::bigint[])" +
" AS m(id, user_id, team_id, hlc)" +
" ON CONFLICT (type, id) DO UPDATE SET" +
" user_id = EXCLUDED.user_id, team_id = EXCLUDED.team_id," +
" hlc = EXCLUDED.hlc, seq = EXCLUDED.seq" +
" WHERE EXCLUDED.hlc > ts.hlc"
if _, err := tx.Exec(ctx, query,
model, ids, users, teams, hlcs,
); err != nil {
return fmt.Errorf("failed to record move tombstones: %w", err)
}
return nil
}
// stamp is one written row as reported by a RETURNING clause: its
// post-write identity and timestamp. A zero team denotes a personal
// document (NULL team column).
type stamp struct {
id uuid.UUID
user uuid.UUID
team uuid.UUID
hlc int64
}
// stamps consumes rows of the shape (id, user_id, team_id, hlc).
func stamps(rows pgx.Rows) ([]stamp, error) {
defer rows.Close()
var out []stamp
for rows.Next() {
var st stamp
if err := rows.Scan(&st.id, &st.user, &st.team, &st.hlc); err != nil {
return nil, fmt.Errorf("failed to scan written document: %w", err)
}
out = append(out, st)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to read written documents: %w", err)
}
return out, nil
}
// collect scans feed rows of the shape (id, seq, hlc, deleted, data) into
// versions, preserving row order.
func collect(rows pgx.Rows) ([]diff.Version, error) {
defer rows.Close()
var out []diff.Version
for rows.Next() {
var (
id uuid.UUID
seq int64
ts int64
deleted bool
data []byte
)
if err := rows.Scan(&id, &seq, &ts, &deleted, &data); err != nil {
return nil, fmt.Errorf("failed to scan feed row: %w", err)
}
v := diff.Version{
ID: id,
Seq: seq,
Time: hlc.Time(ts),
Deleted: deleted,
}
if !deleted {
v.Data = jsontext.Value(data)
}
out = append(out, v)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to read feed rows: %w", err)
}
return out, nil
}
// resolve executes an identity lookup of the shape (id, user_id, team_id)
// and assembles the resulting envelopes keyed by document ID.
func resolve(
ctx context.Context,
tx pgx.Tx,
query string,
ids []uuid.UUID,
) (map[uuid.UUID]diff.Meta, error) {
out := make(map[uuid.UUID]diff.Meta, len(ids))
if len(ids) == 0 {
return out, nil
}
rows, err := tx.Query(ctx, query, ids)
if err != nil {
return nil, fmt.Errorf("failed to resolve documents: %w", err)
}
defer rows.Close()
for rows.Next() {
var id, user, team uuid.UUID
if err := rows.Scan(&id, &user, &team); err != nil {
return nil, fmt.Errorf("failed to scan document identity: %w", err)
}
out[id] = diff.Meta{ID: id, UserID: user, TeamID: team}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to read document identities: %w", err)
}
return out, nil
}
var (
_ diff.Store[pgx.Tx] = (*Store)(nil)
_ diff.Handler[pgx.Tx] = (*shares)(nil)
_ diff.Reader[pgx.Tx] = (*shares)(nil)
)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package postgres
import (
"context"
"time"
"github.com/jackc/pgx/v5"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/metrics"
)
// Retention is a ready-made maintenance task enforcing the store's two
// retention windows: it prunes aged mutation deduplication records
// ([Store.PruneMutations]) and aged tombstones ([Store.PruneTombstones]),
// logging the outcome through the store's logger. Failures are logged and
// swallowed; the next run retries, so the task is safe to schedule
// fire-and-forget.
//
// Retention satisfies the [schedule.Task] contract, so wiring the
// maintenance loop is a single dispatch:
//
// s := schedule.New(ctx)
// defer s.Shutdown()
// s.Dispatch(schedule.Every(time.Hour, postgres.NewRetention(store)))
//
// Runs are idempotent and cheap when there is nothing to prune; an hourly
// cadence is plenty for the default windows. In multi-replica deployments,
// schedule it on every replica or one — concurrent runs are safe, merely
// redundant.
//
// [schedule.Task]: github.com/deep-rent/nexus/sys/schedule#Task
type Retention struct {
store *Store
mutations time.Duration
tombstones time.Duration
reg *metrics.Registry
// prunedMutations and prunedTombstones count the reaped rows; floor
// gauges the retention floor, whose advancement is the visible pulse
// of tombstone pruning — a floor that stops moving while tombstones
// accrue means the maintenance loop is failing.
prunedMutations *metrics.Counter
prunedTombstones *metrics.Counter
floor *metrics.Gauge
}
// NewRetention creates a retention task around the given store. It panics
// if the store is nil (programmer error).
func NewRetention(s *Store, opts ...RetentionOption) *Retention {
if s == nil {
panic("store is required")
}
r := &Retention{
store: s,
mutations: DefaultMutationRetention,
tombstones: DefaultTombstoneRetention,
reg: metrics.DefaultRegistry,
}
for _, opt := range opts {
opt(r)
}
r.prunedMutations = r.reg.Counter(
"dse_retention_pruned_total", metrics.T("kind", "mutations"),
)
r.prunedTombstones = r.reg.Counter(
"dse_retention_pruned_total", metrics.T("kind", "tombstones"),
)
r.floor = r.reg.Gauge("dse_retention_floor")
return r
}
// Run executes one maintenance pass. A failing prune is logged and does not
// prevent the other from running.
func (r *Retention) Run(ctx context.Context) {
logger := r.store.logger
if n, err := r.store.PruneMutations(ctx, r.mutations); err != nil {
logger.Error(ctx, "Failed to prune mutations", log.Error(err))
} else if n > 0 {
r.prunedMutations.Add(uint64(n))
logger.Info(ctx, "Pruned aged mutations", log.Int64("count", n))
}
if n, err := r.store.PruneTombstones(ctx, r.tombstones); err != nil {
logger.Error(ctx, "Failed to prune tombstones", log.Error(err))
} else if n > 0 {
r.prunedTombstones.Add(uint64(n))
logger.Info(ctx, "Pruned aged tombstones", log.Int64("count", n))
}
err := r.store.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
floor, err := r.store.Floor(ctx, tx)
if err != nil {
return err
}
r.floor.Set(float64(floor))
return nil
})
if err != nil {
logger.Error(ctx, "Failed to read retention floor", log.Error(err))
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
// Explicitly allow SQL string concatenation:
// #nosec G202
package postgres
import (
"context"
"encoding/json/jsontext"
"errors"
"fmt"
"slices"
"strconv"
"strings"
"sync"
"uuid"
"github.com/jackc/pgx/v5"
"github.com/deep-rent/nexus/eco/dse/diff"
"github.com/deep-rent/nexus/eco/dse/hlc"
"github.com/deep-rent/nexus/std/quote"
)
// Table implements the [diff.Handler] interface for one document model
// backed by a single PostgreSQL table. It enforces row-level
// last-write-wins over HLC timestamps, honors tombstones, guards existing
// rows against out-of-scope writers, and cascades team moves and deletions
// to registered child tables.
//
// The backing tables are owned by the application (create them through your
// own schema migrations) and must match the following shapes. The feed scan
// is a union of independently indexable visibility branches, so each table
// needs three indexes: one for the caller's own documents, a partial one
// for team documents, and a partial one for personal documents reached
// through share grants.
//
// Root tables:
//
// CREATE TABLE assets (
// id UUID PRIMARY KEY,
// user_id UUID NOT NULL,
// team_id UUID,
// hlc BIGINT NOT NULL,
// seq BIGINT NOT NULL,
// data JSONB NOT NULL
// );
// CREATE INDEX assets_user_seq ON assets (user_id, seq);
// CREATE INDEX assets_team_seq ON assets (team_id, seq)
// WHERE team_id IS NOT NULL;
// CREATE INDEX assets_personal_seq ON assets (user_id, seq)
// WHERE team_id IS NULL;
//
// Child tables carry the denormalized root identity plus the parent
// reference column named in [WithParent], which needs its own index for the
// cascade scans:
//
// CREATE TABLE files (
// id UUID PRIMARY KEY,
// root_user_id UUID NOT NULL,
// root_team_id UUID,
// asset_id UUID NOT NULL,
// hlc BIGINT NOT NULL,
// seq BIGINT NOT NULL,
// data JSONB NOT NULL
// );
// CREATE INDEX files_user_seq ON files (root_user_id, seq);
// CREATE INDEX files_team_seq ON files (root_team_id, seq)
// WHERE root_team_id IS NOT NULL;
// CREATE INDEX files_personal_seq ON files (root_user_id, seq)
// WHERE root_team_id IS NULL;
// CREATE INDEX files_asset ON files (asset_id);
//
// Foreign keys from the identity columns (user_id/team_id and
// root_user_id/root_team_id) to the application's users and teams tables
// are recommended. Foreign keys BETWEEN synced document tables, however,
// remain unsupported: offline clients may split a parent and its children
// across separate pushes, so a child row can arrive before its parent.
//
// The owner column (user_id or root_user_id) is immutable: conflicting
// upserts never reassign it. On child tables, the parent reference may only
// move between roots of the SAME owner; re-parenting a child under a root
// with a different owner is silently skipped, like any other out-of-scope
// write. Operation batches must contain at most one operation per document;
// the engine guarantees this via compaction.
//
// When a write changes a row's team assignment (directly, or via the team
// move cascade), the row's previous audience receives a move tombstone
// carrying the old identity under the move's timestamp: departed clients
// delete the document, while clients that also receive the new version in
// the same page keep it (equal-time updates beat deletions in the client
// contract).
type Table struct {
// store is the owning sync store.
store *Store
// model is the registered model name recorded in tombstones. It must
// equal the name the table is registered under in the engine's registry.
model string
// name is the unquoted table name.
name string
// ident is the precomputed, safely quoted schema and table identifier.
ident string
// parent links a child table to its ownership parent.
parent *Table
// child marks tables carrying the denormalized root identity columns:
// tables with a parent, and adopted tables such as the file table.
child bool
// ref is the column (and JSON field) referencing the parent row.
ref string
// children lists the tables hanging off this table: those registered
// with this table as parent, plus adopted discriminated children (the
// file table under each of its anchors).
children []childLink
// userCol and teamCol name the identity columns: user_id/team_id on
// roots and root_user_id/root_team_id on children.
userCol string
teamCol string
// dataExpr is the SQL expression producing the served payload; "data"
// for plain tables, a jsonb merge for tables carrying
// server-authoritative columns.
dataExpr string
// Precomputed SQL statements.
upsertSQL string
deleteSQL string
burySQL string
resolveSQL string
snapshotSQL string
reseqSQL string
readSQL string
// fetchCache memoizes the feed scan SQL keyed by the number of teams in
// the caller's scope. The scan expands one indexable UNION ALL arm per
// team, so its shape depends on the team count and cannot be a single
// precomputed string (see fetchQuery).
fetchCache sync.Map
}
// NewTable registers a declarative table handler for one document model
// with the given store. The model name is recorded in tombstones and MUST
// equal the model name the handler is registered under in the engine's
// registry; the third argument names the backing table. Parent tables must
// be registered before their children.
//
// NewTable panics on missing arguments, duplicate registrations, or
// unregistered parent tables (programmer error). Registration is not safe
// for concurrent use; register all tables during startup.
func NewTable(s *Store, model, name string, opts ...TableOption) *Table {
if s == nil {
panic("store is required")
}
if model == "" {
panic("model name is required")
}
if name == "" {
panic("table name is required")
}
if _, exists := s.tables[name]; exists {
panic(fmt.Sprintf("table %q is already registered", name))
}
cfg := &tableConfig{schema: s.schema}
for _, opt := range opts {
opt(cfg)
}
t := &Table{
store: s,
model: model,
name: name,
ident: quote.Ident(cfg.schema, name),
}
if cfg.parent != "" {
p, ok := s.tables[cfg.parent]
if !ok {
panic(fmt.Sprintf("parent table %q is not registered", cfg.parent))
}
t.parent = p
t.child = true
t.ref = cfg.ref
}
if !t.child {
t.userCol, t.teamCol = "user_id", "team_id"
} else {
t.userCol, t.teamCol = "root_user_id", "root_team_id"
}
t.dataExpr = "data"
t.buildSQL()
s.tables[name] = t
s.order = append(s.order, t)
if t.parent != nil {
t.parent.adopt(t, "")
}
return t
}
// childLink hangs one table off another for the cascade walks. The extra
// condition (aliased c for the child table) discriminates adopted
// children, such as the file table's anchor_type per anchor.
type childLink struct {
table *Table
extra string
}
// adopt registers child as a dependent of t for the team move and delete
// cascades. Adopted children must be leaves: the cascade walk cannot
// chain through a discriminated link.
func (t *Table) adopt(child *Table, extra string) {
if extra != "" && len(child.children) > 0 {
panic("adopted children must be leaves")
}
t.children = append(t.children, childLink{table: child, extra: extra})
}
// buildSQL precomputes the statements of the handler methods.
func (t *Table) buildSQL() {
s := t.store
tomb := s.tombstones
// The insert column list, select list, and conflict assignments differ
// between root and child tables: children additionally extract the
// parent reference from the JSON payload, and their identity columns
// carry the denormalized root identity.
cols := "id, " + t.userCol + ", " + t.teamCol + ", hlc, seq, data"
sel := "a.id, a.user_id, a.team_id, a.hlc, " + s.nextval + ", a.data"
set := t.teamCol + " = EXCLUDED." + t.teamCol + "," +
" hlc = EXCLUDED.hlc, seq = EXCLUDED.seq, data = EXCLUDED.data"
guard := "(d." + t.userCol + " = $7::uuid" +
" OR d." + t.teamCol + " = ANY($8::uuid[]))"
if t.parent != nil {
refCol := quote.Escape(t.ref)
cols = "id, " + t.userCol + ", " + t.teamCol + ", " + refCol +
", hlc, seq, data"
sel = "a.id, a.user_id, a.team_id, (a.data ->> " + quote.Literal(
t.ref,
) +
")::uuid, a.hlc, " + s.nextval + ", a.data"
set = t.teamCol + " = EXCLUDED." + t.teamCol + "," +
" " + refCol + " = EXCLUDED." + refCol + "," +
" hlc = EXCLUDED.hlc, seq = EXCLUDED.seq, data = EXCLUDED.data"
// Re-parenting a child under a root with a different owner is
// silently skipped, like the hijack guard above.
guard += " AND d." + t.userCol + " = EXCLUDED." + t.userCol
}
// A tombstone may only be bypassed (resurrection) when its identity
// lies within the caller's scope or the row is still alive (then the
// tombstone records a past team move and the conflict guard governs);
// clearing it additionally requires the row to be dead, so departure
// tombstones of live rows survive for late syncers.
t.upsertSQL = "WITH incoming AS (" +
" SELECT t.id, t.user_id," +
" nullif(t.team_id, " + zeroUUID + ") AS team_id," +
" t.hlc, t.data" +
" FROM unnest($1::uuid[], $2::uuid[], $3::uuid[], $4::bigint[]," +
" $5::jsonb[]) AS t(id, user_id, team_id, hlc, data)" +
"), alive AS (" +
" SELECT i.* FROM incoming i" +
" LEFT JOIN " + tomb + " ts ON ts.type = $6::text AND ts.id = i.id" +
" LEFT JOIN " + t.ident + " r ON r.id = i.id" +
" WHERE ts.id IS NULL OR (i.hlc > ts.hlc" +
" AND (r.id IS NOT NULL" +
" OR ts.user_id = $7::uuid OR ts.team_id = ANY($8::uuid[])))" +
"), cleared AS (" +
" DELETE FROM " + tomb + " ts USING alive a" +
" WHERE ts.type = $6::text AND ts.id = a.id" +
" AND (ts.user_id = $7::uuid OR ts.team_id = ANY($8::uuid[]))" +
" AND NOT EXISTS (" +
" SELECT 1 FROM " + t.ident + " r WHERE r.id = a.id" +
")" +
") INSERT INTO " + t.ident + " AS d (" + cols + ")" +
" SELECT " + sel + " FROM alive a" +
" ON CONFLICT (id) DO UPDATE SET " + set +
" WHERE EXCLUDED.hlc > d.hlc AND " + guard +
" RETURNING d.id, d." + t.userCol + "," +
" COALESCE(d." + t.teamCol + ", " + zeroUUID + "), d.hlc"
// The scoped variant backs Delete; the unscoped variant backs the
// backend-write helper Bury. Their argument lists differ: the scope
// occupies $5 and $6 in the scoped variant, shifting the model name.
remove := func(guard, model string) string {
return "WITH incoming AS (" +
" SELECT t.id, t.hlc, t.user_id," +
" nullif(t.team_id, " + zeroUUID + ") AS team_id" +
" FROM unnest($1::uuid[], $2::bigint[], $3::uuid[], $4::uuid[])" +
" AS t(id, hlc, user_id, team_id)" +
"), victims AS (" +
" DELETE FROM " + t.ident + " a USING incoming i" +
" WHERE a.id = i.id AND a.hlc < i.hlc" + guard +
" RETURNING a.id, a." + t.userCol + " AS user_id," +
" a." + t.teamCol + " AS team_id, i.hlc" +
"), scoped AS (" +
" SELECT id, user_id, team_id, hlc FROM victims" +
" UNION ALL" +
" SELECT i.id, i.user_id, i.team_id, i.hlc FROM incoming i" +
" WHERE NOT EXISTS (" +
" SELECT 1 FROM " + t.ident + " a WHERE a.id = i.id" +
")" +
") INSERT INTO " + tomb + " AS ts" +
" (type, id, user_id, team_id, hlc, seq)" +
" SELECT " + model + "::text, id, user_id, team_id, hlc, " + s.nextval +
" FROM scoped" +
" ON CONFLICT (type, id) DO UPDATE SET" +
" user_id = EXCLUDED.user_id, team_id = EXCLUDED.team_id," +
" hlc = EXCLUDED.hlc, seq = EXCLUDED.seq" +
" WHERE EXCLUDED.hlc > ts.hlc" +
" RETURNING ts.id, ts.user_id," +
" COALESCE(ts.team_id, " + zeroUUID + "), ts.hlc"
}
t.deleteSQL = remove(
" AND (a."+t.userCol+" = $5::uuid"+
" OR a."+t.teamCol+" = ANY($6::uuid[]))",
"$7",
)
t.burySQL = remove("", "$5")
t.resolveSQL = "SELECT id, " + t.userCol + "," +
" COALESCE(" + t.teamCol + ", " + zeroUUID + ") FROM " + t.ident +
" WHERE id = ANY($1::uuid[])"
t.snapshotSQL = "SELECT id, COALESCE(" + t.teamCol + ", " + zeroUUID +
") FROM " + t.ident + " WHERE id = ANY($1::uuid[])"
t.reseqSQL = "UPDATE " + t.ident + " SET seq = " + s.nextval +
" WHERE id = ANY($1::uuid[])"
// A point read is a primary key lookup guarded by the same visibility
// branches as the feed scan: the caller's own documents, their teams'
// documents, and foreign personal documents shared through live grants.
t.readSQL = "SELECT seq, hlc, " + t.dataExpr + " FROM " + t.ident +
" WHERE id = $1::uuid AND (" + t.userCol + " = $2::uuid" +
" OR " + t.teamCol + " = ANY($3::uuid[])" +
" OR (" + t.teamCol + " IS NULL AND " + t.userCol + " IN (" +
"SELECT user_id FROM " + s.shares +
" WHERE team_id = ANY($3::uuid[]))))"
}
// Upsert implements the [diff.Handler] interface. It applies
// create-or-replace operations in bulk with row-level last-write-wins:
// tombstones block stale upserts and are cleared by newer ones
// (resurrection, permitted only within the tombstone's identity scope),
// conflicting rows only yield to strictly newer timestamps, existing rows
// must lie inside the caller's scope, and the owner column is never
// reassigned. Team moves leave a move tombstone for the departed audience
// and cascade to all descendant tables, updating their denormalized root
// identity and re-sequencing the affected rows.
func (t *Table) Upsert(
ctx context.Context,
tx pgx.Tx,
scope diff.Scope,
ops []diff.Op,
) error {
if len(ops) == 0 {
return nil
}
n := len(ops)
ids := make([]uuid.UUID, n)
users := make([]uuid.UUID, n)
teams := make([]uuid.UUID, n)
hlcs := make([]int64, n)
datas := make([]string, n)
for i, op := range ops {
ids[i] = op.Meta.ID
users[i] = op.Meta.UserID
teams[i] = op.Meta.TeamID
hlcs[i] = int64(op.Time)
datas[i] = string(op.Data)
}
// Snapshot the current team assignments so team moves can be detected
// after the upsert. The engine holds the scope's advisory locks, so no
// concurrent writer can interleave.
before, err := t.snapshot(ctx, tx, ids)
if err != nil {
return err
}
rows, err := tx.Query(ctx, t.upsertSQL,
ids, users, teams, hlcs, datas,
t.model, scope.UserID, scope.Teams,
)
if err != nil {
return fmt.Errorf("failed to upsert documents: %w", err)
}
after, err := stamps(rows)
if err != nil {
return err
}
// Rows whose team assignment changed depart their previous audience:
// bury the old identity under the move's timestamp, and propagate the
// move to all descendant rows.
var moves []move
var moved []stamp
for _, st := range after {
old, existed := before[st.id]
if !existed || old == st.team {
continue // fresh insert or unchanged team
}
moves = append(moves, move{
id: st.id,
user: st.user,
team: old,
hlc: st.hlc,
})
moved = append(moved, st)
}
if len(moves) == 0 {
return nil
}
if err := t.store.entomb(ctx, tx, t.model, moves); err != nil {
return err
}
if len(t.children) == 0 {
return nil
}
return t.cascadeTeam(ctx, tx, moved)
}
// snapshot returns the current team assignment of the given rows.
func (t *Table) snapshot(
ctx context.Context,
tx pgx.Tx,
ids []uuid.UUID,
) (map[uuid.UUID]uuid.UUID, error) {
rows, err := tx.Query(ctx, t.snapshotSQL, ids)
if err != nil {
return nil, fmt.Errorf("failed to snapshot documents: %w", err)
}
states, err := scanStates(rows)
if err != nil {
return nil, err
}
out := make(map[uuid.UUID]uuid.UUID, len(states))
for _, st := range states {
out[st.id] = st.team
}
return out, nil
}
// state pairs a document ID with its team assignment; a zero team denotes
// a personal document (NULL team column).
type state struct {
id uuid.UUID
team uuid.UUID
}
// scanStates consumes rows of the shape (id, team_id).
func scanStates(rows pgx.Rows) ([]state, error) {
defer rows.Close()
var out []state
for rows.Next() {
var st state
if err := rows.Scan(&st.id, &st.team); err != nil {
return nil, fmt.Errorf("failed to scan document state: %w", err)
}
out = append(out, st)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to read document states: %w", err)
}
return out, nil
}
// Delete implements the [diff.Handler] interface. It removes documents and
// records tombstones in bulk: only strictly newer timestamps delete an
// existing in-scope row (tombstoning the row's stored identity), deletes of
// absent documents tombstone the payload identity, and stale deletes are
// skipped entirely. Deletions cascade to all descendant tables under the
// same timestamp.
func (t *Table) Delete(
ctx context.Context,
tx pgx.Tx,
scope diff.Scope,
ops []diff.Op,
) error {
if len(ops) == 0 {
return nil
}
ids, hlcs, users, teams := deleteArgs(ops)
return t.remove(ctx, tx, t.deleteSQL,
ids, hlcs, users, teams, scope.UserID, scope.Teams, t.model,
)
}
// Bury is the backend-write counterpart of [Table.Delete]: it removes the
// given documents and records tombstones under the identities and
// timestamps carried by the given operations, cascading to all descendant
// tables, but without any scope checks. Use it for server-initiated
// deletions of synced rows; callers must hold the scope's advisory locks
// (see [Store.Mutate]) and stamp the operations with fresh engine
// timestamps.
func (t *Table) Bury(
ctx context.Context,
tx pgx.Tx,
ops []diff.Op,
) error {
if len(ops) == 0 {
return nil
}
ids, hlcs, users, teams := deleteArgs(ops)
return t.remove(ctx, tx, t.burySQL, ids, hlcs, users, teams, t.model)
}
// deleteArgs renders delete operations into the parallel array parameters
// shared by Delete and Bury. Team arrays carry the zero-UUID sentinel for
// personal documents.
func deleteArgs(ops []diff.Op) (
ids []uuid.UUID,
hlcs []int64,
users []uuid.UUID,
teams []uuid.UUID,
) {
n := len(ops)
ids = make([]uuid.UUID, n)
hlcs = make([]int64, n)
users = make([]uuid.UUID, n)
teams = make([]uuid.UUID, n)
for i, op := range ops {
ids[i] = op.Meta.ID
hlcs[i] = int64(op.Time)
users[i] = op.Meta.UserID
teams[i] = op.Meta.TeamID
}
return ids, hlcs, users, teams
}
// remove executes a delete statement and cascades the recorded tombstones
// to all descendant tables.
func (t *Table) remove(
ctx context.Context,
tx pgx.Tx,
query string,
args ...any,
) error {
rows, err := tx.Query(ctx, query, args...)
if err != nil {
return fmt.Errorf("failed to delete documents: %w", err)
}
victims, err := stamps(rows)
if err != nil {
return err
}
if len(t.children) == 0 || len(victims) == 0 {
return nil
}
return t.cascadeDelete(ctx, tx, victims)
}
// Reseq assigns fresh feed sequence values to the given rows so they
// re-enter the patch feed. It is the companion of [Store.Mutate] for
// backend-initiated updates of synced rows; callers must hold the scope's
// advisory locks.
func (t *Table) Reseq(
ctx context.Context,
tx pgx.Tx,
ids []uuid.UUID,
) error {
if len(ids) == 0 {
return nil
}
if _, err := tx.Exec(ctx, t.reseqSQL, ids); err != nil {
return fmt.Errorf("failed to reseq documents: %w", err)
}
return nil
}
// fetchQuery builds (and memoizes) the feed scan SQL for a scope holding
// the given number of teams. The scan unions independently indexable
// visibility branches: the caller's own documents, each team's documents,
// and foreign personal documents shared with any of their teams through
// live grants. The tombstone half mirrors the same branches.
//
// The team-visibility branch is expanded into ONE arm per team —
// team_col = $k rather than team_col = ANY($teams) — so each arm streams
// from the (team, seq) index already in sequence order. The planner can
// then MergeAppend the arms under ORDER BY seq LIMIT and stop as soon as
// the page fills, instead of sorting the whole (since, until) window on
// every page (a btree on (team, seq) groups rows by team, not by global
// seq, so ANY(array) forces a blocking Sort that defeats the LIMIT). Team
// counts per scope are small and stable, so the per-team fan-out is cheap
// and the memoized SQL is reused across requests with the same team count.
//
// The granted-owner set is resolved ONCE per fetch through a single CTE
// referenced by both the live and dead personal arms, replacing the two
// identical share subqueries the branch inlined before. (Cross-model
// dedup — resolving it once per feed rather than once per model — is left
// on the table: it would require threading the set through the Fetch
// signature, which the diff.Handler contract does not expose.)
//
// Parameter layout: $1 user, $2 teams array (granted CTE only), $3 since,
// $4 until, $5 model, $6 limit, $7.. the individual team keys.
func (t *Table) fetchQuery(n int) string {
if q, ok := t.fetchCache.Load(n); ok {
return q.(string)
}
s := t.store
tomb := s.tombstones
live := func(cond string) string {
return "SELECT id, seq, hlc, FALSE AS deleted, " +
t.dataExpr + " AS data FROM " + t.ident +
" WHERE " + cond + " AND seq > $3 AND seq < $4"
}
dead := func(cond string) string {
return "SELECT id, seq, hlc, TRUE AS deleted, NULL::jsonb AS data" +
" FROM " + tomb +
" WHERE type = $5::text AND " + cond +
" AND seq > $3 AND seq < $4"
}
var b strings.Builder
b.WriteString("WITH granted AS (SELECT user_id FROM " + s.shares +
" WHERE team_id = ANY($2::uuid[])) (")
b.WriteString(live(t.userCol + " = $1::uuid"))
for i := range n {
p := "$" + strconv.Itoa(7+i)
b.WriteString(" UNION ALL " +
live(t.teamCol+" = "+p+"::uuid AND "+t.userCol+" <> $1::uuid"))
}
b.WriteString(" UNION ALL " +
live(t.teamCol+" IS NULL AND "+t.userCol+" <> $1::uuid"+
" AND "+t.userCol+" IN (SELECT user_id FROM granted)"))
b.WriteString(" UNION ALL " + dead("user_id = $1::uuid"))
for i := range n {
p := "$" + strconv.Itoa(7+i)
b.WriteString(" UNION ALL " +
dead("team_id = "+p+"::uuid AND user_id <> $1::uuid"))
}
b.WriteString(" UNION ALL " +
dead("team_id IS NULL AND user_id <> $1::uuid"+
" AND user_id IN (SELECT user_id FROM granted)"))
b.WriteString(") ORDER BY seq LIMIT $6")
q := b.String()
t.fetchCache.Store(n, q)
return q
}
// Fetch implements the [diff.Handler] interface. It returns live versions
// and tombstones visible to the scope within the window, in ascending
// sequence order: the caller's own documents, their teams' documents, and
// foreign personal documents shared with any of their teams.
func (t *Table) Fetch(
ctx context.Context,
tx pgx.Tx,
scope diff.Scope,
w diff.Window,
) ([]diff.Version, error) {
args := make([]any, 0, 6+len(scope.Teams))
args = append(args,
scope.UserID, scope.Teams, w.Since, w.Until, t.model, w.Limit)
for _, team := range scope.Teams {
args = append(args, team)
}
rows, err := tx.Query(ctx, t.fetchQuery(len(scope.Teams)), args...)
if err != nil {
return nil, fmt.Errorf("failed to fetch documents: %w", err)
}
return collect(rows)
}
// Read implements the [diff.Reader] interface. It returns the live version
// of the given document if it is visible to the scope, applying the same
// visibility branches as the feed scan. Absent, deleted, and out-of-scope
// documents uniformly report ok == false.
func (t *Table) Read(
ctx context.Context,
tx pgx.Tx,
scope diff.Scope,
id uuid.UUID,
) (diff.Version, bool, error) {
v := diff.Version{ID: id}
var ts int64
var data []byte
err := tx.QueryRow(ctx, t.readSQL,
id, scope.UserID, scope.Teams,
).Scan(&v.Seq, &ts, &data)
if errors.Is(err, pgx.ErrNoRows) {
return diff.Version{}, false, nil
}
if err != nil {
return diff.Version{}, false, fmt.Errorf(
"failed to read document: %w", err,
)
}
v.Time = hlc.Time(ts)
v.Data = jsontext.Value(data)
return v, true, nil
}
// Resolve implements the [diff.Handler] interface. For child tables, the
// returned envelopes carry the denormalized root identity.
func (t *Table) Resolve(
ctx context.Context,
tx pgx.Tx,
ids []uuid.UUID,
) (map[uuid.UUID]diff.Meta, error) {
return resolve(ctx, tx, t.resolveSQL, ids)
}
// descendant pairs a descendant table with the SQL condition selecting its
// rows under the root row ids exposed by a relation aliased r; the
// descendant table itself is aliased c.
type descendant struct {
table *Table
cond string
}
// descendants walks the registered child tables recursively and returns
// them in parent-first order, each with a condition chaining through the
// intermediate parent tables down from the root rows r. Discriminated
// links carry their extra condition; they are always leaves (see adopt),
// so the walk never chains through one.
func (t *Table) descendants() []descendant {
var out []descendant
var walk func(l childLink, parents string, depth int)
walk = func(l childLink, parents string, depth int) {
c := l.table
out = append(out, descendant{
table: c,
cond: "c." + quote.Escape(c.ref) + parents + l.extra,
})
alias := fmt.Sprintf("p%d", depth)
sub := " IN (SELECT " + alias + ".id FROM " + c.ident + " " + alias +
" WHERE " + alias + "." + quote.Escape(c.ref) + parents + ")"
for _, gc := range c.children {
walk(gc, sub, depth+1)
}
}
for _, l := range t.children {
walk(l, " = r.id", 1)
}
return out
}
// cascadeTeam propagates root team moves to all descendant rows in bulk:
// one statement per descendant table updates the denormalized root
// identity, re-sequences the affected rows so they re-enter the patch feed,
// and buries each row's previous identity under the move's timestamp for
// the departed audience.
func (t *Table) cascadeTeam(
ctx context.Context,
tx pgx.Tx,
moved []stamp,
) error {
n := len(moved)
ids := make([]uuid.UUID, n)
teams := make([]uuid.UUID, n)
hlcs := make([]int64, n)
for i, m := range moved {
ids[i] = m.id
teams[i] = m.team // zero sentinel for personal documents
hlcs[i] = m.hlc
}
for _, d := range t.descendants() {
// The self-join against o captures the pre-update root identity for
// the move tombstones: within one statement, o reads the snapshot
// taken at statement start.
query := "WITH roots AS (" +
" SELECT r.id, nullif(r.team, " + zeroUUID + ") AS team, r.hlc" +
" FROM unnest($1::uuid[], $2::uuid[], $3::bigint[])" +
" AS r(id, team, hlc)" +
"), moved AS (" +
" UPDATE " + d.table.ident + " c" +
" SET " + d.table.teamCol + " = r.team, seq = " + t.store.nextval +
" FROM roots r, " + d.table.ident + " o" +
" WHERE o.id = c.id AND " + d.cond +
" RETURNING c.id, o." + d.table.userCol + " AS user_id," +
" o." + d.table.teamCol + " AS team_id, r.hlc" +
") INSERT INTO " + t.store.tombstones + " AS ts" +
" (type, id, user_id, team_id, hlc, seq)" +
" SELECT $4::text, id, user_id, team_id, hlc, " + t.store.nextval +
" FROM moved" +
" ON CONFLICT (type, id) DO UPDATE SET" +
" user_id = EXCLUDED.user_id, team_id = EXCLUDED.team_id," +
" hlc = EXCLUDED.hlc, seq = EXCLUDED.seq" +
" WHERE EXCLUDED.hlc > ts.hlc"
if _, err := tx.Exec(ctx, query,
ids, teams, hlcs, d.table.model,
); err != nil {
return fmt.Errorf(
"failed to cascade team move to table %q: %w",
d.table.name, err,
)
}
}
return nil
}
// cascadeDelete removes all descendant rows of the deleted root rows in
// bulk and tombstones them under the root's identity and delete timestamp:
// one statement per descendant table. Deeper tables are processed first so
// the conditions chaining through their parents still find the intermediate
// rows.
func (t *Table) cascadeDelete(
ctx context.Context,
tx pgx.Tx,
victims []stamp,
) error {
n := len(victims)
ids := make([]uuid.UUID, n)
users := make([]uuid.UUID, n)
teams := make([]uuid.UUID, n)
hlcs := make([]int64, n)
for i, v := range victims {
ids[i] = v.id
users[i] = v.user
teams[i] = v.team // zero sentinel for personal documents
hlcs[i] = v.hlc
}
ds := t.descendants()
for _, d := range slices.Backward(ds) {
query := "WITH roots AS (" +
" SELECT r.id, r.user_id," +
" nullif(r.team, " + zeroUUID + ") AS team_id, r.hlc" +
" FROM unnest($1::uuid[], $2::uuid[], $3::uuid[], $4::bigint[])" +
" AS r(id, user_id, team, hlc)" +
"), doomed AS (" +
" DELETE FROM " + d.table.ident + " c USING roots r" +
" WHERE " + d.cond +
" RETURNING c.id, r.user_id, r.team_id, r.hlc" +
") INSERT INTO " + t.store.tombstones + " AS ts" +
" (type, id, user_id, team_id, hlc, seq)" +
" SELECT $5::text, id, user_id, team_id, hlc, " + t.store.nextval +
" FROM doomed" +
" ON CONFLICT (type, id) DO UPDATE SET" +
" user_id = EXCLUDED.user_id, team_id = EXCLUDED.team_id," +
" hlc = EXCLUDED.hlc, seq = EXCLUDED.seq" +
" WHERE EXCLUDED.hlc > ts.hlc"
if _, err := tx.Exec(ctx, query,
ids, users, teams, hlcs, d.table.model,
); err != nil {
return fmt.Errorf(
"failed to cascade deletion to table %q: %w",
d.table.name, err,
)
}
}
return nil
}
// Model implements the [diff.Describer] interface.
func (t *Table) Model() string { return t.model }
// Parent implements the [diff.Describer] interface.
func (t *Table) Parent() (via string, ok bool) {
if !t.child {
return "", false
}
return t.ref, true
}
var (
_ diff.Handler[pgx.Tx] = (*Table)(nil)
_ diff.Reader[pgx.Tx] = (*Table)(nil)
_ diff.Describer = (*Table)(nil)
)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package hlc
import (
"errors"
"sync"
"time"
"github.com/deep-rent/nexus/std/clock"
)
const (
bits = 20
mask = (1 << bits) - 1
// Max is the highest representable timestamp. It equals 2^53 - 1, the
// largest integer that survives a round-trip through an IEEE 754 double.
Max = (1 << 53) - 1
// maxOffset is the maximum physical clock drift (in seconds) allowed from
// remote peers. Timestamps further in the future are rejected.
maxOffset = 60
)
// ErrClockDriftTooLarge is returned when updating the clock with a remote
// timestamp that is too far in the future compared to the local physical clock.
var ErrClockDriftTooLarge = errors.New(
"remote clock drift exceeds maximum offset",
)
// ErrLogicalOverflow is returned when the logical counter space for a single
// second is exhausted while applying a remote timestamp.
var ErrLogicalOverflow = errors.New("logical counter overflow")
// Time represents a causally ordered timestamp.
// It combines physical Unix seconds and logical counter into a single value
// no greater than [Max].
type Time uint64
// Pack combines physical Unix seconds and logical counter into a single
// [Time]. The physical component must fit into 33 bits; the logical counter
// is masked to 20 bits.
func Pack(physical, logical uint64) Time {
return Time((physical << bits) | (logical & mask))
}
// Unpack splits a packed [Time] into physical Unix seconds and logical
// counter.
func Unpack(packed Time) (physical, logical uint64) {
return uint64(packed) >> bits, uint64(packed) & mask
}
// Clock is a thread-safe HLC instance.
type Clock struct {
mu sync.Mutex
now clock.Clock
l uint64 // Highest physical second observed
c uint64 // Logical counter
}
// New initializes a [Clock] to the current wall time. A custom time provider
// may be injected for testing; if nil, [clock.System] is used instead.
func New(now clock.Clock) *Clock {
if now == nil {
now = clock.System
}
return &Clock{
now: now,
l: uint64(now().Unix()),
}
}
// Now generates a new local HLC timestamp.
func (c *Clock) Now() Time {
for {
c.mu.Lock()
pt := uint64(c.now().Unix())
// If wall clock is ahead, catch up and reset counter.
if pt > c.l {
c.l = pt
c.c = 0
res := Pack(c.l, c.c)
c.mu.Unlock()
return res
}
// Wall clock is behind or equal, increment logical counter.
c.c++
if c.c <= mask {
res := Pack(c.l, c.c)
c.mu.Unlock()
return res
}
// Overflow prevention: release lock and wait until physical clock
// advances to the next second.
target := c.l
c.mu.Unlock()
for uint64(c.now().Unix()) <= target {
time.Sleep(10 * time.Millisecond)
}
}
}
// Update ticks the clock forward based on an incoming remote timestamp. It
// guarantees that the next generated local timestamp is greater than the
// given one.
func (c *Clock) Update(remote Time) (Time, error) {
// Reject values outside the 53-bit space so malformed input can never
// enter causal comparisons.
if remote > Max {
return 0, ErrClockDriftTooLarge
}
rl, rc := Unpack(remote)
pt := uint64(c.now().Unix())
// Prevent malicious/misconfigured clients from dragging the clock too far
// forward.
if rl > pt+maxOffset {
return 0, ErrClockDriftTooLarge
}
c.mu.Lock()
defer c.mu.Unlock()
// Calculate the new physical time component.
ln := max(rl, max(pt, c.l))
// Calculate new logical counter.
if ln == c.l && ln == rl { //nolint:gocritic
c.c = max(c.c, rc) + 1
} else if ln == c.l {
c.c++
} else if ln == rl {
c.c = rc + 1
} else {
c.c = 0
}
c.l = ln
// Handle counter overflow.
if c.c > mask {
return 0, ErrLogicalOverflow
}
return Pack(c.l, c.c), nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package instrument
import (
"context"
"errors"
"uuid"
"github.com/deep-rent/nexus/eco/dse/blob"
"github.com/deep-rent/nexus/eco/dse/diff"
"github.com/deep-rent/nexus/sys/metrics"
)
// The sync outcome vocabulary of dse_sync_total{outcome}. Fixed and
// exported so dashboards, alert rules, and tests reference the same
// spelling the counters are registered under — a drifted literal would
// silently mint a fresh, empty time series.
const (
// OutcomeOK counts syncs that applied and served normally.
OutcomeOK = "ok"
// OutcomeRejected counts syncs refused change-by-change; the causes
// land in dse_sync_rejections_total{code}.
OutcomeRejected = "rejected"
// OutcomeConflict counts syncs beaten by concurrent ownership drift;
// clients retry these identically.
OutcomeConflict = "conflict"
// OutcomeResync counts cursors below the retention floor — the
// direct tuning signal for the tombstone retention window.
OutcomeResync = "resync"
// OutcomeTooMany counts oversized change sets.
OutcomeTooMany = "too_many"
// OutcomeError counts operational failures.
OutcomeError = "error"
)
// The blob result vocabulary of dse_blob_{grants,confirms,downloads}_total
// {result}, mirroring the manager's typed errors.
const (
// ResultOK counts operations that succeeded.
ResultOK = "ok"
// ResultNotFound counts operations on absent or invisible files.
ResultNotFound = "not_found"
// ResultUnknownSlot counts files naming a slot their anchor does not
// declare — a schema mismatch between client and server.
ResultUnknownSlot = "unknown_slot"
// ResultNoThumbnail counts thumbnail operations on slots without one.
ResultNoThumbnail = "no_thumbnail"
// ResultAlreadyUploaded counts grants and confirms of a variant that
// is already verified and therefore immutable.
ResultAlreadyUploaded = "already_uploaded"
// ResultNotReady counts reads (and thumbnail grants) ahead of the
// required verification.
ResultNotReady = "not_ready"
// ResultNoPending counts confirmations without a grant behind them.
ResultNoPending = "no_pending"
// ResultMissing counts confirmations whose object never arrived.
ResultMissing = "missing"
// ResultTooLarge counts uploads beyond the announced size cap.
ResultTooLarge = "too_large"
// ResultBadType counts uploads outside the content type whitelist.
ResultBadType = "bad_type"
// ResultChecksum counts uploads contradicting their announced size
// or checksum; the documents were marked corrupted.
ResultChecksum = "checksum"
// ResultQuota counts owners at their storage limit.
ResultQuota = "quota"
// ResultConflict counts operations beaten by concurrent drift.
ResultConflict = "conflict"
// ResultError counts operational failures.
ResultError = "error"
)
// Engine is what the sync endpoint mounts: the push/pull and point-read
// capabilities together, as the engine implements them.
type Engine interface {
diff.Syncer
diff.Getter
}
// syncer decorates an engine with outcome accounting. The embedded API
// forwards point reads untouched — their outcomes are plain HTTP
// statuses the route middleware already measures.
type syncer struct {
Engine
outcomes map[string]*metrics.Counter
causes *metrics.Registry
changes *metrics.Summary
patches *metrics.Summary
more *metrics.Counter
}
// Syncer decorates the engine with sync outcome accounting, registering
// its instruments with the given registry: dse_sync_total{outcome},
// dse_sync_rejections_total{code}, the workload summaries, and the
// pagination counter.
func Syncer(next Engine, reg *metrics.Registry) Engine {
outcomes := make(map[string]*metrics.Counter)
for _, outcome := range []string{
OutcomeOK, OutcomeRejected, OutcomeConflict,
OutcomeResync, OutcomeTooMany, OutcomeError,
} {
outcomes[outcome] = reg.Counter(
"dse_sync_total", metrics.T("outcome", outcome),
)
}
return &syncer{
Engine: next,
outcomes: outcomes,
causes: reg,
changes: reg.Summary("dse_sync_changes", nil, 0),
patches: reg.Summary("dse_sync_patches", nil, 0),
more: reg.Counter("dse_sync_more_total"),
}
}
// Sync implements the [diff.Syncer] interface.
func (s *syncer) Sync(
ctx context.Context,
scope diff.Scope,
req *diff.Request,
) (*diff.Response, error) {
res, err := s.Engine.Sync(ctx, scope, req)
switch {
case err == nil:
s.outcomes[OutcomeOK].Inc()
s.changes.Observe(float64(len(req.Changes)))
rows := 0
for _, p := range res.Patches {
rows += len(p.Update) + len(p.Delete)
}
s.patches.Observe(float64(rows))
if res.More {
s.more.Inc()
}
case isRejection(err, s.causes):
s.outcomes[OutcomeRejected].Inc()
case isResync(err):
s.outcomes[OutcomeResync].Inc()
case errors.Is(err, diff.ErrConflict):
s.outcomes[OutcomeConflict].Inc()
case errors.Is(err, diff.ErrTooManyChanges):
s.outcomes[OutcomeTooMany].Inc()
default:
s.outcomes[OutcomeError].Inc()
}
return res, err
}
// isResync reports whether the error demands a full resync.
func isResync(err error) bool {
_, ok := errors.AsType[*diff.ResyncError](err)
return ok
}
// isRejection reports whether the error is a per-change rejection and, if
// so, counts its causes by code. The code vocabulary is fixed by the
// engine, so the tag stays bounded.
func isRejection(err error, reg *metrics.Registry) bool {
rejected, ok := errors.AsType[*diff.Error](err)
if !ok {
return false
}
for _, cause := range rejected.Causes {
reg.Counter(
"dse_sync_rejections_total",
metrics.T("code", string(cause.Code)),
).Inc()
}
return true
}
// Applied bridges the engine's post-commit observer onto applied
// operation counters by model and action
// (dse_sync_applied_total{model,action}).
func Applied(reg *metrics.Registry) diff.Observer {
return func(_ context.Context, _ diff.Scope, applied []diff.Applied) {
for _, a := range applied {
reg.Counter(
"dse_sync_applied_total",
metrics.T("model", a.Model),
metrics.T("action", string(a.Op.Action)),
).Inc()
}
}
}
// lifecycle decorates the blob manager with outcome accounting per
// operation and variant.
type lifecycle struct {
next blob.Lifecycle
reg *metrics.Registry
}
// Lifecycle decorates the blob manager with outcome accounting:
// dse_blob_grants_total, dse_blob_confirms_total, and
// dse_blob_downloads_total, each tagged {variant, result}.
func Lifecycle(next blob.Lifecycle, reg *metrics.Registry) blob.Lifecycle {
return &lifecycle{next: next, reg: reg}
}
// count records one lifecycle outcome.
func (l *lifecycle) count(op string, v blob.Variant, err error) {
l.reg.Counter(
"dse_blob_"+op+"_total",
metrics.T("variant", string(v)),
metrics.T("result", result(err)),
).Inc()
}
// Upload implements the [blob.Lifecycle] interface.
func (l *lifecycle) Upload(
ctx context.Context, scope diff.Scope, id uuid.UUID, v blob.Variant,
) (blob.Grant, error) {
grant, err := l.next.Upload(ctx, scope, id, v)
l.count("grants", v, err)
return grant, err
}
// Confirm implements the [blob.Lifecycle] interface.
func (l *lifecycle) Confirm(
ctx context.Context, scope diff.Scope, id uuid.UUID, v blob.Variant,
) (blob.Stat, error) {
stat, err := l.next.Confirm(ctx, scope, id, v)
l.count("confirms", v, err)
return stat, err
}
// Download implements the [blob.Lifecycle] interface.
func (l *lifecycle) Download(
ctx context.Context, scope diff.Scope, id uuid.UUID, v blob.Variant,
) (blob.Link, error) {
link, err := l.next.Download(ctx, scope, id, v)
l.count("downloads", v, err)
return link, err
}
// Report implements the [blob.Lifecycle] interface. Accounting reads are
// route-level noise; the middleware covers them.
func (l *lifecycle) Report(
ctx context.Context, scope diff.Scope,
) ([]blob.Usage, error) {
return l.next.Report(ctx, scope)
}
// Audit implements the [blob.Lifecycle] interface; like [lifecycle.Report]
// it passes through uncounted.
func (l *lifecycle) Audit(
ctx context.Context, owner blob.Owner,
) (blob.Usage, error) {
return l.next.Audit(ctx, owner)
}
// Verified bridges the blob observer onto verified byte counters
// (dse_blob_verified_bytes_total{variant}).
func Verified(reg *metrics.Registry) blob.Observer {
return func(e blob.Event) {
variant := blob.Original
if e.Kind == blob.EventThumbnail {
variant = blob.Thumb
}
reg.Counter(
"dse_blob_verified_bytes_total",
metrics.T("variant", string(variant)),
).Add(uint64(e.Size))
}
}
// result classifies a lifecycle outcome into the fixed result vocabulary
// of the blob counters.
func result(err error) string {
switch {
case err == nil:
return ResultOK
case errors.Is(err, blob.ErrNotFound):
return ResultNotFound
case errors.Is(err, blob.ErrUnknownSlot):
return ResultUnknownSlot
case errors.Is(err, blob.ErrNoThumbnail):
return ResultNoThumbnail
case errors.Is(err, blob.ErrUploaded):
return ResultAlreadyUploaded
case errors.Is(err, blob.ErrNotReady):
return ResultNotReady
case errors.Is(err, blob.ErrNoPending):
return ResultNoPending
case errors.Is(err, blob.ErrNotUploaded):
return ResultMissing
case errors.Is(err, blob.ErrTooLarge):
return ResultTooLarge
case errors.Is(err, blob.ErrBadType):
return ResultBadType
case errors.Is(err, blob.ErrChecksum):
return ResultChecksum
case errors.Is(err, blob.ErrQuota):
return ResultQuota
case errors.Is(err, diff.ErrConflict):
return ResultConflict
default:
return ResultError
}
}
// Offboard counts one offboarding and the documents it buried
// (dse_offboardings_total{kind}, dse_offboarded_documents_total{kind}).
func Offboard(reg *metrics.Registry, kind string, buried int64) {
reg.Counter(
"dse_offboardings_total", metrics.T("kind", kind),
).Inc()
reg.Counter(
"dse_offboarded_documents_total", metrics.T("kind", kind),
).Add(uint64(buried))
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package plug
import (
"fmt"
"io/fs"
"plugin"
"github.com/deep-rent/nexus/eco/dse"
"github.com/deep-rent/nexus/eco/dse/blob"
"github.com/deep-rent/nexus/eco/dse/schema"
)
// Symbol names of the plugin contract.
const (
// SymbolDeclare builds the deployment schema. Required.
SymbolDeclare = "Declare"
// SymbolQuota resolves per-owner storage limits. Optional.
SymbolQuota = "Quota"
// SymbolMigrations exposes the deployment's document migrations.
// Optional.
SymbolMigrations = "Migrations"
)
// Load opens the schema plugin at the given path and resolves its
// contract: the declared schema plus the service options carrying the
// optional quota and migration stream. Structural mistakes inside the
// declaration panic exactly as they would in a compiled-in schema; Load
// itself returns errors only for an unloadable plugin or a symbol of the
// wrong shape.
func Load(path string) (*schema.Schema, []dse.Option, error) {
p, err := plugin.Open(path)
if err != nil {
return nil, nil, fmt.Errorf(
"failed to load schema plugin %q "+
"(host and plugin must be built from the same module "+
"state with the same toolchain): %w",
path, err,
)
}
declare, err := lookup[func() *schema.Schema](p, SymbolDeclare)
if err != nil {
return nil, nil, err
}
if declare == nil {
return nil, nil, fmt.Errorf(
"schema plugin %q exports no %q symbol", path, SymbolDeclare,
)
}
sch := declare()
if sch == nil {
return nil, nil, fmt.Errorf(
"schema plugin %q declared a nil schema", path,
)
}
var opts []dse.Option
quota, err := lookup[func() blob.Quota](p, SymbolQuota)
if err != nil {
return nil, nil, err
}
if quota != nil {
opts = append(opts, dse.WithQuota(quota()))
}
migrations, err := lookup[func() (string, fs.FS)](p, SymbolMigrations)
if err != nil {
return nil, nil, err
}
if migrations != nil {
module, source := migrations()
opts = append(opts, dse.WithMigrations(module, source))
}
return sch, opts, nil
}
// lookup resolves an optional symbol, distinguishing absence (nil, nil)
// from a symbol of the wrong shape (an error naming both types).
func lookup[T any](p *plugin.Plugin, name string) (T, error) {
var zero T
sym, err := p.Lookup(name)
if err != nil {
// The plugin package reports missing symbols as errors; absence
// of an optional symbol is not one for us.
return zero, nil //nolint:nilerr // absence is not a failure.
}
fn, ok := sym.(T)
if !ok {
return zero, fmt.Errorf(
"plugin symbol %q is a %T, not a %T", name, sym, zero,
)
}
return fn, nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package schema
import (
"github.com/deep-rent/nexus/eco/dse/blob"
)
// config carries the option state of one model declaration.
type config struct {
table string
root bool
owner string
ownerVia string
parents []string
slots []Attachment
hook Hook
}
// ModelOption configures one model declaration.
type ModelOption func(*config)
// Root marks the model as a hierarchy root: its payloads carry the
// identifying envelope (id, user_id, and optional team_id).
func Root() ModelOption {
return func(c *config) { c.root = true }
}
// Owner marks the model as a child owned by the given parent model,
// referenced from the child payload by the given JSON field name. The
// parent must be declared first.
func Owner(parent, via string) ModelOption {
return func(c *config) { c.owner, c.ownerVia = parent, via }
}
// Parents declares additional foreign key dependencies: the named models
// are upserted before, and deleted after, this model in the patch feed.
func Parents(models ...string) ModelOption {
return func(c *config) { c.parents = append(c.parents, models...) }
}
// Table overrides the backing table name for relational drivers; the
// default is the model name itself.
func Table(name string) ModelOption {
return func(c *config) { c.table = name }
}
// Hooked registers the model's post-commit hook.
func Hooked(h Hook) ModelOption {
return func(c *config) { c.hook = h }
}
// Attachments declares the model's attachment slots; declaring any makes
// the model an anchor of the reserved file model.
func Attachments(slots ...Attachment) ModelOption {
return func(c *config) { c.slots = append(c.slots, slots...) }
}
// SlotOption configures one attachment slot.
type SlotOption func(*blob.Slot)
// Types whitelists the content types the slot's originals may carry.
// Every slot needs at least one.
func Types(types ...string) SlotOption {
return func(s *blob.Slot) {
s.ContentTypes = append(s.ContentTypes, types...)
}
}
// MaxSize caps an original's size in bytes; unset applies the blob
// engine's default.
func MaxSize(bytes int64) SlotOption {
return func(s *blob.Slot) { s.MaxSize = bytes }
}
// Max caps how many live files may occupy the slot per anchor document;
// unset is unlimited.
func Max(count int) SlotOption {
return func(s *blob.Slot) { s.MaxCount = count }
}
// Thumbnail declares that the slot carries a client-generated thumbnail
// accepting the given content types, capped at the given size (<= 0
// applies the blob engine's default).
func Thumbnail(maxSize int64, types ...string) SlotOption {
return func(s *blob.Slot) {
s.Thumb = &blob.ThumbPolicy{ContentTypes: types, MaxSize: maxSize}
}
}
// Slot declares one attachment slot.
func Slot(name string, opts ...SlotOption) Attachment {
var policy blob.Slot
for _, opt := range opts {
opt(&policy)
}
return Attachment{Name: name, Policy: policy}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package schema
import (
"context"
"encoding/json/jsontext"
"encoding/json/v2"
"errors"
"fmt"
"slices"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/eco/dse/blob"
"github.com/deep-rent/nexus/eco/dse/diff"
)
// Bounds enforced at declaration, matching the driver's column widths:
// a name that fits the schema cannot overflow a row.
const (
// MaxNameLength bounds model and slot names at 64 characters
// (tombstone type and file slot columns).
MaxNameLength = 64
// MaxTypeLength bounds a declared content type at 255 characters
// (the file table's content_type column).
MaxTypeLength = 255
)
// Hook observes the applied operations of one model, invoked after the
// sync transaction committed. It runs synchronously on the syncing
// request, so it must stay cheap and must not block; hand events to a
// bus or queue for anything heavier.
type Hook func(ctx context.Context, scope diff.Scope, op diff.Op)
// Definition is the read view of one declared model, everything a driver
// needs to materialize it: registry constraints, table name, the payload
// check captured at declaration, attachment slots, and the hook.
type Definition struct {
// Name is the model name.
Name string
// Table is the backing table name for relational drivers.
Table string
// Root reports a hierarchy root; otherwise Owner and OwnerVia name
// the ownership parent model and the payload field referencing it.
Root bool
Owner string
OwnerVia string
// Parents lists additional foreign key dependencies (feed ordering).
Parents []string
// Slots lists the declared attachment slots in declaration order.
Slots []Attachment
// Hook is the model's post-commit observer, nil when none.
Hook Hook
// Check validates a payload; it was captured from the declared
// payload type.
Check func(data jsontext.Value) valid.Error
}
// Attachment declares one slot on a model: its name and the policy
// governing the files that may occupy it.
type Attachment struct {
// Name is the slot name, unique per model.
Name string
// Policy is the slot's attachment policy.
Policy blob.Slot
}
// Schema collects the model declarations of one deployment. Populate it
// with [Model] during startup and hand it to the service's composition
// root; it is not safe for concurrent mutation.
//
// Every structural mistake — duplicate names, reserved names, children
// declared before their parents, slots without content types — panics at
// declaration, so a defective schema cannot survive startup.
type Schema struct {
models []*Definition
index map[string]*Definition
}
// New creates an empty schema.
func New() *Schema {
return &Schema{index: make(map[string]*Definition)}
}
// Model declares one document model. The type parameter T is the payload
// type: incoming payloads are unmarshaled into T and, if T implements
// [valid.Validatable], validated before ingestion. Exactly one of [Root]
// or [Owner] must be given.
//
// Model panics on every structural mistake (programmer error): duplicate
// or reserved names, a missing or ambiguous hierarchy role, an owner that
// is not declared yet, duplicate slot names, slots or thumbnails without
// content types.
func Model[T any](s *Schema, name string, opts ...ModelOption) {
if name == "" {
panic("model name is required")
}
if len(name) > MaxNameLength {
panic(fmt.Sprintf(
"model name %q exceeds %d characters", name, MaxNameLength,
))
}
if name == blob.Model || name == diff.ModelShare {
panic(fmt.Sprintf("model name %q is reserved", name))
}
if _, exists := s.index[name]; exists {
panic(fmt.Sprintf("model %q is already declared", name))
}
cfg := config{table: name}
for _, opt := range opts {
opt(&cfg)
}
if cfg.root == (cfg.owner != "") {
panic(fmt.Sprintf(
"model %q needs exactly one of Root or Owner", name,
))
}
if cfg.owner != "" {
if cfg.ownerVia == "" {
panic(fmt.Sprintf(
"model %q names no field referencing its owner", name,
))
}
if _, exists := s.index[cfg.owner]; !exists {
panic(fmt.Sprintf(
"model %q is owned by undeclared model %q; "+
"declare parents before children",
name, cfg.owner,
))
}
}
seen := make(map[string]struct{}, len(cfg.slots))
for _, slot := range cfg.slots {
if slot.Name == "" {
panic(fmt.Sprintf("model %q declares an unnamed slot", name))
}
if len(slot.Name) > MaxNameLength {
panic(fmt.Sprintf(
"slot %q of model %q exceeds %d characters",
slot.Name, name, MaxNameLength,
))
}
if _, dup := seen[slot.Name]; dup {
panic(fmt.Sprintf(
"model %q declares slot %q twice", name, slot.Name,
))
}
seen[slot.Name] = struct{}{}
if len(slot.Policy.ContentTypes) == 0 {
panic(fmt.Sprintf(
"slot %q of model %q needs at least one content type",
slot.Name, name,
))
}
types := slices.Clone(slot.Policy.ContentTypes)
if slot.Policy.Thumb != nil {
if len(slot.Policy.Thumb.ContentTypes) == 0 {
panic(fmt.Sprintf(
"thumbnail of slot %q of model %q needs at least one "+
"content type",
slot.Name, name,
))
}
types = append(types, slot.Policy.Thumb.ContentTypes...)
}
for _, ct := range types {
if ct == "" || len(ct) > MaxTypeLength {
panic(fmt.Sprintf(
"content type %q of slot %q of model %q must be "+
"between 1 and %d characters",
ct, slot.Name, name, MaxTypeLength,
))
}
}
}
def := &Definition{
Name: name,
Table: cfg.table,
Root: cfg.root,
Owner: cfg.owner,
OwnerVia: cfg.ownerVia,
Parents: slices.Clone(cfg.parents),
Slots: slices.Clone(cfg.slots),
Hook: cfg.hook,
Check: func(data jsontext.Value) valid.Error {
var v T
if err := json.Unmarshal(data, &v); err != nil {
return valid.Single("data", "must be a well-formed document")
}
verr, _ := errors.AsType[valid.Error](valid.Test(&v))
return verr
},
}
s.models = append(s.models, def)
s.index[name] = def
}
// Models returns the declared models in declaration order — owners always
// precede their children, so a driver may materialize tables in this
// order directly. It panics when a [Parents] reference names an
// undeclared model, the one structural mistake only visible once the
// schema is complete.
func (s *Schema) Models() []Definition {
out := make([]Definition, len(s.models))
for i, def := range s.models {
for _, parent := range def.Parents {
if _, exists := s.index[parent]; !exists {
panic(fmt.Sprintf(
"model %q references undeclared parent %q",
def.Name, parent,
))
}
}
out[i] = *def
}
return out
}
// Anchors returns the models declaring attachment slots, in declaration
// order: the allowed parents of the reserved file model.
func (s *Schema) Anchors() []string {
var out []string
for _, def := range s.models {
if len(def.Slots) > 0 {
out = append(out, def.Name)
}
}
return out
}
// Policies compiles the declared slots into the blob engine's policy
// resolver.
func (s *Schema) Policies() blob.Slots {
out := make(blob.Slots)
for _, def := range s.models {
if len(def.Slots) == 0 {
continue
}
slots := make(map[string]blob.Slot, len(def.Slots))
for _, slot := range def.Slots {
slots[slot.Name] = slot.Policy
}
out[def.Name] = slots
}
return out
}
// Observer bridges the declared hooks onto the engine's post-commit
// observer: each applied operation dispatches to its model's hook, in
// apply order. Models without hooks are skipped; the file model has no
// hook (observe uploads through the blob engine's events instead). It
// returns nil when no model declares a hook, so wiring it is
// unconditional.
func (s *Schema) Observer() diff.Observer {
hooks := make(map[string]Hook)
for _, def := range s.models {
if def.Hook != nil {
hooks[def.Name] = def.Hook
}
}
if len(hooks) == 0 {
return nil
}
return func(ctx context.Context, scope diff.Scope, applied []diff.Applied) {
for _, a := range applied {
if hook, ok := hooks[a.Model]; ok {
hook(ctx, scope, a.Op)
}
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package dse
import (
"context"
"database/sql"
"encoding/json/jsontext"
"encoding/json/v2"
"errors"
"io/fs"
"net/http"
"slices"
"strings"
"time"
"uuid"
"github.com/jackc/pgx/v5"
"github.com/deep-rent/nexus/dat/migrate"
driver "github.com/deep-rent/nexus/dat/migrate/driver/postgres"
source "github.com/deep-rent/nexus/dat/migrate/source/file"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/eco/dse/blob"
"github.com/deep-rent/nexus/eco/dse/config"
"github.com/deep-rent/nexus/eco/dse/diff"
"github.com/deep-rent/nexus/eco/dse/driver/mock"
"github.com/deep-rent/nexus/eco/dse/driver/postgres"
"github.com/deep-rent/nexus/eco/dse/hlc"
"github.com/deep-rent/nexus/eco/dse/instrument"
"github.com/deep-rent/nexus/eco/dse/schema"
"github.com/deep-rent/nexus/eco/dse/usage"
"github.com/deep-rent/nexus/net/aws4"
"github.com/deep-rent/nexus/net/middleware/limit"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/net/s3"
"github.com/deep-rent/nexus/sec/auth"
"github.com/deep-rent/nexus/sys/boot"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/metrics"
"github.com/deep-rent/nexus/sys/schedule"
)
// PermissionAdmin is the permission the offboarding endpoints demand. A
// machine client registered with this scope at the identity provider —
// and nothing else — may erase users and teams.
const PermissionAdmin = "dse:admin"
// PermissionRead is the permission the admin read surface demands: the
// per-owner accounting endpoints backing support tooling and billing.
// [PermissionAdmin] implies it, so an offboarding client needs no second
// scope to inspect what it is about to erase.
const PermissionRead = "dse:read"
// Tuning constants of the assembled service. They are deliberately not
// configuration: each is a property of the service's own shape rather
// than of the deployment around it. The connection timeouts, which do
// depend on the deployment, live in [boot.Timeouts] instead, alongside
// what every service shares: the header cap, the probe cadences, and
// the shutdown margin.
const (
// MaxBodySize caps a request body at 4 MiB: a full sync push of the
// engine's maximum change count with generously sized documents.
// File content never passes through this service, so nothing larger
// is legitimate.
MaxBodySize = 4 << 20
// RetentionInterval is how often aged mutations and tombstones are
// pruned.
RetentionInterval = time.Hour
// BlobSweepInterval is how often doomed objects — expired pending
// uploads and orphans — are evicted from storage.
BlobSweepInterval = 15 * time.Minute
// ReconcileInterval is how often the object ledger is reconciled
// against the file documents, orphaning strays left by cascaded or
// out-of-band deletions.
ReconcileInterval = time.Hour
// StorageProbeInterval is how often the object store's reachability
// is probed. The verdict lands in the dse_storage_up gauge and the
// log — deliberately NOT in the health probes: sync works without
// storage, so an object-store blip must neither drain traffic
// (readiness) nor restart the process (liveness).
StorageProbeInterval = time.Minute
)
// Service is the fully assembled document sync service. Create instances
// with [New], serve them with [Service.Run], or embed [Service.Handler]
// into a custom server.
type Service struct {
cfg config.Config
rt *boot.Runtime
logger *log.Logger
store *postgres.Store // nil on the in-memory driver
// stamp mints engine timestamps for backend-initiated writes.
stamp func() hlc.Time
// blobs is the measured blob lifecycle, kept for the usage handler;
// nil without object storage.
blobs blob.Lifecycle
retention *postgres.Retention // nil on the in-memory driver
sweep func(ctx context.Context)
reconcile func(ctx context.Context)
// bucket is the object store behind file attachments, kept for the
// reachability probe; nil without object storage.
bucket *s3.Bucket
}
// Option configures the assembly beyond what the environment declares.
type Option func(*options)
type options struct {
quota blob.Quota
streams []stream
sync []diff.Option
}
// stream is one deployment-owned migration stream.
type stream struct {
module string
source fs.FS
}
// WithQuota injects an entitlement-driven storage quota, replacing the
// flat limits of [config.Quota]. This is the seam a compiled deployment
// bridges to wherever its plans live. A nil quota is ignored.
func WithQuota(q blob.Quota) Option {
return func(o *options) {
if q != nil {
o.quota = q
}
}
}
// WithSync forwards engine options to the sync engine: the request and
// page limits ([diff.WithMaxChanges], [diff.WithMaxPatches],
// [diff.WithDefaultLimit]) and the duplicate prefilter
// ([diff.WithPrefilter], for deployments fronting the claim table with
// a fast shared cache). The service owns the engine's logger and
// observer wiring; passing [diff.WithLogger] or [diff.WithObserver]
// here has no effect, since the assembly's own options are applied
// last. Register additional observers through the schema instead.
func WithSync(opts ...diff.Option) Option {
return func(o *options) {
o.sync = append(o.sync, opts...)
}
}
// WithMigrations registers a deployment-owned migration stream — the
// document tables behind the declared schema — applied at startup after
// the engine's bookkeeping stream. Scripts reference the bookkeeping
// objects safely by gating on the engine's module: "-- requires: dse@1".
// The option may be given once per module.
func WithMigrations(module string, source fs.FS) Option {
return func(o *options) {
if module != "" && source != nil {
o.streams = append(o.streams, stream{
module: module,
source: source,
})
}
}
}
// New assembles the service from its configuration and the deployment's
// declared schema. It returns an error for unusable external inputs (an
// unreachable database configuration); inconsistent wiring inside the
// schema surfaces as panics from the underlying constructors, since
// those are compile-a-new-binary errors to fail fast on.
//
// The version identifies this build in the User-Agent of every outbound
// request; resolve it via [cli.Version].
//
// [cli.Version]: github.com/deep-rent/nexus/sys/cli#Version
func New(
ctx context.Context,
cfg config.Config,
sch *schema.Schema,
version string,
opts ...Option,
) (*Service, error) {
if sch == nil {
panic("schema is required")
}
var o options
for _, opt := range opts {
opt(&o)
}
if o.quota == nil && cfg.Quota.Enabled() {
o.quota = blob.FlatQuota{
User: cfg.Quota.UserBytes,
Team: cfg.Quota.TeamBytes,
}
}
// The mock driver backs local development and end-to-end tests:
// state lives in memory, and file attachments are unavailable since
// their lifecycle is inseparable from durable storage. Withholding
// the section is what selects it.
var database *boot.Database
if cfg.Database.Enabled() {
database = &cfg.Database
}
bootOpts := []boot.Option{boot.WithMaxBody(MaxBodySize)}
if database == nil {
// A rig on the mock driver often runs against a placeholder
// issuer, and stalling every start for the key-fetch budget
// helps nobody; the cache keeps retrying in the background.
bootOpts = append(bootOpts, boot.WithKeyWait(0))
}
rt, err := boot.New(ctx, boot.Spec{
Name: "dse",
Version: version,
Core: cfg.Core,
Database: database,
Auth: &cfg.Auth,
}, bootOpts...)
if err != nil {
return nil, err
}
s := &Service{cfg: cfg, rt: rt, logger: rt.Logger()}
httpClient := rt.Client()
var (
engine instrument.Engine
manager blob.Lifecycle
)
if rt.Pool() == nil {
engine = s.assembleMock(sch, o)
s.logger.Warn(
ctx,
"No DATABASE_URL: running on the mock driver, which keeps "+
"all state in memory, loses it on restart, and disables "+
"file attachments",
)
} else {
engine, manager = s.assemblePostgres(cfg, sch, o, httpClient)
rt.Migrate(postgres.Migrator)
// The deployment's own streams follow the bookkeeping stream,
// so their requirement gates on the dse module resolve.
for _, st := range o.streams {
rt.Migrate(stream(st).migrator())
}
if manager == nil && len(sch.Anchors()) > 0 {
// Most likely a fat-fingered STORAGE_ variable: the schema
// wants attachments, but without a bucket the file model is
// not registered and every file payload will be rejected as
// an unknown model.
s.logger.Warn(
ctx,
"Schema declares attachment slots but object storage is "+
"not (fully) configured; file documents will be "+
"rejected as an unknown model",
)
}
}
r := rt.Router()
guard := rt.Guard()
// The per-user meter guards the document endpoints against a hot
// client. It keys on the token subject and runs behind the guard, so
// unauthenticated requests never occupy a bucket; requests without a
// subject (never the case past the guard) pass unmetered. The admin
// surface is deliberately unmetered: its callers are registered
// machine clients. nil when disabled — Chain skips it.
var meter router.Middleware
if cfg.Rate.Enabled() {
meter = limit.New(
limit.WithRate(cfg.Rate.PerSecond),
limit.WithBurst(cfg.Rate.Burst),
limit.WithKey(func(e *router.Exchange) string {
if claims, ok := auth.From(e); ok {
if id := claims.UserID(); id != uuid.Nil() {
return id.String()
}
}
return ""
}),
)
}
// The sync protocol and the single-document endpoint, decorated with
// the domain metrics (see metrics.go). Mount installs the
// "GET /{type}/{id}" catch-all, so the blob endpoints (whose paths
// carry a literal first segment or a third segment) register
// alongside without collisions.
diff.Mount(r, instrument.Syncer(engine, metrics.DefaultRegistry),
guard.Secure(), meter)
if manager != nil {
s.blobs = instrument.Lifecycle(manager, metrics.DefaultRegistry)
blob.Mount(r, s.blobs, guard.Secure(), meter)
}
if s.store != nil {
s.mountAdmin(r, guard)
// The webhook receiver acts on the identity service's deletion
// events; without a store there is nothing to clean up, so —
// like the offboarding endpoints — it exists only on the
// PostgreSQL driver.
if cfg.Intake.Enabled() {
rcv, err := rt.Receiver(cfg.Intake)
if err != nil {
return nil, err
}
s.mountHooks(rcv, r)
}
// The census binds the store's grouped document counts to one
// transaction; storage joins in when the blob engine exists.
usage.Mount(r, s.census, s.blobs, guard.Secure(), meter)
}
// The scheduled upkeep: retention on the sync store, the blob
// engine's own sweeps, and the object store's reachability.
if s.retention != nil {
rt.Every("retention", RetentionInterval, s.retention)
}
if s.sweep != nil {
rt.Every("blobs", BlobSweepInterval, schedule.TaskFn(s.sweep))
rt.Every("reconcile", ReconcileInterval,
schedule.TaskFn(s.reconcile))
}
if s.bucket != nil {
rt.Every("storage", StorageProbeInterval,
schedule.TaskFn(s.probeStorage))
}
// One line answering the questions a misconfigured deployment raises
// first: which driver, which identity provider, and whether storage
// and quotas are on.
driver := "postgres"
if rt.Pool() == nil {
driver = "mock"
}
s.logger.Info(ctx, "Assembled DSE service",
log.String("driver", driver),
log.Int("models", len(sch.Models())),
log.Bool("storage", s.blobs != nil),
log.Bool("quota", o.quota != nil),
log.String("issuer", cfg.Auth.Issuer),
log.String("jwks", cfg.Auth.Keys()),
)
return s, nil
}
// census binds the store's grouped per-owner document counts to a
// transaction, satisfying [usage.Census].
func (s *Service) census(
ctx context.Context,
userID uuid.UUID,
teams []uuid.UUID,
) (map[uuid.UUID]map[string]int64, error) {
var out map[uuid.UUID]map[string]int64
err := s.store.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
var err error
out, err = s.store.Census(ctx, tx, userID, teams)
return err
})
return out, err
}
// assembleMock materializes the schema on the in-memory driver.
func (s *Service) assembleMock(
sch *schema.Schema,
o options,
) instrument.Engine {
store := mock.New()
reg := diff.NewRegistry[*mock.Tx]()
for _, def := range sch.Models() {
reg.RegisterRaw(
def.Name,
mock.NewHandler(store),
def.Check,
constraints(def)...,
)
}
reg.RegisterShares(mock.NewShares(store))
engine := diff.New(store, reg, s.syncOptions(sch, o)...)
s.stamp = engine.Now
return engine
}
// syncOptions renders the engine options: the deployment's own first,
// then the assembly's logger and observer wiring, which must win.
func (s *Service) syncOptions(sch *schema.Schema, o options) []diff.Option {
return append(slices.Clone(o.sync),
diff.WithLogger(s.logger.Child("sync")),
diff.WithObserver(diff.Observers(
instrument.Applied(metrics.DefaultRegistry), sch.Observer(),
)),
)
}
// assemblePostgres materializes the schema on the PostgreSQL driver and,
// when object storage is configured, wires the blob engine around the
// file table.
func (s *Service) assemblePostgres(
cfg config.Config,
sch *schema.Schema,
o options,
httpClient *http.Client,
) (instrument.Engine, blob.Lifecycle) {
store := postgres.New(
s.rt.Pool(), postgres.WithLogger(s.logger.Child("store")),
)
s.store = store
s.retention = postgres.NewRetention(store,
postgres.WithMutationRetention(cfg.Retention.Mutations),
postgres.WithTombstoneRetention(cfg.Retention.Tombstones),
)
reg := diff.NewRegistry[pgx.Tx]()
tables := make(map[string]*postgres.Table)
names := make(map[string]string) // model -> table name
for _, def := range sch.Models() {
var topts []postgres.TableOption
if def.Owner != "" {
topts = append(topts,
postgres.WithParent(names[def.Owner], def.OwnerVia))
}
tbl := postgres.NewTable(store, def.Name, def.Table, topts...)
tables[def.Name] = tbl
names[def.Name] = def.Table
reg.RegisterRaw(def.Name, tbl, def.Check, constraints(def)...)
}
reg.RegisterShares(store.Shares())
// The file model registers only when a deployment can actually store
// objects: a schema with slots but no bucket syncs its documents and
// simply refuses file payloads as an unknown model.
anchors := sch.Anchors()
var files *postgres.Files
if cfg.Storage.Enabled() && len(anchors) > 0 {
files = store.Files(sch.Policies())
for _, anchor := range anchors {
files.AttachTo(tables[anchor])
}
reg.RegisterRaw(
blob.Model,
files,
func(data jsontext.Value) valid.Error {
var f blob.File
if err := json.Unmarshal(data, &f); err != nil {
return valid.Single(
"data", "must be a well-formed document",
)
}
verr, _ := errors.AsType[valid.Error](valid.Test(&f))
return verr
},
diff.PolyOwner(
blob.FieldAnchorType, blob.FieldAnchorID, anchors...,
),
)
}
engine := diff.New(store, reg, s.syncOptions(sch, o)...)
s.stamp = engine.Now
if files == nil {
return engine, nil
}
bucket := s3.New(
cfg.Storage.Bucket,
aws4.New(aws4.Credentials{
AccessKey: cfg.Storage.AccessKey,
SecretKey: cfg.Storage.SecretKey,
}, cfg.Storage.Region),
s3.WithClient(httpClient),
)
s.bucket = bucket
manager := blob.New(blob.Config[pgx.Tx]{
Store: files,
Storage: bucket,
Policies: sch.Policies(),
Stamp: engine.Now,
Quota: o.quota,
Prefix: cfg.Storage.Prefix,
GrantTTL: cfg.Storage.GrantTTL,
ConfirmWindow: cfg.Storage.ConfirmWindow,
DownloadTTL: cfg.Storage.DownloadTTL,
},
blob.WithLogger[pgx.Tx](s.logger.Child("blob")),
blob.WithObserver[pgx.Tx](
instrument.Verified(metrics.DefaultRegistry),
),
)
s.sweep = manager.Sweep
s.reconcile = manager.Reconcile
return engine, manager
}
// constraints renders a schema definition into registry constraints.
func constraints(def schema.Definition) []diff.Constraint {
var out []diff.Constraint
if def.Root {
out = append(out, diff.Root())
} else {
out = append(out, diff.Owner(def.Owner, def.OwnerVia))
}
if len(def.Parents) > 0 {
out = append(out, diff.Parents(def.Parents...))
}
return out
}
// mountAdmin registers the offboarding endpoints, guarded by the
// [PermissionAdmin] scope. They exist for the identity service's (or an
// operator's) explicit lifecycle flows: this schema carries no foreign
// keys onto identity tables, so erasure is an API call, not a cascade.
func (s *Service) mountAdmin(r router.Registrar, guard *auth.Guard) {
rule := auth.Grants{}.Require(PermissionAdmin)
// The read surface: per-owner accounting for support tooling and
// billing. The write scope implies it.
read := auth.Any(rule, auth.Grants{}.Require(PermissionRead))
r.HandleFunc(
http.MethodGet,
"/admin/users/{id}/usage",
usage.Admin(s.census, s.blobs, blob.KindUser),
guard.Secure(read),
)
r.HandleFunc(
http.MethodGet,
"/admin/teams/{id}/usage",
usage.Admin(s.census, s.blobs, blob.KindTeam),
guard.Secure(read),
)
// DELETE /admin/users/{id}: bury the user's personal estate; see
// Store.OffboardUser. Together with the identity-side deletion and
// the retention windows, this completes GDPR erasure.
r.HandleFunc(
http.MethodDelete,
"/admin/users/{id}",
s.offboard("user", s.offboardUser),
guard.Secure(rule),
)
// DELETE /admin/teams/{id}: bury the grants pointing at the team,
// then every document assigned to it.
r.HandleFunc(
http.MethodDelete,
"/admin/teams/{id}",
s.offboard("team", s.offboardTeam),
guard.Secure(rule),
)
}
// settleOffboard runs one offboarding flow and settles its verdict: the
// conflict a concurrent change raises becomes the 409 both surfaces
// answer, and a completed burial is counted. It is shared by the admin
// handler and the webhook receiver, so the conflict contract and the
// offboarding accounting cannot drift between them; each caller shapes
// its own response and log line.
func (*Service) settleOffboard(
ctx context.Context,
kind string,
id uuid.UUID,
flow func(ctx context.Context, id uuid.UUID) (int64, error),
) (int64, error) {
buried, err := flow(ctx, id)
if errors.Is(err, diff.ErrConflict) {
return 0, &router.Error{
Status: http.StatusConflict,
Reason: diff.ReasonConflict,
Description: "a concurrent change interfered; retry",
Cause: err,
}
}
if err != nil {
return 0, err
}
instrument.Offboard(metrics.DefaultRegistry, kind, buried)
return buried, nil
}
// offboard builds one offboarding handler around the given flow.
func (s *Service) offboard(
kind string,
flow func(ctx context.Context, id uuid.UUID) (int64, error),
) router.HandlerFunc {
return func(e *router.Exchange) error {
id, err := uuid.Parse(e.Param("id"))
if err != nil {
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: kind + " ID is not a valid UUID",
Context: valid.Single("id", "must be a valid UUID"),
}
}
buried, err := s.settleOffboard(e.Context(), kind, id, flow)
if err != nil {
return err
}
s.logger.Info(e.Context(), "Offboarded principal",
log.String("kind", kind),
log.UUID("id", id),
log.Int64("buried", buried),
)
return e.JSON(http.StatusOK, map[string]int64{"buried": buried})
}
}
// Handler returns the assembled HTTP handler, for embedding the service
// into a custom server.
func (s *Service) Handler() http.Handler { return s.rt.Handler() }
// Run serves the document sync service until the context is canceled or
// a termination signal arrives.
func (s *Service) Run(ctx context.Context) error { return s.rt.Run(ctx) }
// migrator renders a deployment-owned stream into a [boot.Migrator].
func (st stream) migrator() boot.Migrator {
return func(
db *sql.DB,
opts ...migrate.Option,
) *migrate.Migrator {
return migrate.New(append([]migrate.Option{
migrate.WithModule(st.module),
migrate.WithSource(source.New(st.source)),
migrate.WithDriver(driver.New(db)),
}, opts...)...)
}
}
// probeStorage checks that the object store answers signed requests,
// reporting the verdict as the dse_storage_up gauge. The sentinel key
// never exists; its absence — or any evaluated refusal — still proves
// the store reachable and the signing machinery sound, so only
// transport failures and server errors count as down. See
// [StorageProbeInterval] for why this is not a health probe.
func (s *Service) probeStorage(ctx context.Context) {
key := "files/.probe"
if p := s.cfg.Storage.Prefix; p != "" {
key = strings.TrimSuffix(p, "/") + "/" + key
}
_, err := s.bucket.Head(ctx, key)
if apiErr, ok := errors.AsType[*s3.APIError](err); ok &&
apiErr.Status < 500 {
err = nil
}
gauge := metrics.DefaultRegistry.Gauge("dse_storage_up")
if err != nil {
gauge.Set(0)
s.logger.Warn(ctx, "Object store unreachable", log.Error(err))
return
}
gauge.Set(1)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package usage
import (
"context"
"net/http"
"uuid"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/eco/dse/blob"
"github.com/deep-rent/nexus/eco/dse/diff"
"github.com/deep-rent/nexus/net/router"
)
// Report is one owner scope's accounting: the caller personally, or one
// of their teams.
type Report struct {
// Owner is the accounted scope.
Owner blob.Owner `json:"owner"`
// Documents counts the live documents per model; models without
// documents are omitted.
Documents map[string]int64 `json:"documents"`
// Storage carries the verified-bytes accounting. It is absent when
// the deployment runs without object storage.
Storage *Storage `json:"storage,omitzero"`
}
// Storage is one owner scope's verified-bytes accounting.
type Storage struct {
// Used is the sum of verified bytes.
Used int64 `json:"used"`
// Limit is the owner's limit in bytes; absent means unlimited.
Limit int64 `json:"limit,omitzero"`
// Available is the room left under the limit; absent when the limit
// is unlimited. Zero means genuinely full.
Available *int64 `json:"available,omitzero"`
}
// Census tallies the live documents per model of a whole scope in one
// call: the user's personal documents plus every team. The result maps
// each owner — the zero UUID for the personal scope, the team ID
// otherwise — to its per-model counts; owners and models without
// documents are omitted. The persistent driver's Store.Census has this
// shape once bound to a transaction runner.
type Census func(ctx context.Context, userID uuid.UUID, teams []uuid.UUID) (
map[uuid.UUID]map[string]int64, error)
// Endpoint builds the accounting handler, serving "GET /usage": the
// caller's personal entry first, then one per team. The storage
// lifecycle may be nil on deployments without object storage; the
// storage section is then absent. It panics on a nil census
// (programmer error).
func Endpoint(census Census, storage blob.Lifecycle) router.HandlerFunc {
if census == nil {
panic("census is required")
}
return func(e *router.Exchange) error {
scope, err := diff.ScopeFrom(e)
if err != nil {
return err
}
owners := make([]blob.Owner, 0, len(scope.Teams)+1)
owners = append(owners, blob.Owner{
Kind: blob.KindUser, ID: scope.UserID,
})
for _, team := range scope.Teams {
owners = append(owners, blob.Owner{
Kind: blob.KindTeam, ID: team,
})
}
counts, err := census(e.Context(), scope.UserID, scope.Teams)
if err != nil {
return err
}
report := make([]Report, len(owners))
for i, owner := range owners {
var key uuid.UUID // zero selects the personal bucket
if owner.Kind == blob.KindTeam {
key = owner.ID
}
documents := counts[key]
if documents == nil {
documents = map[string]int64{}
}
report[i] = Report{Owner: owner, Documents: documents}
}
if storage != nil {
usage, err := storage.Report(e.Context(), scope)
if err != nil {
return err
}
byOwner := make(map[blob.Owner]blob.Usage, len(usage))
for _, u := range usage {
byOwner[u.Owner] = u
}
for i := range report {
u, ok := byOwner[report[i].Owner]
if !ok {
continue
}
report[i].Storage = section(u)
}
}
return e.JSON(http.StatusOK, map[string][]Report{"usage": report})
}
}
// Mount registers the accounting endpoint as "GET /usage" following the
// mount convention of this framework. Pass the auth guard (and any
// additional route middleware) as mws.
func Mount(
r *router.Router,
census Census,
storage blob.Lifecycle,
mws ...router.Middleware,
) {
r.HandleFunc(http.MethodGet, "/usage", Endpoint(census, storage), mws...)
}
// Admin builds the machine-facing accounting handler for one owner kind,
// reading the owner ID from the "id" path parameter instead of the
// caller's claims. It serves back-office needs — support tooling and
// billing — behind a machine scope the service mounts it under; unlike
// [Endpoint] it deliberately skips [diff.ScopeFrom], since machine
// tokens carry no user identity. The storage lifecycle may be nil on
// deployments without object storage. It panics on a nil census
// (programmer error).
func Admin(
census Census,
storage blob.Lifecycle,
kind blob.Kind,
) router.HandlerFunc {
if census == nil {
panic("census is required")
}
return func(e *router.Exchange) error {
id, err := uuid.Parse(e.Param("id"))
if err != nil {
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: "owner ID is not a valid UUID",
Context: valid.Single("id", "must be a valid UUID"),
}
}
owner := blob.Owner{Kind: kind, ID: id}
// The census keys the personal bucket on the zero UUID; a team
// query passes no user, a user query no teams.
var (
userID uuid.UUID
teams []uuid.UUID
key uuid.UUID
)
if kind == blob.KindTeam {
teams, key = []uuid.UUID{id}, id
} else {
userID = id
}
counts, err := census(e.Context(), userID, teams)
if err != nil {
return err
}
documents := counts[key]
if documents == nil {
documents = map[string]int64{}
}
out := Report{Owner: owner, Documents: documents}
if storage != nil {
u, err := storage.Audit(e.Context(), owner)
if err != nil {
return err
}
out.Storage = section(u)
}
return e.JSON(http.StatusOK, out)
}
}
// section renders one usage read into the storage section of a [Report]
// entry: Available appears only under a finite limit, and zero then
// means genuinely full.
func section(u blob.Usage) *Storage {
s := &Storage{Used: u.Used, Limit: u.Limit}
if u.Limit > 0 {
available := max(u.Limit-u.Used, 0)
s.Available = &available
}
return s
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package dse
import (
"context"
"net/http"
"uuid"
"github.com/deep-rent/nexus/eco/identity"
"github.com/deep-rent/nexus/net/notify/hook"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/sys/log"
)
// PathHooks is where the identity service's webhook deliveries arrive;
// see [Service] and the deployment README for the registration recipe.
const PathHooks = "/hooks/iam"
// offboardUser buries a deleted user's personal estate; see
// Store.OffboardUser. It is idempotent: a second call finds nothing
// left to bury.
func (s *Service) offboardUser(
ctx context.Context, id uuid.UUID,
) (int64, error) {
return s.store.OffboardUser(ctx, id, s.stamp())
}
// offboardTeam buries the grants pointing at a dissolved team, then
// every document assigned to it. Idempotent like offboardUser.
func (s *Service) offboardTeam(
ctx context.Context, id uuid.UUID,
) (int64, error) {
grants, err := s.store.OffboardTeam(ctx, id, s.stamp())
if err != nil {
return 0, err
}
docs, err := s.store.PurgeTeam(ctx, id, s.stamp())
return grants + docs, err
}
// bury builds a handler that offboards one kind of principal on the
// identifier its events carry.
//
// A verified event of a subscribed topic naming nobody is a sender-side
// defect, and one whose flow fails is a store this service could not
// reach; both refuse the delivery, so the sender comes back rather than
// leaving a principal half-buried. The flows are idempotent, so a
// replay costs a no-op pass over an estate that is already gone.
func (s *Service) bury(
kind string,
pick func(identity.Event) (uuid.UUID, bool),
flow func(context.Context, uuid.UUID) (int64, error),
) hook.Accept {
return func(e *router.Exchange, d hook.Delivery) error {
ev, err := identity.Decode(d)
if err != nil {
return err
}
id, ok := pick(ev)
if !ok {
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: "the event names no principal",
}
}
buried, err := s.settleOffboard(e.Context(), kind, id, flow)
if err != nil {
return err
}
s.logger.Info(e.Context(), "Offboarded principal on webhook",
log.String("kind", kind),
log.UUID("id", id),
log.Int64("buried", buried),
log.String("origin", d.Origin),
)
return nil
}
}
// mountHooks registers the webhook receiver on the public router.
func (s *Service) mountHooks(rcv *hook.Receiver, r router.Registrar) {
rcv.
On(identity.TopicUserDeleted, s.bury(
"user",
identity.Event.User,
s.offboardUser,
)).
On(identity.TopicTeamDissolved, s.bury(
"team",
identity.Event.Team,
s.offboardTeam,
)).
Mount(r, PathHooks)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package api
import (
"context"
"encoding/json/jsontext"
"errors"
"net/http"
"slices"
"strconv"
"time"
"uuid"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/eco/hds/attach"
"github.com/deep-rent/nexus/eco/hds/desk"
"github.com/deep-rent/nexus/eco/hds/notify"
"github.com/deep-rent/nexus/eco/hds/store"
"github.com/deep-rent/nexus/eco/hds/ticket"
"github.com/deep-rent/nexus/eco/identity"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/sec/auth"
)
// Bounds on what a client may write.
const (
// MaxSubjectLength bounds a ticket's subject line.
MaxSubjectLength = 200
// MaxBodyLength bounds one message. It is long enough for a
// pasted stack trace and short of an upload.
MaxBodyLength = 32_000
// MaxMetaSize bounds the context an app attaches at creation.
MaxMetaSize = 4 << 10
// MaxTags bounds how many tags one ticket may carry.
MaxTags = 8
)
// Config bundles the collaborators of a [Server].
type Config struct {
// Desk is the engine every change goes through. Required.
Desk *desk.Engine
// Files authorizes and confirms attachments. Required.
Files *attach.Engine
// People resolves the names shown on a thread. Required.
People *identity.Directory
// Mailer redeems unsubscribe tokens. Required.
Mailer *notify.Mailer
// StaffRole is the IAM role granting support standing.
// Required.
StaffRole string
// Tags is the deployment's tag vocabulary, served so a client can
// render the choices rather than guess them.
Tags []string
}
// Server implements the help desk API. Create instances with [New]
// and attach the routes with [Server.Mount].
type Server struct {
cfg Config
}
// New assembles a [Server]. It panics if a required [Config] field is
// missing, since startup misconfiguration is a programmer error.
func New(cfg Config) *Server {
switch {
case cfg.Desk == nil:
panic("desk engine is required")
case cfg.Files == nil:
panic("attachment engine is required")
case cfg.People == nil:
panic("directory is required")
case cfg.Mailer == nil:
panic("mailer is required")
case cfg.StaffRole == "":
panic("staff role is required")
}
return &Server{cfg: cfg}
}
// Mount registers the guarded surface. Pass the auth guard (and any
// additional route middleware) as mws.
//
// Staff and customers share every route. The engine decides what each
// caller may do, so there is no second surface to keep in step with
// the first — and no chance of one of them forgetting a check.
func (s *Server) Mount(r *router.Router, mws ...router.Middleware) {
g := r.Group("", mws...)
g.HandleFunc(http.MethodGet, "/tags", s.tags)
g.HandleFunc(http.MethodGet, "/tickets", s.list)
g.HandleFunc(http.MethodPost, "/tickets", s.open)
g.HandleFunc(http.MethodGet, "/tickets/{id}", s.read)
g.HandleFunc(http.MethodPost, "/tickets/{id}/messages", s.reply)
g.HandleFunc(http.MethodPost, "/tickets/{id}/read", s.markRead)
g.HandleFunc(http.MethodPut,
"/tickets/{id}/subscription", s.subscribe)
g.HandleFunc(http.MethodPost, "/tickets/{id}/shares", s.share)
g.HandleFunc(http.MethodDelete,
"/tickets/{id}/shares/{user}", s.unshare)
g.HandleFunc(http.MethodPost, "/tickets/{id}/links", s.link)
g.HandleFunc(http.MethodDelete,
"/tickets/{id}/links/{other}", s.unlink)
g.HandleFunc(http.MethodPost,
"/tickets/{id}/attachments", s.upload)
g.HandleFunc(http.MethodPost,
"/tickets/{id}/attachments/{file}/confirm", s.confirm)
g.HandleFunc(http.MethodGet,
"/tickets/{id}/attachments/{file}", s.download)
// Staff actions. They live beside the rest because the engine —
// not the routing table — is what refuses a customer.
g.HandleFunc(http.MethodPut, "/tickets/{id}/status", s.setStatus)
g.HandleFunc(http.MethodPut,
"/tickets/{id}/priority", s.setPriority)
g.HandleFunc(http.MethodPut, "/tickets/{id}/tags", s.setTags)
g.HandleFunc(http.MethodPut, "/tickets/{id}/assignee", s.assign)
g.HandleFunc(http.MethodGet, "/agents", s.agents)
}
// MountPublic registers the routes that deliberately carry no
// session: somebody who wants the mail to stop is rarely in the mood
// to sign in first, and the token in the link is the authorization.
func (s *Server) MountPublic(r *router.Router) {
r.HandleFunc(http.MethodPost, "/unsubscribe", s.unsubscribe)
}
// actor resolves who is calling, and with what standing.
//
// Every surface here is delegated-only: a ticket belongs to a person,
// and a machine token names none. Staff standing comes from the
// token's roles, so losing the support role takes effect on the next
// request rather than whenever something is rewritten.
func (s *Server) actor(e *router.Exchange) (desk.Actor, error) {
claims := auth.Must(e)
id := claims.UserID()
if id == uuid.Nil() {
return desk.Actor{}, &router.Error{
Status: http.StatusForbidden,
Reason: router.ReasonValidationFailed,
Description: "the help desk is delegated-only",
}
}
return desk.Actor{
User: id,
Staff: claims.HasRole(s.cfg.StaffRole),
}, nil
}
// number reads a ticket number from the path. Anything that is not
// one answers as a missing ticket, since a number nobody could have
// is a ticket nobody has.
func number(e *router.Exchange, name string) (int64, error) {
id, err := strconv.ParseInt(e.Param(name), 10, 64)
if err != nil || id <= 0 {
return 0, &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "no such ticket",
}
}
return id, nil
}
// fail maps an engine error onto a status.
//
// Unreachable answers exactly as missing does, so ticket numbers —
// which are sequential and therefore guessable — cannot be probed for
// existence. A staff-only refusal is answered plainly, since it
// discloses nothing about which tickets there are.
func fail(err error) error {
switch {
case err == nil:
return nil
case errors.Is(err, desk.ErrForbidden):
return &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "no such ticket",
}
case errors.Is(err, desk.ErrStaffOnly):
return &router.Error{
Status: http.StatusForbidden,
Reason: router.ReasonValidationFailed,
Description: err.Error(),
}
case errors.Is(err, ticket.ErrClosed):
return &router.Error{
Status: http.StatusConflict,
Reason: router.ReasonValidationFailed,
Description: "this ticket is closed; open a new one",
}
case errors.Is(err, ticket.ErrTooManyOpen),
errors.Is(err, ticket.ErrTooManyMonthly):
return &router.Error{
Status: http.StatusTooManyRequests,
Reason: router.ReasonRateLimit,
Description: err.Error(),
}
case errors.Is(err, desk.ErrUnknownTag),
errors.Is(err, desk.ErrInvalid),
errors.Is(err, attach.ErrInvalid),
errors.Is(err, attach.ErrTooLarge),
errors.Is(err, attach.ErrUnsupportedType),
errors.Is(err, attach.ErrMissing):
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: err.Error(),
}
}
return err
}
// tags serves the deployment's tag vocabulary, so a client renders
// the choices rather than inventing them.
func (s *Server) tags(e *router.Exchange) error {
out := s.cfg.Tags
if out == nil {
out = []string{}
}
return e.JSON(http.StatusOK, map[string][]string{"tags": out})
}
// draft is the payload opening a ticket.
type draft struct {
Subject string `json:"subject"`
Body string `json:"message"`
Tags []string `json:"tags,omitzero"`
Meta jsontext.Value `json:"meta,omitzero"`
}
// Validate implements the [valid.Validatable] interface.
func (d *draft) Validate(v *valid.Validator) {
v.NotBlank("subject", d.Subject)
v.MaxLen("subject", d.Subject, MaxSubjectLength)
v.NotBlank("message", d.Body)
v.MaxLen("message", d.Body, MaxBodyLength)
v.MaxSize("tags", len(d.Tags), MaxTags)
v.MaxSize("meta", len(d.Meta), MaxMetaSize)
}
// open serves "POST /tickets".
func (s *Server) open(e *router.Exchange) error {
actor, err := s.actor(e)
if err != nil {
return err
}
var req draft
if err := e.BindJSON(&req); err != nil {
return err
}
tk, err := s.cfg.Desk.Open(e.Context(), actor, desk.Draft{
Subject: req.Subject,
Body: req.Body,
Tags: req.Tags,
Meta: req.Meta,
})
if err != nil {
return fail(err)
}
e.NoStore()
return e.JSON(http.StatusCreated, s.summary(
e.Context(), tk, map[int64]bool{}, nil,
))
}
// list serves "GET /tickets": the caller's own tickets, or — for
// staff — whatever the filters ask of the whole desk.
func (s *Server) list(e *router.Exchange) error {
actor, err := s.actor(e)
if err != nil {
return err
}
q := e.Query()
f := store.Filter{
Status: ticket.Status(q.Get("status")),
Priority: ticket.Priority(q.Get("priority")),
Tag: q.Get("tag"),
Text: q.Get("q"),
Unassigned: q.Get("assignee") == "none",
}
if q.Get("assignee") == "me" {
f.Assignee = actor.User
} else if raw := q.Get("assignee"); raw != "" && raw != "none" {
id, err := uuid.Parse(raw)
if err != nil {
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: "assignee must be an identifier, me, or none",
}
}
f.Assignee = id
}
if raw := q.Get("before"); raw != "" {
before, err := strconv.ParseInt(raw, 10, 64)
if err != nil || before < 0 {
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: "before must be a ticket number",
}
}
f.Before = before
}
if raw := q.Get("limit"); raw != "" {
limit, err := strconv.Atoi(raw)
if err != nil || limit < 1 {
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: "limit must be a positive integer",
}
}
f.Limit = limit
}
if f.Status != "" && !f.Status.Valid() {
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: "unknown status",
}
}
tickets, err := s.cfg.Desk.List(e.Context(), actor, f)
if err != nil {
return fail(err)
}
unread, err := s.cfg.Desk.Unread(e.Context(), actor, tickets)
if err != nil {
return fail(err)
}
who, err := s.names(e.Context(), participantsOf(tickets))
if err != nil {
return err
}
out := make([]summary, 0, len(tickets))
for _, tk := range tickets {
out = append(out, s.summary(e.Context(), tk, unread, who))
}
e.NoStore()
return e.JSON(http.StatusOK, map[string]any{"tickets": out})
}
// read serves "GET /tickets/{id}".
func (s *Server) read(e *router.Exchange) error {
actor, err := s.actor(e)
if err != nil {
return err
}
id, err := number(e, "id")
if err != nil {
return err
}
th, err := s.cfg.Desk.Thread(e.Context(), actor, id)
if err != nil {
return fail(err)
}
view, err := s.thread(e.Context(), th)
if err != nil {
return err
}
e.NoStore()
return e.JSON(http.StatusOK, view)
}
// message is the payload of a reply.
type message struct {
Body string `json:"message"`
Visibility string `json:"visibility,omitzero"`
// Attachments are the identifiers of uploads to hang on this
// message, authorized earlier and confirmed with it.
Attachments []int64 `json:"attachments,omitzero"`
}
// Validate implements the [valid.Validatable] interface.
func (m *message) Validate(v *valid.Validator) {
v.NotBlank("message", m.Body)
v.MaxLen("message", m.Body, MaxBodyLength)
v.MaxSize("attachments", len(m.Attachments), MaxTags)
if m.Visibility != "" {
v.Whitelist("visibility", ticket.Visibility(m.Visibility),
ticket.VisibilityPublic, ticket.VisibilityInternal)
}
}
// reply serves "POST /tickets/{id}/messages".
func (s *Server) reply(e *router.Exchange) error {
actor, err := s.actor(e)
if err != nil {
return err
}
id, err := number(e, "id")
if err != nil {
return err
}
var req message
if err := e.BindJSON(&req); err != nil {
return err
}
visibility := ticket.Visibility(req.Visibility)
if visibility == "" {
visibility = ticket.VisibilityPublic
}
m, err := s.cfg.Desk.Reply(
e.Context(), actor, id, visibility, req.Body,
)
if err != nil {
return fail(err)
}
// The files were uploaded before the message existed; hanging
// them on it is what makes them visible, and what gives them the
// message's own visibility.
for _, file := range req.Attachments {
if _, err := s.cfg.Files.Confirm(
e.Context(), file, m.ID,
); err != nil {
return fail(err)
}
}
e.NoStore()
return e.JSON(http.StatusCreated, map[string]any{
"id": m.ID, "at": m.CreatedAt,
})
}
// markRead serves "POST /tickets/{id}/read".
func (s *Server) markRead(e *router.Exchange) error {
actor, err := s.actor(e)
if err != nil {
return err
}
id, err := number(e, "id")
if err != nil {
return err
}
if err := s.cfg.Desk.Read(e.Context(), actor, id); err != nil {
return fail(err)
}
e.Status(http.StatusNoContent)
return nil
}
// subscription is the payload of a subscription change.
type subscription struct {
Subscribed bool `json:"subscribed"`
}
// subscribe serves "PUT /tickets/{id}/subscription".
func (s *Server) subscribe(e *router.Exchange) error {
actor, err := s.actor(e)
if err != nil {
return err
}
id, err := number(e, "id")
if err != nil {
return err
}
var req subscription
if err := e.BindJSON(&req); err != nil {
return err
}
if err := s.cfg.Desk.Subscribe(
e.Context(), actor, id, req.Subscribed,
); err != nil {
return fail(err)
}
e.Status(http.StatusNoContent)
return nil
}
// invitation names who to share a ticket with.
type invitation struct {
Email string `json:"email"`
}
// Validate implements the [valid.Validatable] interface.
func (i *invitation) Validate(v *valid.Validator) {
v.NotEmpty("email", i.Email)
v.Email("email", i.Email)
}
// share serves "POST /tickets/{id}/shares".
//
// The address is resolved through the identity service and refused
// where it holds no account: this service never stores an address,
// and a ticket shared with a stranger's mailbox would be a ticket
// nobody could authenticate to read.
func (s *Server) share(e *router.Exchange) error {
actor, err := s.actor(e)
if err != nil {
return err
}
id, err := number(e, "id")
if err != nil {
return err
}
var req invitation
if err := e.BindJSON(&req); err != nil {
return err
}
person, found, err := s.cfg.People.Lookup(e.Context(), req.Email)
if err != nil {
return err
}
if !found {
return &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "no account holds that address",
}
}
if err := s.cfg.Desk.Share(
e.Context(), actor, id, person.ID,
); err != nil {
return fail(err)
}
e.NoStore()
return e.JSON(http.StatusOK, person5(person))
}
// unshare serves "DELETE /tickets/{id}/shares/{user}".
func (s *Server) unshare(e *router.Exchange) error {
actor, err := s.actor(e)
if err != nil {
return err
}
id, err := number(e, "id")
if err != nil {
return err
}
user, err := uuid.Parse(e.Param("user"))
if err != nil {
return &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "no such participant",
}
}
if err := s.cfg.Desk.Unshare(
e.Context(), actor, id, user,
); err != nil {
return fail(err)
}
e.Status(http.StatusNoContent)
return nil
}
// relation is the payload linking two tickets.
type relation struct {
Ticket int64 `json:"ticket"`
Kind string `json:"kind,omitzero"`
}
// Validate implements the [valid.Validatable] interface.
func (r *relation) Validate(v *valid.Validator) {
v.Min("ticket", r.Ticket, 1)
if r.Kind != "" {
v.Whitelist("kind", ticket.LinkKind(r.Kind),
ticket.LinkRelated, ticket.LinkDuplicate)
}
}
// link serves "POST /tickets/{id}/links".
func (s *Server) link(e *router.Exchange) error {
actor, err := s.actor(e)
if err != nil {
return err
}
id, err := number(e, "id")
if err != nil {
return err
}
var req relation
if err := e.BindJSON(&req); err != nil {
return err
}
kind := ticket.LinkKind(req.Kind)
if kind == "" {
kind = ticket.LinkRelated
}
if err := s.cfg.Desk.Link(
e.Context(), actor, id, req.Ticket, kind,
); err != nil {
return fail(err)
}
e.Status(http.StatusNoContent)
return nil
}
// unlink serves "DELETE /tickets/{id}/links/{other}".
func (s *Server) unlink(e *router.Exchange) error {
actor, err := s.actor(e)
if err != nil {
return err
}
id, err := number(e, "id")
if err != nil {
return err
}
other, err := number(e, "other")
if err != nil {
return err
}
if err := s.cfg.Desk.Unlink(
e.Context(), actor, id, other,
); err != nil {
return fail(err)
}
e.Status(http.StatusNoContent)
return nil
}
// status, priority, tags, and assignee are the staff judgments.
type (
statusChange struct {
Status string `json:"status"`
}
priorityChange struct {
Priority string `json:"priority"`
}
tagChange struct {
Tags []string `json:"tags"`
}
assigneeChange struct {
// Assignee is who takes the ticket; the zero identifier
// returns it to the pending queue.
Assignee uuid.UUID `json:"assignee,omitzero"`
}
)
// setStatus serves "PUT /tickets/{id}/status".
func (s *Server) setStatus(e *router.Exchange) error {
actor, id, err := s.target(e)
if err != nil {
return err
}
var req statusChange
if err := e.BindJSON(&req); err != nil {
return err
}
return fail(s.cfg.Desk.SetStatus(
e.Context(), actor, id, ticket.Status(req.Status),
))
}
// setPriority serves "PUT /tickets/{id}/priority".
func (s *Server) setPriority(e *router.Exchange) error {
actor, id, err := s.target(e)
if err != nil {
return err
}
var req priorityChange
if err := e.BindJSON(&req); err != nil {
return err
}
return fail(s.cfg.Desk.SetPriority(
e.Context(), actor, id, ticket.Priority(req.Priority),
))
}
// setTags serves "PUT /tickets/{id}/tags".
func (s *Server) setTags(e *router.Exchange) error {
actor, id, err := s.target(e)
if err != nil {
return err
}
var req tagChange
if err := e.BindJSON(&req); err != nil {
return err
}
if len(req.Tags) > MaxTags {
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: "too many tags",
}
}
return fail(s.cfg.Desk.SetTags(e.Context(), actor, id, req.Tags))
}
// assign serves "PUT /tickets/{id}/assignee".
func (s *Server) assign(e *router.Exchange) error {
actor, id, err := s.target(e)
if err != nil {
return err
}
var req assigneeChange
if err := e.BindJSON(&req); err != nil {
return err
}
return fail(s.cfg.Desk.Assign(
e.Context(), actor, id, req.Assignee,
))
}
// agents serves "GET /agents": who a ticket may be assigned to.
func (s *Server) agents(e *router.Exchange) error {
actor, err := s.actor(e)
if err != nil {
return err
}
if !actor.Staff {
return fail(desk.ErrStaffOnly)
}
staff, err := s.cfg.People.Staff(e.Context(), s.cfg.StaffRole)
if err != nil {
return err
}
out := make([]named, 0, len(staff))
for _, p := range staff {
out = append(out, person5(p))
}
e.NoStore()
return e.JSON(http.StatusOK, map[string]any{"agents": out})
}
// unsubscribeRequest carries the token from the mail.
type unsubscribeRequest struct {
Token string `json:"token"`
}
// unsubscribe serves "POST /unsubscribe", which carries no session.
//
// Every refusal answers alike: at this surface the caller is whoever
// holds the link, and the difference between a forged token and an
// expired one is not theirs to learn.
func (s *Server) unsubscribe(e *router.Exchange) error {
var req unsubscribeRequest
if err := e.BindJSON(&req); err != nil {
return err
}
if req.Token == "" {
req.Token = e.Query().Get("t")
}
id, user, err := s.cfg.Mailer.Redeem(req.Token)
if err != nil {
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: "this link is no longer valid",
}
}
// The token names its own subject, so the change is made on their
// behalf without a session to prove who is clicking.
if err := s.cfg.Desk.Subscribe(
e.Context(), desk.Actor{User: user}, id, false,
); err != nil {
return fail(err)
}
e.Status(http.StatusNoContent)
return nil
}
// target resolves the actor and ticket a staff action names.
func (s *Server) target(
e *router.Exchange,
) (desk.Actor, int64, error) {
actor, err := s.actor(e)
if err != nil {
return desk.Actor{}, 0, err
}
id, err := number(e, "id")
if err != nil {
return desk.Actor{}, 0, err
}
return actor, id, nil
}
// participantsOf collects the identifiers a listing needs names for.
func participantsOf(tickets []ticket.Ticket) []uuid.UUID {
out := make([]uuid.UUID, 0, len(tickets)*2)
for _, tk := range tickets {
out = append(out, tk.Creator)
if tk.Assignee != uuid.Nil() {
out = append(out, tk.Assignee)
}
}
return out
}
// names resolves identifiers to the people behind them, in one
// question rather than one per row.
func (s *Server) names(
ctx context.Context,
ids []uuid.UUID,
) (map[uuid.UUID]identity.Person, error) {
ids = slices.Compact(slices.SortedFunc(
slices.Values(ids),
func(a, b uuid.UUID) int { return a.Compare(b) },
))
found, err := s.cfg.People.ResolveAll(ctx, ids)
if err != nil {
return nil, &router.Error{
Status: http.StatusBadGateway,
Reason: router.ReasonServerError,
Description: "the directory could not be reached",
Cause: err,
}
}
return found, nil
}
// named is how a person appears on a ticket: who they are, never how
// to reach them. Addresses are the identity service's to hand out,
// and a thread is not the place to collect them.
type named struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
}
// person5 renders a resolved person for the wire.
func person5(p identity.Person) named {
return named{ID: p.ID, Name: p.Salutation()}
}
// person renders an identifier, filling in the name where the
// directory knew it.
func person(
id uuid.UUID,
who map[uuid.UUID]identity.Person,
) *named {
if id == uuid.Nil() {
return nil
}
if p, ok := who[id]; ok {
out := person5(p)
return &out
}
return &named{ID: id}
}
// summary is how a ticket appears in a listing.
type summary struct {
ID int64 `json:"id"`
Subject string `json:"subject"`
Status string `json:"status"`
Priority string `json:"priority"`
Tags []string `json:"tags"`
Creator *named `json:"creator,omitzero"`
Assignee *named `json:"assignee,omitzero"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Unread bool `json:"unread,omitzero"`
}
// summary renders one ticket for a listing.
func (*Server) summary(
_ context.Context,
tk ticket.Ticket,
unread map[int64]bool,
who map[uuid.UUID]identity.Person,
) summary {
tags := tk.Tags
if tags == nil {
tags = []string{}
}
return summary{
ID: tk.ID,
Subject: tk.Subject,
Status: string(tk.Status),
Priority: string(tk.Priority),
Tags: tags,
Creator: person(tk.Creator, who),
Assignee: person(tk.Assignee, who),
CreatedAt: tk.CreatedAt,
UpdatedAt: tk.UpdatedAt,
Unread: unread[tk.ID],
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package api
import (
"context"
"encoding/json/jsontext"
"net/http"
"slices"
"time"
"uuid"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/eco/hds/attach"
"github.com/deep-rent/nexus/eco/hds/desk"
"github.com/deep-rent/nexus/eco/hds/ticket"
"github.com/deep-rent/nexus/net/router"
)
// The kinds an entry in a thread can be.
const (
// EntryMessage is something somebody wrote.
EntryMessage = "message"
// EntryEvent is something that happened.
EntryEvent = "event"
)
// entry is one item in a thread: a message or an event.
//
// They travel as one ordered stream rather than two lists, because
// that is what a reader sees — "she wrote this, then it was assigned
// to him, then he answered" — and reconstructing that order in every
// client is how two clients end up telling different stories.
type entry struct {
// Type is [EntryMessage] or [EntryEvent].
Type string `json:"type"`
// ID identifies the message or event.
ID int64 `json:"id"`
// At is when it happened.
At time.Time `json:"at"`
// Actor is who did it, absent when the service itself did.
Actor *named `json:"actor,omitzero"`
// Staff reports whether the author wrote as support.
Staff bool `json:"staff,omitzero"`
// Internal marks what only staff can see, so a client can render
// it as the aside it is.
Internal bool `json:"internal,omitzero"`
// Body is the message text, verbatim as it was written. Escaping
// belongs to whoever renders it.
Body string `json:"body,omitzero"`
// Kind, From, and To describe an event.
Kind string `json:"kind,omitzero"`
From string `json:"from,omitzero"`
To string `json:"to,omitzero"`
// Attachments are the files sent with a message.
Attachments []file `json:"attachments,omitzero"`
}
// file is one attachment on a message.
type file struct {
ID int64 `json:"id"`
Name string `json:"name"`
Size int64 `json:"size"`
Type string `json:"type"`
}
// relatedTicket is one link on a thread.
type relatedTicket struct {
Ticket int64 `json:"ticket"`
Kind string `json:"kind"`
}
// member is one person on a ticket.
type member struct {
named
// Role is how they came to be here.
Role string `json:"role"`
// Subscribed reports whether they are mailed about it. It is only
// ever true of the caller's own row from their own point of view;
// a client renders it as their own switch.
Subscribed bool `json:"subscribed,omitzero"`
}
// threadView is everything a viewer may see of one ticket.
type threadView struct {
summary
// Meta is the context the app attached when the ticket was
// opened.
Meta jsontext.Value `json:"meta,omitzero"`
// Entries is the conversation and its history, in order.
Entries []entry `json:"entries"`
// Participants is who is on the ticket.
Participants []member `json:"participants"`
// Links are the related tickets.
Links []relatedTicket `json:"links,omitzero"`
// Subscribed reports whether the caller is mailed about it.
Subscribed bool `json:"subscribed"`
}
// thread renders a thread for the wire, resolving every name in one
// question to the directory.
func (s *Server) thread(
ctx context.Context,
th desk.Thread,
) (threadView, error) {
ids := []uuid.UUID{th.Ticket.Creator}
if th.Ticket.Assignee != uuid.Nil() {
ids = append(ids, th.Ticket.Assignee)
}
for _, m := range th.Messages {
ids = append(ids, m.Author)
}
for _, ev := range th.Events {
if ev.Actor != uuid.Nil() {
ids = append(ids, ev.Actor)
}
}
for _, p := range th.Participants {
ids = append(ids, p.User)
}
who, err := s.names(ctx, ids)
if err != nil {
return threadView{}, err
}
// The files are grouped by the message they travelled with, which
// is also what decides who may see them.
files := map[int64][]file{}
for _, a := range th.Attachments {
files[a.Message] = append(files[a.Message], file{
ID: a.ID, Name: a.Name, Size: a.Size, Type: a.Type,
})
}
entries := make([]entry, 0, len(th.Messages)+len(th.Events))
for _, m := range th.Messages {
entries = append(entries, entry{
Type: EntryMessage,
ID: m.ID,
At: m.CreatedAt,
Actor: person(m.Author, who),
Staff: m.Staff,
Internal: m.Visibility == ticket.VisibilityInternal,
Body: m.Body,
Attachments: files[m.ID],
})
}
for _, ev := range th.Events {
e := entry{
Type: EntryEvent,
ID: ev.ID,
At: ev.CreatedAt,
Actor: person(ev.Actor, who),
Internal: ev.Internal,
Kind: string(ev.Kind),
From: ev.From,
To: ev.To,
}
// An event naming a person reads better with their name than
// with their identifier.
if p, ok := who[parse(ev.To)]; ok {
e.To = p.Salutation()
}
if p, ok := who[parse(ev.From)]; ok {
e.From = p.Salutation()
}
entries = append(entries, e)
}
slices.SortStableFunc(entries, func(a, b entry) int {
if c := a.At.Compare(b.At); c != 0 {
return c
}
// Within one instant, what happened comes before what was
// said about it: a ticket is assigned and then answered.
if a.Type != b.Type {
if a.Type == EntryEvent {
return -1
}
return 1
}
return int(a.ID - b.ID)
})
members := make([]member, 0, len(th.Participants))
for _, p := range th.Participants {
m := member{Role: string(p.Role)}
if named := person(p.User, who); named != nil {
m.named = *named
}
members = append(members, m)
}
links := make([]relatedTicket, 0, len(th.Links))
for _, l := range th.Links {
links = append(links, relatedTicket{
Ticket: l.Other, Kind: string(l.Kind),
})
}
return threadView{
summary: s.summary(ctx, th.Ticket,
map[int64]bool{th.Ticket.ID: th.Unread}, who),
Meta: th.Ticket.Meta,
Entries: entries,
Participants: members,
Links: links,
Subscribed: th.Seat.Subscribed,
}, nil
}
// parse reads an identifier an event recorded, if it is one.
func parse(s string) uuid.UUID {
id, err := uuid.Parse(s)
if err != nil {
return uuid.Nil()
}
return id
}
// upload is what a client announces before uploading a file.
type upload struct {
Name string `json:"name"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
Type string `json:"type"`
}
// Validate implements the [valid.Validatable] interface.
func (u *upload) Validate(v *valid.Validator) {
v.NotBlank("name", u.Name)
v.MaxLen("name", u.Name, 255)
v.Min("size", u.Size, 1)
v.Len("sha256", u.SHA256, 64)
v.NotBlank("type", u.Type)
}
// grant is where to put the bytes.
type grant struct {
ID int64 `json:"id"`
URL string `json:"url"`
Method string `json:"method"`
Headers map[string]string `json:"headers"`
}
// upload serves "POST /tickets/{id}/attachments": it authorizes an
// upload the client performs itself.
//
// Reaching the ticket is checked first, so an upload cannot be
// authorized against somebody else's ticket, and the bytes never
// touch this service either way.
func (s *Server) upload(e *router.Exchange) error {
actor, err := s.actor(e)
if err != nil {
return err
}
id, err := number(e, "id")
if err != nil {
return err
}
if _, err := s.cfg.Desk.Thread(e.Context(), actor, id); err != nil {
return fail(err)
}
var req upload
if err := e.BindJSON(&req); err != nil {
return err
}
g, err := s.cfg.Files.Authorize(
e.Context(), id, actor.User, attach.Upload{
Name: req.Name,
Size: req.Size,
SHA256: req.SHA256,
Type: req.Type,
},
)
if err != nil {
return fail(err)
}
e.NoStore()
return e.JSON(http.StatusCreated, grant{
ID: g.Attachment.ID,
URL: g.URL,
Method: http.MethodPut,
Headers: g.Headers,
})
}
// confirmRequest names the message an upload belongs to.
type confirmRequest struct {
Message int64 `json:"message"`
}
// Validate implements the [valid.Validatable] interface.
func (c *confirmRequest) Validate(v *valid.Validator) {
v.Min("message", c.Message, 1)
}
// confirm serves "POST /tickets/{id}/attachments/{file}/confirm", for
// a client that uploaded after writing its message rather than
// before.
func (s *Server) confirm(e *router.Exchange) error {
actor, err := s.actor(e)
if err != nil {
return err
}
id, err := number(e, "id")
if err != nil {
return err
}
file, err := number(e, "file")
if err != nil {
return err
}
if _, err := s.cfg.Desk.Thread(e.Context(), actor, id); err != nil {
return fail(err)
}
var req confirmRequest
if err := e.BindJSON(&req); err != nil {
return err
}
if _, err := s.cfg.Files.Confirm(
e.Context(), file, req.Message,
); err != nil {
return fail(err)
}
e.Status(http.StatusNoContent)
return nil
}
// download serves "GET /tickets/{id}/attachments/{file}": a redirect
// to a short-lived link.
//
// The attachment must be one the viewer may see, which is decided by
// the visibility of the message it hangs on — so the thread is read
// through the engine first and the file looked for in what came
// back. A file on an internal note simply is not there for a
// customer.
func (s *Server) download(e *router.Exchange) error {
actor, err := s.actor(e)
if err != nil {
return err
}
id, err := number(e, "id")
if err != nil {
return err
}
want, err := number(e, "file")
if err != nil {
return err
}
th, err := s.cfg.Desk.Thread(e.Context(), actor, id)
if err != nil {
return fail(err)
}
i := slices.IndexFunc(th.Attachments, func(a ticket.Attachment) bool {
return a.ID == want
})
if i < 0 {
return &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "no such attachment",
}
}
url, err := s.cfg.Files.Download(th.Attachments[i])
if err != nil {
return err
}
e.NoStore()
e.SetHeader("Location", url)
e.Status(http.StatusFound)
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package attach
import (
"context"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"net/http"
"path"
"slices"
"strconv"
"strings"
"time"
"uuid"
"github.com/jackc/pgx/v5"
"github.com/deep-rent/nexus/eco/hds/store"
"github.com/deep-rent/nexus/eco/hds/ticket"
"github.com/deep-rent/nexus/net/header"
"github.com/deep-rent/nexus/net/s3"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/sys/log"
)
// Defaults bounding what may be attached.
const (
// DefaultMaxSize caps one file at 25 MiB — enough for a log
// bundle or a screen recording, and far short of what makes a
// support bucket a file share.
DefaultMaxSize = 25 << 20
// DefaultUploadWindow is how long an upload grant stays valid.
DefaultUploadWindow = 15 * time.Minute
// DefaultDownloadWindow is how long a download link stays valid.
// It is short because the link is a bearer credential: whoever
// holds it reads the file, and it travels through whatever the
// browser does with URLs.
DefaultDownloadWindow = 2 * time.Minute
// DefaultOrphanAge is how long an unconfirmed upload is kept
// before its object is swept. It sits well beyond the upload
// window, so a slow client is never swept mid-upload.
DefaultOrphanAge = time.Hour
// SweepLimit bounds one orphan pass.
SweepLimit = 200
)
// DefaultTypes is what a support desk actually needs to receive:
// screenshots, screen recordings, logs, and the archives people put
// them in. Anything else is refused, because an attachment surface
// that accepts everything becomes a way to host anything.
var DefaultTypes = []string{
"image/png", "image/jpeg", "image/gif", "image/webp", "image/heic",
"video/mp4", "video/quicktime",
"text/plain", "text/csv", "application/json",
"application/pdf", "application/zip", "application/gzip",
}
// Errors the engine reports to its callers.
var (
// ErrTooLarge reports a file above the configured cap.
ErrTooLarge = errors.New("attachment is too large")
// ErrUnsupportedType reports a content type outside the allowed
// set.
ErrUnsupportedType = errors.New("attachment type is not accepted")
// ErrInvalid reports an announcement that cannot be honored — a
// missing digest, an empty name.
ErrInvalid = errors.New("invalid attachment")
// ErrMissing reports a confirmation for an upload that never
// landed, or landed as something other than what was announced.
ErrMissing = errors.New("attachment was not uploaded")
)
// Option configures an [Engine].
type Option func(*Engine)
// WithMaxSize caps one attachment.
func WithMaxSize(n int64) Option {
return func(e *Engine) {
if n > 0 {
e.max = n
}
}
}
// WithTypes replaces the accepted content types.
func WithTypes(types ...string) Option {
return func(e *Engine) {
if len(types) > 0 {
e.types = slices.Clone(types)
}
}
}
// WithLogger sets the logger narrating sweeps. A nil logger is
// ignored.
func WithLogger(logger *log.Logger) Option {
return func(e *Engine) {
if logger != nil {
e.logger = logger
}
}
}
// WithClock injects the time source. A nil clock is ignored.
func WithClock(now clock.Clock) Option {
return func(e *Engine) {
if now != nil {
e.now = now
}
}
}
// Engine authorizes uploads, confirms what landed, and hands out
// downloads. Content never passes through it.
type Engine struct {
db *store.Store
bucket *s3.Bucket
prefix string
max int64
types []string
logger *log.Logger
now clock.Clock
}
// New assembles the attachment engine over a bucket. The prefix
// namespaces this service's objects within it.
func New(
db *store.Store,
bucket *s3.Bucket,
prefix string,
opts ...Option,
) *Engine {
if db == nil {
panic("store is required")
}
if bucket == nil {
panic("bucket is required")
}
e := &Engine{
db: db,
bucket: bucket,
prefix: strings.Trim(prefix, "/"),
max: DefaultMaxSize,
types: slices.Clone(DefaultTypes),
logger: log.Discard(),
now: clock.System,
}
for _, opt := range opts {
opt(e)
}
return e
}
// Upload is what a client announces before uploading.
type Upload struct {
// Name is the file name, kept for display and for the download's
// filename. It never becomes part of the object key.
Name string
// Size is the length in bytes the client will upload.
Size int64
// SHA256 is the content digest in hex, which the grant pins and
// the provider verifies while receiving the bytes.
SHA256 string
// Type is the content type.
Type string
}
// Grant is an authorized upload: where to put the bytes, and under
// which headers.
type Grant struct {
// Attachment is the row the upload was recorded as.
Attachment ticket.Attachment
// URL is the presigned destination.
URL string
// Headers are what the client must send with the upload; the
// grant is pinned to them, so anything else is refused by the
// provider rather than by this service.
Headers map[string]string
}
// Authorize records an intended upload and mints a grant for it. The
// caller has already established that the actor may write to the
// ticket.
//
// The digest and length are signed into the grant, so the provider
// hashes the arriving bytes itself and refuses anything that does not
// match what was announced. A client cannot upload one file having
// announced another.
func (e *Engine) Authorize(
ctx context.Context,
id int64,
actor uuid.UUID,
up Upload,
) (Grant, error) {
if up.Name == "" {
return Grant{}, fmt.Errorf("%w: no file name", ErrInvalid)
}
if up.Size <= 0 {
return Grant{}, fmt.Errorf("%w: no length", ErrInvalid)
}
if up.Size > e.max {
return Grant{}, fmt.Errorf(
"%w: %d bytes exceeds the %d allowed",
ErrTooLarge, up.Size, e.max,
)
}
if !slices.Contains(e.types, up.Type) {
return Grant{}, fmt.Errorf(
"%w: %q", ErrUnsupportedType, up.Type,
)
}
raw, err := hex.DecodeString(up.SHA256)
if err != nil || len(raw) != 32 {
return Grant{}, fmt.Errorf(
"%w: sha256 must be 64 hex characters", ErrInvalid,
)
}
// The key carries no user input: a name is display text, and
// display text in a key is how a bucket ends up with paths
// nobody meant to create.
a := ticket.Attachment{
Ticket: id,
Key: e.key(id),
Name: up.Name,
Size: up.Size,
SHA256: up.SHA256,
Type: up.Type,
UploadedBy: actor,
CreatedAt: e.now().UTC(),
}
if err := e.db.Exec(ctx, func(
ctx context.Context, tx pgx.Tx,
) error {
return e.db.AddAttachment(ctx, tx, &a)
}); err != nil {
return Grant{}, err
}
// The disposition is signed into the upload, so the object
// carries it from the moment it lands: every link to it forces a
// download and names the file, whoever mints the link and
// whenever. Deciding that at download time would leave the same
// object one forgotten parameter away from rendering inline.
headers := map[string]string{
s3.HeaderChecksumSHA256: base64.StdEncoding.EncodeToString(raw),
"Content-Length": strconv.FormatInt(up.Size, 10),
"Content-Type": "application/octet-stream",
"Content-Disposition": header.Disposition(up.Name),
}
signed := http.Header{}
for k, v := range headers {
signed.Set(k, v)
}
url, err := e.bucket.Presign(
http.MethodPut, a.Key, DefaultUploadWindow, signed,
)
if err != nil {
return Grant{}, fmt.Errorf("failed to authorize an upload: %w", err)
}
return Grant{Attachment: a, URL: url, Headers: headers}, nil
}
// Confirm verifies that an announced upload actually landed, and
// hangs it on the message it travelled with.
//
// A confirmation is a claim; the HEAD is the proof. Size and digest
// are checked against what was announced, so a client that uploaded
// something else — or nothing at all — cannot make this service show
// it as a file.
func (e *Engine) Confirm(
ctx context.Context,
attachment, message int64,
) (ticket.Attachment, error) {
var a ticket.Attachment
err := e.db.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
var err error
a, err = e.db.Attachment(ctx, tx, attachment)
return err
})
if err != nil {
return ticket.Attachment{}, err
}
obj, err := e.bucket.Head(ctx, a.Key)
if err != nil {
return ticket.Attachment{}, fmt.Errorf(
"failed to verify an upload: %w", err,
)
}
if obj == nil {
return ticket.Attachment{}, ErrMissing
}
if obj.Size != a.Size {
return ticket.Attachment{}, fmt.Errorf(
"%w: landed %d bytes, announced %d",
ErrMissing, obj.Size, a.Size,
)
}
// The provider attests the digest it computed while receiving the
// bytes. Where it records none, the grant's own checksum header
// already refused a mismatch, so the absence is not a hole.
if obj.SHA256 != "" && !strings.EqualFold(obj.SHA256, a.SHA256) {
return ticket.Attachment{}, fmt.Errorf(
"%w: the stored digest differs from the announced one",
ErrMissing,
)
}
err = e.db.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
ok, err := e.db.ConfirmAttachment(ctx, tx, attachment, message)
if err != nil || ok {
return err
}
return fmt.Errorf("%w: already confirmed", ErrInvalid)
})
if err != nil {
return ticket.Attachment{}, err
}
a.Stored = true
a.Message = message
return a, nil
}
// Download mints a short-lived link to one attachment. The caller has
// already established that the actor may read the message it hangs
// on.
//
// The link needs no instructions about how to serve the file: the
// object was stored as an octet-stream carrying its own
// Content-Disposition, so it downloads rather than renders wherever
// the link is opened. An attachment is a stranger's file, and a
// browser that rendered it would run whatever it contains under this
// deployment's origin.
func (e *Engine) Download(a ticket.Attachment) (string, error) {
url, err := e.bucket.Presign(
http.MethodGet, a.Key, DefaultDownloadWindow, nil,
)
if err != nil {
return "", fmt.Errorf("failed to authorize a download: %w", err)
}
return url, nil
}
// Sweep removes uploads that were authorized and never confirmed:
// their objects from the bucket, then their rows. It runs objects
// first, so a failure between the two leaves a row pointing at
// nothing rather than an object nothing points at.
func (e *Engine) Sweep(ctx context.Context, after time.Duration) {
if after <= 0 {
after = DefaultOrphanAge
}
var orphans []ticket.Attachment
err := e.db.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
var err error
orphans, err = e.db.Orphans(
ctx, tx, e.now().Add(-after), SweepLimit,
)
return err
})
if err != nil {
e.logger.Error(ctx, "Orphan worklist failed", log.Error(err))
return
}
if len(orphans) == 0 {
return
}
keys := make([]string, len(orphans))
ids := make([]int64, len(orphans))
for i, a := range orphans {
keys[i], ids[i] = a.Key, a.ID
}
if _, err := e.bucket.Delete(ctx, keys); err != nil {
e.logger.Error(ctx, "Failed to sweep orphaned objects",
log.Error(err))
return
}
if err := e.db.Exec(ctx, func(
ctx context.Context, tx pgx.Tx,
) error {
return e.db.DropAttachments(ctx, tx, ids)
}); err != nil {
e.logger.Error(ctx, "Failed to drop orphaned uploads",
log.Error(err))
return
}
e.logger.Info(ctx, "Swept uploads nobody confirmed",
log.Int("count", len(orphans)))
}
// key mints an object key for one ticket's attachment. The random
// half keeps two uploads to one ticket apart without consulting
// anything.
func (e *Engine) key(id int64) string {
return path.Join(
e.prefix,
"tickets",
strconv.FormatInt(id, 10),
uuid.NewV7().String(),
)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package config
import (
"errors"
"fmt"
"strings"
"time"
"github.com/deep-rent/nexus/eco/identity"
"github.com/deep-rent/nexus/eco/notify"
"github.com/deep-rent/nexus/sys/boot"
"github.com/deep-rent/nexus/sys/env"
)
// Prefix namespaces every environment variable of the service.
const Prefix = "HDS_"
// DefaultStaffRole is the IAM role granting support standing when the
// environment names none.
const DefaultStaffRole = "support"
// Floors on the tunable windows, below which a setting stops meaning
// what it says.
const (
// MinReopenWindow is the shortest reopen window worth having: a
// customer who reads their mail the next morning must still be
// able to continue their own ticket.
MinReopenWindow = 24 * time.Hour
// MinCloseAfter is the shortest silence that may close a ticket.
// Anything less would close tickets over a weekend.
MinCloseAfter = 72 * time.Hour
)
// Config declares the deployment configuration of the help desk.
// Bind it from the environment with [Load].
type Config struct {
// Core is the listen addresses, CORS origins, log level, and
// connection timeouts every service carries. The public listener
// carries the API, the contact form, and the unsubscribe endpoint.
boot.Core `env:",inline"`
// Tags is the closed vocabulary tickets may be grouped by.
// Free-text tags are how a desk's grouping turns to mush, so
// anything outside this list is refused.
Tags []string
// Limits caps how many tickets one person may hold.
Limits Limits `env:",prefix:LIMIT_"`
// ReopenWindow is how long after closing a customer's reply still
// continues the same ticket rather than needing a new one.
ReopenWindow time.Duration `env:",default:336h"`
// CloseAfter is how long a ticket may wait on its customer before
// it closes itself. Zero leaves abandoned tickets open forever,
// which makes every backlog number the desk reports untrue.
CloseAfter time.Duration `env:",default:336h"`
// Database configures the PostgreSQL connection holding the desk.
// Required: a desk without one has nowhere to keep a ticket.
Database boot.Database `env:",prefix:DATABASE_"`
// Auth declares the identity provider whose tokens the API
// accepts.
Auth Auth `env:",prefix:AUTH_"`
// Directory declares where people are read from — the same identity
// service, under this service's own machine credentials. Required:
// a desk that cannot resolve a name cannot render a thread, so the
// assembly refuses a deployment without one.
Directory identity.Config `env:",prefix:DIRECTORY_"`
// Storage configures the bucket attachments live in. Required:
// a support desk without attachments is one that asks customers
// to describe their screenshots.
Storage Storage `env:",prefix:STORAGE_"`
// Mail configures the provider notifications and relayed contact
// messages travel through.
Mail Mail `env:",prefix:MAIL_"`
// Contact configures the website's contact form. Empty leaves it
// unmounted.
Contact Contact `env:",prefix:CONTACT_"`
// Notify configures the notification service the desk asks for a
// push, alongside the mail. Empty leaves the desk mailing and
// nothing else, which is how it worked before push existed.
Notify Notify `env:",prefix:NOTIFY_"`
// Hook configures the webhook engine announcing ticket events.
Hook boot.Sender `env:",prefix:HOOK_"`
// Intake configures the webhook receiver through which the
// identity service announces deleted accounts, so nobody who
// asked to be forgotten stays named on a ticket.
Intake boot.Intake `env:",prefix:INTAKE_"`
}
// Notify configures the notification service the desk asks for a push.
//
// It is the same occasion as the notification mail and rides the same
// job, so a burst of edits collapses into one of each rather than one
// mail and six pushes. The desk supplies a category and variables and
// never any text: what reaches a lock screen is rendered by the
// notification service from its own catalog, which is what keeps a
// ticket subject off a stranger's phone.
type Notify struct {
notify.Config `env:",inline"`
// Category names what the notification service renders, as its
// catalog declares it.
Category string `env:",default:'ticket.answered'"`
}
// Enabled reports whether push notifications are configured.
func (c Notify) Enabled() bool {
return c.Config.Enabled() && c.Category != ""
}
// Limits caps how many tickets one person may hold; see
// [ticket.Limits]. Zero leaves a dimension uncapped.
//
// [ticket.Limits]: github.com/deep-rent/nexus/eco/hds/ticket#Limits
type Limits struct {
// Open caps simultaneously open tickets.
Open int `env:",default:10"`
// Monthly caps tickets opened within a rolling thirty days.
Monthly int `env:",default:50"`
}
// Auth declares the identity provider whose access tokens the API
// verifies. The service issues none of its own.
type Auth struct {
boot.Auth `env:",inline"`
// StaffRole is the role granting support standing: reading every
// ticket, writing internal notes, assigning, and judging status
// and priority.
StaffRole string `env:",default:support"`
}
// Directory declares how this service reads people.
//
// It holds machine credentials of its own rather than borrowing a
// user's token, because notifications and sweeps run with nobody
// signed in. The scope is narrow by design; see the identity
// service's directory API.
type Directory struct {
// URL is the identity service's base URL. Required.
URL string `env:",required"`
// TokenURL is where the client-credentials grant is exchanged.
// Empty derives "<url>/token".
TokenURL string `env:"TOKEN_URL"`
// ClientID and ClientSecret are this service's own credentials.
// Required.
ClientID string `env:"CLIENT_ID,required"`
ClientSecret string `env:"CLIENT_SECRET,required"`
// Scope is what the minted token asks for.
Scope string `env:",default:'iam:directory:read'"`
// TTL is how long a resolved person is reused before being read
// again. Zero keeps the client's default.
TTL time.Duration
}
// Tokens returns the token endpoint, deriving it from the base URL
// when none is configured.
func (c Directory) Tokens() string {
if c.TokenURL != "" {
return c.TokenURL
}
return strings.TrimSuffix(c.URL, "/") + "/token"
}
// Storage configures the bucket attachments live in.
type Storage struct {
// AccessKey is the S3 access key. Required.
AccessKey string
// SecretKey is the S3 secret key.
SecretKey string
// Region is the provider region the credentials sign for.
Region string
// Bucket is the bucket's base URL — virtual-hosted or path style.
Bucket string
// Prefix is prepended to every object key, so several deployments
// can share one bucket.
Prefix string `env:",default:hds"`
// MaxSize caps one attachment. Zero keeps the engine's default.
MaxSize int64
// Types replaces the accepted content types. Empty keeps the
// engine's default set.
Types []string
}
// Enabled reports whether attachments are configured.
func (c Storage) Enabled() bool {
return c.AccessKey != "" && c.SecretKey != "" && c.Bucket != ""
}
// Mail configures the provider transactional mail travels through,
// and the templates it renders from. Copy lives at the provider;
// this service supplies variables only.
type Mail struct {
// AccessKey, Workspace, and Channel address the Bird channel.
AccessKey string `env:"ACCESS_KEY"`
Workspace string
Channel string
// Notification is the template announcing that a ticket moved.
Notification string
// Contact is the template relaying a contact-form message.
Contact string
// Languages are the locales the templates are published in, most
// preferred first. The first is the fallback.
Languages []string `env:",default:en"`
// TicketURL is where a ticket lives in the frontend; its number
// is appended.
TicketURL string `env:"TICKET_URL"`
// UnsubscribeURL is where an unsubscribe link lands.
UnsubscribeURL string `env:"UNSUBSCRIBE_URL"`
// Keys seal the unsubscribe tokens. Empty leaves notifications
// unmounted, since a link nobody can unsubscribe through does not
// belong in a mailbox.
boot.Keys `env:",inline"`
}
// Enabled reports whether notification mail is configured.
func (c Mail) Enabled() bool {
return c.AccessKey != "" && c.Workspace != "" &&
c.Channel != "" && c.Notification != "" && c.Sealed()
}
// Contact configures the website's contact form.
type Contact struct {
// Secret is the Turnstile secret verifying a visitor. Empty
// leaves the form unmounted: an unauthenticated mail relay
// without a challenge is a spam cannon.
Secret string
// Recipient is where relayed messages go. It comes from here and
// never from the request.
Recipient string
// Language is the locale the relay template renders in.
Language string
// Rate and Burst tune the per-address limit standing behind the
// challenge; zero values take the relay's defaults.
Rate float64
Burst int
}
// Enabled reports whether the contact form is configured.
func (c Contact) Enabled() bool {
return c.Secret != "" && c.Recipient != ""
}
// Load binds a [Config] from the environment under [Prefix] and
// rejects settings that would not mean what they say.
func Load(opts ...env.Option) (Config, error) {
cfg, err := boot.Load[Config](Prefix, opts...)
if err != nil {
return cfg, err
}
if !cfg.Database.Enabled() {
// Named rather than tagged required, so the message says which
// variable to set rather than which field failed to bind.
return cfg, fmt.Errorf("%sDATABASE_URL is not set", Prefix)
}
if cfg.ReopenWindow != 0 && cfg.ReopenWindow < MinReopenWindow {
return cfg, fmt.Errorf(
"reopen window %v is below the %v floor",
cfg.ReopenWindow, MinReopenWindow,
)
}
if cfg.CloseAfter != 0 && cfg.CloseAfter < MinCloseAfter {
return cfg, fmt.Errorf(
"close-after %v is below the %v floor",
cfg.CloseAfter, MinCloseAfter,
)
}
if cfg.Limits.Open < 0 || cfg.Limits.Monthly < 0 {
return cfg, errors.New("ticket limits must not be negative")
}
// A ticket that closes itself before a customer could have
// answered, and reopens for less time than it waited, would read
// as a desk that hangs up on people.
if cfg.CloseAfter > 0 && cfg.ReopenWindow > 0 &&
cfg.ReopenWindow < cfg.CloseAfter {
return cfg, fmt.Errorf(
"the reopen window (%v) must not be shorter than the "+
"silence that closes a ticket (%v)",
cfg.ReopenWindow, cfg.CloseAfter,
)
}
if !cfg.Storage.Enabled() {
return cfg, errors.New(
"attachments need storage credentials and a bucket",
)
}
if !cfg.Mail.Enabled() {
return cfg, errors.New(
"notifications need a mail channel, a template, and a " +
"sealing key",
)
}
if cfg.Contact.Secret != "" && !cfg.Contact.Enabled() {
return cfg, errors.New(
"the contact challenge and recipient must be configured " +
"together",
)
}
// The relay renders through a template of its own; without it the
// form would accept a visitor's message and have nothing to send
// it as.
if cfg.Contact.Enabled() && cfg.Mail.Contact == "" {
return cfg, errors.New("the contact form needs its own mail template")
}
return cfg, nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package contact
import (
"context"
"net/http"
"time"
"golang.org/x/time/rate"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/net/notify"
"github.com/deep-rent/nexus/net/notify/mail"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/net/throttle"
"github.com/deep-rent/nexus/net/turnstile"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/metrics"
)
// Bounds on what a stranger may send.
const (
// MaxNameLength bounds the sender's name.
MaxNameLength = 128
// MaxSubjectLength bounds the subject line.
MaxSubjectLength = 200
// MaxMessageLength bounds the message. It is generous enough for
// somebody explaining a problem and far short of a payload.
MaxMessageLength = 8000
)
// Defaults for the rate limit standing behind the challenge.
const (
// DefaultRate is the sustained per-address rate, in messages per
// second — roughly one a minute.
DefaultRate = rate.Limit(1.0 / 60.0)
// DefaultBurst is how many a visitor may send back to back.
DefaultBurst = 3
)
// Action is the widget action this form's tokens must carry, so a
// token minted on another form cannot be spent here.
const Action = "contact"
// Sender dispatches a message, satisfied by [mail.Sender].
type Sender interface {
Send(ctx context.Context, msg *mail.Message) error
}
// Config bundles what the relay needs.
type Config struct {
// Verifier checks the visitor's challenge token. Required.
Verifier turnstile.Verifier
// Sender dispatches the relayed mail. Required.
Sender Sender
// Template is the provider-side template rendering it. Required.
Template string
// Recipient is where the message goes. It comes from the
// deployment's configuration and never from the request, which is
// what keeps this a relay rather than an open one. Required.
Recipient string
// Language is the locale the template renders in.
Language string
// Rate and Burst tune the per-address limit standing behind the
// challenge; zero values take the defaults.
Rate rate.Limit
Burst int
// Logger receives diagnostics. Defaults to [log.Discard].
Logger *log.Logger
// Registry receives the instruments. Defaults to
// [metrics.DefaultRegistry].
Registry *metrics.Registry
}
// Relay forwards contact-form messages to one configured address.
type Relay struct {
cfg Config
limit *throttle.Throttle
}
// New assembles a [Relay]. It panics if a required collaborator is
// missing, since startup misconfiguration is a programmer error.
func New(cfg Config) *Relay {
switch {
case cfg.Verifier == nil:
panic("turnstile verifier is required")
case cfg.Sender == nil:
panic("sender is required")
case cfg.Template == "":
panic("template is required")
case cfg.Recipient == "":
panic("recipient is required")
}
if cfg.Logger == nil {
cfg.Logger = log.Discard()
}
if cfg.Registry == nil {
cfg.Registry = metrics.DefaultRegistry
}
limit := cfg.Rate
if limit <= 0 {
limit = DefaultRate
}
burst := cfg.Burst
if burst <= 0 {
burst = DefaultBurst
}
return &Relay{
cfg: cfg,
limit: throttle.New(throttle.Config{
Limit: limit,
Burst: burst,
}),
}
}
// Message is what a visitor submits.
type Message struct {
// Name is who they say they are. It is display text, believed no
// further than that.
Name string `json:"name"`
// Email is where they would like an answer. It is validated as an
// address and travels as a template variable, never as a header
// this service composes.
Email string `json:"email"`
// Subject is the one-line summary.
Subject string `json:"subject"`
// Body is the message itself.
Body string `json:"message"`
// Token is the challenge token the widget produced.
Token string `json:"token"`
}
// Validate implements the [valid.Validatable] interface.
func (m *Message) Validate(v *valid.Validator) {
v.NotBlank("name", m.Name)
v.MaxLen("name", m.Name, MaxNameLength)
v.NotEmpty("email", m.Email)
v.Email("email", m.Email)
v.NotBlank("subject", m.Subject)
v.MaxLen("subject", m.Subject, MaxSubjectLength)
v.NotBlank("message", m.Body)
v.MaxLen("message", m.Body, MaxMessageLength)
v.NotBlank("token", m.Token)
}
// Mount registers the unauthenticated contact endpoint.
//
// It is deliberately open: a visitor on the company's website has no
// account, and demanding one would defeat the point of a contact
// form. What stands in for authentication is the challenge, a
// per-address rate limit behind it, and the fact that the endpoint
// can do exactly one thing — send one templated mail to one address
// fixed in configuration.
func (r *Relay) Mount(reg router.Registrar, path string) {
reg.HandleFunc(http.MethodPost, path, r.Handle)
}
// Handle verifies and relays one submission.
func (r *Relay) Handle(e *router.Exchange) error {
addr := throttle.RemoteAddr(e.R)
if !r.limit.Allow(addr) {
r.cfg.Registry.Counter("hds_contact_throttled_total").Inc()
return &router.Error{
Status: http.StatusTooManyRequests,
Reason: router.ReasonRateLimit,
}
}
var msg Message
if err := e.BindJSON(&msg); err != nil {
return err
}
res, err := r.cfg.Verifier.Verify(e.Context(), turnstile.Request{
Token: msg.Token,
Addr: addr,
Action: Action,
})
if err != nil {
// The verdict could not be had. This endpoint fails closed:
// an unauthenticated mail relay with its challenge switched
// off is a spam cannon, and the cost of the other choice is
// that a visitor is asked to try again later.
r.cfg.Logger.Warn(e.Context(),
"Contact challenge unavailable", log.Error(err))
r.cfg.Registry.Counter("hds_contact_unverifiable_total").Inc()
return &router.Error{
Status: http.StatusServiceUnavailable,
Reason: router.ReasonServerError,
Description: "the challenge could not be checked; try again",
Cause: err,
}
}
if !res.Success || (res.Action != "" && res.Action != Action) {
r.cfg.Registry.Counter("hds_contact_refused_total").Inc()
return &router.Error{
Status: http.StatusForbidden,
Reason: router.ReasonValidationFailed,
Description: "the challenge was not passed",
}
}
// Everything the visitor wrote travels as a template variable.
// The subject, the sender identity, and the recipient live in the
// template and in configuration, so there is no header for a
// newline to break out into and no body for markup to land in.
out := mail.NewMessage(r.cfg.Template, r.cfg.Recipient).
WithCategory(notify.CategoryTransactional).
AddParameter("name", msg.Name).
AddParameter("email", msg.Email).
AddParameter("subject", msg.Subject).
AddParameter("message", msg.Body).
AddParameter("received", time.Now().UTC().Format(time.RFC3339))
if r.cfg.Language != "" {
out = out.WithLanguage(r.cfg.Language)
}
if err := r.cfg.Sender.Send(e.Context(), out); err != nil {
// The visitor is waiting on this request, so a failure is
// worth telling them about rather than swallowing: there is
// no transaction here to be atomic with and no queue behind
// it, and a form that silently loses a message is worse than
// one that says to try again.
r.cfg.Logger.Error(e.Context(),
"Failed to relay a contact message", log.Error(err))
return &router.Error{
Status: http.StatusBadGateway,
Reason: router.ReasonServerError,
Description: "the message could not be delivered",
Cause: err,
}
}
r.cfg.Registry.Counter("hds_contact_relayed_total").Inc()
e.Status(http.StatusNoContent)
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package desk
import (
"context"
"encoding/json/jsontext"
"encoding/json/v2"
"errors"
"fmt"
"slices"
"strconv"
"time"
"uuid"
"github.com/jackc/pgx/v5"
"github.com/deep-rent/nexus/eco/hds/store"
"github.com/deep-rent/nexus/eco/hds/ticket"
"github.com/deep-rent/nexus/net/notify/hook"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/std/text"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/metrics"
"github.com/deep-rent/nexus/sys/queue"
)
// The webhook topics this service publishes. They are a public
// contract a subscriber keeps in its own configuration, so they must
// outlive any renaming inside this package.
const (
// TopicOpened announces a ticket somebody just opened, which is
// what a support channel wants to hear about.
TopicOpened = "hds.ticket.opened"
// TopicClosed announces a ticket reaching its end.
TopicClosed = "hds.ticket.closed"
)
// Topics lists every topic this service publishes.
var Topics = []string{TopicOpened, TopicClosed}
// NotifyKind is the queue kind carrying ticket notifications.
const NotifyKind = "hds.notify"
// How the first-response time is summarized: the quantiles a desk
// judges itself by — the middle, the slow tail, and the worst of it —
// over a window long enough to survive a quiet weekend.
var (
FirstResponseQuantiles = []float64{0.5, 0.9, 0.99}
FirstResponseWindow = 7 * 24 * time.Hour
)
// NotifyDelay is how long a notification waits before it is sent.
// Everything that happens to the ticket in the meantime folds into
// the same mail, since the pending job absorbs further pushes and its
// handler reads the current state when it runs.
const NotifyDelay = 30 * time.Second
// Errors the engine reports to its callers, which the API maps onto
// status codes.
var (
// ErrForbidden reports a viewer who may not see or touch the
// ticket at all — including one asking about a ticket that does
// not exist, so that the two cannot be told apart.
ErrForbidden = errors.New("not permitted")
// ErrStaffOnly reports an action reserved for the support role.
// It is deliberately distinct from [ErrForbidden]: refusing a
// customer the right to close their own ticket says nothing about
// which tickets exist, so it may be answered plainly.
ErrStaffOnly = errors.New("requires the support role")
// ErrUnknownTag reports a tag outside the deployment's configured
// vocabulary. Free-text tags are how a help desk's grouping turns
// to mush, so the vocabulary is closed.
ErrUnknownTag = errors.New("unknown tag")
// ErrInvalid reports a value outside the service's vocabulary — a
// status, priority, visibility, or link kind it does not know.
ErrInvalid = errors.New("invalid value")
)
// Notifier is the outbox the engine pushes notifications onto,
// satisfied by [queue.Queue].
type Notifier interface {
Push(ctx context.Context, tx pgx.Tx, r queue.Request) (
queue.Job, bool, error)
}
// Publisher is the webhook engine announcements travel through,
// satisfied by [hook.Engine].
type Publisher interface {
Publish(ctx context.Context, tx pgx.Tx, event hook.Event) (
int, error)
}
// Option configures an [Engine].
type Option func(*Engine)
// WithLogger sets the logger narrating what the engine does. A nil
// logger is ignored.
func WithLogger(logger *log.Logger) Option {
return func(e *Engine) {
if logger != nil {
e.logger = logger
}
}
}
// WithClock injects the time source, which is primarily useful for
// testing. A nil clock is ignored.
func WithClock(now clock.Clock) Option {
return func(e *Engine) {
if now != nil {
e.now = now
}
}
}
// WithNotifier routes notifications onto the job queue. Without one,
// tickets still work and nobody is mailed.
func WithNotifier(n Notifier) Option {
return func(e *Engine) { e.jobs = n }
}
// WithPublisher routes announcements onto the webhook engine.
func WithPublisher(p Publisher) Option {
return func(e *Engine) { e.hooks = p }
}
// WithRegistry registers the engine's instruments with reg instead of
// [metrics.DefaultRegistry]. A nil registry is ignored.
func WithRegistry(reg *metrics.Registry) Option {
return func(e *Engine) {
if reg != nil {
e.reg = reg
}
}
}
// WithLimits caps how many tickets one person may hold; the zero
// value leaves both dimensions uncapped.
func WithLimits(l ticket.Limits) Option {
return func(e *Engine) { e.limits = l }
}
// WithTags closes the tag vocabulary to the given names. Empty admits
// no tags at all, which is the safe direction to forget.
func WithTags(tags ...string) Option {
return func(e *Engine) { e.tags = slices.Clone(tags) }
}
// WithReopenWindow sets how long after closing a customer's reply
// still continues the same ticket. Zero closes tickets for good.
func WithReopenWindow(d time.Duration) Option {
return func(e *Engine) { e.reopen = d }
}
// Engine is the help desk's one writer. It is safe for concurrent
// use.
type Engine struct {
db *store.Store
jobs Notifier
hooks Publisher
limits ticket.Limits
tags []string
reopen time.Duration
logger *log.Logger
now clock.Clock
reg *metrics.Registry
}
// New assembles an engine over the store.
func New(db *store.Store, opts ...Option) *Engine {
if db == nil {
panic("store is required")
}
e := &Engine{
db: db,
logger: log.Discard(),
now: clock.System,
reg: metrics.DefaultRegistry,
}
for _, opt := range opts {
opt(e)
}
return e
}
// Actor is who is acting, and with what standing. Staff is resolved
// from the caller's token rather than from anything stored, so losing
// the support role takes effect on the next request.
type Actor struct {
// User is the acting IAM user.
User uuid.UUID
// Staff reports whether they hold the deployment's support role.
Staff bool
}
// Draft is a new ticket.
type Draft struct {
// Subject is the one-line summary.
Subject string
// Body is the first message.
Body string
// Tags group the ticket; every one must be in the configured
// vocabulary.
Tags []string
// Meta is the context an app attaches; see [ticket.Ticket.Meta].
Meta jsontext.Value
}
// Open creates a ticket, enrolls its creator, records the opening, and
// announces it. The creator's limits are checked in the same
// transaction that inserts the row, so two simultaneous requests
// cannot both slip past the last allowance.
func (e *Engine) Open(
ctx context.Context,
actor Actor,
d Draft,
) (ticket.Ticket, error) {
if err := e.checkTags(d.Tags); err != nil {
return ticket.Ticket{}, err
}
now := e.now().UTC()
tk := ticket.Ticket{
Subject: d.Subject,
Status: ticket.StatusWaitingSupport,
Priority: ticket.PriorityMedium,
Creator: actor.User,
Tags: d.Tags,
Meta: d.Meta,
CreatedAt: now,
UpdatedAt: now,
}
err := e.db.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
// Staff open tickets on a customer's behalf without spending
// an allowance; the limits exist to bound one person's demand
// on the desk, not the desk's own work.
if !actor.Staff {
open, recent, err := e.db.Counts(
ctx, tx, actor.User, now.Add(-ticket.MonthlyWindow),
)
if err != nil {
return err
}
if err := e.limits.Allow(open, recent); err != nil {
return err
}
}
if err := e.db.CreateTicket(ctx, tx, &tk); err != nil {
return err
}
if err := e.db.AddMessage(ctx, tx, &ticket.Message{
Ticket: tk.ID,
Author: actor.User,
Staff: actor.Staff,
Visibility: ticket.VisibilityPublic,
Body: d.Body,
CreatedAt: now,
}); err != nil {
return err
}
if err := e.record(ctx, tx, tk.ID, actor.User,
ticket.EventOpened, false, "", "", now); err != nil {
return err
}
return e.announce(ctx, tx, TopicOpened, tk, now)
})
if err != nil {
return ticket.Ticket{}, err
}
e.reg.Counter("hds_tickets_opened_total").Inc()
return tk, nil
}
// Reply appends a message and moves the ticket where the reply leaves
// it; see [ticket.Reply]. An internal note demands staff standing —
// nobody outside support may write one, and nobody outside support
// ever sees one.
func (e *Engine) Reply(
ctx context.Context,
actor Actor,
id int64,
visibility ticket.Visibility,
body string,
) (ticket.Message, error) {
if !visibility.Valid() {
return ticket.Message{}, fmt.Errorf(
"%w: visibility %q", ErrInvalid, visibility,
)
}
if visibility == ticket.VisibilityInternal && !actor.Staff {
return ticket.Message{}, ErrStaffOnly
}
now := e.now().UTC()
var m ticket.Message
err := e.db.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
tk, seat, err := e.reach(ctx, tx, id, actor)
if err != nil {
return err
}
status, err := ticket.Reply(
tk, seat.role, visibility, e.reopen, now,
)
if err != nil {
return err
}
m = ticket.Message{
Ticket: tk.ID,
Author: actor.User,
Staff: actor.Staff,
Visibility: visibility,
Body: body,
CreatedAt: now,
}
if err := e.db.AddMessage(ctx, tx, &m); err != nil {
return err
}
// A note is not activity the customer sees, so it does not
// disturb the ticket's own timestamps or status.
if visibility == ticket.VisibilityInternal {
return nil
}
before := tk.Status
tk.Status = status
tk.UpdatedAt = now
if actor.Staff && tk.AnsweredAt.IsZero() {
tk.AnsweredAt = now
e.reg.Summary(
"hds_first_response_seconds",
FirstResponseQuantiles, FirstResponseWindow,
).Observe(now.Sub(tk.CreatedAt).Seconds())
}
if status != ticket.StatusDone {
tk.ClosedAt = time.Time{}
}
if err := e.db.SaveTicket(ctx, tx, tk); err != nil {
return err
}
if before != status {
if err := e.record(ctx, tx, tk.ID, actor.User,
ticket.EventStatus, false,
string(before), string(status), now); err != nil {
return err
}
}
return e.notify(ctx, tx, tk.ID, actor.User)
})
if err != nil {
return ticket.Message{}, err
}
e.reg.Counter("hds_messages_total",
metrics.T("visibility", string(visibility)),
).Inc()
return m, nil
}
// Assign hands a ticket to a staff member, or — with the zero
// identifier — returns it to the pending queue.
func (e *Engine) Assign(
ctx context.Context,
actor Actor,
id int64,
assignee uuid.UUID,
) error {
if !actor.Staff {
return ErrStaffOnly
}
now := e.now().UTC()
return e.db.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
tk, _, err := e.reach(ctx, tx, id, actor)
if err != nil {
return err
}
if tk.Assignee == assignee {
return nil
}
before := tk.Assignee
tk.Assignee = assignee
tk.UpdatedAt = now
if err := e.db.SaveTicket(ctx, tx, tk); err != nil {
return err
}
kind := ticket.EventAssigned
if assignee == uuid.Nil() {
kind = ticket.EventUnassigned
}
// Who is working a ticket is the desk's business, not the
// customer's, so the record of it stays internal.
return e.record(ctx, tx, tk.ID, actor.User, kind, true,
label(before), label(assignee), now)
})
}
// SetStatus moves a ticket outright, which only staff may do: every
// other move is a consequence of somebody writing something.
func (e *Engine) SetStatus(
ctx context.Context,
actor Actor,
id int64,
status ticket.Status,
) error {
if !actor.Staff {
return ErrStaffOnly
}
if !status.Valid() {
return fmt.Errorf("%w: status %q", ErrInvalid, status)
}
now := e.now().UTC()
return e.db.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
tk, _, err := e.reach(ctx, tx, id, actor)
if err != nil {
return err
}
if tk.Status == status {
return nil
}
before := tk.Status
if err := e.move(
ctx, tx, &tk, status, actor.User, now,
); err != nil {
return err
}
if before != ticket.StatusDone && status == ticket.StatusDone {
e.reg.Counter("hds_tickets_closed_total").Inc()
if err := e.announce(
ctx, tx, TopicClosed, tk, now,
); err != nil {
return err
}
}
return e.notify(ctx, tx, tk.ID, actor.User)
})
}
// SetPriority judges how urgent a ticket is, which only staff may do:
// a priority everyone sets for themselves says nothing.
func (e *Engine) SetPriority(
ctx context.Context,
actor Actor,
id int64,
priority ticket.Priority,
) error {
if !actor.Staff {
return ErrStaffOnly
}
if !priority.Valid() {
return fmt.Errorf("%w: priority %q", ErrInvalid, priority)
}
now := e.now().UTC()
return e.db.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
tk, _, err := e.reach(ctx, tx, id, actor)
if err != nil {
return err
}
if tk.Priority == priority {
return nil
}
before := tk.Priority
tk.Priority = priority
tk.UpdatedAt = now
if err := e.db.SaveTicket(ctx, tx, tk); err != nil {
return err
}
return e.record(ctx, tx, tk.ID, actor.User,
ticket.EventPriority, true,
string(before), string(priority), now)
})
}
// SetTags groups a ticket, within the configured vocabulary. Only
// staff regroup a ticket after it is open; a customer's own words
// belong in the subject and the body.
func (e *Engine) SetTags(
ctx context.Context,
actor Actor,
id int64,
tags []string,
) error {
if !actor.Staff {
return ErrStaffOnly
}
if err := e.checkTags(tags); err != nil {
return err
}
now := e.now().UTC()
return e.db.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
tk, _, err := e.reach(ctx, tx, id, actor)
if err != nil {
return err
}
before := slices.Clone(tk.Tags)
slices.Sort(tags)
tk.Tags = slices.Compact(tags)
tk.UpdatedAt = now
if err := e.db.SaveTicket(ctx, tx, tk); err != nil {
return err
}
for _, tag := range tk.Tags {
if !slices.Contains(before, tag) {
if err := e.record(ctx, tx, tk.ID, actor.User,
ticket.EventTagged, true, "", tag, now,
); err != nil {
return err
}
}
}
for _, tag := range before {
if !slices.Contains(tk.Tags, tag) {
if err := e.record(ctx, tx, tk.ID, actor.User,
ticket.EventUntagged, true, tag, "", now,
); err != nil {
return err
}
}
}
return nil
})
}
// Share puts another person on a ticket. The caller must be on it
// themselves — or be staff — and the person joining is resolved
// elsewhere: the engine takes an identifier, never an address.
func (e *Engine) Share(
ctx context.Context,
actor Actor,
id int64,
user uuid.UUID,
) error {
now := e.now().UTC()
return e.db.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
tk, _, err := e.reach(ctx, tx, id, actor)
if err != nil {
return err
}
if _, on, err := e.db.Participant(
ctx, tx, tk.ID, user,
); err != nil {
return err
} else if on {
return nil
}
if err := e.db.AddParticipant(ctx, tx, ticket.Participant{
Ticket: tk.ID,
User: user,
Role: ticket.RoleShared,
Subscribed: true,
AddedBy: actor.User,
AddedAt: now,
}); err != nil {
return err
}
if err := e.record(ctx, tx, tk.ID, actor.User,
ticket.EventShared, false, "", label(user), now,
); err != nil {
return err
}
// The new participant hears about the ticket they were just
// given; everyone else on it hears nothing, since being
// shared with is news only to the recipient.
return e.push(ctx, tx, tk.ID, user)
})
}
// Unshare takes someone off a ticket. The creator cannot be removed.
func (e *Engine) Unshare(
ctx context.Context,
actor Actor,
id int64,
user uuid.UUID,
) error {
now := e.now().UTC()
return e.db.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
tk, _, err := e.reach(ctx, tx, id, actor)
if err != nil {
return err
}
gone, err := e.db.RemoveParticipant(ctx, tx, tk.ID, user)
if err != nil || !gone {
return err
}
return e.record(ctx, tx, tk.ID, actor.User,
ticket.EventUnshared, false, label(user), "", now)
})
}
// Link relates two tickets. Both must be reachable by the caller, so
// linking cannot be used to learn that a stranger's ticket exists.
func (e *Engine) Link(
ctx context.Context,
actor Actor,
id, other int64,
kind ticket.LinkKind,
) error {
if !kind.Valid() {
return fmt.Errorf("%w: link kind %q", ErrInvalid, kind)
}
if id == other {
return fmt.Errorf("%w: a ticket cannot link to itself", ErrInvalid)
}
now := e.now().UTC()
return e.db.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
if _, _, err := e.reach(ctx, tx, id, actor); err != nil {
return err
}
if _, _, err := e.reach(ctx, tx, other, actor); err != nil {
return err
}
if err := e.db.AddLink(ctx, tx, ticket.Link{
Ticket: id, Other: other, Kind: kind, CreatedAt: now,
}); err != nil {
return err
}
return e.record(ctx, tx, id, actor.User, ticket.EventLinked,
false, "", strconv.FormatInt(other, 10), now)
})
}
// Unlink unrelates two tickets.
func (e *Engine) Unlink(
ctx context.Context,
actor Actor,
id, other int64,
) error {
now := e.now().UTC()
return e.db.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
if _, _, err := e.reach(ctx, tx, id, actor); err != nil {
return err
}
gone, err := e.db.RemoveLink(ctx, tx, id, other)
if err != nil || !gone {
return err
}
return e.record(ctx, tx, id, actor.User, ticket.EventUnlinked,
false, strconv.FormatInt(other, 10), "", now)
})
}
// Subscribe sets whether the caller wants mail about a ticket.
func (e *Engine) Subscribe(
ctx context.Context,
actor Actor,
id int64,
subscribed bool,
) error {
return e.db.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
if _, _, err := e.reach(ctx, tx, id, actor); err != nil {
return err
}
_, err := e.db.Subscribe(ctx, tx, id, actor.User, subscribed)
return err
})
}
// Read stamps that the caller has seen the ticket as it now stands.
func (e *Engine) Read(
ctx context.Context,
actor Actor,
id int64,
) error {
now := e.now().UTC()
return e.db.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
if _, _, err := e.reach(ctx, tx, id, actor); err != nil {
return err
}
return e.db.MarkRead(ctx, tx, id, actor.User, now)
})
}
// Forget erases a departed person; see [store.Store.Forget].
func (e *Engine) Forget(ctx context.Context, user uuid.UUID) error {
err := e.db.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
return e.db.Forget(ctx, tx, user)
})
if err != nil {
return err
}
e.logger.Info(ctx, "Forgot a departed user",
log.UUID("user", user))
return nil
}
// seat is what one actor may do on one ticket.
type seat struct {
audience ticket.Audience
role ticket.Role
}
// reach reads a ticket and the caller's standing on it, refusing a
// viewer who may not see it at all. A ticket that does not exist and
// one the viewer may not see answer alike, so identifiers cannot be
// probed by watching the difference.
func (e *Engine) reach(
ctx context.Context,
tx pgx.Tx,
id int64,
actor Actor,
) (ticket.Ticket, seat, error) {
tk, err := e.db.Ticket(ctx, tx, id)
if errors.Is(err, pgx.ErrNoRows) {
return ticket.Ticket{}, seat{}, ErrForbidden
}
if err != nil {
return ticket.Ticket{}, seat{}, err
}
s := seat{
audience: ticket.Audience{Staff: actor.Staff},
role: ticket.RoleStaff,
}
if !actor.Staff {
p, on, err := e.db.Participant(ctx, tx, id, actor.User)
if err != nil {
return ticket.Ticket{}, seat{}, err
}
if !on {
return ticket.Ticket{}, seat{}, ErrForbidden
}
s.audience.Participant = true
s.role = p.Role
}
return tk, s, nil
}
// move applies a status change, stamping the timestamps that hang off
// it and recording the event.
func (e *Engine) move(
ctx context.Context,
tx pgx.Tx,
tk *ticket.Ticket,
status ticket.Status,
actor uuid.UUID,
now time.Time,
) error {
before := tk.Status
tk.Status = status
tk.UpdatedAt = now
switch status {
case ticket.StatusDone:
tk.ClosedAt = now
default:
tk.ClosedAt = time.Time{}
}
if err := e.db.SaveTicket(ctx, tx, *tk); err != nil {
return err
}
return e.record(ctx, tx, tk.ID, actor, ticket.EventStatus,
false, string(before), string(status), now)
}
// record writes one event into the thread.
func (e *Engine) record(
ctx context.Context,
tx pgx.Tx,
id int64,
actor uuid.UUID,
kind ticket.EventKind,
internal bool,
from, to string,
now time.Time,
) error {
ev := ticket.Event{
Ticket: id,
Actor: actor,
Kind: kind,
Internal: internal,
From: from,
To: to,
CreatedAt: now,
}
return e.db.AddEvent(ctx, tx, &ev)
}
// notify enqueues a notification for everyone subscribed to the
// ticket except the person who caused the change.
func (e *Engine) notify(
ctx context.Context,
tx pgx.Tx,
id int64,
actor uuid.UUID,
) error {
if e.jobs == nil {
return nil
}
who, err := e.db.Subscribers(ctx, tx, id, actor)
if err != nil {
return err
}
for _, user := range who {
if err := e.push(ctx, tx, id, user); err != nil {
return err
}
}
return nil
}
// push enqueues one notification. The key collapses a burst into one
// mail: while a job for this pair is pending, further pushes return
// it instead of enqueueing another, and its handler reads the ticket
// as it stands when the delay expires.
func (e *Engine) push(
ctx context.Context,
tx pgx.Tx,
id int64,
user uuid.UUID,
) error {
if e.jobs == nil {
return nil
}
key := fmt.Sprintf("%s:%d:%s", NotifyKind, id, user)
payload, err := json.Marshal(Notification{Ticket: id, User: user})
if err != nil {
return fmt.Errorf("failed to encode a notification: %w", err)
}
_, _, err = e.jobs.Push(ctx, tx, queue.Request{
Kind: NotifyKind,
Payload: payload,
Key: key,
RunAt: e.now().Add(NotifyDelay),
})
if err != nil {
return fmt.Errorf("failed to enqueue a notification: %w", err)
}
return nil
}
// Notification is the queue payload naming who to tell about what. It
// is thin on purpose: the handler reads the ticket when it runs, so a
// mail sent minutes later describes the ticket as it is rather than
// as it was.
type Notification struct {
// Ticket is the ticket that changed.
Ticket int64 `json:"ticket"`
// User is who to tell.
User uuid.UUID `json:"user"`
}
// announce publishes a webhook event, if a publisher is configured.
// Payloads carry identifiers alone: a subscriber that needs the
// ticket reads it back under its own authority, which keeps subjects
// and bodies out of third-party request logs.
func (e *Engine) announce(
ctx context.Context,
tx pgx.Tx,
topic string,
tk ticket.Ticket,
now time.Time,
) error {
if e.hooks == nil {
return nil
}
body, err := json.Marshal(map[string]any{
"ticket": tk.ID,
"creator": tk.Creator.String(),
})
if err != nil {
return fmt.Errorf("failed to encode the payload: %w", err)
}
_, err = e.hooks.Publish(ctx, tx, hook.Event{
Topic: topic,
At: now,
Data: body,
})
return err
}
// checkTags refuses anything outside the configured vocabulary.
//
// The vocabulary is closed and small, and a caller who reaches this
// almost always meant one of the tags in it, so the refusal names the
// nearest match where there is one. Nothing is corrected on the
// caller's behalf: a tag is how tickets get grouped, and guessing
// wrong would file the ticket under something nobody asked for.
func (e *Engine) checkTags(tags []string) error {
for _, tag := range tags {
if slices.Contains(e.tags, tag) {
continue
}
if near, ok := text.Nearest(tag, e.tags); ok {
return fmt.Errorf(
"%w: %q; did you mean %q?", ErrUnknownTag, tag, near,
)
}
return fmt.Errorf("%w: %q", ErrUnknownTag, tag)
}
return nil
}
// label renders an identifier for an event's from and to fields,
// leaving the zero identifier empty.
func label(id uuid.UUID) string {
if id == uuid.Nil() {
return ""
}
return id.String()
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package desk
import (
"context"
"time"
"uuid"
"github.com/jackc/pgx/v5"
"github.com/deep-rent/nexus/eco/hds/store"
"github.com/deep-rent/nexus/eco/hds/ticket"
"github.com/deep-rent/nexus/sys/log"
)
// SweepLimit bounds one auto-close pass, so a desk that has been left
// alone for a month closes over several runs rather than in one long
// transaction.
const SweepLimit = 200
// Thread is everything a viewer may see of one ticket, read in a
// single transaction so its parts agree with each other.
type Thread struct {
// Ticket is the ticket itself.
Ticket ticket.Ticket
// Messages is the conversation, filtered to what the viewer may
// read.
Messages []ticket.Message
// Events is what changed, filtered the same way.
Events []ticket.Event
// Attachments are the confirmed files on messages the viewer may
// read.
Attachments []ticket.Attachment
// Participants is who is on the ticket.
Participants []ticket.Participant
// Links are the related tickets.
Links []ticket.Link
// Seat is the viewer's own place on the ticket, absent for staff
// who are not participants.
Seat ticket.Participant
// Unread reports whether the ticket moved since the viewer last
// read it.
Unread bool
}
// Thread reads one ticket as the actor may see it. A ticket that does
// not exist and one they may not see answer alike with [ErrForbidden],
// so identifiers cannot be probed.
func (e *Engine) Thread(
ctx context.Context,
actor Actor,
id int64,
) (Thread, error) {
var out Thread
err := e.db.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
tk, seat, err := e.reach(ctx, tx, id, actor)
if err != nil {
return err
}
internal := seat.audience.Sees(true)
out.Ticket = tk
if out.Messages, err = e.db.Messages(
ctx, tx, id, internal,
); err != nil {
return err
}
if out.Events, err = e.db.Events(
ctx, tx, id, internal,
); err != nil {
return err
}
if out.Attachments, err = e.db.Attachments(
ctx, tx, id, internal,
); err != nil {
return err
}
if out.Participants, err = e.db.Participants(
ctx, tx, id,
); err != nil {
return err
}
if out.Links, err = e.db.Links(ctx, tx, id); err != nil {
return err
}
for _, p := range out.Participants {
if p.User == actor.User {
out.Seat = p
out.Unread = ticket.Unread(p, tk)
}
}
return nil
})
if err != nil {
return Thread{}, err
}
return out, nil
}
// List reads a page of tickets.
//
// The filter is taken as given for staff, who see the whole desk, and
// pinned to the caller for everyone else: a customer's listing is the
// tickets they are on, whatever the request asked for. Pinning it here
// rather than at the surface means a new endpoint cannot widen it by
// forgetting.
func (e *Engine) List(
ctx context.Context,
actor Actor,
f store.Filter,
) ([]ticket.Ticket, error) {
if !actor.Staff {
f.Viewer = actor.User
f.Internal = false
} else {
f.Internal = true
}
var out []ticket.Ticket
err := e.db.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
var err error
out, err = e.db.Tickets(ctx, tx, f)
return err
})
return out, err
}
// Unread reports which of the given tickets have moved since the
// viewer last read them, so a listing can mark them without a query
// per row.
func (e *Engine) Unread(
ctx context.Context,
actor Actor,
tickets []ticket.Ticket,
) (map[int64]bool, error) {
if actor.User == uuid.Nil() || len(tickets) == 0 {
return map[int64]bool{}, nil
}
ids := make([]int64, len(tickets))
for i, tk := range tickets {
ids[i] = tk.ID
}
var out map[int64]bool
err := e.db.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
var err error
out, err = e.db.Unread(ctx, tx, actor.User, ids)
return err
})
return out, err
}
// Sweep closes tickets whose customer has gone quiet for longer than
// after, and tells them it happened.
//
// It is not destructive: the close is announced like any other, and a
// reply within the reopen window continues the same ticket. What it
// prevents is a backlog that silently fills with conversations nobody
// intends to finish, which is how every number a desk reports about
// itself stops being true.
func (e *Engine) Sweep(ctx context.Context, after time.Duration) {
if after <= 0 {
return
}
now := e.now().UTC()
var stale []ticket.Ticket
err := e.db.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
var err error
stale, err = e.db.Stale(
ctx, tx, now.Add(-after), SweepLimit,
)
return err
})
if err != nil {
e.logger.Error(ctx, "Auto-close worklist failed",
log.Error(err))
return
}
closed := 0
for _, tk := range stale {
err := e.db.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
// Re-read under the transaction: the customer may have
// replied between the worklist and this moment, and
// closing a ticket somebody just answered is worse than
// leaving it open one more pass.
fresh, err := e.db.Ticket(ctx, tx, tk.ID)
if err != nil {
return err
}
if !ticket.Stale(fresh, after, now) {
return nil
}
if err := e.move(
ctx, tx, &fresh, ticket.StatusDone, uuid.Nil(), now,
); err != nil {
return err
}
closed++
if err := e.announce(
ctx, tx, TopicClosed, fresh, now,
); err != nil {
return err
}
return e.notify(ctx, tx, fresh.ID, uuid.Nil())
})
if err != nil {
e.logger.Warn(ctx, "Failed to close a stale ticket",
log.Int("ticket", int(tk.ID)), log.Error(err))
}
}
if closed > 0 {
e.reg.Counter("hds_tickets_closed_total").Add(uint64(closed))
e.logger.Info(ctx, "Closed tickets nobody came back to",
log.Int("count", closed))
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package notify
import (
"context"
"encoding/base64"
"encoding/json/v2"
"errors"
"fmt"
"net/url"
"strconv"
"strings"
"time"
"uuid"
"github.com/jackc/pgx/v5"
"github.com/deep-rent/nexus/eco/hds/desk"
"github.com/deep-rent/nexus/eco/hds/store"
"github.com/deep-rent/nexus/eco/hds/ticket"
"github.com/deep-rent/nexus/eco/identity"
push "github.com/deep-rent/nexus/eco/notify"
"github.com/deep-rent/nexus/net/notify"
"github.com/deep-rent/nexus/net/notify/mail"
"github.com/deep-rent/nexus/sec/seal"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/std/i18n"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/metrics"
"github.com/deep-rent/nexus/sys/queue"
)
// TicketPurpose binds an unsubscribe token to the one thing it may
// do, so a token minted for one purpose can never be redeemed for
// another.
const TicketPurpose = "hds:unsubscribe"
// TokenLifetime is how long an unsubscribe link works. It outlives
// any mailbox someone reads at leisure without becoming a permanent
// credential sitting in a mail archive.
const TokenLifetime = 90 * 24 * time.Hour
// ErrToken reports an unsubscribe token that does not verify, has
// expired, or was minted for something else.
var ErrToken = errors.New("invalid unsubscribe token")
// Sender dispatches a message, satisfied by [mail.Sender].
type Sender interface {
Send(ctx context.Context, msg *mail.Message) error
}
// Pusher asks for a push notification, satisfied by
// [notify.Publisher].
//
// The desk supplies a category and variables and never any text: what
// reaches a lock screen is rendered by the notification service from its
// own catalog, which is what keeps a ticket subject off a stranger's
// phone.
//
// [notify.Publisher]: github.com/deep-rent/nexus/eco/notify#Publisher
type Pusher interface {
Publish(ctx context.Context, req push.Request) (
push.Receipt, error)
}
// Config bundles the collaborators of a [Mailer].
type Config struct {
// Store reads the ticket a notification is about. Required.
Store *store.Store
// People resolves who to mail, in which language. Required.
People *identity.Directory
// Sender dispatches the mail. Required.
Sender Sender
// Keyring seals the unsubscribe tokens. Required.
Keyring *seal.Keyring
// Template is the provider-side template rendering the
// notification. Required.
Template string
// TicketURL is where a ticket lives in the frontend; the number
// is appended to it.
TicketURL string
// UnsubscribeURL is where an unsubscribe token is redeemed; the
// token travels as its "t" parameter.
UnsubscribeURL string
// Languages are the locales the template is published in, most
// preferred first.
Languages []string
// Pusher asks the notification service for a push alongside the
// mail. Optional: without one the desk mails and nothing else, which
// is how it worked before push existed.
//
// It is the same occasion as the mail and rides the same job, so a
// burst of edits collapses into one of each rather than one mail and
// six pushes.
Pusher Pusher
// Category names what the notification service should render, as
// its catalog declares it. Required with a Pusher.
Category string
// Logger receives diagnostics. Defaults to [log.Discard].
Logger *log.Logger
// Registry receives the instruments. Defaults to
// [metrics.DefaultRegistry].
Registry *metrics.Registry
// Clock is the time source. Defaults to [clock.System].
Clock clock.Clock
}
// Mailer turns queued notifications into mail.
type Mailer struct {
cfg Config
}
// New assembles a [Mailer]. It panics if a required collaborator is
// missing, since startup misconfiguration is a programmer error.
func New(cfg Config) *Mailer {
switch {
case cfg.Store == nil:
panic("store is required")
case cfg.People == nil:
panic("directory is required")
case cfg.Sender == nil:
panic("sender is required")
case cfg.Keyring == nil:
panic("keyring is required")
case cfg.Pusher != nil && cfg.Category == "":
panic("a category is required to push")
case cfg.Template == "":
panic("template is required")
}
if cfg.Logger == nil {
cfg.Logger = log.Discard()
}
if cfg.Registry == nil {
cfg.Registry = metrics.DefaultRegistry
}
if cfg.Clock == nil {
cfg.Clock = clock.System
}
return &Mailer{cfg: cfg}
}
// Handle is the queue handler for [desk.NotifyKind].
//
// It reads the ticket as it stands now rather than as it stood when
// the job was pushed, which is what makes a notification delayed by
// the debounce describe the truth rather than a moment that has
// passed. A recipient who unsubscribed in the meantime is not
// mailed, and a ticket that has since been deleted settles the job
// rather than retrying forever.
func (m *Mailer) Handle(ctx context.Context, job queue.Job) error {
var n desk.Notification
if err := json.Unmarshal(job.Payload, &n); err != nil {
// Nothing about a retry will make this parse.
return queue.Abort(fmt.Errorf(
"failed to parse the notification: %w", err,
))
}
var (
tk ticket.Ticket
p ticket.Participant
on bool
)
err := m.cfg.Store.Exec(ctx, func(
ctx context.Context, tx pgx.Tx,
) error {
var err error
if tk, err = m.cfg.Store.Ticket(ctx, tx, n.Ticket); err != nil {
return err
}
p, on, err = m.cfg.Store.Participant(ctx, tx, n.Ticket, n.User)
return err
})
switch {
case errors.Is(err, pgx.ErrNoRows):
// The ticket is gone; there is nothing to tell anyone about.
return nil
case err != nil:
return err
}
if !on || !p.Subscribed {
// They left the ticket or muted it while the job waited.
return nil
}
person, found, err := m.cfg.People.Resolve(ctx, n.User)
if err != nil {
return err
}
if !found || !person.Addressed() {
m.cfg.Logger.Warn(ctx, "Nobody to notify",
log.UUID("user", n.User),
log.Int("ticket", int(tk.ID)))
return nil
}
token, err := m.Token(n.Ticket, n.User)
if err != nil {
return err
}
// The mail carries no message body — only that something
// happened and where to read it. That keeps ticket content out of
// mailboxes and provider logs, and keeps a stranger's text out of
// a template that renders into HTML.
msg := mail.NewMessage(m.cfg.Template, person.Email).
WithCategory(notify.CategoryTransactional).
AddParameter("salutation", person.Salutation()).
AddParameter("ticket", strconv.FormatInt(tk.ID, 10)).
AddParameter("subject", tk.Subject).
AddParameter("status", string(tk.Status)).
AddParameter("url", m.ticketURL(tk.ID)).
AddParameter("unsubscribe_url", m.unsubscribeURL(token))
if lang := m.language(person); lang != "" {
msg = msg.WithLanguage(lang)
}
if err := m.cfg.Sender.Send(ctx, msg); err != nil {
return fmt.Errorf("failed to send a notification: %w", err)
}
m.cfg.Registry.Counter("hds_notifications_total").Inc()
m.push(ctx, n, tk)
return nil
}
// push asks the notification service for a push about the same ticket,
// after the mail has gone.
//
// It is deliberately best-effort, and deliberately last. The mail is the
// notification of record — it reaches somebody who has never installed
// the app, and it carries the unsubscribe link — so a push that fails
// must not cost the mail a retry that would send it twice. A person with
// no registered phone is the ordinary case rather than a failure, and
// the receipt says so without anything going wrong.
//
// The idempotency key is the job's own outbox key: the queue already
// collapses a burst of edits into one job per person per ticket, so
// reusing it means a retried job asks for the same notification rather
// than a second one.
func (m *Mailer) push(
ctx context.Context,
n desk.Notification,
tk ticket.Ticket,
) {
if m.cfg.Pusher == nil {
return
}
got, err := m.cfg.Pusher.Publish(ctx, push.Request{
Category: m.cfg.Category,
Recipients: []uuid.UUID{n.User},
Vars: map[string]string{
"ticket": strconv.FormatInt(tk.ID, 10),
"subject": tk.Subject,
"status": string(tk.Status),
},
// One ticket's notifications supersede one another on the
// phone: somebody returning to it wants the latest state, not
// six rows of history.
Collapse: strconv.FormatInt(tk.ID, 10),
Key: fmt.Sprintf("%s:%d:%s:%d",
desk.NotifyKind, tk.ID, n.User, tk.UpdatedAt.Unix()),
})
if err != nil {
m.cfg.Logger.Warn(ctx, "Could not ask for a push notification",
log.Int("ticket", int(tk.ID)),
log.UUID("user", n.User),
log.Bool("permanent", push.Permanent(err)),
log.Error(err),
)
return
}
if got.Reached() {
m.cfg.Registry.Counter("hds_pushes_total").Inc()
}
}
// stamped is what an unsubscribe token carries.
type stamped struct {
// Ticket and User are who is unsubscribing from what.
Ticket int64 `json:"t"`
User uuid.UUID `json:"u"`
// Expires is when the link stops working.
Expires time.Time `json:"e"`
}
// Token mints an unsubscribe token for one person on one ticket.
//
// It is sealed rather than signed, so the link carries no readable
// identifiers: a URL sitting in a mail archive says nothing about who
// it belongs to, and the pair inside it is covered by the
// authentication tag rather than merely accompanied by one.
//
// The binding is the purpose, which both sides know without reading
// the payload — binding the pair itself would be circular, since the
// pair is what the payload is for. What it buys is domain
// separation: a value sealed by anything else under the same keyring
// cannot be redeemed here.
func (m *Mailer) Token(id int64, user uuid.UUID) (string, error) {
body, err := json.Marshal(stamped{
Ticket: id,
User: user,
Expires: m.cfg.Clock().Add(TokenLifetime),
})
if err != nil {
return "", fmt.Errorf("failed to encode a token: %w", err)
}
sealed, err := m.cfg.Keyring.Seal(body, purpose)
if err != nil {
return "", fmt.Errorf("failed to seal a token: %w", err)
}
return base64.RawURLEncoding.EncodeToString(sealed), nil
}
// Redeem opens an unsubscribe token, reporting who it belongs to.
//
// Every refusal is [ErrToken] and says nothing further: at this
// surface the caller is whoever holds the link, and the difference
// between a forged token and an expired one is not theirs to learn.
func (m *Mailer) Redeem(token string) (int64, uuid.UUID, error) {
raw, err := base64.RawURLEncoding.DecodeString(token)
if err != nil {
return 0, uuid.Nil(), ErrToken
}
body, err := m.cfg.Keyring.Open(raw, purpose)
if err != nil {
return 0, uuid.Nil(), ErrToken
}
var claim stamped
if err := json.Unmarshal(body, &claim); err != nil {
return 0, uuid.Nil(), ErrToken
}
if m.cfg.Clock().After(claim.Expires) {
return 0, uuid.Nil(), ErrToken
}
return claim.Ticket, claim.User, nil
}
// purpose is the associated data every unsubscribe token is sealed
// under.
var purpose = []byte(TicketPurpose)
// ticketURL renders where a ticket lives in the frontend.
func (m *Mailer) ticketURL(id int64) string {
if m.cfg.TicketURL == "" {
return ""
}
return strings.TrimSuffix(m.cfg.TicketURL, "/") + "/" +
strconv.FormatInt(id, 10)
}
// unsubscribeURL renders where a token is redeemed.
func (m *Mailer) unsubscribeURL(token string) string {
if m.cfg.UnsubscribeURL == "" {
return ""
}
sep := "?"
if strings.Contains(m.cfg.UnsubscribeURL, "?") {
sep = "&"
}
return m.cfg.UnsubscribeURL + sep + "t=" + url.QueryEscape(token)
}
// language negotiates the recipient's preferred locale against the ones
// the template is published in: the best match by RFC 4647 lookup, the
// first published language otherwise, or none at all when the template
// declares no languages.
//
// A shared language beats an exact region, which is what the lookup
// scheme gives: someone asking for de-CH is served de rather than the
// fallback.
func (m *Mailer) language(p identity.Person) string {
if len(m.cfg.Languages) == 0 {
return ""
}
if tag, ok := i18n.Match(p.Locales, m.cfg.Languages); ok {
return tag
}
return m.cfg.Languages[0]
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package hds
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"github.com/deep-rent/nexus/eco/hds/api"
"github.com/deep-rent/nexus/eco/hds/attach"
"github.com/deep-rent/nexus/eco/hds/config"
"github.com/deep-rent/nexus/eco/hds/contact"
"github.com/deep-rent/nexus/eco/hds/desk"
"github.com/deep-rent/nexus/eco/hds/notify"
"github.com/deep-rent/nexus/eco/hds/store"
"github.com/deep-rent/nexus/eco/hds/ticket"
"github.com/deep-rent/nexus/eco/identity"
push "github.com/deep-rent/nexus/eco/notify"
"github.com/deep-rent/nexus/net/aws4"
"github.com/deep-rent/nexus/net/notify/hook"
hookadmin "github.com/deep-rent/nexus/net/notify/hook/admin"
"github.com/deep-rent/nexus/net/notify/mail"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/net/s3"
"github.com/deep-rent/nexus/net/turnstile"
"github.com/deep-rent/nexus/sec/auth"
"github.com/deep-rent/nexus/sys/boot"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/queue"
"github.com/deep-rent/nexus/sys/schedule"
)
// PermissionAdmin is the permission the webhook management surface
// demands. Machine clients need the scope itself; delegated (staff)
// tokens need the scope plus the support role.
const PermissionAdmin = "hds:admin"
// HookOwner is the owner every endpoint of this registry is filed
// under: the desk keeps one flat, staff-managed subscriber list.
const HookOwner = "hds"
// Paths the service mounts its unauthenticated surfaces at.
const (
// PathContact receives the website's contact form.
PathContact = "/contact"
// PathAdmin roots the webhook management surface.
PathAdmin = "/admin"
// PathHooks is where the identity service's webhook deliveries
// arrive; see the deployment README for the registration recipe.
PathHooks = "/hooks/iam"
)
// Tuning constants of the assembled service. They are deliberately
// not configuration: each is a property of the service's own shape
// rather than of the deployment around it. What every service shares —
// the header cap, the probe cadences, the shutdown margin — belongs to
// [boot] instead.
const (
// MaxBodySize caps a request body at 256 KiB. Messages are text
// and attachments never pass through this service, so nothing
// legitimate comes close.
MaxBodySize = 256 << 10
// Workers bounds the job fleet: webhook deliveries and
// notification mail share it, and both are IO on somebody else's
// server rather than work of this service's own.
Workers = 8
// NotifyRetries is how many times a notification is retried
// before it dead-letters. Mail is worth several attempts: the
// customer cannot tell a lost notification from being ignored.
NotifyRetries = 5
// NotifyTimeout bounds one attempt at sending.
NotifyTimeout = 30 * time.Second
// SweepInterval is how often abandoned tickets are closed and
// unconfirmed uploads are swept.
SweepInterval = time.Hour
)
// Service is the fully assembled help desk. Create instances with
// [New], serve them with [Service.Run], or embed [Service.Handler]
// into a custom server.
type Service struct {
cfg config.Config
rt *boot.Runtime
logger *log.Logger
store *store.Store
// people is the door to identity, held so that a deleted account
// can be dropped from its cache the moment the news arrives.
people *identity.Directory
// The desk itself, and the machinery around it. mailer and files
// are nil where the deployment left those capabilities off.
desk *desk.Engine
files *attach.Engine
mailer *notify.Mailer
}
// New assembles the service from its configuration. It returns an
// error for unusable external inputs — an unreachable database,
// unreadable sealing keys, a half-configured capability.
//
// The version identifies this build in the User-Agent of every
// outbound request; resolve it via [cli.Version].
//
// [cli.Version]: github.com/deep-rent/nexus/sys/cli#Version
func New(
ctx context.Context,
cfg config.Config,
version string,
) (*Service, error) {
rt, err := boot.New(ctx, boot.Spec{
Name: "hds",
Version: version,
Core: cfg.Core,
Database: &cfg.Database,
Auth: &cfg.Auth.Auth,
Sender: &cfg.Hook,
},
boot.WithMaxBody(MaxBodySize),
boot.WithWorkers(Workers),
)
if err != nil {
return nil, err
}
s := &Service{cfg: cfg, rt: rt, logger: rt.Logger()}
client := rt.Client()
s.store = store.New(rt.Pool())
rt.Migrate(store.Migrator)
rt.Every("sweep", SweepInterval, schedule.TaskFn(s.sweep))
// People are read from the identity service under this service's
// own machine credentials, since notifications and sweeps run with
// nobody signed in. The desk cannot manage without: every rendered
// thread names somebody, so an unconfigured directory is refused
// here rather than surfacing as an empty name on every page.
if !cfg.Directory.Enabled() {
return nil, errors.New(
"the help desk needs a directory URL and machine " +
"credentials; see the README",
)
}
s.people = identity.Open(cfg.Directory, client)
// Notifications need a key to seal their unsubscribe links with.
// Without one the desk still works and nobody is mailed — which is
// a deployment choice, not a failure.
var sender mail.Sender
if cfg.Mail.Enabled() {
ring, err := cfg.Mail.Keyring()
if err != nil {
return nil, err
}
sender = mail.NewSender(
cfg.Mail.AccessKey, cfg.Mail.Workspace, cfg.Mail.Channel,
)
// A push rides the same job as the mail. Without a notification
// service configured the desk mails and nothing else, which is
// how it worked before push existed.
var pusher notify.Pusher
if cfg.Notify.Enabled() {
pusher = push.Open(cfg.Notify.Config, client)
}
s.mailer = notify.New(notify.Config{
Store: s.store,
Pusher: pusher,
Category: cfg.Notify.Category,
People: s.people,
Sender: sender,
Keyring: ring,
Template: cfg.Mail.Notification,
TicketURL: cfg.Mail.TicketURL,
UnsubscribeURL: cfg.Mail.UnsubscribeURL,
Languages: cfg.Mail.Languages,
Logger: s.logger.Child("notify"),
})
} else {
s.logger.Warn(ctx,
"No mail configured; nobody is notified about their tickets",
)
}
opts := []desk.Option{
desk.WithLogger(s.logger.Child("desk")),
desk.WithTags(cfg.Tags...),
desk.WithReopenWindow(cfg.ReopenWindow),
desk.WithLimits(ticket.Limits{
Open: cfg.Limits.Open,
Monthly: cfg.Limits.Monthly,
}),
}
if h := rt.Hooks(); h != nil {
opts = append(opts, desk.WithPublisher(h))
}
if s.mailer != nil {
opts = append(opts, desk.WithNotifier(rt.Jobs()))
rt.Handle(desk.NotifyKind, s.mailer.Handle,
queue.HandlerTimeout(NotifyTimeout),
queue.HandlerRetries(NotifyRetries),
)
}
s.desk = desk.New(s.store, opts...)
if cfg.Storage.Enabled() {
bucket := s3.New(
cfg.Storage.Bucket,
aws4.New(aws4.Credentials{
AccessKey: cfg.Storage.AccessKey,
SecretKey: cfg.Storage.SecretKey,
}, cfg.Storage.Region),
s3.WithClient(client),
)
s.files = attach.New(s.store, bucket, cfg.Storage.Prefix,
attach.WithLogger(s.logger.Child("attach")),
attach.WithMaxSize(cfg.Storage.MaxSize),
attach.WithTypes(cfg.Storage.Types...),
)
}
r := rt.Router()
guard := rt.Guard()
// The application surface. Attachments and unsubscribe links need
// their engines, so a deployment without them serves a desk that
// simply cannot do those two things.
if s.files == nil || s.mailer == nil {
return nil, errors.New(
"the help desk needs storage and mail configured; see " +
"the README",
)
}
server := api.New(api.Config{
Desk: s.desk,
Files: s.files,
People: s.people,
Mailer: s.mailer,
StaffRole: cfg.Auth.StaffRole,
Tags: cfg.Tags,
})
server.Mount(r, guard.Secure())
server.MountPublic(r)
// The identity service tells this one when an account is deleted.
// Without it the desk keeps naming somebody who asked to be
// forgotten, so a deployment that leaves it unconfigured is
// warned rather than quietly served. The receiver carries no
// bearer guard: the request authenticates by its signature, which
// proves possession of the secret minted when this endpoint was
// registered with the identity service.
if cfg.Intake.Enabled() {
rcv, err := rt.Receiver(cfg.Intake)
if err != nil {
return nil, err
}
rcv.On(identity.TopicUserDeleted, s.forget).Mount(r, PathHooks)
} else {
s.logger.Warn(ctx,
"No identity webhook secret; deleted accounts stay named "+
"on their tickets until Forget is called by hand",
)
}
if cfg.Contact.Enabled() {
contact.New(contact.Config{
Verifier: turnstile.New(cfg.Contact.Secret,
turnstile.WithClient(client),
turnstile.WithLogger(s.logger.Child("turnstile")),
),
Sender: sender,
Template: cfg.Mail.Contact,
Recipient: cfg.Contact.Recipient,
Language: cfg.Contact.Language,
Burst: cfg.Contact.Burst,
Logger: s.logger.Child("contact"),
}).Mount(r, PathContact)
}
if h := rt.Hooks(); h != nil {
// One flat, staff-managed subscriber list. The desk names the
// owner itself, only its own topics may be subscribed to, and
// registering a receiver is an administrative act.
grants := auth.Grants{
cfg.Auth.StaffRole: {PermissionAdmin},
auth.RoleAdmin: {PermissionAdmin},
}
hookadmin.Mount(r.Group(PathAdmin), hookadmin.Config{
Hooks: h,
Owner: hookadmin.Fixed(HookOwner),
Topics: desk.Topics,
Internal: true,
Read: []router.Middleware{
guard.Secure(grants.Require(PermissionAdmin)),
},
})
}
s.logger.Info(ctx, "Assembled HDS service",
log.Int("tags", len(cfg.Tags)),
log.Bool("attachments", s.files != nil),
log.Bool("contact", cfg.Contact.Enabled()),
log.Bool("push", cfg.Notify.Enabled()),
log.Duration("reopen", cfg.ReopenWindow),
log.Duration("close_after", cfg.CloseAfter),
log.String("issuer", cfg.Auth.Issuer),
log.String("directory", cfg.Directory.URL),
)
return s, nil
}
// Handler returns the assembled HTTP handler, for embedding the API
// into a custom server.
func (s *Service) Handler() http.Handler { return s.rt.Handler() }
// Run serves the desk until the context is canceled or a termination
// signal arrives.
func (s *Service) Run(ctx context.Context) error { return s.rt.Run(ctx) }
// forget acts on a deleted account: the person's participations go,
// their assignments return to the queue, their authorship is
// anonymized, and the directory drops them from its cache.
//
// Forgetting is idempotent, so a redelivery costs a second pass over
// rows that are already anonymous.
func (s *Service) forget(e *router.Exchange, d hook.Delivery) error {
ev, err := identity.Decode(d)
if err != nil {
return err
}
id, ok := ev.User()
if !ok {
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: "delivery names no user",
}
}
// A 5xx makes the sender retry, which is right: the alternative is
// a person who asked to be forgotten still named on a thread.
if err := s.desk.Forget(e.Context(), id); err != nil {
return fmt.Errorf("failed to forget a deleted user: %w", err)
}
s.people.Forget(id)
return nil
}
// sweep closes tickets nobody came back to and collects uploads
// nobody confirmed.
func (s *Service) sweep(ctx context.Context) {
s.desk.Sweep(ctx, s.cfg.CloseAfter)
if s.files != nil {
s.files.Sweep(ctx, attach.DefaultOrphanAge)
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package store
import (
"context"
"fmt"
"strings"
"time"
"uuid"
"github.com/jackc/pgx/v5"
"github.com/deep-rent/nexus/eco/hds/ticket"
)
// MaxPage is the largest page a listing will answer with.
const MaxPage = 100
// Filter selects a page of tickets.
//
// The two audiences differ in one field: a staff filter leaves Viewer
// zero and sees the whole desk, while everyone else names themselves
// and sees the tickets they are on. Nothing else about a listing
// changes with the caller, so there is one query rather than two that
// could drift apart.
type Filter struct {
// Viewer scopes the listing to the tickets this person is on.
// Zero widens it to every ticket, which only staff may ask for.
Viewer uuid.UUID
// Status keeps one status; empty keeps every one.
Status ticket.Status
// Priority keeps one priority; empty keeps every one.
Priority ticket.Priority
// Tag keeps tickets carrying it; empty keeps every one.
Tag string
// Assignee keeps the tickets one staff member owns.
Assignee uuid.UUID
// Unassigned keeps only tickets nobody owns — the pending queue.
Unassigned bool
// Text keeps tickets whose subject or conversation matches. The
// conversation half honors visibility: a customer's search never
// reaches into internal notes.
Text string
// Internal admits internal notes to the text search, for staff.
Internal bool
// Before pages backwards through identifiers: only tickets below
// it are returned. Zero starts at the newest.
Before int64
// Limit caps the page, bounded by [MaxPage].
Limit int
}
// Tickets reads one page of the listing, newest first.
func (*Store) Tickets(
ctx context.Context,
tx pgx.Tx,
f Filter,
) ([]ticket.Ticket, error) {
var (
where []string
args []any
)
arg := func(v any) string {
args = append(args, v)
return fmt.Sprintf("$%d", len(args))
}
if f.Viewer != uuid.Nil() {
where = append(where, `EXISTS (
SELECT 1 FROM participants p
WHERE p.ticket_id = t.id AND p.user_id = `+arg(f.Viewer)+`)`)
}
if f.Status != "" {
where = append(where, "t.status = "+arg(f.Status))
}
if f.Priority != "" {
where = append(where, "t.priority = "+arg(f.Priority))
}
if f.Tag != "" {
where = append(where, "t.tags @> ARRAY["+arg(f.Tag)+"]::varchar[]")
}
if f.Assignee != uuid.Nil() {
where = append(where, "t.assignee = "+arg(f.Assignee))
}
if f.Unassigned {
where = append(where, "t.assignee IS NULL")
}
if f.Text != "" {
// The subject always matches; the conversation matches only
// through messages the asker may read.
//
// Two ways to match, because they fail differently. The
// tsvector finds whole words anywhere in a thread, which is
// what somebody quoting an error message wants. The subject
// additionally matches on substring, so "auth" reaches
// "authentication" — the tsvector cannot, since 'simple' does
// no stemming and a generated column cannot be bilingual.
q := arg(f.Text)
like := arg("%" + escapeLike(f.Text) + "%")
where = append(where, `(
t.search @@ plainto_tsquery('simple', `+q+`)
OR t.subject ILIKE `+like+` ESCAPE '`+string(likeEscape)+`'
OR EXISTS (
SELECT 1 FROM messages m
WHERE m.ticket_id = t.id
AND m.search @@ plainto_tsquery('simple', `+q+`)
AND (`+arg(f.Internal)+` OR m.visibility = `+
arg(ticket.VisibilityPublic)+`)
))`)
}
if f.Before > 0 {
where = append(where, "t.id < "+arg(f.Before))
}
limit := f.Limit
if limit <= 0 || limit > MaxPage {
limit = MaxPage
}
sb := strings.Builder{}
sb.WriteString("SELECT ")
sb.WriteString(ticketColumns)
sb.WriteString(" FROM tickets t")
if len(where) > 0 {
sb.WriteString(" WHERE ")
sb.WriteString(strings.Join(where, " AND "))
}
sb.WriteString(" ORDER BY t.id DESC LIMIT " + arg(limit))
rows, err := tx.Query(ctx, sb.String(), args...)
if err != nil {
return nil, fmt.Errorf("failed to list tickets: %w", err)
}
defer rows.Close()
var out []ticket.Ticket
for rows.Next() {
t, err := scanTicket(rows)
if err != nil {
return nil, fmt.Errorf("failed to scan a ticket: %w", err)
}
out = append(out, t)
}
return out, rows.Err()
}
// Counts reports how many tickets a creator holds open and how many
// they have opened since the given instant — the two numbers the
// per-user limits are judged against, read in one round trip because
// they are always asked together.
func (*Store) Counts(
ctx context.Context,
tx pgx.Tx,
creator uuid.UUID,
since time.Time,
) (open, recent int, err error) {
err = tx.QueryRow(ctx, `
SELECT
COUNT(*) FILTER (WHERE status <> $3),
COUNT(*) FILTER (WHERE created_at >= $2)
FROM tickets
WHERE creator = $1`,
creator, since.UTC(), ticket.StatusDone,
).Scan(&open, &recent)
if err != nil {
return 0, 0, fmt.Errorf("failed to count tickets: %w", err)
}
return open, recent, nil
}
// AddLink relates two tickets. Relating a pair twice is not an error;
// the newer kind wins, so promoting a related pair to duplicates is
// one call.
func (*Store) AddLink(
ctx context.Context,
tx pgx.Tx,
l ticket.Link,
) error {
// One row per pair, ordered so that either end finds it.
a, b := l.Ticket, l.Other
if a > b {
a, b = b, a
}
_, err := tx.Exec(ctx, `
INSERT INTO links (ticket_id, other_id, kind, created_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (ticket_id, other_id)
DO UPDATE SET kind = EXCLUDED.kind`,
a, b, l.Kind, l.CreatedAt.UTC(),
)
if err != nil {
return fmt.Errorf("failed to link tickets: %w", err)
}
return nil
}
// RemoveLink unrelates two tickets, reporting whether they were.
func (*Store) RemoveLink(
ctx context.Context,
tx pgx.Tx,
a, b int64,
) (bool, error) {
if a > b {
a, b = b, a
}
tag, err := tx.Exec(ctx,
`DELETE FROM links WHERE ticket_id = $1 AND other_id = $2`,
a, b,
)
if err != nil {
return false, fmt.Errorf("failed to unlink tickets: %w", err)
}
return tag.RowsAffected() > 0, nil
}
// Links reads everything related to one ticket, from either end. The
// far end is always reported as Other, whichever way the row was
// stored.
func (*Store) Links(
ctx context.Context,
tx pgx.Tx,
id int64,
) ([]ticket.Link, error) {
rows, err := tx.Query(ctx, `
SELECT
$1::bigint,
CASE WHEN ticket_id = $1 THEN other_id ELSE ticket_id END,
kind, created_at
FROM links
WHERE ticket_id = $1 OR other_id = $1
ORDER BY created_at, other_id`,
id,
)
if err != nil {
return nil, fmt.Errorf("failed to read the links: %w", err)
}
defer rows.Close()
var out []ticket.Link
for rows.Next() {
var l ticket.Link
if err := rows.Scan(
&l.Ticket, &l.Other, &l.Kind, &l.CreatedAt,
); err != nil {
return nil, fmt.Errorf("failed to scan a link: %w", err)
}
out = append(out, l)
}
return out, rows.Err()
}
// AddAttachment records an authorized upload, before the bytes exist
// and before the message it will hang on does.
func (*Store) AddAttachment(
ctx context.Context,
tx pgx.Tx,
a *ticket.Attachment,
) error {
err := tx.QueryRow(ctx, `
INSERT INTO attachments (
ticket_id, key, name, size, sha256, type, uploaded_by,
created_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id`,
a.Ticket, a.Key, a.Name, a.Size, a.SHA256, a.Type,
a.UploadedBy, a.CreatedAt.UTC(),
).Scan(&a.ID)
if err != nil {
return fmt.Errorf("failed to record an attachment: %w", err)
}
return nil
}
// Attachment reads one attachment.
func (*Store) Attachment(
ctx context.Context,
tx pgx.Tx,
id int64,
) (ticket.Attachment, error) {
return scanAttachment(tx.QueryRow(ctx, `
SELECT
id, ticket_id, COALESCE(message_id, 0), key, name, size,
sha256, type, stored, uploaded_by, created_at
FROM attachments WHERE id = $1`,
id,
))
}
// scanAttachment reads one attachment row.
func scanAttachment(row pgx.Row) (ticket.Attachment, error) {
var a ticket.Attachment
err := row.Scan(
&a.ID, &a.Ticket, &a.Message, &a.Key, &a.Name, &a.Size,
&a.SHA256, &a.Type, &a.Stored, &a.UploadedBy, &a.CreatedAt,
)
return a, err
}
// ConfirmAttachment marks an upload as landed and hangs it on the
// message it travelled with, reporting whether the row was still
// waiting for confirmation.
func (*Store) ConfirmAttachment(
ctx context.Context,
tx pgx.Tx,
id, message int64,
) (bool, error) {
tag, err := tx.Exec(ctx, `
UPDATE attachments SET stored = TRUE, message_id = $2
WHERE id = $1 AND NOT stored`,
id, message,
)
if err != nil {
return false, fmt.Errorf("failed to confirm an upload: %w", err)
}
return tag.RowsAffected() > 0, nil
}
// Attachments reads a ticket's confirmed files, filtered by the
// visibility of the message each hangs on — which is what keeps a log
// attached to an internal note out of a customer's hands.
func (*Store) Attachments(
ctx context.Context,
tx pgx.Tx,
id int64,
internal bool,
) ([]ticket.Attachment, error) {
rows, err := tx.Query(ctx, `
SELECT
a.id, a.ticket_id, COALESCE(a.message_id, 0), a.key, a.name,
a.size, a.sha256, a.type, a.stored, a.uploaded_by,
a.created_at
FROM attachments a
JOIN messages m ON m.id = a.message_id
WHERE a.ticket_id = $1 AND a.stored
AND ($2 OR m.visibility = $3)
ORDER BY a.id`,
id, internal, ticket.VisibilityPublic,
)
if err != nil {
return nil, fmt.Errorf("failed to read the attachments: %w", err)
}
defer rows.Close()
var out []ticket.Attachment
for rows.Next() {
a, err := scanAttachment(rows)
if err != nil {
return nil, fmt.Errorf("failed to scan an attachment: %w", err)
}
out = append(out, a)
}
return out, rows.Err()
}
// Orphans reads uploads authorized before the cutoff whose
// confirmation never came, so their objects can be swept from the
// bucket and their rows dropped.
func (*Store) Orphans(
ctx context.Context,
tx pgx.Tx,
before time.Time,
limit int,
) ([]ticket.Attachment, error) {
rows, err := tx.Query(ctx, `
SELECT
id, ticket_id, COALESCE(message_id, 0), key, name, size,
sha256, type, stored, uploaded_by, created_at
FROM attachments
WHERE NOT stored AND created_at < $1
ORDER BY created_at
LIMIT $2`,
before.UTC(), limit,
)
if err != nil {
return nil, fmt.Errorf("failed to read orphaned uploads: %w", err)
}
defer rows.Close()
var out []ticket.Attachment
for rows.Next() {
a, err := scanAttachment(rows)
if err != nil {
return nil, fmt.Errorf("failed to scan an attachment: %w", err)
}
out = append(out, a)
}
return out, rows.Err()
}
// DropAttachments removes attachment rows by identifier, after their
// objects are gone from the bucket.
func (*Store) DropAttachments(
ctx context.Context,
tx pgx.Tx,
ids []int64,
) error {
if len(ids) == 0 {
return nil
}
_, err := tx.Exec(ctx,
`DELETE FROM attachments WHERE id = ANY($1)`, ids,
)
if err != nil {
return fmt.Errorf("failed to drop attachments: %w", err)
}
return nil
}
// Stale reads tickets that have waited on their customer since before
// the cutoff — the auto-close sweep's worklist.
func (*Store) Stale(
ctx context.Context,
tx pgx.Tx,
before time.Time,
limit int,
) ([]ticket.Ticket, error) {
rows, err := tx.Query(ctx,
`SELECT `+ticketColumns+`
FROM tickets
WHERE status = $1 AND updated_at < $2
ORDER BY updated_at
LIMIT $3`,
ticket.StatusWaitingCustomer, before.UTC(), limit,
)
if err != nil {
return nil, fmt.Errorf("failed to read stale tickets: %w", err)
}
defer rows.Close()
var out []ticket.Ticket
for rows.Next() {
t, err := scanTicket(rows)
if err != nil {
return nil, fmt.Errorf("failed to scan a ticket: %w", err)
}
out = append(out, t)
}
return out, rows.Err()
}
// Unread reports which of the given tickets have moved since the
// viewer last read them — one question rather than one per row,
// because a listing asks it about every ticket on the page.
func (*Store) Unread(
ctx context.Context,
tx pgx.Tx,
user uuid.UUID,
ids []int64,
) (map[int64]bool, error) {
out := make(map[int64]bool, len(ids))
if user == uuid.Nil() || len(ids) == 0 {
return out, nil
}
rows, err := tx.Query(ctx, `
SELECT p.ticket_id
FROM participants p
JOIN tickets t ON t.id = p.ticket_id
WHERE p.user_id = $1 AND p.ticket_id = ANY($2)
AND (p.read_at IS NULL OR p.read_at < t.updated_at)`,
user, ids,
)
if err != nil {
return nil, fmt.Errorf("failed to read unread state: %w", err)
}
defer rows.Close()
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("failed to scan unread state: %w", err)
}
out[id] = true
}
return out, rows.Err()
}
// Subscribers reads the people who want mail about a ticket, other
// than the one who caused the change. It is the notification
// audience, and it deliberately excludes the actor: nobody needs a
// mail telling them what they just did.
func (*Store) Subscribers(
ctx context.Context,
tx pgx.Tx,
id int64,
except uuid.UUID,
) ([]uuid.UUID, error) {
rows, err := tx.Query(ctx, `
SELECT user_id FROM participants
WHERE ticket_id = $1 AND subscribed AND user_id <> $2
ORDER BY user_id`,
id, except,
)
if err != nil {
return nil, fmt.Errorf("failed to read the subscribers: %w", err)
}
defer rows.Close()
var out []uuid.UUID
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("failed to scan a subscriber: %w", err)
}
out = append(out, id)
}
return out, rows.Err()
}
// Forget erases one person from the desk without erasing the desk's
// history: their participations go, their assignments are released
// back to the queue, and their authorship is anonymized in place.
//
// The threads themselves stay. A support history that vanishes when
// one participant leaves is worse for everyone still on it, and the
// personal data in it — the address, the name — never lived here in
// the first place; it lived in the identity service, which is where
// the deletion started.
func (*Store) Forget(
ctx context.Context,
tx pgx.Tx,
user uuid.UUID,
) error {
statements := []struct {
what string
sql string
}{
{"participations", `
DELETE FROM participants WHERE user_id = $1`},
{"assignments", `
UPDATE tickets SET assignee = NULL WHERE assignee = $1`},
{"authorship", `
UPDATE messages
SET author = '00000000-0000-0000-0000-000000000000'::uuid
WHERE author = $1`},
{"actorship", `
UPDATE events SET actor = NULL WHERE actor = $1`},
{"uploads", `
UPDATE attachments
SET uploaded_by = '00000000-0000-0000-0000-000000000000'::uuid
WHERE uploaded_by = $1`},
}
for _, s := range statements {
if _, err := tx.Exec(ctx, s.sql, user); err != nil {
return fmt.Errorf("failed to forget %s: %w", s.what, err)
}
}
return nil
}
// likeEscape is the character the substring search escapes LIKE
// wildcards with, named in the ESCAPE clause the predicate emits.
const likeEscape = '\\'
// escapeLike neutralizes the wildcards in a search term, so a customer
// searching for "50%" or "read_at" gets those tickets rather than every
// ticket.
//
// The term is already a bound parameter, so this is about meaning
// rather than injection: without it, % and _ are pattern syntax the
// person typing them did not ask for.
func escapeLike(s string) string {
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
if r == '%' || r == '_' || r == likeEscape {
b.WriteRune(likeEscape)
}
b.WriteRune(r)
}
return b.String()
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package store
import (
"context"
"database/sql"
"embed"
"fmt"
"io/fs"
"time"
"uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/deep-rent/nexus/dat/migrate"
driver "github.com/deep-rent/nexus/dat/migrate/driver/postgres"
source "github.com/deep-rent/nexus/dat/migrate/source/file"
"github.com/deep-rent/nexus/eco/hds/ticket"
)
//go:embed migrations
var migrations embed.FS
// Module is the migration stream the help desk schema lives in.
const Module = "hds"
// Migrations exposes the embedded schema migrations for [migrate]
// paired with its PostgreSQL driver.
func Migrations() fs.FS {
sub, err := fs.Sub(migrations, "migrations")
if err != nil {
// The subdirectory is embedded at compile time; failing to
// open it is a build defect, not a runtime condition.
panic(err)
}
return sub
}
// Migrator wires a [migrate.Migrator] for the help desk schema over an
// existing database handle. The module, source, and driver are this
// schema's to declare; opts carry what the caller legitimately varies.
func Migrator(db *sql.DB, opts ...migrate.Option) *migrate.Migrator {
return migrate.New(append([]migrate.Option{
migrate.WithModule(Module),
migrate.WithSource(source.New(Migrations())),
migrate.WithDriver(driver.New(db)),
}, opts...)...)
}
// Open connects a migrator for the help desk schema to the database at
// url, for commands that only run migrations. The returned close
// function releases the connection.
func Open(
url string,
opts ...migrate.Option,
) (*migrate.Migrator, func() error, error) {
return driver.Open(url, append([]migrate.Option{
migrate.WithModule(Module),
migrate.WithSource(source.New(Migrations())),
}, opts...)...)
}
// Store persists tickets and everything hanging off them. It is safe
// for concurrent use and carries no logger: every operation returns
// its error, and narrating outcomes is its callers' business.
type Store struct {
pool *pgxpool.Pool
}
// New creates a [Store] over the given connection pool. It panics on a
// nil pool (programmer error).
func New(pool *pgxpool.Pool) *Store {
if pool == nil {
panic("pool is required")
}
return &Store{pool: pool}
}
// Exec runs fn within a single transaction, so that a change and the
// events, notifications, and webhooks announcing it either all land or
// none do.
func (s *Store) Exec(
ctx context.Context,
fn func(ctx context.Context, tx pgx.Tx) error,
) error {
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
return fn(ctx, tx)
})
}
// ticketColumns is the read shape of a ticket, in scanTicket order.
// The NULL columns collapse to sentinels the record models as zero
// values, so every read of a ticket scans the same shape.
const ticketColumns = "id, subject, status, priority, creator, " +
"COALESCE(assignee, '00000000-0000-0000-0000-000000000000'::uuid), " +
"tags, COALESCE(meta, 'null'::jsonb), created_at, updated_at, " +
"COALESCE(answered_at, 'epoch'::timestamptz), " +
"COALESCE(closed_at, 'epoch'::timestamptz)"
// tags renders a tag set for a NOT NULL array column: an untagged
// ticket carries no tags, which is an empty array rather than the
// absence of one, and a nil slice would reach the column as NULL.
func tags(t []string) []string {
if t == nil {
return []string{}
}
return t
}
// scanTicket reads one ticket row in ticketColumns order.
func scanTicket(row pgx.Row) (ticket.Ticket, error) {
var t ticket.Ticket
err := row.Scan(
&t.ID, &t.Subject, &t.Status, &t.Priority, &t.Creator,
&t.Assignee, &t.Tags, &t.Meta, &t.CreatedAt, &t.UpdatedAt,
&t.AnsweredAt, &t.ClosedAt,
)
if err != nil {
return ticket.Ticket{}, err
}
// The epoch sentinels stand in for NULL, which the record models
// as the zero time.
if t.AnsweredAt.Unix() == 0 {
t.AnsweredAt = time.Time{}
}
if t.ClosedAt.Unix() == 0 {
t.ClosedAt = time.Time{}
}
return t, nil
}
// CreateTicket opens a ticket, assigning its number, and enrolls the
// creator as a participant in the same breath — a ticket nobody is on
// is unreachable by its own author.
func (s *Store) CreateTicket(
ctx context.Context,
tx pgx.Tx,
t *ticket.Ticket,
) error {
var assignee *uuid.UUID
if t.Assignee != uuid.Nil() {
assignee = &t.Assignee
}
err := tx.QueryRow(ctx, `
INSERT INTO tickets (
subject, status, priority, creator, assignee, tags, meta,
created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id`,
t.Subject, t.Status, t.Priority, t.Creator, assignee,
tags(t.Tags), t.Meta, t.CreatedAt.UTC(), t.UpdatedAt.UTC(),
).Scan(&t.ID)
if err != nil {
return fmt.Errorf("failed to open a ticket: %w", err)
}
return s.AddParticipant(ctx, tx, ticket.Participant{
Ticket: t.ID,
User: t.Creator,
Role: ticket.RoleCreator,
Subscribed: true,
AddedAt: t.CreatedAt,
})
}
// Ticket reads one ticket. It reports [pgx.ErrNoRows] for a ticket
// that does not exist, which callers map onto the same answer they
// give for one the viewer may not see.
func (*Store) Ticket(
ctx context.Context,
tx pgx.Tx,
id int64,
) (ticket.Ticket, error) {
return scanTicket(tx.QueryRow(ctx,
`SELECT `+ticketColumns+` FROM tickets WHERE id = $1`, id,
))
}
// SaveTicket writes back the mutable columns of a ticket. The engine
// owns which of them may change and records the events; this only
// stores the result.
func (*Store) SaveTicket(
ctx context.Context,
tx pgx.Tx,
t ticket.Ticket,
) error {
var assignee *uuid.UUID
if t.Assignee != uuid.Nil() {
assignee = &t.Assignee
}
var answered, closed *time.Time
if !t.AnsweredAt.IsZero() {
at := t.AnsweredAt.UTC()
answered = &at
}
if !t.ClosedAt.IsZero() {
at := t.ClosedAt.UTC()
closed = &at
}
_, err := tx.Exec(ctx, `
UPDATE tickets SET
subject = $2, status = $3, priority = $4, assignee = $5,
tags = $6, updated_at = $7, answered_at = $8, closed_at = $9
WHERE id = $1`,
t.ID, t.Subject, t.Status, t.Priority, assignee, tags(t.Tags),
t.UpdatedAt.UTC(), answered, closed,
)
if err != nil {
return fmt.Errorf("failed to save a ticket: %w", err)
}
return nil
}
// AddMessage appends one message to a ticket.
func (*Store) AddMessage(
ctx context.Context,
tx pgx.Tx,
m *ticket.Message,
) error {
err := tx.QueryRow(ctx, `
INSERT INTO messages (
ticket_id, author, staff, visibility, body, created_at
)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id`,
m.Ticket, m.Author, m.Staff, m.Visibility, m.Body,
m.CreatedAt.UTC(),
).Scan(&m.ID)
if err != nil {
return fmt.Errorf("failed to append a message: %w", err)
}
return nil
}
// Messages reads a ticket's conversation in order. Internal notes are
// included only when the viewer may see them; see [ticket.Audience].
func (*Store) Messages(
ctx context.Context,
tx pgx.Tx,
id int64,
internal bool,
) ([]ticket.Message, error) {
rows, err := tx.Query(ctx, `
SELECT id, ticket_id, author, staff, visibility, body, created_at
FROM messages
WHERE ticket_id = $1 AND ($2 OR visibility = $3)
ORDER BY id`,
id, internal, ticket.VisibilityPublic,
)
if err != nil {
return nil, fmt.Errorf("failed to read the conversation: %w", err)
}
defer rows.Close()
var out []ticket.Message
for rows.Next() {
var m ticket.Message
if err := rows.Scan(
&m.ID, &m.Ticket, &m.Author, &m.Staff, &m.Visibility,
&m.Body, &m.CreatedAt,
); err != nil {
return nil, fmt.Errorf("failed to scan a message: %w", err)
}
out = append(out, m)
}
return out, rows.Err()
}
// AddEvent records one change to a ticket.
func (*Store) AddEvent(
ctx context.Context,
tx pgx.Tx,
e *ticket.Event,
) error {
var actor *uuid.UUID
if e.Actor != uuid.Nil() {
actor = &e.Actor
}
err := tx.QueryRow(ctx, `
INSERT INTO events (
ticket_id, actor, kind, internal, from_value, to_value,
created_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`,
e.Ticket, actor, e.Kind, e.Internal, e.From, e.To,
e.CreatedAt.UTC(),
).Scan(&e.ID)
if err != nil {
return fmt.Errorf("failed to record an event: %w", err)
}
return nil
}
// Events reads a ticket's recorded changes in order, filtered like
// [Store.Messages].
func (*Store) Events(
ctx context.Context,
tx pgx.Tx,
id int64,
internal bool,
) ([]ticket.Event, error) {
rows, err := tx.Query(ctx, `
SELECT
id, ticket_id,
COALESCE(actor, '00000000-0000-0000-0000-000000000000'::uuid),
kind, internal, from_value, to_value, created_at
FROM events
WHERE ticket_id = $1 AND ($2 OR NOT internal)
ORDER BY id`,
id, internal,
)
if err != nil {
return nil, fmt.Errorf("failed to read the history: %w", err)
}
defer rows.Close()
var out []ticket.Event
for rows.Next() {
var e ticket.Event
if err := rows.Scan(
&e.ID, &e.Ticket, &e.Actor, &e.Kind, &e.Internal,
&e.From, &e.To, &e.CreatedAt,
); err != nil {
return nil, fmt.Errorf("failed to scan an event: %w", err)
}
out = append(out, e)
}
return out, rows.Err()
}
// AddParticipant puts someone on a ticket, or refreshes how they came
// to be there. Re-sharing with the same person is not an error.
func (*Store) AddParticipant(
ctx context.Context,
tx pgx.Tx,
p ticket.Participant,
) error {
var addedBy *uuid.UUID
if p.AddedBy != uuid.Nil() {
addedBy = &p.AddedBy
}
_, err := tx.Exec(ctx, `
INSERT INTO participants (
ticket_id, user_id, role, subscribed, added_by, added_at
)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (ticket_id, user_id) DO NOTHING`,
p.Ticket, p.User, p.Role, p.Subscribed, addedBy,
p.AddedAt.UTC(),
)
if err != nil {
return fmt.Errorf("failed to add a participant: %w", err)
}
return nil
}
// RemoveParticipant takes someone off a ticket, reporting whether they
// were on it. The creator cannot be removed: a ticket without its
// author is unreachable by the person it belongs to.
func (*Store) RemoveParticipant(
ctx context.Context,
tx pgx.Tx,
id int64,
user uuid.UUID,
) (bool, error) {
tag, err := tx.Exec(ctx, `
DELETE FROM participants
WHERE ticket_id = $1 AND user_id = $2 AND role <> $3`,
id, user, ticket.RoleCreator,
)
if err != nil {
return false, fmt.Errorf("failed to remove a participant: %w", err)
}
return tag.RowsAffected() > 0, nil
}
// Participants reads everyone on a ticket.
func (*Store) Participants(
ctx context.Context,
tx pgx.Tx,
id int64,
) ([]ticket.Participant, error) {
rows, err := tx.Query(ctx, `
SELECT
ticket_id, user_id, role, subscribed,
COALESCE(read_at, 'epoch'::timestamptz),
COALESCE(added_by, '00000000-0000-0000-0000-000000000000'::uuid),
added_at
FROM participants
WHERE ticket_id = $1
ORDER BY added_at, user_id`,
id,
)
if err != nil {
return nil, fmt.Errorf("failed to read the participants: %w", err)
}
defer rows.Close()
var out []ticket.Participant
for rows.Next() {
var p ticket.Participant
if err := rows.Scan(
&p.Ticket, &p.User, &p.Role, &p.Subscribed, &p.ReadAt,
&p.AddedBy, &p.AddedAt,
); err != nil {
return nil, fmt.Errorf("failed to scan a participant: %w", err)
}
if p.ReadAt.Unix() == 0 {
p.ReadAt = time.Time{}
}
out = append(out, p)
}
return out, rows.Err()
}
// Participant reads one person's place on a ticket, reporting whether
// they are on it at all.
func (s *Store) Participant(
ctx context.Context,
tx pgx.Tx,
id int64,
user uuid.UUID,
) (ticket.Participant, bool, error) {
all, err := s.Participants(ctx, tx, id)
if err != nil {
return ticket.Participant{}, false, err
}
for _, p := range all {
if p.User == user {
return p, true, nil
}
}
return ticket.Participant{}, false, nil
}
// Subscribe sets whether a participant wants mail about a ticket.
func (*Store) Subscribe(
ctx context.Context,
tx pgx.Tx,
id int64,
user uuid.UUID,
subscribed bool,
) (bool, error) {
tag, err := tx.Exec(ctx, `
UPDATE participants SET subscribed = $3
WHERE ticket_id = $1 AND user_id = $2`,
id, user, subscribed,
)
if err != nil {
return false, fmt.Errorf("failed to set the subscription: %w", err)
}
return tag.RowsAffected() > 0, nil
}
// MarkRead stamps when a participant last read a ticket, which is what
// unread activity is measured against.
func (*Store) MarkRead(
ctx context.Context,
tx pgx.Tx,
id int64,
user uuid.UUID,
at time.Time,
) error {
_, err := tx.Exec(ctx, `
UPDATE participants SET read_at = $3
WHERE ticket_id = $1 AND user_id = $2`,
id, user, at.UTC(),
)
if err != nil {
return fmt.Errorf("failed to stamp the read: %w", err)
}
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package ticket
import (
"errors"
"time"
)
// ErrClosed reports a reply to a ticket that closed too long ago to
// reopen. The exchange is over; the answer is a new ticket, which the
// caller may link to this one.
var ErrClosed = errors.New("ticket is closed")
// ErrTooManyOpen reports a creator already holding as many open
// tickets as the deployment allows.
var ErrTooManyOpen = errors.New("too many open tickets")
// ErrTooManyMonthly reports a creator who has opened as many tickets
// this month as the deployment allows.
var ErrTooManyMonthly = errors.New("monthly ticket limit reached")
// Reply decides where a message leaves the ticket, which is what makes
// the status mean anything:
//
// - A public answer from staff hands the ticket to the customer.
// - Anything the customer says hands it back to support.
// - An internal note changes nothing at all; staff annotating a
// thread is not an answer, and must not look like one.
//
// A closed ticket reopens on a reply within the window — the customer
// who says "this came back" continues the conversation they had rather
// than starting a stranger's — and refuses one after it with
// [ErrClosed]. Staff replying to a closed ticket always reopen it,
// since they are adding something the customer is meant to read.
func Reply(
t Ticket,
author Role,
visibility Visibility,
window time.Duration,
now time.Time,
) (Status, error) {
if visibility == VisibilityInternal {
// A note is written about the ticket, not into the
// conversation, so it moves nothing — not even a closed one.
return t.Status, nil
}
if t.Status == StatusDone &&
author.Customer() && !Reopenable(t, window, now) {
return "", ErrClosed
}
if author.Customer() {
return StatusWaitingSupport, nil
}
return StatusWaitingCustomer, nil
}
// Reopenable reports whether a closed ticket is still within the
// window in which a customer's reply continues it. A zero or negative
// window closes tickets for good; a ticket that is not closed is
// trivially reopenable.
func Reopenable(t Ticket, window time.Duration, now time.Time) bool {
if t.Status != StatusDone {
return true
}
if window <= 0 {
return false
}
if t.ClosedAt.IsZero() {
// Closed without a stamp — an older row, or one closed by a
// path that forgot. Refusing the reply would strand the
// customer, so the benefit of the doubt goes to them.
return true
}
return !now.After(t.ClosedAt.Add(window))
}
// Audience decides what a viewer may read of a ticket.
//
// It is the one answer to that question in the service. A surface that
// asked it for itself would eventually disagree, and the way that
// disagreement shows up is an internal note in front of a customer.
type Audience struct {
// Staff reports whether the viewer holds the support role, and so
// reads everything including internal notes.
Staff bool
// Participant reports whether the viewer is on the ticket, and so
// reads its public messages.
Participant bool
}
// Reads reports whether the audience may read the ticket at all.
func (a Audience) Reads() bool { return a.Staff || a.Participant }
// Sees reports whether the audience may read a message, an attachment,
// or an event of the given visibility.
func (a Audience) Sees(internal bool) bool {
if internal {
return a.Staff
}
return a.Reads()
}
// Writes reports whether the audience may write to the ticket. Staff
// write to any ticket; everyone else must be on it.
func (a Audience) Writes() bool { return a.Reads() }
// Limits caps how many tickets one person may hold. Staff are never
// counted against them: an agent opening a ticket on a customer's
// behalf is doing the job, not consuming an allowance.
type Limits struct {
// Open caps simultaneously open tickets. Zero leaves it uncapped.
Open int
// Monthly caps tickets opened within a rolling window of
// [MonthlyWindow]. Zero leaves it uncapped.
Monthly int
}
// MonthlyWindow is the span the monthly limit counts over. It rolls
// rather than following the calendar, so a limit cannot be doubled by
// opening tickets on the last day of one month and the first of the
// next.
const MonthlyWindow = 30 * 24 * time.Hour
// Allow reports whether someone holding open open tickets, having
// opened monthly within the window, may open one more.
func (l Limits) Allow(open, monthly int) error {
if l.Open > 0 && open >= l.Open {
return ErrTooManyOpen
}
if l.Monthly > 0 && monthly >= l.Monthly {
return ErrTooManyMonthly
}
return nil
}
// Unread reports whether a participant has activity waiting: the
// ticket moved after they last read it. A participant who never read
// it has unread activity by definition.
func Unread(p Participant, t Ticket) bool {
return p.ReadAt.Before(t.UpdatedAt)
}
// Stale reports whether a ticket has waited on its customer long
// enough to be closed automatically. Only [StatusWaitingCustomer]
// goes stale: a ticket waiting on support is the desk's own backlog,
// and closing it would hide the failure rather than fix it.
func Stale(t Ticket, after time.Duration, now time.Time) bool {
if t.Status != StatusWaitingCustomer || after <= 0 {
return false
}
return now.After(t.UpdatedAt.Add(after))
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package ticket
import (
"encoding/json/jsontext"
"slices"
"time"
"uuid"
)
// Status is where a ticket stands. There are three, and no deployment
// may add a fourth: a status vocabulary that grows is how a help desk
// becomes an issue tracker.
type Status string
const (
// StatusWaitingSupport means the customer has spoken last and
// support owes an answer. It is the only status that belongs in a
// backlog.
StatusWaitingSupport Status = "waiting_support"
// StatusWaitingCustomer means support has answered and the ticket
// rests with the customer.
StatusWaitingCustomer Status = "waiting_customer"
// StatusDone means the exchange is over.
StatusDone Status = "done"
)
// Statuses lists every status.
var Statuses = []Status{
StatusWaitingSupport, StatusWaitingCustomer, StatusDone,
}
// Valid reports whether the status is one this service knows.
func (s Status) Valid() bool { return slices.Contains(Statuses, s) }
// Open reports whether the ticket is still live, which is what the
// per-user limit on simultaneous tickets counts.
func (s Status) Open() bool { return s != StatusDone }
// Priority is how urgent a ticket is. Only staff set it; a customer
// naming their own priority would make the field meaningless.
type Priority string
// The priority vocabulary, least urgent first.
const (
PriorityLow Priority = "low"
PriorityMedium Priority = "medium"
PriorityHigh Priority = "high"
)
// Priorities lists every priority, least urgent first.
var Priorities = []Priority{PriorityLow, PriorityMedium, PriorityHigh}
// Valid reports whether the priority is one this service knows.
func (p Priority) Valid() bool { return slices.Contains(Priorities, p) }
// Visibility decides who a message reaches.
type Visibility string
const (
// VisibilityPublic reaches everyone on the ticket.
VisibilityPublic Visibility = "public"
// VisibilityInternal reaches staff alone. It is the note one agent
// leaves another, and it must never surface to a participant.
VisibilityInternal Visibility = "internal"
)
// Valid reports whether the visibility is one this service knows.
func (v Visibility) Valid() bool {
return v == VisibilityPublic || v == VisibilityInternal
}
// Role is how a person relates to one ticket.
type Role string
const (
// RoleCreator opened the ticket.
RoleCreator Role = "creator"
// RoleShared was added to it by another participant.
RoleShared Role = "shared"
// RoleStaff holds the deployment's support role. Staff are not
// participants of a ticket: they reach every ticket by role, which
// is why the role is resolved from the token rather than stored.
RoleStaff Role = "staff"
)
// Customer reports whether the role belongs to the asking side of the
// conversation — the people a "waiting for customer" status waits on.
func (r Role) Customer() bool {
return r == RoleCreator || r == RoleShared
}
// Link relates one ticket to another.
type Link struct {
// Ticket is the ticket the link hangs on.
Ticket int64
// Other is the ticket at the far end.
Other int64
// Kind is how they relate.
Kind LinkKind
// CreatedAt is when the link was drawn.
CreatedAt time.Time
}
// LinkKind is how two tickets relate. Duplicates are a link rather
// than a merge: merging rewrites history, while a link leaves both
// threads readable and says which one to follow.
type LinkKind string
const (
// LinkRelated marks tickets worth reading together.
LinkRelated LinkKind = "related"
// LinkDuplicate marks a ticket that says the same thing as another.
LinkDuplicate LinkKind = "duplicate"
)
// LinkKinds lists every link kind.
var LinkKinds = []LinkKind{LinkRelated, LinkDuplicate}
// Valid reports whether the kind is one this service knows.
func (k LinkKind) Valid() bool { return slices.Contains(LinkKinds, k) }
// Ticket is one support conversation.
//
// Its identifier is the number people quote: a sequence assigned by
// the database, readable over the phone and in an email subject, where
// a UUID would be neither.
type Ticket struct {
// ID identifies the ticket and is the number shown to humans.
ID int64
// Subject is the one-line summary the creator wrote.
Subject string
// Status is where the ticket stands; see [Reply].
Status Status
// Priority is how urgent staff judged it.
Priority Priority
// Creator is the IAM user who opened the ticket.
Creator uuid.UUID
// Assignee is the staff member who owns it, zero while the ticket
// waits in the unassigned queue.
Assignee uuid.UUID
// Tags group the ticket, drawn from the deployment's configured
// vocabulary.
Tags []string
// Meta is the context an app attaches at creation — OS and app
// version, device model, a build number, an issue URL. It is
// opaque to this service, bounded in size, and never trusted for
// anything but display.
//
// Storage canonicalizes it: key order and whitespace are not
// preserved, and duplicate keys collapse. What survives is the
// document's meaning, which is all a diagnostic context is read
// for — and it stays queryable for the day someone asks which
// tickets came from one app version.
Meta jsontext.Value
// CreatedAt is when the ticket was opened.
CreatedAt time.Time
// UpdatedAt is when it last saw activity.
UpdatedAt time.Time
// AnsweredAt is when staff first answered publicly, zero until
// they have. It is what a first-response time is measured from.
AnsweredAt time.Time
// ClosedAt is when the ticket last reached [StatusDone], zero
// while it is open. [Reopenable] measures the reopen window from
// it.
ClosedAt time.Time
}
// Message is one entry in a ticket's conversation.
type Message struct {
// ID identifies the message.
ID int64
// Ticket is the ticket it belongs to.
Ticket int64
// Author is the IAM user who wrote it.
Author uuid.UUID
// Staff records whether the author wrote as staff. It is stamped
// at write time rather than resolved at read time, so a thread
// still reads correctly after someone leaves the support team.
Staff bool
// Visibility decides who may read it.
Visibility Visibility
// Body is the text as the author typed it. It is stored verbatim:
// escaping belongs to whoever renders it, and escaping on the way
// in would corrupt every pasted log and stack trace.
Body string
// CreatedAt is when it was written.
CreatedAt time.Time
}
// Participant is one person on a ticket.
type Participant struct {
// Ticket is the ticket they are on.
Ticket int64
// User is the IAM user.
User uuid.UUID
// Role is how they came to be here.
Role Role
// Subscribed reports whether they want mail about it.
Subscribed bool
// ReadAt is when they last read the thread, zero if never. It is
// what "new since you looked" is measured against.
ReadAt time.Time
// AddedBy is the participant who shared the ticket with them, zero
// for the creator.
AddedBy uuid.UUID
// AddedAt is when they joined.
AddedAt time.Time
}
// EventKind names a change worth recording in the thread.
type EventKind string
// The event vocabulary a ticket's history is written in.
const (
EventOpened EventKind = "opened"
EventStatus EventKind = "status"
EventPriority EventKind = "priority"
EventAssigned EventKind = "assigned"
EventUnassigned EventKind = "unassigned"
EventTagged EventKind = "tagged"
EventUntagged EventKind = "untagged"
EventLinked EventKind = "linked"
EventUnlinked EventKind = "unlinked"
EventShared EventKind = "shared"
EventUnshared EventKind = "unshared"
EventAttached EventKind = "attached"
)
// Event is one recorded change to a ticket.
//
// Events and messages are one ordered stream, not two: the thread IS
// the audit log, so "who closed this, and when did it change hands"
// is answered by reading it rather than by trusting a memory.
type Event struct {
// ID identifies the event.
ID int64
// Ticket is the ticket it happened to.
Ticket int64
// Actor is the IAM user who caused it, zero when the service
// itself did — an automatic close, say.
Actor uuid.UUID
// Kind is what happened.
Kind EventKind
// Internal keeps an event to staff, for changes a customer has no
// business seeing.
Internal bool
// From and To carry the change where one has two sides: a status,
// a priority, an assignee. Either may be empty.
From string
To string
// CreatedAt is when it happened.
CreatedAt time.Time
}
// Attachment is one file hanging on a message.
//
// It belongs to a message rather than to the ticket, so that a log
// file attached to an internal note inherits that note's visibility.
// Hanging files on the ticket instead is how a screenshot meant for
// one agent ends up in front of a customer.
type Attachment struct {
// ID identifies the attachment.
ID int64
// Ticket and Message are what it hangs on. Message is zero only
// while the attachment is still pending, before the message it
// belongs to exists.
Ticket int64
Message int64
// Key is the object key in the bucket.
Key string
// Name is the file name as the uploader gave it, for display and
// for the download's filename. It is never part of Key.
Name string
// Size is the announced length in bytes, confirmed against the
// object the provider reports.
Size int64
// SHA256 is the announced content digest, likewise confirmed.
SHA256 string
// Type is the announced content type.
Type string
// Stored reports whether the upload was confirmed against the
// bucket. An unconfirmed attachment is invisible and sweepable.
Stored bool
// UploadedBy is the IAM user who uploaded it.
UploadedBy uuid.UUID
// CreatedAt is when the upload was authorized.
CreatedAt time.Time
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package account
import (
"cmp"
"context"
"net/http"
"time"
"uuid"
"github.com/deep-rent/nexus/eco/iam/authn"
"github.com/deep-rent/nexus/eco/iam/avatar"
"github.com/deep-rent/nexus/eco/iam/limit"
"github.com/deep-rent/nexus/eco/iam/login"
"github.com/deep-rent/nexus/eco/iam/passkey"
"github.com/deep-rent/nexus/eco/iam/post"
"github.com/deep-rent/nexus/eco/iam/ticket"
"github.com/deep-rent/nexus/eco/iam/user"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/net/throttle"
"github.com/deep-rent/nexus/sec/auth"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/sys/log"
)
// Endpoint paths registered by [Server.Mount], relative to the mount
// prefix.
const (
// PathAccount serves the profile.
PathAccount = "/account"
// PathPassword changes the password interactively.
PathPassword = "/account/password"
// PathPasswordForgot starts a password recovery by mail.
PathPasswordForgot = "/account/password/forgot"
// PathPasswordReset redeems a recovery ticket for a new password.
PathPasswordReset = "/account/password/reset"
// PathEmail starts an email change or re-verification.
PathEmail = "/account/email"
// PathEmailConfirm redeems an email confirmation ticket, for primary
// and recovery addresses alike.
PathEmailConfirm = "/account/email/confirm"
// PathRecovery manages the recovery email address.
PathRecovery = "/account/recovery"
// PathSessions manages the user's login sessions.
PathSessions = "/account/sessions"
// PathDevices manages the user's remembered devices.
PathDevices = "/account/devices"
// PathPasskeys manages the user's passkeys.
PathPasskeys = "/account/passkeys"
// PathFactors manages the user's second factors.
PathFactors = "/account/factors"
// PathAuthenticator manages the user's authenticator app.
PathAuthenticator = "/account/authenticator"
// PathAuthenticatorConfirm proves a pending authenticator enrollment.
PathAuthenticatorConfirm = "/account/authenticator/confirm"
// PathRecoveryCodes manages the user's recovery codes.
PathRecoveryCodes = "/account/recovery-codes"
// PathAvatar is where an avatar upload is granted and the avatar
// removed.
PathAvatar = "/account/avatar"
// PathAvatarConfirm is where a pending avatar upload is confirmed.
PathAvatarConfirm = "/account/avatar/confirm"
)
// Ticket purposes minted and redeemed by the mail flows.
const (
// PurposeVerifyEmail confirms ownership of a primary email address.
PurposeVerifyEmail = "verify:email"
// PurposeVerifyRecovery confirms ownership of a recovery email
// address.
PurposeVerifyRecovery = "verify:recovery"
// PurposeResetPassword authorizes one password reset.
PurposeResetPassword = "reset:password"
)
// TokenRevoker revokes the refresh tokens standing for a user, so that a
// credential change also cuts the OAuth grant chains established under the
// old credential. Both IAM drivers satisfy it.
type TokenRevoker interface {
DeleteRefreshTokensForUser(
ctx context.Context,
userID uuid.UUID,
) error
}
// Config bundles the required collaborators of a [Server]. Optional
// behavior — mail flows, token revocation, throttling — attaches through
// options.
type Config struct {
// Login is the authentication core whose sessions authenticate this API
// and whose engines carry out revocations. Required.
Login *login.Manager
// Users is the identity engine behind the IAM server. Required.
Users *user.Manager
// Throttle is the shared limiter guarding the public endpoints. If nil,
// throttling is disabled; see [throttle.Throttle] for the caveats.
Throttle *throttle.Throttle
// ThrottlePenalty is the number of tokens a failed or abusable request
// costs. Defaults to [DefaultThrottlePenalty] when nonpositive.
ThrottlePenalty int
// Logger receives diagnostics for best-effort work such as mail
// dispatch. Defaults to [log.Discard].
Logger *log.Logger
}
// Server implements the self-service API over the IAM engines. Create
// instances with [New] and attach the routes with [Server.Mount].
type Server struct {
login *login.Manager
users *user.Manager
store user.Store
tickets ticket.Store
verify *ticket.Manager
reset *ticket.Manager
post *post.Mailer
credentials passkey.CredentialStore
authn *authn.Manager // absent without an authenticator engine
avatars *avatar.Manager // absent without picture storage
revoker TokenRevoker
limit limit.Limiter
logger *log.Logger
now clock.Clock
verifyLifetime time.Duration
resetLifetime time.Duration
}
// New assembles a [Server]. It panics if a required [Config] field is
// missing, since startup misconfiguration is a programmer error.
func New(cfg Config, opts ...Option) *Server {
switch {
case cfg.Login == nil:
panic("login manager is required")
case cfg.Users == nil:
panic("user manager is required")
}
s := &Server{
login: cfg.Login,
users: cfg.Users,
store: cfg.Users.Store(),
logger: cmp.Or(cfg.Logger, log.Discard()),
now: clock.System,
verifyLifetime: DefaultVerifyLifetime,
resetLifetime: DefaultResetLifetime,
limit: limit.New(
cfg.Throttle,
cmp.Or(cfg.ThrottlePenalty, DefaultThrottlePenalty),
),
}
for _, opt := range opts {
opt(s)
}
// The ticket managers are built after the option loop so they observe
// the final lifetimes and clock.
if s.tickets != nil {
s.verify = ticket.New(
s.tickets,
ticket.WithLifetime(s.verifyLifetime),
ticket.WithClock(s.now),
)
s.reset = ticket.New(
s.tickets,
ticket.WithLifetime(s.resetLifetime),
ticket.WithClock(s.now),
)
}
return s
}
// Mount registers the self-service endpoints on the registrar — the
// router itself for a root mount, or a [router.Group] to nest them under a
// path prefix. The mail-flow endpoints are only mounted when [WithMail]
// was given; the device and passkey endpoints only when the underlying
// engines and stores exist.
func (s *Server) Mount(r router.Registrar) {
r.HandleFunc(http.MethodGet, PathAccount, s.Profile)
r.HandleFunc(http.MethodPut, PathPassword, s.ChangePassword)
r.HandleFunc(http.MethodGet, PathSessions, s.ListSessions)
r.HandleFunc(http.MethodDelete, PathSessions, s.RevokeSessions)
r.HandleFunc(http.MethodDelete, PathSessions+"/{id}", s.RevokeSession)
r.HandleFunc(http.MethodPost, PathFactors, s.EnrollFactor)
r.HandleFunc(http.MethodDelete, PathFactors+"/{factor}", s.UnenrollFactor)
if s.login.Trust() != nil {
r.HandleFunc(http.MethodGet, PathDevices, s.ListDevices)
r.HandleFunc(http.MethodDelete, PathDevices, s.RevokeDevices)
r.HandleFunc(http.MethodDelete, PathDevices+"/{id}", s.RevokeDevice)
}
if s.authn != nil {
r.HandleFunc(http.MethodPost, PathAuthenticator, s.BeginAuthenticator)
r.HandleFunc(
http.MethodPost,
PathAuthenticatorConfirm,
s.ConfirmAuthenticator,
)
r.HandleFunc(
http.MethodDelete,
PathAuthenticator,
s.DeleteAuthenticator,
)
r.HandleFunc(
http.MethodPost,
PathRecoveryCodes,
s.GenerateRecoveryCodes,
)
r.HandleFunc(http.MethodGet, PathRecoveryCodes, s.RecoveryStatus)
}
if s.avatars != nil {
r.HandleFunc(http.MethodPost, PathAvatar, s.BeginAvatar)
r.HandleFunc(http.MethodPost, PathAvatarConfirm, s.ConfirmAvatar)
r.HandleFunc(http.MethodDelete, PathAvatar, s.DeleteAvatar)
}
if s.credentials != nil {
r.HandleFunc(http.MethodGet, PathPasskeys, s.ListPasskeys)
r.HandleFunc(http.MethodPatch, PathPasskeys+"/{id}", s.RenamePasskey)
r.HandleFunc(http.MethodDelete, PathPasskeys+"/{id}", s.DeletePasskey)
}
if s.verify != nil && s.post != nil {
r.HandleFunc(http.MethodPost, PathEmail, s.ChangeEmail)
r.HandleFunc(http.MethodPost, PathEmailConfirm, s.ConfirmEmail)
r.HandleFunc(http.MethodPost, PathRecovery, s.ChangeRecovery)
r.HandleFunc(http.MethodDelete, PathRecovery, s.DeleteRecovery)
r.HandleFunc(http.MethodPost, PathPasswordForgot, s.ForgotPassword)
r.HandleFunc(http.MethodPost, PathPasswordReset, s.ResetPassword)
}
}
// identify resolves the calling user from the session cookie. Mutating
// requests from cross-site callers are rejected, since every state change
// on this API rides on ambient cookie authority.
//
// Every authentication failure answers the same 401: an absent cookie, an
// expired session, and a disabled account are deliberately
// indistinguishable.
func (s *Server) identify(e *router.Exchange) (*user.User, error) {
if e.Method() != http.MethodGet && e.CrossSite() {
return nil, &router.Error{
Status: http.StatusForbidden,
Reason: router.ReasonCrossSite,
Description: "cross-site requests are not allowed",
}
}
key := s.sessionKey(e)
if key == "" {
return nil, &router.Error{
Status: http.StatusUnauthorized,
Reason: auth.ReasonAuthenticationFailed,
Description: "login required",
}
}
owner, ok, err := s.login.Sessions().Resolve(e.Context(), key)
if err != nil {
return nil, &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to resolve session",
Cause: err,
}
}
if !ok {
return nil, &router.Error{
Status: http.StatusUnauthorized,
Reason: auth.ReasonAuthenticationFailed,
Description: "login required",
}
}
u, err := s.store.Get(e.Context(), owner)
if err != nil {
return nil, &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to look up user",
Cause: err,
}
}
if u == nil || u.Disabled {
return nil, &router.Error{
Status: http.StatusUnauthorized,
Reason: auth.ReasonAuthenticationFailed,
Description: "login required",
}
}
return u, nil
}
// sessionKey extracts the raw session key from the request, or an empty
// string when absent.
func (s *Server) sessionKey(e *router.Exchange) string {
if c, err := e.Cookie(s.login.SessionCookieName()); err == nil {
return c.Value
}
return ""
}
// Profile returns the calling user's account record.
func (s *Server) Profile(e *router.Exchange) error {
u, err := s.identify(e)
if err != nil {
return err
}
return e.JSON(http.StatusOK, u)
}
// bindStepUp reads the step-up payload, tolerating an absent body.
//
// The endpoints that need it are DELETEs, where a body is unusual and
// many clients send none at all. An absent one is not an error: it
// simply supplies no password, which the step-up then refuses on an
// account that has one.
func bindStepUp(e *router.Exchange) (StepUpRequest, error) {
var req StepUpRequest
if e.R.ContentLength == 0 {
return req, nil
}
if err := e.BindJSON(&req); err != nil {
return req, err
}
return req, nil
}
// stepUp re-proves the caller before a change that weakens the account:
// removing a second factor, replacing recovery codes, repointing the
// email. A session cookie alone must not be enough for these — a stolen
// one would otherwise dismantle every protection the account has, quietly.
//
// An account without a password cannot be asked for one, so the check
// collapses for passwordless holders, who prove themselves through
// federation or a passkey. That is a known gap rather than an oversight:
// closing it needs a second-factor challenge on this API, which the login
// flow owns rather than this server.
func (s *Server) stepUp(
e *router.Exchange,
u *user.User,
password string,
) error {
if len(u.Password) == 0 {
return nil
}
ok, err := s.users.VerifyPassword(u, password)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to verify password",
Cause: err,
}
}
if !ok {
s.limit.Penalize(s.limit.Addr(e))
return &router.Error{
Status: http.StatusUnauthorized,
Reason: auth.ReasonAuthenticationFailed,
Description: "wrong password",
}
}
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package account
import (
"errors"
"net/http"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/eco/iam/authn"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/sec/auth"
)
// ReasonAuthenticatorEnrolled indicates that the account already holds a
// confirmed authenticator. Replacing one is deliberate: remove the old
// first, so a user is never left believing a half-scanned code works.
const ReasonAuthenticatorEnrolled router.Reason = "authenticator_enrolled"
// AuthenticatorRequest proves the caller before an authenticator is
// enrolled or removed.
type AuthenticatorRequest struct {
// Password is the account's current password. It is required on an
// account that has one; see [Server.stepUp].
Password string `json:"password,omitzero"`
}
// AuthenticatorResponse carries a pending enrollment to the client.
type AuthenticatorResponse struct {
// URI is the otpauth URI to render as a QR code. It carries the
// shared secret in the clear and is returned exactly once, to the
// screen that begins the enrollment.
URI string `json:"uri"`
}
// ConfirmAuthenticatorRequest proves a pending enrollment.
type ConfirmAuthenticatorRequest struct {
// Code is a code read off the authenticator being enrolled.
Code string `json:"code"`
}
// Validate implements the [valid.Validatable] interface.
func (r *ConfirmAuthenticatorRequest) Validate(v *valid.Validator) {
v.NotEmpty("code", r.Code)
}
// RecoveryCodesResponse carries a freshly generated set of recovery
// codes. It is the only time they exist outside a digest.
type RecoveryCodesResponse struct {
// Codes are the generated codes, in the clear. Show them once and do
// not store them.
Codes []string `json:"codes"`
}
// RecoveryStatusResponse reports how many recovery codes remain.
type RecoveryStatusResponse struct {
// Remaining is the number of unredeemed codes.
Remaining int `json:"remaining"`
// Low reports whether the user should be prompted to regenerate.
// Running out silently is how a lost authenticator becomes a lost
// account.
Low bool `json:"low"`
}
var _ valid.Validatable = (*ConfirmAuthenticatorRequest)(nil)
// BeginAuthenticator mints a pending authenticator enrollment and returns
// the otpauth URI to scan. The factor does not count until
// [Server.ConfirmAuthenticator] proves the user can produce a code.
func (s *Server) BeginAuthenticator(e *router.Exchange) error {
u, err := s.identify(e)
if err != nil {
return err
}
var req AuthenticatorRequest
if err := e.BindJSON(&req); err != nil {
return err
}
if err := s.stepUp(e, u, req.Password); err != nil {
return err
}
uri, err := s.authn.Begin(e.Context(), u.ID, u.Email)
if errors.Is(err, authn.ErrEnrolled) {
return &router.Error{
Status: http.StatusConflict,
Reason: ReasonAuthenticatorEnrolled,
Description: "an authenticator is already enrolled",
}
}
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to begin enrollment",
Cause: err,
}
}
return e.JSON(http.StatusOK, AuthenticatorResponse{URI: uri})
}
// ConfirmAuthenticator completes a pending enrollment with a code read
// off the authenticator, and only then does the factor gate logins.
func (s *Server) ConfirmAuthenticator(e *router.Exchange) error {
u, err := s.identify(e)
if err != nil {
return err
}
// Confirmation is a guessing surface like any other code check.
addrKey := s.limit.Addr(e)
if s.limit.Throttled(e, addrKey) {
return &router.Error{
Status: http.StatusTooManyRequests,
Reason: router.ReasonRateLimit,
Description: "too many attempts; try again later",
}
}
var req ConfirmAuthenticatorRequest
if err := e.BindJSON(&req); err != nil {
return err
}
ok, err := s.authn.Confirm(e.Context(), u.ID, req.Code)
if errors.Is(err, authn.ErrNotEnrolled) {
return &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "no pending enrollment",
}
}
if errors.Is(err, authn.ErrEnrolled) {
return &router.Error{
Status: http.StatusConflict,
Reason: ReasonAuthenticatorEnrolled,
Description: "an authenticator is already enrolled",
}
}
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to confirm enrollment",
Cause: err,
}
}
if !ok {
s.limit.Penalize(addrKey)
return &router.Error{
Status: http.StatusUnauthorized,
Reason: auth.ReasonAuthenticationFailed,
Description: "wrong code",
}
}
e.NoContent()
return nil
}
// DeleteAuthenticator removes the account's authenticator.
//
// It demands the password because removing a second factor weakens the
// account: without the proof, a stolen session alone would silently
// downgrade it to a single factor.
func (s *Server) DeleteAuthenticator(e *router.Exchange) error {
u, err := s.identify(e)
if err != nil {
return err
}
// A DELETE commonly carries no body; an absent one simply supplies no
// password, which the step-up then refuses.
req, err := bindStepUp(e)
if err != nil {
return err
}
if err := s.stepUp(e, u, req.Password); err != nil {
return err
}
deleted, err := s.authn.Disable(e.Context(), u.ID)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to remove the authenticator",
Cause: err,
}
}
if !deleted {
return &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "no authenticator is enrolled",
}
}
e.NoContent()
return nil
}
// GenerateRecoveryCodes replaces the account's recovery codes and returns
// the fresh set. Whatever the user had written down stops working, which
// is the point: this is the answer to "I lost my codes".
func (s *Server) GenerateRecoveryCodes(e *router.Exchange) error {
u, err := s.identify(e)
if err != nil {
return err
}
var req AuthenticatorRequest
if err := e.BindJSON(&req); err != nil {
return err
}
if err := s.stepUp(e, u, req.Password); err != nil {
return err
}
codes, err := s.authn.GenerateCodes(e.Context(), u.ID)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to generate recovery codes",
Cause: err,
}
}
return e.JSON(http.StatusOK, RecoveryCodesResponse{Codes: codes})
}
// RecoveryStatus reports how many recovery codes remain. It carries no
// codes, so it needs no step-up.
func (s *Server) RecoveryStatus(e *router.Exchange) error {
u, err := s.identify(e)
if err != nil {
return err
}
remaining, err := s.authn.RemainingCodes(e.Context(), u.ID)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to count recovery codes",
Cause: err,
}
}
return e.JSON(http.StatusOK, RecoveryStatusResponse{
Remaining: remaining,
Low: remaining < authn.LowCodeThreshold,
})
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package account
import (
"net/http"
"github.com/deep-rent/nexus/eco/iam/avatar"
"github.com/deep-rent/nexus/net/router"
)
// AvatarResponse carries the public URL of a confirmed avatar.
type AvatarResponse struct {
// URL is where the fresh avatar is served from.
URL string `json:"url"`
}
// BeginAvatar grants the calling user a direct avatar upload: it answers
// with a presigned PUT URL and the policy the confirmation will enforce.
// Nothing changes until [Server.ConfirmAvatar] verifies the upload.
func (s *Server) BeginAvatar(e *router.Exchange) error {
u, err := s.identify(e)
if err != nil {
return err
}
grant, err := s.avatars.Begin(e.Context(), avatar.ScopeUser, u.ID)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to grant upload",
Cause: err,
}
}
return e.JSON(http.StatusOK, grant)
}
// ConfirmAvatar verifies the pending upload and puts it into service,
// answering with the avatar's public URL; see [avatar.ConfirmError] for
// the refusals.
func (s *Server) ConfirmAvatar(e *router.Exchange) error {
u, err := s.identify(e)
if err != nil {
return err
}
url, err := s.avatars.Confirm(e.Context(), avatar.ScopeUser, u.ID)
if err != nil {
return avatar.ConfirmError(err)
}
return e.JSON(http.StatusOK, AvatarResponse{URL: url})
}
// DeleteAvatar takes the calling user's avatar out of service. Deleting
// an absent avatar succeeds, so the operation is idempotent.
func (s *Server) DeleteAvatar(e *router.Exchange) error {
u, err := s.identify(e)
if err != nil {
return err
}
if err := s.avatars.Remove(
e.Context(), avatar.ScopeUser, u.ID,
); err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to remove avatar",
Cause: err,
}
}
e.NoContent()
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package account
import (
"context"
"errors"
"net/http"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/eco/iam/post"
"github.com/deep-rent/nexus/eco/iam/user"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/sec/auth"
"github.com/deep-rent/nexus/sys/log"
)
// ReasonEmailTaken is the reason code returned when an email address is
// already taken by another account.
const ReasonEmailTaken router.Reason = "email_taken"
// EmailChangeRequest is the payload starting a change of the primary or
// recovery email address, or a re-verification of the current one.
type EmailChangeRequest struct {
// Email is the address to attach to the account. Submitting the current
// address re-sends its verification mail.
Email string `json:"email"`
// Password proves the caller holds the account password. It is ignored
// for passwordless accounts.
Password string `json:"password,omitzero"`
}
// Validate implements the [valid.Validatable] interface.
func (r *EmailChangeRequest) Validate(v *valid.Validator) {
v.Email("email", r.Email)
v.NotEmpty("email", r.Email)
}
// EmailConfirmRequest is the payload redeeming an email confirmation
// ticket.
type EmailConfirmRequest struct {
// Token is the raw confirmation ticket from the mailed link.
Token string `json:"token"`
}
// Validate implements the [valid.Validatable] interface.
func (r *EmailConfirmRequest) Validate(v *valid.Validator) {
v.NotEmpty("token", r.Token)
}
var (
_ valid.Validatable = (*EmailChangeRequest)(nil)
_ valid.Validatable = (*EmailConfirmRequest)(nil)
)
// ChangeEmail starts a change of the primary email address: it mints a
// confirmation ticket carrying the pending address and mails it there. The
// stored email only changes once the ticket is redeemed. The response never
// reveals whether the address is occupied by another account — an occupied
// address simply receives no mail.
//
// Accounts with a password must prove it, so a hijacked session alone
// cannot redirect the account's contact address.
func (s *Server) ChangeEmail(e *router.Exchange) error {
return s.startChange(
e,
PurposeVerifyEmail,
func(u *user.User, email string) bool {
return email == u.Email && u.EmailVerified
},
s.store.GetByEmail,
)
}
// ChangeRecovery starts a change of the recovery email address, mirroring
// [Server.ChangeEmail]: the address only attaches once its confirmation
// ticket is redeemed, and an address occupied as another account's recovery
// address silently receives no mail.
func (s *Server) ChangeRecovery(e *router.Exchange) error {
return s.startChange(
e,
PurposeVerifyRecovery,
func(u *user.User, email string) bool {
return email == u.RecoveryEmail && u.RecoveryEmailVerified
},
s.store.GetByRecoveryEmail,
)
}
// startChange runs the shared address-change flow: prove the password,
// short-circuit when the address is already attached and verified, and
// otherwise mint a purpose-bound ticket and mail the confirmation link —
// unless the address is occupied per the given lookup, which stays
// indistinguishable from the outside.
func (s *Server) startChange(
e *router.Exchange,
purpose string,
settled func(u *user.User, email string) bool,
occupied func(ctx context.Context, email string) (*user.User, error),
) error {
u, err := s.identify(e)
if err != nil {
return err
}
var req EmailChangeRequest
if err := e.BindJSON(&req); err != nil {
return err
}
if len(u.Password) > 0 {
ok, err := s.users.VerifyPassword(u, req.Password)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to verify password",
Cause: err,
}
}
if !ok {
s.limit.Penalize(s.limit.Addr(e))
return &router.Error{
Status: http.StatusUnauthorized,
Reason: auth.ReasonAuthenticationFailed,
Description: "wrong password",
}
}
}
email := user.Normalize(req.Email)
if settled(u, email) {
e.NoContent()
return nil
}
holder, err := occupied(e.Context(), email)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to look up email",
Cause: err,
}
}
if holder == nil || holder.ID == u.ID {
token, err := s.verify.Issue(
e.Context(),
u.ID,
purpose,
email,
)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to issue ticket",
Cause: err,
}
}
if err := s.post.VerifyEmail(
e.Context(),
post.Recipient{
Addr: email,
Name: u.Name,
DisplayName: u.DisplayName,
Locales: u.Locales,
},
token,
); err != nil {
s.logger.Error(
e.Context(),
"Failed to dispatch email verification",
log.UUID("user_id", u.ID),
log.Error(err),
)
}
}
e.Status(http.StatusAccepted)
return nil
}
// DeleteRecovery detaches the recovery email address from the account.
func (s *Server) DeleteRecovery(e *router.Exchange) error {
u, err := s.identify(e)
if err != nil {
return err
}
if u.RecoveryEmail != "" {
u.RecoveryEmail = ""
u.RecoveryEmailVerified = false
u.UpdatedAt = s.now()
if err := s.store.Update(e.Context(), u); err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to update user",
Cause: err,
}
}
}
e.NoContent()
return nil
}
// ConfirmEmail redeems a confirmation ticket, attaching the pending
// primary or recovery address to the account as verified. It is public: the
// link may well be opened in a browser without a session. The ticket's
// purpose decides which address it confirms, so one endpoint serves both
// mails.
func (s *Server) ConfirmEmail(e *router.Exchange) error {
var req EmailConfirmRequest
if err := e.BindJSON(&req); err != nil {
return err
}
addrKey := s.limit.Addr(e)
if s.limit.Throttled(e, addrKey) {
return &router.Error{
Status: http.StatusTooManyRequests,
Reason: router.ReasonRateLimit,
Description: "too many confirmation attempts",
}
}
// A redemption under the wrong purpose leaves the ticket intact, so
// probing the purposes in order is free of side effects.
purpose := PurposeVerifyEmail
t, ok, err := s.verify.Redeem(e.Context(), req.Token, purpose)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to redeem ticket",
Cause: err,
}
}
if !ok {
purpose = PurposeVerifyRecovery
t, ok, err = s.verify.Redeem(e.Context(), req.Token, purpose)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to redeem ticket",
Cause: err,
}
}
}
if !ok {
s.limit.Penalize(addrKey)
return &router.Error{
Status: http.StatusUnauthorized,
Reason: auth.ReasonAuthenticationFailed,
Description: "invalid or expired ticket",
}
}
u, err := s.store.Get(e.Context(), t.Owner)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to look up user",
Cause: err,
}
}
if u == nil || u.Disabled {
return &router.Error{
Status: http.StatusUnauthorized,
Reason: auth.ReasonAuthenticationFailed,
Description: "login required",
}
}
if purpose == PurposeVerifyEmail {
u.Email = t.Payload
u.EmailVerified = true
} else {
u.RecoveryEmail = t.Payload
u.RecoveryEmailVerified = true
}
u.UpdatedAt = s.now()
if err := s.store.Update(e.Context(), u); err != nil {
// The address was claimed by another account while the ticket was
// in flight.
if errors.Is(err, user.ErrDuplicate) {
return &router.Error{
Status: http.StatusConflict,
Reason: ReasonEmailTaken,
Description: "email address is already in use",
}
}
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to update user",
Cause: err,
}
}
e.NoContent()
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package account
import (
"time"
"github.com/deep-rent/nexus/eco/iam/authn"
"github.com/deep-rent/nexus/eco/iam/avatar"
"github.com/deep-rent/nexus/eco/iam/passkey"
"github.com/deep-rent/nexus/eco/iam/post"
"github.com/deep-rent/nexus/eco/iam/ticket"
"github.com/deep-rent/nexus/std/clock"
)
// Default values applied by [New] for optional configuration.
const (
// DefaultThrottlePenalty is the token cost of a failed or abusable
// request when [Config.ThrottlePenalty] is unset.
DefaultThrottlePenalty = 10
// DefaultVerifyLifetime is the redemption window of email confirmation
// tickets.
DefaultVerifyLifetime = 24 * time.Hour
// DefaultResetLifetime is the redemption window of password recovery
// tickets.
DefaultResetLifetime = 1 * time.Hour
// MinPasswordLength is the minimum length accepted for new passwords.
MinPasswordLength = 8
// MaxPasswordLength bounds new passwords, guarding the hasher against
// pathological inputs.
MaxPasswordLength = 512
)
// Option customizes a [Server] during construction with [New].
type Option func(*Server)
// WithMail enables the mail-backed flows — email verification, email
// change, and password recovery — issuing single-use tickets from the given
// store and dispatching mails through the given mailer. Both must be
// non-nil, or the option panics, since a half-configured mail flow is a
// startup configuration error.
func WithMail(tickets ticket.Store, mailer *post.Mailer) Option {
if tickets == nil || mailer == nil {
panic("ticket store and mailer are required")
}
return func(s *Server) {
s.tickets = tickets
s.post = mailer
}
}
// WithPasskeys enables the passkey management endpoints over the given
// credential store — the same store backing the IAM server's WebAuthn
// registration. A nil store is ignored.
func WithPasskeys(credentials passkey.CredentialStore) Option {
return func(s *Server) {
if credentials != nil {
s.credentials = credentials
}
}
}
// WithRevoker wires refresh token revocation into credential changes. A nil
// revoker is ignored; without one, only sessions and device trust are
// revoked.
func WithRevoker(revoker TokenRevoker) Option {
return func(s *Server) {
if revoker != nil {
s.revoker = revoker
}
}
}
// WithVerifyLifetime sets the redemption window of email confirmation
// tickets. Nonpositive values are ignored. Defaults to
// [DefaultVerifyLifetime].
func WithVerifyLifetime(d time.Duration) Option {
return func(s *Server) {
if d > 0 {
s.verifyLifetime = d
}
}
}
// WithResetLifetime sets the redemption window of password recovery
// tickets. Nonpositive values are ignored. Defaults to
// [DefaultResetLifetime].
func WithResetLifetime(d time.Duration) Option {
return func(s *Server) {
if d > 0 {
s.resetLifetime = d
}
}
}
// WithClock overrides the time source, primarily for testing. A nil
// function is ignored. Defaults to [clock.System].
func WithClock(now clock.Clock) Option {
return func(s *Server) {
if now != nil {
s.now = now
}
}
}
// WithAvatars enables the avatar endpoints over the given picture
// engine. A nil manager is ignored, leaving avatars unavailable.
func WithAvatars(m *avatar.Manager) Option {
return func(s *Server) {
if m != nil {
s.avatars = m
}
}
}
// WithAuthenticator wires the engine behind authenticator apps and
// recovery codes, registering their endpoints. A nil manager leaves both
// factors unavailable, which is what a deployment without a sealing key
// gets — a shared secret cannot be stored as a digest, so there is no
// safe fallback.
func WithAuthenticator(m *authn.Manager) Option {
return func(s *Server) {
if m != nil {
s.authn = m
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package account
import (
"encoding/base64"
"net/http"
"slices"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/eco/iam/user"
"github.com/deep-rent/nexus/net/router"
)
// ReasonFactorUnavailable is the reason code returned when a factor's
// contact point has not been verified.
const ReasonFactorUnavailable router.Reason = "factor_unavailable"
// PasskeyResponse describes one registered passkey in a listing.
type PasskeyResponse struct {
// ID is the WebAuthn credential ID, base64url-encoded without padding.
ID string `json:"id"`
// Name is the label chosen at registration or through a rename.
Name string `json:"name,omitzero"`
// CreatedAt is when the passkey was registered, as a Unix timestamp in
// seconds. Zero when the backend does not track it.
CreatedAt int64 `json:"created_at,omitzero"`
}
// PasskeyRenameRequest is the payload relabeling a passkey.
type PasskeyRenameRequest struct {
// Name is the new label.
Name string `json:"name"`
}
// Validate implements the [valid.Validatable] interface.
func (r *PasskeyRenameRequest) Validate(v *valid.Validator) {
v.NotBlank("name", r.Name)
v.MaxLen("name", r.Name, 128)
}
// StepUpRequest re-proves the caller before a change that weakens the
// account. See [Server.stepUp].
type StepUpRequest struct {
// Password is the account's current password, required on an account
// that has one.
Password string `json:"password,omitzero"`
}
// FactorRequest is the payload enrolling a second factor.
type FactorRequest struct {
// Factor names the channel to enroll: "mail" or "text".
Factor string `json:"factor"`
}
// Validate implements the [valid.Validatable] interface.
func (r *FactorRequest) Validate(v *valid.Validator) {
v.Whitelist(
"factor",
r.Factor,
string(user.FactorMail),
string(user.FactorText),
)
}
var (
_ valid.Validatable = (*PasskeyRenameRequest)(nil)
_ valid.Validatable = (*FactorRequest)(nil)
)
// credentialID decodes a passkey path parameter back into the raw
// credential ID.
func credentialID(e *router.Exchange) ([]byte, error) {
id, err := base64.RawURLEncoding.DecodeString(e.Param("id"))
if err != nil {
return nil, &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: "malformed passkey ID",
}
}
return id, nil
}
// ListPasskeys returns the calling user's registered passkeys.
func (s *Server) ListPasskeys(e *router.Exchange) error {
u, err := s.identify(e)
if err != nil {
return err
}
keys, err := s.credentials.List(e.Context(), u.ID)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to list passkeys",
Cause: err,
}
}
out := make([]PasskeyResponse, len(keys))
for i, k := range keys {
out[i] = PasskeyResponse{
ID: base64.RawURLEncoding.EncodeToString(k.Credential.ID),
Name: k.Name,
CreatedAt: k.CreatedAt.Unix(),
}
}
return e.JSON(http.StatusOK, out)
}
// RenamePasskey relabels one of the calling user's passkeys. Unknown IDs
// yield 404.
func (s *Server) RenamePasskey(e *router.Exchange) error {
u, err := s.identify(e)
if err != nil {
return err
}
id, err := credentialID(e)
if err != nil {
return err
}
var req PasskeyRenameRequest
if err := e.BindJSON(&req); err != nil {
return err
}
keys, err := s.credentials.List(e.Context(), u.ID)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to list passkeys",
Cause: err,
}
}
// Check if the user holds a credential with the given ID:
found := false
for _, k := range keys {
if slices.Equal(k.Credential.ID, id) {
found = true
break
}
}
if !found {
return &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "no such passkey",
}
}
if err := s.credentials.Rename(
e.Context(), u.ID, id, req.Name,
); err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to rename passkey",
Cause: err,
}
}
e.NoContent()
return nil
}
// DeletePasskey removes one of the calling user's passkeys. Unknown IDs
// yield 404.
func (s *Server) DeletePasskey(e *router.Exchange) error {
u, err := s.identify(e)
if err != nil {
return err
}
req, err := bindStepUp(e)
if err != nil {
return err
}
// A passkey is a full-strength login credential; removing one is the
// same weakening as unenrolling a factor. See [Server.stepUp].
if err := s.stepUp(e, u, req.Password); err != nil {
return err
}
id, err := credentialID(e)
if err != nil {
return err
}
deleted, err := s.credentials.Delete(e.Context(), u.ID, id)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to delete passkey",
Cause: err,
}
}
if !deleted {
return &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "no such passkey",
}
}
e.NoContent()
return nil
}
// EnrollFactor adds a second-factor channel to the account. The channel's
// contact point must be verified first, so codes never travel to an
// unproven destination.
func (s *Server) EnrollFactor(e *router.Exchange) error {
u, err := s.identify(e)
if err != nil {
return err
}
var req FactorRequest
if err := e.BindJSON(&req); err != nil {
return err
}
factor := user.Factor(req.Factor)
verified := (factor == user.FactorMail && u.EmailVerified) ||
(factor == user.FactorText && u.PhoneVerified)
if !verified {
return &router.Error{
Status: http.StatusConflict,
Reason: ReasonFactorUnavailable,
Description: "the factor's contact point is not verified",
}
}
if !u.HasFactor(factor) {
u.Factors = append(u.Factors, factor)
u.UpdatedAt = s.now()
if err := s.store.Update(e.Context(), u); err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to enroll factor",
Cause: err,
}
}
}
e.NoContent()
return nil
}
// UnenrollFactor removes a second-factor channel from the account. Unknown
// factors yield 404.
func (s *Server) UnenrollFactor(e *router.Exchange) error {
u, err := s.identify(e)
if err != nil {
return err
}
req, err := bindStepUp(e)
if err != nil {
return err
}
// Removing a second factor weakens the account, so the caller
// re-proves themselves: a stolen session must not be able to
// downgrade an account to a single factor without a sound.
if err := s.stepUp(e, u, req.Password); err != nil {
return err
}
factor := user.Factor(e.Param("factor"))
if !u.HasFactor(factor) {
return &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "factor not enrolled",
}
}
u.Factors = slices.DeleteFunc(
u.Factors,
func(f user.Factor) bool { return f == factor },
)
u.UpdatedAt = s.now()
if err := s.store.Update(e.Context(), u); err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to unenroll factor",
Cause: err,
}
}
e.NoContent()
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package account
import (
"context"
"net/http"
"uuid"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/eco/iam/limit"
"github.com/deep-rent/nexus/eco/iam/post"
"github.com/deep-rent/nexus/eco/iam/user"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/sec/auth"
"github.com/deep-rent/nexus/sys/log"
)
// PasswordChangeRequest is the payload of an interactive password change.
type PasswordChangeRequest struct {
// Current is the password being replaced, proving the caller holds it.
Current string `json:"current"`
// Password is the replacement password.
Password string `json:"password"`
}
// Validate implements the [valid.Validatable] interface.
func (r *PasswordChangeRequest) Validate(v *valid.Validator) {
v.NotEmpty("current", r.Current)
v.NotEmpty("password", r.Password)
v.MinLen("password", r.Password, MinPasswordLength)
v.MaxLen("password", r.Password, MaxPasswordLength)
}
// PasswordForgotRequest is the payload starting a password recovery.
type PasswordForgotRequest struct {
// Email is the address whose account requests recovery.
Email string `json:"email"`
}
// Validate implements the [valid.Validatable] interface.
func (r *PasswordForgotRequest) Validate(v *valid.Validator) {
v.Email("email", r.Email)
v.NotEmpty("email", r.Email)
}
// PasswordResetRequest is the payload redeeming a recovery ticket.
type PasswordResetRequest struct {
// Token is the raw recovery ticket from the mailed link.
Token string `json:"token"`
// Password is the replacement password.
Password string `json:"password"`
}
// Validate implements the [valid.Validatable] interface.
func (r *PasswordResetRequest) Validate(v *valid.Validator) {
v.NotEmpty("token", r.Token)
v.NotEmpty("password", r.Password)
v.MinLen("password", r.Password, MinPasswordLength)
v.MaxLen("password", r.Password, MaxPasswordLength)
}
var (
_ valid.Validatable = (*PasswordChangeRequest)(nil)
_ valid.Validatable = (*PasswordForgotRequest)(nil)
_ valid.Validatable = (*PasswordResetRequest)(nil)
)
// ChangePassword replaces the calling user's password after proving the
// current one, then revokes every other session, all remembered devices,
// and (when configured) all refresh tokens. The calling session survives.
func (s *Server) ChangePassword(e *router.Exchange) error {
u, err := s.identify(e)
if err != nil {
return err
}
var req PasswordChangeRequest
if err := e.BindJSON(&req); err != nil {
return err
}
ok, err := s.users.VerifyPassword(u, req.Current)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to verify password",
Cause: err,
}
}
if !ok {
s.limit.Penalize(s.limit.Addr(e))
return &router.Error{
Status: http.StatusUnauthorized,
Reason: auth.ReasonAuthenticationFailed,
Description: "wrong password",
}
}
if err := s.users.SetPassword(
e.Context(), u.ID, req.Password,
); err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to set password",
Cause: err,
}
}
s.revokeStanding(e, u.ID, false)
e.NoContent()
return nil
}
// ForgotPassword mails a recovery link when the address belongs to an
// enabled account with a verified email. It always reports success, so
// callers cannot probe which addresses have accounts, and every call is
// charged against the throttle since each may dispatch a mail.
func (s *Server) ForgotPassword(e *router.Exchange) error {
var req PasswordForgotRequest
if err := e.BindJSON(&req); err != nil {
return err
}
email := user.Normalize(req.Email)
addrKey := s.limit.Addr(e)
userKey := limit.ScopeUser + email
if s.limit.Throttled(e, addrKey) || s.limit.Throttled(e, userKey) {
return &router.Error{
Status: http.StatusTooManyRequests,
Reason: router.ReasonRateLimit,
Description: "too many recovery requests",
}
}
s.limit.Penalize(addrKey, userKey)
// The submitted address may be an account's primary or its recovery
// address; the reset link travels to exactly the address submitted,
// and only when the account has proven ownership of it.
u, err := s.store.GetByEmail(e.Context(), email)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to look up user",
Cause: err,
}
}
if u != nil && !u.Disabled && u.EmailVerified {
s.sendReset(e.Context(), u, u.Email)
} else if u == nil {
u, err = s.store.GetByRecoveryEmail(e.Context(), email)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to look up user",
Cause: err,
}
}
if u != nil && !u.Disabled && u.RecoveryEmailVerified {
s.sendReset(e.Context(), u, u.RecoveryEmail)
}
}
e.NoContent()
return nil
}
// sendReset issues a recovery ticket and dispatches the mail to the given
// address, best-effort: a failure is logged, never surfaced, so responses
// stay uniform.
func (s *Server) sendReset(ctx context.Context, u *user.User, to string) {
token, err := s.reset.Issue(
ctx,
u.ID,
PurposeResetPassword,
"",
)
if err == nil {
err = s.post.ResetPassword(
ctx,
post.Recipient{
Addr: to,
Name: u.Name,
DisplayName: u.DisplayName,
Locales: u.Locales,
},
token,
)
}
if err != nil {
s.logger.Error(
ctx,
"Failed to dispatch password recovery",
log.UUID("user_id", u.ID),
log.Error(err),
)
}
}
// ResetPassword redeems a recovery ticket for a new password and revokes
// every standing credential, including all sessions.
func (s *Server) ResetPassword(e *router.Exchange) error {
var req PasswordResetRequest
if err := e.BindJSON(&req); err != nil {
return err
}
addrKey := s.limit.Addr(e)
if s.limit.Throttled(e, addrKey) {
return &router.Error{
Status: http.StatusTooManyRequests,
Reason: router.ReasonRateLimit,
Description: "too many reset attempts",
}
}
t, ok, err := s.reset.Redeem(e.Context(), req.Token, PurposeResetPassword)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to redeem ticket",
Cause: err,
}
}
if !ok {
s.limit.Penalize(addrKey)
return &router.Error{
Status: http.StatusUnauthorized,
Reason: auth.ReasonAuthenticationFailed,
Description: "invalid or expired ticket",
}
}
if err := s.users.SetPassword(
e.Context(),
t.Owner,
req.Password,
); err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to set password",
Cause: err,
}
}
s.revokeStanding(e, t.Owner, true)
e.NoContent()
return nil
}
// revokeStanding cuts the credentials standing for the user: sessions
// (sparing the calling session unless every one of them must go), device
// trust, and refresh tokens. Failures are logged, never surfaced — the
// credential change itself already succeeded and expiry is the backstop.
func (s *Server) revokeStanding(
e *router.Exchange,
userID uuid.UUID,
all bool,
) {
ctx := e.Context()
s.revokeSessions(e, userID, all)
if err := s.login.RevokeTrustedDevices(ctx, userID); err != nil {
s.logger.Error(
ctx,
"Failed to revoke trusted devices",
log.UUID("user_id", userID),
log.Error(err),
)
}
if s.revoker != nil {
if err := s.revoker.DeleteRefreshTokensForUser(
ctx,
userID,
); err != nil {
s.logger.Error(
ctx,
"Failed to delete refresh tokens",
log.UUID("user_id", userID),
log.Error(err),
)
}
}
// Standing tickets authorize a deferred action — confirming a pending
// email address, resetting the password — and so outlive the
// credentials they were issued under unless they go too. Leaving one
// alive lets whoever obtained it act after the account holder thinks
// they have locked the intruder out.
if s.tickets != nil {
if err := s.tickets.DeleteForOwner(ctx, userID); err != nil {
s.logger.Error(
ctx,
"Failed to revoke standing tickets",
log.UUID("user_id", userID),
log.Error(err),
)
}
}
}
// revokeSessions revokes standing sessions for the user. When every session
// must go, the caller's active one is included too; otherwise it is spared.
func (s *Server) revokeSessions(
e *router.Exchange,
userID uuid.UUID,
all bool,
) {
ctx := e.Context()
var spare string
if !all {
spare = s.login.Sessions().ID(s.sessionKey(e))
}
if spare == "" {
if err := s.login.RevokeSessions(ctx, userID); err != nil {
s.logger.Error(
ctx,
"Failed to revoke sessions",
log.UUID("user_id", userID),
log.Error(err),
)
}
return
}
records, err := s.login.Sessions().List(ctx, userID)
if err != nil {
s.logger.Error(
ctx,
"Failed to list sessions for revocation",
log.UUID("user_id", userID),
log.Error(err),
)
return
}
for _, r := range records {
if r.ID != spare {
if _, err := s.login.Sessions().DestroyByID(ctx, r.ID); err != nil {
s.logger.Error(
ctx,
"Failed to destroy session",
log.UUID("user_id", userID),
log.Error(err),
)
}
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package account
import (
"net/http"
"github.com/deep-rent/nexus/net/router"
)
// SessionResponse describes one login session in a listing. It carries only
// the storage ID (a digest), never a raw session key.
type SessionResponse struct {
// ID is the session's storage reference, used to revoke it.
ID string `json:"id"`
// CreatedAt is when the session was established, as a Unix timestamp in
// seconds.
CreatedAt int64 `json:"created_at,omitzero"`
// ExpiresAt is when the session lapses, as a Unix timestamp in seconds.
// Every session carries one.
ExpiresAt int64 `json:"expires_at,omitzero"`
// Label is the user agent hint recorded at login.
Label string `json:"label,omitzero"`
// Current marks the session making this request.
Current bool `json:"current,omitzero"`
}
// DeviceResponse describes one remembered device in a listing. It carries
// only the storage ID (a digest), never a raw trust token.
type DeviceResponse struct {
// ID is the trust record's storage reference, used to revoke it.
ID string `json:"id"`
// CreatedAt is when the trust was enrolled, as a Unix timestamp in
// seconds.
CreatedAt int64 `json:"created_at,omitzero"`
// ExpiresAt is when the trust lapses, as a Unix timestamp in seconds.
ExpiresAt int64 `json:"expires_at"`
// Label is the user agent hint recorded at enrollment.
Label string `json:"label,omitzero"`
}
// ListSessions returns the calling user's live sessions, most recent
// first, marking the one making this request.
func (s *Server) ListSessions(e *router.Exchange) error {
u, err := s.identify(e)
if err != nil {
return err
}
records, err := s.login.Sessions().List(e.Context(), u.ID)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to list sessions",
Cause: err,
}
}
current := s.login.Sessions().ID(s.sessionKey(e))
out := make([]SessionResponse, len(records))
for i, r := range records {
out[i] = SessionResponse{
ID: r.ID,
CreatedAt: r.CreatedAt.Unix(),
ExpiresAt: r.ExpiresAt.Unix(),
Label: r.Label,
Current: r.ID == current,
}
}
return e.JSON(http.StatusOK, out)
}
// RevokeSession revokes one of the calling user's sessions by its listing
// ID. Foreign or unknown IDs yield 404, so a caller can only ever revoke
// their own.
func (s *Server) RevokeSession(e *router.Exchange) error {
u, err := s.identify(e)
if err != nil {
return err
}
id := e.Param("id")
records, err := s.login.Sessions().List(e.Context(), u.ID)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to list sessions",
Cause: err,
}
}
for _, r := range records {
if r.ID != id {
continue
}
if _, err := s.login.Sessions().DestroyByID(
e.Context(), id,
); err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to revoke session",
Cause: err,
}
}
e.NoContent()
return nil
}
return &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "no such session",
}
}
// RevokeSessions signs the calling user out everywhere, including the
// session making this request.
func (s *Server) RevokeSessions(e *router.Exchange) error {
u, err := s.identify(e)
if err != nil {
return err
}
if err := s.login.RevokeSessions(e.Context(), u.ID); err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to revoke sessions",
Cause: err,
}
}
e.NoContent()
return nil
}
// ListDevices returns the calling user's remembered devices, most recently
// enrolled first.
func (s *Server) ListDevices(e *router.Exchange) error {
u, err := s.identify(e)
if err != nil {
return err
}
records, err := s.login.Trust().List(e.Context(), u.ID)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to list devices",
Cause: err,
}
}
out := make([]DeviceResponse, len(records))
for i, r := range records {
out[i] = DeviceResponse{
ID: r.ID,
CreatedAt: r.CreatedAt.Unix(),
ExpiresAt: r.ExpiresAt.Unix(),
Label: r.Label,
}
}
return e.JSON(http.StatusOK, out)
}
// RevokeDevice revokes one of the calling user's remembered devices by its
// listing ID. Foreign or unknown IDs yield 404.
func (s *Server) RevokeDevice(e *router.Exchange) error {
u, err := s.identify(e)
if err != nil {
return err
}
id := e.Param("id")
records, err := s.login.Trust().List(e.Context(), u.ID)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to list devices",
Cause: err,
}
}
for _, r := range records {
if r.ID != id {
continue
}
if _, err := s.login.Trust().RevokeByID(e.Context(), id); err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to revoke device",
Cause: err,
}
}
e.NoContent()
return nil
}
return &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "no such device",
}
}
// RevokeDevices forgets every device the calling user remembered.
func (s *Server) RevokeDevices(e *router.Exchange) error {
u, err := s.identify(e)
if err != nil {
return err
}
if err := s.login.RevokeTrustedDevices(e.Context(), u.ID); err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to revoke devices",
Cause: err,
}
}
e.NoContent()
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package admin
import (
"cmp"
"context"
"net/http"
"path"
"time"
"uuid"
"github.com/deep-rent/nexus/eco/iam/client"
"github.com/deep-rent/nexus/eco/iam/login"
"github.com/deep-rent/nexus/eco/iam/passkey"
"github.com/deep-rent/nexus/eco/iam/post"
"github.com/deep-rent/nexus/eco/iam/relay"
"github.com/deep-rent/nexus/eco/iam/team"
"github.com/deep-rent/nexus/eco/iam/ticket"
"github.com/deep-rent/nexus/eco/iam/user"
hookadmin "github.com/deep-rent/nexus/net/notify/hook/admin"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/sec/auth"
"github.com/deep-rent/nexus/sec/jose/jwt"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/sys/log"
)
// Permissions guarding the management API, one pair per resource. Scopes
// and permissions share a namespace: a token must carry a permission as a
// scope, and — for human administrators — a role granting it through the
// configured [auth.Grants]. See [auth.Grants.Permits] for the exact rule.
const (
// PermUsersRead lists and inspects user accounts, their identities,
// and their passkeys.
PermUsersRead = "iam:users:read"
// PermUsersWrite creates, updates, and deletes user accounts, manages
// their credentials, and revokes their sessions, devices, and tokens.
PermUsersWrite = "iam:users:write"
// PermClientsRead lists and inspects OAuth client registrations.
PermClientsRead = "iam:clients:read"
// PermClientsWrite manages OAuth client registrations, their secrets,
// and their standing tokens.
PermClientsWrite = "iam:clients:write"
// PermTeamsRead lists and inspects teams, their members, and their
// invitations.
PermTeamsRead = "iam:teams:read"
// PermTeamsWrite manages teams, their members, and their invitations.
PermTeamsWrite = "iam:teams:write"
// PermHooksRead lists and inspects webhook endpoints.
PermHooksRead = "iam:hooks:read"
// PermHooksWrite registers, rotates, suspends, and removes webhook
// endpoints. It hands out signing secrets and can point a receiver at
// any address this service can reach, so it is the most powerful
// permission of this API after user administration.
PermHooksWrite = "iam:hooks:write"
)
// Permissions lists every permission of this API.
var Permissions = []string{
PermUsersRead, PermUsersWrite,
PermClientsRead, PermClientsWrite,
PermTeamsRead, PermTeamsWrite,
PermHooksRead, PermHooksWrite,
}
// DefaultGrants maps [auth.RoleAdmin] onto every permission of this API.
var DefaultGrants = auth.Grants{auth.RoleAdmin: Permissions}
// Endpoint path bases registered by [Server.Mount], relative to the mount
// prefix.
const (
// PathUsers manages user accounts.
PathUsers = "/admin/users"
// PathClients manages OAuth client registrations.
PathClients = "/admin/clients"
// PathTeams manages teams globally.
PathTeams = "/admin/teams"
// PathHooks manages the webhook endpoints subscribed to this
// service's events. The shared surface names the "/hooks" leg
// itself, so the group is rooted one level above it.
PathHooks = "/admin/hooks"
)
// TokenRevoker revokes standing refresh tokens per user and per client.
// Both IAM drivers satisfy it.
type TokenRevoker interface {
DeleteRefreshTokensForUser(
ctx context.Context,
userID uuid.UUID,
) error
DeleteRefreshTokensForClient(
ctx context.Context,
clientID uuid.UUID,
) error
}
// Config bundles the required collaborators of a [Server]. Optional
// behavior — recovery mails, passkey administration — attaches through
// options.
type Config struct {
// Server is the authorization server whose engines carry out session and
// device revocations. Required.
Login *login.Manager
// Users is the identity engine behind the IAM server. Required.
Users *user.Manager
// Clients is the client registry engine behind the IAM server.
// Required.
Clients *client.Manager
// Verifier validates the Bearer access tokens guarding this API,
// typically built over the deployment's own key set and issuer.
// Required.
Verifier jwt.Verifier[*auth.Claims]
// Revoker revokes refresh tokens alongside credential changes and
// deletions. If nil, refresh tokens are left to expire.
Revoker TokenRevoker
// Grants maps administrator roles onto the permissions they carry.
// Defaults to [DefaultGrants]. Machine clients are unaffected: their
// vetted scopes speak for themselves.
Grants auth.Grants
// Logger receives diagnostics for best-effort work such as mail
// dispatch. Defaults to [log.Discard].
Logger *log.Logger
}
// Server implements the management API. Create instances with [New] and
// attach the routes with [Server.Mount].
type Server struct {
login *login.Manager
users *user.Manager
store user.Store
clients *client.Manager
registry client.Store
guard router.Middleware
grants auth.Grants
revoker TokenRevoker
hooks hookadmin.Hooks
credentials passkey.CredentialStore
tickets ticket.Store
reset *ticket.Manager
post *post.Mailer
teams *team.Manager
logger *log.Logger
now clock.Clock
resetLifetime time.Duration
}
// New assembles a [Server]. It panics if a required [Config] field is
// missing, since startup misconfiguration is a programmer error.
func New(cfg Config, opts ...Option) *Server {
switch {
case cfg.Login == nil:
panic("login manager is required")
case cfg.Users == nil:
panic("user manager is required")
case cfg.Clients == nil:
panic("client manager is required")
case cfg.Verifier == nil:
panic("token verifier is required")
}
s := &Server{
login: cfg.Login,
users: cfg.Users,
store: cfg.Users.Store(),
clients: cfg.Clients,
registry: cfg.Clients.Store(),
revoker: cfg.Revoker,
logger: cmp.Or(cfg.Logger, log.Discard()),
now: clock.System,
guard: auth.NewGuard(cfg.Verifier).Secure(),
grants: cfg.Grants,
resetLifetime: DefaultResetLifetime,
}
if s.grants == nil {
s.grants = DefaultGrants
}
for _, opt := range opts {
opt(s)
}
if s.tickets != nil {
s.reset = ticket.New(
s.tickets,
ticket.WithLifetime(s.resetLifetime),
ticket.WithClock(s.now),
)
}
return s
}
// can produces the authorization middleware demanding one permission.
func (s *Server) can(perm string) router.Middleware {
return auth.Enforce(s.grants.Require(perm))
}
// Mount registers the management endpoints on the registrar — the router
// itself for a root mount, or a [router.Group] to nest them under a path
// prefix. Each group authenticates behind the Bearer guard; each route
// demands the permission covering it.
func (s *Server) Mount(reg router.Registrar) {
read, write := s.can(PermUsersRead), s.can(PermUsersWrite)
users := reg.Group(PathUsers, s.guard)
users.HandleFunc(http.MethodGet, "", s.ListUsers, read)
users.HandleFunc(http.MethodPost, "", s.CreateUser, write)
users.HandleFunc(http.MethodGet, "/{id}", s.GetUser, read)
users.HandleFunc(http.MethodPatch, "/{id}", s.UpdateUser, write)
users.HandleFunc(http.MethodDelete, "/{id}", s.DeleteUser, write)
users.HandleFunc(http.MethodPost, "/{id}/password", s.SetPassword, write)
users.HandleFunc(
http.MethodDelete,
"/{id}/sessions",
s.RevokeSessions,
write,
)
users.HandleFunc(http.MethodDelete, "/{id}/devices", s.RevokeDevices, write)
users.HandleFunc(http.MethodDelete, "/{id}/tokens", s.RevokeTokens, write)
users.HandleFunc(http.MethodGet, "/{id}/identities", s.ListIdentities, read)
if s.teams != nil {
// Affiliations reveal team data, so they sit behind the team
// permission rather than the user one.
users.HandleFunc(
http.MethodGet,
"/{id}/teams",
s.ListUserTeams,
s.can(PermTeamsRead),
)
}
users.HandleFunc(http.MethodDelete, "/{id}/identities/{provider}",
s.UnlinkIdentity, write,
)
if s.credentials != nil {
users.HandleFunc(http.MethodGet, "/{id}/passkeys", s.ListPasskeys, read)
users.HandleFunc(http.MethodDelete, "/{id}/passkeys/{credential}",
s.DeletePasskey, write,
)
}
if s.reset != nil && s.post != nil {
users.HandleFunc(
http.MethodPost,
"/{id}/recover",
s.RecoverPassword,
write,
)
}
if s.hooks != nil {
// The registry spans every subscribing service, so the owner
// comes from the payload; only the topics this service
// publishes may be subscribed to, and internal endpoints are
// admissible because the surface is staff-only.
hookadmin.Mount(
reg.Group(path.Dir(PathHooks), s.guard),
hookadmin.Config{
Hooks: s.hooks,
Topics: relay.Topics,
Internal: true,
Read: []router.Middleware{s.can(PermHooksRead)},
Write: []router.Middleware{s.can(PermHooksWrite)},
},
)
}
if s.teams != nil {
read, write := s.can(PermTeamsRead), s.can(PermTeamsWrite)
teams := reg.Group(PathTeams, s.guard)
teams.HandleFunc(http.MethodGet, "", s.ListTeams, read)
teams.HandleFunc(http.MethodPost, "", s.CreateTeam, write)
teams.HandleFunc(http.MethodGet, "/{id}", s.GetTeam, read)
teams.HandleFunc(http.MethodPatch, "/{id}", s.UpdateTeam, write)
teams.HandleFunc(http.MethodDelete, "/{id}", s.DeleteTeam, write)
teams.HandleFunc(
http.MethodGet,
"/{id}/members",
s.ListTeamMembers,
read,
)
teams.HandleFunc(
http.MethodPut,
"/{id}/members/{user}",
s.PutTeamMember,
write,
)
teams.HandleFunc(http.MethodDelete, "/{id}/members/{user}",
s.DeleteTeamMember, write,
)
teams.HandleFunc(
http.MethodGet,
"/{id}/invitations",
s.ListTeamInvitations,
read,
)
teams.HandleFunc(http.MethodDelete, "/{id}/invitations/{invitation}",
s.DeleteTeamInvitation, write,
)
}
read, write = s.can(PermClientsRead), s.can(PermClientsWrite)
clients := reg.Group(PathClients, s.guard)
clients.HandleFunc(http.MethodGet, "", s.ListClients, read)
clients.HandleFunc(http.MethodPost, "", s.CreateClient, write)
clients.HandleFunc(http.MethodGet, "/{id}", s.GetClient, read)
clients.HandleFunc(http.MethodPatch, "/{id}", s.UpdateClient, write)
clients.HandleFunc(http.MethodDelete, "/{id}", s.DeleteClient, write)
clients.HandleFunc(http.MethodPost, "/{id}/secret", s.RotateSecret, write)
clients.HandleFunc(
http.MethodDelete,
"/{id}/tokens",
s.RevokeClientTokens,
write,
)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package admin
import (
"net/http"
"time"
"uuid"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/eco/iam/client"
"github.com/deep-rent/nexus/eco/iam/oauth"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/std/pointer"
"github.com/deep-rent/nexus/sys/log"
)
// grantNames lists the grant types a registration may enable.
var grantNames = []string{
string(oauth.GrantTypeAuthorizationCode),
string(oauth.GrantTypeClientCredentials),
string(oauth.GrantTypeRefreshToken),
string(oauth.GrantTypeDeviceCode),
string(oauth.GrantTypeWebAuthn),
}
// validateGrants checks every requested grant type against the known set.
func validateGrants(v *valid.Validator, grants []oauth.GrantType) {
for _, g := range grants {
v.Whitelist("grants", string(g), grantNames...)
}
}
// validateEntries bounds the repeated fields of a registration. Each entry
// occupies one array element of a bounded column, so an over-long one must
// be refused here rather than surfacing as a storage failure.
func validateEntries(
v *valid.Validator,
redirectURIs, scopes, audience []string,
) {
for _, uri := range redirectURIs {
v.NotBlank("redirect_uris", uri)
v.MaxLen("redirect_uris", uri, client.MaxRedirectURILength)
}
for _, scope := range scopes {
v.NotBlank("scopes", scope)
v.MaxLen("scopes", scope, client.MaxScopeLength)
}
for _, aud := range audience {
v.NotBlank("audience", aud)
v.MaxLen("audience", aud, client.MaxAudienceLength)
}
}
// ClientCreateRequest is the payload registering an OAuth client.
type ClientCreateRequest struct {
// Name is the human-facing label. Required.
Name string `json:"name"`
// Confidential mints a client secret; the response reveals it exactly
// once. Public clients (SPAs, native apps) leave it false.
Confidential bool `json:"confidential,omitzero"`
// RedirectURIs is the whitelist of allowed redirect destinations.
RedirectURIs []string `json:"redirect_uris,omitzero"`
// Grants are the grant types the client may exercise.
Grants []oauth.GrantType `json:"grants,omitzero"`
// Scopes are the scope tokens the client may request.
Scopes []string `json:"scopes,omitzero"`
// Audience populates the aud claim of issued access tokens.
Audience []string `json:"audience,omitzero"`
// Disabled provisions the registration locked.
Disabled bool `json:"disabled,omitzero"`
// SecretExpiresAt sets when the minted secret stops verifying; it must
// lie in the future and needs a confidential registration. Absent, the
// secret never expires.
SecretExpiresAt time.Time `json:"client_secret_expires_at,omitzero"`
}
// Validate implements the [valid.Validatable] interface.
func (r *ClientCreateRequest) Validate(v *valid.Validator) {
v.NotBlank("name", r.Name)
v.MaxLen("name", r.Name, client.MaxNameLength)
validateGrants(v, r.Grants)
validateEntries(v, r.RedirectURIs, r.Scopes, r.Audience)
if !r.SecretExpiresAt.IsZero() && !r.Confidential {
v.Fail(
"client_secret_expires_at",
"a secret expiry needs a confidential client",
)
}
}
// ClientUpdateRequest is the payload partially updating a client
// registration. Absent fields keep their stored value.
type ClientUpdateRequest struct {
// Name replaces the human-facing label.
Name *string `json:"name,omitzero"`
// RedirectURIs replaces the redirect whitelist.
RedirectURIs *[]string `json:"redirect_uris,omitzero"`
// Grants replaces the allowed grant types.
Grants *[]oauth.GrantType `json:"grants,omitzero"`
// Scopes replaces the allowed scope tokens.
Scopes *[]string `json:"scopes,omitzero"`
// Audience replaces the token audience.
Audience *[]string `json:"audience,omitzero"`
// Disabled locks or unlocks the registration.
Disabled *bool `json:"disabled,omitzero"`
}
// Validate implements the [valid.Validatable] interface.
func (r *ClientUpdateRequest) Validate(v *valid.Validator) {
if r.Name != nil {
v.NotBlank("name", *r.Name)
v.MaxLen("name", *r.Name, client.MaxNameLength)
}
if r.Grants != nil {
validateGrants(v, *r.Grants)
}
validateEntries(
v,
pointer.Value(r.RedirectURIs),
pointer.Value(r.Scopes),
pointer.Value(r.Audience),
)
}
// SecretRotateRequest is the optional payload of a secret rotation. An
// absent body — or an absent field — mints a secret that never expires.
type SecretRotateRequest struct {
// SecretExpiresAt sets when the fresh secret stops verifying; it must
// lie in the future.
SecretExpiresAt time.Time `json:"client_secret_expires_at,omitzero"`
}
// Validate implements the [valid.Validatable] interface. The future check
// lives in the handler, which owns the clock.
func (*SecretRotateRequest) Validate(_ *valid.Validator) {}
// ClientCreatedResponse couples a fresh registration with its one-time
// secret.
type ClientCreatedResponse struct {
// Client is the stored registration.
Client *client.Client `json:"client"`
// Secret is the plaintext client secret — shown here and never again.
// Empty for public clients.
Secret string `json:"secret,omitzero"`
}
// SecretResponse carries a rotated one-time secret.
type SecretResponse struct {
// Secret is the plaintext client secret — shown here and never again.
Secret string `json:"secret"`
// SecretExpiresAt is when the secret stops verifying; absent, it never
// expires.
SecretExpiresAt time.Time `json:"client_secret_expires_at,omitzero"`
}
var (
_ valid.Validatable = (*ClientCreateRequest)(nil)
_ valid.Validatable = (*ClientUpdateRequest)(nil)
_ valid.Validatable = (*SecretRotateRequest)(nil)
)
// validateSecretExpiry refuses an expiry that has already passed: it would
// register a secret that is dead on arrival, which is never what the
// caller meant.
func (s *Server) validateSecretExpiry(expiresAt time.Time) error {
if !expiresAt.IsZero() && !expiresAt.After(s.now()) {
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: "the secret expiry must lie in the future",
}
}
return nil
}
// ListClients returns a page of the client registry, searched in the
// vocabulary of [client.Search]: the free-text term matches a client's
// name.
func (s *Server) ListClients(e *router.Exchange) error {
q, err := client.Search.Bind(e)
if err != nil {
return err
}
clients, err := s.registry.List(e.Context(), q)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to list clients",
Cause: err,
}
}
return e.JSON(http.StatusOK, clients)
}
// CreateClient registers an OAuth client, revealing the minted secret
// exactly once for confidential registrations.
func (s *Server) CreateClient(e *router.Exchange) error {
var req ClientCreateRequest
if err := e.BindJSON(&req); err != nil {
return err
}
if err := s.validateSecretExpiry(req.SecretExpiresAt); err != nil {
return err
}
c := &client.Client{
Name: req.Name,
RedirectURIs: req.RedirectURIs,
Grants: req.Grants,
Scopes: req.Scopes,
Audience: req.Audience,
Disabled: req.Disabled,
SecretExpiresAt: req.SecretExpiresAt,
}
secret, err := s.clients.Register(e.Context(), c, req.Confidential)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to register client",
Cause: err,
}
}
return e.JSON(http.StatusCreated, ClientCreatedResponse{
Client: c,
Secret: secret,
})
}
// GetClient returns one client registration by ID.
func (s *Server) GetClient(e *router.Exchange) error {
c, err := s.lookupClient(e)
if err != nil {
return err
}
return e.JSON(http.StatusOK, c)
}
// UpdateClient partially updates a client registration.
func (s *Server) UpdateClient(e *router.Exchange) error {
c, err := s.lookupClient(e)
if err != nil {
return err
}
var req ClientUpdateRequest
if err := e.BindJSON(&req); err != nil {
return err
}
if req.Name != nil {
c.Name = *req.Name
}
if req.RedirectURIs != nil {
c.RedirectURIs = *req.RedirectURIs
}
if req.Grants != nil {
c.Grants = *req.Grants
}
if req.Scopes != nil {
c.Scopes = *req.Scopes
}
if req.Audience != nil {
c.Audience = *req.Audience
}
if req.Disabled != nil {
c.Disabled = *req.Disabled
}
c.UpdatedAt = s.now()
if err := s.registry.Update(e.Context(), c); err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to update client",
Cause: err,
}
}
return e.JSON(http.StatusOK, c)
}
// DeleteClient removes a client registration after revoking its refresh
// tokens.
func (s *Server) DeleteClient(e *router.Exchange) error {
var params struct {
ID uuid.UUID `path:"id"`
}
if err := e.BindPath(¶ms); err != nil {
return err
}
id := params.ID
s.revokeClient(e, id)
deleted, err := s.registry.Delete(e.Context(), id)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to delete client",
Cause: err,
}
}
if !deleted {
return &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "no such client",
}
}
e.NoContent()
return nil
}
// RotateSecret mints a fresh client secret, revealing it exactly once. The
// previous secret stops working immediately, and the request's expiry — if
// any — becomes the fresh secret's, so rotation is also how an expired
// client comes back to life.
func (s *Server) RotateSecret(e *router.Exchange) error {
c, err := s.lookupClient(e)
if err != nil {
return err
}
// The body is optional: a bare rotation mints a secret that never
// expires.
var req SecretRotateRequest
if e.R.ContentLength != 0 {
if err := e.BindJSON(&req); err != nil {
return err
}
}
if err := s.validateSecretExpiry(req.SecretExpiresAt); err != nil {
return err
}
secret, err := s.clients.RotateSecret(
e.Context(), c.ID, req.SecretExpiresAt,
)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to rotate secret",
Cause: err,
}
}
return e.JSON(http.StatusOK, SecretResponse{
Secret: secret,
SecretExpiresAt: req.SecretExpiresAt,
})
}
// RevokeClientTokens revokes every refresh token issued to the client.
func (s *Server) RevokeClientTokens(e *router.Exchange) error {
var params struct {
ID uuid.UUID `path:"id"`
}
if err := e.BindPath(¶ms); err != nil {
return err
}
id := params.ID
if s.revoker == nil {
return &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "token revocation is not configured",
}
}
if err := s.revoker.DeleteRefreshTokensForClient(
e.Context(), id,
); err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to revoke tokens",
Cause: err,
}
}
e.NoContent()
return nil
}
// lookupClient resolves the {id} path parameter to a stored client.
func (s *Server) lookupClient(e *router.Exchange) (*client.Client, error) {
var params struct {
ID uuid.UUID `path:"id"`
}
if err := e.BindPath(¶ms); err != nil {
return nil, err
}
id := params.ID
c, err := s.registry.Get(e.Context(), id)
if err != nil {
return nil, &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to look up client",
Cause: err,
}
}
if c == nil {
return nil, &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "no such client",
}
}
return c, nil
}
// revokeClient cuts the client's refresh tokens, best-effort.
func (s *Server) revokeClient(e *router.Exchange, id uuid.UUID) {
if s.revoker == nil {
return
}
if err := s.revoker.DeleteRefreshTokensForClient(
e.Context(), id,
); err != nil {
s.logger.Error(
e.Context(),
"Failed to revoke client tokens",
log.UUID("client_id", id),
log.Error(err),
)
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package admin
import (
"time"
"github.com/deep-rent/nexus/eco/iam/passkey"
"github.com/deep-rent/nexus/eco/iam/post"
"github.com/deep-rent/nexus/eco/iam/team"
"github.com/deep-rent/nexus/eco/iam/ticket"
hookadmin "github.com/deep-rent/nexus/net/notify/hook/admin"
"github.com/deep-rent/nexus/std/clock"
)
// DefaultResetLifetime is the redemption window of administratively
// triggered password recovery tickets.
const DefaultResetLifetime = 1 * time.Hour
// Option customizes a [Server] during construction with [New].
type Option func(*Server)
// WithMail enables the administrative password recovery endpoint, issuing
// single-use tickets from the given store and dispatching mails through
// the given mailer. Both must be non-nil, or the option panics, since a
// half-configured mail flow is a startup configuration error. Use the same
// ticket store as the account API, whose reset endpoint redeems these
// tickets.
func WithMail(tickets ticket.Store, mailer *post.Mailer) Option {
if tickets == nil || mailer == nil {
panic("ticket store and mailer are required")
}
return func(s *Server) {
s.tickets = tickets
s.post = mailer
}
}
// WithTeams enables global team management over the given engine. A nil
// manager is ignored.
func WithTeams(teams *team.Manager) Option {
return func(s *Server) {
if teams != nil {
s.teams = teams
}
}
}
// WithHooks enables webhook endpoint management over the given
// registry, served by the shared surface in [hookadmin]. A nil
// registry is ignored, and leaves the endpoints unmanageable — which
// is what a deployment publishing no webhooks wants.
func WithHooks(hooks hookadmin.Hooks) Option {
return func(s *Server) {
if hooks != nil {
s.hooks = hooks
}
}
}
// WithPasskeys enables passkey administration over the given credential
// store. A nil store is ignored.
func WithPasskeys(credentials passkey.CredentialStore) Option {
return func(s *Server) {
if credentials != nil {
s.credentials = credentials
}
}
}
// WithResetLifetime sets the redemption window of recovery tickets.
// Nonpositive values are ignored. Defaults to [DefaultResetLifetime].
func WithResetLifetime(d time.Duration) Option {
return func(s *Server) {
if d > 0 {
s.resetLifetime = d
}
}
}
// WithClock overrides the time source, primarily for testing. A nil
// function is ignored. Defaults to [clock.System].
func WithClock(now clock.Clock) Option {
return func(s *Server) {
if now != nil {
s.now = now
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package admin
import (
"errors"
"net/http"
"uuid"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/eco/iam/team"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/sec/auth"
)
// TeamCreateRequest is the payload provisioning a team administratively.
type TeamCreateRequest struct {
// Name is the human-facing team name.
Name string `json:"name"`
// Owner identifies the user installed as the team's first owner.
// Administrative creation bypasses the founding limit and records no
// founder, so the team never counts against anyone's quota.
Owner uuid.UUID `json:"owner"`
}
// Validate implements the [valid.Validatable] interface.
func (r *TeamCreateRequest) Validate(v *valid.Validator) {
v.NotBlank("name", r.Name)
v.MaxLen("name", r.Name, team.MaxNameLength)
if r.Owner == uuid.Nil() {
v.Fail("owner", "must not be empty")
}
}
// TeamUpdateRequest is the payload renaming a team administratively.
type TeamUpdateRequest struct {
// Name is the replacement team name.
Name string `json:"name"`
}
// Validate implements the [valid.Validatable] interface.
func (r *TeamUpdateRequest) Validate(v *valid.Validator) {
v.NotBlank("name", r.Name)
v.MaxLen("name", r.Name, team.MaxNameLength)
}
// MemberRequest is the payload adding a member or setting their role.
type MemberRequest struct {
// Owner grants the management role.
Owner bool `json:"owner,omitzero"`
}
var (
_ valid.Validatable = (*TeamCreateRequest)(nil)
_ valid.Validatable = (*TeamUpdateRequest)(nil)
)
// ListTeams returns a page of the team registry, searched in the
// vocabulary of [team.Search]: the free-text term matches a team's name.
func (s *Server) ListTeams(e *router.Exchange) error {
q, err := team.Search.Bind(e)
if err != nil {
return err
}
teams, err := s.teams.Store().ListTeams(e.Context(), q)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to list teams",
Cause: err,
}
}
return e.JSON(http.StatusOK, teams)
}
// CreateTeam provisions a team with the given first owner. Unlike
// self-service founding it bypasses the owner's team limit and records no
// founder.
func (s *Server) CreateTeam(e *router.Exchange) error {
var req TeamCreateRequest
if err := e.BindJSON(&req); err != nil {
return err
}
owner, err := s.store.Get(e.Context(), req.Owner)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to look up owner",
Cause: err,
}
}
if owner == nil {
return &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "no such user",
}
}
t, err := s.teams.Install(e.Context(), req.Name, owner.ID, s.actor(e))
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to create team",
Cause: err,
}
}
return e.JSON(http.StatusCreated, t)
}
// actor resolves the acting administrator for event attribution: the
// user behind the Bearer token, or the zero UUID for a machine client,
// which names no user.
func (*Server) actor(e *router.Exchange) uuid.UUID {
if claims, ok := auth.From(e); ok {
return claims.UserID()
}
return uuid.Nil()
}
// GetTeam returns one team by ID.
func (s *Server) GetTeam(e *router.Exchange) error {
t, err := s.lookupTeam(e)
if err != nil {
return err
}
return e.JSON(http.StatusOK, t)
}
// UpdateTeam renames a team.
func (s *Server) UpdateTeam(e *router.Exchange) error {
t, err := s.lookupTeam(e)
if err != nil {
return err
}
var req TeamUpdateRequest
if err := e.BindJSON(&req); err != nil {
return err
}
t.Name = req.Name
t.UpdatedAt = s.now()
if err := s.teams.Store().UpdateTeam(e.Context(), t); err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to update team",
Cause: err,
}
}
return e.JSON(http.StatusOK, t)
}
// DeleteTeam dissolves a team with its memberships and invitations.
func (s *Server) DeleteTeam(e *router.Exchange) error {
t, err := s.lookupTeam(e)
if err != nil {
return err
}
if _, err := s.teams.Dissolve(
e.Context(), t.ID, s.actor(e),
); err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to delete team",
Cause: err,
}
}
e.NoContent()
return nil
}
// ListTeamMembers returns the team's roster.
func (s *Server) ListTeamMembers(e *router.Exchange) error {
t, err := s.lookupTeam(e)
if err != nil {
return err
}
members, err := s.teams.Store().ListMembers(e.Context(), t.ID)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to list members",
Cause: err,
}
}
if members == nil {
members = []team.Member{}
}
return e.JSON(http.StatusOK, members)
}
// PutTeamMember adds a user to the team or sets an existing member's role;
// the request body carries the desired role either way. Unlike the
// self-service API it may also demote an owner — administrators outrank
// the at-least-one-owner invariant, since they can always re-appoint.
func (s *Server) PutTeamMember(e *router.Exchange) error {
t, err := s.lookupTeam(e)
if err != nil {
return err
}
var params struct {
User uuid.UUID `path:"user"`
}
if err := e.BindPath(¶ms); err != nil {
return err
}
var req MemberRequest
if err := e.BindJSON(&req); err != nil {
return err
}
u, err := s.store.Get(e.Context(), params.User)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to look up user",
Cause: err,
}
}
if u == nil {
return &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "no such user",
}
}
if err := s.teams.SetRole(
e.Context(), t.ID, u.ID, req.Owner, s.actor(e),
); err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to set membership",
Cause: err,
}
}
e.NoContent()
return nil
}
// DeleteTeamMember removes a membership. Unlike the self-service API it
// may also evict owners.
func (s *Server) DeleteTeamMember(e *router.Exchange) error {
t, err := s.lookupTeam(e)
if err != nil {
return err
}
var params struct {
User uuid.UUID `path:"user"`
}
if err := e.BindPath(¶ms); err != nil {
return err
}
switch err := s.teams.Evict(
e.Context(), t.ID, params.User, s.actor(e),
); {
case errors.Is(err, team.ErrNotMember):
return &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "no such member",
}
case err != nil:
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to remove member",
Cause: err,
}
}
e.NoContent()
return nil
}
// ListTeamInvitations returns the team's standing invitations.
func (s *Server) ListTeamInvitations(e *router.Exchange) error {
t, err := s.lookupTeam(e)
if err != nil {
return err
}
invs, err := s.teams.Store().ListInvitations(e.Context(), t.ID)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to list invitations",
Cause: err,
}
}
if invs == nil {
invs = []team.Invitation{}
}
return e.JSON(http.StatusOK, invs)
}
// DeleteTeamInvitation withdraws a standing invitation. Withdrawing a
// rejected record also clears its cooldown history.
func (s *Server) DeleteTeamInvitation(e *router.Exchange) error {
t, err := s.lookupTeam(e)
if err != nil {
return err
}
var params struct {
Invitation uuid.UUID `path:"invitation"`
}
if err := e.BindPath(¶ms); err != nil {
return err
}
roster := s.teams.Store()
inv, err := roster.GetInvitation(e.Context(), params.Invitation)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to look up invitation",
Cause: err,
}
}
if inv == nil || inv.TeamID != t.ID {
return &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "no such invitation",
}
}
if _, err := roster.DeleteInvitation(
e.Context(), inv.ID,
); err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to delete invitation",
Cause: err,
}
}
e.NoContent()
return nil
}
// ListUserTeams returns the teams a user belongs to, with their role in
// each — the reverse view of the per-team roster.
func (s *Server) ListUserTeams(e *router.Exchange) error {
u, err := s.lookupUser(e)
if err != nil {
return err
}
affs, err := s.teams.Store().ListAffiliations(e.Context(), u.ID)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to list teams",
Cause: err,
}
}
if affs == nil {
affs = []team.Affiliation{}
}
return e.JSON(http.StatusOK, affs)
}
// lookupTeam resolves the {id} path parameter to a stored team.
func (s *Server) lookupTeam(e *router.Exchange) (*team.Team, error) {
var params struct {
ID uuid.UUID `path:"id"`
}
if err := e.BindPath(¶ms); err != nil {
return nil, err
}
t, err := s.teams.Store().GetTeam(e.Context(), params.ID)
if err != nil {
return nil, &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to look up team",
Cause: err,
}
}
if t == nil {
return nil, &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "no such team",
}
}
return t, nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package admin
import (
"encoding/base64"
"errors"
"net/http"
"uuid"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/eco/iam/account"
"github.com/deep-rent/nexus/eco/iam/post"
"github.com/deep-rent/nexus/eco/iam/user"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/std/i18n"
"github.com/deep-rent/nexus/sys/log"
)
// UserCreateRequest is the payload provisioning a user account.
type UserCreateRequest struct {
// Name is the user's full name. Required.
Name string `json:"name"`
// DisplayName is the optional name the user prefers to be addressed
// by.
DisplayName string `json:"display_name,omitzero"`
// Email is the unique login identifier, stored verified when
// EmailVerified is set — administrators vouch for addresses they
// provision. Required.
Email string `json:"email"`
// EmailVerified marks the email as proven.
EmailVerified bool `json:"email_verified,omitzero"`
// Phone is the contact number in E.164 format.
Phone string `json:"phone,omitzero"`
// PhoneVerified marks the phone as proven.
PhoneVerified bool `json:"phone_verified,omitzero"`
// Locales are the user's preferred locales as BCP 47 language tags,
// most preferred first.
Locales []string `json:"locales,omitzero"`
// Zone is the user's time zone as an IANA Time Zone Database name.
Zone string `json:"zone,omitzero"`
// Password is the initial password. Empty provisions a passwordless
// account that signs in via federation, passkeys, or recovery.
Password string `json:"password,omitzero"`
// Roles are the role names granted to the user.
Roles []string `json:"roles,omitzero"`
// TeamLimit is how many teams the user may found. Defaults to zero:
// founding is an administrator-granted privilege.
TeamLimit int `json:"team_limit,omitzero"`
// MembershipLimit is how many teams the user may belong to through
// self-service joins. Defaults to zero: membership stays uncapped.
MembershipLimit int `json:"membership_limit,omitzero"`
// SeatLimit is how many seats each team the user founds may hold —
// members plus pending invitations. Defaults to zero: size stays
// uncapped.
SeatLimit int `json:"seat_limit,omitzero"`
// Disabled provisions the account locked.
Disabled bool `json:"disabled,omitzero"`
}
// Validate implements the [valid.Validatable] interface.
func (r *UserCreateRequest) Validate(v *valid.Validator) {
v.NotBlank("name", r.Name)
v.MaxLen("name", r.Name, user.MaxNameLength)
v.MaxLen("display_name", r.DisplayName, user.MaxNameLength)
v.NotEmpty("email", r.Email)
v.Email("email", r.Email)
if r.Phone != "" {
v.Phone("phone", r.Phone)
}
locales("locales", v, r.Locales)
roles("roles", v, r.Roles)
if r.Zone != "" {
v.Timezone("zone", r.Zone)
v.MaxLen("zone", r.Zone, user.MaxZoneLength)
}
if r.Password != "" {
v.MinLen("password", r.Password, account.MinPasswordLength)
v.MaxLen("password", r.Password, account.MaxPasswordLength)
}
v.Min("team_limit", r.TeamLimit, 0)
v.Min("membership_limit", r.MembershipLimit, 0)
v.Min("seat_limit", r.SeatLimit, 0)
}
// UserUpdateRequest is the payload partially updating a user account.
// Absent fields keep their stored value.
type UserUpdateRequest struct {
// Name replaces the full name.
Name *string `json:"name,omitzero"`
// DisplayName replaces the display name; an empty string detaches it.
DisplayName *string `json:"display_name,omitzero"`
// Email replaces the login identifier and clears its verification.
Email *string `json:"email,omitzero"`
// EmailVerified sets the email verification state.
EmailVerified *bool `json:"email_verified,omitzero"`
// RecoveryEmail replaces the recovery address; an empty string detaches
// it and clears the verification.
RecoveryEmail *string `json:"recovery_email,omitzero"`
// RecoveryEmailVerified sets the recovery email verification state.
RecoveryEmailVerified *bool `json:"recovery_email_verified,omitzero"`
// Phone replaces the contact number; an empty string detaches it and
// clears the verification.
Phone *string `json:"phone,omitzero"`
// PhoneVerified sets the phone verification state.
PhoneVerified *bool `json:"phone_verified,omitzero"`
// Locales replaces the preferred locales; an empty list detaches them.
Locales *[]string `json:"locales,omitzero"`
// Zone replaces the time zone; an empty string detaches it.
Zone *string `json:"zone,omitzero"`
// Roles replaces the granted role names.
Roles *[]string `json:"roles,omitzero"`
// TeamLimit replaces how many teams the user may found. Lowering it
// below the number already founded stops further founding without
// dissolving anything.
TeamLimit *int `json:"team_limit,omitzero"`
// MembershipLimit replaces how many teams the user may belong to
// through self-service joins; zero lifts the cap. Lowering it below
// the number already joined stops further joins without evicting the
// user from anywhere.
MembershipLimit *int `json:"membership_limit,omitzero"`
// SeatLimit replaces how many seats each team the user founds may
// hold; zero lifts the cap. Lowering it below a team's occupancy
// stops further invitations without evicting anyone.
SeatLimit *int `json:"seat_limit,omitzero"`
// Factors replaces the enrolled second factors.
Factors *[]user.Factor `json:"factors,omitzero"`
// Disabled locks or unlocks the account.
Disabled *bool `json:"disabled,omitzero"`
}
// Validate implements the [valid.Validatable] interface.
func (r *UserUpdateRequest) Validate(v *valid.Validator) {
if r.Name != nil {
v.NotBlank("name", *r.Name)
v.MaxLen("name", *r.Name, user.MaxNameLength)
}
if r.DisplayName != nil {
v.MaxLen("display_name", *r.DisplayName, user.MaxNameLength)
}
if r.Email != nil {
v.NotEmpty("email", *r.Email)
v.Email("email", *r.Email)
}
if r.RecoveryEmail != nil && *r.RecoveryEmail != "" {
v.Email("recovery_email", *r.RecoveryEmail)
}
if r.Phone != nil && *r.Phone != "" {
v.Phone("phone", *r.Phone)
}
if r.Locales != nil {
locales("locales", v, *r.Locales)
}
if r.Roles != nil {
roles("roles", v, *r.Roles)
}
if r.Zone != nil && *r.Zone != "" {
v.Timezone("zone", *r.Zone)
v.MaxLen("zone", *r.Zone, user.MaxZoneLength)
}
if r.Factors != nil {
for _, f := range *r.Factors {
v.Whitelist(
"factors",
string(f),
string(user.FactorMail),
string(user.FactorText),
)
}
}
if r.TeamLimit != nil {
v.Min("team_limit", *r.TeamLimit, 0)
}
if r.MembershipLimit != nil {
v.Min("membership_limit", *r.MembershipLimit, 0)
}
if r.SeatLimit != nil {
v.Min("seat_limit", *r.SeatLimit, 0)
}
}
// PasswordRequest is the payload setting a user's password outright.
type PasswordRequest struct {
// Password is the replacement password.
Password string `json:"password"`
}
// Validate implements the [valid.Validatable] interface.
func (r *PasswordRequest) Validate(v *valid.Validator) {
v.NotEmpty("password", r.Password)
v.MinLen("password", r.Password, account.MinPasswordLength)
v.MaxLen("password", r.Password, account.MaxPasswordLength)
}
var (
_ valid.Validatable = (*UserCreateRequest)(nil)
_ valid.Validatable = (*UserUpdateRequest)(nil)
_ valid.Validatable = (*PasswordRequest)(nil)
)
// locales checks every entry of a preferred-locale list for BCP 47
// well-formedness and refuses duplicates. Each tag occupies one array
// element of a bounded column, so its length is checked here rather than
// left to surface as a storage failure.
func locales(field string, v *valid.Validator, tags []string) {
for _, tag := range tags {
v.NotEmpty(field, tag)
v.Lang(field, tag)
v.MaxLen(field, tag, user.MaxLocaleLength)
}
v.Unique(field, tags)
}
// roles bounds every entry of a granted-role list, for the same reason.
func roles(field string, v *valid.Validator, names []string) {
for _, name := range names {
v.NotBlank(field, name)
v.MaxLen(field, name, user.MaxRoleLength)
}
v.Unique(field, names)
}
// canonicalize brings every tag of a validated locale list into the
// conventional BCP 47 letter case, matching what [user.Manager.Register]
// stores.
func canonicalize(tags []string) []string {
for i, tag := range tags {
tags[i] = i18n.Canonical(tag)
}
return tags
}
// ListUsers returns a page of the user directory, searched in the
// vocabulary of [user.Search]: the free-text term matches a user's name or
// email address, and the two account states filter it.
//
// GET /admin/users?q=alice&verified=eq:true&sort=+name
func (s *Server) ListUsers(e *router.Exchange) error {
q, err := user.Search.Bind(e)
if err != nil {
return err
}
users, err := s.store.List(e.Context(), q)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to list users",
Cause: err,
}
}
return e.JSON(http.StatusOK, users)
}
// CreateUser provisions a user account.
func (s *Server) CreateUser(e *router.Exchange) error {
var req UserCreateRequest
if err := e.BindJSON(&req); err != nil {
return err
}
u := &user.User{
Name: req.Name,
DisplayName: req.DisplayName,
Email: req.Email,
EmailVerified: req.EmailVerified && req.Email != "",
Phone: req.Phone,
PhoneVerified: req.PhoneVerified && req.Phone != "",
Locales: req.Locales,
Zone: req.Zone,
Roles: req.Roles,
TeamLimit: req.TeamLimit,
MembershipLimit: req.MembershipLimit,
SeatLimit: req.SeatLimit,
Disabled: req.Disabled,
}
if err := s.users.Register(e.Context(), u, req.Password); err != nil {
if errors.Is(err, user.ErrDuplicate) {
return &router.Error{
Status: http.StatusConflict,
Reason: account.ReasonEmailTaken,
Description: "email address is already in use",
}
}
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to create user",
Cause: err,
}
}
return e.JSON(http.StatusCreated, u)
}
// GetUser returns one user by ID.
func (s *Server) GetUser(e *router.Exchange) error {
u, err := s.lookupUser(e)
if err != nil {
return err
}
return e.JSON(http.StatusOK, u)
}
// UpdateUser partially updates a user account.
func (s *Server) UpdateUser(e *router.Exchange) error {
u, err := s.lookupUser(e)
if err != nil {
return err
}
var req UserUpdateRequest
if err := e.BindJSON(&req); err != nil {
return err
}
if req.Name != nil {
u.Name = *req.Name
}
if req.DisplayName != nil {
u.DisplayName = *req.DisplayName
}
if req.Email != nil {
u.Email = user.Normalize(*req.Email)
u.EmailVerified = false
}
if req.EmailVerified != nil {
u.EmailVerified = *req.EmailVerified && u.Email != ""
}
if req.RecoveryEmail != nil {
u.RecoveryEmail = user.Normalize(*req.RecoveryEmail)
u.RecoveryEmailVerified = false
}
if req.RecoveryEmailVerified != nil {
u.RecoveryEmailVerified = *req.RecoveryEmailVerified &&
u.RecoveryEmail != ""
}
if req.Phone != nil {
u.Phone = *req.Phone
u.PhoneVerified = false
}
if req.PhoneVerified != nil {
u.PhoneVerified = *req.PhoneVerified && u.Phone != ""
}
if req.Locales != nil {
u.Locales = canonicalize(*req.Locales)
}
if req.Zone != nil {
u.Zone = *req.Zone
}
if req.Roles != nil {
u.Roles = *req.Roles
}
if req.Factors != nil {
u.Factors = *req.Factors
}
if req.TeamLimit != nil {
u.TeamLimit = *req.TeamLimit
}
if req.MembershipLimit != nil {
u.MembershipLimit = *req.MembershipLimit
}
if req.SeatLimit != nil {
u.SeatLimit = *req.SeatLimit
}
// A lockout only takes hold once the credentials standing for the
// account are gone: token minting stops the moment the record says
// disabled, but sessions and trusted devices would otherwise survive
// and restore the intruder's foothold when the account is reopened.
locked := req.Disabled != nil && *req.Disabled && !u.Disabled
if req.Disabled != nil {
u.Disabled = *req.Disabled
}
u.UpdatedAt = s.now()
if err := s.users.Update(e.Context(), u); err != nil {
if errors.Is(err, user.ErrDuplicate) {
return &router.Error{
Status: http.StatusConflict,
Reason: account.ReasonEmailTaken,
Description: "email address is already in use",
}
}
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to update user",
Cause: err,
}
}
if locked {
s.revoke(e, u.ID)
}
return e.JSON(http.StatusOK, u)
}
// DeleteUser removes a user account after revoking everything standing for
// it. Durable dependents (identities, passkeys) cascade in the store.
func (s *Server) DeleteUser(e *router.Exchange) error {
var params struct {
ID uuid.UUID `path:"id"`
}
if err := e.BindPath(¶ms); err != nil {
return err
}
id := params.ID
s.revoke(e, id)
deleted, err := s.users.Delete(e.Context(), id)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to delete user",
Cause: err,
}
}
if !deleted {
return &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "no such user",
}
}
e.NoContent()
return nil
}
// SetPassword replaces a user's password outright and revokes everything
// standing for the old one.
func (s *Server) SetPassword(e *router.Exchange) error {
u, err := s.lookupUser(e)
if err != nil {
return err
}
var req PasswordRequest
if err := e.BindJSON(&req); err != nil {
return err
}
if err := s.users.SetPassword(
e.Context(), u.ID, req.Password,
); err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to set password",
Cause: err,
}
}
s.revoke(e, u.ID)
e.NoContent()
return nil
}
// RecoverPassword mails the user a password recovery link, mirroring the
// self-service flow.
func (s *Server) RecoverPassword(e *router.Exchange) error {
u, err := s.lookupUser(e)
if err != nil {
return err
}
if u.Email == "" {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "user has no email address",
Cause: nil,
}
}
token, err := s.reset.Issue(
e.Context(),
u.ID,
account.PurposeResetPassword,
"",
)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to issue ticket",
Cause: err,
}
}
if err := s.post.ResetPassword(
e.Context(),
post.Recipient{
Addr: u.Email,
Name: u.Name,
DisplayName: u.DisplayName,
Locales: u.Locales,
},
token,
); err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to dispatch recovery mail",
Cause: err,
}
}
e.NoContent()
return nil
}
// RevokeSessions destroys every login session of the user.
func (s *Server) RevokeSessions(e *router.Exchange) error {
var params struct {
ID uuid.UUID `path:"id"`
}
if err := e.BindPath(¶ms); err != nil {
return err
}
id := params.ID
if err := s.login.RevokeSessions(e.Context(), id); err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to revoke sessions",
Cause: err,
}
}
e.NoContent()
return nil
}
// RevokeDevices forgets every remembered device of the user.
func (s *Server) RevokeDevices(e *router.Exchange) error {
var params struct {
ID uuid.UUID `path:"id"`
}
if err := e.BindPath(¶ms); err != nil {
return err
}
id := params.ID
if err := s.login.RevokeTrustedDevices(e.Context(), id); err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to revoke devices",
Cause: err,
}
}
e.NoContent()
return nil
}
// RevokeTokens revokes every refresh token held on behalf of the user.
func (s *Server) RevokeTokens(e *router.Exchange) error {
var params struct {
ID uuid.UUID `path:"id"`
}
if err := e.BindPath(¶ms); err != nil {
return err
}
id := params.ID
if s.revoker == nil {
return &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "token revocation is not configured",
}
}
if err := s.revoker.DeleteRefreshTokensForUser(
e.Context(), id,
); err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to revoke tokens",
Cause: err,
}
}
e.NoContent()
return nil
}
// ListIdentities returns the external identities linked to the user.
func (s *Server) ListIdentities(e *router.Exchange) error {
u, err := s.lookupUser(e)
if err != nil {
return err
}
identities, err := s.store.ListIdentities(e.Context(), u.ID)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to list identities",
Cause: err,
}
}
if identities == nil {
identities = []user.Identity{}
}
return e.JSON(http.StatusOK, identities)
}
// UnlinkIdentity detaches an external identity from the user.
func (s *Server) UnlinkIdentity(e *router.Exchange) error {
var params struct {
ID uuid.UUID `path:"id"`
}
if err := e.BindPath(¶ms); err != nil {
return err
}
id := params.ID
deleted, err := s.store.UnlinkIdentity(
e.Context(),
e.Param("provider"),
id,
)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to unlink identity",
Cause: err,
}
}
if !deleted {
return &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "no such identity",
}
}
e.NoContent()
return nil
}
// ListPasskeys returns the user's registered passkeys.
func (s *Server) ListPasskeys(e *router.Exchange) error {
u, err := s.lookupUser(e)
if err != nil {
return err
}
keys, err := s.credentials.List(e.Context(), u.ID)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to list passkeys",
Cause: err,
}
}
type passkeyResponse struct {
ID string `json:"id"`
Name string `json:"name,omitzero"`
CreatedAt int64 `json:"created_at,omitzero"`
}
out := make([]passkeyResponse, len(keys))
for i, k := range keys {
out[i] = passkeyResponse{
ID: base64.RawURLEncoding.EncodeToString(k.Credential.ID),
Name: k.Name,
CreatedAt: k.CreatedAt.Unix(),
}
}
return e.JSON(http.StatusOK, out)
}
// DeletePasskey removes one of the user's passkeys.
func (s *Server) DeletePasskey(e *router.Exchange) error {
var params struct {
ID uuid.UUID `path:"id"`
}
if err := e.BindPath(¶ms); err != nil {
return err
}
id := params.ID
cred, err := base64.RawURLEncoding.DecodeString(e.Param("credential"))
if err != nil {
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: "malformed passkey ID",
}
}
deleted, err := s.credentials.Delete(e.Context(), id, cred)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to delete passkey",
Cause: err,
}
}
if !deleted {
return &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "no such passkey",
}
}
e.NoContent()
return nil
}
// lookupUser resolves the {id} path parameter to a stored user.
func (s *Server) lookupUser(e *router.Exchange) (*user.User, error) {
var params struct {
ID uuid.UUID `path:"id"`
}
if err := e.BindPath(¶ms); err != nil {
return nil, err
}
id := params.ID
u, err := s.store.Get(e.Context(), id)
if err != nil {
return nil, &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to look up user",
Cause: err,
}
}
if u == nil {
return nil, &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "no such user",
}
}
return u, nil
}
// revoke cuts every standing credential of the user, best-effort.
func (s *Server) revoke(e *router.Exchange, id uuid.UUID) {
ctx := e.Context()
if err := s.login.RevokeSessions(ctx, id); err != nil {
s.logger.Error(
ctx,
"Failed to revoke user sessions",
log.UUID("user_id", id),
log.Error(err),
)
}
if err := s.login.RevokeTrustedDevices(ctx, id); err != nil {
s.logger.Error(
ctx,
"Failed to revoke trusted devices",
log.UUID("user_id", id),
log.Error(err),
)
}
if s.revoker != nil {
if err := s.revoker.DeleteRefreshTokensForUser(ctx, id); err != nil {
s.logger.Error(
ctx,
"Failed to revoke user tokens",
log.UUID("user_id", id),
log.Error(err),
)
}
}
// See the account server's counterpart: a standing ticket authorizes a
// deferred action and outlives the credentials it was issued under.
if s.tickets != nil {
if err := s.tickets.DeleteForOwner(ctx, id); err != nil {
s.logger.Error(
ctx,
"Failed to revoke standing tickets",
log.UUID("user_id", id),
log.Error(err),
)
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package alert
import (
"context"
"errors"
"time"
"uuid"
"github.com/deep-rent/nexus/eco/iam/login"
"github.com/deep-rent/nexus/eco/iam/post"
"github.com/deep-rent/nexus/eco/iam/team"
"github.com/deep-rent/nexus/eco/iam/topic"
"github.com/deep-rent/nexus/eco/iam/user"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/sys/event"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/metrics"
)
// DefaultTimeout bounds one notification: the account lookup, the team
// lookup behind a membership notice, and the dispatch itself. It is generous
// relative to any of them, since a notification runs off the request path
// where nothing waits on it, and missing one is worse than a slow one.
const DefaultTimeout = 15 * time.Second
// Users is the narrow seam onto the user directory, satisfied by
// [user.Store]. The notifier reads an account for the address to mail and
// the settings that say whether to.
type Users interface {
// Get returns the user with the given ID, or nil when none exists.
Get(ctx context.Context, id uuid.UUID) (*user.User, error)
}
// Teams is the narrow seam onto the team directory, satisfied by
// [team.Store]. The notifier reads a team only for the name to put in a
// membership notice.
type Teams interface {
// GetTeam returns the team with the given ID, or nil when none exists.
GetTeam(ctx context.Context, id uuid.UUID) (*team.Team, error)
}
// Config configures a [Notifier].
type Config struct {
// Post dispatches the mails. Required.
Post *post.Mailer
// Users resolves the account to notify. Required.
Users Users
// Teams resolves the team named in a membership notice. Membership
// notices are skipped when nil.
Teams Teams
// Logger reports a notification that could not be dispatched. It
// defaults to [log.Discard].
Logger *log.Logger
// Registry receives the dispatch counters. It defaults to
// [metrics.DefaultRegistry].
Registry *metrics.Registry
// Clock stamps the notices whose event reports no time of its own. It
// defaults to [clock.System].
Clock clock.Clock
// Timeout bounds one notification. It defaults to [DefaultTimeout].
Timeout time.Duration
}
// Notifier mails users about what happened to their account. Every notice
// is opt-in: the account's [user.Alerts] decides whether one is sent at
// all, so a user hears only about what they asked to hear about.
//
// Handlers run on the bus's dispatch goroutine, detached from the request
// that produced the event.
type Notifier struct {
// ctx bounds a notification's own work; cancelling it at shutdown
// aborts a lookup or a dispatch instead of letting it outlive the
// service.
ctx context.Context
post *post.Mailer
users Users
teams Teams
logger *log.Logger
reg *metrics.Registry
clock clock.Clock
timeout time.Duration
}
// New builds a notifier. It sends nothing until [Notifier.Attach] puts it
// behind a broker's topics, and panics without a mailer or a directory,
// since those are startup configuration errors.
//
// The ctx spans the service lifetime, not one notification: it bounds the
// lookups and the dispatch, so a shutdown cancels a notice in flight rather
// than letting it outlive the service. Each notification derives a child
// bounded by [Config.Timeout] on top of it.
func New(ctx context.Context, cfg Config) *Notifier {
switch {
case cfg.Post == nil:
panic("mailer is required")
case cfg.Users == nil:
panic("user directory is required")
}
n := &Notifier{
ctx: ctx,
post: cfg.Post,
users: cfg.Users,
teams: cfg.Teams,
logger: cfg.Logger,
reg: cfg.Registry,
clock: cfg.Clock,
timeout: cfg.Timeout,
}
if n.logger == nil {
n.logger = log.Discard()
}
if n.reg == nil {
n.reg = metrics.DefaultRegistry
}
if n.clock == nil {
n.clock = clock.System
}
if n.timeout <= 0 {
n.timeout = DefaultTimeout
}
return n
}
// Attach subscribes the notifier to the topics it notifies on: logins,
// directory events, and team memberships. Events published before this
// returns reach no handler.
func (n *Notifier) Attach(b *event.Broker) {
topic.Logins(b).Subscribe(n.onLogin)
topic.Directory(b).Subscribe(n.onDirectory)
topic.Teams(b).Subscribe(n.onTeam)
}
// recipient resolves the account to notify, reporting whether it may be
// mailed at all: an account that does not resolve, or whose address was
// never proven, is not written to. The want callback reads the one setting
// the notice hangs off, so an opted-out user costs a lookup and nothing
// more.
//
// It returns a false ok for every skip, since none of them is an error: the
// event that triggered the notice has already happened either way.
func (n *Notifier) recipient(
ctx context.Context,
id uuid.UUID,
want func(user.Alerts) bool,
) (post.Recipient, bool) {
u, err := n.users.Get(ctx, id)
if err != nil {
n.logger.Error(
ctx,
"Failed to resolve the account to notify",
log.UUID("user_id", id),
log.Error(err),
)
return post.Recipient{}, false
}
if u == nil || !u.EmailVerified || !want(u.Alerts) {
return post.Recipient{}, false
}
return post.Recipient{
Addr: u.Email,
Name: u.Name,
DisplayName: u.DisplayName,
Locales: u.Locales,
}, true
}
// dispatch records the outcome of one notice. A disabled occasion is not a
// failure: a deployment rolls its templates out one at a time, and the
// events keep flowing meanwhile.
func (n *Notifier) dispatch(kind string, err error) {
switch {
case err == nil:
n.reg.Counter(
"iam_alerts_sent_total",
metrics.T("kind", kind),
).Inc()
case errors.Is(err, post.ErrDisabled):
n.reg.Counter(
"iam_alerts_skipped_total",
metrics.T("kind", kind),
).Inc()
default:
n.reg.Counter(
"iam_alerts_failed_total",
metrics.T("kind", kind),
).Inc()
n.logger.Error(
n.ctx,
"Failed to dispatch an alert",
log.String("kind", kind),
log.Error(err),
)
}
}
// onLogin notifies the user of a sign-in from a device the account has not
// been used on before. A login on a trusted device says nothing they do not
// already know, so only an untrusted one is worth a mail.
func (n *Notifier) onLogin(e login.Event) {
if e.Kind != login.EventLogin || e.Device.Trusted {
return
}
ctx, cancel := n.bounded()
defer cancel()
to, ok := n.recipient(ctx, e.UserID, func(a user.Alerts) bool {
return a.Login
})
if !ok {
return
}
n.dispatch("login", n.post.LoginAlert(
ctx,
to,
post.LoginInfo{Device: e.Label, Addr: e.Addr, At: e.At},
))
}
// onDirectory notifies the user that their password was replaced, whoever
// replaced it. A change the account holder did not make is the one they most
// need to hear about.
func (n *Notifier) onDirectory(e user.Event) {
if e.Kind != user.EventPasswordChanged {
return
}
ctx, cancel := n.bounded()
defer cancel()
to, ok := n.recipient(ctx, e.UserID, func(a user.Alerts) bool {
return a.PasswordChange
})
if !ok {
return
}
n.dispatch("password_change", n.post.PasswordChanged(ctx, to, e.At))
}
// onTeam notifies the member that they entered or left a team. Team-level
// events name no member and notify nobody.
func (n *Notifier) onTeam(e team.Event) {
var want func(user.Alerts) bool
switch e.Kind {
case team.EventJoined:
want = func(a user.Alerts) bool {
return a.TeamJoin
}
case team.EventLeft:
want = func(a user.Alerts) bool {
return a.TeamLeave
}
default:
return
}
if n.teams == nil || e.UserID == (uuid.UUID{}) {
return
}
ctx, cancel := n.bounded()
defer cancel()
to, ok := n.recipient(ctx, e.UserID, want)
if !ok {
return
}
// The event names the team by ID alone; the notice needs the label the
// user would recognize.
t, err := n.teams.GetTeam(ctx, e.TeamID)
if err != nil {
n.logger.Error(
ctx,
"Failed to resolve the team to name in an alert",
log.UUID("team_id", e.TeamID),
log.Error(err),
)
return
}
if t == nil {
return
}
at := n.clock.Now()
if e.Kind == team.EventJoined {
n.dispatch(
"team_join",
n.post.TeamJoined(ctx, to, t.Name, at),
)
} else {
n.dispatch(
"team_leave",
n.post.TeamLeft(ctx, to, t.Name, at),
)
}
}
// bounded derives the context one notification runs under.
func (n *Notifier) bounded() (context.Context, context.CancelFunc) {
return context.WithTimeout(n.ctx, n.timeout)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package artifact
import (
"context"
"maps"
"sync"
)
// Store persists ephemeral artifacts of type V keyed by K, the digest of a
// bearer secret. See the package documentation for the storage contract
// every implementation must honor.
//
// Implementations are expected to be safe for concurrent use and to honor
// the provided context.
type Store[K ~string, V any] interface {
// Create persists a new record.
Create(ctx context.Context, v V) error
// Get returns the record with the given key. found is false when no such
// record exists (including after expiry-driven cleanup); the returned
// error is reserved for storage failures.
Get(ctx context.Context, id K) (v V, found bool, err error)
// Update persists changes to an existing record, keyed by its key.
Update(ctx context.Context, v V) error
// Delete removes the record with the given key, reporting whether it
// existed and was removed by this call. The removal and the report must
// be atomic; see the package documentation.
Delete(ctx context.Context, id K) (deleted bool, err error)
}
// Map is a mutex-guarded in-memory [Store] keyed by a caller-provided key
// extractor.
//
// It is unbounded — nothing evicts expired records — and therefore meant for
// tests and local development, not production deployments. The exported
// [Map.Err] fault knob makes every method fail, so storage-error paths can
// be exercised without a bespoke fake.
type Map[K ~string, V any] struct {
// Err, when non-nil, is returned by every method. Set it before use;
// mutating it concurrently with store calls is not synchronized.
Err error
mu sync.Mutex
key func(V) K
items map[K]V
}
// NewMap creates an empty [Map] whose records are keyed by the given
// extractor. It panics if key is nil, since that is a static configuration
// error.
func NewMap[K ~string, V any](key func(V) K) *Map[K, V] {
if key == nil {
panic("key extractor is required")
}
return &Map[K, V]{key: key, items: make(map[K]V)}
}
// Create implements [Store].
func (m *Map[K, V]) Create(_ context.Context, v V) error {
if m.Err != nil {
return m.Err
}
m.mu.Lock()
defer m.mu.Unlock()
m.items[m.key(v)] = v
return nil
}
// Get implements [Store].
func (m *Map[K, V]) Get(_ context.Context, id K) (v V, found bool, err error) {
if m.Err != nil {
return v, false, m.Err
}
m.mu.Lock()
defer m.mu.Unlock()
v, found = m.items[id]
return v, found, nil
}
// Update implements [Store].
func (m *Map[K, V]) Update(_ context.Context, v V) error {
if m.Err != nil {
return m.Err
}
m.mu.Lock()
defer m.mu.Unlock()
m.items[m.key(v)] = v
return nil
}
// Delete implements [Store].
func (m *Map[K, V]) Delete(_ context.Context, id K) (bool, error) {
if m.Err != nil {
return false, m.Err
}
m.mu.Lock()
defer m.mu.Unlock()
_, ok := m.items[id]
delete(m.items, id)
return ok, nil
}
// Len reports the number of stored records.
func (m *Map[K, V]) Len() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.items)
}
// Range calls f for every stored record until f returns false. It iterates
// over a snapshot, so f may call back into the Map.
func (m *Map[K, V]) Range(f func(id K, v V) bool) {
m.mu.Lock()
snapshot := make(map[K]V, len(m.items))
maps.Copy(snapshot, m.items)
m.mu.Unlock()
for k, v := range snapshot {
if !f(k, v) {
return
}
}
}
// OwnedMap extends [Map] with the owner-scoped queries of the stores whose
// records belong to a principal — sessions, device trust, action tickets.
// The owner type stays a parameter so that this package keeps its promise
// of knowing nothing about the domain it stores.
//
// It carries the same caveat as [Map]: nothing evicts expired records, so
// it serves tests and local development, not production.
type OwnedMap[K ~string, V any, O comparable] struct {
*Map[K, V]
owner func(V) O
}
// NewOwnedMap creates an empty [OwnedMap] whose records are keyed by key
// and attributed by owner. It panics if either extractor is nil, since
// that is a static configuration error.
func NewOwnedMap[K ~string, V any, O comparable](
key func(V) K,
owner func(V) O,
) *OwnedMap[K, V, O] {
if owner == nil {
panic("owner extractor is required")
}
return &OwnedMap[K, V, O]{Map: NewMap(key), owner: owner}
}
// ListForOwner returns every record held by the owner, in no particular
// order. An owner holding nothing yields a nil slice.
func (m *OwnedMap[K, V, O]) ListForOwner(
_ context.Context,
owner O,
) ([]V, error) {
if m.Err != nil {
return nil, m.Err
}
var records []V
m.Range(func(_ K, v V) bool {
if m.owner(v) == owner {
records = append(records, v)
}
return true
})
return records, nil
}
// DeleteForOwner removes every record held by the owner. It is a no-op
// when the owner holds nothing.
func (m *OwnedMap[K, V, O]) DeleteForOwner(
ctx context.Context,
owner O,
) error {
if m.Err != nil {
return m.Err
}
records, err := m.ListForOwner(ctx, owner)
if err != nil {
return err
}
for _, v := range records {
if _, err := m.Delete(ctx, m.key(v)); err != nil {
return err
}
}
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package artifact
import (
"context"
"github.com/deep-rent/nexus/sec/digest"
"github.com/deep-rent/nexus/sec/nonce"
)
// Digester mints bearer secrets and derives the storage keys they are
// persisted under, enforcing the package's central invariant in one place:
// plaintext secrets never cross a [Store] boundary, only their digests do.
//
// The zero value is ready to use with the default hasher and entropy
// source; engines expose their [digest.Hasher] and [nonce.Generator]
// options by assigning the fields. The value is cheap to copy and safe for
// concurrent use.
type Digester struct {
// Hasher fingerprints secrets into storage keys. A nil hasher falls
// back to [digest.DefaultHasher].
Hasher *digest.Hasher
// Source draws fresh secrets. A nil source falls back to
// [nonce.DefaultGenerator] (256-bit secrets).
Source *nonce.Generator
}
// hasher resolves the effective hasher.
func (d Digester) hasher() *digest.Hasher {
if d.Hasher != nil {
return d.Hasher
}
return digest.DefaultHasher
}
// source resolves the effective source.
func (d Digester) source() *nonce.Generator {
if d.Source != nil {
return d.Source
}
return nonce.DefaultGenerator
}
// Draw draws a fresh bearer secret without deriving its storage key, for
// engines that derive keys later or from composed values.
func (d Digester) Draw(ctx context.Context) (string, error) {
return d.source().Draw(ctx)
}
// Mint draws a fresh bearer secret and returns it together with the
// storage key of the record it authorizes. The secret goes to the client,
// the key into the store; the two never trade places.
func (d Digester) Mint(ctx context.Context) (secret, key string, err error) {
secret, err = d.Draw(ctx)
if err != nil {
return "", "", err
}
return secret, d.Key(secret), nil
}
// Key derives the storage key of the record a presented secret refers to.
func (d Digester) Key(secret string) string {
return d.hasher().String(secret)
}
// Match reports whether the presented secret digests to the given storage
// key, comparing in constant time.
func (d Digester) Match(secret, key string) bool {
return d.hasher().Match(secret, key)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package audit
import (
"context"
"strconv"
"github.com/deep-rent/nexus/eco/iam/authn"
"github.com/deep-rent/nexus/eco/iam/avatar"
"github.com/deep-rent/nexus/eco/iam/idp"
"github.com/deep-rent/nexus/eco/iam/login"
"github.com/deep-rent/nexus/eco/iam/oauth"
"github.com/deep-rent/nexus/eco/iam/passkey"
"github.com/deep-rent/nexus/eco/iam/team"
"github.com/deep-rent/nexus/eco/iam/topic"
"github.com/deep-rent/nexus/eco/iam/user"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/sys/event"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/metrics"
)
// Config configures an [Auditor].
type Config struct {
// Logger is the logger the audit trail is written to. Required. The
// caller names it: every component of the service is named where it
// is assembled, so that the naming scheme reads in one place rather
// than being scattered across the modules that happen to log.
Logger *log.Logger
// DisableTrail silences the audit trail, leaving the counters and the
// error reporting in place. It is for a deployment that collects the
// trail elsewhere — from the events themselves, or from a database
// audit log — and would otherwise pay to format every event twice.
DisableTrail bool
// Registry receives the event counters. It defaults to
// [metrics.DefaultRegistry].
Registry *metrics.Registry
// Clock stamps the events whose module reports no time of its own. It
// defaults to [clock.System].
Clock clock.Clock
}
// Auditor consumes the IAM modules' lifecycle events, recording each in the
// audit trail and the metrics registry.
//
// Handlers run on the bus's dispatch goroutine, detached from the request
// that produced the event.
type Auditor struct {
// ctx is what the trail is written under, the handlers running detached
// from the request that produced their event.
ctx context.Context
// trail is nil where [Config.DisableTrail] silenced it, which is what
// the record helper checks before assembling any argument.
trail *log.Logger
reg *metrics.Registry
clock clock.Clock
}
// New builds an auditor. It records nothing until [Auditor.Attach] puts it
// behind a broker's topics.
//
// The ctx spans the service lifetime and is what the trail is written under,
// since a handler runs detached from the request that produced its event.
// The auditor performs no I/O of its own; mailing the account holder is
// [alert]'s job.
//
// [alert]: github.com/deep-rent/nexus/eco/iam/alert
func New(ctx context.Context, cfg Config) *Auditor {
aud := &Auditor{
ctx: ctx,
reg: cfg.Registry,
clock: cfg.Clock,
}
if aud.reg == nil {
aud.reg = metrics.DefaultRegistry
}
if aud.clock == nil {
aud.clock = clock.System
}
if !cfg.DisableTrail {
aud.trail = cfg.Logger
}
return aud
}
// Attach subscribes the auditor to every topic in [topic]. Events published
// before this returns reach no handler.
func (a *Auditor) Attach(b *event.Broker) {
topic.Logins(b).Subscribe(a.onLogin)
topic.Tokens(b).Subscribe(a.onToken)
topic.Passkeys(b).Subscribe(a.onPasskey)
topic.Federations(b).Subscribe(a.onFederation)
topic.Teams(b).Subscribe(a.onTeam)
topic.Directory(b).Subscribe(a.onDirectory)
topic.Accounts(b).Subscribe(a.onAccount)
topic.Avatars(b).Subscribe(a.onAvatar)
}
// onLogin records an event from the login core. Beyond the trail and the
// counters, a login on a device the user has not been seen on before earns
// a "new login on your account" mail.
func (a *Auditor) onLogin(e login.Event) {
if a.trail != nil {
a.trail.Info(a.ctx, "Login event", a.loginArgs(e)...)
}
switch e.Kind {
case login.EventLogin:
a.reg.Counter(
"iam_logins_total",
metrics.T("trusted", strconv.FormatBool(e.Device.Trusted)),
).Inc()
case login.EventLogout:
a.reg.Counter(
"iam_logouts_total",
).Inc()
case login.EventLoginFailed:
a.reg.Counter(
"iam_login_failures_total",
metrics.T("method", "password"),
).Inc()
case login.EventFlowStepRejected:
a.reg.Counter(
"iam_flow_failures_total",
metrics.T("reason", "wrong_input"),
).Inc()
case login.EventFlowFailed:
a.reg.Counter(
"iam_flow_failures_total",
metrics.T("reason", e.Reason.String()),
).Inc()
}
}
// loginArgs describes a login event for the trail, reporting the fields the
// kind actually carries rather than a union of all of them.
func (*Auditor) loginArgs(e login.Event) []log.Arg {
args := []log.Arg{
log.String("kind", string(e.Kind)),
log.UUID("user_id", e.UserID),
log.String("agent", e.Label),
log.String("addr", e.Addr),
log.Time("at", e.At),
}
switch e.Kind {
case login.EventLoginFailed:
args = append(args, log.String("username", e.Username))
case login.EventFlowFailed:
args = append(args, log.String("reason", e.Reason.String()))
default:
args = append(args,
log.Bool("trusted", e.Device.Trusted),
log.Bool("remember", e.Remember),
)
}
return args
}
// onToken records an OAuth token lifecycle event. The module reports no time
// of its own, so the auditor stamps it on arrival.
func (a *Auditor) onToken(e oauth.Event) {
if a.trail != nil {
a.trail.Info(a.ctx, "Token event",
log.String("kind", string(e.Kind)),
log.UUID("user_id", e.UserID),
log.UUID("client_id", e.ClientID),
log.String("grant", string(e.Grant)),
log.String("scope", e.Scope),
log.Time("at", a.clock.Now()),
)
}
switch e.Kind {
case oauth.EventTokenIssued:
a.reg.Counter(
"iam_tokens_issued_total",
metrics.T("grant", string(e.Grant)),
).Inc()
case oauth.EventTokenRevoked:
a.reg.Counter("iam_tokens_revoked_total").Inc()
}
}
// onPasskey records a WebAuthn ceremony event. A successful passkey login
// surfaces on the login core instead, so only a refused ceremony counts as
// a failure here.
func (a *Auditor) onPasskey(e passkey.Event) {
if a.trail != nil {
a.trail.Info(a.ctx, "Passkey event",
log.String("kind", string(e.Kind)),
log.UUID("user_id", e.UserID),
log.String("agent", e.Label),
log.String("addr", e.Addr),
log.Time("at", e.At),
)
}
if e.Kind == passkey.EventAssertionFailed {
a.reg.Counter(
"iam_login_failures_total",
metrics.T("method", "passkey"),
).Inc()
}
}
// onFederation records an external identity provider event. Both kinds are
// refusals — a successful social login surfaces on the login core.
func (a *Auditor) onFederation(e idp.Event) {
if a.trail != nil {
a.trail.Info(a.ctx, "Federation event",
log.String("kind", string(e.Kind)),
log.String("provider", e.Provider),
log.String("subject", e.Subject),
log.String("addr", e.Addr),
log.Time("at", e.At),
)
}
a.reg.Counter(
"iam_login_failures_total",
metrics.T("method", "idp"),
).Inc()
}
// onTeam records a team lifecycle event. The module reports no time of its
// own, so the auditor stamps it on arrival.
func (a *Auditor) onTeam(e team.Event) {
if a.trail != nil {
a.trail.Info(a.ctx, "Team event",
log.String("kind", string(e.Kind)),
log.UUID("team_id", e.TeamID),
log.UUID("user_id", e.UserID),
log.UUID("actor", e.Actor),
log.Time("at", a.clock.Now()),
)
}
a.reg.Counter(
"iam_team_events_total",
metrics.T("kind", string(e.Kind)),
).Inc()
}
// onDirectory records a user directory event. A changed password is the one
// kind so far, and the counter separates the paths a deployment watches for
// a spike: an administrative reset campaign looks nothing like users
// rotating their own.
func (a *Auditor) onDirectory(e user.Event) {
if a.trail != nil {
a.trail.Info(a.ctx, "Directory event",
log.String("kind", string(e.Kind)),
log.UUID("user_id", e.UserID),
log.Time("at", e.At),
)
}
a.reg.Counter(
"iam_directory_events_total",
metrics.T("kind", string(e.Kind)),
).Inc()
}
// onAccount records a change to the second factors a user carries.
// Enrolling one, removing one, and spending a recovery code are the steps
// of an account takeover as much as of ordinary use, so the trail carries
// them whether or not anyone was mailed about it.
func (a *Auditor) onAccount(e authn.Event) {
if a.trail != nil {
args := []log.Arg{
log.String("kind", string(e.Kind)),
log.UUID("user_id", e.UserID),
log.Time("at", e.At),
}
// How many ways back in remain is what turns a redemption from a
// data point into a warning.
if e.Kind == authn.EventCodeRedeemed {
args = append(args, log.Int("remaining", e.Remaining))
}
a.trail.Info(a.ctx, "Account factor event", args...)
}
a.reg.Counter(
"iam_account_factor_events_total",
metrics.T("kind", string(e.Kind)),
).Inc()
}
// onAvatar records picture lifecycle events: user avatars and team
// logos. They matter less than credentials, but a changed picture is a
// visible account change worth a trail entry.
func (a *Auditor) onAvatar(e avatar.Event) {
if a.trail != nil {
a.trail.Info(a.ctx, "Picture event",
log.String("kind", string(e.Kind)),
log.String("scope", string(e.Scope)),
log.UUID("owner_id", e.OwnerID),
log.Time("at", e.At),
)
}
a.reg.Counter(
"iam_avatar_events_total",
metrics.T("kind", string(e.Kind)),
metrics.T("scope", string(e.Scope)),
).Inc()
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
// Package authn holds the second factors a user carries rather than
// receives: an authenticator app's time-based codes, and the recovery
// codes that are the way back in when the authenticator is gone.
//
// m := authn.New(authn.Config{
// Store: store.Enrollments(),
// Codes: store.RecoveryCodes(),
// Keyring: ring,
// Issuer: "Example",
// })
// uri, err := m.Begin(ctx, userID, "alice@example.com")
// // ... the user scans uri and reads back a code ...
// ok, err := m.Confirm(ctx, userID, code)
//
// It is the sibling of [otp], which covers the factors the service
// delivers — mail, text, push. The split is deliberate: those have a
// channel, a template, a resend; these have neither, and folding them
// into one abstraction would leave every delivery concern nil-able.
//
// # Why the two live together
//
// An authenticator lives on exactly one device, so time-based codes
// without a recovery path turn a lost phone into a lost account.
// Recovery codes are what make an authenticator safe to rely on, and
// they are useless on their own — so the manager owns both.
//
// # Secrets at rest
//
// A time-based secret cannot be stored as a digest: deriving the
// expected code needs the key itself. It is therefore sealed with [seal]
// and bound to its owner, so a database dump does not hand over every
// user's second factor and a sealed secret cannot be moved between
// accounts. Recovery codes have no such constraint and are stored as
// digests, like every other bearer artifact in the service.
//
// [otp]: github.com/deep-rent/nexus/eco/iam/otp
package authn
import (
"context"
"time"
"uuid"
"github.com/deep-rent/nexus/eco/iam/artifact"
"github.com/deep-rent/nexus/sec/totp"
)
// Enrollment is a user's authenticator: the sealed shared secret, the
// parameters it was minted under, and the replay watermark.
//
// A user holds at most one. Supporting several is a real feature but a
// separable one, and one keeps "does this account still have a second
// factor?" an unambiguous question.
type Enrollment struct {
// UserID is the account the authenticator belongs to, and the
// storage key.
UserID uuid.UUID `json:"user_id"`
// Secret is the sealed shared secret. It is never the plaintext: the
// manager seals on the way in and opens on the way out, bound to
// UserID.
Secret []byte `json:"-"`
// Algorithm, Digits, and Period are the authenticator parameters the
// enrollment was minted under. They travel to the authenticator in
// the otpauth URI and must not change afterwards, since the app has
// no way to learn of it.
Algorithm string `json:"algorithm"`
Digits int `json:"digits"`
Period int `json:"period"`
// LastCounter is the highest time step already accepted. A code
// stands for its whole period and the skew widens that further, so
// refusing anything at or below this watermark is what makes a code
// single-use. Zero means none has been accepted yet.
LastCounter int64 `json:"last_counter,omitzero"`
// ConfirmedAt is when the user proved they could produce a code. The
// zero time marks an enrollment begun but never proven, which must
// never gate a login: a user who mis-scans the QR would otherwise
// lock themselves out.
ConfirmedAt time.Time `json:"confirmed_at,omitzero"`
// CreatedAt is when the enrollment was begun.
CreatedAt time.Time `json:"created_at,omitzero"`
}
// Confirmed reports whether the user proved the enrollment.
func (e *Enrollment) Confirmed() bool { return !e.ConfirmedAt.IsZero() }
// Params returns the authenticator parameters of the enrollment.
func (e *Enrollment) Params() totp.Params {
return totp.Params{
Algorithm: totp.Algorithm(e.Algorithm),
Digits: e.Digits,
Period: time.Duration(e.Period) * time.Second,
}
}
// Store persists authenticator enrollments, one per user.
//
// Lookups return nil and a nil error when no enrollment exists; errors
// are reserved for storage failures. Implementations must be safe for
// concurrent use.
type Store interface {
// Get retrieves the user's enrollment, or nil when there is none.
Get(ctx context.Context, userID uuid.UUID) (*Enrollment, error)
// Put stores the enrollment, replacing any the user already has. It
// is an upsert because a user holds at most one: re-enrolling
// abandons the previous authenticator by definition.
Put(ctx context.Context, e *Enrollment) error
// Delete removes the user's enrollment, reporting whether this call
// removed it.
Delete(ctx context.Context, userID uuid.UUID) (deleted bool, err error)
}
// Code is one unredeemed recovery code, stored as a digest like every
// other bearer artifact.
type Code struct {
// ID is the digest of the code and the storage key. The plaintext
// reaches the user once, at generation, and never the store.
ID string `json:"id"`
// Owner identifies the account the code admits.
Owner uuid.UUID `json:"owner"`
// CreatedAt is when the code was generated.
CreatedAt time.Time `json:"created_at,omitzero"`
}
// Codes persists recovery codes keyed by [Code.ID].
//
// Redemption rides on the atomic deletion of [artifact.Store]: of two
// concurrent redemptions of one code exactly one may succeed. A spent
// code is deleted rather than marked, since — unlike a rotated refresh
// token — it carries no evidence worth keeping.
type Codes interface {
artifact.Store[string, Code]
// ListForOwner returns the user's unredeemed codes. It backs the
// remaining count shown after a redemption.
ListForOwner(ctx context.Context, owner uuid.UUID) ([]Code, error)
// DeleteForOwner removes every code the user holds, which is how a
// regenerated set replaces its predecessor.
DeleteForOwner(ctx context.Context, owner uuid.UUID) error
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package authn
import (
"context"
"errors"
"fmt"
"strings"
"time"
"uuid"
"github.com/deep-rent/nexus/eco/iam/artifact"
"github.com/deep-rent/nexus/sec/nonce"
"github.com/deep-rent/nexus/sec/seal"
"github.com/deep-rent/nexus/sec/totp"
"github.com/deep-rent/nexus/std/clock"
)
// Defaults applied by [New] where no option overrides them.
const (
// DefaultRecoveryCodes is how many codes a generated set holds. Ten
// is enough to survive a stretch of lockouts without becoming a list
// nobody stores carefully.
DefaultRecoveryCodes = 10
// DefaultSkew is how many periods either side of the current one a
// code is accepted in; see [totp.DefaultSkew].
DefaultSkew = totp.DefaultSkew
// LowCodeThreshold is the remaining count below which a caller
// should prompt the user to regenerate. Running out silently is how
// a lost authenticator becomes a lost account.
LowCodeThreshold = 3
)
// Recovery code shape. The alphabet drops the characters that are read
// wrong off a screen — the same reasoning behind [usercode.Alphabet],
// which solved this for device codes.
//
// [usercode.Alphabet]:
// github.com/deep-rent/nexus/eco/iam/oauth/usercode#Alphabet
const (
// Alphabet is the character set recovery codes are sampled from.
Alphabet = "BCDFGHJKLMNPQRSTVWXZ23456789"
// CodeLength is how many characters one code carries, excluding the
// separator. Ten characters over this alphabet is about 48 bits,
// which is far beyond guessable under any attempt budget.
CodeLength = 10
// CodeGroup is how many characters appear between separators.
CodeGroup = 5
// Separator divides a rendered code into groups.
Separator = "-"
)
// Domain failures. Storage errors travel as ordinary errors alongside.
var (
// ErrEnrolled refuses beginning an enrollment for a user who already
// has a confirmed authenticator. Replacing one is a deliberate act:
// remove the old first, so the user cannot be left believing a
// half-scanned code still works.
ErrEnrolled = errors.New("an authenticator is already enrolled")
// ErrNotEnrolled refuses an operation on a user without one.
ErrNotEnrolled = errors.New("no authenticator is enrolled")
)
// Manager runs the lifecycle of authenticator enrollments and recovery
// codes. It is safe for concurrent use if its stores are.
type Manager struct {
store Store
codes Codes
ring *seal.Keyring
secrets artifact.Digester
sampler *nonce.Sampler
now clock.Clock
params totp.Params
skew int
issuer string
count int
observer Observer
}
// Config bundles the required collaborators of a [Manager].
type Config struct {
// Store persists enrollments. Required.
Store Store
// Codes persists recovery codes. Required.
Codes Codes
// Keyring seals shared secrets at rest. Required: a time-based
// secret cannot be hashed, so there is no safe default.
Keyring *seal.Keyring
// Issuer names the deployment in the authenticator's list, typically
// the product or host name. Required.
Issuer string
}
// New creates a [Manager]. It panics if a required collaborator is
// missing, since that is a startup configuration error.
func New(cfg Config, opts ...Option) *Manager {
switch {
case cfg.Store == nil:
panic("enrollment store is required")
case cfg.Codes == nil:
panic("recovery code store is required")
case cfg.Keyring == nil:
panic("keyring is required")
case cfg.Issuer == "":
panic("issuer is required")
}
m := &Manager{
store: cfg.Store,
codes: cfg.Codes,
ring: cfg.Keyring,
secrets: artifact.Digester{},
now: clock.System,
skew: DefaultSkew,
issuer: cfg.Issuer,
count: DefaultRecoveryCodes,
}
for _, opt := range opts {
opt(m)
}
if m.sampler == nil {
m.sampler = nonce.NewSampler(nil, Alphabet, CodeLength)
}
return m
}
// publish hands an event to the observer, if any.
func (m *Manager) publish(e Event) {
if m.observer == nil {
return
}
if e.At.IsZero() {
e.At = m.now()
}
m.observer(e)
}
// Enrollment returns the user's enrollment, or nil when there is none.
// The returned secret stays sealed; only the manager opens it.
func (m *Manager) Enrollment(
ctx context.Context,
userID uuid.UUID,
) (*Enrollment, error) {
return m.store.Get(ctx, userID)
}
// Enrolled reports whether the user holds a *confirmed* authenticator.
// An enrollment begun but never proven does not count, so a mis-scanned
// QR code cannot lock anyone out.
func (m *Manager) Enrolled(
ctx context.Context,
userID uuid.UUID,
) (bool, error) {
e, err := m.store.Get(ctx, userID)
if err != nil || e == nil {
return false, err
}
return e.Confirmed(), nil
}
// Begin mints a fresh authenticator for the user and returns the otpauth
// URI to render as a QR code. The enrollment is unconfirmed until
// [Manager.Confirm] proves the user can produce a code from it.
//
// The given account label — an email address or username — identifies the
// user inside the authenticator's list. A user who already holds a
// confirmed authenticator is refused with [ErrEnrolled]; an unconfirmed
// one is simply replaced, since it never gated anything.
//
// The returned URI carries the shared secret in the clear. It is meant
// for exactly one screen, once, over an already-authenticated channel.
func (m *Manager) Begin(
ctx context.Context,
userID uuid.UUID,
account string,
) (uri string, err error) {
existing, err := m.store.Get(ctx, userID)
if err != nil {
return "", err
}
if existing != nil && existing.Confirmed() {
return "", ErrEnrolled
}
secret, err := totp.Generate(nil, m.params)
if err != nil {
return "", err
}
sealed, err := m.ring.Seal(secret, userID[:])
if err != nil {
return "", fmt.Errorf("failed to seal the shared secret: %w", err)
}
p := m.resolved()
if err := m.store.Put(ctx, &Enrollment{
UserID: userID,
Secret: sealed,
Algorithm: string(p.Algorithm),
Digits: p.Digits,
Period: int(p.Period / time.Second),
CreatedAt: m.now(),
}); err != nil {
return "", err
}
return secret.URI(m.issuer, account, p), nil
}
// Confirm proves an enrollment: a code the user reads off their
// authenticator completes it, and only then does the factor count.
//
// ok is false for a wrong code, leaving the pending enrollment in place
// so the user may simply try again. Confirming an already confirmed
// enrollment reports [ErrEnrolled] rather than silently re-confirming.
func (m *Manager) Confirm(
ctx context.Context,
userID uuid.UUID,
code string,
) (ok bool, err error) {
e, err := m.store.Get(ctx, userID)
if err != nil {
return false, err
}
if e == nil {
return false, ErrNotEnrolled
}
if e.Confirmed() {
return false, ErrEnrolled
}
counter, ok, err := m.check(e, code)
if err != nil || !ok {
return false, err
}
e.ConfirmedAt = m.now()
e.LastCounter = counter
if err := m.store.Put(ctx, e); err != nil {
return false, err
}
m.publish(Event{Kind: EventEnrolled, UserID: userID})
return true, nil
}
// Verify checks a code from the user's authenticator during a login.
//
// It refuses an unconfirmed enrollment, a wrong code, and — the part
// verification alone would miss — a code already accepted: a code stands
// for its whole period, so without the watermark one read over a
// shoulder would work twice. The error is reserved for storage and
// sealing failures.
func (m *Manager) Verify(
ctx context.Context,
userID uuid.UUID,
code string,
) (ok bool, err error) {
e, err := m.store.Get(ctx, userID)
if err != nil {
return false, err
}
if e == nil || !e.Confirmed() {
return false, nil
}
counter, ok, err := m.check(e, code)
if err != nil || !ok {
return false, err
}
// A code at or below the watermark has been seen. Refusing it is what
// makes it single-use within its own window.
if counter <= e.LastCounter {
return false, nil
}
e.LastCounter = counter
if err := m.store.Put(ctx, e); err != nil {
return false, err
}
return true, nil
}
// check opens the sealed secret and verifies the code against it,
// returning the time step it matched.
func (m *Manager) check(
e *Enrollment,
code string,
) (counter int64, ok bool, err error) {
secret, err := m.ring.Open(e.Secret, e.UserID[:])
if err != nil {
// The secret cannot be read: the keyring lost the key, or the row
// was moved between accounts. Either is a fault to surface, not a
// failed guess to report as a wrong code.
return 0, false, fmt.Errorf(
"failed to open the shared secret: %w", err,
)
}
counter, ok = totp.Secret(secret).Verify(
code, m.now(), e.Params(), m.skew,
)
return counter, ok, nil
}
// Disable removes the user's authenticator, reporting whether this call
// removed one. Recovery codes are left alone: they are the way back in
// for every factor, not only this one.
func (m *Manager) Disable(
ctx context.Context,
userID uuid.UUID,
) (bool, error) {
deleted, err := m.store.Delete(ctx, userID)
if err != nil || !deleted {
return deleted, err
}
m.publish(Event{Kind: EventDisabled, UserID: userID})
return true, nil
}
// GenerateCodes replaces the user's recovery codes with a fresh set and
// returns them in the clear — the only time they exist outside a digest.
// The caller shows them once and does not store them.
//
// Replacing rather than appending is the documented answer to "I lost my
// codes": whatever was written down stops working.
func (m *Manager) GenerateCodes(
ctx context.Context,
userID uuid.UUID,
) ([]string, error) {
if err := m.codes.DeleteForOwner(ctx, userID); err != nil {
return nil, err
}
now := m.now()
out := make([]string, 0, m.count)
for range m.count {
raw, err := m.sampler.Draw(ctx)
if err != nil {
return nil, fmt.Errorf("failed to draw a recovery code: %w", err)
}
code := format(raw)
if err := m.codes.Create(ctx, Code{
ID: m.secrets.Key(code),
Owner: userID,
CreatedAt: now,
}); err != nil {
return nil, err
}
out = append(out, code)
}
m.publish(Event{Kind: EventCodesGenerated, UserID: userID})
return out, nil
}
// RedeemCode spends one of the user's recovery codes.
//
// ok is false for an unknown code, one belonging to another account, or
// one a concurrent redemption already spent; the reasons are collapsed so
// a caller cannot probe. remaining reports how many the user has left,
// which the caller surfaces — a user who does not know they are running
// out will run out.
func (m *Manager) RedeemCode(
ctx context.Context,
userID uuid.UUID,
code string,
) (ok bool, remaining int, err error) {
code = Normalize(code)
if code == "" {
return false, 0, nil
}
digest := m.secrets.Key(code)
held, found, err := m.codes.Get(ctx, digest)
if err != nil {
return false, 0, err
}
// The digest alone would admit a code minted for someone else, so the
// owner is checked before it is spent.
if !found || held.Owner != userID {
return false, 0, nil
}
// The atomic delete decides a concurrent redemption: exactly one
// caller sees deleted.
deleted, err := m.codes.Delete(ctx, digest)
if err != nil || !deleted {
return false, 0, err
}
remaining, err = m.RemainingCodes(ctx, userID)
if err != nil {
// The code is spent either way; the count is advisory.
remaining = 0
}
m.publish(Event{
Kind: EventCodeRedeemed,
UserID: userID,
Remaining: remaining,
})
return true, remaining, nil
}
// RemainingCodes reports how many unredeemed recovery codes the user
// holds.
func (m *Manager) RemainingCodes(
ctx context.Context,
userID uuid.UUID,
) (int, error) {
held, err := m.codes.ListForOwner(ctx, userID)
if err != nil {
return 0, err
}
return len(held), nil
}
// RevokeCodes discards every recovery code the user holds, without
// issuing replacements. It belongs alongside the other credential
// revocations on account deletion.
func (m *Manager) RevokeCodes(
ctx context.Context,
userID uuid.UUID,
) error {
return m.codes.DeleteForOwner(ctx, userID)
}
// resolved returns the manager's authenticator parameters with the
// defaults applied, so the enrollment records what it actually used
// rather than the zero value. Validity is settled by then: [Begin] mints
// the secret first, and totp refuses unusable parameters there.
func (m *Manager) resolved() totp.Params {
p := m.params
if p.Algorithm == "" {
p.Algorithm = totp.DefaultAlgorithm
}
if p.Digits == 0 {
p.Digits = totp.DefaultDigits
}
if p.Period <= 0 {
p.Period = totp.DefaultPeriod
}
return p
}
// format groups a raw code for reading, e.g. "BCDFG-HJKLM".
func format(raw string) string {
var b strings.Builder
for i, r := range raw {
if i > 0 && i%CodeGroup == 0 {
b.WriteString(Separator)
}
b.WriteRune(r)
}
return b.String()
}
// Normalize canonicalizes a recovery code as typed: upper case, with
// separators and spaces stripped, then regrouped. A user reading a code
// off paper supplies neither casing nor hyphens reliably.
func Normalize(code string) string {
var b strings.Builder
for _, r := range strings.ToUpper(strings.TrimSpace(code)) {
if strings.ContainsRune(Alphabet, r) {
b.WriteRune(r)
}
}
raw := b.String()
if len(raw) != CodeLength {
return ""
}
return format(raw)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package authn
import (
"github.com/deep-rent/nexus/eco/iam/artifact"
"github.com/deep-rent/nexus/sec/nonce"
"github.com/deep-rent/nexus/sec/totp"
"github.com/deep-rent/nexus/std/clock"
)
// Option customizes a [Manager] during construction with [New].
type Option func(*Manager)
// WithParams sets the authenticator parameters new enrollments are minted
// under. The zero value — HMAC-SHA1, six digits, thirty seconds — is what
// every mainstream authenticator implements; see [totp.Params] before
// deviating.
//
// It affects new enrollments only. Existing ones carry the parameters
// they were minted under, since the authenticator app has no way to learn
// of a change.
func WithParams(p totp.Params) Option {
return func(m *Manager) { m.params = p }
}
// WithSkew sets how many periods either side of the current one a code is
// accepted in, defaulting to [DefaultSkew]. Negative values are treated
// as zero. Widening it multiplies the guessing surface.
func WithSkew(n int) Option {
return func(m *Manager) {
if n < 0 {
n = 0
}
m.skew = n
}
}
// WithRecoveryCodes sets how many codes a generated set holds, defaulting
// to [DefaultRecoveryCodes]. Nonpositive values are ignored.
func WithRecoveryCodes(n int) Option {
return func(m *Manager) {
if n > 0 {
m.count = n
}
}
}
// WithDigester sets how recovery codes are fingerprinted into storage
// keys.
func WithDigester(d artifact.Digester) Option {
return func(m *Manager) { m.secrets = d }
}
// WithSampler sets the source recovery codes are drawn from. A nil
// sampler is ignored.
func WithSampler(s *nonce.Sampler) Option {
return func(m *Manager) {
if s != nil {
m.sampler = s
}
}
}
// WithClock injects the time source, for tests that need a controllable
// clock. A nil clock is ignored.
func WithClock(now clock.Clock) Option {
return func(m *Manager) {
if now != nil {
m.now = now
}
}
}
// WithObserver registers the observer lifecycle events are delivered to.
// A nil observer is ignored.
func WithObserver(o Observer) Option {
return func(m *Manager) {
if o != nil {
m.observer = o
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package avatar
import (
"errors"
"net/http"
"github.com/deep-rent/nexus/net/router"
)
// Reasons of the confirmation refusals, shared by every surface exposing
// the lifecycle — a client sees the same vocabulary whether it confirms a
// user avatar or a team logo.
const (
// ReasonUploadNotPending indicates a confirmation with no upload
// grant behind it: none was requested, it lapsed, or it was already
// confirmed.
ReasonUploadNotPending router.Reason = "upload_not_pending"
// ReasonUploadMissing indicates a confirmation whose object never
// arrived at the granted URL.
ReasonUploadMissing router.Reason = "upload_missing"
// ReasonUploadTooLarge indicates an uploaded picture beyond the size
// cap the grant announced. The upload was discarded.
ReasonUploadTooLarge router.Reason = "upload_too_large"
// ReasonUploadBadType indicates an uploaded picture outside the
// content type whitelist the grant announced. The upload was
// discarded.
ReasonUploadBadType router.Reason = "upload_bad_type"
)
// ConfirmError maps a [Manager.Confirm] failure onto the HTTP surface:
// lifecycle refusals become 409s, policy refusals 400s, and anything
// else a plain server error carrying the cause.
func ConfirmError(err error) *router.Error {
switch {
case errors.Is(err, ErrNoPending):
return &router.Error{
Status: http.StatusConflict,
Reason: ReasonUploadNotPending,
Description: "no upload is pending confirmation",
}
case errors.Is(err, ErrNotUploaded):
return &router.Error{
Status: http.StatusConflict,
Reason: ReasonUploadMissing,
Description: "no object arrived at the granted URL",
}
case errors.Is(err, ErrTooLarge):
return &router.Error{
Status: http.StatusBadRequest,
Reason: ReasonUploadTooLarge,
Description: "the uploaded picture is too large",
}
case errors.Is(err, ErrBadType):
return &router.Error{
Status: http.StatusBadRequest,
Reason: ReasonUploadBadType,
Description: "the uploaded picture has a disallowed type",
}
}
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to confirm upload",
Cause: err,
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package avatar
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"uuid"
"github.com/deep-rent/nexus/std/ascii"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/sys/log"
)
// Defaults of the policy knobs left unset in [Config].
const (
// DefaultMaxSize caps an uploaded picture at 2 MiB. Avatars render at
// thumbnail sizes, so anything larger is a mistake or an abuse.
DefaultMaxSize = 2 << 20
// DefaultGrantTTL bounds the upload itself: the presigned PUT lapses
// this long after Begin.
DefaultGrantTTL = 15 * time.Minute
// DefaultConfirmWindow bounds the whole grant lifecycle: an upload
// not confirmed within it becomes sweepable. It exceeds the grant
// TTL, so a client that uploaded at the last moment still has time
// to confirm.
DefaultConfirmWindow = time.Hour
// SweepLimit bounds how many expired grants one sweep pass reaps.
// A pass runs on a schedule, so a backlog larger than this drains
// over successive runs rather than in one giant batch.
SweepLimit = 256
)
// DefaultContentTypes is the content type whitelist applied when [Config]
// names none: the ubiquitous raster formats. SVG is deliberately absent —
// it is scriptable, and a user-supplied document that can run script has
// no business being served as someone's picture.
var DefaultContentTypes = []string{
"image/jpeg",
"image/png",
"image/webp",
}
// Config bundles the construction parameters of a [Manager].
type Config struct {
// Store persists pending upload grants. Required.
Store Store
// Storage is the object store pictures live in. Required.
Storage Storage
// PublicURL is the base URL pictures are served from — typically a
// CDN over the bucket, on a domain of its own so user content never
// shares an origin with the service. Required; the object key
// appends to its path.
PublicURL string
// Users swaps the avatar key on a user record. A scope without a
// binder refuses grants with [ErrUnknownScope].
Users Binder
// Teams swaps the logo key on a team record. A scope without a
// binder refuses grants with [ErrUnknownScope].
Teams Binder
// MaxSize caps an uploaded picture's size in bytes. Defaults to
// [DefaultMaxSize].
MaxSize int64
// ContentTypes whitelists the content types an upload may declare.
// Defaults to [DefaultContentTypes].
ContentTypes []string
// GrantTTL bounds the presigned upload. Defaults to
// [DefaultGrantTTL].
GrantTTL time.Duration
// ConfirmWindow bounds the grant lifecycle; see
// [DefaultConfirmWindow].
ConfirmWindow time.Duration
}
// Grant is one issued upload permission, everything a client needs to
// perform and understand the upload.
type Grant struct {
// URL is the presigned PUT target. It must be used byte-for-byte.
URL string `json:"url"`
// Method is the HTTP method the URL grants, always PUT.
Method string `json:"method"`
// MaxSize is the size cap the confirmation will enforce, in bytes.
MaxSize int64 `json:"max_size"`
// ContentTypes are the content types the confirmation will accept.
ContentTypes []string `json:"content_types"`
// ExpiresAt is when the upload URL lapses.
ExpiresAt time.Time `json:"expires_at"`
}
// Manager runs the picture lifecycle for users and teams alike: it grants
// direct uploads, verifies what actually landed before anything becomes
// visible, keeps the live object immutable, and evicts what no longer
// belongs. It is stateless beyond its collaborators and safe for
// concurrent use.
type Manager struct {
cfg Config
now clock.Clock
logger *log.Logger
observer Observer
}
// New creates a [Manager] from the given configuration. It panics on a
// missing store, storage, or public URL, since those are startup
// configuration errors; the binders stay optional, so a deployment may
// wire one scope without the other.
func New(cfg Config, opts ...Option) *Manager {
switch {
case cfg.Store == nil:
panic("store is required")
case cfg.Storage == nil:
panic("storage is required")
case cfg.PublicURL == "":
panic("public URL is required")
}
if cfg.MaxSize <= 0 {
cfg.MaxSize = DefaultMaxSize
}
if len(cfg.ContentTypes) == 0 {
cfg.ContentTypes = DefaultContentTypes
}
if cfg.GrantTTL <= 0 {
cfg.GrantTTL = DefaultGrantTTL
}
if cfg.ConfirmWindow <= 0 {
cfg.ConfirmWindow = DefaultConfirmWindow
}
m := &Manager{
cfg: cfg,
now: clock.System,
logger: log.Discard(),
}
for _, opt := range opts {
opt(m)
}
return m
}
// binder resolves the scope's record seam.
func (m *Manager) binder(scope Scope) Binder {
switch scope {
case ScopeUser:
return m.cfg.Users
case ScopeTeam:
return m.cfg.Teams
}
return nil
}
// Begin grants the principal a direct upload: it mints a fresh object
// key, presigns a PUT on it, and records the grant as the principal's
// pending upload — replacing any previous one, whose orphaned object is
// evicted best-effort. Authorizing the principal is the caller's job;
// the manager takes the IDs it is given.
//
// The returned [Grant] carries the policy the confirmation will enforce,
// so an honest client can check its file before uploading. Nothing
// becomes visible until [Manager.Confirm] verifies it.
func (m *Manager) Begin(
ctx context.Context,
scope Scope,
ownerID uuid.UUID,
) (Grant, error) {
if m.binder(scope) == nil {
return Grant{}, ErrUnknownScope
}
// The key is fresh per upload, so a live picture is never
// overwritten in place: whatever sits at a published key stays
// byte-for-byte what was verified.
key := fmt.Sprintf(
"avatars/%s/%s/%s", scope, ownerID, uuid.NewV7(),
)
signed, err := m.cfg.Storage.Presign(
http.MethodPut, key, m.cfg.GrantTTL, nil,
)
if err != nil {
return Grant{}, fmt.Errorf("failed to presign upload: %w", err)
}
now := m.now()
prior, err := m.cfg.Store.Upsert(ctx, Pending{
Scope: scope,
OwnerID: ownerID,
Key: key,
ExpiresAt: now.Add(m.cfg.ConfirmWindow),
})
if err != nil {
return Grant{}, err
}
// The displaced grant's object — if its upload ever happened — is
// unreferenced now and would otherwise wait for nothing.
m.evict(ctx, prior)
return Grant{
URL: signed,
Method: http.MethodPut,
MaxSize: m.cfg.MaxSize,
ContentTypes: m.cfg.ContentTypes,
ExpiresAt: now.Add(m.cfg.GrantTTL),
}, nil
}
// Confirm turns the principal's pending upload into their live picture:
// it claims the grant, verifies the uploaded object against the size and
// content type policy, swaps the record's key, and evicts the picture it
// replaced. It returns the public URL the fresh picture is served under.
//
// A confirmation is a claim and the verification is the proof: an
// object that never arrived, breaks policy, or belongs to a vanished
// principal never becomes visible, and in the latter two cases it is
// deleted on the spot. The claim is atomic, so of two racing
// confirmations — or a confirmation racing the sweep — exactly one owns
// the object.
func (m *Manager) Confirm(
ctx context.Context,
scope Scope,
ownerID uuid.UUID,
) (string, error) {
bind := m.binder(scope)
if bind == nil {
return "", ErrUnknownScope
}
pending, err := m.cfg.Store.Claim(ctx, scope, ownerID)
if err != nil {
return "", err
}
if pending == nil {
return "", ErrNoPending
}
obj, err := m.cfg.Storage.Head(ctx, pending.Key)
if err != nil {
return "", fmt.Errorf("failed to verify upload: %w", err)
}
if obj == nil {
return "", ErrNotUploaded
}
if obj.Size <= 0 || obj.Size > m.cfg.MaxSize {
m.evict(ctx, pending.Key)
return "", ErrTooLarge
}
if !m.allowed(obj.ContentType) {
m.evict(ctx, pending.Key)
return "", ErrBadType
}
prior, found, err := bind(ctx, ownerID, pending.Key)
if err != nil {
return "", err
}
if !found {
m.evict(ctx, pending.Key)
return "", ErrUnknownOwner
}
m.evict(ctx, prior)
m.publish(Event{
Kind: EventChanged,
Scope: scope,
OwnerID: ownerID,
Key: pending.Key,
At: m.now(),
})
return m.URL(pending.Key), nil
}
// Remove takes the principal's picture out of service and evicts its
// object. Removing a picture that does not exist is a no-op, so the
// operation is idempotent.
func (m *Manager) Remove(
ctx context.Context,
scope Scope,
ownerID uuid.UUID,
) error {
bind := m.binder(scope)
if bind == nil {
return ErrUnknownScope
}
prior, found, err := bind(ctx, ownerID, "")
if err != nil {
return err
}
if !found {
return ErrUnknownOwner
}
if prior == "" {
return nil
}
m.evict(ctx, prior)
m.publish(Event{
Kind: EventRemoved,
Scope: scope,
OwnerID: ownerID,
At: m.now(),
})
return nil
}
// Sweep reaps expired upload grants: rows first, then objects. The row
// deletion is what decides ownership — a grant whose row a concurrent
// confirmation already claimed is left alone — so the sweep can never
// evict an object that just went live. Failures are logged and
// swallowed; the next run catches up.
//
// It satisfies [schedule.TaskFn]; dispatch it on the service's
// scheduler:
//
// sched.Dispatch(schedule.Named(
// "iam.avatars",
// schedule.Every(interval, schedule.TaskFn(m.Sweep)),
// ))
//
// [schedule.TaskFn]: github.com/deep-rent/nexus/sys/schedule#TaskFn
func (m *Manager) Sweep(ctx context.Context) {
expired, err := m.cfg.Store.ListExpired(ctx, m.now(), SweepLimit)
if err != nil {
m.logger.Error(
ctx,
"Failed to list expired upload grants",
log.Error(err),
)
return
}
var keys []string
for _, p := range expired {
deleted, err := m.cfg.Store.Delete(
ctx, p.Scope, p.OwnerID, p.Key,
)
if err != nil {
m.logger.Error(
ctx,
"Failed to reap expired upload grant",
log.String("key", p.Key),
log.Error(err),
)
continue
}
if deleted {
keys = append(keys, p.Key)
}
}
if len(keys) == 0 {
return
}
verdict, err := m.cfg.Storage.Delete(ctx, keys)
if err != nil {
m.logger.Error(
ctx,
"Failed to evict expired upload objects",
log.Int("keys", len(keys)),
log.Error(err),
)
}
for _, e := range verdict.Errors {
m.logger.Warn(
ctx,
"Expired upload object was not evicted",
log.String("key", e.Key),
log.Error(e),
)
}
if n := len(verdict.Deleted); n > 0 {
m.logger.Debug(
ctx,
"Swept expired upload grants",
log.Int("objects", n),
)
}
}
// URL renders the public URL the given object key is served under.
func (m *Manager) URL(key string) string {
return strings.TrimSuffix(m.cfg.PublicURL, "/") + "/" + key
}
// allowed reports whether the declared content type passes the
// whitelist. The comparison folds case, since media types are
// case-insensitive.
func (m *Manager) allowed(contentType string) bool {
for _, t := range m.cfg.ContentTypes {
if ascii.EqualFold(contentType, t) {
return true
}
}
return false
}
// evict best-effort deletes one object. Eviction failures leave an
// unreferenced object behind, which costs storage but breaks nothing, so
// they are logged rather than surfaced.
func (m *Manager) evict(ctx context.Context, key string) {
if key == "" {
return
}
verdict, err := m.cfg.Storage.Delete(ctx, []string{key})
if err == nil && len(verdict.Errors) > 0 {
err = verdict.Errors[0]
}
if err != nil {
m.logger.Warn(
ctx,
"Failed to evict picture object",
log.String("key", key),
log.Error(err),
)
}
}
// publish hands the event to the observer, if any.
func (m *Manager) publish(e Event) {
if m.observer != nil {
m.observer(e)
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package avatar
import (
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/sys/log"
)
// Option configures a [Manager].
type Option func(*Manager)
// WithClock overrides the time source, primarily for testing. A nil
// function is ignored. Defaults to [clock.System].
func WithClock(now clock.Clock) Option {
return func(m *Manager) {
if now != nil {
m.now = now
}
}
}
// WithLogger injects a structured logger for eviction and sweep
// diagnostics. A nil logger is ignored; the default stays silent
// ([log.Discard]).
func WithLogger(logger *log.Logger) Option {
return func(m *Manager) {
if logger != nil {
m.logger = logger
}
}
}
// WithObserver registers the observer lifecycle events are delivered to.
// A nil observer is ignored; the default drops events.
func WithObserver(o Observer) Option {
return func(m *Manager) {
if o != nil {
m.observer = o
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package client
import (
"context"
"time"
"uuid"
"github.com/deep-rent/nexus/dat/page"
"github.com/deep-rent/nexus/dat/search"
"github.com/deep-rent/nexus/eco/iam/oauth"
)
// Client is a durable OAuth 2.0 client registration.
//
// The zero value is not valid; create records through [Manager.Register] so
// identifiers, timestamps, and secrets are assigned consistently. The secret
// digest never serializes to JSON, making the struct safe to return from
// management APIs as-is.
type Client struct {
// ID is the immutable client identifier (a UUIDv7), presented by the
// client as its client_id.
ID uuid.UUID `json:"id"`
// Name is a human-facing label for management UIs. It carries no
// protocol meaning and need not be unique.
Name string `json:"name"`
// SecretDigest is the fingerprint of the client secret, produced by
// [digest]. Empty marks a public client incapable of keeping a secret,
// such as an SPA or a native app.
SecretDigest string `json:"-"`
// SecretExpiresAt is when the secret behind SecretDigest stops
// verifying. From that instant the client can no longer authenticate —
// effectively disabling a confidential client — until a rotation (see
// [Manager.RotateSecret]) mints a fresh secret with a fresh expiry.
// The zero value never expires; public clients always carry it, having
// no secret to expire. The JSON name follows RFC 7591's
// client_secret_expires_at.
SecretExpiresAt time.Time `json:"client_secret_expires_at,omitzero"`
// RedirectURIs is the whitelist of allowed redirect destinations,
// compared by the exact matching of [redirect].
RedirectURIs []string `json:"redirect_uris,omitzero"`
// Grants are the grant types the client may exercise at the token
// endpoint.
Grants []oauth.GrantType `json:"grants,omitzero"`
// Scopes are the scope tokens the client may request. Requests are
// checked per token, not against the space-delimited list.
Scopes []string `json:"scopes,omitzero"`
// Audience populates the aud claim of access tokens issued to the
// client. Empty omits the claim.
Audience []string `json:"audience,omitzero"`
// Disabled locks the registration: a disabled client resolves to no
// client, so every protocol interaction fails as if it were unknown.
Disabled bool `json:"disabled,omitzero"`
// CreatedAt is when the client was registered.
CreatedAt time.Time `json:"created_at,omitzero"`
// UpdatedAt is when the record last changed.
UpdatedAt time.Time `json:"updated_at,omitzero"`
}
// Bounds on the registration's repeated fields, matching the columns
// behind them. They are counted in characters rather than bytes, like
// [user.MaxNameLength], so that a value written in a non-Latin script is
// not penalized for the width of its encoding.
//
// Every one of them is generous for its field: they exist so an entry
// cannot grow without limit, not to make a legitimate registration
// awkward to express.
//
// [user.MaxNameLength]: github.com/deep-rent/nexus/eco/iam/user#MaxNameLength
const (
// MaxNameLength bounds the human-facing label at 128 characters.
MaxNameLength = 128
// MaxRedirectURILength bounds one redirect destination at 512
// characters. Redirect URIs are registered up front rather than
// composed per request, so they carry a path and at most a marker
// query — far below what a browser would accept.
MaxRedirectURILength = 512
// MaxScopeLength bounds one scope token at 64 characters. Scope tokens
// are identifiers ("openid", "iam:clients:write"), not sentences.
MaxScopeLength = 64
// MaxAudienceLength bounds one audience entry at 256 characters. The
// aud claim names a resource server, usually by URL.
MaxAudienceLength = 256
)
// Public reports whether the client is incapable of keeping a secret.
func (c *Client) Public() bool { return c.SecretDigest == "" }
// Sortable fields of the client listing; see [Search].
const (
// ByName sorts by the client's display name.
ByName = "name"
// ByCreated sorts by the registration's creation timestamp.
ByCreated = "created_at"
)
// Search is the searchable surface of the client listing. The free-text
// term matches a client's name, and the listing sorts by creation time
// (newest first by default) or name.
var Search = search.Schema{
Sorts: []string{ByName, ByCreated},
Order: []search.Sort{{Field: ByCreated, Desc: true}},
}
// Query addresses one page of the client listing, spoken in the vocabulary
// of [Search].
type Query = search.Query
// Store is the persistence contract for client registrations.
//
// Lookups return nil and a nil error when no record matches; errors are
// reserved for storage failures. Implementations must be safe for concurrent
// use.
type Store interface {
// Create persists a new client.
Create(ctx context.Context, c *Client) error
// Get retrieves a client by ID.
Get(ctx context.Context, id uuid.UUID) (*Client, error)
// Update persists changes to an existing client, keyed by [Client.ID].
// It is a no-op if the client does not exist.
Update(ctx context.Context, c *Client) error
// SetSecret replaces the stored secret digest of the given client,
// along with the expiry of the new secret (zero for one that never
// expires). It is a no-op if the client does not exist.
SetSecret(
ctx context.Context,
id uuid.UUID,
digest string,
expiresAt time.Time,
) error
// Delete removes a client and everything owned by it, reporting whether
// this call removed the record.
Delete(ctx context.Context, id uuid.UUID) (deleted bool, err error)
// List returns the requested page of clients matching the query, along
// with the total number of matches across all pages.
List(ctx context.Context, q Query) (page.Page[*Client], error)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package client
import (
"context"
"errors"
"slices"
"time"
"uuid"
"github.com/deep-rent/nexus/eco/iam/oauth"
"github.com/deep-rent/nexus/eco/iam/oauth/redirect"
"github.com/deep-rent/nexus/sec/digest"
"github.com/deep-rent/nexus/sec/nonce"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/sys/log"
)
// SecretExpiryWarning is the lead time at which [Manager.GetClient] starts
// warning that a client's secret is about to expire. An expiry with no
// warning is a scheduled outage; a week gives operations room to rotate
// on their own terms.
const SecretExpiryWarning = 7 * 24 * time.Hour
// Manager is the secret lifecycle engine over a [Store] and the adapter that
// serves stored records to the authorization server.
//
// It implements [oauth.ClientStore]. It is safe for concurrent use if its
// [Store] is.
type Manager struct {
store Store
hasher *digest.Hasher
secrets *nonce.Generator
now clock.Clock
logger *log.Logger
}
// New creates a [Manager] backed by the given [Store]. It panics if store is
// nil, since that is a startup configuration error.
func New(store Store, opts ...Option) *Manager {
if store == nil {
panic("store is required")
}
m := &Manager{
store: store,
hasher: digest.DefaultHasher,
secrets: nonce.DefaultGenerator,
now: clock.System,
logger: log.Discard(),
}
for _, opt := range opts {
opt(m)
}
return m
}
// Store exposes the underlying persistence, for management surfaces that
// read or mutate registrations without secret logic.
func (m *Manager) Store() Store { return m.store }
// Register provisions a new client registration: it assigns a fresh UUIDv7
// and stamps the timestamps. When confidential is true, it additionally
// mints a high-entropy secret, persists only its digest, and returns the
// plaintext — the sole moment it is ever available. Public clients return
// an empty secret and never carry a secret expiry, having no secret to
// expire.
func (m *Manager) Register(
ctx context.Context,
c *Client,
confidential bool,
) (secret string, err error) {
if c.Name == "" {
return "", errors.New("name is required")
}
c.ID = uuid.NewV7()
c.SecretDigest = ""
if confidential {
secret, err = m.secrets.Draw(ctx)
if err != nil {
return "", err
}
c.SecretDigest = m.hasher.String(secret)
} else {
c.SecretExpiresAt = time.Time{}
}
now := m.now()
c.CreatedAt = now
c.UpdatedAt = now
if err := m.store.Create(ctx, c); err != nil {
return "", err
}
return secret, nil
}
// RotateSecret mints a fresh secret for the client, replaces the stored
// digest, and returns the plaintext — the sole moment it is ever available.
// The previous secret stops working immediately, and the given expiry
// becomes the new secret's: rotation is how an expired client comes back
// to life. A zero expiry mints a secret that never expires.
//
// Rotating a public client's secret converts it into a confidential client.
func (m *Manager) RotateSecret(
ctx context.Context,
id uuid.UUID,
expiresAt time.Time,
) (secret string, err error) {
secret, err = m.secrets.Draw(ctx)
if err != nil {
return "", err
}
if err := m.store.SetSecret(
ctx, id, m.hasher.String(secret), expiresAt,
); err != nil {
return "", err
}
return secret, nil
}
// GetClient implements [oauth.ClientStore]. Disabled registrations resolve
// to no client, so every protocol interaction treats them as unknown.
//
// A client whose secret has expired still resolves — only its secret stops
// verifying — but resolving one is worth a warning, as is resolving one
// whose secret expires within [SecretExpiryWarning]: the operator reading
// the log is the one who can rotate before the client goes dark.
func (m *Manager) GetClient(
ctx context.Context,
id uuid.UUID,
) (oauth.Client, error) {
c, err := m.store.Get(ctx, id)
if err != nil || c == nil || c.Disabled {
return nil, err
}
if exp := c.SecretExpiresAt; !exp.IsZero() {
switch now := m.now(); {
case !now.Before(exp):
m.logger.Warn(
ctx,
"Client secret has expired; rotate it to restore access",
log.UUID("client_id", c.ID),
log.Time("expired_at", exp),
)
case exp.Sub(now) <= SecretExpiryWarning:
m.logger.Warn(
ctx,
"Client secret expires soon; rotate it to avoid an outage",
log.UUID("client_id", c.ID),
log.Time("expires_at", exp),
)
}
}
return registered{c: c, hasher: m.hasher, now: m.now}, nil
}
// registered adapts a stored [Client] to the [oauth.Client] interface.
type registered struct {
c *Client
hasher *digest.Hasher
now clock.Clock
}
// ID implements [oauth.Client].
func (r registered) ID() uuid.UUID { return r.c.ID }
// Public implements [oauth.Client].
func (r registered) Public() bool { return r.c.Public() }
// Audience implements [oauth.Client].
func (r registered) Audience() []string { return r.c.Audience }
// VerifySecret implements [oauth.Client]. It compares in constant time and
// always fails for public clients and for a secret past its expiry — an
// expired secret is as unusable as a wrong one, and the caller cannot tell
// the two apart.
func (r registered) VerifySecret(secret string) bool {
if r.c.Public() {
return false
}
if exp := r.c.SecretExpiresAt; !exp.IsZero() && !r.now().Before(exp) {
return false
}
return r.hasher.Match(secret, r.c.SecretDigest)
}
// VerifyRedirectURI implements [oauth.Client].
func (r registered) VerifyRedirectURI(uri string) bool {
return redirect.Verify(uri, r.c.RedirectURIs)
}
// CanUseGrant implements [oauth.Client].
func (r registered) CanUseGrant(grant oauth.GrantType) bool {
return slices.Contains(r.c.Grants, grant)
}
// CanUseScope implements [oauth.Client].
func (r registered) CanUseScope(scope string) bool {
return slices.Contains(r.c.Scopes, scope)
}
var (
_ oauth.Client = registered{}
_ oauth.ClientStore = (*Manager)(nil)
)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package client
import (
"github.com/deep-rent/nexus/sec/digest"
"github.com/deep-rent/nexus/sec/nonce"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/sys/log"
)
// Option configures a [Manager].
type Option func(*Manager)
// WithHasher sets the hasher that fingerprints client secrets. It must match
// the hasher of any store that predates the change, or existing secrets stop
// verifying. A nil hasher is ignored. Defaults to [digest.DefaultHasher].
func WithHasher(h *digest.Hasher) Option {
return func(m *Manager) {
if h != nil {
m.hasher = h
}
}
}
// WithGenerator overrides the source of client secrets. A nil generator is
// ignored. Defaults to [nonce.DefaultGenerator] (256-bit secrets).
func WithGenerator(g *nonce.Generator) Option {
return func(m *Manager) {
if g != nil {
m.secrets = g
}
}
}
// WithClock overrides the time source, primarily for testing. A nil function
// is ignored. Defaults to [clock.System].
func WithClock(now clock.Clock) Option {
return func(m *Manager) {
if now != nil {
m.now = now
}
}
}
// WithLogger injects a structured logger, which carries the secret expiry
// warnings of [Manager.GetClient]. A nil logger is ignored; the default
// stays silent ([log.Discard]).
func WithLogger(logger *log.Logger) Option {
return func(m *Manager) {
if logger != nil {
m.logger = logger
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package config
import (
"fmt"
"strings"
"uuid"
"github.com/deep-rent/nexus/eco/attest"
"github.com/deep-rent/nexus/eco/notify"
"github.com/deep-rent/nexus/sec/seal"
"github.com/deep-rent/nexus/sys/boot"
"github.com/deep-rent/nexus/sys/env"
)
// Config declares the deployment configuration of the IAM service. Bind
// it from the environment with [Load].
type Config struct {
// Core is the listen addresses, CORS origins, log level, and
// connection timeouts every service carries. The operational
// listener matters more here than elsewhere: its endpoints expose
// no credentials, but they do expose a live view of the service —
// login failure rates most of all — which is not something to
// publish.
boot.Core `env:",inline"`
// Issuer is the public HTTPS base URL of this deployment. It stamps
// issued tokens and the discovery metadata.
Issuer string `env:",required"`
// Database configures the PostgreSQL connection. Empty selects the
// in-memory driver, which forgets everything on restart and exists
// for local development only.
Database boot.Database `env:",prefix:DATABASE_"`
// Vault declares where the token signing keys come from.
Vault Vault `env:",prefix:VAULT_"`
// LoginRedirectURI is the frontend URL a completed external login
// redirects to. Required when a social provider is configured.
LoginRedirectURI string
// LoginTerminalURI is the frontend URL that renders terminal login
// errors. Required when a social provider is configured.
LoginTerminalURI string
// VerificationURI is the frontend URL shown to device-flow users.
// Setting it enables the Device Code grant endpoints.
VerificationURI string
// Passkeys declares the WebAuthn relying party; passkey endpoints and
// the WebAuthn grant enable when its ID is set.
Passkeys Passkeys
// Bird configures the messaging provider shared by mail and text.
Bird Bird
// Mail configures the transactional mail channel; mail flows enable
// when the provider and the channel are set.
Mail Mail
// Text configures the text channel backing text second factors.
Text Text
// Google configures the Google social login provider.
Google Social
// Apple configures the Apple social login provider.
Apple Apple
// Admin provisions an initial admin machine client at startup.
Admin Admin
// Throttle tunes the shared credential-endpoint limiter.
Throttle Throttle
// Captcha guards the login endpoint with Cloudflare Turnstile.
Captcha Captcha
// Authenticator configures authenticator apps and recovery codes.
Authenticator Authenticator
// Avatars configures the object storage behind user avatars and team
// logos.
Avatars Avatars
// Hooks configures the webhooks carrying lifecycle events to other
// services. Endpoints are registered through the management API
// rather than here, since they come and go with the services that
// subscribe.
Hooks boot.Sender `env:",prefix:HOOK_"`
// Notify configures the notification service a team invitation is
// pushed through, alongside the mail. Empty leaves invitations
// mailed and nothing else.
Notify Notify `env:",prefix:NOTIFY_"`
// Attest configures the audit trail lifecycle events are asserted
// to. Empty leaves acts unattested, which is how the service ran
// before the trail existed.
Attest attest.Config `env:",prefix:ATTEST_"`
}
// Notify configures the notification service the identity service asks
// for a push when a team invitation goes out.
//
// It reaches only an invitee who already has an account and a registered
// phone. An invitation is addressed to an email address and the flow
// deliberately serves people who have neither, so the push supplements
// the mailed link and never replaces it.
type Notify struct {
notify.Config `env:",inline"`
// Category names what the notification service renders, as its
// catalog declares it.
Category string `env:",default:'team.invited'"`
}
// Enabled reports whether invitation pushes are configured.
func (c Notify) Enabled() bool {
return c.Config.Enabled() && c.Category != ""
}
// Vault declares where the service's token signing keys come from: an
// OVHcloud KMS domain holding the keys on the service's behalf, or — when
// no KMS endpoint is configured — a key file mounted into the container.
//
// The file is the simpler deployment and the one the vault command
// manages; the KMS keeps private keys out of the cluster entirely and can
// hold them in an HSM. See [Vault.Validate] for the rules.
type Vault struct {
// File is the path to the signing key JSON file in the
// [file.Items] format, typically a mounted Kubernetes Secret. Produce
// one with "vault add". It is the fallback source, consulted only
// while no KMS endpoint is configured.
//
// [file.Items]: github.com/deep-rent/nexus/sec/vault/source/file#Items
File string `env:",default:'./vault.json'"`
// OKMS configures the OVHcloud KMS domain backing the keys. Naming an
// endpoint selects the KMS; the file is ignored then.
OKMS OKMS `env:",prefix:OKMS_"`
}
// OKMS configures an OVHcloud KMS domain as the signing key source. The
// service authenticates to it with a mutual-TLS client certificate, so no
// private key material lives in the cluster.
type OKMS struct {
// Endpoint is the base URL of the KMS REST API, for example
// "https://eu-west-rbx.okms.ovh.net". Empty falls back to the key
// file.
Endpoint string
// Domain is the KMS domain identifier, the "okmsId" path segment of
// every request.
Domain string
// CertFile is the path to the PEM-encoded client certificate the KMS
// authenticates.
CertFile string
// KeyFile is the path to the certificate's PEM-encoded private key.
KeyFile string
}
// Enabled reports whether a KMS domain is configured.
func (c OKMS) Enabled() bool { return c.Endpoint != "" }
// Validate reports whether the vault section names a usable source: a
// fully configured KMS domain when an endpoint is named, the key file
// otherwise. It is checked at startup rather than at first signature, so
// a deployment that would fail to mint tokens fails to start instead.
func (c Vault) Validate() error {
if !c.OKMS.Enabled() {
// The binding defaults the file path, so an empty one only
// happens where the section is built by hand.
if c.File == "" {
return fmt.Errorf(
"set %sVAULT_FILE or %sVAULT_OKMS_ENDPOINT",
Prefix, Prefix,
)
}
return nil
}
// A half-configured KMS is a likelier mistake than a deliberate one,
// and every missing piece is fatal, so name them all at once.
var missing []string
if c.OKMS.Domain == "" {
missing = append(missing, Prefix+"VAULT_OKMS_DOMAIN")
}
if c.OKMS.CertFile == "" {
missing = append(missing, Prefix+"VAULT_OKMS_CERT_FILE")
}
if c.OKMS.KeyFile == "" {
missing = append(missing, Prefix+"VAULT_OKMS_KEY_FILE")
}
if len(missing) > 0 {
return fmt.Errorf("%s must be set", strings.Join(missing, ", "))
}
return nil
}
// Passkeys declares the WebAuthn relying party.
type Passkeys struct {
// ID is the relying party identifier (the effective domain passkeys
// are scoped to). Empty disables passkeys.
ID string
// Name is the human-palatable relying party name shown by
// authenticators.
Name string
// Origins lists the origins allowed to answer challenges.
Origins []string
}
// Enabled reports whether passkeys are configured.
func (c Passkeys) Enabled() bool { return c.ID != "" }
// Bird configures the messaging provider (Bird) shared by the mail and
// text channels. The channels themselves — which carry the sender
// identities — are declared per section in [Mail] and [Text].
type Bird struct {
// AccessKey authenticates against the Bird API. Empty disables all
// mail and text delivery.
AccessKey string
// WorkspaceID is the Bird workspace the channels live in.
WorkspaceID string
}
// Enabled reports whether the provider is configured.
func (c Bird) Enabled() bool {
return c.AccessKey != "" && c.WorkspaceID != ""
}
// Mail configures the transactional mail channel and the per-occasion
// templates. The sender identity belongs to the channel at the provider,
// so there is no from address here.
type Mail struct {
// ChannelID is the Bird email channel messages dispatch through.
// Empty disables all mail; see also [Bird].
ChannelID string
// Languages lists the locales the mail template projects publish, as
// BCP 47 tags; the first is the deployment default. Empty renders
// every mail in its template's own default locale.
Languages []string
// VerifyURL is the frontend URL redeeming email confirmation tickets.
VerifyURL string
// ResetURL is the frontend URL redeeming password recovery tickets.
ResetURL string
// TemplateVerify renders the email ownership confirmation.
TemplateVerify string
// TemplateReset renders the password recovery mail.
TemplateReset string
// TemplateAlert renders the unfamiliar-device login alert.
TemplateAlert string
// TemplateOTP renders the one-time password mail.
TemplateOTP string
// TemplateInvite renders the team invitation mail.
TemplateInvite string
// InviteURL is the frontend URL redeeming team invitation tokens.
InviteURL string
}
// Enabled reports whether mail is configured. The provider must be
// configured too; see [Bird.Enabled].
func (c Mail) Enabled() bool { return c.ChannelID != "" }
// Text configures the text channel backing text second factors. The
// sending number belongs to the channel at the provider, so there is no
// from number here.
type Text struct {
// ChannelID is the Bird SMS channel messages dispatch through. Empty
// disables texts; see also [Bird].
ChannelID string
// Template is the ID of the template project rendering texted
// one-time passwords.
Template string
// Languages lists the locales the text template publishes, as BCP 47
// tags; the first is the deployment default. Empty renders every text
// in the template's default locale.
Languages []string
}
// Enabled reports whether texts are configured. The provider must be
// configured too; see [Bird.Enabled].
func (c Text) Enabled() bool {
return c.ChannelID != "" && c.Template != ""
}
// Avatars configures the S3-compatible object storage behind user
// avatars and team logos; the picture endpoints enable when it is set.
type Avatars struct {
// AccessKey is the S3 access key. Empty disables pictures.
AccessKey string
// SecretKey is the S3 secret key.
SecretKey string
// Region is the provider region the credentials sign for.
Region string
// Bucket is the bucket's base URL — virtual-hosted or path style.
Bucket string
// PublicURL is the base URL pictures are served from: a CDN over the
// bucket, on a domain of its own so user content never shares an
// origin with this service.
PublicURL string
// MaxSize optionally caps an uploaded picture's size in bytes; zero
// keeps the engine's default.
MaxSize int64
}
// Enabled reports whether picture storage is configured.
func (c Avatars) Enabled() bool {
return c.AccessKey != "" && c.SecretKey != "" &&
c.Region != "" && c.Bucket != "" && c.PublicURL != ""
}
// Social configures an OIDC social login provider.
type Social struct {
// ClientID is the OAuth client ID at the provider. Empty disables the
// provider.
ClientID string
// ClientSecret is the OAuth client secret at the provider.
ClientSecret string
// RedirectURI is the absolute URL of this service's callback endpoint
// as registered at the provider. The federation endpoints sit behind
// the service's version prefix, so it must include that too.
RedirectURI string
}
// Enabled reports whether the provider is configured.
func (c Social) Enabled() bool { return c.ClientID != "" }
// Apple configures the Apple social login provider, whose client secret is
// a self-signed JWT rather than a static string.
type Apple struct {
// ClientID is the Services ID registered with Apple. Empty disables
// the provider.
ClientID string
// TeamID is the Apple Developer team identifier.
TeamID string
// KeyID identifies the private key registered with Apple.
KeyID string
// KeyFile is the path to the PEM-encoded private key.
KeyFile string
// RedirectURI is the absolute URL of this service's callback endpoint
// as registered with Apple. The federation endpoints sit behind the
// service's version prefix, so it must include that too.
RedirectURI string
}
// Enabled reports whether the provider is configured.
func (c Apple) Enabled() bool { return c.ClientID != "" }
// Admin provisions an initial admin machine client, so a fresh deployment
// can be administered before any interactive user exists. The client is
// created once, idempotently, with the Client Credentials grant and the
// admin scope.
type Admin struct {
// ClientID is the fixed identifier of the admin client. The zero UUID
// disables the provisioning.
ClientID uuid.UUID
// ClientSecret is the client secret; only its digest is stored.
ClientSecret string
}
// Enabled reports whether the admin client is configured.
func (c Admin) Enabled() bool {
return c.ClientID != uuid.Nil() && c.ClientSecret != ""
}
// Throttle tunes the shared limiter charged by credential endpoints.
type Throttle struct {
// Limit is the sustained token refill per second and key.
Limit float64 `env:",default:5"`
// Burst is the bucket capacity per key.
Burst int `env:",default:100"`
// Disabled switches throttling off entirely, for deployments limiting
// at the edge.
Disabled bool
}
// Captcha configures the Cloudflare Turnstile check guarding the login
// endpoint. It screens automated traffic before any password is hashed,
// which is what prices statistical timing enumeration out of reach; it
// complements the throttle rather than replacing it.
type Captcha struct {
// Secret is the Turnstile secret key, the server-side half of the
// widget's key pair. Empty leaves the login endpoint unguarded.
Secret string
// Action optionally pins the widget's action name, so a token minted
// on another form of the same site cannot be replayed at the login
// endpoint.
Action string
// Hostname optionally pins the domain the widget must have run on,
// refusing a token minted on a page an attacker controls under the
// same site key.
Hostname string
// Required decides what happens when Cloudflare is unreachable.
// Unset — the default — proceeds on the throttle alone, since an
// outage at Cloudflare would otherwise lock every user out of the
// deployment. Set it to refuse logins instead.
Required bool
}
// Enabled reports whether a captcha is configured.
func (c Captcha) Enabled() bool { return c.Secret != "" }
// Authenticator configures the second factors a user carries: an
// authenticator app's time-based codes, and the recovery codes that are
// the way back in when the authenticator is gone.
//
// It needs a sealing key because a shared secret, unlike a password,
// must be read back to verify a code and so cannot be stored as a
// digest. Without one the deployment offers neither factor rather than
// keeping secrets in the clear.
type Authenticator struct {
// Key is the sealing key, 32 random bytes in base64. Generate one
// with "openssl rand -base64 32". Empty disables authenticator apps
// and recovery codes.
Key string
// KeyID names the key inside sealed values, so a rotation can tell
// them apart. Any short stable string does; a date is conventional.
KeyID string `env:",default:primary"`
// RetiredKeys are previously used keys, kept so values sealed under
// them can still be opened while they are rewritten. Give them as
// "id:base64" pairs.
RetiredKeys []string
}
// Enabled reports whether a sealing key is configured.
func (c Authenticator) Enabled() bool { return c.Key != "" }
// Keyring builds the keyring from the configured keys.
func (c Authenticator) Keyring() (*seal.Keyring, error) {
return seal.ParseKeyring(c.KeyID, c.Key, c.RetiredKeys)
}
// Prefix is the environment namespace every configuration variable of the
// IAM service lives under.
const Prefix = "IAM_"
// Load binds a [Config] from the environment under [Prefix], reporting
// every binding problem at once.
func Load(opts ...env.Option) (Config, error) {
return boot.Load[Config](Prefix, opts...)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package directory
import (
"net/http"
"strconv"
"time"
"uuid"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/eco/iam/team"
"github.com/deep-rent/nexus/eco/iam/user"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/sec/auth"
"github.com/deep-rent/nexus/sec/jose/jwt"
)
// PermDirectoryRead resolves identifiers and addresses to people, and
// lists the holders of a role. Scopes and permissions share a
// namespace: a token must carry the permission as a scope, and — for a
// token acting on a person's behalf — a role granting it through the
// configured [auth.Grants]. See [auth.Grants.Permits].
const PermDirectoryRead = "iam:directory:read"
// Permissions lists every permission of this API.
var Permissions = []string{PermDirectoryRead}
// DefaultGrants maps [auth.RoleAdmin] onto every permission of this
// API. A deployment whose support staff resolve people from a
// first-party frontend widens this through [Config.Grants]; machine
// clients are unaffected, since their vetted scopes speak for
// themselves.
var DefaultGrants = auth.Grants{auth.RoleAdmin: Permissions}
// PathUsers is the endpoint path base registered by [Server.Mount],
// relative to the mount prefix.
const PathUsers = "/directory/users"
// PathTeams is the base of the team routes, relative to the mount
// prefix.
const PathTeams = "/directory/teams"
// Bounds on what one request may ask for.
const (
// MaxResolve is how many people one batch may name. The batch
// exists to collapse the round-trips of a rendered page, not to
// export the directory.
MaxResolve = 100
// MaxRoleResults caps the role listing. Role populations are the
// staff of one deployment, so the cap sits far above any real
// answer and exists to bound a mistake.
MaxRoleResults = 500
)
// Person is the directory's view of a user: who they are, how to
// address them, and — where the caller already named them — how to
// reach them. Everything else an administrator sees is deliberately
// absent; see the package documentation.
type Person struct {
// ID identifies the user.
ID uuid.UUID `json:"id"`
// Name is the user's full name.
Name string `json:"name"`
// DisplayName is the name they prefer to be addressed by, absent
// when they expressed none.
DisplayName string `json:"display_name,omitzero"`
// Email is the contact address, carried only by the resolution
// routes; listings omit it.
Email string `json:"email,omitzero"`
// Locales are the preferred locales as BCP 47 tags, most preferred
// first — what a sender negotiates a template language against.
Locales []string `json:"locales,omitzero"`
// Zone is the preferred time zone as an IANA name, carried only by
// the resolution routes. It is what a sender deciding WHEN to reach
// somebody reads, as Locales is what it reads to decide in which
// language — a notification service holding quiet hours needs both.
//
// Listings omit it: a zone is coarse location, and the role listing
// is the one route that answers about people the caller did not
// name.
Zone string `json:"zone,omitzero"`
// Roles are the role names the user carries, carried only by the
// resolution routes. A service that gates on a role — may this
// person be assigned work? — reads it here rather than listing
// every holder to look for one.
Roles []string `json:"roles,omitzero"`
}
// identity projects a user as a listing entry: who they are, without
// how to reach them.
func identity(u *user.User) Person {
return Person{
ID: u.ID,
Name: u.Name,
DisplayName: u.DisplayName,
Locales: u.Locales,
}
}
// contact projects a user the caller named, which additionally carries
// the address, the roles, and the time zone.
func contact(u *user.User) Person {
p := identity(u)
p.Email = u.Email
p.Roles = u.Roles
p.Zone = u.Zone
return p
}
// Config bundles the collaborators of a [Server].
type Config struct {
// Users is the identity engine behind the IAM server. Required.
Users *user.Manager
// Teams is the persistence behind the team routes. Required: a
// sibling deciding whether somebody may act for a team needs the
// membership answered by the same authority that answers who
// somebody is.
Teams team.Store
// Verifier validates the Bearer access tokens guarding this API,
// typically built over the deployment's own key set and issuer.
// Required.
Verifier jwt.Verifier[*auth.Claims]
// Grants maps roles onto the permissions they carry for tokens
// acting on a person's behalf. Defaults to [DefaultGrants].
Grants auth.Grants
}
// Server implements the directory API. Create instances with [New] and
// attach the routes with [Server.Mount].
type Server struct {
store user.Store
roster team.Store
guard router.Middleware
grants auth.Grants
}
// New assembles a [Server]. It panics if a required [Config] field is
// missing, since startup misconfiguration is a programmer error.
func New(cfg Config) *Server {
switch {
case cfg.Users == nil:
panic("user manager is required")
case cfg.Teams == nil:
panic("team store is required")
case cfg.Verifier == nil:
panic("token verifier is required")
}
s := &Server{
store: cfg.Users.Store(),
roster: cfg.Teams,
guard: auth.NewGuard(cfg.Verifier).Secure(),
grants: cfg.Grants,
}
if s.grants == nil {
s.grants = DefaultGrants
}
return s
}
// Mount registers the directory endpoints on the registrar — the
// router itself for a root mount, or a [router.Group] to nest them
// under a path prefix. The group authenticates behind the Bearer
// guard; every route demands [PermDirectoryRead].
func (s *Server) Mount(reg router.Registrar) {
read := auth.Enforce(s.grants.Require(PermDirectoryRead))
users := reg.Group(PathUsers, s.guard)
users.HandleFunc(http.MethodGet, "", s.ListUsers, read)
users.HandleFunc(http.MethodGet, "/{id}", s.GetUser, read)
users.HandleFunc(http.MethodPost, "/resolve", s.ResolveUsers, read)
teams := reg.Group(PathTeams, s.guard)
teams.HandleFunc(
http.MethodGet, "/{id}/members/{user}", s.GetMembership, read,
)
}
// Membership is the directory's answer about one user's standing in
// one team: whether they belong, and in which role. It is what a
// sibling service gates a team-directed action on — may this person
// spend the team's money, see the team's numbers — without holding a
// roster of its own.
type Membership struct {
// Owner reports whether the member holds the management role.
Owner bool `json:"owner"`
// Since is when the membership was established.
Since time.Time `json:"since,omitzero"`
}
// GetMembership answers one user's standing in one team. A non-member
// and a nonexistent team are deliberately indistinguishable — both
// answer 404 — so the route confirms rosters, never enumerates them.
func (s *Server) GetMembership(e *router.Exchange) error {
var params struct {
ID uuid.UUID `path:"id"`
User uuid.UUID `path:"user"`
}
if err := e.BindPath(¶ms); err != nil {
return err
}
m, err := s.roster.GetMembership(e.Context(), params.ID, params.User)
if err != nil {
return failure(err)
}
if m == nil {
return &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "no such membership",
}
}
return e.JSON(http.StatusOK, Membership{
Owner: m.Owner,
Since: m.CreatedAt,
})
}
// GetUser resolves one identifier.
func (s *Server) GetUser(e *router.Exchange) error {
var params struct {
ID uuid.UUID `path:"id"`
}
if err := e.BindPath(¶ms); err != nil {
return err
}
u, err := s.store.Get(e.Context(), params.ID)
if err != nil {
return failure(err)
}
if u == nil {
return &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "no such user",
}
}
return e.JSON(http.StatusOK, contact(u))
}
// ResolveRequest names the people to resolve, by identifier, by
// address, or both.
type ResolveRequest struct {
// IDs are the user identifiers to resolve.
IDs []uuid.UUID `json:"ids,omitzero"`
// Emails are the contact addresses to resolve. Matching is exact
// on the normalized address, never a search.
Emails []string `json:"emails,omitzero"`
}
// Validate implements the [valid.Validatable] interface.
func (r *ResolveRequest) Validate(v *valid.Validator) {
if len(r.IDs) == 0 && len(r.Emails) == 0 {
v.Fail("ids", "name at least one identifier or address")
}
v.MaxSize("ids", len(r.IDs), MaxResolve)
v.MaxSize("emails", len(r.Emails), MaxResolve)
for _, email := range r.Emails {
v.Email("emails", email)
}
}
// ResolveResponse carries the people that resolved.
type ResolveResponse struct {
// Users are the people found, in no particular order. An
// identifier or address that matches nobody is absent rather than
// an error: a batch is a question about several people, and one
// unknown among them does not make the question invalid.
Users []Person `json:"users"`
}
// ResolveUsers resolves a batch of identifiers and addresses.
//
// It reads one record per name rather than one statement per batch:
// the round-trip this route removes is the caller's, and each read is
// an indexed point lookup on a connection already in hand. Collapsing
// them into a statement of their own would buy microseconds at the
// price of two more methods on [user.Store] and both its backends.
func (s *Server) ResolveUsers(e *router.Exchange) error {
var req ResolveRequest
if err := e.BindJSON(&req); err != nil {
return err
}
ctx := e.Context()
seen := make(map[uuid.UUID]bool, len(req.IDs)+len(req.Emails))
out := ResolveResponse{Users: []Person{}}
add := func(u *user.User) {
if u == nil || seen[u.ID] {
return
}
seen[u.ID] = true
out.Users = append(out.Users, contact(u))
}
for _, id := range req.IDs {
u, err := s.store.Get(ctx, id)
if err != nil {
return failure(err)
}
add(u)
}
for _, email := range req.Emails {
u, err := s.store.GetByEmail(ctx, user.Normalize(email))
if err != nil {
return failure(err)
}
add(u)
}
return e.JSON(http.StatusOK, out)
}
// ListResponse carries the holders of a role.
type ListResponse struct {
// Users are the role's holders, ordered by name and capped at
// [MaxRoleResults]. Disabled accounts are left out: the listing
// answers who can be given work, not who ever could.
Users []Person `json:"users"`
}
// ListUsers lists the holders of one role. There is deliberately no
// unfiltered listing: a caller names the role it asks about.
func (s *Server) ListUsers(e *router.Exchange) error {
q := e.Query()
role := q.Get("role")
if role == "" {
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: "role is required; there is no full listing",
}
}
limit := MaxRoleResults
if raw := q.Get("limit"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n < 1 {
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: "limit must be a positive integer",
}
}
limit = min(n, MaxRoleResults)
}
users, err := s.store.ListByRole(e.Context(), role, limit)
if err != nil {
return failure(err)
}
out := ListResponse{Users: []Person{}}
for _, u := range users {
if u.Disabled {
continue
}
out.Users = append(out.Users, identity(u))
}
return e.JSON(http.StatusOK, out)
}
// failure renders a storage failure, which is never the caller's
// fault and never says more than that.
func failure(err error) error {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to look up the directory",
Cause: err,
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
// Package drivertest is the shared conformance suite for IAM storage
// drivers: it exercises the driver-agnostic storage contracts — the
// [artifact.Store] CRUD semantics with their atomic deletion guarantee, the
// owner-scoped session and trust queries, the device code lookups, the
// passkey credential lifecycle, and the cross-cutting refresh token
// revocations — against any [Store] implementation.
//
// Both bundled drivers run it: the PostgreSQL driver against a real
// database and the mock driver against its in-memory maps, which is what
// keeps the two accessor-compatible. Domain-specific store behavior
// (users, clients, teams, tickets — search, pagination, uniqueness) stays
// in each driver's own tests; this suite pins only the contracts every
// engine relies on.
//
// func TestStore_Conformance(t *testing.T) {
// drivertest.Run(t, driver.New(...))
// }
package drivertest
import (
"context"
"slices"
"testing"
"time"
"uuid"
"github.com/deep-rent/nexus/eco/iam/artifact"
"github.com/deep-rent/nexus/eco/iam/authn"
"github.com/deep-rent/nexus/eco/iam/avatar"
"github.com/deep-rent/nexus/eco/iam/flow"
"github.com/deep-rent/nexus/eco/iam/oauth"
"github.com/deep-rent/nexus/eco/iam/otp"
"github.com/deep-rent/nexus/eco/iam/passkey"
"github.com/deep-rent/nexus/eco/iam/session"
"github.com/deep-rent/nexus/eco/iam/ticket"
"github.com/deep-rent/nexus/eco/iam/trust"
"github.com/deep-rent/nexus/eco/iam/user"
)
// Store is the surface the suite exercises. It mirrors the service's
// driver contract; both bundled drivers satisfy it.
type Store interface {
Users() user.Store
Sessions() session.Store
Trust() trust.Store
Credentials() passkey.CredentialStore
Challenges() otp.Store
Flows() flow.Store
Ceremonies() passkey.Store
Tickets() ticket.Store
Enrollments() authn.Store
RecoveryCodes() authn.Codes
AvatarUploads() avatar.Store
AuthCodes() artifact.Store[oauth.Digest, oauth.AuthCode]
RefreshTokens() oauth.RefreshTokenStore
DeviceCodes() oauth.DeviceCodeStore
DeleteRefreshTokensForUser(
ctx context.Context,
userID uuid.UUID,
) error
DeleteRefreshTokensForClient(
ctx context.Context,
clientID uuid.UUID,
) error
}
// Run exercises the conformance suite against the given store. The store
// must be empty (or at least free of records colliding with the suite's
// fixtures); every fixture is keyed by fresh UUIDs, so a shared database is
// safe as long as migrations have run.
func Run(t *testing.T, s Store) {
t.Run("ArtifactCRUD", func(t *testing.T) { artifactCRUD(t, s) })
t.Run("AtomicDelete", func(t *testing.T) { atomicDelete(t, s) })
t.Run("ZeroExpiry", func(t *testing.T) { zeroExpiry(t, s) })
t.Run("SessionsByOwner", func(t *testing.T) { sessionsByOwner(t, s) })
t.Run("TrustByOwner", func(t *testing.T) { trustByOwner(t, s) })
t.Run("DeviceCodes", func(t *testing.T) { deviceCodes(t, s) })
t.Run("Credentials", func(t *testing.T) { credentials(t, s) })
t.Run("TokenRevocation", func(t *testing.T) { tokenRevocation(t, s) })
t.Run("RefreshFamilies", func(t *testing.T) { refreshFamilies(t, s) })
t.Run("UserAlerts", func(t *testing.T) { userAlerts(t, s) })
t.Run("UsersByRole", func(t *testing.T) { usersByRole(t, s) })
t.Run("Challenges", func(t *testing.T) { challenges(t, s) })
t.Run("Flows", func(t *testing.T) { flows(t, s) })
t.Run("Ceremonies", func(t *testing.T) { ceremonies(t, s) })
t.Run("Tickets", func(t *testing.T) { tickets(t, s) })
t.Run("Enrollments", func(t *testing.T) { enrollments(t, s) })
t.Run("RecoveryCodes", func(t *testing.T) { recoveryCodes(t, s) })
t.Run("AvatarUploads", func(t *testing.T) { avatarUploads(t, s) })
t.Run("AvatarSwap", func(t *testing.T) { avatarSwap(t, s) })
}
// stamp is a fixed reference instant, kept away from real time so expiry
// sweeps never race the suite.
var stamp = time.Unix(1_752_000_000, 0).UTC()
// seedUser creates a minimal user row, for records that reference one.
func seedUser(t *testing.T, s Store) uuid.UUID {
t.Helper()
id := uuid.NewV7()
u := &user.User{
ID: id,
Name: "Conformance",
Email: id.String() + "@drivertest.example",
CreatedAt: stamp,
UpdatedAt: stamp,
}
if err := s.Users().Create(t.Context(), u); err != nil {
t.Fatalf("failed to seed user: %v", err)
}
return id
}
// userAlerts pins the alert settings through both write paths. They gate
// every notification the service mails, so a driver that silently drops
// them would leave a user who opted in hearing nothing.
// usersByRole covers the role lookup behind the directory: membership
// of a set-valued column, ordered by name and bounded by the caller's
// limit.
func usersByRole(t *testing.T, s Store) {
store := s.Users()
role := "role-" + uuid.NewV7().String()
// Created out of alphabetical order, so an implementation that
// merely preserves insertion order fails.
for _, name := range []string{"Zoe Role", "Ada Role"} {
id := uuid.NewV7()
if err := store.Create(t.Context(), &user.User{
ID: id,
Name: name,
Email: id.String() + "@drivertest.example",
Roles: []string{role, "unrelated"},
CreatedAt: stamp,
UpdatedAt: stamp,
}); err != nil {
t.Fatalf("Create: %v", err)
}
}
// A user carrying a role that merely shares a prefix must not be
// picked up by a containment check gone stringly.
other := uuid.NewV7()
if err := store.Create(t.Context(), &user.User{
ID: other,
Name: "Bystander",
Email: other.String() + "@drivertest.example",
Roles: []string{role + "-extra"},
CreatedAt: stamp,
UpdatedAt: stamp,
}); err != nil {
t.Fatalf("Create: %v", err)
}
found, err := store.ListByRole(t.Context(), role, 10)
if err != nil {
t.Fatalf("ListByRole: %v", err)
}
if exp, act := 2, len(found); exp != act {
t.Fatalf("ListByRole: got %d holders; want %d", act, exp)
}
if exp, act := "Ada Role", found[0].Name; exp != act {
t.Errorf("ListByRole: got %q first; want %q", act, exp)
}
// The limit bounds the answer, and an unheld role is empty rather
// than an error.
found, err = store.ListByRole(t.Context(), role, 1)
if err != nil || len(found) != 1 {
t.Errorf("ListByRole(limit 1): got %d, %v; want 1, nil",
len(found), err)
}
found, err = store.ListByRole(t.Context(), "nobody-holds-this", 10)
if err != nil || len(found) != 0 {
t.Errorf("ListByRole(unheld): got %d, %v; want 0, nil",
len(found), err)
}
// A blank role and a nonpositive limit ask for nothing.
if found, err := store.ListByRole(t.Context(), "", 10); err != nil ||
len(found) != 0 {
t.Errorf("ListByRole(blank): got %d, %v; want 0, nil",
len(found), err)
}
if found, err := store.ListByRole(t.Context(), role, 0); err != nil ||
len(found) != 0 {
t.Errorf("ListByRole(limit 0): got %d, %v; want 0, nil",
len(found), err)
}
}
func userAlerts(t *testing.T, s Store) {
store := s.Users()
id := uuid.NewV7()
u := &user.User{
ID: id,
Name: "Conformance",
Email: id.String() + "@drivertest.example",
Alerts: user.Alerts{Login: true, TeamJoin: true},
CreatedAt: stamp,
UpdatedAt: stamp,
}
if err := store.Create(t.Context(), u); err != nil {
t.Fatalf("Create: %v", err)
}
got, err := store.Get(t.Context(), id)
if err != nil || got == nil {
t.Fatalf("Get after Create: %v", err)
}
if exp := (user.Alerts{Login: true, TeamJoin: true}); got.Alerts != exp {
t.Errorf("after Create: got %+v; want %+v", got.Alerts, exp)
}
// A fresh account opts into nothing, so the zero value must survive as
// false rather than come back set.
got.Alerts = user.Alerts{PasswordChange: true, TeamLeave: true}
if err := store.Update(t.Context(), got); err != nil {
t.Fatalf("Update: %v", err)
}
got, err = store.Get(t.Context(), id)
if err != nil || got == nil {
t.Fatalf("Get after Update: %v", err)
}
if exp := (user.Alerts{
PasswordChange: true,
TeamLeave: true,
}); got.Alerts != exp {
t.Errorf("after Update: got %+v; want %+v", got.Alerts, exp)
}
}
// artifactCRUD pins the Create/Get/Update round trip and field fidelity on
// a representative artifact store.
func artifactCRUD(t *testing.T, s Store) {
store := s.RefreshTokens()
tok := oauth.RefreshToken{
Token: oauth.Digest("drivertest-rt-" + uuid.NewV7().String()),
ClientID: uuid.NewV7(),
UserID: uuid.NewV7(),
Scope: "read write",
Family: uuid.NewV7(),
ExpiresAt: stamp.Add(time.Hour),
}
if _, found, err := store.Get(t.Context(), tok.Token); err != nil {
t.Fatalf("Get: %v", err)
} else if found {
t.Fatal("a missing record must report found=false, not an error")
}
if err := store.Create(t.Context(), tok); err != nil {
t.Fatalf("Create: %v", err)
}
got, found, err := store.Get(t.Context(), tok.Token)
if err != nil || !found {
t.Fatalf("Get after Create: found=%t err=%v", found, err)
}
if got != tok {
t.Errorf("round trip mutated the record:\n got %+v\nwant %+v", got, tok)
}
tok.Scope = "read"
if err := store.Update(t.Context(), tok); err != nil {
t.Fatalf("Update: %v", err)
}
if got, _, _ := store.Get(t.Context(), tok.Token); got.Scope != "read" {
t.Errorf("Update did not persist: got scope %q", got.Scope)
}
}
// atomicDelete pins the single-use guarantee: of two deletions of the same
// record, exactly one reports deleted=true.
func atomicDelete(t *testing.T, s Store) {
store := s.AuthCodes()
code := oauth.AuthCode{
Code: oauth.Digest(
"drivertest-ac-" + uuid.NewV7().String(),
),
ClientID: uuid.NewV7(),
RedirectURI: "https://app.example.com/callback",
Scope: "read",
UserID: uuid.NewV7(),
CodeChallenge: "challenge",
CodeChallengeMethod: "S256",
ExpiresAt: stamp.Add(time.Minute),
}
if err := store.Create(t.Context(), code); err != nil {
t.Fatalf("Create: %v", err)
}
deleted, err := store.Delete(t.Context(), code.Code)
if err != nil {
t.Fatalf("Delete: %v", err)
}
if !deleted {
t.Fatal("first Delete must report deleted=true")
}
deleted, err = store.Delete(t.Context(), code.Code)
if err != nil {
t.Fatalf("second Delete: %v", err)
}
if deleted {
t.Fatal("second Delete must report deleted=false")
}
}
// zeroExpiry pins the zero-value expiry round trip. Every artifact must
// carry an expiry, and one written without it has already expired — which
// only holds if the zero instant survives storage as the zero instant. A
// driver that turned it into anything else (a NULL read back as "no
// expiry", or a clamped minimum) would hand back a credential that never
// lapses.
func zeroExpiry(t *testing.T, s Store) {
store := s.Sessions()
rec := session.Record{
ID: "drivertest-s-" + uuid.NewV7().String(),
Owner: uuid.NewV7(),
CreatedAt: stamp,
}
if err := store.Create(t.Context(), rec); err != nil {
t.Fatalf("Create: %v", err)
}
got, found, err := store.Get(t.Context(), rec.ID)
if err != nil || !found {
t.Fatalf("Get: found=%t err=%v", found, err)
}
if !got.ExpiresAt.IsZero() {
t.Errorf("zero expiry did not round trip: got %v", got.ExpiresAt)
}
}
// sessionsByOwner pins the owner-scoped listing and bulk deletion the
// session management APIs rely on.
func sessionsByOwner(t *testing.T, s Store) {
store := s.Sessions()
mine, other := uuid.NewV7(), uuid.NewV7()
for i, owner := range []uuid.UUID{mine, mine, other} {
if err := store.Create(t.Context(), session.Record{
ID: "drivertest-so-" + uuid.NewV7().String(),
Owner: owner,
Label: "device",
CreatedAt: stamp.Add(time.Duration(i) * time.Second),
ExpiresAt: stamp.Add(time.Hour),
}); err != nil {
t.Fatalf("Create: %v", err)
}
}
records, err := store.ListForOwner(t.Context(), mine)
if err != nil {
t.Fatalf("ListForOwner: %v", err)
}
if len(records) != 2 {
t.Fatalf("got %d sessions; want 2", len(records))
}
for _, r := range records {
if r.Owner != mine {
t.Errorf("listing leaked a foreign session: %+v", r)
}
}
if err := store.DeleteForOwner(t.Context(), mine); err != nil {
t.Fatalf("DeleteForOwner: %v", err)
}
if records, _ := store.ListForOwner(t.Context(), mine); len(records) != 0 {
t.Error("DeleteForOwner left the owner's sessions behind")
}
if records, _ := store.ListForOwner(
t.Context(), other,
); len(records) != 1 {
t.Error("DeleteForOwner must not touch other owners")
}
}
// trustByOwner pins the same owner scoping on the trust store.
func trustByOwner(t *testing.T, s Store) {
store := s.Trust()
mine, other := uuid.NewV7(), uuid.NewV7()
for _, owner := range []uuid.UUID{mine, other} {
if err := store.Create(t.Context(), trust.Record{
ID: "drivertest-tr-" + uuid.NewV7().String(),
Owner: owner,
Label: "laptop",
CreatedAt: stamp,
ExpiresAt: stamp.Add(time.Hour),
}); err != nil {
t.Fatalf("Create: %v", err)
}
}
if records, err := store.ListForOwner(t.Context(), mine); err != nil {
t.Fatalf("ListForOwner: %v", err)
} else if len(records) != 1 || records[0].Owner != mine {
t.Fatalf("got %+v; want exactly the owner's record", records)
}
if err := store.DeleteForOwner(t.Context(), mine); err != nil {
t.Fatalf("DeleteForOwner: %v", err)
}
if records, _ := store.ListForOwner(
t.Context(), other,
); len(records) != 1 {
t.Error("DeleteForOwner must not touch other owners")
}
}
// deviceCodes pins the user-code lookup and the poll-time update.
func deviceCodes(t *testing.T, s Store) {
store := s.DeviceCodes()
code := oauth.DeviceCode{
DeviceCode: oauth.Digest("drivertest-dc-" + uuid.NewV7().String()),
UserCode: oauth.Digest("drivertest-uc-" + uuid.NewV7().String()),
ClientID: uuid.NewV7(),
Scope: "read",
Status: oauth.DeviceCodeStatusPending,
ExpiresAt: stamp.Add(time.Minute),
}
if err := store.Create(t.Context(), code); err != nil {
t.Fatalf("Create: %v", err)
}
got, found, err := store.GetByUserCode(t.Context(), code.UserCode)
if err != nil || !found {
t.Fatalf("GetByUserCode: found=%t err=%v", found, err)
}
if got.DeviceCode != code.DeviceCode {
t.Errorf("user code resolved the wrong record: %+v", got)
}
if _, found, err := store.GetByUserCode(
t.Context(), oauth.Digest("drivertest-none"),
); err != nil || found {
t.Errorf("unknown user code: found=%t err=%v", found, err)
}
polled := stamp.Add(30 * time.Second)
if err := store.Touch(t.Context(), code.DeviceCode, polled); err != nil {
t.Fatalf("Touch: %v", err)
}
if got, _, _ := store.Get(
t.Context(), code.DeviceCode,
); !got.LastPolledAt.Equal(polled) {
t.Errorf("Touch did not persist: got %v; want %v",
got.LastPolledAt, polled)
}
}
// credentials pins the passkey credential lifecycle against a real user
// row, since the credential table references one.
func credentials(t *testing.T, s Store) {
store := s.Credentials()
owner := seedUser(t, s)
cred := passkey.Credential{ID: []byte(uuid.NewV7().String())}
if err := store.Create(
t.Context(), owner, "MacBook Touch ID", cred,
); err != nil {
t.Fatalf("Create: %v", err)
}
keys, err := store.List(t.Context(), owner)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(keys) != 1 || keys[0].Name != "MacBook Touch ID" {
t.Fatalf("got %+v; want the named credential", keys)
}
if !slices.Equal(keys[0].Credential.ID, cred.ID) {
t.Error("credential ID did not round trip")
}
if err := store.Rename(
t.Context(), owner, cred.ID, "Work key",
); err != nil {
t.Fatalf("Rename: %v", err)
}
if keys, _ := store.List(
t.Context(), owner,
); len(keys) != 1 || keys[0].Name != "Work key" {
t.Errorf("Rename did not persist: %+v", keys)
}
deleted, err := store.Delete(t.Context(), owner, cred.ID)
if err != nil {
t.Fatalf("Delete: %v", err)
}
if !deleted {
t.Fatal("Delete must report the removal")
}
if keys, _ := store.List(t.Context(), owner); len(keys) != 0 {
t.Error("Delete left the credential behind")
}
}
// tokenRevocation pins the cross-cutting bulk deletions a credential change
// triggers: revoking one principal's refresh tokens must spare everyone
// else's.
func tokenRevocation(t *testing.T, s Store) {
store := s.RefreshTokens()
mine, other := uuid.NewV7(), uuid.NewV7()
client := uuid.NewV7()
seed := func(userID uuid.UUID) oauth.Digest {
t.Helper()
token := oauth.Digest("drivertest-rv-" + uuid.NewV7().String())
if err := store.Create(t.Context(), oauth.RefreshToken{
Token: token,
ClientID: client,
UserID: userID,
Scope: "read",
Family: uuid.NewV7(),
ExpiresAt: stamp.Add(time.Hour),
}); err != nil {
t.Fatalf("Create: %v", err)
}
return token
}
revoked, kept := seed(mine), seed(other)
if err := s.DeleteRefreshTokensForUser(t.Context(), mine); err != nil {
t.Fatalf("DeleteRefreshTokensForUser: %v", err)
}
if _, found, _ := store.Get(t.Context(), revoked); found {
t.Error("the user's token survived the revocation")
}
if _, found, _ := store.Get(t.Context(), kept); !found {
t.Error("the revocation deleted another user's token")
}
if err := s.DeleteRefreshTokensForClient(t.Context(), client); err != nil {
t.Fatalf("DeleteRefreshTokensForClient: %v", err)
}
if _, found, _ := store.Get(t.Context(), kept); found {
t.Error("the client revocation left its token behind")
}
}
// challenges round-trips a one-time password challenge. Every field is
// load-bearing for a 2FA login — the attempt and resend counters bound
// guessing, the purpose keeps a handle minted for one flow from completing
// another — so a driver that drops one silently weakens the second factor.
func challenges(t *testing.T, s Store) {
store := s.Challenges()
owner := seedUser(t, s)
id := uuid.NewV7().String()
c := otp.Challenge{
ID: id,
Code: "code-digest",
Owner: owner,
Purpose: "2fa",
MethodID: "mail",
ExpiresAt: stamp.Add(10 * time.Minute),
Attempts: 1,
Resends: 2,
}
if err := store.Create(t.Context(), c); err != nil {
t.Fatalf("Create: %v", err)
}
got, found, err := store.Get(t.Context(), id)
if err != nil || !found {
t.Fatalf("Get = %t, %v; want the stored challenge", found, err)
}
if got.Code != c.Code || got.Owner != c.Owner ||
got.Purpose != c.Purpose || got.MethodID != c.MethodID {
t.Errorf("challenge did not round-trip: %+v", got)
}
if got.Attempts != c.Attempts || got.Resends != c.Resends {
t.Errorf(
"got attempts %d, resends %d; want %d and %d",
got.Attempts, got.Resends, c.Attempts, c.Resends,
)
}
if !got.ExpiresAt.Equal(c.ExpiresAt) {
t.Errorf("got expiry %v; want %v", got.ExpiresAt, c.ExpiresAt)
}
// The engine persists the attempt before comparing the code, so an
// update must survive for the guessing budget to hold.
got.Attempts++
got.Code = "rotated-digest"
if err := store.Update(t.Context(), got); err != nil {
t.Fatalf("Update: %v", err)
}
after, _, _ := store.Get(t.Context(), id)
if after.Attempts != c.Attempts+1 || after.Code != "rotated-digest" {
t.Errorf("update did not persist: %+v", after)
}
// An unset method is the shape a challenge takes before delivery.
bare := uuid.NewV7().String()
if err := store.Create(t.Context(), otp.Challenge{
ID: bare,
Code: "code-digest",
Owner: owner,
Purpose: "2fa",
ExpiresAt: stamp.Add(time.Minute),
}); err != nil {
t.Fatalf("Create without a method: %v", err)
}
if got, _, _ := store.Get(t.Context(), bare); got.MethodID != "" {
t.Errorf("got method %q; want it empty", got.MethodID)
}
}
// flows round-trips a login transaction, whose Completed list is the
// record of which factors have been satisfied. A driver that mangles it
// would let a login resume with steps it never finished.
func flows(t *testing.T, s Store) {
store := s.Flows()
owner := seedUser(t, s)
id := uuid.NewV7().String()
tx := flow.Transaction{
ID: id,
Owner: owner,
Completed: []string{"password", "otp:mail"},
Remember: true,
ExpiresAt: stamp.Add(15 * time.Minute),
}
if err := store.Create(t.Context(), tx); err != nil {
t.Fatalf("Create: %v", err)
}
got, found, err := store.Get(t.Context(), id)
if err != nil || !found {
t.Fatalf("Get = %t, %v; want the stored transaction", found, err)
}
if got.Owner != tx.Owner || !got.Remember {
t.Errorf("transaction did not round-trip: %+v", got)
}
if !slices.Equal(got.Completed, tx.Completed) {
t.Errorf("got completed %v; want %v", got.Completed, tx.Completed)
}
// A fresh transaction has completed nothing. The empty list must come
// back as such rather than as a one-element list holding "".
empty := uuid.NewV7().String()
if err := store.Create(t.Context(), flow.Transaction{
ID: empty,
Owner: owner,
ExpiresAt: stamp.Add(time.Minute),
}); err != nil {
t.Fatalf("Create without steps: %v", err)
}
if got, _, _ := store.Get(t.Context(), empty); len(got.Completed) != 0 {
t.Errorf("got completed %v; want none", got.Completed)
}
}
// ceremonies round-trips a WebAuthn ceremony. The opaque Data must survive
// verbatim — it is the challenge state the assertion is checked against —
// and a login ceremony carries no owner, since the account is only
// discovered from the assertion itself.
func ceremonies(t *testing.T, s Store) {
store := s.Ceremonies()
owner := seedUser(t, s)
registration := uuid.NewV7().String()
data := []byte{0x00, 0x01, 0xff, 0xfe, 0x7f}
if err := store.Create(t.Context(), passkey.Ceremony{
ID: registration,
Kind: passkey.KindRegistration,
Owner: owner,
Data: data,
ExpiresAt: stamp.Add(5 * time.Minute),
}); err != nil {
t.Fatalf("Create: %v", err)
}
got, found, err := store.Get(t.Context(), registration)
if err != nil || !found {
t.Fatalf("Get = %t, %v; want the stored ceremony", found, err)
}
if got.Kind != passkey.KindRegistration || got.Owner != owner {
t.Errorf("ceremony did not round-trip: %+v", got)
}
if !slices.Equal(got.Data, data) {
t.Errorf("got data %v; want %v verbatim", got.Data, data)
}
login := uuid.NewV7().String()
if err := store.Create(t.Context(), passkey.Ceremony{
ID: login,
Kind: passkey.KindLogin,
Data: data,
ExpiresAt: stamp.Add(5 * time.Minute),
}); err != nil {
t.Fatalf("Create a login ceremony: %v", err)
}
if got, _, _ := store.Get(t.Context(), login); got.Owner != uuid.Nil() {
t.Errorf("got owner %v; want the zero UUID", got.Owner)
}
}
// tickets round-trips an action ticket and pins the owner-scoped
// revocation. A ticket authorizes a deferred action, so one surviving a
// credential change would outlive the credentials it was issued under.
func tickets(t *testing.T, s Store) {
store := s.Tickets()
mine, other := seedUser(t, s), seedUser(t, s)
issue := func(owner uuid.UUID, purpose, payload string) string {
t.Helper()
id := uuid.NewV7().String()
if err := store.Create(t.Context(), ticket.Ticket{
ID: id,
Owner: owner,
Purpose: purpose,
Payload: payload,
ExpiresAt: stamp.Add(time.Hour),
}); err != nil {
t.Fatalf("Create: %v", err)
}
return id
}
verify := issue(mine, "verify:email", "alice@example.com")
reset := issue(mine, "reset:password", "")
kept := issue(other, "verify:email", "bob@example.com")
got, found, err := store.Get(t.Context(), verify)
if err != nil || !found {
t.Fatalf("Get = %t, %v; want the stored ticket", found, err)
}
if got.Owner != mine || got.Purpose != "verify:email" ||
got.Payload != "alice@example.com" {
t.Errorf("ticket did not round-trip: %+v", got)
}
// An empty payload is the shape of a ticket carrying no state.
if got, _, _ := store.Get(t.Context(), reset); got.Payload != "" {
t.Errorf("got payload %q; want it empty", got.Payload)
}
// Revocation reaches every purpose the owner holds, and nobody else's.
if err := store.DeleteForOwner(t.Context(), mine); err != nil {
t.Fatalf("DeleteForOwner: %v", err)
}
for _, id := range []string{verify, reset} {
if _, found, _ := store.Get(t.Context(), id); found {
t.Error("a ticket survived the owner's revocation")
}
}
if _, found, _ := store.Get(t.Context(), kept); !found {
t.Error("the revocation deleted another owner's ticket")
}
// Revoking for an owner holding nothing is a no-op, not an error.
if err := store.DeleteForOwner(t.Context(), uuid.NewV7()); err != nil {
t.Errorf("DeleteForOwner on an empty owner: %v", err)
}
}
// refreshFamilies pins the lineage-scoped revocation replay detection
// rests on: a replayed token must take its whole family with it, spent
// records included, and leave every other lineage standing.
func refreshFamilies(t *testing.T, s Store) {
store := s.RefreshTokens()
doomed, spared := uuid.NewV7(), uuid.NewV7()
client, owner := uuid.NewV7(), seedUser(t, s)
seed := func(family uuid.UUID, spent bool) oauth.Digest {
t.Helper()
token := oauth.Digest("drivertest-fam-" + uuid.NewV7().String())
if err := store.Create(t.Context(), oauth.RefreshToken{
Token: token,
ClientID: client,
UserID: owner,
Scope: "read",
Family: family,
Spent: spent,
ExpiresAt: stamp.Add(time.Hour),
}); err != nil {
t.Fatalf("Create: %v", err)
}
return token
}
// A lineage in flight: one token already rotated out, one still live.
rotated := seed(doomed, true)
live := seed(doomed, false)
untouched := seed(spared, false)
// The spent marker must round-trip, since it is what tells a replay
// apart from a token that never existed.
if got, found, err := store.Get(t.Context(), rotated); err != nil ||
!found {
t.Fatalf("Get = %t, %v; want the spent record", found, err)
} else if !got.Spent {
t.Error("the spent marker did not round-trip")
} else if got.Family != doomed {
t.Errorf("got family %v; want %v", got.Family, doomed)
}
if err := store.DeleteForFamily(t.Context(), doomed); err != nil {
t.Fatalf("DeleteForFamily: %v", err)
}
for _, token := range []oauth.Digest{rotated, live} {
if _, found, _ := store.Get(t.Context(), token); found {
t.Error("a token survived its family's revocation")
}
}
if _, found, _ := store.Get(t.Context(), untouched); !found {
t.Error("the revocation reached an unrelated family")
}
// Revoking an unknown lineage is a no-op, not an error.
if err := store.DeleteForFamily(t.Context(), uuid.NewV7()); err != nil {
t.Errorf("DeleteForFamily on an unknown family: %v", err)
}
}
// enrollments round-trips an authenticator. The sealed secret must
// survive byte for byte — it is a key, not text — and the confirmation
// and watermark decide whether the factor gates a login at all and
// whether a code can be replayed.
func enrollments(t *testing.T, s Store) {
store := s.Enrollments()
owner := seedUser(t, s)
if got, err := store.Get(t.Context(), owner); err != nil || got != nil {
t.Fatalf("Get on an unenrolled user = %v, %v; want nil, nil", got, err)
}
// A sealed secret is arbitrary bytes, NUL and high bytes included.
secret := []byte{0x00, 0x01, 0x7f, 0x80, 0xff, 0x00}
pending := &authn.Enrollment{
UserID: owner,
Secret: secret,
Algorithm: "SHA256",
Digits: 8,
Period: 60,
CreatedAt: stamp,
}
if err := store.Put(t.Context(), pending); err != nil {
t.Fatalf("Put: %v", err)
}
got, err := store.Get(t.Context(), owner)
if err != nil || got == nil {
t.Fatalf("Get = %v, %v; want the enrollment", got, err)
}
if !slices.Equal(got.Secret, secret) {
t.Errorf("the sealed secret did not round-trip: %v", got.Secret)
}
if got.Algorithm != "SHA256" || got.Digits != 8 || got.Period != 60 {
t.Errorf("the parameters did not round-trip: %+v", got)
}
// An unconfirmed enrollment must read back as unconfirmed, or a
// mis-scanned QR would start gating logins.
if got.Confirmed() {
t.Error("a pending enrollment reads as confirmed")
}
// Put upserts: a user holds at most one authenticator.
got.ConfirmedAt = stamp.Add(time.Minute)
got.LastCounter = 12345
if err := store.Put(t.Context(), got); err != nil {
t.Fatalf("Put (update): %v", err)
}
after, err := store.Get(t.Context(), owner)
if err != nil || after == nil {
t.Fatalf("Get after update = %v, %v", after, err)
}
if !after.Confirmed() || !after.ConfirmedAt.Equal(got.ConfirmedAt) {
t.Errorf("the confirmation did not persist: %+v", after)
}
if after.LastCounter != 12345 {
t.Errorf("got watermark %d; want 12345", after.LastCounter)
}
deleted, err := store.Delete(t.Context(), owner)
if err != nil || !deleted {
t.Fatalf("Delete = %t, %v; want true, nil", deleted, err)
}
if deleted, _ := store.Delete(t.Context(), owner); deleted {
t.Error("a second Delete should report nothing removed")
}
}
// recoveryCodes pins single-use redemption and the owner-scoped queries
// the remaining count and regeneration rest on.
func recoveryCodes(t *testing.T, s Store) {
store := s.RecoveryCodes()
mine, other := seedUser(t, s), seedUser(t, s)
issue := func(owner uuid.UUID) string {
t.Helper()
id := "drivertest-rc-" + uuid.NewV7().String()
if err := store.Create(t.Context(), authn.Code{
ID: id,
Owner: owner,
CreatedAt: stamp,
}); err != nil {
t.Fatalf("Create: %v", err)
}
return id
}
held := []string{issue(mine), issue(mine), issue(mine)}
theirs := issue(other)
mineHeld, err := store.ListForOwner(t.Context(), mine)
if err != nil {
t.Fatalf("ListForOwner: %v", err)
}
if len(mineHeld) != len(held) {
t.Errorf("got %d codes; want %d", len(mineHeld), len(held))
}
// Redemption is single-use: of two deletions exactly one reports it
// removed the record.
deleted, err := store.Delete(t.Context(), held[0])
if err != nil || !deleted {
t.Fatalf("Delete = %t, %v; want true, nil", deleted, err)
}
if again, _ := store.Delete(t.Context(), held[0]); again {
t.Error("a spent code was deleted twice")
}
if remaining, _ := store.ListForOwner(t.Context(), mine); len(
remaining,
) != len(held)-1 {
t.Errorf("got %d remaining; want %d", len(remaining), len(held)-1)
}
// Regenerating replaces the whole set, and reaches nobody else's.
if err := store.DeleteForOwner(t.Context(), mine); err != nil {
t.Fatalf("DeleteForOwner: %v", err)
}
if remaining, _ := store.ListForOwner(t.Context(), mine); len(
remaining,
) != 0 {
t.Errorf("got %d codes; want the set replaced", len(remaining))
}
if _, found, _ := store.Get(t.Context(), theirs); !found {
t.Error("the replacement reached another owner's codes")
}
}
// avatarUploads pins the pending upload grant lifecycle: one grant per
// principal, an atomic claim deciding races, key-qualified deletion, and
// expiry listing. The avatar engine's object eviction is only truthful
// if every one of these holds.
func avatarUploads(t *testing.T, s Store) {
store := s.AvatarUploads()
owner := uuid.NewV7()
// A fresh grant displaces nothing.
prior, err := store.Upsert(t.Context(), avatar.Pending{
Scope: avatar.ScopeUser,
OwnerID: owner,
Key: "avatars/user/a",
ExpiresAt: stamp.Add(time.Hour),
})
if err != nil || prior != "" {
t.Fatalf("first Upsert = %q, %v; want no prior", prior, err)
}
// A replacement names the grant it displaced.
prior, err = store.Upsert(t.Context(), avatar.Pending{
Scope: avatar.ScopeUser,
OwnerID: owner,
Key: "avatars/user/b",
ExpiresAt: stamp.Add(time.Hour),
})
if err != nil || prior != "avatars/user/a" {
t.Fatalf("second Upsert = %q, %v; want the displaced key",
prior, err)
}
// Scopes do not collide: a team grant under the same UUID is its own.
if _, err := store.Upsert(t.Context(), avatar.Pending{
Scope: avatar.ScopeTeam,
OwnerID: owner,
Key: "avatars/team/x",
ExpiresAt: stamp.Add(time.Hour),
}); err != nil {
t.Fatalf("team Upsert: %v", err)
}
// The claim returns the grant exactly once.
p, err := store.Claim(t.Context(), avatar.ScopeUser, owner)
if err != nil || p == nil || p.Key != "avatars/user/b" {
t.Fatalf("Claim = %+v, %v; want the pending grant", p, err)
}
if p, err := store.Claim(
t.Context(), avatar.ScopeUser, owner,
); err != nil || p != nil {
t.Fatalf("second Claim = %+v, %v; want nothing", p, err)
}
// Deletion is key-qualified, so a stale sweep cannot reap a grant
// issued after its listing.
if _, err := store.Upsert(t.Context(), avatar.Pending{
Scope: avatar.ScopeUser,
OwnerID: owner,
Key: "avatars/user/c",
ExpiresAt: stamp.Add(time.Hour),
}); err != nil {
t.Fatalf("Upsert: %v", err)
}
if ok, err := store.Delete(
t.Context(), avatar.ScopeUser, owner, "avatars/user/stale",
); err != nil || ok {
t.Fatalf("stale Delete = %t, %v; want no deletion", ok, err)
}
if ok, err := store.Delete(
t.Context(), avatar.ScopeUser, owner, "avatars/user/c",
); err != nil || !ok {
t.Fatalf("Delete = %t, %v; want deletion", ok, err)
}
if ok, err := store.Delete(
t.Context(), avatar.ScopeUser, owner, "avatars/user/c",
); err != nil || ok {
t.Fatalf("repeat Delete = %t, %v; want no deletion", ok, err)
}
// Only lapsed grants list as expired.
fresh := uuid.NewV7()
lapsed := uuid.NewV7()
for _, p := range []avatar.Pending{
{
Scope: avatar.ScopeUser,
OwnerID: fresh,
Key: "avatars/user/fresh",
ExpiresAt: stamp.Add(time.Hour),
},
{
Scope: avatar.ScopeUser,
OwnerID: lapsed,
Key: "avatars/user/lapsed",
ExpiresAt: stamp.Add(-time.Hour),
},
} {
if _, err := store.Upsert(t.Context(), p); err != nil {
t.Fatalf("Upsert: %v", err)
}
}
expired, err := store.ListExpired(t.Context(), stamp, 10)
if err != nil {
t.Fatalf("ListExpired: %v", err)
}
for _, p := range expired {
if p.Key == "avatars/user/fresh" {
t.Error("an unexpired grant listed as expired")
}
}
found := false
for _, p := range expired {
found = found || p.Key == "avatars/user/lapsed"
}
if !found {
t.Error("the lapsed grant did not list as expired")
}
}
// avatarSwap pins the atomic avatar exchange on the user record: the
// swap reports the key it displaced, a plain update never touches the
// key, and an unknown user reports as such.
func avatarSwap(t *testing.T, s Store) {
store := s.Users()
id := seedUser(t, s)
if _, found, err := store.SetAvatar(
t.Context(), uuid.NewV7(), "avatars/user/x",
); err != nil || found {
t.Fatalf("unknown SetAvatar = %t, %v; want not found", found, err)
}
prior, found, err := store.SetAvatar(
t.Context(), id, "avatars/user/one",
)
if err != nil || !found || prior != "" {
t.Fatalf("first SetAvatar = %q, %t, %v; want empty prior",
prior, found, err)
}
prior, found, err = store.SetAvatar(
t.Context(), id, "avatars/user/two",
)
if err != nil || !found || prior != "avatars/user/one" {
t.Fatalf("second SetAvatar = %q, %t, %v; want the displaced key",
prior, found, err)
}
// A plain update must not clobber the swapped key: the avatar column
// belongs to the swap alone, or the engine's eviction bookkeeping
// stops being truthful.
u, err := store.Get(t.Context(), id)
if err != nil || u == nil {
t.Fatalf("Get = %v, %v", u, err)
}
u.Avatar = "avatars/user/clobbered"
u.Name = "Renamed"
if err := store.Update(t.Context(), u); err != nil {
t.Fatalf("Update: %v", err)
}
got, _ := store.Get(t.Context(), id)
if got.Name != "Renamed" {
t.Errorf("update did not persist: %+v", got)
}
if got.Avatar != "avatars/user/two" {
t.Errorf(
"got avatar %q after update; want the swapped key untouched",
got.Avatar,
)
}
// Clearing reports the key taken out of service.
prior, found, err = store.SetAvatar(t.Context(), id, "")
if err != nil || !found || prior != "avatars/user/two" {
t.Fatalf("clearing SetAvatar = %q, %t, %v; want the live key",
prior, found, err)
}
if got, _ := store.Get(t.Context(), id); got.Avatar != "" {
t.Errorf("got avatar %q after clearing; want none", got.Avatar)
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package mock
import (
"bytes"
"context"
"sync"
"uuid"
"github.com/deep-rent/nexus/eco/iam/authn"
)
// enrollments implements [authn.Store] over a map keyed by user.
type enrollments struct {
mu sync.Mutex
items map[uuid.UUID]*authn.Enrollment
}
func newEnrollments() *enrollments {
return &enrollments{items: make(map[uuid.UUID]*authn.Enrollment)}
}
// clone detaches a record from the store, so a caller mutating what it
// read cannot reach in — the SQL driver hands back copies and the two
// must behave alike.
func cloneEnrollment(e *authn.Enrollment) *authn.Enrollment {
c := *e
c.Secret = bytes.Clone(e.Secret)
return &c
}
// Get implements [authn.Store].
func (s *enrollments) Get(
_ context.Context,
userID uuid.UUID,
) (*authn.Enrollment, error) {
s.mu.Lock()
defer s.mu.Unlock()
e, ok := s.items[userID]
if !ok {
return nil, nil
}
return cloneEnrollment(e), nil
}
// Put implements [authn.Store].
func (s *enrollments) Put(_ context.Context, e *authn.Enrollment) error {
s.mu.Lock()
defer s.mu.Unlock()
s.items[e.UserID] = cloneEnrollment(e)
return nil
}
// Delete implements [authn.Store].
func (s *enrollments) Delete(
_ context.Context,
userID uuid.UUID,
) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.items[userID]; !ok {
return false, nil
}
delete(s.items, userID)
return true, nil
}
// dropUser removes the user's enrollment, mirroring the FK cascade of the
// PostgreSQL driver.
func (s *enrollments) dropUser(id uuid.UUID) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.items, id)
}
var _ authn.Store = (*enrollments)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package mock
import (
"context"
"sync"
"time"
"uuid"
"github.com/deep-rent/nexus/eco/iam/avatar"
)
// grantRef addresses one principal's pending upload.
type grantRef struct {
scope avatar.Scope
owner uuid.UUID
}
// avatars implements [avatar.Store] in memory.
type avatars struct {
mu sync.Mutex
grants map[grantRef]avatar.Pending
}
func newAvatars() *avatars {
return &avatars{grants: make(map[grantRef]avatar.Pending)}
}
// Upsert implements [avatar.Store].
func (s *avatars) Upsert(
_ context.Context,
p avatar.Pending,
) (prior string, err error) {
s.mu.Lock()
defer s.mu.Unlock()
ref := grantRef{scope: p.Scope, owner: p.OwnerID}
if old, ok := s.grants[ref]; ok {
prior = old.Key
}
s.grants[ref] = p
return prior, nil
}
// Claim implements [avatar.Store].
func (s *avatars) Claim(
_ context.Context,
scope avatar.Scope,
ownerID uuid.UUID,
) (*avatar.Pending, error) {
s.mu.Lock()
defer s.mu.Unlock()
ref := grantRef{scope: scope, owner: ownerID}
p, ok := s.grants[ref]
if !ok {
return nil, nil
}
delete(s.grants, ref)
return &p, nil
}
// Delete implements [avatar.Store].
func (s *avatars) Delete(
_ context.Context,
scope avatar.Scope,
ownerID uuid.UUID,
key string,
) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
ref := grantRef{scope: scope, owner: ownerID}
p, ok := s.grants[ref]
if !ok || p.Key != key {
return false, nil
}
delete(s.grants, ref)
return true, nil
}
// ListExpired implements [avatar.Store].
func (s *avatars) ListExpired(
_ context.Context,
now time.Time,
limit int,
) ([]avatar.Pending, error) {
s.mu.Lock()
defer s.mu.Unlock()
var out []avatar.Pending
for _, p := range s.grants {
if len(out) == limit {
break
}
if !p.ExpiresAt.After(now) {
out = append(out, p)
}
}
return out, nil
}
var _ avatar.Store = (*avatars)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package mock
import (
"bytes"
"context"
"fmt"
"slices"
"strings"
"sync"
"time"
"uuid"
"github.com/deep-rent/nexus/dat/page"
"github.com/deep-rent/nexus/dat/search"
"github.com/deep-rent/nexus/eco/iam/client"
"github.com/deep-rent/nexus/eco/iam/passkey"
"github.com/deep-rent/nexus/eco/iam/user"
)
// paginate sorts the matched records as the normalized query asks and cuts
// out the requested page, reporting the total alongside it. The user, client
// and team listings share a sort vocabulary of a name and a creation time,
// so the comparators cover both keys; ordering breaks ties on ID, so a page
// boundary never drops or repeats a record just because two were created in
// the same instant.
func paginate[V any](
list []V,
q search.Query,
name func(V) string,
created func(V) time.Time,
id func(V) uuid.UUID,
) page.Page[V] {
// The user, client and team listings share a sort vocabulary of a name
// and a creation time, so one comparator covers all three. The last
// key's direction carries the ID tiebreaker, matching the ORDER BY the
// PostgreSQL driver emits.
desc := len(q.Sort) > 0 && q.Sort[len(q.Sort)-1].Desc
slices.SortFunc(list, func(a, b V) int {
for _, o := range q.Sort {
var c int
if o.Field == user.ByName {
c = strings.Compare(
strings.ToLower(name(a)),
strings.ToLower(name(b)),
)
} else {
c = created(a).Compare(created(b))
}
if o.Desc {
c = -c
}
if c != 0 {
return c
}
}
x, y := id(a), id(b)
c := bytes.Compare(x[:], y[:])
if desc {
c = -c
}
return c
})
w := q.Window()
total := len(list)
lo := min(w.Offset, total)
return page.Of(list[lo:min(lo+w.Limit, total)], w, total)
}
// holds reports whether a record whose field carries the given value
// satisfies every constraint the query places on it. A field with no
// constraint restricts nothing. Only the boolean fields of this driver are
// filterable, so the two equality operators are the whole vocabulary.
//
// Every constraint must hold, not just the first: the SQL driver conjoins
// them, and a caller writing a contradictory pair must get the same empty
// listing from both.
func holds(q search.Query, field string, value bool) bool {
for _, c := range q.FilterAll(field) {
if (value == c.Bool()) != (c.Op == search.Eq) {
return false
}
}
return true
}
// users implements [user.Store] in memory.
type users struct {
mu sync.Mutex
users map[uuid.UUID]*user.User
identities []user.Identity
// onDelete mirrors the FK cascades of the PostgreSQL driver: the team
// store registers itself here so a deleted user also leaves their
// teams. It runs outside the mutex, so the callee may lock freely.
onDelete func(id uuid.UUID)
}
func newUsers() *users {
return &users{users: make(map[uuid.UUID]*user.User)}
}
func cloneUser(u *user.User) *user.User {
c := *u
c.Password = bytes.Clone(u.Password)
c.Roles = slices.Clone(u.Roles)
c.Factors = slices.Clone(u.Factors)
c.Locales = slices.Clone(u.Locales)
return &c
}
// Create implements [user.Store].
func (s *users) Create(_ context.Context, u *user.User) error {
s.mu.Lock()
defer s.mu.Unlock()
for _, e := range s.users {
if e.Email == u.Email ||
(u.RecoveryEmail != "" &&
e.RecoveryEmail == u.RecoveryEmail) {
return fmt.Errorf("%w: user", user.ErrDuplicate)
}
}
s.users[u.ID] = cloneUser(u)
return nil
}
// Get implements [user.Store].
func (s *users) Get(
_ context.Context,
id uuid.UUID,
) (*user.User, error) {
s.mu.Lock()
defer s.mu.Unlock()
if u, ok := s.users[id]; ok {
return cloneUser(u), nil
}
return nil, nil
}
// GetByEmail implements [user.Store].
func (s *users) GetByEmail(
_ context.Context,
email string,
) (*user.User, error) {
if email == "" {
return nil, nil
}
s.mu.Lock()
defer s.mu.Unlock()
for _, u := range s.users {
if u.Email == email {
return cloneUser(u), nil
}
}
return nil, nil
}
// GetByRecoveryEmail implements [user.Store].
func (s *users) GetByRecoveryEmail(
_ context.Context,
email string,
) (*user.User, error) {
if email == "" {
return nil, nil
}
s.mu.Lock()
defer s.mu.Unlock()
for _, u := range s.users {
if u.RecoveryEmail == email {
return cloneUser(u), nil
}
}
return nil, nil
}
// Update implements [user.Store].
// Update implements [user.Store]. The avatar key is deliberately
// preserved: it changes only through [users.SetAvatar], matching the
// PostgreSQL driver's contract.
func (s *users) Update(_ context.Context, u *user.User) error {
s.mu.Lock()
defer s.mu.Unlock()
stored, ok := s.users[u.ID]
if !ok {
return nil
}
for _, e := range s.users {
if e.ID != u.ID && (e.Email == u.Email ||
(u.RecoveryEmail != "" &&
e.RecoveryEmail == u.RecoveryEmail)) {
return fmt.Errorf("%w: user", user.ErrDuplicate)
}
}
c := cloneUser(u)
c.Avatar = stored.Avatar
s.users[u.ID] = c
return nil
}
// SetAvatar implements [user.Store].
func (s *users) SetAvatar(
_ context.Context,
id uuid.UUID,
key string,
) (prior string, found bool, err error) {
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.users[id]
if !ok {
return "", false, nil
}
prior = u.Avatar
u.Avatar = key
return prior, true, nil
}
// SetPassword implements [user.Store].
func (s *users) SetPassword(
_ context.Context,
id uuid.UUID,
hash []byte,
changedAt time.Time,
) error {
s.mu.Lock()
defer s.mu.Unlock()
if u, ok := s.users[id]; ok {
u.Password = bytes.Clone(hash)
u.PasswordChangedAt = changedAt
}
return nil
}
// Delete implements [user.Store].
func (s *users) Delete(_ context.Context, id uuid.UUID) (bool, error) {
s.mu.Lock()
if _, ok := s.users[id]; !ok {
s.mu.Unlock()
return false, nil
}
delete(s.users, id)
s.identities = slices.DeleteFunc(
s.identities,
func(i user.Identity) bool { return i.UserID == id },
)
// Cascades run unlocked so the callee may consult this store again.
s.mu.Unlock()
if s.onDelete != nil {
s.onDelete(id)
}
return true, nil
}
// lookup resolves a user by ID for the member-listing join. ok is false
// for an unknown user.
func (s *users) lookup(id uuid.UUID) (*user.User, bool) {
s.mu.Lock()
defer s.mu.Unlock()
if u, ok := s.users[id]; ok {
return cloneUser(u), true
}
return nil, false
}
// List implements [user.Store].
// ListByRole implements [user.Store].
func (s *users) ListByRole(
_ context.Context,
role string,
limit int,
) ([]*user.User, error) {
s.mu.Lock()
defer s.mu.Unlock()
if role == "" || limit <= 0 {
return nil, nil
}
var out []*user.User
for _, u := range s.users {
if slices.Contains(u.Roles, role) {
out = append(out, cloneUser(u))
}
}
slices.SortFunc(out, func(a, b *user.User) int {
if c := strings.Compare(a.Name, b.Name); c != 0 {
return c
}
return a.ID.Compare(b.ID)
})
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
func (s *users) List(
_ context.Context,
q user.Query,
) (page.Page[*user.User], error) {
s.mu.Lock()
defer s.mu.Unlock()
q = user.Search.Normalize(q)
needle := strings.ToLower(q.Text)
var out []*user.User
for _, u := range s.users {
if !holds(q, user.FieldVerified, u.EmailVerified) {
continue
}
if !holds(q, user.FieldDisabled, u.Disabled) {
continue
}
if needle != "" &&
!strings.Contains(strings.ToLower(u.Name), needle) &&
!strings.Contains(strings.ToLower(u.Email), needle) {
continue
}
out = append(out, cloneUser(u))
}
return paginate(
out,
q,
func(u *user.User) string { return u.Name },
func(u *user.User) time.Time { return u.CreatedAt },
func(u *user.User) uuid.UUID { return u.ID },
), nil
}
// GetIdentity implements [user.Store].
func (s *users) GetIdentity(
_ context.Context,
provider, subject string,
) (*user.Identity, error) {
s.mu.Lock()
defer s.mu.Unlock()
for _, i := range s.identities {
if i.Provider == provider && i.Subject == subject {
return &i, nil
}
}
return nil, nil
}
// LinkIdentity implements [user.Store].
func (s *users) LinkIdentity(_ context.Context, id user.Identity) error {
s.mu.Lock()
defer s.mu.Unlock()
for _, i := range s.identities {
if i.Provider == id.Provider && i.Subject == id.Subject {
return fmt.Errorf("%w: identity", user.ErrDuplicate)
}
}
s.identities = append(s.identities, id)
return nil
}
// UnlinkIdentity implements [user.Store].
func (s *users) UnlinkIdentity(
_ context.Context,
provider string,
userID uuid.UUID,
) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
n := len(s.identities)
s.identities = slices.DeleteFunc(
s.identities,
func(i user.Identity) bool {
return i.Provider == provider && i.UserID == userID
},
)
return len(s.identities) < n, nil
}
// ListIdentities implements [user.Store].
func (s *users) ListIdentities(
_ context.Context,
userID uuid.UUID,
) ([]user.Identity, error) {
s.mu.Lock()
defer s.mu.Unlock()
var out []user.Identity
for _, i := range s.identities {
if i.UserID == userID {
out = append(out, i)
}
}
return out, nil
}
// clients implements [client.Store] in memory.
type clients struct {
mu sync.Mutex
clients map[uuid.UUID]*client.Client
}
func newClients() *clients {
return &clients{clients: make(map[uuid.UUID]*client.Client)}
}
func cloneClient(c *client.Client) *client.Client {
d := *c
d.RedirectURIs = slices.Clone(c.RedirectURIs)
d.Grants = slices.Clone(c.Grants)
d.Scopes = slices.Clone(c.Scopes)
d.Audience = slices.Clone(c.Audience)
return &d
}
// Create implements [client.Store].
func (s *clients) Create(_ context.Context, c *client.Client) error {
s.mu.Lock()
defer s.mu.Unlock()
s.clients[c.ID] = cloneClient(c)
return nil
}
// Get implements [client.Store].
func (s *clients) Get(
_ context.Context,
id uuid.UUID,
) (*client.Client, error) {
s.mu.Lock()
defer s.mu.Unlock()
if c, ok := s.clients[id]; ok {
return cloneClient(c), nil
}
return nil, nil
}
// Update implements [client.Store].
func (s *clients) Update(_ context.Context, c *client.Client) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.clients[c.ID]; ok {
s.clients[c.ID] = cloneClient(c)
}
return nil
}
// SetSecret implements [client.Store].
func (s *clients) SetSecret(
_ context.Context,
id uuid.UUID,
digest string,
expiresAt time.Time,
) error {
s.mu.Lock()
defer s.mu.Unlock()
if c, ok := s.clients[id]; ok {
c.SecretDigest = digest
c.SecretExpiresAt = expiresAt
}
return nil
}
// Delete implements [client.Store].
func (s *clients) Delete(_ context.Context, id uuid.UUID) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.clients[id]; !ok {
return false, nil
}
delete(s.clients, id)
return true, nil
}
// List implements [client.Store].
func (s *clients) List(
_ context.Context,
q client.Query,
) (page.Page[*client.Client], error) {
s.mu.Lock()
defer s.mu.Unlock()
q = client.Search.Normalize(q)
needle := strings.ToLower(q.Text)
var out []*client.Client
for _, c := range s.clients {
if needle == "" || strings.Contains(strings.ToLower(c.Name), needle) {
out = append(out, cloneClient(c))
}
}
return paginate(
out,
q,
func(c *client.Client) string { return c.Name },
func(c *client.Client) time.Time { return c.CreatedAt },
func(c *client.Client) uuid.UUID { return c.ID },
), nil
}
// credentials implements [passkey.CredentialStore] in memory.
type credentials struct {
mu sync.Mutex
keys map[uuid.UUID][]passkey.Passkey
now func() time.Time
}
func newCredentials() *credentials {
return &credentials{
keys: make(map[uuid.UUID][]passkey.Passkey),
now: time.Now,
}
}
// List implements [passkey.CredentialStore].
func (s *credentials) List(
_ context.Context,
owner uuid.UUID,
) ([]passkey.Passkey, error) {
s.mu.Lock()
defer s.mu.Unlock()
return slices.Clone(s.keys[owner]), nil
}
// Create implements [passkey.CredentialStore].
func (s *credentials) Create(
_ context.Context,
owner uuid.UUID,
name string,
cred passkey.Credential,
) error {
s.mu.Lock()
defer s.mu.Unlock()
s.keys[owner] = append(s.keys[owner], passkey.Passkey{
Name: name,
CreatedAt: s.now(),
Credential: cred,
})
return nil
}
// Update implements [passkey.CredentialStore].
func (s *credentials) Update(
_ context.Context,
owner uuid.UUID,
cred passkey.Credential,
) error {
s.mu.Lock()
defer s.mu.Unlock()
for i, k := range s.keys[owner] {
if bytes.Equal(k.Credential.ID, cred.ID) {
s.keys[owner][i].Credential = cred
}
}
return nil
}
// Delete implements [passkey.CredentialStore].
func (s *credentials) Delete(
_ context.Context,
owner uuid.UUID,
credentialID []byte,
) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
n := len(s.keys[owner])
s.keys[owner] = slices.DeleteFunc(
s.keys[owner],
func(k passkey.Passkey) bool {
return bytes.Equal(k.Credential.ID, credentialID)
},
)
return len(s.keys[owner]) < n, nil
}
// Rename implements [passkey.CredentialStore].
func (s *credentials) Rename(
_ context.Context,
owner uuid.UUID,
credentialID []byte,
name string,
) error {
s.mu.Lock()
defer s.mu.Unlock()
for i, k := range s.keys[owner] {
if bytes.Equal(k.Credential.ID, credentialID) {
s.keys[owner][i].Name = name
}
}
return nil
}
var (
_ user.Store = (*users)(nil)
_ client.Store = (*clients)(nil)
_ passkey.CredentialStore = (*credentials)(nil)
)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package mock
import (
"context"
"time"
"uuid"
"github.com/deep-rent/nexus/eco/iam/artifact"
"github.com/deep-rent/nexus/eco/iam/authn"
"github.com/deep-rent/nexus/eco/iam/avatar"
"github.com/deep-rent/nexus/eco/iam/client"
"github.com/deep-rent/nexus/eco/iam/flow"
"github.com/deep-rent/nexus/eco/iam/oauth"
"github.com/deep-rent/nexus/eco/iam/otp"
"github.com/deep-rent/nexus/eco/iam/passkey"
"github.com/deep-rent/nexus/eco/iam/session"
"github.com/deep-rent/nexus/eco/iam/team"
"github.com/deep-rent/nexus/eco/iam/ticket"
"github.com/deep-rent/nexus/eco/iam/trust"
"github.com/deep-rent/nexus/eco/iam/user"
)
// Store implements every persistence contract of the IAM service in
// process memory. Create instances with [New]; the zero value is not
// usable.
type Store struct {
users *users
clients *clients
credentials *credentials
sessions *owned[session.Record]
authCodes *artifact.Map[oauth.Digest, oauth.AuthCode]
refreshTokens *refreshTokens
deviceCodes *deviceCodes
challenges *artifact.Map[string, otp.Challenge]
flows *artifact.Map[string, flow.Transaction]
trust *owned[trust.Record]
ceremonies *artifact.Map[string, passkey.Ceremony]
enrollments *enrollments
recovery *owned[authn.Code]
tickets *owned[ticket.Ticket]
teams *teams
avatars *avatars
}
// New creates an empty in-memory [Store].
func New() *Store {
s := &Store{
users: newUsers(),
clients: newClients(),
credentials: newCredentials(),
sessions: artifact.NewOwnedMap(
func(r session.Record) string { return r.ID },
func(r session.Record) uuid.UUID { return r.Owner },
),
authCodes: artifact.NewMap(
func(c oauth.AuthCode) oauth.Digest { return c.Code },
),
refreshTokens: &refreshTokens{prunable[
oauth.Digest, oauth.RefreshToken,
]{
Map: artifact.NewMap(
func(r oauth.RefreshToken) oauth.Digest { return r.Token },
),
}},
deviceCodes: &deviceCodes{Map: artifact.NewMap(
func(c oauth.DeviceCode) oauth.Digest { return c.DeviceCode },
)},
challenges: artifact.NewMap(
func(c otp.Challenge) string { return c.ID },
),
flows: artifact.NewMap(
func(t flow.Transaction) string { return t.ID },
),
trust: artifact.NewOwnedMap(
func(r trust.Record) string { return r.ID },
func(r trust.Record) uuid.UUID { return r.Owner },
),
ceremonies: artifact.NewMap(
func(c passkey.Ceremony) string { return c.ID },
),
tickets: artifact.NewOwnedMap(
func(t ticket.Ticket) string { return t.ID },
func(t ticket.Ticket) uuid.UUID { return t.Owner },
),
enrollments: newEnrollments(),
avatars: newAvatars(),
recovery: artifact.NewOwnedMap(
func(c authn.Code) string { return c.ID },
func(c authn.Code) uuid.UUID { return c.Owner },
),
}
s.teams = newTeams(s.users)
s.users.onDelete = func(id uuid.UUID) {
s.teams.dropUser(id)
s.enrollments.dropUser(id)
_ = s.recovery.DeleteForOwner(context.Background(), id)
}
return s
}
// Users returns the durable user registry.
func (s *Store) Users() user.Store { return s.users }
// Clients returns the durable client registry.
func (s *Store) Clients() client.Store { return s.clients }
// Credentials returns the durable passkey credential store.
func (s *Store) Credentials() passkey.CredentialStore { return s.credentials }
// Sessions returns the login session store.
func (s *Store) Sessions() session.Store { return s.sessions }
// AuthCodes returns the authorization code store.
func (s *Store) AuthCodes() artifact.Store[oauth.Digest, oauth.AuthCode] {
return s.authCodes
}
// RefreshTokens returns the refresh token store.
func (s *Store) RefreshTokens() oauth.RefreshTokenStore {
return s.refreshTokens
}
// DeviceCodes returns the device code store.
func (s *Store) DeviceCodes() oauth.DeviceCodeStore { return s.deviceCodes }
// Challenges returns the one-time password challenge store.
func (s *Store) Challenges() otp.Store { return s.challenges }
// Flows returns the login flow transaction store.
func (s *Store) Flows() flow.Store { return s.flows }
// Trust returns the device trust store.
func (s *Store) Trust() trust.Store { return s.trust }
// Ceremonies returns the WebAuthn ceremony store.
func (s *Store) Ceremonies() passkey.Store { return s.ceremonies }
// Tickets returns the action ticket store.
func (s *Store) Tickets() ticket.Store { return s.tickets }
// Teams returns the durable team registry.
func (s *Store) Teams() team.Store { return s.teams }
// AvatarUploads returns the pending picture upload store.
func (s *Store) AvatarUploads() avatar.Store { return s.avatars }
// Enrollments returns the authenticator enrollment store.
func (s *Store) Enrollments() authn.Store { return s.enrollments }
// RecoveryCodes returns the recovery code store.
func (s *Store) RecoveryCodes() authn.Codes { return s.recovery }
// DeleteRefreshTokensForUser revokes every refresh token held on behalf of
// the user.
func (s *Store) DeleteRefreshTokensForUser(
ctx context.Context,
userID uuid.UUID,
) error {
return s.refreshTokens.deleteWhere(ctx, func(r oauth.RefreshToken) bool {
return r.UserID == userID
})
}
// DeleteRefreshTokensForClient revokes every refresh token issued to the
// client.
func (s *Store) DeleteRefreshTokensForClient(
ctx context.Context,
clientID uuid.UUID,
) error {
return s.refreshTokens.deleteWhere(ctx, func(r oauth.RefreshToken) bool {
return r.ClientID == clientID
})
}
// owned is an [artifact.OwnedMap] over the driver's UUID owners, shared by
// the session, trust, and ticket stores.
type owned[V any] = artifact.OwnedMap[string, V, uuid.UUID]
// refreshTokens extends the prunable map with the lineage-scoped
// revocation of [oauth.RefreshTokenStore].
type refreshTokens struct {
prunable[oauth.Digest, oauth.RefreshToken]
}
// DeleteForFamily implements [oauth.RefreshTokenStore].
func (s *refreshTokens) DeleteForFamily(
ctx context.Context,
family uuid.UUID,
) error {
return s.deleteWhere(ctx, func(r oauth.RefreshToken) bool {
return r.Family == family
})
}
// prunable extends an [artifact.Map] with predicate-scoped bulk deletion,
// which the revocation surfaces sweep with.
type prunable[K ~string, V any] struct {
*artifact.Map[K, V]
}
// deleteWhere removes every record matching the predicate.
func (s *prunable[K, V]) deleteWhere(
ctx context.Context,
match func(V) bool,
) error {
if s.Err != nil {
return s.Err
}
s.Range(func(id K, v V) bool {
if match(v) {
_, _ = s.Delete(ctx, id)
}
return true
})
return nil
}
// deviceCodes extends the artifact map with the user-code lookup and poll
// bookkeeping of [oauth.DeviceCodeStore].
type deviceCodes struct {
*artifact.Map[oauth.Digest, oauth.DeviceCode]
}
// GetByUserCode implements [oauth.DeviceCodeStore].
func (s *deviceCodes) GetByUserCode(
_ context.Context,
userCode oauth.Digest,
) (v oauth.DeviceCode, found bool, err error) {
if s.Err != nil {
return v, false, s.Err
}
s.Range(func(_ oauth.Digest, c oauth.DeviceCode) bool {
if c.UserCode == userCode {
v, found = c, true
return false
}
return true
})
return v, found, nil
}
// Touch implements [oauth.DeviceCodeStore].
func (s *deviceCodes) Touch(
ctx context.Context,
code oauth.Digest,
lastPolledAt time.Time,
) error {
c, found, err := s.Get(ctx, code)
if err != nil || !found {
return err
}
c.LastPolledAt = lastPolledAt
return s.Update(ctx, c)
}
var (
_ session.Store = (*owned[session.Record])(nil)
_ trust.Store = (*owned[trust.Record])(nil)
_ oauth.DeviceCodeStore = (*deviceCodes)(nil)
_ oauth.RefreshTokenStore = (*refreshTokens)(nil)
)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package mock
import (
"bytes"
"context"
"fmt"
"slices"
"strings"
"sync"
"time"
"uuid"
"github.com/deep-rent/nexus/dat/page"
"github.com/deep-rent/nexus/eco/iam/team"
)
// teams implements [team.Store] in memory. Member listings join against
// the driver's user registry, mirroring the SQL join of the PostgreSQL
// driver.
type teams struct {
mu sync.Mutex
teams map[uuid.UUID]*team.Team
members []team.Membership
invitations map[uuid.UUID]*team.Invitation
users *users
}
func newTeams(u *users) *teams {
return &teams{
teams: make(map[uuid.UUID]*team.Team),
invitations: make(map[uuid.UUID]*team.Invitation),
users: u,
}
}
// CreateTeam implements [team.Store].
func (s *teams) CreateTeam(_ context.Context, t *team.Team) error {
s.mu.Lock()
defer s.mu.Unlock()
c := *t
s.teams[t.ID] = &c
return nil
}
// GetTeam implements [team.Store].
func (s *teams) GetTeam(
_ context.Context,
id uuid.UUID,
) (*team.Team, error) {
s.mu.Lock()
defer s.mu.Unlock()
if t, ok := s.teams[id]; ok {
c := *t
return &c, nil
}
return nil, nil
}
// UpdateTeam implements [team.Store].
// UpdateTeam implements [team.Store]. The logo key is deliberately
// preserved: it changes only through [teams.SetLogo], matching the
// PostgreSQL driver's contract.
func (s *teams) UpdateTeam(_ context.Context, t *team.Team) error {
s.mu.Lock()
defer s.mu.Unlock()
stored, ok := s.teams[t.ID]
if !ok {
return nil
}
c := *t
c.Logo = stored.Logo
s.teams[t.ID] = &c
return nil
}
// SetLogo implements [team.Store].
func (s *teams) SetLogo(
_ context.Context,
id uuid.UUID,
key string,
) (prior string, found bool, err error) {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.teams[id]
if !ok {
return "", false, nil
}
prior = t.Logo
t.Logo = key
return prior, true, nil
}
// DeleteTeam implements [team.Store].
func (s *teams) DeleteTeam(
_ context.Context,
id uuid.UUID,
) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.teams[id]; !ok {
return false, nil
}
delete(s.teams, id)
s.members = slices.DeleteFunc(
s.members,
func(m team.Membership) bool { return m.TeamID == id },
)
for invID, inv := range s.invitations {
if inv.TeamID == id {
delete(s.invitations, invID)
}
}
return true, nil
}
// ListTeams implements [team.Store].
func (s *teams) ListTeams(
_ context.Context,
q team.Query,
) (page.Page[*team.Team], error) {
s.mu.Lock()
defer s.mu.Unlock()
q = team.Search.Normalize(q)
needle := strings.ToLower(q.Text)
var out []*team.Team
for _, t := range s.teams {
if needle == "" || strings.Contains(strings.ToLower(t.Name), needle) {
c := *t
out = append(out, &c)
}
}
return paginate(
out,
q,
func(t *team.Team) string { return t.Name },
func(t *team.Team) time.Time { return t.CreatedAt },
func(t *team.Team) uuid.UUID { return t.ID },
), nil
}
// CountFounded implements [team.Store].
func (s *teams) CountFounded(
_ context.Context,
founder uuid.UUID,
) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
n := 0
for _, t := range s.teams {
if t.Founder == founder {
n++
}
}
return n, nil
}
// AddMember implements [team.Store].
func (s *teams) AddMember(_ context.Context, m team.Membership) error {
s.mu.Lock()
defer s.mu.Unlock()
for _, e := range s.members {
if e.TeamID == m.TeamID && e.UserID == m.UserID {
return fmt.Errorf("%w: membership", team.ErrDuplicate)
}
}
s.members = append(s.members, m)
return nil
}
// SetOwner implements [team.Store].
func (s *teams) SetOwner(
_ context.Context,
teamID, userID uuid.UUID,
owner bool,
) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
for i, m := range s.members {
if m.TeamID == teamID && m.UserID == userID {
s.members[i].Owner = owner
return true, nil
}
}
return false, nil
}
// RemoveMember implements [team.Store].
func (s *teams) RemoveMember(
_ context.Context,
teamID, userID uuid.UUID,
) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
n := len(s.members)
s.members = slices.DeleteFunc(
s.members,
func(m team.Membership) bool {
return m.TeamID == teamID && m.UserID == userID
},
)
return len(s.members) < n, nil
}
// GetMembership implements [team.Store].
func (s *teams) GetMembership(
_ context.Context,
teamID, userID uuid.UUID,
) (*team.Membership, error) {
s.mu.Lock()
defer s.mu.Unlock()
for _, m := range s.members {
if m.TeamID == teamID && m.UserID == userID {
c := m
return &c, nil
}
}
return nil, nil
}
// ListMembers implements [team.Store].
func (s *teams) ListMembers(
_ context.Context,
teamID uuid.UUID,
) ([]team.Member, error) {
s.mu.Lock()
defer s.mu.Unlock()
var out []team.Member
for _, m := range s.members {
if m.TeamID != teamID {
continue
}
member := team.Member{
UserID: m.UserID,
Owner: m.Owner,
CreatedAt: m.CreatedAt,
}
if u, ok := s.users.lookup(m.UserID); ok {
member.Email = u.Email
member.Name = u.Name
member.DisplayName = u.DisplayName
}
out = append(out, member)
}
slices.SortFunc(out, func(a, b team.Member) int {
// Owners first, then oldest membership first, IDs breaking ties.
if a.Owner != b.Owner {
if a.Owner {
return -1
}
return 1
}
if c := a.CreatedAt.Compare(b.CreatedAt); c != 0 {
return c
}
return bytes.Compare(a.UserID[:], b.UserID[:])
})
return out, nil
}
// ListAffiliations implements [team.Store].
func (s *teams) ListAffiliations(
_ context.Context,
userID uuid.UUID,
) ([]team.Affiliation, error) {
s.mu.Lock()
defer s.mu.Unlock()
var out []team.Affiliation
for _, m := range s.members {
if m.UserID != userID {
continue
}
t, ok := s.teams[m.TeamID]
if !ok {
continue
}
out = append(out, team.Affiliation{
Team: *t,
Owner: m.Owner,
CreatedAt: m.CreatedAt,
})
}
slices.SortFunc(out, func(a, b team.Affiliation) int {
if c := a.CreatedAt.Compare(b.CreatedAt); c != 0 {
return c
}
return bytes.Compare(a.Team.ID[:], b.Team.ID[:])
})
return out, nil
}
// ListTeamIDs implements [team.Store].
func (s *teams) ListTeamIDs(
_ context.Context,
userID uuid.UUID,
) ([]uuid.UUID, error) {
s.mu.Lock()
defer s.mu.Unlock()
var out []uuid.UUID
for _, m := range s.members {
if m.UserID == userID {
out = append(out, m.TeamID)
}
}
slices.SortFunc(out, func(a, b uuid.UUID) int {
return bytes.Compare(a[:], b[:])
})
return out, nil
}
// CountOwners implements [team.Store].
func (s *teams) CountOwners(
_ context.Context,
teamID uuid.UUID,
) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
n := 0
for _, m := range s.members {
if m.TeamID == teamID && m.Owner {
n++
}
}
return n, nil
}
// CountSeats implements [team.Store].
func (s *teams) CountSeats(
_ context.Context,
teamID uuid.UUID,
) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
n := 0
for _, m := range s.members {
if m.TeamID == teamID {
n++
}
}
for _, inv := range s.invitations {
if inv.TeamID == teamID && inv.Status == team.StatusPending {
n++
}
}
return n, nil
}
// CreateInvitation implements [team.Store].
func (s *teams) CreateInvitation(
_ context.Context,
inv *team.Invitation,
) error {
s.mu.Lock()
defer s.mu.Unlock()
for _, e := range s.invitations {
if e.TeamID == inv.TeamID && e.Email == inv.Email {
return fmt.Errorf("%w: invitation", team.ErrDuplicate)
}
}
c := *inv
s.invitations[inv.ID] = &c
return nil
}
// GetInvitation implements [team.Store].
func (s *teams) GetInvitation(
_ context.Context,
id uuid.UUID,
) (*team.Invitation, error) {
s.mu.Lock()
defer s.mu.Unlock()
if inv, ok := s.invitations[id]; ok {
c := *inv
return &c, nil
}
return nil, nil
}
// GetInvitationByEmail implements [team.Store].
func (s *teams) GetInvitationByEmail(
_ context.Context,
teamID uuid.UUID,
email string,
) (*team.Invitation, error) {
s.mu.Lock()
defer s.mu.Unlock()
for _, inv := range s.invitations {
if inv.TeamID == teamID && inv.Email == email {
c := *inv
return &c, nil
}
}
return nil, nil
}
// GetInvitationByToken implements [team.Store].
func (s *teams) GetInvitationByToken(
_ context.Context,
digest string,
) (*team.Invitation, error) {
s.mu.Lock()
defer s.mu.Unlock()
for _, inv := range s.invitations {
if inv.Token == digest {
c := *inv
return &c, nil
}
}
return nil, nil
}
// UpdateInvitation implements [team.Store].
func (s *teams) UpdateInvitation(
_ context.Context,
inv *team.Invitation,
) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.invitations[inv.ID]; !ok {
return nil
}
c := *inv
s.invitations[inv.ID] = &c
return nil
}
// DeleteInvitation implements [team.Store].
func (s *teams) DeleteInvitation(
_ context.Context,
id uuid.UUID,
) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.invitations[id]; !ok {
return false, nil
}
delete(s.invitations, id)
return true, nil
}
// ListInvitations implements [team.Store].
func (s *teams) ListInvitations(
_ context.Context,
teamID uuid.UUID,
) ([]team.Invitation, error) {
s.mu.Lock()
defer s.mu.Unlock()
var out []team.Invitation
for _, inv := range s.invitations {
if inv.TeamID == teamID {
out = append(out, *inv)
}
}
slices.SortFunc(out, func(a, b team.Invitation) int {
if c := b.CreatedAt.Compare(a.CreatedAt); c != 0 {
return c
}
return bytes.Compare(a.ID[:], b.ID[:])
})
return out, nil
}
var _ team.Store = (*teams)(nil)
// dropUser mirrors the FK cascades of the PostgreSQL driver when a user is
// deleted: their memberships disappear and any team they founded loses its
// founder reference (freeing no slot for the deleted account matters no
// longer). Teams they solely owned remain, ownerless, for an administrator
// to adopt or dissolve.
func (s *teams) dropUser(id uuid.UUID) {
s.mu.Lock()
defer s.mu.Unlock()
s.members = slices.DeleteFunc(
s.members,
func(m team.Membership) bool { return m.UserID == id },
)
for _, t := range s.teams {
if t.Founder == id {
t.Founder = uuid.Nil()
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package postgres
import (
"context"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/deep-rent/nexus/eco/iam/artifact"
"github.com/deep-rent/nexus/std/quote"
)
// scanner abstracts the shared Scan method of [pgx.Row] and [pgx.Rows].
type scanner interface {
Scan(dest ...any) error
}
// table implements [artifact.Store] for one artifact type over typed
// columns. Each instantiation supplies the column list beyond the digest
// key, an extractor producing the column values of a record, and a scanner
// reading a full row back; the statements are precomputed once at
// construction.
type table[K ~string, V any] struct {
db *pgxpool.Pool
cols []string
args func(V) ([]any, error)
scan func(row scanner) (V, error)
name string // quoted identifier
insertSQL string
updateSQL string
getSQL string
deleteSQL string
selectSQL string
}
// newTable precomputes the statements for one artifact table.
func newTable[K ~string, V any](
db *pgxpool.Pool,
name string,
cols []string,
args func(V) ([]any, error),
scan func(row scanner) (V, error),
) *table[K, V] {
t := &table[K, V]{
db: db,
cols: cols,
args: args,
scan: scan,
name: quote.Ident(name),
}
quoted := make([]string, len(cols))
for i, c := range cols {
quoted[i] = quote.Ident(c)
}
// Explicitly allow SQL string concatenation: only quoted identifiers
// and static expressions are concatenated below, every value binds as a
// parameter.
// #nosec G202
{
params := make([]string, len(cols)+1)
for i := range params {
params[i] = fmt.Sprintf("$%d", i+1)
}
t.insertSQL = "INSERT INTO " + t.name +
" (id, " + strings.Join(quoted, ", ") + ")" +
" VALUES (" + strings.Join(params, ", ") + ")"
sets := make([]string, len(cols))
for i, q := range quoted {
sets[i] = fmt.Sprintf("%s = $%d", q, i+2)
}
t.updateSQL = "UPDATE " + t.name +
" SET " + strings.Join(sets, ", ") +
" WHERE id = $1"
t.selectSQL = "SELECT id, " + strings.Join(quoted, ", ") +
" FROM " + t.name
t.getSQL = t.selectSQL + " WHERE id = $1"
t.deleteSQL = "DELETE FROM " + t.name + " WHERE id = $1"
}
return t
}
// row assembles the bind parameters for an insert or update: the key
// followed by the column values.
func (t *table[K, V]) row(id K, v V) ([]any, error) {
args, err := t.args(v)
if err != nil {
return nil, err
}
if len(args) != len(t.cols) {
return nil, fmt.Errorf(
"got %d column values; want %d", len(args), len(t.cols),
)
}
return append([]any{string(id)}, args...), nil
}
// insert persists a new record under the given key.
func (t *table[K, V]) insert(ctx context.Context, id K, v V) error {
args, err := t.row(id, v)
if err != nil {
return err
}
if _, err := t.db.Exec(ctx, t.insertSQL, args...); err != nil {
return fmt.Errorf("failed to insert record: %w", err)
}
return nil
}
// update persists changes to an existing record under the given key.
func (t *table[K, V]) update(ctx context.Context, id K, v V) error {
args, err := t.row(id, v)
if err != nil {
return err
}
if _, err := t.db.Exec(ctx, t.updateSQL, args...); err != nil {
return fmt.Errorf("failed to update record: %w", err)
}
return nil
}
// Get implements [artifact.Store].
func (t *table[K, V]) Get(
ctx context.Context,
id K,
) (v V, found bool, err error) {
v, err = t.scan(t.db.QueryRow(ctx, t.getSQL, string(id)))
if errors.Is(err, pgx.ErrNoRows) {
var zero V
return zero, false, nil
}
if err != nil {
var zero V
return zero, false, fmt.Errorf("failed to read record: %w", err)
}
return v, true, nil
}
// Delete implements [artifact.Store]. The affected-row count of the single
// DELETE statement atomically decides which concurrent caller removed the
// record.
func (t *table[K, V]) Delete(ctx context.Context, id K) (bool, error) {
res, err := t.db.Exec(ctx, t.deleteSQL, string(id))
if err != nil {
return false, fmt.Errorf("failed to delete record: %w", err)
}
return res.RowsAffected() > 0, nil
}
// listBy returns every record whose column matches the value.
func (t *table[K, V]) listBy(
ctx context.Context,
col string,
value any,
) ([]V, error) {
// #nosec G202 -- only the quoted identifier is concatenated.
rows, err := t.db.Query(
ctx,
t.selectSQL+" WHERE "+quote.Ident(col)+" = $1",
value,
)
if err != nil {
return nil, fmt.Errorf("failed to list records: %w", err)
}
defer rows.Close()
var out []V
for rows.Next() {
v, err := t.scan(rows)
if err != nil {
return nil, fmt.Errorf("failed to read record: %w", err)
}
out = append(out, v)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to list records: %w", err)
}
return out, nil
}
// deleteBy removes every record whose column matches the value.
func (t *table[K, V]) deleteBy(
ctx context.Context,
col string,
value any,
) error {
// #nosec G202 -- only the quoted identifier is concatenated.
if _, err := t.db.Exec(
ctx,
"DELETE FROM "+t.name+" WHERE "+quote.Ident(col)+" = $1",
value,
); err != nil {
return fmt.Errorf("failed to delete records: %w", err)
}
return nil
}
// keyed adapts a table to the full [artifact.Store] contract by extracting
// the digest key from the record for Create and Update.
type keyed[K ~string, V any] struct {
*table[K, V]
key func(V) K
}
// Create implements [artifact.Store].
func (t keyed[K, V]) Create(ctx context.Context, v V) error {
return t.insert(ctx, t.key(v), v)
}
// Update implements [artifact.Store].
func (t keyed[K, V]) Update(ctx context.Context, v V) error {
return t.update(ctx, t.key(v), v)
}
var _ artifact.Store[string, struct{}] = keyed[string, struct{}]{}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package postgres
import (
"context"
"errors"
"fmt"
"time"
"uuid"
"github.com/jackc/pgx/v5"
"github.com/deep-rent/nexus/eco/iam/authn"
)
// enrollments implements [authn.Store]. It is keyed by user rather than
// by a digest, so it does not ride on the artifact table.
type enrollments struct {
s *Store
}
// Get implements [authn.Store].
func (s *enrollments) Get(
ctx context.Context,
userID uuid.UUID,
) (*authn.Enrollment, error) {
var e authn.Enrollment
var confirmed *time.Time
err := s.s.db.QueryRow(ctx, `
SELECT user_id, secret, algorithm, digits, period,
last_counter, confirmed_at, created_at
FROM user_authenticators WHERE user_id = $1`,
userID,
).Scan(
&e.UserID,
&e.Secret,
&e.Algorithm,
&e.Digits,
&e.Period,
&e.LastCounter,
&confirmed,
&e.CreatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to read authenticator: %w", err)
}
if confirmed != nil {
e.ConfirmedAt = *confirmed
}
return &e, nil
}
// Put implements [authn.Store]. A user holds at most one authenticator,
// so this upserts: re-enrolling abandons the previous one by definition.
func (s *enrollments) Put(
ctx context.Context,
e *authn.Enrollment,
) error {
_, err := s.s.db.Exec(ctx, `
INSERT INTO user_authenticators (
user_id, secret, algorithm, digits, period,
last_counter, confirmed_at, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (user_id) DO UPDATE SET
secret = EXCLUDED.secret,
algorithm = EXCLUDED.algorithm,
digits = EXCLUDED.digits,
period = EXCLUDED.period,
last_counter = EXCLUDED.last_counter,
confirmed_at = EXCLUDED.confirmed_at`,
e.UserID,
e.Secret,
e.Algorithm,
e.Digits,
e.Period,
e.LastCounter,
nullTime(e.ConfirmedAt),
e.CreatedAt,
)
if err != nil {
return fmt.Errorf("failed to store authenticator: %w", err)
}
return nil
}
// Delete implements [authn.Store].
func (s *enrollments) Delete(
ctx context.Context,
userID uuid.UUID,
) (bool, error) {
res, err := s.s.db.Exec(
ctx,
"DELETE FROM user_authenticators WHERE user_id = $1",
userID,
)
if err != nil {
return false, fmt.Errorf("failed to delete authenticator: %w", err)
}
return res.RowsAffected() > 0, nil
}
var _ authn.Store = (*enrollments)(nil)
// recoveryCodes extends the artifact table with the owner-scoped queries
// of [authn.Codes].
type recoveryCodes struct {
keyed[string, authn.Code]
}
// ListForOwner implements [authn.Codes].
func (s *recoveryCodes) ListForOwner(
ctx context.Context,
owner uuid.UUID,
) ([]authn.Code, error) {
return s.listBy(ctx, "owner", owner)
}
// DeleteForOwner implements [authn.Codes].
func (s *recoveryCodes) DeleteForOwner(
ctx context.Context,
owner uuid.UUID,
) error {
return s.deleteBy(ctx, "owner", owner)
}
var _ authn.Codes = (*recoveryCodes)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package postgres
import (
"context"
"errors"
"fmt"
"time"
"uuid"
"github.com/jackc/pgx/v5"
"github.com/deep-rent/nexus/eco/iam/avatar"
)
// avatars implements [avatar.Store].
type avatars struct {
s *Store
}
// Upsert implements [avatar.Store]. The displaced key is read under the
// row lock the update takes, so racing upserts serialize and each learns
// the true key it displaced.
func (s *avatars) Upsert(
ctx context.Context,
p avatar.Pending,
) (prior string, err error) {
err = s.s.db.QueryRow(ctx, `
WITH old AS (
SELECT key FROM avatar_uploads
WHERE scope = $1 AND owner_id = $2
FOR UPDATE
), up AS (
INSERT INTO avatar_uploads (scope, owner_id, key, expires_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (scope, owner_id) DO UPDATE
SET key = EXCLUDED.key, expires_at = EXCLUDED.expires_at
)
SELECT COALESCE((SELECT key FROM old), '')`,
p.Scope, p.OwnerID, p.Key, p.ExpiresAt,
).Scan(&prior)
if err != nil {
return "", fmt.Errorf("failed to upsert upload grant: %w", err)
}
return prior, nil
}
// Claim implements [avatar.Store]. The DELETE's affected row decides the
// winner of concurrent claims, exactly as the artifact stores do.
func (s *avatars) Claim(
ctx context.Context,
scope avatar.Scope,
ownerID uuid.UUID,
) (*avatar.Pending, error) {
p := avatar.Pending{Scope: scope, OwnerID: ownerID}
err := s.s.db.QueryRow(ctx, `
DELETE FROM avatar_uploads
WHERE scope = $1 AND owner_id = $2
RETURNING key, expires_at`,
scope, ownerID,
).Scan(&p.Key, &p.ExpiresAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to claim upload grant: %w", err)
}
return &p, nil
}
// Delete implements [avatar.Store].
func (s *avatars) Delete(
ctx context.Context,
scope avatar.Scope,
ownerID uuid.UUID,
key string,
) (bool, error) {
res, err := s.s.db.Exec(ctx, `
DELETE FROM avatar_uploads
WHERE scope = $1 AND owner_id = $2 AND key = $3`,
scope, ownerID, key,
)
if err != nil {
return false, fmt.Errorf("failed to delete upload grant: %w", err)
}
return res.RowsAffected() > 0, nil
}
// ListExpired implements [avatar.Store].
func (s *avatars) ListExpired(
ctx context.Context,
now time.Time,
limit int,
) ([]avatar.Pending, error) {
rows, err := s.s.db.Query(ctx, `
SELECT scope, owner_id, key, expires_at FROM avatar_uploads
WHERE expires_at <= $1
ORDER BY expires_at
LIMIT $2`,
now, limit,
)
if err != nil {
return nil, fmt.Errorf("failed to list upload grants: %w", err)
}
defer rows.Close()
var out []avatar.Pending
for rows.Next() {
var p avatar.Pending
if err := rows.Scan(
&p.Scope, &p.OwnerID, &p.Key, &p.ExpiresAt,
); err != nil {
return nil, fmt.Errorf(
"failed to read upload grant: %w", err,
)
}
out = append(out, p)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to list upload grants: %w", err)
}
return out, nil
}
var _ avatar.Store = (*avatars)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package postgres
import (
"context"
"errors"
"fmt"
"strings"
"time"
"uuid"
"github.com/jackc/pgx/v5"
"github.com/deep-rent/nexus/dat/page"
"github.com/deep-rent/nexus/dat/search"
"github.com/deep-rent/nexus/eco/iam/client"
)
// clientColumns is the column list shared by every client query, aligned
// with scanClient. The array columns scan into their slices natively.
var clientColumns = strings.Join([]string{
"id",
"name",
"secret_digest",
"secret_expires_at",
"redirect_uris",
"grants",
"scopes",
"audience",
"disabled",
"created_at",
"updated_at",
}, ", ")
// clientListing searches a client's generated search_name column.
var clientListing = listing{
search: []string{"search_name"},
compiler: search.Compiler{Columns: map[string]string{
client.ByName: "search_name",
client.ByCreated: "created_at",
}},
tie: "id",
}
// clients implements [client.Store].
type clients struct {
s *Store
}
// scanClient reads one client row in clientColumns order.
func scanClient(row scanner) (*client.Client, error) {
var c client.Client
var secretExpiresAt *time.Time
err := row.Scan(
&c.ID,
&c.Name,
&c.SecretDigest,
&secretExpiresAt,
&c.RedirectURIs,
&c.Grants,
&c.Scopes,
&c.Audience,
&c.Disabled,
&c.CreatedAt,
&c.UpdatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to read client: %w", err)
}
if secretExpiresAt != nil {
c.SecretExpiresAt = *secretExpiresAt
}
c.RedirectURIs = emptyToNil(c.RedirectURIs)
c.Grants = emptyToNil(c.Grants)
c.Scopes = emptyToNil(c.Scopes)
c.Audience = emptyToNil(c.Audience)
return &c, nil
}
// Create implements [client.Store].
func (s *clients) Create(ctx context.Context, c *client.Client) error {
if _, err := s.s.db.Exec(ctx, `
INSERT INTO clients (
id, name, secret_digest, secret_expires_at, redirect_uris,
grants, scopes, audience, disabled, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
clientArgs(c)...,
); err != nil {
return fmt.Errorf("failed to insert client: %w", err)
}
return nil
}
// clientArgs assembles the bind parameters shared by insert and update.
func clientArgs(c *client.Client) []any {
return []any{
c.ID,
c.Name,
c.SecretDigest,
nullTime(c.SecretExpiresAt),
nilToEmpty(c.RedirectURIs),
nilToEmpty(c.Grants),
nilToEmpty(c.Scopes),
nilToEmpty(c.Audience),
c.Disabled,
c.CreatedAt,
c.UpdatedAt,
}
}
// Get implements [client.Store].
func (s *clients) Get(
ctx context.Context,
id uuid.UUID,
) (*client.Client, error) {
return scanClient(s.s.db.QueryRow(
ctx,
"SELECT "+clientColumns+" FROM clients WHERE id = $1",
id,
))
}
// Update implements [client.Store].
func (s *clients) Update(ctx context.Context, c *client.Client) error {
// Drop the immutable created_at from the shared parameter list.
args := clientArgs(c)
args = append(args[:9], args[10])
if _, err := s.s.db.Exec(ctx, `
UPDATE clients SET
name = $2, secret_digest = $3, secret_expires_at = $4,
redirect_uris = $5, grants = $6, scopes = $7, audience = $8,
disabled = $9, updated_at = $10
WHERE id = $1`,
args...,
); err != nil {
return fmt.Errorf("failed to update client: %w", err)
}
return nil
}
// SetSecret implements [client.Store].
func (s *clients) SetSecret(
ctx context.Context,
id uuid.UUID,
digest string,
expiresAt time.Time,
) error {
if _, err := s.s.db.Exec(
ctx,
`UPDATE clients SET secret_digest = $2, secret_expires_at = $3,
updated_at = CURRENT_TIMESTAMP WHERE id = $1`,
id, digest, nullTime(expiresAt),
); err != nil {
return fmt.Errorf("failed to set secret: %w", err)
}
return nil
}
// Delete implements [client.Store].
func (s *clients) Delete(ctx context.Context, id uuid.UUID) (bool, error) {
res, err := s.s.db.Exec(
ctx,
"DELETE FROM clients WHERE id = $1",
id,
)
if err != nil {
return false, fmt.Errorf("failed to delete client: %w", err)
}
return res.RowsAffected() > 0, nil
}
// List implements [client.Store].
func (s *clients) List(
ctx context.Context,
q client.Query,
) (page.Page[*client.Client], error) {
return list(
ctx, s.s.db, clientListing,
"clients", clientColumns,
client.Search.Normalize(q), scanClient,
)
}
var _ client.Store = (*clients)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package postgres
import (
"context"
"encoding/json/v2"
"fmt"
"uuid"
"github.com/deep-rent/nexus/eco/iam/passkey"
)
// credentials implements [passkey.CredentialStore]. Credentials persist as
// opaque JSONB documents keyed by user and credential ID.
type credentials struct {
s *Store
}
// List implements [passkey.CredentialStore].
func (s *credentials) List(
ctx context.Context,
owner uuid.UUID,
) ([]passkey.Passkey, error) {
rows, err := s.s.db.Query(ctx, `
SELECT name, created_at, data
FROM passkey_credentials WHERE user_id = $1
ORDER BY created_at`,
owner,
)
if err != nil {
return nil, fmt.Errorf("failed to list credentials: %w", err)
}
defer rows.Close()
var out []passkey.Passkey
for rows.Next() {
var (
k passkey.Passkey
data []byte
)
if err := rows.Scan(
&k.Name,
&k.CreatedAt,
&data,
); err != nil {
return nil, fmt.Errorf("failed to read credential: %w", err)
}
if err := json.Unmarshal(data, &k.Credential); err != nil {
return nil, fmt.Errorf("failed to decode credential: %w", err)
}
out = append(out, k)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to list credentials: %w", err)
}
return out, nil
}
// Create implements [passkey.CredentialStore].
func (s *credentials) Create(
ctx context.Context,
owner uuid.UUID,
name string,
cred passkey.Credential,
) error {
data, err := json.Marshal(cred)
if err != nil {
return fmt.Errorf("failed to encode credential: %w", err)
}
if _, err := s.s.db.Exec(ctx, `
INSERT INTO passkey_credentials (user_id, credential_id, name, data)
VALUES ($1, $2, $3, $4)`,
owner,
cred.ID,
name,
data,
); err != nil {
return fmt.Errorf("failed to insert credential: %w", err)
}
return nil
}
// Update implements [passkey.CredentialStore].
func (s *credentials) Update(
ctx context.Context,
owner uuid.UUID,
cred passkey.Credential,
) error {
data, err := json.Marshal(cred)
if err != nil {
return fmt.Errorf("failed to encode credential: %w", err)
}
if _, err := s.s.db.Exec(ctx, `
UPDATE passkey_credentials SET data = $3
WHERE user_id = $1 AND credential_id = $2`,
owner,
cred.ID,
data,
); err != nil {
return fmt.Errorf("failed to update credential: %w", err)
}
return nil
}
// Delete implements [passkey.CredentialStore].
func (s *credentials) Delete(
ctx context.Context,
owner uuid.UUID,
credentialID []byte,
) (bool, error) {
res, err := s.s.db.Exec(ctx, `
DELETE FROM passkey_credentials
WHERE user_id = $1 AND credential_id = $2`,
owner, credentialID,
)
if err != nil {
return false, fmt.Errorf("failed to delete credential: %w", err)
}
return res.RowsAffected() > 0, nil
}
// Rename implements [passkey.CredentialStore].
func (s *credentials) Rename(
ctx context.Context,
owner uuid.UUID,
credentialID []byte,
name string,
) error {
if _, err := s.s.db.Exec(ctx, `
UPDATE passkey_credentials SET name = $3
WHERE user_id = $1 AND credential_id = $2`,
owner,
credentialID,
name,
); err != nil {
return fmt.Errorf("failed to rename credential: %w", err)
}
return nil
}
var _ passkey.CredentialStore = (*credentials)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package postgres
import (
"context"
"fmt"
"time"
"github.com/deep-rent/nexus/eco/iam/oauth"
)
// deviceCodeStore extends the artifact table with the user-code lookup and
// poll bookkeeping of [oauth.DeviceCodeStore].
type deviceCodeStore struct {
keyed[oauth.Digest, oauth.DeviceCode]
}
// GetByUserCode implements [oauth.DeviceCodeStore].
func (s *deviceCodeStore) GetByUserCode(
ctx context.Context,
userCode oauth.Digest,
) (v oauth.DeviceCode, found bool, err error) {
codes, err := s.listBy(ctx, "user_code", string(userCode))
if err != nil || len(codes) == 0 {
return v, false, err
}
return codes[0], true, nil
}
// Touch implements [oauth.DeviceCodeStore]. It writes only the poll
// timestamp, so concurrent status updates are not clobbered.
func (s *deviceCodeStore) Touch(
ctx context.Context,
code oauth.Digest,
lastPolledAt time.Time,
) error {
// #nosec G202 -- only the quoted identifier is concatenated.
if _, err := s.db.Exec(
ctx,
"UPDATE "+s.name+" SET last_polled_at = $2 WHERE id = $1",
string(code),
lastPolledAt.UTC(),
); err != nil {
return fmt.Errorf("failed to touch device code: %w", err)
}
return nil
}
var _ oauth.DeviceCodeStore = (*deviceCodeStore)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package postgres
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/deep-rent/nexus/dat/page"
"github.com/deep-rent/nexus/dat/search"
)
// listing renders the SQL of one searchable listing: the WHERE clause both
// halves of the query share, and the ORDER BY of the page half.
//
// The free-text term always binds $1, so the declared filters number from
// $2 up.
type listing struct {
// search names the columns the free-text term is matched against, as
// a substring of each. They are compared case-folded, so every one of
// them must already be stored lower case — the generated search_name
// columns are, and so are the email addresses.
search []string
// compiler maps the schema's fields onto this table's columns.
compiler search.Compiler
// tie is the column breaking sort ties, unique per row.
tie string
}
// where renders the listing's WHERE clause along with the arguments it
// binds, the free-text term first.
func (l listing) where(q search.Query) (string, []any) {
filters, args := l.compiler.Where(q, 2)
// The term is escaped by the compiler, so its wildcards are
// neutralized with the same character the ESCAPE clause names.
args = append(
[]any{l.compiler.Term(strings.ToLower(q.Text))},
args...,
)
return " WHERE " + l.compiler.Contains(1, l.search...) +
" AND " + filters, args
}
// orderBy renders the listing's ORDER BY clause.
func (l listing) orderBy(q search.Query) string {
return l.compiler.OrderBy(q, l.tie)
}
// limit renders the LIMIT and OFFSET clause binding the two placeholders
// that follow the n arguments already bound.
func limit(n int) string {
return " LIMIT $" + strconv.Itoa(n+1) + " OFFSET $" + strconv.Itoa(n+2)
}
// list runs one page of a searchable listing: it counts the matches and
// fetches the requested window of them, reading each row with scan. The
// table name doubles as the noun in the error messages, so a failure names
// the listing it came from.
//
// The count and the page travel as one batch, so a listing costs a single
// round trip rather than two. A page past the end still executes its
// query server-side — the batch is sent before the total is known — but
// that query walks no further than the count beside it, and stale
// pagination is the rare case; the round trip saved on every listing is
// the common one.
//
// It is the whole body of every List method in this driver. Those differ
// only in their table, their column list, and how a row becomes a record,
// which is exactly what the parameters carry.
func list[T any](
ctx context.Context,
db *pgxpool.Pool,
l listing,
table, columns string,
q search.Query,
scan func(row scanner) (T, error),
) (_ page.Page[T], err error) {
where, args := l.where(q)
w := q.Window()
b := &pgx.Batch{}
b.Queue(`SELECT COUNT(*) FROM `+table+where, args...)
b.Queue(`
SELECT `+columns+` FROM `+table+where+`
`+l.orderBy(q)+limit(len(args)),
append(args, w.Limit, w.Offset)...,
)
br := db.SendBatch(ctx, b)
// Close reports anything the queued statements left unsaid, which
// on a batch means a connection that failed mid-flight.
defer func() { err = errors.Join(err, br.Close()) }()
var total int
if err := br.QueryRow().Scan(&total); err != nil {
return page.Page[T]{}, fmt.Errorf(
"failed to count %s: %w", table, err,
)
}
rows, err := br.Query()
if err != nil {
return page.Page[T]{}, fmt.Errorf("failed to list %s: %w", table, err)
}
defer rows.Close()
var out []T
for rows.Next() {
v, err := scan(rows)
if err != nil {
return page.Page[T]{}, err
}
out = append(out, v)
}
if err := rows.Err(); err != nil {
return page.Page[T]{}, fmt.Errorf("failed to list %s: %w", table, err)
}
return page.Of(out, w, total), nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package postgres
import (
"github.com/deep-rent/nexus/sys/log"
)
// Option configures a [Store].
type Option func(*Store)
// WithLogger sets the logger for background failures that do not surface as
// errors. A nil logger is ignored. Defaults to [log.Discard].
func WithLogger(logger *log.Logger) Option {
return func(s *Store) {
if logger != nil {
s.logger = logger
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package postgres
import (
"context"
"database/sql"
"embed"
"errors"
"io/fs"
"time"
"uuid"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/deep-rent/nexus/dat/migrate"
driver "github.com/deep-rent/nexus/dat/migrate/driver/postgres"
source "github.com/deep-rent/nexus/dat/migrate/source/file"
"github.com/deep-rent/nexus/eco/iam/artifact"
"github.com/deep-rent/nexus/eco/iam/authn"
"github.com/deep-rent/nexus/eco/iam/avatar"
"github.com/deep-rent/nexus/eco/iam/client"
"github.com/deep-rent/nexus/eco/iam/flow"
"github.com/deep-rent/nexus/eco/iam/oauth"
"github.com/deep-rent/nexus/eco/iam/otp"
"github.com/deep-rent/nexus/eco/iam/passkey"
"github.com/deep-rent/nexus/eco/iam/session"
"github.com/deep-rent/nexus/eco/iam/team"
"github.com/deep-rent/nexus/eco/iam/ticket"
"github.com/deep-rent/nexus/eco/iam/trust"
"github.com/deep-rent/nexus/eco/iam/user"
"github.com/deep-rent/nexus/sys/log"
)
//go:embed migrations
var migrations embed.FS
// Module is the migration stream this schema owns. Every service sharing a
// database namespaces its migrations under its own module name, so that one
// schema's version does not gate another's.
const Module = "iam"
// Migrations exposes the embedded schema migrations for [migrate] paired
// with its PostgreSQL driver. Prefer [Migrator], which wires them up.
func Migrations() fs.FS {
sub, err := fs.Sub(migrations, "migrations")
if err != nil {
// The subdirectory is embedded at compile time; failing to open it
// is a build defect, not a runtime condition.
panic(err)
}
return sub
}
// Migrator builds a migrator for this schema over a [database/sql] handle,
// which is the interface the migrate driver deliberately speaks: it serves
// services beyond this one. Obtain the handle from [stdlib.OpenDBFromPool]
// over a [Connect] pool where the service already holds one; a command
// that has only the database URL calls [Open], which connects the
// migrator directly.
//
// The module, the embedded source, and the driver are this schema's to
// name, so they are fixed here rather than at each call site; opts carry
// what the caller legitimately varies, such as a logger.
//
// [stdlib.OpenDBFromPool]: github.com/jackc/pgx/v5/stdlib#OpenDBFromPool
func Migrator(db *sql.DB, opts ...migrate.Option) *migrate.Migrator {
return migrate.New(append([]migrate.Option{
migrate.WithModule(Module),
migrate.WithSource(source.New(Migrations())),
migrate.WithDriver(driver.New(db)),
}, opts...)...)
}
// Open connects a migrator for this schema to the database at url, for the
// commands that only run migrations and have no use for a native pool. As
// with [Migrator], the schema names its own module and source; opts carry
// the rest. The returned close function releases the underlying handle,
// which carries none of the type registrations the stores rely on —
// harmless for the migrator, because DDL names no UUID of its own.
func Open(
url string,
opts ...migrate.Option,
) (*migrate.Migrator, func() error, error) {
return driver.Open(url, append([]migrate.Option{
migrate.WithModule(Module),
migrate.WithSource(source.New(Migrations())),
}, opts...)...)
}
// Store implements every persistence contract of the IAM service on an
// injected [pgxpool.Pool]. Sub-stores are handed out through the accessor
// methods; all of them remain valid for the lifetime of the pool.
type Store struct {
db *pgxpool.Pool
logger *log.Logger
users *users
clients *clients
credentials *credentials
teams *teams
sessions *sessionStore
authCodes keyed[oauth.Digest, oauth.AuthCode]
refreshTokens *refreshTokenStore
deviceCodes *deviceCodeStore
challenges keyed[string, otp.Challenge]
flows keyed[string, flow.Transaction]
trust *trustStore
ceremonies keyed[string, passkey.Ceremony]
tickets *ticketStore
enrollments *enrollments
recovery *recoveryCodes
avatars *avatars
}
// New creates a [Store] on the given connection pool. The pool is injected
// and never closed by the store. It panics if db is nil, since that is a
// startup configuration error.
func New(db *pgxpool.Pool, opts ...Option) *Store {
if db == nil {
panic("db is required")
}
s := &Store{db: db, logger: log.Discard()}
for _, opt := range opts {
opt(s)
}
s.users = &users{s: s}
s.clients = &clients{s: s}
s.credentials = &credentials{s: s}
s.teams = &teams{s: s}
s.enrollments = &enrollments{s: s}
s.avatars = &avatars{s: s}
s.sessions = &sessionStore{keyed[string, session.Record]{
key: func(r session.Record) string { return r.ID },
table: newTable[string, session.Record](db, "sessions",
[]string{
"owner",
"expires_at",
"created_at",
"label",
},
func(r session.Record) ([]any, error) {
return []any{
r.Owner,
r.ExpiresAt.UTC(),
r.CreatedAt.UTC(),
r.Label,
}, nil
},
func(row scanner) (session.Record, error) {
var r session.Record
return r, row.Scan(
&r.ID,
&r.Owner,
&r.ExpiresAt,
&r.CreatedAt,
&r.Label,
)
},
),
}}
s.authCodes = keyed[oauth.Digest, oauth.AuthCode]{
key: func(c oauth.AuthCode) oauth.Digest { return c.Code },
table: newTable[oauth.Digest, oauth.AuthCode](db, "auth_codes",
[]string{
"client_id",
"redirect_uri",
"scope",
"owner",
"code_challenge",
"code_challenge_method",
"nonce",
"expires_at",
},
func(c oauth.AuthCode) ([]any, error) {
return []any{
c.ClientID,
c.RedirectURI,
c.Scope,
c.UserID,
c.CodeChallenge,
c.CodeChallengeMethod,
c.Nonce,
c.ExpiresAt.UTC(),
}, nil
},
func(row scanner) (oauth.AuthCode, error) {
var c oauth.AuthCode
return c, row.Scan(
&c.Code,
&c.ClientID,
&c.RedirectURI,
&c.Scope,
&c.UserID,
&c.CodeChallenge,
&c.CodeChallengeMethod,
&c.Nonce,
&c.ExpiresAt,
)
},
),
}
s.refreshTokens = &refreshTokenStore{keyed[
oauth.Digest, oauth.RefreshToken,
]{
key: func(r oauth.RefreshToken) oauth.Digest { return r.Token },
table: newTable[oauth.Digest, oauth.RefreshToken](db, "refresh_tokens",
[]string{
"client_id",
"owner",
"scope",
"family",
"spent",
"expires_at",
},
func(r oauth.RefreshToken) ([]any, error) {
// The zero user marks a token minted to the client
// itself; the codec stores it as NULL.
return []any{
r.ClientID,
r.UserID,
r.Scope,
r.Family,
r.Spent,
r.ExpiresAt.UTC(),
}, nil
},
func(row scanner) (oauth.RefreshToken, error) {
var r oauth.RefreshToken
return r, row.Scan(
&r.Token,
&r.ClientID,
&r.UserID,
&r.Scope,
&r.Family,
&r.Spent,
&r.ExpiresAt,
)
},
),
}}
s.recovery = &recoveryCodes{keyed[string, authn.Code]{
key: func(c authn.Code) string { return c.ID },
table: newTable[string, authn.Code](db, "recovery_codes",
[]string{"owner", "created_at"},
func(c authn.Code) ([]any, error) {
return []any{c.Owner, c.CreatedAt.UTC()}, nil
},
func(row scanner) (authn.Code, error) {
var c authn.Code
return c, row.Scan(&c.ID, &c.Owner, &c.CreatedAt)
},
),
}}
s.deviceCodes = &deviceCodeStore{keyed[oauth.Digest, oauth.DeviceCode]{
key: func(c oauth.DeviceCode) oauth.Digest { return c.DeviceCode },
table: newTable[oauth.Digest, oauth.DeviceCode](db, "device_codes",
[]string{
"user_code",
"client_id",
"owner",
"scope",
"status",
"expires_at",
"poll_interval",
"last_polled_at",
},
func(c oauth.DeviceCode) ([]any, error) {
// The zero user marks a request not yet approved; the
// codec stores it as NULL.
return []any{
string(c.UserCode),
c.ClientID,
c.UserID,
c.Scope,
string(c.Status),
c.ExpiresAt.UTC(),
c.Interval,
c.LastPolledAt.UTC(),
}, nil
},
func(row scanner) (oauth.DeviceCode, error) {
var c oauth.DeviceCode
return c, row.Scan(
&c.DeviceCode,
&c.UserCode,
&c.ClientID,
&c.UserID,
&c.Scope,
&c.Status,
&c.ExpiresAt,
&c.Interval,
&c.LastPolledAt,
)
},
),
}}
s.challenges = keyed[string, otp.Challenge]{
key: func(c otp.Challenge) string { return c.ID },
table: newTable[string, otp.Challenge](db, "otp_challenges",
[]string{
"code",
"owner",
"purpose",
"method_id",
"expires_at",
"attempts",
"resends",
},
func(c otp.Challenge) ([]any, error) {
return []any{
c.Code,
c.Owner,
c.Purpose,
c.MethodID,
c.ExpiresAt.UTC(),
c.Attempts,
c.Resends,
}, nil
},
func(row scanner) (otp.Challenge, error) {
var c otp.Challenge
return c, row.Scan(
&c.ID,
&c.Code,
&c.Owner,
&c.Purpose,
&c.MethodID,
&c.ExpiresAt,
&c.Attempts,
&c.Resends,
)
},
),
}
s.flows = keyed[string, flow.Transaction]{
key: func(t flow.Transaction) string { return t.ID },
table: newTable[string, flow.Transaction](db, "flow_transactions",
[]string{
"owner",
"completed",
"remember",
"expires_at",
},
func(t flow.Transaction) ([]any, error) {
return []any{
t.Owner,
nilToEmpty(t.Completed),
t.Remember,
t.ExpiresAt.UTC(),
}, nil
},
func(row scanner) (flow.Transaction, error) {
var t flow.Transaction
err := row.Scan(
&t.ID,
&t.Owner,
&t.Completed,
&t.Remember,
&t.ExpiresAt,
)
if err != nil {
return t, err
}
t.Completed = emptyToNil(t.Completed)
return t, nil
},
),
}
s.trust = &trustStore{keyed[string, trust.Record]{
key: func(r trust.Record) string { return r.ID },
table: newTable[string, trust.Record](db, "trusted_devices",
[]string{
"owner",
"expires_at",
"created_at",
"label",
},
func(r trust.Record) ([]any, error) {
return []any{
r.Owner,
r.ExpiresAt.UTC(),
r.CreatedAt.UTC(),
r.Label,
}, nil
},
func(row scanner) (trust.Record, error) {
var r trust.Record
err := row.Scan(
&r.ID,
&r.Owner,
&r.ExpiresAt,
&r.CreatedAt,
&r.Label,
)
if err != nil {
return r, err
}
return r, nil
},
),
}}
s.ceremonies = keyed[string, passkey.Ceremony]{
key: func(c passkey.Ceremony) string { return c.ID },
table: newTable[string, passkey.Ceremony](db, "passkey_ceremonies",
[]string{
"kind",
"owner",
"data",
"expires_at",
},
func(c passkey.Ceremony) ([]any, error) {
// Login ceremonies carry no owner: the account is only
// discovered from the assertion, and the zero UUID the
// codec maps to NULL says so.
return []any{
string(c.Kind),
c.Owner,
c.Data,
c.ExpiresAt.UTC(),
}, nil
},
func(row scanner) (passkey.Ceremony, error) {
var c passkey.Ceremony
err := row.Scan(
&c.ID,
&c.Kind,
&c.Owner,
&c.Data,
&c.ExpiresAt,
)
if err != nil {
return c, err
}
return c, nil
},
),
}
s.tickets = &ticketStore{keyed[string, ticket.Ticket]{
key: func(t ticket.Ticket) string { return t.ID },
table: newTable[string, ticket.Ticket](db, "tickets",
[]string{
"owner",
"purpose",
"payload",
"expires_at",
},
func(t ticket.Ticket) ([]any, error) {
return []any{
t.Owner,
t.Purpose,
t.Payload,
t.ExpiresAt.UTC(),
}, nil
},
func(row scanner) (ticket.Ticket, error) {
var t ticket.Ticket
return t, row.Scan(
&t.ID,
&t.Owner,
&t.Purpose,
&t.Payload,
&t.ExpiresAt,
)
},
),
}}
return s
}
// Users returns the durable user registry.
func (s *Store) Users() user.Store { return s.users }
// Teams returns the durable team registry.
func (s *Store) Teams() team.Store { return s.teams }
// Enrollments returns the authenticator enrollment store.
func (s *Store) Enrollments() authn.Store { return s.enrollments }
// AvatarUploads returns the pending picture upload store.
func (s *Store) AvatarUploads() avatar.Store { return s.avatars }
// RecoveryCodes returns the recovery code store.
func (s *Store) RecoveryCodes() authn.Codes { return s.recovery }
// Clients returns the durable client registry.
func (s *Store) Clients() client.Store { return s.clients }
// Credentials returns the durable passkey credential store.
func (s *Store) Credentials() passkey.CredentialStore { return s.credentials }
// Sessions returns the login session store.
func (s *Store) Sessions() session.Store { return s.sessions }
// AuthCodes returns the authorization code store.
func (s *Store) AuthCodes() artifact.Store[oauth.Digest, oauth.AuthCode] {
return s.authCodes
}
// RefreshTokens returns the refresh token store.
func (s *Store) RefreshTokens() oauth.RefreshTokenStore {
return s.refreshTokens
}
// DeviceCodes returns the device code store.
func (s *Store) DeviceCodes() oauth.DeviceCodeStore { return s.deviceCodes }
// Challenges returns the one-time password challenge store.
func (s *Store) Challenges() otp.Store { return s.challenges }
// Flows returns the login flow transaction store.
func (s *Store) Flows() flow.Store { return s.flows }
// Trust returns the device trust store.
func (s *Store) Trust() trust.Store { return s.trust }
// Ceremonies returns the WebAuthn ceremony store.
func (s *Store) Ceremonies() passkey.Store { return s.ceremonies }
// Tickets returns the action ticket store.
func (s *Store) Tickets() ticket.Store { return s.tickets }
// DeleteRefreshTokensForUser revokes every refresh token held on behalf of
// the user, cutting all standing grant chains.
func (s *Store) DeleteRefreshTokensForUser(
ctx context.Context,
userID uuid.UUID,
) error {
return s.refreshTokens.deleteBy(ctx, "owner", userID)
}
// DeleteRefreshTokensForClient revokes every refresh token issued to the
// client.
func (s *Store) DeleteRefreshTokensForClient(
ctx context.Context,
clientID uuid.UUID,
) error {
return s.refreshTokens.deleteBy(ctx, "client_id", clientID)
}
// duplicate reports whether the error is a PostgreSQL unique-constraint
// violation, translating it to [user.ErrDuplicate] semantics.
func duplicate(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
// nilToEmpty prepares a slice for an array column, normalizing nil to the empty
// non-nil slice the driver encodes as an empty array. A nil slice would encode
// as NULL, which the NOT NULL columns refuse. Slices go in and out of array
// columns natively otherwise; there is no wrapping.
func nilToEmpty[T any](list []T) []T {
if list == nil {
return []T{}
}
return list
}
// emptyToNil normalizes a scanned array column to the record's shape: the
// driver decodes an empty array as an empty non-nil slice, while the records
// model absence as nil, matching the shape their JSON serialization expects.
func emptyToNil[T any](list []T) []T {
if len(list) == 0 {
return nil
}
return list
}
// nullTime prepares a timestamp for a nullable column, collapsing the zero
// time the records model absence as to NULL — the timestamp counterpart of
// the NULLIF(”) collapse on the text columns. Scanning goes through a
// *time.Time the same way, nil mapping back to the zero time.
func nullTime(t time.Time) *time.Time {
if t.IsZero() {
return nil
}
return &t
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package postgres
import (
"context"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/std/quote"
"github.com/deep-rent/nexus/sys/log"
)
// artifactTables names every digest-keyed table the retention sweep reaps.
var artifactTables = []string{
"sessions",
"auth_codes",
"refresh_tokens",
"device_codes",
"otp_challenges",
"flow_transactions",
"trusted_devices",
"passkey_ceremonies",
"tickets",
}
// Retention reaps expired artifact rows. The engines already treat lapsed
// records as absent, so the sweep is pure space reclamation and safe to run
// at any cadence; sweep failures are logged and swallowed, since the next
// run will catch up.
//
// Run satisfies the [schedule.Task] contract:
//
// sched.Dispatch(schedule.Every(time.Hour, retention))
//
// [schedule.Task]: github.com/deep-rent/nexus/sys/schedule#Task
type Retention struct {
s *Store
now clock.Clock
logger *log.Logger
}
// NewRetention creates a [Retention] sweep over the given store. It panics
// if s is nil, since that is a startup configuration error.
func NewRetention(s *Store, opts ...RetentionOption) *Retention {
if s == nil {
panic("store is required")
}
r := &Retention{s: s, now: clock.System, logger: s.logger}
for _, opt := range opts {
opt(r)
}
return r
}
// RetentionOption configures a [Retention].
type RetentionOption func(*Retention)
// WithRetentionClock overrides the time source, primarily for testing. A
// nil function is ignored. Defaults to [clock.System].
func WithRetentionClock(now clock.Clock) RetentionOption {
return func(r *Retention) {
if now != nil {
r.now = now
}
}
}
// WithRetentionLogger sets the logger for sweep failures. A nil logger is
// ignored. Defaults to the store's logger.
func WithRetentionLogger(logger *log.Logger) RetentionOption {
return func(r *Retention) {
if logger != nil {
r.logger = logger
}
}
}
// Run deletes every expired artifact row across all digest-keyed tables.
func (r *Retention) Run(ctx context.Context) {
now := r.now().UTC()
for _, name := range artifactTables {
// #nosec G202 -- only the quoted identifier is concatenated.
res, err := r.s.db.Exec(
ctx,
"DELETE FROM "+quote.Ident(name)+" WHERE expires_at < $1",
now,
)
if err != nil {
r.logger.Warn(
ctx,
"Retention sweep failed",
log.String("table", name),
log.Error(err),
)
continue
}
if n := res.RowsAffected(); n > 0 {
r.logger.Debug(
ctx,
"Reaped expired records",
log.String("table", name),
log.Int64("count", n),
)
}
}
// Team invitations are not an artifact table: rejected rows must
// outlive any expiry to enforce the re-invitation cooldown. Only
// abandoned pending invitations are reaped.
res, err := r.s.db.Exec(ctx, `
DELETE FROM team_invitations
WHERE status = 'pending' AND expires_at < $1`,
now,
)
if err != nil {
r.logger.Warn(
ctx,
"Retention sweep failed",
log.String("table", "team_invitations"),
log.Error(err),
)
return
}
if n := res.RowsAffected(); n > 0 {
r.logger.Debug(
ctx,
"Reaped expired records",
log.String("table", "team_invitations"),
log.Int64("count", n),
)
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package postgres
import (
"context"
"uuid"
"github.com/deep-rent/nexus/eco/iam/session"
)
// sessionStore extends the artifact table with the owner-scoped queries of
// [session.Store].
type sessionStore struct {
keyed[string, session.Record]
}
// ListForOwner implements [session.Store].
func (s *sessionStore) ListForOwner(
ctx context.Context,
owner uuid.UUID,
) ([]session.Record, error) {
return s.listBy(ctx, "owner", owner)
}
// DeleteForOwner implements [session.Store].
func (s *sessionStore) DeleteForOwner(
ctx context.Context,
owner uuid.UUID,
) error {
return s.deleteBy(ctx, "owner", owner)
}
var _ session.Store = (*sessionStore)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package postgres
import (
"context"
"errors"
"fmt"
"uuid"
"github.com/jackc/pgx/v5"
"github.com/deep-rent/nexus/dat/page"
"github.com/deep-rent/nexus/dat/search"
"github.com/deep-rent/nexus/eco/iam/team"
)
// teamColumns is the column list shared by every team query, aligned with
// scanTeam. The founder survives account deletion as NULL, surfaced as the
// zero UUID.
const teamColumns = "id, name, founder, COALESCE(logo, '')" +
", created_at, updated_at"
// teams implements [team.Store].
type teams struct {
s *Store
}
// scanTeam reads one team row in teamColumns order.
func scanTeam(row scanner) (*team.Team, error) {
var t team.Team
err := row.Scan(
&t.ID,
&t.Name,
&t.Founder,
&t.Logo,
&t.CreatedAt,
&t.UpdatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to read team: %w", err)
}
return &t, nil
}
// CreateTeam implements [team.Store].
func (s *teams) CreateTeam(ctx context.Context, t *team.Team) error {
if _, err := s.s.db.Exec(ctx, `
INSERT INTO teams (id, name, founder, logo, created_at, updated_at)
VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6)`,
t.ID,
t.Name,
t.Founder,
t.Logo,
t.CreatedAt,
t.UpdatedAt,
); err != nil {
return fmt.Errorf("failed to insert team: %w", err)
}
return nil
}
// GetTeam implements [team.Store].
func (s *teams) GetTeam(
ctx context.Context,
id uuid.UUID,
) (*team.Team, error) {
return scanTeam(s.s.db.QueryRow(ctx, `
SELECT `+teamColumns+` FROM teams WHERE id = $1`, id,
))
}
// UpdateTeam implements [team.Store]. The logo column is deliberately
// not written: it changes only through [teams.SetLogo], whose atomic
// exchange is what keeps the avatar engine's object eviction truthful.
func (s *teams) UpdateTeam(ctx context.Context, t *team.Team) error {
if _, err := s.s.db.Exec(ctx, `
UPDATE teams SET name = $2, updated_at = $3 WHERE id = $1`,
t.ID,
t.Name,
t.UpdatedAt,
); err != nil {
return fmt.Errorf("failed to update team: %w", err)
}
return nil
}
// SetLogo implements [team.Store]. The row is locked while the old key
// is read, so two concurrent swaps serialize and each learns the true
// key it displaced.
func (s *teams) SetLogo(
ctx context.Context,
id uuid.UUID,
key string,
) (prior string, found bool, err error) {
err = s.s.db.QueryRow(ctx, `
UPDATE teams t SET
logo = NULLIF($2, ''), updated_at = CURRENT_TIMESTAMP
FROM (
SELECT id, COALESCE(logo, '') AS prior
FROM teams WHERE id = $1 FOR UPDATE
) old
WHERE t.id = old.id
RETURNING old.prior`,
id, key,
).Scan(&prior)
if errors.Is(err, pgx.ErrNoRows) {
return "", false, nil
}
if err != nil {
return "", false, fmt.Errorf("failed to set logo: %w", err)
}
return prior, true, nil
}
// DeleteTeam implements [team.Store]. Memberships and invitations cascade
// in the schema.
func (s *teams) DeleteTeam(
ctx context.Context,
id uuid.UUID,
) (bool, error) {
res, err := s.s.db.Exec(
ctx, `DELETE FROM teams WHERE id = $1`, id,
)
if err != nil {
return false, fmt.Errorf("failed to delete team: %w", err)
}
return res.RowsAffected() > 0, nil
}
// teamListing searches a team's generated search_name column.
var teamListing = listing{
search: []string{"search_name"},
compiler: search.Compiler{Columns: map[string]string{
team.ByName: "search_name",
team.ByCreated: "created_at",
}},
tie: "id",
}
// ListTeams implements [team.Store].
func (s *teams) ListTeams(
ctx context.Context,
q team.Query,
) (page.Page[*team.Team], error) {
return list(
ctx, s.s.db, teamListing,
"teams", teamColumns,
team.Search.Normalize(q), scanTeam,
)
}
// CountFounded implements [team.Store].
func (s *teams) CountFounded(
ctx context.Context,
founder uuid.UUID,
) (int, error) {
var n int
if err := s.s.db.QueryRow(ctx, `
SELECT COUNT(*) FROM teams WHERE founder = $1`, founder,
).Scan(&n); err != nil {
return 0, fmt.Errorf("failed to count founded teams: %w", err)
}
return n, nil
}
// AddMember implements [team.Store].
func (s *teams) AddMember(ctx context.Context, m team.Membership) error {
_, err := s.s.db.Exec(ctx, `
INSERT INTO team_members (team_id, user_id, owner, created_at)
VALUES ($1, $2, $3, $4)`,
m.TeamID,
m.UserID,
m.Owner,
m.CreatedAt,
)
if duplicate(err) {
return fmt.Errorf("%w: membership", team.ErrDuplicate)
}
if err != nil {
return fmt.Errorf("failed to insert membership: %w", err)
}
return nil
}
// SetOwner implements [team.Store].
func (s *teams) SetOwner(
ctx context.Context,
teamID, userID uuid.UUID,
owner bool,
) (bool, error) {
res, err := s.s.db.Exec(ctx, `
UPDATE team_members SET owner = $3
WHERE team_id = $1 AND user_id = $2`,
teamID,
userID,
owner,
)
if err != nil {
return false, fmt.Errorf("failed to update membership: %w", err)
}
return res.RowsAffected() > 0, nil
}
// RemoveMember implements [team.Store].
func (s *teams) RemoveMember(
ctx context.Context,
teamID, userID uuid.UUID,
) (bool, error) {
res, err := s.s.db.Exec(ctx, `
DELETE FROM team_members WHERE team_id = $1 AND user_id = $2`,
teamID, userID,
)
if err != nil {
return false, fmt.Errorf("failed to delete membership: %w", err)
}
return res.RowsAffected() > 0, nil
}
// GetMembership implements [team.Store].
func (s *teams) GetMembership(
ctx context.Context,
teamID, userID uuid.UUID,
) (*team.Membership, error) {
var m team.Membership
err := s.s.db.QueryRow(ctx, `
SELECT team_id, user_id, owner, created_at
FROM team_members WHERE team_id = $1 AND user_id = $2`,
teamID, userID,
).Scan(
&m.TeamID,
&m.UserID,
&m.Owner,
&m.CreatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to read membership: %w", err)
}
return &m, nil
}
// ListMembers implements [team.Store].
func (s *teams) ListMembers(
ctx context.Context,
teamID uuid.UUID,
) ([]team.Member, error) {
rows, err := s.s.db.Query(ctx, `
SELECT m.user_id, u.email, u.name,
COALESCE(u.display_name, ''), m.owner, m.created_at
FROM team_members m JOIN users u ON u.id = m.user_id
WHERE m.team_id = $1
ORDER BY m.owner DESC, m.created_at, m.user_id`,
teamID,
)
if err != nil {
return nil, fmt.Errorf("failed to list members: %w", err)
}
defer rows.Close()
var out []team.Member
for rows.Next() {
var m team.Member
if err := rows.Scan(
&m.UserID,
&m.Email,
&m.Name,
&m.DisplayName,
&m.Owner,
&m.CreatedAt,
); err != nil {
return nil, fmt.Errorf("failed to read member: %w", err)
}
out = append(out, m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to list members: %w", err)
}
return out, nil
}
// ListAffiliations implements [team.Store].
func (s *teams) ListAffiliations(
ctx context.Context,
userID uuid.UUID,
) ([]team.Affiliation, error) {
rows, err := s.s.db.Query(ctx, `
SELECT t.id, t.name, t.founder, t.created_at, t.updated_at,
m.owner, m.created_at
FROM team_members m JOIN teams t ON t.id = m.team_id
WHERE m.user_id = $1
ORDER BY m.created_at, t.id`,
userID,
)
if err != nil {
return nil, fmt.Errorf("failed to list affiliations: %w", err)
}
defer rows.Close()
var out []team.Affiliation
for rows.Next() {
var a team.Affiliation
if err := rows.Scan(
&a.Team.ID,
&a.Team.Name,
&a.Team.Founder,
&a.Team.CreatedAt,
&a.Team.UpdatedAt,
&a.Owner,
&a.CreatedAt,
); err != nil {
return nil, fmt.Errorf("failed to read affiliation: %w", err)
}
out = append(out, a)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to list affiliations: %w", err)
}
return out, nil
}
// ListTeamIDs implements [team.Store].
func (s *teams) ListTeamIDs(
ctx context.Context,
userID uuid.UUID,
) ([]uuid.UUID, error) {
rows, err := s.s.db.Query(ctx, `
SELECT team_id FROM team_members
WHERE user_id = $1 ORDER BY team_id`,
userID,
)
if err != nil {
return nil, fmt.Errorf("failed to list team IDs: %w", err)
}
defer rows.Close()
var out []uuid.UUID
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("failed to read team ID: %w", err)
}
out = append(out, id)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to list team IDs: %w", err)
}
return out, nil
}
// CountOwners implements [team.Store].
func (s *teams) CountOwners(
ctx context.Context,
teamID uuid.UUID,
) (int, error) {
var n int
if err := s.s.db.QueryRow(ctx, `
SELECT COUNT(*) FROM team_members
WHERE team_id = $1 AND owner`, teamID,
).Scan(&n); err != nil {
return 0, fmt.Errorf("failed to count owners: %w", err)
}
return n, nil
}
// CountSeats implements [team.Store]. One round trip covers both
// tables: a seat is a member or a pending invitation, expired or not,
// because an expired invitation still renews in place.
func (s *teams) CountSeats(
ctx context.Context,
teamID uuid.UUID,
) (int, error) {
var n int
if err := s.s.db.QueryRow(ctx, `
SELECT
(SELECT COUNT(*) FROM team_members WHERE team_id = $1) +
(SELECT COUNT(*) FROM team_invitations
WHERE team_id = $1 AND status = 'pending')`, teamID,
).Scan(&n); err != nil {
return 0, fmt.Errorf("failed to count seats: %w", err)
}
return n, nil
}
// invitationColumns is the column list shared by every invitation query,
// aligned with scanInvitation.
const invitationColumns = `id, team_id, email, token, status, rejections,
cooldown_until, expires_at, created_at, updated_at`
// scanInvitation reads one invitation row in invitationColumns order.
func scanInvitation(
row scanner,
) (*team.Invitation, error) {
var inv team.Invitation
err := row.Scan(
&inv.ID,
&inv.TeamID,
&inv.Email,
&inv.Token,
&inv.Status,
&inv.Rejections,
&inv.CooldownUntil,
&inv.ExpiresAt,
&inv.CreatedAt,
&inv.UpdatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to read invitation: %w", err)
}
return &inv, nil
}
// CreateInvitation implements [team.Store].
func (s *teams) CreateInvitation(
ctx context.Context,
inv *team.Invitation,
) error {
_, err := s.s.db.Exec(ctx, `
INSERT INTO team_invitations (
id, team_id, email, token, status, rejections,
cooldown_until, expires_at, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
inv.ID,
inv.TeamID,
inv.Email,
inv.Token,
inv.Status,
inv.Rejections,
inv.CooldownUntil.UTC(),
inv.ExpiresAt.UTC(),
inv.CreatedAt,
inv.UpdatedAt,
)
if duplicate(err) {
return fmt.Errorf("%w: invitation", team.ErrDuplicate)
}
if err != nil {
return fmt.Errorf("failed to insert invitation: %w", err)
}
return nil
}
// GetInvitation implements [team.Store].
func (s *teams) GetInvitation(
ctx context.Context,
id uuid.UUID,
) (*team.Invitation, error) {
return scanInvitation(s.s.db.QueryRow(ctx, `
SELECT `+invitationColumns+` FROM team_invitations
WHERE id = $1`, id,
))
}
// GetInvitationByEmail implements [team.Store].
func (s *teams) GetInvitationByEmail(
ctx context.Context,
teamID uuid.UUID,
email string,
) (*team.Invitation, error) {
return scanInvitation(s.s.db.QueryRow(ctx, `
SELECT `+invitationColumns+` FROM team_invitations
WHERE team_id = $1 AND email = $2`, teamID, email,
))
}
// GetInvitationByToken implements [team.Store].
func (s *teams) GetInvitationByToken(
ctx context.Context,
digest string,
) (*team.Invitation, error) {
return scanInvitation(s.s.db.QueryRow(ctx, `
SELECT `+invitationColumns+` FROM team_invitations
WHERE token = $1`, digest,
))
}
// UpdateInvitation implements [team.Store].
func (s *teams) UpdateInvitation(
ctx context.Context,
inv *team.Invitation,
) error {
if _, err := s.s.db.Exec(ctx, `
UPDATE team_invitations SET
token = $2, status = $3, rejections = $4,
cooldown_until = $5, expires_at = $6, updated_at = $7
WHERE id = $1`,
inv.ID,
inv.Token,
inv.Status,
inv.Rejections,
inv.CooldownUntil.UTC(),
inv.ExpiresAt.UTC(),
inv.UpdatedAt,
); err != nil {
return fmt.Errorf("failed to update invitation: %w", err)
}
return nil
}
// DeleteInvitation implements [team.Store].
func (s *teams) DeleteInvitation(
ctx context.Context,
id uuid.UUID,
) (bool, error) {
res, err := s.s.db.Exec(
ctx, `DELETE FROM team_invitations WHERE id = $1`, id,
)
if err != nil {
return false, fmt.Errorf("failed to delete invitation: %w", err)
}
return res.RowsAffected() > 0, nil
}
// ListInvitations implements [team.Store].
func (s *teams) ListInvitations(
ctx context.Context,
teamID uuid.UUID,
) ([]team.Invitation, error) {
rows, err := s.s.db.Query(ctx, `
SELECT `+invitationColumns+` FROM team_invitations
WHERE team_id = $1
ORDER BY created_at DESC, id`,
teamID,
)
if err != nil {
return nil, fmt.Errorf("failed to list invitations: %w", err)
}
defer rows.Close()
var out []team.Invitation
for rows.Next() {
inv, err := scanInvitation(rows)
if err != nil {
return nil, err
}
out = append(out, *inv)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to list invitations: %w", err)
}
return out, nil
}
var _ team.Store = (*teams)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package postgres
import (
"context"
"uuid"
"github.com/deep-rent/nexus/eco/iam/ticket"
)
// ticketStore extends the artifact table with the owner-scoped revocation
// of [ticket.Store].
type ticketStore struct {
keyed[string, ticket.Ticket]
}
// DeleteForOwner implements [ticket.Store].
func (s *ticketStore) DeleteForOwner(
ctx context.Context,
owner uuid.UUID,
) error {
return s.deleteBy(ctx, "owner", owner)
}
var _ ticket.Store = (*ticketStore)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package postgres
import (
"context"
"uuid"
"github.com/deep-rent/nexus/eco/iam/oauth"
)
// refreshTokenStore extends the artifact table with the lineage-scoped
// revocation of [oauth.RefreshTokenStore].
type refreshTokenStore struct {
keyed[oauth.Digest, oauth.RefreshToken]
}
// DeleteForFamily implements [oauth.RefreshTokenStore].
func (s *refreshTokenStore) DeleteForFamily(
ctx context.Context,
family uuid.UUID,
) error {
return s.deleteBy(ctx, "family", family)
}
var _ oauth.RefreshTokenStore = (*refreshTokenStore)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package postgres
import (
"context"
"uuid"
"github.com/deep-rent/nexus/eco/iam/trust"
)
// trustStore extends the artifact table with the owner-scoped queries of
// [trust.Store].
type trustStore struct {
keyed[string, trust.Record]
}
// ListForOwner implements [trust.Store].
func (s *trustStore) ListForOwner(
ctx context.Context,
owner uuid.UUID,
) ([]trust.Record, error) {
return s.listBy(ctx, "owner", owner)
}
// DeleteForOwner implements [trust.Store].
func (s *trustStore) DeleteForOwner(
ctx context.Context,
owner uuid.UUID,
) error {
return s.deleteBy(ctx, "owner", owner)
}
var _ trust.Store = (*trustStore)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package postgres
import (
"context"
"errors"
"fmt"
"strings"
"time"
"uuid"
"github.com/jackc/pgx/v5"
"github.com/deep-rent/nexus/dat/page"
"github.com/deep-rent/nexus/dat/search"
"github.com/deep-rent/nexus/eco/iam/user"
)
// userColumns is the column list shared by every user query, aligned with
// scanUser. The nullable text columns collapse NULL to the empty string the
// record models it as; the array columns scan into their slices natively.
var userColumns = strings.Join([]string{
"id",
"name",
"COALESCE(display_name, '')",
"email",
"email_verified",
"COALESCE(recovery_email, '')",
"recovery_email_verified",
"COALESCE(phone, '')",
"phone_verified",
"locales",
"COALESCE(zone, '')",
"password",
"password_changed_at",
"roles",
"factors",
"team_limit",
"membership_limit",
"seat_limit",
"disabled",
"alert_login",
"alert_password_change",
"alert_team_join",
"alert_team_leave",
"created_at",
"updated_at",
"COALESCE(avatar, '')",
}, ", ")
// users implements [user.Store].
type users struct {
s *Store
}
// scanUser reads one user row in userColumns order.
func scanUser(row scanner) (*user.User, error) {
var u user.User
var passwordChangedAt *time.Time
err := row.Scan(
&u.ID,
&u.Name,
&u.DisplayName,
&u.Email,
&u.EmailVerified,
&u.RecoveryEmail,
&u.RecoveryEmailVerified,
&u.Phone,
&u.PhoneVerified,
&u.Locales,
&u.Zone,
&u.Password,
&passwordChangedAt,
&u.Roles,
&u.Factors,
&u.TeamLimit,
&u.MembershipLimit,
&u.SeatLimit,
&u.Disabled,
&u.Alerts.Login,
&u.Alerts.PasswordChange,
&u.Alerts.TeamJoin,
&u.Alerts.TeamLeave,
&u.CreatedAt,
&u.UpdatedAt,
&u.Avatar,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to read user: %w", err)
}
if passwordChangedAt != nil {
u.PasswordChangedAt = *passwordChangedAt
}
u.Locales = emptyToNil(u.Locales)
u.Roles = emptyToNil(u.Roles)
u.Factors = emptyToNil(u.Factors)
return &u, nil
}
// Create implements [user.Store].
func (s *users) Create(ctx context.Context, u *user.User) error {
_, err := s.s.db.Exec(ctx, `
INSERT INTO users (
id, name, display_name, email, email_verified,
recovery_email, recovery_email_verified, phone, phone_verified,
locales, zone, password, password_changed_at,
roles, factors, team_limit, membership_limit, seat_limit,
disabled, alert_login, alert_password_change,
alert_team_join, alert_team_leave,
created_at, updated_at, avatar
) VALUES (
$1, $2, NULLIF($3, ''), $4, $5, NULLIF($6, ''), $7,
NULLIF($8, ''), $9, $10, NULLIF($11, ''), $12, $13, $14,
$15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25,
NULLIF($26, '')
)`,
u.ID,
u.Name,
u.DisplayName,
u.Email,
u.EmailVerified,
u.RecoveryEmail,
u.RecoveryEmailVerified,
u.Phone,
u.PhoneVerified,
nilToEmpty(u.Locales),
u.Zone,
u.Password,
nullTime(u.PasswordChangedAt),
nilToEmpty(u.Roles),
nilToEmpty(u.Factors),
u.TeamLimit,
u.MembershipLimit,
u.SeatLimit,
u.Disabled,
u.Alerts.Login,
u.Alerts.PasswordChange,
u.Alerts.TeamJoin,
u.Alerts.TeamLeave,
u.CreatedAt,
u.UpdatedAt,
u.Avatar,
)
if duplicate(err) {
return fmt.Errorf("%w: user", user.ErrDuplicate)
}
if err != nil {
return fmt.Errorf("failed to insert user: %w", err)
}
return nil
}
// Get implements [user.Store].
func (s *users) Get(ctx context.Context, id uuid.UUID) (*user.User, error) {
return scanUser(s.s.db.QueryRow(
ctx,
"SELECT "+userColumns+" FROM users WHERE id = $1",
id,
))
}
// GetByEmail implements [user.Store].
func (s *users) GetByEmail(
ctx context.Context,
email string,
) (*user.User, error) {
if email == "" {
return nil, nil
}
return scanUser(s.s.db.QueryRow(
ctx,
"SELECT "+userColumns+" FROM users WHERE email = $1",
email,
))
}
// GetByRecoveryEmail implements [user.Store].
func (s *users) GetByRecoveryEmail(
ctx context.Context,
email string,
) (*user.User, error) {
if email == "" {
return nil, nil
}
return scanUser(s.s.db.QueryRow(
ctx,
"SELECT "+userColumns+" FROM users WHERE recovery_email = $1",
email,
))
}
// Update implements [user.Store]. The avatar column is deliberately not
// written: it changes only through [users.SetAvatar], whose atomic
// exchange is what keeps the avatar engine's object eviction truthful — a
// read-modify-write here could silently clobber a concurrent swap.
func (s *users) Update(ctx context.Context, u *user.User) error {
_, err := s.s.db.Exec(ctx, `
UPDATE users SET
name = $2, display_name = NULLIF($3, ''), email = $4,
email_verified = $5,
recovery_email = NULLIF($6, ''), recovery_email_verified = $7,
phone = NULLIF($8, ''), phone_verified = $9,
locales = $10, zone = NULLIF($11, ''), password = $12,
password_changed_at = $13, roles = $14, factors = $15,
team_limit = $16, membership_limit = $17, seat_limit = $18,
disabled = $19, alert_login = $20, alert_password_change = $21,
alert_team_join = $22, alert_team_leave = $23,
updated_at = $24
WHERE id = $1`,
u.ID,
u.Name,
u.DisplayName,
u.Email,
u.EmailVerified,
u.RecoveryEmail,
u.RecoveryEmailVerified,
u.Phone,
u.PhoneVerified,
nilToEmpty(u.Locales),
u.Zone,
u.Password,
nullTime(u.PasswordChangedAt),
nilToEmpty(u.Roles),
nilToEmpty(u.Factors),
u.TeamLimit,
u.MembershipLimit,
u.SeatLimit,
u.Disabled,
u.Alerts.Login,
u.Alerts.PasswordChange,
u.Alerts.TeamJoin,
u.Alerts.TeamLeave,
u.UpdatedAt,
)
if duplicate(err) {
return fmt.Errorf("%w: user", user.ErrDuplicate)
}
if err != nil {
return fmt.Errorf("failed to update user: %w", err)
}
return nil
}
// SetPassword implements [user.Store].
func (s *users) SetPassword(
ctx context.Context,
id uuid.UUID,
hash []byte,
changedAt time.Time,
) error {
if _, err := s.s.db.Exec(
ctx,
`UPDATE users SET password = $2, password_changed_at = $3,
updated_at = CURRENT_TIMESTAMP WHERE id = $1`,
id, hash, nullTime(changedAt),
); err != nil {
return fmt.Errorf("failed to set password: %w", err)
}
return nil
}
// SetAvatar implements [user.Store]. The row is locked while the old key
// is read, so two concurrent swaps serialize and each learns the true key
// it displaced.
func (s *users) SetAvatar(
ctx context.Context,
id uuid.UUID,
key string,
) (prior string, found bool, err error) {
err = s.s.db.QueryRow(ctx, `
UPDATE users u SET
avatar = NULLIF($2, ''), updated_at = CURRENT_TIMESTAMP
FROM (
SELECT id, COALESCE(avatar, '') AS prior
FROM users WHERE id = $1 FOR UPDATE
) old
WHERE u.id = old.id
RETURNING old.prior`,
id, key,
).Scan(&prior)
if errors.Is(err, pgx.ErrNoRows) {
return "", false, nil
}
if err != nil {
return "", false, fmt.Errorf("failed to set avatar: %w", err)
}
return prior, true, nil
}
// Delete implements [user.Store]. Durable dependents (identities, passkey
// credentials) cascade away with the row; ephemeral artifacts are revoked
// explicitly by the management layer.
func (s *users) Delete(ctx context.Context, id uuid.UUID) (bool, error) {
res, err := s.s.db.Exec(
ctx,
"DELETE FROM users WHERE id = $1",
id,
)
if err != nil {
return false, fmt.Errorf("failed to delete user: %w", err)
}
return res.RowsAffected() > 0, nil
}
// userListing searches a user's generated search_name column and their
// (already lower-cased) email address, and filters on the two account
// state columns.
var userListing = listing{
search: []string{"search_name", "email"},
compiler: search.Compiler{Columns: map[string]string{
user.ByName: "search_name",
user.ByCreated: "created_at",
user.FieldVerified: "email_verified",
user.FieldDisabled: "disabled",
}},
tie: "id",
}
// List implements [user.Store].
func (s *users) List(
ctx context.Context,
q user.Query,
) (page.Page[*user.User], error) {
return list(
ctx, s.s.db, userListing,
"users", userColumns,
user.Search.Normalize(q), scanUser,
)
}
// ListByRole implements [user.Store]. The role set is an array column,
// so membership is an array containment the search vocabulary cannot
// express; the ordering matches the listing's, by generated search
// name, so a staff picker reads the same way as a user listing.
func (s *users) ListByRole(
ctx context.Context,
role string,
limit int,
) ([]*user.User, error) {
if role == "" || limit <= 0 {
return nil, nil
}
rows, err := s.s.db.Query(
ctx,
"SELECT "+userColumns+" FROM users "+
"WHERE roles @> ARRAY[$1]::varchar[] "+
"ORDER BY search_name, id LIMIT $2",
role, limit,
)
if err != nil {
return nil, fmt.Errorf("failed to query users by role: %w", err)
}
defer rows.Close()
var out []*user.User
for rows.Next() {
u, err := scanUser(rows)
if err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
}
// GetIdentity implements [user.Store].
func (s *users) GetIdentity(
ctx context.Context,
provider, subject string,
) (*user.Identity, error) {
var i user.Identity
err := s.s.db.QueryRow(ctx, `
SELECT provider, subject, user_id, created_at
FROM user_identities WHERE provider = $1 AND subject = $2`,
provider, subject,
).Scan(
&i.Provider,
&i.Subject,
&i.UserID,
&i.CreatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to read identity: %w", err)
}
return &i, nil
}
// LinkIdentity implements [user.Store].
func (s *users) LinkIdentity(ctx context.Context, id user.Identity) error {
_, err := s.s.db.Exec(ctx, `
INSERT INTO user_identities (provider, subject, user_id, created_at)
VALUES ($1, $2, $3, $4)`,
id.Provider,
id.Subject,
id.UserID,
id.CreatedAt,
)
if duplicate(err) {
return fmt.Errorf("%w: identity", user.ErrDuplicate)
}
if err != nil {
return fmt.Errorf("failed to link identity: %w", err)
}
return nil
}
// UnlinkIdentity implements [user.Store].
func (s *users) UnlinkIdentity(
ctx context.Context,
provider string,
userID uuid.UUID,
) (bool, error) {
res, err := s.s.db.Exec(
ctx,
"DELETE FROM user_identities WHERE provider = $1 AND user_id = $2",
provider, userID,
)
if err != nil {
return false, fmt.Errorf("failed to unlink identity: %w", err)
}
return res.RowsAffected() > 0, nil
}
// ListIdentities implements [user.Store].
func (s *users) ListIdentities(
ctx context.Context,
userID uuid.UUID,
) ([]user.Identity, error) {
rows, err := s.s.db.Query(ctx, `
SELECT provider, subject, user_id, created_at
FROM user_identities WHERE user_id = $1
ORDER BY provider`,
userID,
)
if err != nil {
return nil, fmt.Errorf("failed to list identities: %w", err)
}
defer rows.Close()
var out []user.Identity
for rows.Next() {
var i user.Identity
if err := rows.Scan(
&i.Provider,
&i.Subject,
&i.UserID,
&i.CreatedAt,
); err != nil {
return nil, fmt.Errorf("failed to read identity: %w", err)
}
out = append(out, i)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to list identities: %w", err)
}
return out, nil
}
var _ user.Store = (*users)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package flow
import (
"context"
"slices"
"time"
"uuid"
"github.com/deep-rent/nexus/eco/iam/artifact"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/sys/log"
)
// Transaction is the persisted state of an in-progress login. It holds no
// secret: the client-facing handle is stored only as its digest
// ([Transaction.ID]),
// and each step keeps its own state elsewhere.
type Transaction struct {
// ID is the digest of the client-facing handle and the storage key.
ID string `json:"id"`
// Owner identifies the authenticated user, carried through to
// [Result.Owner] on completion.
Owner uuid.UUID `json:"owner"`
// Completed lists the IDs of the steps finished so far, in order.
Completed []string `json:"completed,omitzero"`
// Remember records whether the client asked to be remembered, carried
// through to [Result.Remember] on completion.
Remember bool `json:"remember,omitzero"`
// ExpiresAt is the expiry of the whole login.
ExpiresAt time.Time `json:"expires_at"`
}
// done reports whether the step with the given ID has been completed.
func (t *Transaction) done(id string) bool {
return slices.Contains(t.Completed, id)
}
// complete records the step as finished. It reallocates Completed rather than
// appending in place, so the transaction never shares a backing array with the
// stored record — two concurrent operations on the same handle therefore cannot
// race on it.
func (t *Transaction) complete(id string) {
t.Completed = append(slices.Clip(t.Completed), id)
}
// Store persists login transactions keyed by [Transaction.ID]. See
// [artifact.Store] for the storage contract, notably the atomic deletion the
// engine relies on to establish a login exactly once under concurrent
// completions.
type Store = artifact.Store[string, Transaction]
// Coordinator drives multi-step logins over a [Store]. It is transport-agnostic
// and does not throttle, leaving those to the caller, which maps the returned
// [Result] onto its own protocol.
//
// A Coordinator is safe for concurrent use if its [Store] is.
type Coordinator struct {
store Store
lifetime time.Duration
now clock.Clock
secrets artifact.Digester
logger *log.Logger
}
// New creates a [Coordinator] backed by the given [Store]. It panics if store
// is nil, since that is a startup configuration error.
func New(store Store, opts ...Option) *Coordinator {
if store == nil {
panic("store is required")
}
c := &Coordinator{
store: store,
lifetime: DefaultLifetime,
now: clock.System,
logger: log.Discard(),
}
for _, opt := range opts {
opt(c)
}
return c
}
// Begin starts a login for an already-authenticated owner with the given
// ordered steps, activating the first and returning the client-facing handle.
//
// An empty plan means no further factors are required: no transaction is
// minted, and the result is [StatusDone] with an empty handle. The remember
// flag is carried through to completion. If activating the first step fails,
// the transaction is removed and the error returned.
func (c *Coordinator) Begin(
ctx context.Context,
owner uuid.UUID,
remember bool,
steps Course,
) (handle string, res Result, err error) {
if err := steps.validate(); err != nil {
return "", Result{}, err
}
if len(steps) == 0 {
return "", Result{
Status: StatusDone,
Owner: owner,
Remember: remember,
}, nil
}
handle, id, err := c.secrets.Mint(ctx)
if err != nil {
return "", Result{}, err
}
t := Transaction{
ID: id,
Owner: owner,
Remember: remember,
ExpiresAt: c.now().Add(c.lifetime),
}
if err := c.store.Create(ctx, t); err != nil {
return "", Result{}, err
}
active := steps[0]
payload, err := active.Begin(ctx, &t, handle)
if err != nil {
// The step failure travels back to the caller, whose error boundary
// records it with the request attributes attached; logging it here
// too would report it twice. The cleanup below is swallowed rather
// than returned, so it is the one that has to speak up.
if _, err := c.store.Delete(ctx, t.ID); err != nil {
c.logger.Error(
ctx,
"Could not delete unstartable transaction",
log.Error(err),
)
}
return "", Result{}, err
}
return handle, Result{
Status: StatusPrompt,
Prompt: Prompt{Step: active.ID(), Payload: payload},
}, nil
}
// Continue verifies the client's input against the active step and advances the
// login. The caller supplies a [Plan] so the freshly-planned steps — reflecting
// any change to the owner's factors — decide what runs next.
//
// The error return is reserved for storage and step failures; all logical
// results are conveyed by the [Result].
func (c *Coordinator) Continue(
ctx context.Context,
handle string,
plan Plan,
in Input,
) (Result, error) {
id := c.secrets.Key(handle)
t, found, err := c.store.Get(ctx, id)
if err != nil {
return Result{}, err
}
if !found {
return Result{Status: StatusInvalid, Reason: ReasonUnknown}, nil
}
if c.expired(t) {
return Result{
Status: StatusInvalid,
Reason: ReasonExpired,
Owner: t.Owner,
}, nil
}
steps, err := c.resolve(ctx, plan, t.Owner)
if err != nil {
return Result{}, err
}
active := steps.pending(&t)
if active == nil {
// The plan no longer has pending steps (it shrank since the last call);
// nothing is left to prove, so the login is complete.
return c.finish(ctx, t)
}
verdict, err := active.Verify(ctx, &t, handle, in)
if err != nil {
return Result{}, err
}
switch verdict {
case VerdictReject:
return Result{Status: StatusWrongInput, Owner: t.Owner}, nil
case VerdictFail:
if _, err := c.store.Delete(ctx, t.ID); err != nil {
c.logger.Error(
ctx,
"Could not delete failed transaction",
log.Error(err),
)
}
return Result{
Status: StatusInvalid,
Reason: ReasonStepFailed,
Owner: t.Owner,
}, nil
}
t.complete(active.ID())
next := steps.pending(&t)
if next == nil {
return c.finish(ctx, t)
}
// Persist progress before activating the next step, so a delivery failure
// there does not hand out a free retry of the step just completed.
if err := c.store.Update(ctx, t); err != nil {
return Result{}, err
}
payload, err := next.Begin(ctx, &t, handle)
if err != nil {
// The next step could not be activated (e.g. its code would not send).
// Abort the whole transaction so the client restarts from a clean slate
// rather than being stranded on a step with no prompt. The step failure
// itself is returned, and so recorded by the caller's error boundary;
// only the swallowed cleanup failure is logged here.
if _, err := c.store.Delete(ctx, t.ID); err != nil {
c.logger.Error(
ctx,
"Could not delete unadvanceable transaction",
log.Error(err),
)
}
return Result{}, err
}
return Result{
Status: StatusPrompt,
Prompt: Prompt{Step: next.ID(), Payload: payload},
}, nil
}
// Act runs an out-of-band action against the active step — resending a code or
// switching channels, say — and returns the refreshed prompt. The caller
// supplies a [Plan] to resolve the active step.
func (c *Coordinator) Act(
ctx context.Context,
handle string,
plan Plan,
a Action,
) (Result, error) {
id := c.secrets.Key(handle)
t, found, err := c.store.Get(ctx, id)
if err != nil {
return Result{}, err
}
if !found {
return Result{Status: StatusInvalid, Reason: ReasonUnknown}, nil
}
if c.expired(t) {
return Result{
Status: StatusInvalid,
Reason: ReasonExpired,
Owner: t.Owner,
}, nil
}
steps, err := c.resolve(ctx, plan, t.Owner)
if err != nil {
return Result{}, err
}
active := steps.pending(&t)
if active == nil {
// The plan shrank to nothing pending since the last call, so the login
// is already complete — mirror Continue and finish it rather than
// rejecting an action on an effectively-done flow.
return c.finish(ctx, t)
}
payload, err := active.Act(ctx, &t, handle, a)
if err != nil {
return Result{}, err
}
return Result{
Status: StatusPrompt,
Prompt: Prompt{Step: active.ID(), Payload: payload},
}, nil
}
// resolve runs the plan and validates it.
func (*Coordinator) resolve(
ctx context.Context,
plan Plan,
owner uuid.UUID,
) (Course, error) {
steps, err := plan(ctx, owner)
if err != nil {
return nil, err
}
if err := steps.validate(); err != nil {
return nil, err
}
return steps, nil
}
// finish deletes the transaction and returns a completed result. The atomic
// delete enforces single use: of two concurrent completions, only the one that
// performs the deletion establishes the login.
func (c *Coordinator) finish(
ctx context.Context,
t Transaction,
) (Result, error) {
deleted, err := c.store.Delete(ctx, t.ID)
if err != nil {
return Result{}, err
}
if !deleted {
return Result{
Status: StatusInvalid,
Reason: ReasonReplayed,
Owner: t.Owner,
}, nil
}
return Result{
Status: StatusDone,
Owner: t.Owner,
Remember: t.Remember,
}, nil
}
// expired reports whether the transaction has passed its expiry.
func (c *Coordinator) expired(t Transaction) bool {
return c.now().After(t.ExpiresAt)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package flow
import (
"context"
"errors"
"fmt"
"uuid"
)
// ErrRateLimited is returned by [Step.Act] when an action is refused because a
// per-step allowance — such as a resend cap — is exhausted. It is a soft
// failure the client may retry later; transports typically map it to HTTP 429.
var ErrRateLimited = errors.New("action rate limited")
// ErrRejected is returned by [Step.Act] when an action request is malformed —
// an unsupported action or an unknown parameter. It is a client error;
// transports typically map it to HTTP 400.
var ErrRejected = errors.New("action rejected")
// Verdict is the outcome of verifying a client's input against a [Step]. It is
// the step's domain result, which the [Coordinator] translates into flow
// control and a [Result].
type Verdict int
const (
// VerdictOK indicates the input was accepted; the flow advances to the
// next step.
VerdictOK Verdict = iota
// VerdictReject indicates the input was wrong but the step remains live,
// so the client may try again against the same prompt.
VerdictReject
// VerdictFail indicates the step has permanently failed — for example, its
// attempt budget is exhausted — and the whole login must be restarted.
VerdictFail
)
// Input carries a client's submission for the active [Step].
type Input struct {
// Value is the primary credential when it is a simple string, such as a
// one-time password.
Value string
// Raw is a structured credential payload for steps whose input is not a
// simple string, such as a WebAuthn assertion.
Raw []byte
// Extra holds optional additional fields for steps that need more than a
// single value.
Extra map[string]string
}
// Action requests an out-of-band operation on the active [Step], such as
// resending a code or switching the delivery channel.
type Action struct {
// Name identifies the action (e.g. "resend").
Name string
// Extra carries action parameters (e.g. {"channel": "email"}).
Extra map[string]string
}
// Prompt describes what the client must do next: which step is active and any
// step-specific data needed to satisfy it.
type Prompt struct {
// Step is the [Step.ID] of the active step.
Step string
// Payload is step-specific data for the client, such as the available
// delivery channels and a code's remaining lifetime. It is opaque to the
// engine and marshaled by the transport.
Payload any
}
// Step is one factor in a login chain, built per-login by a [Plan] with full
// knowledge of the user.
//
// A step owns its own state, deriving whatever it needs from the raw
// transaction handle it is given (typically by keying its own store on a value
// derived from the handle). The [Transaction] it receives is read-only: the
// [Coordinator] owns completion tracking and persistence, and does not save
// any field a step sets on it, so implementations must not stash state there.
// Implementations should be safe for concurrent use and honor the context.
type Step interface {
// ID returns the step's identifier, unique within a plan and stable
// across a single login for a given factor (e.g. "otp", "totp"). It
// labels the step to the client and keys the step's completion: a step
// whose ID already appears among the transaction's completed IDs is
// treated as satisfied and skipped, so a [Plan] must not reuse an ID for
// a different factor mid-login (e.g. return "otp" for an OTP step on one
// call and a WebAuthn step on the next) or the re-planned factor goes
// unverified.
ID() string
// Begin activates the step when it becomes current — for example, by
// delivering a code — and returns the payload describing its prompt.
Begin(ctx context.Context, t *Transaction, handle string) (any, error)
// Verify checks the client's input against the active step.
Verify(
ctx context.Context,
t *Transaction,
handle string,
in Input,
) (Verdict, error)
// Act runs an out-of-band action on the active step and returns the
// payload for the refreshed prompt. It may return [ErrRateLimited] as a
// soft failure. A step that supports no actions returns an error.
Act(
ctx context.Context,
t *Transaction,
handle string,
a Action,
) (any, error)
}
// Course is an ordered plan of steps a flow works through. The steps
// carry the flow; the course only fixes their order and identity.
type Course []Step
// validate rejects a plan with empty or duplicate step IDs, since both would
// make completion tracking ambiguous.
func (c Course) validate() error {
seen := make(map[string]struct{}, len(c))
for _, s := range c {
id := s.ID()
if id == "" {
return errors.New("step ID must not be empty")
}
if _, ok := seen[id]; ok {
return fmt.Errorf("duplicate step ID %q", id)
}
seen[id] = struct{}{}
}
return nil
}
// pending returns the first step not yet completed in the given transaction,
// or nil when every step is done.
func (c Course) pending(t *Transaction) Step {
for _, s := range c {
if !t.done(s.ID()) {
return s
}
}
return nil
}
// Plan produces the ordered steps for a login given the owner recorded in the
// transaction. It is invoked on every continuation, so changes to the owner's
// enrolled factors take effect mid-login. Returning an empty slice means no
// (further) factors are required.
type Plan func(ctx context.Context, owner uuid.UUID) (Course, error)
// Status is the logical result of a [Coordinator] operation.
type Status int
const (
// StatusPrompt indicates a step awaits the client; [Result.Prompt] is set.
StatusPrompt Status = iota
// StatusDone indicates every step is complete and the login may be
// established; [Result.Owner] and [Result.Remember] are set.
StatusDone
// StatusWrongInput indicates the active step rejected the input but remains
// live, so the client may retry.
StatusWrongInput
// StatusInvalid indicates the transaction is absent, expired, or was
// aborted by a failed step. The reasons are collapsed so callers cannot
// leak which applies; [Result.Reason] preserves the true cause for the
// caller's own telemetry.
StatusInvalid
)
// Reason names the true cause behind a terminal [StatusInvalid]. It exists
// for audit trails and metrics on the server side only: the wire response
// deliberately collapses every reason into the same refusal, and callers
// must never echo it to the client.
type Reason int
const (
// ReasonNone marks a result that is not [StatusInvalid].
ReasonNone Reason = iota
// ReasonUnknown means no transaction exists under the handle.
ReasonUnknown
// ReasonExpired means the transaction outlived its lifetime.
ReasonExpired
// ReasonStepFailed means the active step failed terminally — for
// example, its attempt budget was exhausted.
ReasonStepFailed
// ReasonReplayed means a concurrent completion consumed the
// transaction first.
ReasonReplayed
)
// String names the reason for log and metric labels.
func (r Reason) String() string {
switch r {
case ReasonUnknown:
return "unknown"
case ReasonExpired:
return "expired"
case ReasonStepFailed:
return "step_failed"
case ReasonReplayed:
return "replayed"
default:
return "none"
}
}
// Result carries the outcome of a [Coordinator] operation.
type Result struct {
// Status is the logical result.
Status Status
// Reason preserves the true cause of a [StatusInvalid] for server-side
// telemetry; see [Reason]. It is [ReasonNone] for every other status.
Reason Reason
// Prompt describes the next step; set when the status is [StatusPrompt].
Prompt Prompt
// Owner is the authenticated user. It is set whenever the transaction
// was resolved — on [StatusDone], [StatusWrongInput], and a
// [StatusInvalid] with a known cause — so telemetry can name the
// affected user. It is never set for an unknown handle.
Owner uuid.UUID
// Remember reports whether the client asked to be remembered; set when
// the status is [StatusDone].
Remember bool
}
// Done reports whether the flow has completed and a login may be established.
func (r Result) Done() bool { return r.Status == StatusDone }
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package flow
import (
"time"
"github.com/deep-rent/nexus/sec/digest"
"github.com/deep-rent/nexus/sec/nonce"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/sys/log"
)
// DefaultLifetime is the validity period of a login transaction applied by
// [New] when [WithLifetime] is not given. It bounds the whole multi-step login,
// independent of any per-step lifetime.
const DefaultLifetime = 10 * time.Minute
// Option configures a [Coordinator].
type Option func(*Coordinator)
// WithLifetime sets the validity period of a login transaction. Nonpositive
// values are ignored. Defaults to [DefaultLifetime].
func WithLifetime(d time.Duration) Option {
return func(c *Coordinator) {
if d > 0 {
c.lifetime = d
}
}
}
// WithClock overrides the time source, primarily for testing. A nil function
// is ignored. Defaults to [clock.System].
func WithClock(now clock.Clock) Option {
return func(c *Coordinator) {
if now != nil {
c.now = now
}
}
}
// WithHandleGenerator overrides the source of client-facing transaction
// handles. A nil generator is ignored. Defaults to [nonce.DefaultGenerator]
// (256-bit handles).
func WithHandleGenerator(g *nonce.Generator) Option {
return func(c *Coordinator) {
if g != nil {
c.secrets.Source = g
}
}
}
// WithHasher sets the hasher that fingerprints transaction handles before
// they reach the store. A nil hasher is ignored. Defaults to
// [digest.DefaultHasher].
func WithHasher(h *digest.Hasher) Option {
return func(c *Coordinator) {
if h != nil {
c.secrets.Hasher = h
}
}
}
// WithLogger injects a structured logger for best-effort cleanup diagnostics.
// A nil logger is ignored. Defaults to [log.Discard], keeping the engine
// silent unless a logger is injected.
func WithLogger(logger *log.Logger) Option {
return func(c *Coordinator) {
if logger != nil {
c.logger = logger
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package apple
import (
"context"
"encoding/json/v2"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/deep-rent/nexus/dat/cache"
"github.com/deep-rent/nexus/eco/iam/idp"
"github.com/deep-rent/nexus/eco/iam/oidc"
"github.com/deep-rent/nexus/net/transport"
"github.com/deep-rent/nexus/sec/jose/jwa"
"github.com/deep-rent/nexus/sec/jose/jwk"
"github.com/deep-rent/nexus/sec/jose/jwt"
"github.com/deep-rent/nexus/sec/sign"
"github.com/deep-rent/nexus/std/clock"
)
// Apple endpoints and token Issuer, as documented at
// https://developer.apple.com/documentation/signinwithapple.
const (
AuthEndpoint = "https://appleid.apple.com/auth/authorize"
TokenEndpoint = "https://appleid.apple.com/auth/token"
KeySetURL = "https://appleid.apple.com/auth/keys"
Issuer = "https://appleid.apple.com"
)
// SecretLifetime bounds the validity of the self-signed client secret JWT.
// Apple allows up to six months; a short window suffices since the secret
// is minted per exchange.
const SecretLifetime = 5 * time.Minute
// Provider implements [idp.Provider] for Apple.
type Provider struct {
clientID string
teamID string
redirectURI string
scopes []string
key jwk.KeyPair
client *http.Client
keys jwk.CacheSet
verifier jwt.Verifier[*oidc.IDToken]
auth string
token string
now clock.Clock
}
// New assembles an Apple [Provider] from the given configuration.
//
// It panics if a required [Config] field is missing or if the private key
// cannot be parsed as an ES256-capable PKCS#8 key; provider construction
// happens once at startup, so misconfiguration is a programmer error.
// Remember to dispatch [Provider.Keys] to a scheduler so that ID token
// verification has fresh signing keys available.
func New(cfg Config) *Provider {
switch {
case cfg.ClientID == "":
panic("client ID is required")
case cfg.TeamID == "":
panic("team ID is required")
case cfg.KeyID == "":
panic("key ID is required")
case len(cfg.PrivateKey) == 0:
panic("private key is required")
case cfg.RedirectURI == "":
panic("redirect URI is required")
}
key, err := parseKey(cfg.PrivateKey, cfg.KeyID)
if err != nil {
panic(err.Error())
}
client := cfg.Client
if client == nil {
client = transport.DefaultClient
}
scopes := cfg.Scopes
if scopes == nil {
scopes = DefaultScopes
}
keys := jwk.NewCacheSet(KeySetURL, cache.WithClient(client))
return &Provider{
clientID: cfg.ClientID,
teamID: cfg.TeamID,
redirectURI: cfg.RedirectURI,
scopes: scopes,
key: key,
client: client,
keys: keys,
verifier: jwt.NewVerifier[*oidc.IDToken](
keys,
jwt.WithIssuers(Issuer),
jwt.WithAudiences(cfg.ClientID),
jwt.WithLeeway(time.Minute),
),
auth: AuthEndpoint,
token: TokenEndpoint,
now: clock.System,
}
}
// parseKey decodes the PEM-encoded private key and wraps it into an ES256
// signing key pair carrying the given key ID.
func parseKey(pemBytes []byte, kid string) (jwk.KeyPair, error) {
signer, err := sign.Decode(pemBytes)
if err != nil {
return nil, fmt.Errorf(
"failed to parse Config.PrivateKey: %w",
err,
)
}
key := jwk.NewKeyPair(jwa.ES256, kid, signer)
if key == nil {
return nil, errors.New(
"Config.PrivateKey is not usable for ES256 signing",
)
}
return key, nil
}
// Keys returns the cached view of Apple's remote JWKS used for ID token
// verification.
//
// The returned set implements [schedule.Tick]; dispatch it to a scheduler so
// the keys are fetched and periodically refreshed in the background:
//
// s := schedule.New(ctx)
// s.Dispatch(p.Keys())
//
// Until the first successful fetch completes, ID token verification fails
// with [jwt.ErrKeyNotFound]; block on the set's Ready channel during
// startup to guarantee keys are available before serving logins:
//
// <-p.Keys().Ready()
//
// [schedule.Tick]: github.com/deep-rent/nexus/schedule#Tick
func (p *Provider) Keys() jwk.CacheSet { return p.keys }
// AuthURL implements [idp.Provider].
func (p *Provider) AuthURL(_ context.Context, state string) (string, error) {
q := url.Values{
"client_id": {p.clientID},
"redirect_uri": {p.redirectURI},
"response_type": {"code"},
"state": {state},
}
if len(p.scopes) > 0 {
q.Set("scope", strings.Join(p.scopes, " "))
// Apple mandates the form_post response mode whenever scopes are
// requested. The callback then arrives as a cross-site POST.
q.Set("response_mode", "form_post")
}
return p.auth + "?" + q.Encode(), nil
}
// secretClaims is the payload of the self-signed client secret JWT.
type secretClaims struct {
Iss string `json:"iss"`
Iat time.Time `json:"iat"`
Exp time.Time `json:"exp"`
Aud string `json:"aud"`
Sub string `json:"sub"`
}
// clientSecret mints the short-lived ES256 JWT that authenticates the
// provider against Apple's token endpoint.
func (p *Provider) clientSecret(ctx context.Context) (string, error) {
now := p.now()
token, err := jwt.Sign(ctx, p.key, secretClaims{
Iss: p.teamID,
Iat: now,
Exp: now.Add(SecretLifetime),
Aud: Issuer,
Sub: p.clientID,
})
if err != nil {
return "", fmt.Errorf("failed to sign client secret: %w", err)
}
return string(token), nil
}
// user models the one-time "user" JSON payload Apple posts alongside the
// first authorization of a subject.
type user struct {
Name struct {
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
} `json:"name"`
}
// Exchange implements [idp.Provider].
//
// It exchanges the authorization code from the callback request for an ID
// token, verifies the token against Apple's signing keys, and extracts the
// user's identity. On the subject's first authorization, the display name
// from the accompanying "user" payload is merged into the result.
func (p *Provider) Exchange(
ctx context.Context,
req *http.Request,
) (idp.Claimant, error) {
secret, err := p.clientSecret(ctx)
if err != nil {
return idp.Claimant{}, err
}
claims, err := oidc.Callback(ctx, p.client, p.token, req, url.Values{
"client_id": {p.clientID},
"client_secret": {secret},
}, p.redirectURI, p.verifier)
if err != nil {
return idp.Claimant{}, err
}
claimant := claims.Claimant()
// Apple shares the user's name only once, in the "user" form field of
// the very first callback; it never appears in the ID token. The payload
// is unauthenticated form data from the user-agent, so only display
// metadata is merged — identity claims such as the email must come from
// the verified ID token.
if raw := req.FormValue("user"); raw != "" && claimant.Name == "" {
var u user
if err := json.Unmarshal([]byte(raw), &u); err == nil {
claimant.Name = strings.TrimSpace(
u.Name.FirstName + " " + u.Name.LastName,
)
}
}
return claimant, nil
}
var _ idp.Provider = (*Provider)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package idp
import (
"context"
"errors"
"net/http"
"net/url"
"strconv"
"github.com/deep-rent/nexus/eco/iam/login"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/net/throttle"
"github.com/deep-rent/nexus/sec/auth"
"github.com/deep-rent/nexus/sec/nonce"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/sys/log"
)
// Path constants define the social login endpoints managed by the [Server].
// The {provider} segment selects the registered [Provider] by name.
const (
PathLogin = "/login/{provider}"
PathCallback = "/callback/{provider}"
)
// DefaultStateCookieName names the cookie carrying the CSRF state during
// external login flows.
const DefaultStateCookieName = "oauth_state"
// stateSize is the byte length of the CSRF state parameter minted for an
// external login flow.
const stateSize = 32
// Users resolves local accounts for external identities.
type Users interface {
// GetUserByExternalID retrieves a user linked to an external
// identity provider.
//
// If no local user is linked to the external ID, it returns nil, nil
// (allowing for Just-In-Time provisioning if the implementation
// chooses to do so); an error signals a storage failure.
GetUserByExternalID(
ctx context.Context,
provider string,
identity Claimant,
) (login.User, error)
}
// ServerConfig holds the parameters for constructing a [Server].
type ServerConfig struct {
// Providers maps each registered [Provider] to the name that selects
// it in the login and callback paths. Required, non-empty.
Providers map[string]Provider
// Login is the authentication core: a verified external identity
// establishes its session through it. Required.
Login *login.Manager
// Users links external identities to local accounts. Required.
Users Users
// TerminalURI locates the frontend login page. The callback redirects
// here with error details when a social login fails. Required.
TerminalURI string
// RedirectURI is the destination after a successful external login.
// Required.
RedirectURI string
// StateCookieName overrides [DefaultStateCookieName].
StateCookieName string
// Source is the entropy source the CSRF state parameters are drawn
// from. Defaults to [nonce.DefaultSource] (crypto/rand).
Source nonce.Source
// Observer receives lifecycle [Event] notifications. A nil observer
// disables publishing.
Observer Observer
// Clock is the time source stamping events. Defaults to
// [clock.System].
Clock clock.Clock
// Logger receives structured diagnostics. Defaults to [log.Discard].
Logger *log.Logger
}
// Server brokers social logins between the registered external identity
// providers and the local login core: it redirects the resource owner to a
// provider, verifies the callback, resolves the external identity to a
// local user, and establishes the same session a password login would.
//
// Create instances with [NewServer] and attach them to a router via
// [Server.Mount].
type Server struct {
providers map[string]Provider
login *login.Manager
users Users
terminalURI string
redirectURI string
stateCookieName string
nonce *nonce.Generator
observer Observer
logger *log.Logger
now clock.Clock
}
// NewServer assembles a [Server] from the given configuration. It panics if
// a required field is missing, since that is a startup configuration error.
func NewServer(cfg ServerConfig) *Server {
switch {
case len(cfg.Providers) == 0:
panic("at least one identity provider is required")
case cfg.Login == nil:
panic("login manager is required")
case cfg.Users == nil:
panic("users is required")
case cfg.TerminalURI == "" || cfg.RedirectURI == "":
panic(
"login terminal URI and login redirect URI are " +
"required when identity providers are registered",
)
}
logger := cfg.Logger
if logger == nil {
logger = log.Discard()
}
name := cfg.StateCookieName
if name == "" {
name = DefaultStateCookieName
}
now := cfg.Clock
if now == nil {
now = clock.System
}
return &Server{
providers: cfg.Providers,
login: cfg.Login,
users: cfg.Users,
terminalURI: cfg.TerminalURI,
redirectURI: cfg.RedirectURI,
stateCookieName: name,
nonce: nonce.NewGenerator(cfg.Source, stateSize),
observer: cfg.Observer,
logger: logger,
now: now,
}
}
// Mount registers the social login endpoints on the registrar — the router
// itself for a root mount, or a [router.Group] to nest the server under a
// path prefix or shared middleware.
func (s *Server) Mount(r router.Registrar) {
r.HandleFunc(http.MethodGet, PathLogin, s.Login)
// Callbacks arrive as GET (query response mode) or POST (form_post
// response mode, e.g. Sign in with Apple).
r.HandleFunc(http.MethodGet, PathCallback, s.Callback)
r.HandleFunc(http.MethodPost, PathCallback, s.Callback)
}
// newStateCookie builds the CSRF state cookie for external login flows. It
// opts out of same-site enforcement because providers using the form_post
// response mode (e.g., Sign in with Apple) deliver the callback as a
// cross-site POST, which would not carry a Lax cookie.
func (s *Server) newStateCookie(value string, maxAge int) *http.Cookie {
return router.NewCookie(
s.stateCookieName,
value,
maxAge,
http.SameSiteNoneMode,
)
}
// Login initiates a social authentication flow by redirecting the resource
// owner to the requested external identity provider.
func (s *Server) Login(e *router.Exchange) error {
name := e.Param("provider")
provider, ok := s.providers[name]
if !ok {
e.Status(http.StatusNotFound)
return nil
}
state, err := s.nonce.Draw(e.Context())
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to generate state",
Cause: err,
}
}
e.SetCookie(s.newStateCookie(state, 300))
authURL, err := provider.AuthURL(e.Context(), state)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to initiate external login",
Cause: err,
}
}
return e.Redirect(authURL, http.StatusFound)
}
// report finalizes an error the server delivers itself rather than handing
// to the router. A server error is the kind a user may quote back from the
// login portal, so it always leaves here carrying an identifier that can be
// found in the logs. Protocol errors are ordinary traffic and pass through
// untouched.
func (s *Server) report(ctx context.Context, err *router.Error) {
if err.Status < http.StatusInternalServerError {
return
}
if err.ID == "" {
err.ID = router.ErrorID()
}
attrs := []log.Arg{
log.Int("status", err.Status),
log.String("reason", err.Reason),
log.String(log.ErrorIDKey, err.ID),
}
// The cause carries the internal detail the description withholds.
if err.Cause != nil {
attrs = append(attrs, log.Error(err.Cause))
}
s.logger.Error(ctx, err.Description, attrs...)
}
// Callback handles the redirect from an external identity provider,
// verifies the state, exchanges credentials for an external identity, and
// establishes a local session.
//
// If a protocol or server error occurs during the exchange, the user-agent
// is redirected back to the configured login portal with the error details
// appended as query parameters.
func (s *Server) Callback(e *router.Exchange) error {
err := s.callback(e)
if v, ok := errors.AsType[*router.Error](err); ok {
// The redirect below consumes the error, so the router never sees
// it. This is therefore the boundary that must identify and record
// it, exactly as the router would have.
s.report(e.Context(), v)
u, err := url.Parse(s.terminalURI)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to parse login terminal URI",
Cause: err,
}
}
q := u.Query()
q.Set("error_status", strconv.Itoa(v.Status))
q.Set("error_reason", v.Reason)
q.Set("error_description", v.Description)
if v.ID != "" {
q.Set("error_id", v.ID)
}
u.RawQuery = q.Encode()
// The user-agent sits in a top-level navigation here, so the error
// must be delivered as an actual redirect back to the login portal.
return e.Redirect(u.String(), http.StatusFound)
}
return err
}
func (s *Server) callback(e *router.Exchange) error {
name := e.Param("provider")
provider, ok := s.providers[name]
if !ok {
return &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "unknown identity provider",
}
}
cookie, err := e.Cookie(s.stateCookieName)
if err != nil || cookie.Value == "" {
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: "missing or expired state cookie",
}
}
// Clear the state cookie immediately to prevent replay attacks.
e.SetCookie(s.newStateCookie("", -1))
// FormValue transparently covers both query-mode (GET) and form_post
// (POST) callback responses.
state := e.R.FormValue("state")
if state != cookie.Value {
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: "state mismatch",
}
}
identity, err := provider.Exchange(e.Context(), e.R)
if err != nil {
id := router.ErrorID()
s.logger.Error(
e.Context(),
"Failed to process external exchange",
log.String("idp", name),
log.String(log.ErrorIDKey, id),
log.Error(err),
)
s.publish(Event{
Kind: EventExchangeFailed,
Provider: name,
Addr: throttle.RemoteAddr(e.R),
})
return &router.Error{
Status: http.StatusUnauthorized,
Reason: auth.ReasonAuthenticationFailed,
Description: "failed to exchange external credentials",
ID: id,
}
}
usr, err := s.users.GetUserByExternalID(
e.Context(),
name,
identity,
)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to lookup user",
Cause: err,
}
}
if usr == nil {
s.publish(Event{
Kind: EventRefused,
Provider: name,
Subject: identity.Subject,
Addr: throttle.RemoteAddr(e.R),
})
return &router.Error{
Status: http.StatusUnauthorized,
Reason: auth.ReasonAuthenticationFailed,
Description: "external identity is not linked to any local user",
}
}
if err := s.login.Establish(e, usr, false); err != nil {
return err
}
return e.Redirect(s.redirectURI, http.StatusFound)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package idp
import "time"
// EventKind names a lifecycle event published by the broker [Server].
type EventKind string
const (
// EventExchangeFailed marks a callback whose credential exchange with
// the external provider failed — a forged or replayed callback, an
// expired authorization code, or a provider outage.
EventExchangeFailed EventKind = "idp_exchange_failed"
// EventRefused marks a verified external identity that no local user is
// linked to, refused by the [Users] seam. A successful social login
// surfaces as a login event on the login core instead.
EventRefused EventKind = "idp_refused"
)
// Event is a lifecycle notification delivered to the observer configured on
// [ServerConfig]. Events are advisory and carry no secrets or tokens.
type Event struct {
// Kind states what happened.
Kind EventKind
// Provider names the registered provider the event concerns.
Provider string
// Subject is the provider-scoped identifier of the external identity,
// when the exchange got far enough to learn it.
Subject string
// Addr is the remote address the request originated from.
Addr string
// At is when the event occurred.
At time.Time
}
// Observer receives lifecycle events. It runs synchronously on the request
// that produced the event, so it must stay cheap and must not block; hand
// events to a bus or queue for anything heavier.
type Observer func(Event)
// publish delivers an event to the configured observer, if any. Publishing
// is advisory: the login path never fails on an observer.
func (s *Server) publish(e Event) {
if s.observer == nil {
return
}
e.At = s.now()
s.observer(e)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package google
import (
"context"
"net/http"
"net/url"
"strings"
"time"
"github.com/deep-rent/nexus/dat/cache"
"github.com/deep-rent/nexus/eco/iam/idp"
"github.com/deep-rent/nexus/eco/iam/oidc"
"github.com/deep-rent/nexus/net/transport"
"github.com/deep-rent/nexus/sec/jose/jwk"
"github.com/deep-rent/nexus/sec/jose/jwt"
)
// Google OIDC endpoints, as published at
// https://accounts.google.com/.well-known/openid-configuration.
const (
AuthEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"
TokenEndpoint = "https://oauth2.googleapis.com/token"
KeySetURL = "https://www.googleapis.com/oauth2/v3/certs"
)
// Issuers lists the values Google uses for the "iss" claim.
var Issuers = []string{"https://accounts.google.com", "accounts.google.com"}
// DefaultScopes requests the standard OIDC identity profile.
var DefaultScopes = []string{"openid", "email", "profile"}
// Config carries the settings for the Google identity provider.
type Config struct {
// ClientID is the OAuth 2.0 client ID issued by the Google Cloud
// console. Required.
ClientID string
// ClientSecret is the client secret paired with ClientID. Required.
ClientSecret string
// RedirectURI is the absolute URL of the authorization server's external
// callback endpoint registered with Google. Required.
RedirectURI string
// Scopes overrides the requested scopes. Defaults to
// "openid email profile".
Scopes []string
// Client overrides the HTTP client used for outbound requests to
// Google. Defaults to [transport.DefaultClient].
Client *http.Client
}
// Provider implements [idp.Provider] for Google.
type Provider struct {
clientID string
clientSecret string
redirectURI string
scopes []string
client *http.Client
keys jwk.CacheSet
verifier jwt.Verifier[*oidc.IDToken]
auth string
token string
}
// New assembles a Google [Provider] from the given configuration.
//
// It panics if a required [Config] field is missing; provider construction
// happens once at startup, so misconfiguration is a programmer error.
// Remember to dispatch [Provider.Keys] to a scheduler so that ID token
// verification has fresh signing keys available.
func New(cfg Config) *Provider {
switch {
case cfg.ClientID == "":
panic("client ID is required")
case cfg.ClientSecret == "":
panic("client secret is required")
case cfg.RedirectURI == "":
panic("redirect URI is required")
}
client := cfg.Client
if client == nil {
client = transport.DefaultClient
}
scopes := cfg.Scopes
if len(scopes) == 0 {
scopes = DefaultScopes
}
keys := jwk.NewCacheSet(KeySetURL, cache.WithClient(client))
return &Provider{
clientID: cfg.ClientID,
clientSecret: cfg.ClientSecret,
redirectURI: cfg.RedirectURI,
scopes: scopes,
client: client,
keys: keys,
verifier: jwt.NewVerifier[*oidc.IDToken](
keys,
jwt.WithIssuers(Issuers...),
jwt.WithAudiences(cfg.ClientID),
jwt.WithLeeway(time.Minute),
),
auth: AuthEndpoint,
token: TokenEndpoint,
}
}
// Keys returns the cached view of Google's remote JWKS used for ID token
// verification.
//
// The returned set implements [schedule.Tick]; dispatch it to a scheduler so
// the keys are fetched and periodically refreshed in the background:
//
// s := schedule.New(ctx)
// s.Dispatch(p.Keys())
//
// Until the first successful fetch completes, ID token verification fails
// with [jwt.ErrKeyNotFound]; block on the set's Ready channel during
// startup to guarantee keys are available before serving logins:
//
// <-p.Keys().Ready()
//
// [schedule.Tick]: github.com/deep-rent/nexus/schedule#Tick
func (p *Provider) Keys() jwk.CacheSet { return p.keys }
// AuthURL implements [idp.Provider].
func (p *Provider) AuthURL(_ context.Context, state string) (string, error) {
q := url.Values{
"client_id": {p.clientID},
"redirect_uri": {p.redirectURI},
"response_type": {"code"},
"scope": {strings.Join(p.scopes, " ")},
"state": {state},
}
return p.auth + "?" + q.Encode(), nil
}
// Exchange implements [idp.Provider].
//
// It exchanges the authorization code from the callback request for an ID
// token, verifies the token against Google's signing keys, and extracts the
// user's identity.
func (p *Provider) Exchange(
ctx context.Context,
req *http.Request,
) (idp.Claimant, error) {
claims, err := oidc.Callback(ctx, p.client, p.token, req, url.Values{
"client_id": {p.clientID},
"client_secret": {p.clientSecret},
}, p.redirectURI, p.verifier)
if err != nil {
return idp.Claimant{}, err
}
return claims.Claimant(), nil
}
var _ idp.Provider = (*Provider)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package invite
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"uuid"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/eco/iam/post"
"github.com/deep-rent/nexus/eco/iam/team"
"github.com/deep-rent/nexus/eco/iam/user"
"github.com/deep-rent/nexus/eco/notify"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/sys/log"
)
// Request is the payload extending a team invitation.
type Request struct {
// Email is the address to invite.
Email string `json:"email"`
}
// Validate implements the [valid.Validatable] interface.
func (r *Request) Validate(v *valid.Validator) {
v.NotEmpty("email", r.Email)
v.Email("email", r.Email)
}
// TokenRequest is the payload redeeming an emailed invitation token.
type TokenRequest struct {
// Token is the raw invitation token from the emailed link.
Token string `json:"token"`
}
// Validate implements the [valid.Validatable] interface.
func (r *TokenRequest) Validate(v *valid.Validator) {
v.NotEmpty("token", r.Token)
}
var (
_ valid.Validatable = (*Request)(nil)
_ valid.Validatable = (*TokenRequest)(nil)
)
// Invite extends an invitation for the given address and mails the
// invitation link. Owner only.
//
// A live pending invitation conflicts (resend it instead), as does an
// address already belonging to a member. During a rejection cooldown the
// request is refused with 429 and a Retry-After header.
func (s *Server) Invite(e *router.Exchange) error {
u, err := s.identify(e)
if err != nil {
return err
}
t, err := s.membership(e, u, true)
if err != nil {
return err
}
var req Request
if err := e.BindJSON(&req); err != nil {
return err
}
// An address already on the roster has nothing to accept. The
// lookup is kept: an invitee who already has an account is the one
// who can also be reached on a phone.
email := user.Normalize(req.Email)
invitee, err := s.store.GetByEmail(e.Context(), email)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to look up invitee",
Cause: err,
}
}
if invitee != nil {
m, err := s.roster.GetMembership(e.Context(), t.ID, invitee.ID)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to resolve membership",
Cause: err,
}
}
if m != nil {
return &router.Error{
Status: http.StatusConflict,
Reason: ReasonAlreadyMember,
Description: "address already belongs to a member",
}
}
}
seats, err := s.seatLimit(e.Context(), t)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to resolve the seat limit",
Cause: err,
}
}
token, inv, err := s.teams.Invite(e.Context(), t.ID, email, seats)
var cooldown *team.CooldownError
switch {
case errors.Is(err, team.ErrSeatLimit):
return &router.Error{
Status: http.StatusForbidden,
Reason: ReasonSeatLimitReached,
Description: "the team has no free seats; withdraw a " +
"pending invitation, or remove a member",
}
case errors.Is(err, team.ErrPending):
return &router.Error{
Status: http.StatusConflict,
Reason: ReasonInvitationPending,
Description: "a pending invitation already exists; resend it",
}
case errors.As(err, &cooldown):
retry := max(cooldown.Until.Sub(s.now()), 0)
e.SetHeader(
"Retry-After",
strconv.FormatInt(int64(retry.Seconds())+1, 10),
)
return &router.Error{
Status: http.StatusTooManyRequests,
Reason: ReasonInvitationCooldown,
Description: "address recently declined; try again later",
}
case err != nil:
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to extend invitation",
Cause: err,
}
}
if err := s.deliver(e.Context(), t, u, inv, token); err != nil {
return err
}
s.notify(e.Context(), t, u, invitee, inv)
return e.JSON(http.StatusCreated, inv)
}
// ListInvitations returns the team's standing invitations — pending and
// rejected — newest first. Owner only.
func (s *Server) ListInvitations(e *router.Exchange) error {
u, err := s.identify(e)
if err != nil {
return err
}
t, err := s.membership(e, u, true)
if err != nil {
return err
}
invs, err := s.roster.ListInvitations(e.Context(), t.ID)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to list invitations",
Cause: err,
}
}
if invs == nil {
invs = []team.Invitation{}
}
return e.JSON(http.StatusOK, invs)
}
// Resend rotates a pending invitation's token and mails a fresh link; the
// superseded link stops working. Owner only.
func (s *Server) Resend(e *router.Exchange) error {
u, err := s.identify(e)
if err != nil {
return err
}
t, err := s.membership(e, u, true)
if err != nil {
return err
}
inv, err := s.invitation(e, t)
if err != nil {
return err
}
token, inv, err := s.teams.Resend(e.Context(), inv.ID)
if errors.Is(err, team.ErrState) {
return &router.Error{
Status: http.StatusConflict,
Reason: ReasonInvitationRejected,
Description: "only pending invitations can be resent",
}
}
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to resend invitation",
Cause: err,
}
}
if err := s.deliver(e.Context(), t, u, inv, token); err != nil {
return err
}
// A resend is the same occasion again, and the invitation carries
// only an address, so the account behind it is resolved here.
invitee, err := s.store.GetByEmail(e.Context(), inv.Email)
if err != nil {
// The mail has gone, which is the invitation itself; failing
// the request now would only invite a retry that mails twice.
s.logger.Warn(e.Context(),
"Could not resolve an invitee to notify",
log.UUID("team", t.ID),
log.Error(err),
)
return e.JSON(http.StatusOK, inv)
}
s.notify(e.Context(), t, u, invitee, inv)
return e.JSON(http.StatusOK, inv)
}
// Withdraw removes a standing invitation; the emailed link stops working.
// Owner only.
func (s *Server) Withdraw(e *router.Exchange) error {
u, err := s.identify(e)
if err != nil {
return err
}
t, err := s.membership(e, u, true)
if err != nil {
return err
}
inv, err := s.invitation(e, t)
if err != nil {
return err
}
if _, err := s.teams.Withdraw(e.Context(), inv.ID); err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to withdraw invitation",
Cause: err,
}
}
e.NoContent()
return nil
}
// Accept redeems an emailed invitation token: the caller joins the
// inviting team. Possession of the link is the proof of invitation, so
// any authenticated account may accept it.
func (s *Server) Accept(e *router.Exchange) error {
u, err := s.identify(e)
if err != nil {
return err
}
addrKey := s.limit.Addr(e)
if s.limit.Throttled(e, addrKey) {
return &router.Error{
Status: http.StatusTooManyRequests,
Reason: router.ReasonRateLimit,
Description: "too many attempts; try again later",
}
}
var req TokenRequest
if err := e.BindJSON(&req); err != nil {
return err
}
t, ok, err := s.teams.Accept(
e.Context(), req.Token, u.ID, u.MembershipLimit,
)
if errors.Is(err, team.ErrMemberLimit) {
return &router.Error{
Status: http.StatusForbidden,
Reason: ReasonMemberLimitReached,
Description: "membership limit reached",
}
}
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to accept invitation",
Cause: err,
}
}
if !ok {
s.limit.Penalize(addrKey)
return &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "invalid or expired invitation",
}
}
return e.JSON(http.StatusOK, t)
}
// Reject declines an emailed invitation token. Declining requires no
// account — the invitee may not have one — so the endpoint rides on
// possession of the link alone, throttled by address.
func (s *Server) Reject(e *router.Exchange) error {
addrKey := s.limit.Addr(e)
if s.limit.Throttled(e, addrKey) {
return &router.Error{
Status: http.StatusTooManyRequests,
Reason: router.ReasonRateLimit,
Description: "too many attempts; try again later",
}
}
var req TokenRequest
if err := e.BindJSON(&req); err != nil {
return err
}
ok, err := s.teams.Reject(e.Context(), req.Token)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to reject invitation",
Cause: err,
}
}
if !ok {
s.limit.Penalize(addrKey)
return &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "invalid or expired invitation",
}
}
e.NoContent()
return nil
}
// invitation binds the {invitation} path parameter to one of the team's
// own invitations. An invitation of another team yields the same 404 as a
// nonexistent one, so identifiers cannot be probed across teams.
//
// That 404 is the uniform answer for any token or identifier resolving to
// nothing redeemable: unknown, expired, rejected, withdrawn, or already
// consumed are all the same to a caller.
func (s *Server) invitation(
e *router.Exchange,
t *team.Team,
) (*team.Invitation, error) {
var params struct {
Invitation uuid.UUID `path:"invitation"`
}
if err := e.BindPath(¶ms); err != nil {
return nil, err
}
inv, err := s.roster.GetInvitation(e.Context(), params.Invitation)
if err != nil {
return nil, &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to look up invitation",
Cause: err,
}
}
if inv == nil || inv.TeamID != t.ID {
return nil, &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "invalid or expired invitation",
}
}
return inv, nil
}
// deliver mails the invitation link. A delivery failure surfaces as a
// server error while the invitation stands: the owner retries through the
// resend endpoint, which mints a fresh link.
func (s *Server) deliver(
ctx context.Context,
t *team.Team,
inviter *user.User,
inv *team.Invitation,
token string,
) error {
if err := s.post.TeamInvite(
ctx,
// The invitee may have no account yet, so the address is all
// this occasion knows about them.
post.Recipient{Addr: inv.Email},
t.Name,
// The address goes along so the invitee can tell an invitation
// they expected from one they did not.
post.Inviter{Name: inviter.Display(), Addr: inviter.Email},
token,
); err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to send invitation mail",
Cause: err,
}
}
return nil
}
// notify asks the notification service for a push about an invitation,
// after the mail has gone.
//
// It reaches only an invitee who ALREADY has an account, which is what a
// nil invitee means: an invitation is addressed to an email address, and
// the flow deliberately serves people who have none. For them the mail
// is the whole of it, and that is not a failure — it is the ordinary
// case for the invitation that grows a team.
//
// It is best-effort and deliberately last. The mailed link is the
// invitation: nothing can be accepted without it, so a notification
// service that is down must not fail a request whose retry would mail a
// second link.
func (s *Server) notify(
ctx context.Context,
t *team.Team,
inviter *user.User,
invitee *user.User,
inv *team.Invitation,
) {
if s.pusher == nil || invitee == nil {
return
}
_, err := s.pusher.Publish(ctx, notify.Request{
Category: s.category,
Recipients: []uuid.UUID{invitee.ID},
Vars: map[string]string{
"team": t.Name,
"inviter": inviter.Display(),
},
// One team's invitation supersedes an earlier one, so a resend
// replaces the notification it repeats rather than stacking
// beside it.
Collapse: inv.TeamID.String(),
// The invitation identifier is stable and the update stamp moves
// on every resend, so a resend asks for a genuinely new
// notification rather than being collapsed into the one it
// repeats. Nanoseconds, because two resends can share a second.
Key: fmt.Sprintf(
"invite:%s:%d", inv.ID, inv.UpdatedAt.UnixNano(),
),
})
if err != nil {
s.logger.Warn(ctx, "Could not ask for an invitation push",
log.UUID("team", t.ID),
log.UUID("invitee", invitee.ID),
log.Bool("permanent", notify.Permanent(err)),
log.Error(err),
)
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package invite
import (
"cmp"
"context"
"fmt"
"net/http"
"uuid"
"github.com/deep-rent/nexus/eco/iam/avatar"
"github.com/deep-rent/nexus/eco/iam/limit"
"github.com/deep-rent/nexus/eco/iam/login"
"github.com/deep-rent/nexus/eco/iam/post"
"github.com/deep-rent/nexus/eco/iam/team"
"github.com/deep-rent/nexus/eco/iam/user"
"github.com/deep-rent/nexus/eco/notify"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/net/throttle"
"github.com/deep-rent/nexus/sec/auth"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/sys/log"
)
// DefaultThrottlePenalty is the token charge applied to failed or abusable
// requests when [Config.ThrottlePenalty] is unset.
const DefaultThrottlePenalty = 10
// Endpoint paths registered by [Server.Mount], relative to the mount
// prefix.
const (
// PathTeams lists and founds the caller's teams; subpaths manage a
// single team, its members, and its invitations.
PathTeams = "/teams"
// PathAccept redeems an emailed invitation token, making
// the caller a member of the inviting team.
PathAccept = "/teams/invitations/accept"
// PathReject declines an emailed invitation token.
PathReject = "/teams/invitations/reject"
)
// Error reasons emitted by the team endpoints, complementing the reasons
// defined by the router and auth packages.
const (
// ReasonOwnerRequired indicates that the caller is a member of the team
// but not an owner, and the operation is reserved for owners.
ReasonOwnerRequired router.Reason = "owner_required"
// ReasonTeamLimitReached indicates that the caller already owns as many
// teams as their account permits.
ReasonTeamLimitReached router.Reason = "team_limit_reached"
// ReasonMemberLimitReached indicates that the caller already belongs
// to as many teams as their account permits.
ReasonMemberLimitReached router.Reason = "member_limit_reached"
// ReasonAlreadyMember indicates that the invited address already belongs
// to a member of the team.
ReasonAlreadyMember router.Reason = "already_member"
// ReasonSeatLimitReached refuses an invitation that would grow the
// team past its founder's seat limit.
ReasonSeatLimitReached router.Reason = "seat_limit_reached"
// ReasonInvitationPending indicates that the address already holds a
// pending invitation; the client should resend that one instead.
ReasonInvitationPending router.Reason = "invitation_pending"
// ReasonInvitationCooldown indicates that the address recently declined
// an invitation and cannot be invited again until the cooldown expires;
// the response carries a Retry-After header.
ReasonInvitationCooldown router.Reason = "invitation_cooldown"
// ReasonInvitationRejected indicates that the invitation has left the
// pending state, so it can no longer be resent.
ReasonInvitationRejected router.Reason = "invitation_rejected"
// ReasonOwnerUndeletable indicates that the targeted member is an owner;
// owners step down before they can be removed.
ReasonOwnerUndeletable router.Reason = "owner_undeletable"
// ReasonLastOwner indicates that the operation would leave the team
// without an owner; the last owner appoints a successor first.
ReasonLastOwner router.Reason = "last_owner"
)
// Pusher asks for a push notification, satisfied by [notify.Publisher].
//
// The identity service supplies a category and variables and never any
// text: what reaches a lock screen is rendered by the notification
// service from its own catalog, so a team name appears there only if the
// deployment's category says it may.
//
// [notify.Publisher]: github.com/deep-rent/nexus/eco/notify#Publisher
type Pusher interface {
Publish(ctx context.Context, req notify.Request) (
notify.Receipt, error)
}
// Config bundles the required collaborators of a [Server].
type Config struct {
// Login is the authentication core whose sessions authenticate this
// API. Required.
Login *login.Manager
// Users is the identity engine resolving callers and invitees.
// Required.
Users *user.Manager
// Teams is the team engine carrying out every mutation. Required.
Teams *team.Manager
// Post dispatches invitation mails. Required: an invitation only
// exists as an emailed link.
Post *post.Mailer
// Pusher asks the notification service for a push alongside the
// mail. Optional: without one an invitation is mailed and nothing
// else, which is how it worked before push existed.
//
// It reaches only an invitee who ALREADY has an account and a
// registered phone. An invitation is addressed to an email address,
// and the flow deliberately serves people who have neither — so the
// push supplements the mail and never replaces it.
Pusher Pusher
// Category names what the notification service should render, as its
// catalog declares it. Required with a Pusher.
Category string
// Throttle is the shared limiter guarding the token-redemption
// endpoints. If nil, throttling is disabled; see [throttle.Throttle]
// for the caveats.
Throttle *throttle.Throttle
// ThrottlePenalty is the number of tokens a failed or abusable request
// costs. Defaults to [DefaultThrottlePenalty] when nonpositive.
ThrottlePenalty int
// Logger receives diagnostics for best-effort work. Defaults to
// [log.Discard].
Logger *log.Logger
}
// Server implements the self-service team API. Create instances with [New]
// and attach the routes with [Server.Mount].
type Server struct {
login *login.Manager
users *user.Manager
store user.Store
teams *team.Manager
roster team.Store
post *post.Mailer
pusher Pusher
category string
avatars *avatar.Manager // absent without picture storage
limit limit.Limiter
logger *log.Logger
now clock.Clock
}
// New assembles a [Server]. It panics if a required [Config] field is
// missing, since startup misconfiguration is a programmer error.
func New(cfg Config, opts ...Option) *Server {
switch {
case cfg.Login == nil:
panic("login manager is required")
case cfg.Users == nil:
panic("user manager is required")
case cfg.Teams == nil:
panic("team manager is required")
case cfg.Pusher != nil && cfg.Category == "":
panic("a category is required to push")
case cfg.Post == nil:
panic("mailer is required")
}
s := &Server{
login: cfg.Login,
users: cfg.Users,
store: cfg.Users.Store(),
teams: cfg.Teams,
roster: cfg.Teams.Store(),
post: cfg.Post,
pusher: cfg.Pusher,
category: cfg.Category,
logger: cmp.Or(cfg.Logger, log.Discard()),
now: clock.System,
limit: limit.New(
cfg.Throttle,
cmp.Or(cfg.ThrottlePenalty, DefaultThrottlePenalty),
),
}
for _, opt := range opts {
opt(s)
}
return s
}
// Mount registers the team endpoints on the registrar — the router itself
// for a root mount, or a [router.Group] to nest them under a path prefix.
func (s *Server) Mount(r router.Registrar) {
r.HandleFunc(
http.MethodGet, PathTeams,
s.ListTeams,
)
r.HandleFunc(
http.MethodPost, PathTeams,
s.Found,
)
r.HandleFunc(
http.MethodGet, PathTeams+"/{id}",
s.GetTeam,
)
r.HandleFunc(
http.MethodPatch, PathTeams+"/{id}",
s.RenameTeam,
)
r.HandleFunc(
http.MethodDelete, PathTeams+"/{id}",
s.DeleteTeam,
)
r.HandleFunc(
http.MethodPost, PathTeams+"/{id}/leave",
s.Leave,
)
r.HandleFunc(
http.MethodGet, PathTeams+"/{id}/members",
s.ListMembers,
)
r.HandleFunc(
http.MethodDelete, PathTeams+"/{id}/members/{user}",
s.RemoveMember,
)
r.HandleFunc(
http.MethodPost, PathTeams+"/{id}/members/{user}/promote",
s.Promote,
)
r.HandleFunc(
http.MethodPost, PathTeams+"/{id}/members/{user}/demote",
s.Demote,
)
r.HandleFunc(
http.MethodPost, PathTeams+"/{id}/invitations",
s.Invite,
)
r.HandleFunc(
http.MethodGet, PathTeams+"/{id}/invitations",
s.ListInvitations,
)
r.HandleFunc(
http.MethodPost, PathTeams+"/{id}/invitations/{invitation}/resend",
s.Resend,
)
r.HandleFunc(
http.MethodDelete, PathTeams+"/{id}/invitations/{invitation}",
s.Withdraw,
)
// The literal segment takes precedence over the {id} wildcard on the
// multiplexer, so these do not collide with the per-team routes.
r.HandleFunc(http.MethodPost, PathAccept, s.Accept)
r.HandleFunc(http.MethodPost, PathReject, s.Reject)
if s.avatars != nil {
r.HandleFunc(
http.MethodPost, PathTeams+"/{id}/logo",
s.BeginLogo,
)
r.HandleFunc(
http.MethodPost, PathTeams+"/{id}/logo/confirm",
s.ConfirmLogo,
)
r.HandleFunc(
http.MethodDelete, PathTeams+"/{id}/logo",
s.DeleteLogo,
)
}
}
// identify resolves the calling user from the session cookie. Mutating
// requests from cross-site callers are rejected, since every state change
// on this API rides on ambient cookie authority.
//
// Every authentication failure answers the same 401: an absent cookie, an
// expired session, and a disabled account are deliberately
// indistinguishable.
func (s *Server) identify(e *router.Exchange) (*user.User, error) {
if e.Method() != http.MethodGet && e.CrossSite() {
return nil, &router.Error{
Status: http.StatusForbidden,
Reason: router.ReasonCrossSite,
Description: "cross-site requests are not allowed",
}
}
cookie, err := e.Cookie(s.login.SessionCookieName())
if err != nil || cookie.Value == "" {
return nil, &router.Error{
Status: http.StatusUnauthorized,
Reason: auth.ReasonAuthenticationFailed,
Description: "login required",
}
}
owner, ok, err := s.login.Sessions().Resolve(e.Context(), cookie.Value)
if err != nil {
return nil, &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to resolve session",
Cause: err,
}
}
if !ok {
return nil, &router.Error{
Status: http.StatusUnauthorized,
Reason: auth.ReasonAuthenticationFailed,
Description: "login required",
}
}
u, err := s.store.Get(e.Context(), owner)
if err != nil {
return nil, &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to look up user",
Cause: err,
}
}
if u == nil || u.Disabled {
return nil, &router.Error{
Status: http.StatusUnauthorized,
Reason: auth.ReasonAuthenticationFailed,
Description: "login required",
}
}
return u, nil
}
// seatLimit resolves the seat limit governing the team's growth: the
// founder's [user.User.SeatLimit]. A team without a living founder — an
// administrative install, or a founding account since deleted — grows
// uncapped; bounding an orphaned team is administrative territory.
//
// [user.User.SeatLimit]:
// github.com/deep-rent/nexus/eco/iam/user#User.SeatLimit
func (s *Server) seatLimit(
ctx context.Context,
t *team.Team,
) (int, error) {
if t.Founder == uuid.Nil() {
return 0, nil
}
founder, err := s.store.Get(ctx, t.Founder)
if err != nil {
return 0, fmt.Errorf("failed to load the founder: %w", err)
}
if founder == nil {
return 0, nil
}
return founder.SeatLimit, nil
}
// membership authorizes the caller against the team in the {id} path
// parameter. A non-member and a nonexistent team are deliberately
// indistinguishable — both answer 404, so the roster cannot be probed —
// while owner demands the management role and turns plain members away
// with a 403.
func (s *Server) membership(
e *router.Exchange,
caller *user.User,
owner bool,
) (*team.Team, error) {
var params struct {
ID uuid.UUID `path:"id"`
}
if err := e.BindPath(¶ms); err != nil {
return nil, err
}
m, err := s.roster.GetMembership(e.Context(), params.ID, caller.ID)
if err != nil {
return nil, &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to resolve membership",
Cause: err,
}
}
if m == nil {
return nil, &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "no such team",
}
}
if owner && !m.Owner {
return nil, &router.Error{
Status: http.StatusForbidden,
Reason: ReasonOwnerRequired,
Description: "this operation requires the owner role",
}
}
t, err := s.roster.GetTeam(e.Context(), params.ID)
if err != nil {
return nil, &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to look up team",
Cause: err,
}
}
if t == nil {
return nil, &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "no such team",
}
}
return t, nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package invite
import (
"net/http"
"github.com/deep-rent/nexus/eco/iam/avatar"
"github.com/deep-rent/nexus/net/router"
)
// LogoResponse carries the public URL of a confirmed team logo.
type LogoResponse struct {
// URL is where the fresh logo is served from.
URL string `json:"url"`
}
// BeginLogo grants a direct logo upload for the team: it answers with a
// presigned PUT URL and the policy the confirmation will enforce. Owner
// only. Nothing changes until [Server.ConfirmLogo] verifies the upload.
func (s *Server) BeginLogo(e *router.Exchange) error {
caller, err := s.identify(e)
if err != nil {
return err
}
t, err := s.membership(e, caller, true)
if err != nil {
return err
}
grant, err := s.avatars.Begin(e.Context(), avatar.ScopeTeam, t.ID)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to grant upload",
Cause: err,
}
}
return e.JSON(http.StatusOK, grant)
}
// ConfirmLogo verifies the team's pending upload and puts it into
// service, answering with the logo's public URL; see
// [avatar.ConfirmError] for the refusals. Owner only.
func (s *Server) ConfirmLogo(e *router.Exchange) error {
caller, err := s.identify(e)
if err != nil {
return err
}
t, err := s.membership(e, caller, true)
if err != nil {
return err
}
url, err := s.avatars.Confirm(e.Context(), avatar.ScopeTeam, t.ID)
if err != nil {
return avatar.ConfirmError(err)
}
return e.JSON(http.StatusOK, LogoResponse{URL: url})
}
// DeleteLogo takes the team's logo out of service. Owner only. Deleting
// an absent logo succeeds, so the operation is idempotent.
func (s *Server) DeleteLogo(e *router.Exchange) error {
caller, err := s.identify(e)
if err != nil {
return err
}
t, err := s.membership(e, caller, true)
if err != nil {
return err
}
if err := s.avatars.Remove(
e.Context(), avatar.ScopeTeam, t.ID,
); err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to remove logo",
Cause: err,
}
}
e.NoContent()
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package invite
import (
"errors"
"net/http"
"uuid"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/eco/iam/team"
"github.com/deep-rent/nexus/net/router"
)
// FoundRequest is the payload founding a team.
type FoundRequest struct {
// Name is the human-facing team name.
Name string `json:"name"`
}
// Validate implements the [valid.Validatable] interface.
func (r *FoundRequest) Validate(v *valid.Validator) {
v.NotBlank("name", r.Name)
v.MaxLen("name", r.Name, team.MaxNameLength)
}
// RenameRequest is the payload renaming a team.
type RenameRequest struct {
// Name is the replacement team name.
Name string `json:"name"`
}
// Validate implements the [valid.Validatable] interface.
func (r *RenameRequest) Validate(v *valid.Validator) {
v.NotBlank("name", r.Name)
v.MaxLen("name", r.Name, team.MaxNameLength)
}
var (
_ valid.Validatable = (*FoundRequest)(nil)
_ valid.Validatable = (*RenameRequest)(nil)
)
// ListTeams returns the caller's teams together with their role in each.
func (s *Server) ListTeams(e *router.Exchange) error {
u, err := s.identify(e)
if err != nil {
return err
}
affs, err := s.roster.ListAffiliations(e.Context(), u.ID)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to list teams",
Cause: err,
}
}
if affs == nil {
affs = []team.Affiliation{}
}
return e.JSON(http.StatusOK, affs)
}
// Found creates a team with the caller as its first owner, bounded by the
// caller's team limit.
func (s *Server) Found(e *router.Exchange) error {
u, err := s.identify(e)
if err != nil {
return err
}
var req FoundRequest
if err := e.BindJSON(&req); err != nil {
return err
}
t, err := s.teams.Found(e.Context(), req.Name, u.ID, team.Limits{
Teams: u.TeamLimit,
Memberships: u.MembershipLimit,
})
if errors.Is(err, team.ErrLimit) {
return &router.Error{
Status: http.StatusForbidden,
Reason: ReasonTeamLimitReached,
Description: "team limit reached",
}
}
if errors.Is(err, team.ErrMemberLimit) {
return &router.Error{
Status: http.StatusForbidden,
Reason: ReasonMemberLimitReached,
Description: "membership limit reached",
}
}
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to found team",
Cause: err,
}
}
return e.JSON(http.StatusCreated, t)
}
// GetTeam returns one of the caller's teams.
func (s *Server) GetTeam(e *router.Exchange) error {
u, err := s.identify(e)
if err != nil {
return err
}
t, err := s.membership(e, u, false)
if err != nil {
return err
}
return e.JSON(http.StatusOK, t)
}
// RenameTeam relabels one of the caller's teams. Owner only.
func (s *Server) RenameTeam(e *router.Exchange) error {
u, err := s.identify(e)
if err != nil {
return err
}
t, err := s.membership(e, u, true)
if err != nil {
return err
}
var req RenameRequest
if err := e.BindJSON(&req); err != nil {
return err
}
t.Name = req.Name
t.UpdatedAt = s.now()
if err := s.roster.UpdateTeam(e.Context(), t); err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to rename team",
Cause: err,
}
}
return e.JSON(http.StatusOK, t)
}
// DeleteTeam dissolves one of the caller's teams: memberships and standing
// invitations disappear with it. Owner only.
func (s *Server) DeleteTeam(e *router.Exchange) error {
u, err := s.identify(e)
if err != nil {
return err
}
t, err := s.membership(e, u, true)
if err != nil {
return err
}
if _, err := s.teams.Dissolve(e.Context(), t.ID, u.ID); err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to delete team",
Cause: err,
}
}
e.NoContent()
return nil
}
// ListMembers returns the team roster: every member's user identifier,
// email address, and name, visible to any member of the team.
func (s *Server) ListMembers(e *router.Exchange) error {
u, err := s.identify(e)
if err != nil {
return err
}
t, err := s.membership(e, u, false)
if err != nil {
return err
}
members, err := s.roster.ListMembers(e.Context(), t.ID)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to list members",
Cause: err,
}
}
if members == nil {
members = []team.Member{}
}
return e.JSON(http.StatusOK, members)
}
// RemoveMember evicts a member from the team. Owner only; owners cannot be
// evicted.
func (s *Server) RemoveMember(e *router.Exchange) error {
u, err := s.identify(e)
if err != nil {
return err
}
t, err := s.membership(e, u, true)
if err != nil {
return err
}
var params struct {
User uuid.UUID `path:"user"`
}
if err := e.BindPath(¶ms); err != nil {
return err
}
switch err := s.teams.RemoveMember(
e.Context(), t.ID, params.User, u.ID,
); {
case errors.Is(err, team.ErrNotMember):
return &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "no such member",
}
case errors.Is(err, team.ErrOwner):
return &router.Error{
Status: http.StatusConflict,
Reason: ReasonOwnerUndeletable,
Description: "owners step down before they can be removed",
}
case err != nil:
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to remove member",
Cause: err,
}
}
e.NoContent()
return nil
}
// Promote appoints a fellow member as an owner of the team. Owner only.
func (s *Server) Promote(e *router.Exchange) error {
u, err := s.identify(e)
if err != nil {
return err
}
t, err := s.membership(e, u, true)
if err != nil {
return err
}
var params struct {
User uuid.UUID `path:"user"`
}
if err := e.BindPath(¶ms); err != nil {
return err
}
switch err := s.teams.Promote(
e.Context(), t.ID, params.User, u.ID,
); {
case errors.Is(err, team.ErrNotMember):
return &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "no such member",
}
case err != nil:
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to promote member",
Cause: err,
}
}
e.NoContent()
return nil
}
// Demote reduces an owner to a plain member: fellow owners may step each
// other down, and an owner may step themselves down. Owner only; the last
// owner cannot step down.
func (s *Server) Demote(e *router.Exchange) error {
u, err := s.identify(e)
if err != nil {
return err
}
t, err := s.membership(e, u, true)
if err != nil {
return err
}
var params struct {
User uuid.UUID `path:"user"`
}
if err := e.BindPath(¶ms); err != nil {
return err
}
switch err := s.teams.Demote(
e.Context(), t.ID, params.User, u.ID,
); {
case errors.Is(err, team.ErrNotMember):
return &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "no such member",
}
case errors.Is(err, team.ErrLastOwner):
return &router.Error{
Status: http.StatusConflict,
Reason: ReasonLastOwner,
Description: "the last owner must appoint a successor first",
}
case err != nil:
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to demote owner",
Cause: err,
}
}
e.NoContent()
return nil
}
// Leave exits the team voluntarily. Any member may leave, except the last
// owner, who appoints a successor first or dissolves the team.
func (s *Server) Leave(e *router.Exchange) error {
u, err := s.identify(e)
if err != nil {
return err
}
t, err := s.membership(e, u, false)
if err != nil {
return err
}
switch err := s.teams.Leave(e.Context(), t.ID, u.ID); {
case errors.Is(err, team.ErrLastOwner):
return &router.Error{
Status: http.StatusConflict,
Reason: ReasonLastOwner,
Description: "the last owner must appoint a successor first",
}
case err != nil:
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to leave team",
Cause: err,
}
}
e.NoContent()
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package invite
import (
"github.com/deep-rent/nexus/eco/iam/avatar"
"github.com/deep-rent/nexus/std/clock"
)
// Option configures a [Server].
type Option func(*Server)
// WithClock overrides the time source, primarily for testing. A nil clock
// is ignored. Defaults to [clock.System].
func WithClock(c clock.Clock) Option {
return func(s *Server) {
if c != nil {
s.now = c
}
}
}
// WithLogos enables the team logo endpoints over the given picture
// engine. A nil manager is ignored, leaving logos unavailable.
func WithLogos(m *avatar.Manager) Option {
return func(s *Server) {
if m != nil {
s.avatars = m
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package limit
import (
"net/http"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/net/throttle"
)
// Key namespaces keep the identifier spaces disjoint within the single
// [throttle.Throttle] shared across every axis the servers limit, so that a
// client ID can never share a bucket with a username, a one-time code, or a
// network address.
const (
// ScopeAddr prefixes keys derived from the requesting network address.
ScopeAddr = "addr:"
// ScopeClient prefixes keys derived from OAuth client identifiers.
ScopeClient = "client:"
// ScopeUser prefixes keys derived from usernames.
ScopeUser = "user:"
// ScopeCode prefixes keys derived from device user codes.
ScopeCode = "code:"
// ScopeOTP prefixes keys derived from login flow handles.
ScopeOTP = "otp:"
)
// Limiter charges failed authentication attempts against a shared throttle.
// The zero Limiter (nil throttle) disables every operation, so callers need
// no nil checks.
type Limiter struct {
wrapped *throttle.Throttle
penalty int
}
// New builds a [Limiter] over the given throttle, charging penalty tokens
// per failed attempt. A nil throttle disables limiting entirely.
func New(t *throttle.Throttle, penalty int) Limiter {
return Limiter{
wrapped: t,
penalty: penalty,
}
}
// Enabled reports whether a throttle is installed.
func (l Limiter) Enabled() bool { return l.wrapped != nil }
// Throttled reports whether the given key has exhausted its throttle
// allowance, setting the Retry-After header when it has. It always reports
// false when throttling is disabled.
func (l Limiter) Throttled(e *router.Exchange, key string) bool {
if !l.Enabled() {
return false
}
blocked, wait := l.wrapped.Blocked(key)
if blocked {
throttle.RetryAfter(e.W.Header(), wait)
}
return blocked
}
// Penalize charges a failed authentication attempt against the given keys.
func (l Limiter) Penalize(keys ...string) {
if l.Enabled() {
for _, key := range keys {
l.wrapped.Penalize(key, l.penalty)
}
}
}
// Clear restores the throttle allowance of a credential that has just been
// proven. Address-scoped keys are deliberately never cleared, so that
// holding one valid credential cannot wipe the penalty accrued while
// guessing others.
func (l Limiter) Clear(key string) {
if l.Enabled() {
l.wrapped.Reset(key)
}
}
// Addr returns the address-scoped throttle key for the request, or an empty
// string when throttling is disabled. It matches the key [Limiter.Middleware]
// spends against, so that per-request volume and per-attempt penalties draw
// down one shared bucket.
func (l Limiter) Addr(e *router.Exchange) string {
if !l.Enabled() {
return ""
}
return ScopeAddr + throttle.RemoteAddr(e.R)
}
// Middleware spends one token per request from the requesting address's
// bucket, rejecting the request with 429 once the bucket is empty. It must
// only be installed when [Limiter.Enabled] reports true.
func (l Limiter) Middleware() router.Middleware {
return l.wrapped.MiddlewareFunc(func(r *http.Request) string {
return ScopeAddr + throttle.RemoteAddr(r)
})
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package login
import (
"time"
"uuid"
"github.com/deep-rent/nexus/eco/iam/flow"
"github.com/deep-rent/nexus/eco/iam/trust"
)
// EventKind names a lifecycle event published by the login core.
type EventKind string
const (
// EventLogin marks an established login session, whether it came from a
// password, a completed multi-step flow, a passkey assertion, or an
// external identity provider.
EventLogin EventKind = "login"
// EventLoginFailed marks a refused identifying factor: a wrong password
// or, on the passwordless endpoint, an unknown username. It is the
// signal credential-stuffing and password-spraying detection feeds on.
EventLoginFailed EventKind = "login_failed"
// EventLogout marks a session terminated through the logout endpoint.
EventLogout EventKind = "logout"
// EventFlowStepRejected marks a wrong input against a live flow step —
// a mistyped code, say. The flow survives and the client may retry.
EventFlowStepRejected EventKind = "flow_step_rejected"
// EventFlowFailed marks a multi-step login that ended without a
// session; [Event.Reason] carries the true cause the wire response
// collapses.
EventFlowFailed EventKind = "flow_failed"
// EventDeviceTrusted marks a remember-me device enrollment after a
// fully completed login.
EventDeviceTrusted EventKind = "device_trusted"
// EventSessionsRevoked marks a bulk destruction of the user's login
// sessions, typically after a credential change.
EventSessionsRevoked EventKind = "sessions_revoked"
// EventTrustRevoked marks a bulk revocation of the user's trusted
// devices, typically after a credential change.
EventTrustRevoked EventKind = "trust_revoked"
)
// Event is a lifecycle notification delivered to the [Observer] registered
// with [WithObserver]. Observers typically feed audit trails, metrics, or
// user-facing notifications, such as a "new login on your account" email.
//
// Events are advisory and carry no secrets: session keys, tokens, passwords,
// and codes never appear in them.
type Event struct {
// Kind states what happened.
Kind EventKind
// UserID identifies the affected user. It is the zero UUID when the
// event precedes identification, such as a failed login for an unknown
// username.
UserID uuid.UUID
// Username is the identifier a refused login attempted; set only on
// [EventLoginFailed], where no user ID may exist to report.
Username string
// Reason is the true cause of an [EventFlowFailed], preserved from
// [flow.Result.Reason]. It is for the audit trail only and must never
// be echoed to the client.
Reason flow.Reason
// Device reports the device trust presented at login. An untrusted
// device on an [EventLogin] marks a sign-in from a browser the user has
// not used before — the classic trigger for a login alert. It is zero
// for other kinds.
Device trust.Device
// Remember reports whether the login requested a persistent session. It
// is false for other kinds.
Remember bool
// Label is a human-facing hint at the acting user agent, mirroring the
// label stored on the session. It is empty for events raised outside a
// request, such as an administrative session revocation.
Label string
// Addr is the remote address the request originated from, as seen by
// this server (behind a proxy, configure the proxy to preserve it). It
// is empty for events raised outside a request.
Addr string
// At is when the event occurred.
At time.Time
}
// Observer receives lifecycle events. It runs synchronously on the request
// that produced the event, so it must stay cheap and must not block; hand
// events to a bus or queue for anything heavier.
type Observer func(Event)
// publish delivers an event to the configured observer, if any. Publishing
// is advisory: the login path never fails on an observer.
func (m *Manager) publish(e Event) {
if m.observer == nil {
return
}
e.At = m.now()
m.observer(e)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package login
import (
"context"
"encoding/json/jsontext"
"uuid"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/net/router"
)
// User represents an authenticated resource owner.
//
// Implementations wrap the primary key and permission set. They are resolved
// and verified via [Users].
type User interface {
// ID returns the unique identifier for the user.
ID() uuid.UUID
// Username returns the unique, human-readable identifier the user
// logs in with (e.g., an email address or handle). It labels the
// user's account in contexts where the raw UUID would be meaningless
// to humans, such as the account picker of a passkey ceremony.
Username() string
// Roles returns the list of roles assigned to the user, used to populate
// the roles claim in access tokens.
Roles() []string
}
// Users provides identity resolution and credential verification for
// resource owners.
//
// It is the [Manager]'s window onto the user directory: it authenticates
// users during the login flow and resolves identities when a flow or an
// authentication method completes. Session state lives elsewhere, in the
// session engine.
type Users interface {
// Authenticate validates user credentials.
//
// If credentials are valid, it must return the user and nil.
// If authentication fails (e.g., wrong password), it must return nil and
// nil. It should return an error only if the underlying storage lookup
// fails.
//
// Implementations should not hand-roll password verification; the
// [pass] package stores self-describing JSON records and resolves the
// hashing algorithm dynamically. Verify the password and, while the
// plaintext is still at hand, transparently upgrade hashes that predate
// the current hashing configuration:
//
// func (s *store) Authenticate(
// ctx context.Context,
// username, password string,
// ) (login.User, error) {
// usr, record, err := s.lookup(ctx, username)
// if err != nil || usr == nil {
// return nil, err
// }
// ok, err := s.hasher.Verify(record, password)
// if err != nil || !ok {
// return nil, err // nil, nil on a wrong password
// }
// // The password is proven; converge its stored hash to the
// // strongest configuration without a mass reset.
// if stale, _ := s.hasher.Outdated(record); stale {
// if record, err = s.hasher.Hash(password); err == nil {
// s.updateRecord(ctx, usr.ID(), record)
// }
// }
// return usr, nil
// }
//
// [pass]: github.com/deep-rent/nexus/sec/pass
Authenticate(
ctx context.Context,
username, password string,
) (User, error)
// GetUser retrieves a user by their unique ID.
//
// If the user is found, it must return the user and nil.
// If the user is not found, it must return nil and nil.
// It should return an error only if the storage lookup fails.
GetUser(ctx context.Context, id uuid.UUID) (User, error)
// GetUserByUsername resolves a user by their username without
// verifying any credential.
//
// If the user is found, it must return the user and nil.
// If the user is not found, it must return nil, nil. It should return an
// error only if the storage lookup fails.
//
// The method is only consulted when passwordless login is enabled via
// [WithPasswordless]; other deployments may return nil, nil. Because it
// identifies a user without authenticating them, callers must treat the
// result as a claim proven only once the login flow completes.
GetUserByUsername(ctx context.Context, username string) (User, error)
}
// Channel is the client-facing description of an enrolled delivery method,
// returned in a login flow prompt so a client can present a channel picker. It
// never carries a secret or a raw destination.
type Channel struct {
// ID is the stable identifier used to select this method on resend.
ID string `json:"id"`
// Label is an optional human-facing hint, such as a masked address or phone
// number.
Label string `json:"label,omitzero"`
}
// Request represents the payload for the resource owner login endpoint.
//
// It is consumed by [Server.Login] to authenticate a resource owner and
// initiate a secure session via the [Users.Authenticate] method.
type Request struct {
// Username is the unique identifier (e.g., an email address or handle)
// used by the resource owner to authenticate. This value is passed to
// [Users.Authenticate] to resolve the [User].
Username string `json:"username"`
// Password is the secret credential provided by the resource owner.
// It is used to verify the identity of the user during the login process.
Password string `json:"password"`
// Remember asks the server to remember the login: it persists the session
// beyond the browser session and, on a device that completes any required
// factors, trusts the device so later logins may skip them.
Remember bool `json:"remember,omitzero"`
// Captcha is the single-use Cloudflare Turnstile token the widget
// produced (its "cf-turnstile-response" value). It is required only
// where the server was configured with a captcha; see
// [WithCaptcha].
Captcha string `json:"captcha,omitzero"`
}
// Validate implements the [valid.Validatable] interface. The captcha token
// is deliberately not required here: whether one is expected is the
// server's configuration, not the payload's shape, and refusing it as a
// validation error would tell an unconfigured client the wrong thing.
func (r *Request) Validate(v *valid.Validator) {
v.NotEmpty("username", r.Username)
v.NotEmpty("password", r.Password)
}
// ReasonCaptchaFailed indicates that the request carried no valid captcha
// token, or that the check could not be made on a server that requires
// one. It is deliberately distinct from a credential rejection: a client
// answering it should obtain a fresh token and retry, not re-prompt for
// the password.
const ReasonCaptchaFailed router.Reason = "captcha_failed"
var _ valid.Validatable = (*Request)(nil)
// IdentifyRequest represents the payload for the passwordless login endpoint.
//
// It is consumed by [Server.Identify] to start a passwordless login: the
// user is identified by username (without a credential) and the login flow
// then authenticates them through its factors.
type IdentifyRequest struct {
// Username identifies the resource owner. It is not a credential; the flow
// factors prove control of the account.
Username string `json:"username"`
// Remember asks the server to remember the login once the flow completes,
// as in [Request].
Remember bool `json:"remember,omitzero"`
}
// Validate implements the [valid.Validatable] interface.
func (r *IdentifyRequest) Validate(v *valid.Validator) {
v.NotEmpty("username", r.Username)
}
var _ valid.Validatable = (*IdentifyRequest)(nil)
// FlowResponse is the payload returned by the login, continue, and action
// endpoints when a login requires a further authentication step.
//
// Instead of a session, the client receives a flow handle and a description of
// the active step. It must satisfy the step and confirm via
// [Server.Continue], or drive an out-of-band action (such as resending a code)
// via [Server.Action], carrying the same handle throughout.
type FlowResponse struct {
// Handle is the opaque handle identifying the pending login. It is required
// to continue or act on the flow.
Handle string `json:"handle"`
// Step is the identifier of the active step (e.g. "otp").
Step string `json:"step"`
// Prompt is the step-specific data the client needs to satisfy the step,
// such as the available delivery channels and a code's remaining lifetime.
// It is omitted when the step needs no such data.
Prompt any `json:"prompt,omitzero"`
}
// ContinueRequest represents the payload for the login continue endpoint.
//
// It is consumed by [Server.Continue] to satisfy the active step of a pending
// login with the credential the resource owner supplied. The active step reads
// whichever field it expects: a code-based step (such as a one-time password)
// reads the code, while an assertion-based step (such as WebAuthn) reads
// the credential.
type ContinueRequest struct {
// Handle is the flow handle returned by the login endpoint.
Handle string `json:"handle"`
// Code is the credential for a code-based step, such as a one-time
// password.
Code string `json:"code,omitzero"`
// Credential is the structured credential for an assertion-based step,
// such as a JSON-encoded WebAuthn assertion.
Credential jsontext.Value `json:"credential,omitzero"`
}
// Validate implements the [valid.Validatable] interface.
func (r *ContinueRequest) Validate(v *valid.Validator) {
v.NotEmpty("handle", r.Handle)
}
var _ valid.Validatable = (*ContinueRequest)(nil)
// ActionRequest represents the payload for the login action endpoint.
//
// It is consumed by [Server.Action] to drive an out-of-band operation on the
// active step of a pending login, such as resending a one-time password or
// switching the delivery channel.
type ActionRequest struct {
// Handle is the flow handle returned by the login endpoint.
Handle string `json:"handle"`
// Action names the operation to run (e.g. [ActionResend]).
Action string `json:"action"`
// Channel optionally selects a different delivery channel (by
// [Channel.ID]) for actions that support it, such as a resend.
Channel string `json:"channel,omitzero"`
}
// Validate implements the [valid.Validatable] interface.
func (r *ActionRequest) Validate(v *valid.Validator) {
v.NotEmpty("handle", r.Handle)
v.NotEmpty("action", r.Action)
}
var _ valid.Validatable = (*ActionRequest)(nil)
// Path constants define the login endpoints managed by the [Server].
const (
PathLogin = "/login"
PathLoginIdentify = "/login/identify"
PathLoginContinue = "/login/continue"
PathLoginAction = "/login/action"
PathLogout = "/logout"
)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package login
import (
"cmp"
"context"
"fmt"
"net/http"
"time"
"uuid"
"github.com/deep-rent/nexus/eco/iam/flow"
"github.com/deep-rent/nexus/eco/iam/otp"
"github.com/deep-rent/nexus/eco/iam/session"
"github.com/deep-rent/nexus/eco/iam/trust"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/net/throttle"
"github.com/deep-rent/nexus/sec/digest"
"github.com/deep-rent/nexus/sec/nonce"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/sys/log"
)
// nonceSize is the byte length of every opaque bearer artifact the manager
// mints — session keys and device trust tokens. It mirrors the size the
// authorization server uses for its own artifacts.
const nonceSize = 32
// Config holds the mandatory and optional parameters for constructing a
// [Manager]. Zero values for optional fields are replaced with the package
// defaults by [NewManager].
type Config struct {
// Users authenticates and resolves resource owners. Required.
Users Users
// Sessions persists login sessions: the digest-keyed mapping from a
// session key to the authenticated user. Required.
Sessions session.Store
// Planner decides the authentication steps a login requires. Setting it
// enables multi-step logins; without one, the identifying factor alone
// establishes the session.
Planner Planner
// Flows persists multi-step login transactions. Required with Planner.
Flows flow.Store
// Challenges persists one-time password challenges for the login flow
// steps built via [Manager.OTPStep]. Required with Planner.
Challenges otp.Store
// Trust persists remember-me device trust records. Required with
// Planner, whose remembered logins enroll devices.
Trust trust.Store
// SessionCookieName overrides [DefaultSessionCookieName].
SessionCookieName string
// TrustCookieName overrides [DefaultTrustCookieName].
TrustCookieName string
// SessionLifetime is the server-side validity period of a session
// established without the remember flag. The session cookie itself
// remains a browser-session cookie; this bounds how long the session
// stays resolvable on the server. Defaults to [DefaultSessionLifetime].
SessionLifetime time.Duration
// RememberedSessionLifetime is how long a session persists when the
// client asked to be remembered at login. It sets both the Max-Age of
// the persistent session cookie and the server-side expiry; without the
// remember flag, SessionLifetime applies instead. Defaults to
// [DefaultRememberedSessionLifetime].
RememberedSessionLifetime time.Duration
// TrustedDeviceLifetime is how long a remember-me device trust token
// stays valid. On a trusted device within this window, a [Planner] may
// skip factors. Defaults to [DefaultTrustedDeviceLifetime].
TrustedDeviceLifetime time.Duration
// OTPCodeLength is the number of digits in a generated one-time
// password. Defaults to [DefaultOTPCodeLength]. It is ignored when a
// custom sampler is installed via an [otp.WithCodeSampler] passed to
// [WithOTPOptions].
OTPCodeLength int
// OTPLifetime overrides [DefaultOTPLifetime]. Resending a one-time
// password does not extend a challenge's lifetime. The challenge
// lifetime doubles as the flow lifetime, so a code stays live for as
// long as the login it backs.
OTPLifetime time.Duration
// OTPMaxAttempts overrides [DefaultOTPMaxAttempts].
OTPMaxAttempts int
// OTPMaxResends overrides [DefaultOTPMaxResends]. A negative value
// disables resends entirely.
OTPMaxResends int
// Logger receives structured diagnostics. Defaults to [log.Discard],
// keeping the manager silent unless a logger is injected.
Logger *log.Logger
}
// Manager is the authentication core: it turns a proven identity into a
// session and runs the machinery every login method shares.
//
// It owns the session and device-trust cookies, evaluates device trust,
// consults the [Planner] for the factors a login still requires, drives the
// resulting flow, and publishes lifecycle events. Authentication methods —
// the password endpoints of the [Server], a passkey assertion, an external
// identity provider callback — converge on [Manager.Complete] (or
// [Manager.Establish]) once their proof is in hand, and sibling APIs resolve
// the session back into a [User] via [Manager.Resolve].
//
// Create instances with [NewManager].
type Manager struct {
users Users
planner Planner
sessions *session.Manager
trust *trust.Manager // nil without a trust store
flow *flow.Coordinator // nil without a planner
otp *otp.Challenger // nil without a planner
sessionCookieName string
trustCookieName string
sessionLifetime time.Duration
rememberedSessionLifetime time.Duration
trustedDeviceLifetime time.Duration
nonceSource nonce.Source
nonce *nonce.Generator
hasher *digest.Hasher
otpOpts []otp.Option
observer Observer
logger *log.Logger
now clock.Clock
}
// NewManager assembles a [Manager] from the given configuration and options.
//
// It panics if a required [Config] field is missing, or if a planner is
// configured without the stores the flow machinery depends on. Construction
// happens once at startup, so misconfiguration is a programmer error rather
// than a recoverable runtime condition.
func NewManager(cfg Config, opts ...Option) *Manager {
switch {
case cfg.Users == nil:
panic("users is required")
case cfg.Sessions == nil:
panic("session store is required")
case cfg.Planner != nil && cfg.Challenges == nil:
panic("flows require a challenge store")
case cfg.Planner != nil && cfg.Flows == nil:
panic("flows require a flow store")
case cfg.Planner != nil && cfg.Trust == nil:
panic("flows require a trust store")
}
logger := cfg.Logger
if logger == nil {
logger = log.Discard()
}
m := &Manager{
users: cfg.Users,
planner: cfg.Planner,
sessionCookieName: cmp.Or(
cfg.SessionCookieName,
DefaultSessionCookieName,
),
trustCookieName: cmp.Or(
cfg.TrustCookieName,
DefaultTrustCookieName,
),
sessionLifetime: cmp.Or(
cfg.SessionLifetime,
DefaultSessionLifetime,
),
rememberedSessionLifetime: cmp.Or(
cfg.RememberedSessionLifetime,
DefaultRememberedSessionLifetime,
),
trustedDeviceLifetime: cmp.Or(
cfg.TrustedDeviceLifetime,
DefaultTrustedDeviceLifetime,
),
hasher: digest.DefaultHasher,
logger: logger,
now: clock.System,
}
for _, opt := range opts {
opt(m)
}
// Every opaque bearer artifact the manager mints — session keys and
// device trust tokens — is drawn from one generator fed by the
// configured source (crypto/rand by default). It is built after the
// options so it observes the final source.
m.nonce = nonce.NewGenerator(m.nonceSource, nonceSize)
// The engines are built only after all options are applied, so they
// observe the final clock, hasher, and logger; per-session lifetimes are
// decided at establishment.
m.sessions = session.New(
cfg.Sessions,
session.WithHasher(m.hasher),
session.WithGenerator(m.nonce),
session.WithClock(m.now),
)
// The device trust engine is built only when a trust store is
// configured; managers without one simply treat every device as
// untrusted.
if cfg.Trust != nil {
m.trust = trust.New(
cfg.Trust,
trust.WithLifetime(m.trustedDeviceLifetime),
trust.WithHasher(m.hasher),
trust.WithGenerator(m.nonce),
trust.WithClock(m.now),
)
}
// The OTP challenge lifetime doubles as the flow lifetime, so a code
// stays live for as long as the login it backs. Caller-supplied
// otp.Options (from WithOTPOptions) win over the Config-derived
// defaults, since they are appended last.
if m.planner != nil {
lifetime := cmp.Or(cfg.OTPLifetime, DefaultOTPLifetime)
m.otp = otp.New(
cfg.Challenges,
append([]otp.Option{
otp.WithCodeSampler(nonce.NewSampler(
nil,
otp.Digits,
cmp.Or(cfg.OTPCodeLength, DefaultOTPCodeLength),
)),
otp.WithLifetime(lifetime),
otp.WithMaxAttempts(
cmp.Or(cfg.OTPMaxAttempts, DefaultOTPMaxAttempts),
),
otp.WithMaxResends(
cmp.Or(cfg.OTPMaxResends, DefaultOTPMaxResends),
),
otp.WithHasher(m.hasher),
otp.WithClock(m.now),
otp.WithLogger(m.logger),
}, m.otpOpts...)...,
)
m.flow = flow.New(
cfg.Flows,
flow.WithLifetime(lifetime),
flow.WithHasher(m.hasher),
flow.WithClock(m.now),
flow.WithLogger(m.logger),
)
}
return m
}
// MultiStep reports whether a [Planner] is configured, i.e. whether logins
// may require further factors beyond the identifying one.
func (m *Manager) MultiStep() bool { return m.flow != nil }
// Resolve returns the resource owner bound to the request's session cookie
// (i.e., the currently authenticated user).
//
// It returns nil (with a nil error) if no valid session exists, and an error
// only if the underlying storage lookup fails.
func (m *Manager) Resolve(e *router.Exchange) (User, error) {
cookie, err := e.Cookie(m.sessionCookieName)
if err != nil || cookie.Value == "" {
// No cookie is no session, which is not a failure to report.
return nil, nil //nolint:nilerr // documented above.
}
owner, ok, err := m.sessions.Resolve(e.Context(), cookie.Value)
if err != nil || !ok {
return nil, err
}
return m.users.GetUser(e.Context(), owner)
}
// Establish creates a session for a user whose identity is proven and sets
// the session cookie on the user-agent. It is the convergence point of every
// login method: the password endpoints, completed flows, passkey logins, and
// external identity provider callbacks all end here.
//
// A remembered session is set as a persistent cookie lasting
// [Config.RememberedSessionLifetime]; otherwise it is a browser-session
// cookie that lapses when the user-agent closes, with the server-side record
// bounded by [Config.SessionLifetime].
func (m *Manager) Establish(
e *router.Exchange,
usr User,
remember bool,
) error {
lifetime := m.sessionLifetime
maxAge := 0
if remember {
lifetime = m.rememberedSessionLifetime
maxAge = int(lifetime.Seconds())
}
key, err := m.sessions.Establish(
e.Context(),
usr.ID(),
e.R.UserAgent(),
lifetime,
)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to establish session",
Cause: err,
}
}
e.SetCookie(m.newSessionCookie(key, maxAge))
// The device trust presented with the login distinguishes a familiar
// browser from a first-time sign-in for subscribers; a resolution
// failure only degrades the event to "untrusted".
var dev trust.Device
if m.trust != nil {
dev, _ = m.Device(e.Context(), m.TrustToken(e), usr.ID())
}
m.publish(Event{
Kind: EventLogin,
UserID: usr.ID(),
Device: dev,
Remember: remember,
Label: e.R.UserAgent(),
Addr: throttle.RemoteAddr(e.R),
})
return nil
}
// Complete establishes the session and, for a remembered login on a
// multi-step manager, issues a device trust token so later logins may skip
// factors. A failure to persist the trust never fails the login.
func (m *Manager) Complete(
e *router.Exchange,
usr User,
remember bool,
) error {
if err := m.Establish(e, usr, remember); err != nil {
return err
}
if remember && m.flow != nil {
token, err := m.trust.Issue(
e.Context(),
usr.ID(),
e.R.UserAgent(),
)
if err != nil {
m.logger.Error(e.Context(),
"Failed to issue device trust", log.Error(err),
)
} else {
e.SetCookie(m.newTrustCookie(
token, int(m.trustedDeviceLifetime.Seconds()),
))
m.publish(Event{
Kind: EventDeviceTrusted,
UserID: usr.ID(),
Label: e.R.UserAgent(),
Addr: throttle.RemoteAddr(e.R),
})
}
}
return nil
}
// Clear terminates the session bound to the request's session cookie and
// clears the cookie on the user-agent by setting a negative Max-Age value.
// A request without a session only clears the cookie.
func (m *Manager) Clear(e *router.Exchange) {
cookie, err := e.Cookie(m.sessionCookieName)
if err == nil && cookie.Value != "" {
// Resolve the owner before destroying the session, so the logout
// event can name them; a failed resolution only mutes the event.
owner, ok, _ := m.sessions.Resolve(e.Context(), cookie.Value)
if _, err := m.sessions.Destroy(
e.Context(),
cookie.Value,
); err != nil {
m.logger.Error(
e.Context(),
"Failed to destroy session",
log.Error(err),
)
} else if ok {
m.publish(Event{
Kind: EventLogout,
UserID: owner,
Label: e.R.UserAgent(),
Addr: throttle.RemoteAddr(e.R),
})
}
}
e.SetCookie(m.newSessionCookie("", -1))
}
// Device reports whether the raw token proves the requesting device is
// trusted for the given user. A wrong or stale token — or a manager without
// a trust store — simply yields an untrusted [trust.Device]; see
// [trust.Manager.Check].
func (m *Manager) Device(
ctx context.Context,
token string,
userID uuid.UUID,
) (trust.Device, error) {
if m.trust == nil {
return trust.Device{}, nil
}
return m.trust.Check(ctx, token, userID)
}
// TrustToken returns the remember-me device trust token from the request,
// or the empty string when absent.
func (m *Manager) TrustToken(e *router.Exchange) string {
if c, err := e.Cookie(m.trustCookieName); err == nil {
return c.Value
}
return ""
}
// Plan consults the [Planner] for the steps a login still requires for the
// user on the given device. Without a planner it returns an empty course.
func (m *Manager) Plan(
ctx context.Context,
usr User,
dev trust.Device,
) (flow.Course, error) {
if m.planner == nil {
return nil, nil
}
return m.planner(ctx, usr, dev)
}
// Begin starts a multi-step login over the given course for a user whose
// identifying factor is verified. An empty course completes immediately; see
// [flow.Coordinator.Begin]. It must only be called on a multi-step manager.
func (m *Manager) Begin(
ctx context.Context,
owner uuid.UUID,
remember bool,
course flow.Course,
) (string, flow.Result, error) {
return m.flow.Begin(ctx, owner, remember, course)
}
// Continue verifies the client's input against the active step of a pending
// login and advances the flow. The plan is re-run with the device trust
// carried by trustToken, so factor changes take effect mid-login. It must
// only be called on a multi-step manager.
func (m *Manager) Continue(
ctx context.Context,
handle, trustToken string,
in flow.Input,
) (flow.Result, error) {
return m.flow.Continue(ctx, handle, m.replan(trustToken), in)
}
// Act runs an out-of-band action on the active step of a pending login,
// such as resending a one-time password. It must only be called on a
// multi-step manager.
func (m *Manager) Act(
ctx context.Context,
handle, trustToken string,
a flow.Action,
) (flow.Result, error) {
return m.flow.Act(ctx, handle, m.replan(trustToken), a)
}
// replan builds a [flow.Plan] that resolves the user recorded in the
// transaction and re-runs the planner, folding in the requesting device's
// trust (from the trust token) so plan changes take effect mid-login.
func (m *Manager) replan(trustToken string) flow.Plan {
return func(ctx context.Context, owner uuid.UUID) (flow.Course, error) {
usr, err := m.users.GetUser(ctx, owner)
if err != nil {
return nil, err
}
if usr == nil {
return nil, fmt.Errorf("user %s no longer exists", owner)
}
dev, err := m.Device(ctx, trustToken, owner)
if err != nil {
return nil, err
}
return m.planner(ctx, usr, dev)
}
}
// OTPStep returns a one-time password [flow.Step] over the manager's
// challenge engine, delivering over the given ordered methods, most
// preferred first. It is the constructor a [Planner] uses to require a code
// factor; see [OTPStep] for the step's behavior. It panics on a manager
// without a planner, since the challenge engine only exists alongside the
// flow machinery.
func (m *Manager) OTPStep(id string, methods []otp.Method) flow.Step {
return m.FactorStep(id, methods, nil)
}
// FactorStep builds a step satisfied by any one of the user's second
// factors — a delivered code, or a value read off something they carry.
// See [FactorStep] for why the alternatives share one step.
//
// It panics if multi-step login is not enabled, since a step is useless
// without the flow machinery to run it.
func (m *Manager) FactorStep(
id string,
methods []otp.Method,
carried []Carried,
) flow.Step {
if m.otp == nil {
panic("multi-step login is not enabled")
}
return FactorStep(id, m.otp, methods, carried)
}
// Fingerprint digests a bearer artifact with the manager's configured
// hasher, for callers that key caches or throttle buckets on artifacts
// without storing them in the clear.
func (m *Manager) Fingerprint(value string) string {
return m.hasher.String(value)
}
// RevokeSessions destroys every login session held by the user. Call it when
// the user's credentials change — for example on a password reset — so that
// no stolen session outlives the credential it was established with.
func (m *Manager) RevokeSessions(
ctx context.Context,
userID uuid.UUID,
) error {
if err := m.sessions.DestroyAll(ctx, userID); err != nil {
return err
}
m.publish(Event{Kind: EventSessionsRevoked, UserID: userID})
return nil
}
// RevokeTrustedDevices removes every remember-me device trust enrolled by
// the user. Call it when the user's credentials change — for example on a
// password reset — so that no previously trusted device can skip
// authentication factors. It is a no-op on a manager without a trust store.
func (m *Manager) RevokeTrustedDevices(
ctx context.Context,
userID uuid.UUID,
) error {
if m.trust == nil {
return nil
}
if err := m.trust.RevokeAll(ctx, userID); err != nil {
return err
}
m.publish(Event{Kind: EventTrustRevoked, UserID: userID})
return nil
}
// Sessions exposes the session engine for management APIs that list or
// revoke individual sessions on the user's behalf.
func (m *Manager) Sessions() *session.Manager { return m.sessions }
// Trust exposes the device trust engine for management APIs that list or
// revoke individual trusted devices on the user's behalf. It is nil on a
// manager without a trust store.
func (m *Manager) Trust() *trust.Manager { return m.trust }
// SessionCookieName returns the name of the cookie carrying the login
// session key, so sibling APIs can resolve the same sessions.
func (m *Manager) SessionCookieName() string { return m.sessionCookieName }
// newSessionCookie builds the cookie carrying the resource owner's session
// key. SameSite=Lax keeps the cookie out of cross-site subrequests while
// still covering top-level navigations to the authorization endpoint.
func (m *Manager) newSessionCookie(value string, maxAge int) *http.Cookie {
return router.NewCookie(
m.sessionCookieName,
value,
maxAge,
http.SameSiteLaxMode,
)
}
// newTrustCookie builds the remember-me device trust cookie. A negative
// maxAge clears it on the user-agent.
func (m *Manager) newTrustCookie(value string, maxAge int) *http.Cookie {
return router.NewCookie(
m.trustCookieName,
value,
maxAge,
http.SameSiteLaxMode,
)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package login
import (
"time"
"github.com/deep-rent/nexus/eco/iam/limit"
"github.com/deep-rent/nexus/eco/iam/otp"
"github.com/deep-rent/nexus/sec/digest"
"github.com/deep-rent/nexus/sec/nonce"
"github.com/deep-rent/nexus/std/clock"
)
// Default values applied by [NewManager] for optional [Config] fields.
const (
// DefaultSessionCookieName names the cookie carrying the resource owner's
// session key.
DefaultSessionCookieName = "oauth_session"
// DefaultTrustCookieName names the cookie carrying the remember-me device
// trust token.
DefaultTrustCookieName = "oauth_trust"
// DefaultSessionLifetime is the server-side validity period of a session
// established without the remember flag.
DefaultSessionLifetime = 24 * time.Hour
// DefaultRememberedSessionLifetime is the persistence of a session when the
// client asked to be remembered.
DefaultRememberedSessionLifetime = 30 * 24 * time.Hour
// DefaultTrustedDeviceLifetime is the validity period of a remember-me
// device trust token.
DefaultTrustedDeviceLifetime = 30 * 24 * time.Hour
)
// Default values applied by [NewManager] for the optional OTP-related
// [Config] fields, which configure the one-time password steps of a login
// flow.
const (
// DefaultOTPCodeLength is the number of digits in a one-time password.
DefaultOTPCodeLength = otp.DefaultLength
// DefaultOTPLifetime is the validity period of a one-time password code
// and, aligned with it, of a login flow.
DefaultOTPLifetime = otp.DefaultLifetime
// DefaultOTPMaxAttempts is the number of failed confirmation attempts after
// which a code is burned.
DefaultOTPMaxAttempts = otp.DefaultMaxAttempts
// DefaultOTPMaxResends is the number of times a single code may be
// redelivered.
DefaultOTPMaxResends = otp.DefaultMaxResends
)
// Option customizes a [Manager] during construction with [NewManager].
type Option func(*Manager)
// WithHasher sets the hasher that fingerprints every bearer artifact before
// it crosses a store boundary — session keys, device trust tokens, login
// flow handles, and one-time passwords. A nil hasher is ignored. Defaults to
// [digest.DefaultHasher] (SHA-256, base64url).
//
// Changing it invalidates every previously stored artifact.
func WithHasher(h *digest.Hasher) Option {
return func(m *Manager) {
if h != nil {
m.hasher = h
}
}
}
// WithClock overrides the manager's time source. This is primarily useful
// for deterministic testing.
func WithClock(now clock.Clock) Option {
return func(m *Manager) {
if now != nil {
m.now = now
}
}
}
// WithNonceSource sets the entropy source for the opaque bearer artifacts
// the manager mints — session keys and device trust tokens — all of which
// are drawn from a single [nonce.Generator]. It defaults to
// [nonce.DefaultSource] (crypto/rand); provide a deterministic source for
// testing or a hardware/remote source in specialized deployments. A nil
// source is ignored.
//
// It does not affect the one-time password steps of a login flow, whose
// generators are configured through [WithOTPOptions].
func WithNonceSource(src nonce.Source) Option {
return func(m *Manager) {
if src != nil {
m.nonceSource = src
}
}
}
// WithOTPOptions appends [otp.Option] values — such as
// [otp.WithCodeSampler] or [otp.WithHandleGenerator] — to the one-time
// password engine backing the flow's code steps, overriding the
// Config-derived defaults.
func WithOTPOptions(opts ...otp.Option) Option {
return func(m *Manager) {
m.otpOpts = append(m.otpOpts, opts...)
}
}
// WithObserver registers an observer for lifecycle [Event] notifications,
// such as established logins. Observers typically feed audit trails or
// user-facing security notifications. A nil observer is ignored; without
// one, no events are published.
func WithObserver(o Observer) Option {
return func(m *Manager) {
if o != nil {
m.observer = o
}
}
}
// ServerOption customizes a [Server] during construction with [NewServer].
type ServerOption func(*Server)
// WithLimiter installs the throttle the endpoints charge failed
// authentication attempts against. The zero [limit.Limiter] disables
// limiting entirely.
func WithLimiter(l limit.Limiter) ServerOption {
return func(s *Server) { s.limit = l }
}
// WithCaptcha guards the password endpoint with a Cloudflare Turnstile
// reputation check, verified before any password is hashed.
//
// It is the answer to the residual timing signal a login endpoint cannot
// remove on its own: an automated attacker measuring response times has
// to pass the challenge on every attempt, which prices statistical
// enumeration out of reach. It does not replace throttling — the two
// bound different things, and the throttle is what still stands when
// Cloudflare is unreachable.
//
// A nil verifier leaves the endpoint unguarded, so a deployment can bind
// the check to configuration without branching at the call site. See
// [Captcha] for the failure policy.
func WithCaptcha(c Captcha) ServerOption {
return func(s *Server) {
if c.Verifier != nil {
s.captcha = c
}
}
}
// WithPasswordless additionally enables passwordless login, in which a user
// is identified by username alone and the flow's factors — rather than a
// password — authenticate them. It requires a multi-step [Manager] and
// registers the [Server.Identify] endpoint.
//
// The same [Planner] serves both entries, so its chain must be sufficient
// authentication on its own; passwordless login ignores device trust and
// refuses to establish a session when the planner yields no factors, so it
// can never authenticate on a username alone. See [Server.Identify] for the
// enumeration considerations of exposing a username-keyed endpoint.
func WithPasswordless() ServerOption {
return func(s *Server) { s.passwordless = true }
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package login
import (
"errors"
"fmt"
"net/http"
"strings"
"github.com/deep-rent/nexus/eco/iam/flow"
"github.com/deep-rent/nexus/eco/iam/limit"
"github.com/deep-rent/nexus/eco/iam/trust"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/net/throttle"
"github.com/deep-rent/nexus/net/turnstile"
"github.com/deep-rent/nexus/sec/auth"
"github.com/deep-rent/nexus/sys/log"
)
// Server mounts the first-party login endpoints over a [Manager]: password
// login, passwordless identification, flow continuation and actions, and
// logout.
//
// Create instances with [NewServer] and attach them to a router via
// [Server.Mount].
type Server struct {
m *Manager
limit limit.Limiter
captcha Captcha
passwordless bool
}
// Captcha configures the reputation check guarding the password endpoint;
// install it with [WithCaptcha].
type Captcha struct {
// Verifier exchanges the client's token for a verdict. A nil verifier
// leaves the endpoint unguarded.
Verifier turnstile.Verifier
// Action optionally pins the widget's action name, so a token minted
// on another form of the same site cannot be replayed at the login
// endpoint. Leave it empty when the widget sets no action.
Action string
// Hostname optionally pins the domain the widget must have run on,
// refusing a token minted on a page an attacker controls under the
// same site key. Leave it empty to accept any of the site's origins.
Hostname string
// Required decides what happens when Cloudflare cannot be reached: by
// default the endpoint proceeds on the throttle alone, trading the
// check for availability, since an outage would otherwise lock every
// user out. Setting it refuses logins instead, for a deployment that
// would rather stop than serve unscreened traffic.
Required bool
}
// screen runs the captcha check, returning a router error when the
// request must not proceed. It reports nothing when no captcha is
// configured.
//
// The verdict and the outage are handled separately on purpose: a visitor
// who did not pass is refused, while an unreachable Cloudflare is a
// judgment call the deployment makes through [Captcha.Required].
func (s *Server) screen(e *router.Exchange, token string) error {
if s.captcha.Verifier == nil {
return nil
}
refuse := &router.Error{
Status: http.StatusForbidden,
Reason: ReasonCaptchaFailed,
Description: "captcha verification failed",
}
res, err := s.captcha.Verifier.Verify(e.Context(), turnstile.Request{
Token: token,
Addr: throttle.RemoteAddr(e.R),
Action: s.captcha.Action,
})
if err != nil {
s.m.logger.Error(
e.Context(),
"Captcha verification is unavailable",
log.Bool("required", s.captcha.Required),
log.Error(err),
)
if s.captcha.Required {
return refuse
}
// The throttle is what bounds the endpoint until the check
// recovers.
return nil
}
if !res.Success {
return refuse
}
// A verdict is only about the token Cloudflare was shown; that it was
// minted for this form, on this origin, is ours to check.
if s.captcha.Action != "" && res.Action != s.captcha.Action {
return refuse
}
if s.captcha.Hostname != "" && res.Hostname != s.captcha.Hostname {
return refuse
}
return nil
}
// NewServer assembles a [Server] over the given manager.
//
// It panics if the manager is nil, or if [WithPasswordless] is requested on
// a manager without a planner — both startup configuration errors.
func NewServer(m *Manager, opts ...ServerOption) *Server {
if m == nil {
panic("manager is required")
}
s := &Server{m: m}
for _, opt := range opts {
opt(s)
}
if s.passwordless && !m.MultiStep() {
panic("passwordless authentication requires a flow")
}
return s
}
// Manager returns the underlying authentication core, for modules that
// complete logins through other methods.
func (s *Server) Manager() *Manager { return s.m }
// Mount registers the login endpoints on the registrar — the router itself
// for a root mount, or a [router.Group] to nest the server under a path
// prefix or shared middleware.
//
// The login continue and action endpoints are only registered when the
// manager runs multi-step logins, and the identify endpoint only when
// passwordless login is enabled via [WithPasswordless]. When a limiter is
// installed via [WithLimiter], every endpoint that accepts credential
// guesses is additionally wrapped in the throttle middleware.
func (s *Server) Mount(r router.Registrar) {
// guarded additionally protects endpoints that accept credential
// guesses.
guarded := r
if s.limit.Enabled() {
guarded = r.Group("", s.limit.Middleware())
}
guarded.HandleFunc(http.MethodPost, PathLogin, s.Login)
r.HandleFunc(http.MethodPost, PathLogout, s.Logout)
if s.m.MultiStep() {
if s.passwordless {
guarded.HandleFunc(
http.MethodPost, PathLoginIdentify,
s.Identify,
)
}
guarded.HandleFunc(
http.MethodPost, PathLoginContinue,
s.Continue,
)
guarded.HandleFunc(http.MethodPost, PathLoginAction, s.Action)
}
}
// Login authenticates a resource owner and either establishes a session or
// begins a multi-step login.
//
// It expects a JSON payload with username, password, and an optional remember
// flag. On a manager without a [Planner], a verified password establishes the
// session directly and the endpoint responds 204.
//
// With a planner, a verified password is only the first factor. The server
// consults the planner for the remaining steps on this user and device; if
// any remain, it responds 200 with a [FlowResponse] carrying a flow handle,
// and the client completes the login via [Server.Continue]. Clients therefore
// must distinguish a 204 (session established) from a 200 (further steps
// pending) response.
//
// Note: When calling this endpoint from a cross-origin frontend (e.g., an
// SPA), the CORS middleware must be configured with AllowCredentials set to
// true, and AllowOrigin must not be a wildcard ("*").
func (s *Server) Login(e *router.Exchange) error {
var cred Request
if err := e.BindJSON(&cred); err != nil {
return err
}
// Guesses are counted per account, folded to lower case so that varying
// the capitalization cannot buy a fresh allowance.
userKey := limit.ScopeUser + strings.ToLower(cred.Username)
if s.limit.Throttled(e, userKey) {
return &router.Error{
Status: http.StatusTooManyRequests,
Reason: router.ReasonRateLimit,
Description: "too many failed attempts; try again later",
}
}
// The captcha runs after the throttle, which is free, and before the
// password hash, which is not: an attacker who is already throttled
// costs no outbound verification, and one who fails the check never
// reaches the hash.
if err := s.screen(e, cred.Captcha); err != nil {
// Only the address is charged. Penalizing the account key here
// would let anyone lock a victim out by submitting their username
// with a junk token, no credential involved.
s.limit.Penalize(s.limit.Addr(e))
return err
}
usr, err := s.m.users.Authenticate(
e.Context(),
cred.Username,
cred.Password,
)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to lookup user",
Cause: err,
}
}
if usr == nil {
s.limit.Penalize(userKey, s.limit.Addr(e))
s.m.publish(Event{
Kind: EventLoginFailed,
Username: cred.Username,
Label: e.R.UserAgent(),
Addr: throttle.RemoteAddr(e.R),
})
return &router.Error{
Status: http.StatusUnauthorized,
Reason: router.ReasonValidationFailed,
Description: "invalid credentials",
}
}
// The password is proven; drop any penalty from earlier attempts.
s.limit.Clear(userKey)
// Without a planner, the password alone establishes the session.
if !s.m.MultiStep() {
return s.complete(e, usr, cred.Remember)
}
// The password is the first factor; the planner decides the rest for this
// user and device.
dev, err := s.m.Device(e.Context(), s.m.TrustToken(e), usr.ID())
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to evaluate device trust",
Cause: err,
}
}
course, err := s.m.Plan(e.Context(), usr, dev)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to plan login",
Cause: err,
}
}
handle, res, err := s.m.Begin(
e.Context(), usr.ID(), cred.Remember, course,
)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to begin login",
Cause: err,
}
}
// No further steps: complete immediately (e.g. a trusted device).
if res.Done() {
return s.complete(e, usr, res.Remember)
}
return e.JSON(http.StatusOK, FlowResponse{
Handle: handle,
Step: res.Prompt.Step,
Prompt: res.Prompt.Payload,
})
}
// Identify starts a passwordless login: it identifies the user by username
// and hands off to the login flow's factors to authenticate them.
//
// It expects an [IdentifyRequest] with a username. When the user exists and
// the planner yields at least one factor, it responds 200 with a
// [FlowResponse]; the client then satisfies the factors via [Server.Continue]
// exactly as after a password login. Unlike the password login, it ignores any
// device trust — a passwordless login always walks its full factor chain — and
// it refuses to complete when the planner yields no factor, so a username alone
// can never establish a session.
//
// Every call is rate limited per username and address, because a successful
// call delivers a code (an email or SMS) and so has a cost. The endpoint is
// username-keyed and distinguishes a known from an unknown username by its
// response; a deployment that must not reveal which usernames exist should
// front it with an additional control such as a CAPTCHA or a strict global
// limit.
func (s *Server) Identify(e *router.Exchange) error {
if !s.m.MultiStep() || !s.passwordless {
e.Status(http.StatusNotFound)
return nil
}
var req IdentifyRequest
if err := e.BindJSON(&req); err != nil {
return err
}
userKey := limit.ScopeUser + strings.ToLower(req.Username)
if s.limit.Throttled(e, userKey) {
return &router.Error{
Status: http.StatusTooManyRequests,
Reason: router.ReasonRateLimit,
Description: "too many attempts; try again later",
}
}
// Each identify triggers a factor delivery, so charge the throttle on every
// call — success included — to bound code-send spam per username and
// address.
s.limit.Penalize(userKey, s.limit.Addr(e))
usr, err := s.m.users.GetUserByUsername(e.Context(), req.Username)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to lookup user",
Cause: err,
}
}
if usr == nil {
s.m.publish(Event{
Kind: EventLoginFailed,
Username: req.Username,
Label: e.R.UserAgent(),
Addr: throttle.RemoteAddr(e.R),
})
return &router.Error{
Status: http.StatusUnauthorized,
Reason: router.ReasonValidationFailed,
Description: "invalid credentials",
}
}
// Passwordless login ignores device trust: the flow must fully
// authenticate, so the planner runs against an untrusted device.
course, err := s.m.Plan(e.Context(), usr, trust.Device{})
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to plan login",
Cause: err,
}
}
if len(course) == 0 {
// A passwordless login with no factor would authenticate on a username
// alone; refuse rather than establish a session.
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "passwordless login has no factor",
Cause: fmt.Errorf(
"planner yielded no steps for %s",
usr.ID(),
),
}
}
handle, res, err := s.m.Begin(
e.Context(), usr.ID(), req.Remember, course,
)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to begin login",
Cause: err,
}
}
// The course is non-empty, so the flow prompts rather than completing.
return e.JSON(http.StatusOK, FlowResponse{
Handle: handle,
Step: res.Prompt.Step,
Prompt: res.Prompt.Payload,
})
}
// Continue satisfies the active step of a pending multi-step login and
// advances it, establishing the session once every step is complete.
//
// It expects a [ContinueRequest] with the flow handle from [Server.Login] and
// the credential for the active step. On completion it responds 204 with a
// session cookie; while steps remain it responds 200 with the next
// [FlowResponse]. The planner is re-run each call, so a change to the user's
// factors takes effect mid-login.
func (s *Server) Continue(e *router.Exchange) error {
if !s.m.MultiStep() {
e.Status(http.StatusNotFound)
return nil
}
var req ContinueRequest
if err := e.BindJSON(&req); err != nil {
return err
}
flowKey := limit.ScopeOTP + s.m.Fingerprint(req.Handle)
if s.limit.Throttled(e, flowKey) {
return &router.Error{
Status: http.StatusTooManyRequests,
Reason: router.ReasonRateLimit,
Description: "too many failed attempts; try again later",
}
}
res, err := s.m.Continue(
e.Context(),
req.Handle,
s.m.TrustToken(e),
flow.Input{Value: req.Code, Raw: []byte(req.Credential)},
)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to continue login",
Cause: err,
}
}
return s.afterStep(e, flowKey, req.Handle, res)
}
// Action drives an out-of-band operation on the active step of a pending
// login, such as resending a one-time password or switching the delivery
// channel.
//
// It expects an [ActionRequest] with the flow handle and the action. It
// responds 200 with a refreshed [FlowResponse] on success, 429 when a
// per-step limit is reached, and 400 for an unsupported action or channel.
func (s *Server) Action(e *router.Exchange) error {
if !s.m.MultiStep() {
e.Status(http.StatusNotFound)
return nil
}
var req ActionRequest
if err := e.BindJSON(&req); err != nil {
return err
}
flowKey := limit.ScopeOTP + s.m.Fingerprint(req.Handle)
if s.limit.Throttled(e, flowKey) {
return &router.Error{
Status: http.StatusTooManyRequests,
Reason: router.ReasonRateLimit,
Description: "too many failed attempts; try again later",
}
}
res, err := s.m.Act(
e.Context(),
req.Handle,
s.m.TrustToken(e),
flow.Action{Name: req.Action, Extra: map[string]string{
"channel": req.Channel,
}},
)
if err != nil {
switch {
case errors.Is(err, flow.ErrRateLimited):
return &router.Error{
Status: http.StatusTooManyRequests,
Reason: router.ReasonRateLimit,
Description: "resend limit reached",
}
case errors.Is(err, flow.ErrRejected):
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: "the action could not be performed",
}
default:
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to run login action",
Cause: err,
}
}
}
return s.afterStep(e, flowKey, req.Handle, res)
}
// Logout terminates the resource owner's session.
//
// It identifies the session via the session cookie, destroys the server-side
// record, and clears the cookie on the user-agent by setting a negative
// Max-Age value.
func (s *Server) Logout(e *router.Exchange) error {
s.m.Clear(e)
// Instruct the browser to wipe all local state (cookies, storage, cache).
// Note: The double-quotes around the asterisk are required by the spec.
e.SetHeader("Clear-Site-Data", `"*"`)
e.NoContent()
return nil
}
// afterStep maps a step outcome onto the HTTP response, establishing the
// session on completion and applying throttle penalties to failures.
//
// Every refusal answers the same 401, so a caller cannot tell an absent
// handle from an expired or aborted flow.
func (s *Server) afterStep(
e *router.Exchange,
flowKey, handle string,
res flow.Result,
) error {
switch res.Status {
case flow.StatusDone:
// Every step is proven; drop any penalty from earlier attempts.
s.limit.Clear(flowKey)
usr, err := s.m.users.GetUser(e.Context(), res.Owner)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to lookup user",
Cause: err,
}
}
if usr == nil {
return &router.Error{
Status: http.StatusUnauthorized,
Reason: auth.ReasonAuthenticationFailed,
Description: "invalid or expired login",
}
}
return s.complete(e, usr, res.Remember)
case flow.StatusPrompt:
return e.JSON(http.StatusOK, FlowResponse{
Handle: handle,
Step: res.Prompt.Step,
Prompt: res.Prompt.Payload,
})
case flow.StatusWrongInput:
s.limit.Penalize(flowKey, s.limit.Addr(e))
s.m.publish(Event{
Kind: EventFlowStepRejected,
UserID: res.Owner,
Label: e.R.UserAgent(),
Addr: throttle.RemoteAddr(e.R),
})
return &router.Error{
Status: http.StatusUnauthorized,
Reason: auth.ReasonAuthenticationFailed,
Description: "invalid code",
}
default:
s.limit.Penalize(flowKey, s.limit.Addr(e))
s.m.publish(Event{
Kind: EventFlowFailed,
UserID: res.Owner,
Reason: res.Reason,
Label: e.R.UserAgent(),
Addr: throttle.RemoteAddr(e.R),
})
return &router.Error{
Status: http.StatusUnauthorized,
Reason: auth.ReasonAuthenticationFailed,
Description: "invalid or expired login",
}
}
}
// complete finishes a proven login through the manager and responds 204.
func (s *Server) complete(
e *router.Exchange,
usr User,
remember bool,
) error {
if err := s.m.Complete(e, usr, remember); err != nil {
return err
}
e.NoContent()
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package login
import (
"context"
"errors"
"fmt"
"uuid"
"github.com/deep-rent/nexus/eco/iam/flow"
"github.com/deep-rent/nexus/eco/iam/otp"
"github.com/deep-rent/nexus/eco/iam/trust"
)
// purposeLogin namespaces one-time password challenges minted for login flow
// steps within the challenge store.
const purposeLogin = "login"
// ActionResend is the [flow.Action] name an [OTPStep] understands: it
// redelivers the code, optionally over a different channel named by the
// "channel" action parameter.
const ActionResend = "resend"
// Planner decides the authentication steps for a user on a given device.
//
// It runs after the identifying factor (e.g. a password) has been verified,
// and again on every continuation, so a change to the user's enrolled
// factors — or the device's trust — takes effect mid-login. Steps come from
// the module that implements the factor: [Manager.OTPStep] builds a one-time
// password step over the manager's challenge engine, and sibling modules
// export their own constructors. Returning an empty course completes the
// login with no further factors.
type Planner func(
ctx context.Context,
usr User,
dev trust.Device,
) (flow.Course, error)
// otpChallengePayload is the client-facing prompt an [OTPStep] returns: the
// channels the code may be delivered over and the code's remaining lifetime.
type otpChallengePayload struct {
// Channels lists the enrolled delivery methods for a client-side picker. It
// is omitted when only a single method is enrolled.
Channels []Channel `json:"channels,omitzero"`
// Carried lists the factors the user already holds — an authenticator
// app, recovery codes — which are answered without anything being
// delivered. It is omitted when the user holds none.
Carried []Channel `json:"carried,omitzero"`
// ExpiresIn is the remaining lifetime of the delivered code in
// seconds. It is zero when nothing was delivered, which is the shape
// a user with only carried factors sees.
ExpiresIn int64 `json:"expires_in,omitzero"`
}
// Verifier checks a value the user submitted against a factor they carry
// rather than receive. ok is false for a wrong value; the error is
// reserved for storage and sealing failures.
type Verifier func(
ctx context.Context,
userID uuid.UUID,
value string,
) (ok bool, err error)
// Carried is a second factor the user already holds, such as an
// authenticator app's time-based codes or a recovery code. Nothing is
// delivered for it: the step prompts, and the user reads the value off
// something they have.
type Carried struct {
// ID is a stable identifier for the client's picker (e.g. "totp",
// "recovery").
ID string
// Label is an optional human-facing hint. It never carries a secret.
Label string
// Verify checks the submitted value. Required.
Verify Verifier
}
// otpStep is a [flow.Step] that delivers and verifies a one-time password over
// a user's enrolled methods, composing an [otp.Challenger].
type otpStep struct {
id string
ch *otp.Challenger
methods []otp.Method
carried []Carried
}
var _ flow.Step = (*otpStep)(nil)
// OTPStep returns a [flow.Step] that delivers a one-time password over the
// user's enrolled methods and verifies the code the user returns.
//
// It composes the given [otp.Challenger], keying the challenge on a value
// derived from the flow handle and the step id, so the client holds only the
// single flow handle for the whole login. The methods are ordered, most
// preferred first; the client may switch channels on resend by [Channel.ID].
// A [Planner] typically builds the step through [Manager.OTPStep], which
// supplies the manager's own challenger.
//
// It panics if id is empty, ch is nil, or methods is empty — all startup
// configuration errors.
func OTPStep(id string, ch *otp.Challenger, methods []otp.Method) flow.Step {
return FactorStep(id, ch, methods, nil)
}
// FactorStep returns a [flow.Step] satisfied by any one of the user's
// second factors: a code delivered over one of methods, or a value read
// off something they carry (see [Carried]).
//
// The factors are alternatives, not a sequence. A course is a
// conjunction — every step in it must be completed — so offering them as
// separate steps would force a user with both an authenticator and a
// mail factor through both. They therefore live inside one step, the
// same way the delivery channels already do.
//
// Verification tries the carried factors before the delivered challenge,
// because only the latter spends an attempt: a user answering with their
// authenticator's code must not burn the mail challenge's budget.
//
// It panics if id is empty, ch is nil, or no factor of either kind is
// offered — all startup configuration errors. A carried factor without a
// verifier is likewise refused.
func FactorStep(
id string,
ch *otp.Challenger,
methods []otp.Method,
carried []Carried,
) flow.Step {
if id == "" {
panic("step ID is required")
}
if ch == nil {
panic("challenger is required")
}
if len(methods) == 0 && len(carried) == 0 {
panic("at least one factor is required")
}
for _, c := range carried {
if c.ID == "" {
panic("carried factor ID is required")
}
if c.Verify == nil {
panic("carried factor verifier is required")
}
}
return &otpStep{id: id, ch: ch, methods: methods, carried: carried}
}
// ID implements [flow.Step].
func (s *otpStep) ID() string { return s.id }
// handle derives the per-step challenge handle from the outer flow handle. The
// flow handle is high-entropy, so the derived value is too.
func (s *otpStep) handle(flowHandle string) string {
return flowHandle + ":" + s.id
}
// Begin implements [flow.Step]: it delivers a fresh code over the default
// method and returns the prompt.
func (s *otpStep) Begin(
ctx context.Context,
t *flow.Transaction,
handle string,
) (any, error) {
// A user with only carried factors has nothing delivered to them:
// the step simply prompts, and no challenge is minted.
if len(s.methods) == 0 {
return s.payload(0), nil
}
m := s.methods[0]
expiresIn, err := s.ch.Start(
ctx,
purposeLogin,
t.Owner,
s.handle(handle),
m,
)
if err != nil {
return nil, err
}
return s.payload(expiresIn), nil
}
// Verify implements [flow.Step]: it confirms the submitted code, translating
// the challenge outcome into a [flow.Verdict].
func (s *otpStep) Verify(
ctx context.Context,
t *flow.Transaction,
handle string,
in flow.Input,
) (flow.Verdict, error) {
// Carried factors are checked first: they spend nothing on a miss,
// while the delivered challenge spends an attempt. Answering with an
// authenticator code must not draw down the mail challenge's budget.
for _, c := range s.carried {
ok, err := c.Verify(ctx, t.Owner, in.Value)
if err != nil {
return 0, err
}
if ok {
return flow.VerdictOK, nil
}
}
// With nothing delivered there is no challenge to consult, so a value
// that satisfied no carried factor is simply wrong.
if len(s.methods) == 0 {
return flow.VerdictReject, nil
}
out, err := s.ch.Verify(ctx, purposeLogin, s.handle(handle), in.Value)
if err != nil {
return 0, err
}
switch out.Status {
case otp.StatusOK:
return flow.VerdictOK, nil
case otp.StatusWrongCode:
return flow.VerdictReject, nil
default:
// Absent, expired, or burned: the factor can no longer be satisfied, so
// the login must restart.
return flow.VerdictFail, nil
}
}
// Act implements [flow.Step]: it handles [ActionResend], redelivering the code
// over the default or a client-selected channel.
func (s *otpStep) Act(
ctx context.Context,
_ *flow.Transaction,
handle string,
a flow.Action,
) (any, error) {
if a.Name != ActionResend {
return nil, fmt.Errorf(
"%w: unsupported action %q",
flow.ErrRejected,
a.Name,
)
}
if len(s.methods) == 0 {
return nil, fmt.Errorf(
"%w: nothing is delivered for this factor",
flow.ErrRejected,
)
}
m := s.methods[0]
if id := a.Extra["channel"]; id != "" {
picked, ok := pickMethod(s.methods, id)
if !ok {
return nil, fmt.Errorf(
"%w: unknown channel %q",
flow.ErrRejected,
id,
)
}
m = picked
}
out, err := s.ch.Resend(ctx, purposeLogin, s.handle(handle), m)
if err != nil {
return nil, err
}
switch out.Status {
case otp.StatusOK:
return s.payload(out.ExpiresIn), nil
case otp.StatusResendLimit:
return nil, flow.ErrRateLimited
default:
return nil, errors.New("otp challenge is no longer resendable")
}
}
// payload builds the client-facing prompt, advertising the method picker only
// when more than one method is enrolled.
func (s *otpStep) payload(expiresIn int64) otpChallengePayload {
var channels []Channel
// The picker is advertised once there is a choice to make, which now
// includes choosing between a delivered code and a carried factor.
if len(s.methods) > 1 || (len(s.methods) > 0 && len(s.carried) > 0) {
channels = make([]Channel, len(s.methods))
for i, m := range s.methods {
channels[i] = Channel{ID: m.ID, Label: m.Label}
}
}
var carried []Channel
if len(s.carried) > 0 {
carried = make([]Channel, len(s.carried))
for i, c := range s.carried {
carried[i] = Channel{ID: c.ID, Label: c.Label}
}
}
return otpChallengePayload{
Channels: channels,
Carried: carried,
ExpiresIn: expiresIn,
}
}
// pickMethod returns the method with the given ID, or false when none matches.
func pickMethod(methods []otp.Method, id string) (otp.Method, bool) {
for _, m := range methods {
if m.ID == id {
return m, true
}
}
return otp.Method{}, false
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package mask
import (
"strings"
"unicode/utf8"
"github.com/deep-rent/nexus/std/ascii"
)
// Bullet is the redaction character every masked form is built from.
const Bullet = "•"
// triple is a bullet repeated three times, used for collapsing redactions.
const triple = Bullet + Bullet + Bullet
// Email redacts an email address down to a recognizable hint: the first
// rune of the local part and the domain survive ("a•••@example.com").
// Malformed addresses collapse entirely.
func Email(addr string) string {
at := strings.LastIndexByte(addr, '@')
if at <= 0 {
return triple
}
size := 1
if addr[0] >= utf8.RuneSelf {
_, size = utf8.DecodeRuneInString(addr[:at])
}
var b strings.Builder
b.Grow(size + len(triple) + len(addr) - at)
b.WriteString(addr[:size])
b.WriteString(triple)
b.WriteString(addr[at:])
return b.String()
}
// Phone redacts a phone number down to a recognizable hint: every digit but
// the last two becomes a bullet, while other characters (like the leading
// +) survive ("+••••••••••34").
func Phone(number string) string {
digits := 0
cutoff := -1
for i := len(number) - 1; i >= 0; i-- {
if ascii.IsDigit(number[i]) {
digits++
if digits == 2 {
cutoff = i
break
}
}
}
if digits < 2 || cutoff <= 0 {
return number
}
var b strings.Builder
b.Grow(len(number) + cutoff*(len(Bullet)-1))
for i := 0; i < cutoff; i++ {
c := number[i]
if ascii.IsDigit(c) {
b.WriteString(Bullet)
} else {
b.WriteByte(c)
}
}
b.WriteString(number[cutoff:])
return b.String()
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package grant
import (
"context"
"net/http"
"strings"
"github.com/deep-rent/nexus/eco/iam/oauth"
"github.com/deep-rent/nexus/eco/iam/oauth/pkce"
)
// authCode implements the [oauth.Grant] interface for the Authorization Code
// flow.
type authCode struct{}
// AuthCode returns a new grant implementation for the Authorization Code
// flow.
//
// Note: This implementation strictly mandates PKCE (RFC 7636) to mitigate
// authorization code injection and interception attacks.
//
// Register the result on the IAM server via [iam.WithGrant] to enable this
// grant.
//
// [iam.WithGrant]: github.com/deep-rent/nexus/eco/iam#WithGrant
func AuthCode() oauth.Grant {
return authCode{}
}
// Type implements [oauth.Grant].
func (authCode) Type() oauth.GrantType {
return oauth.GrantTypeAuthorizationCode
}
// Authorize implements [oauth.Grant].
func (authCode) Authorize(
ctx context.Context,
pro *oauth.Proposal,
) (*oauth.Issuance, error) {
code := pro.Get("code")
if code == "" {
return nil, &oauth.Error{
Status: http.StatusBadRequest,
Code: oauth.ErrorCodeInvalidRequest,
Description: "missing code",
}
}
codeVerifier := pro.Get("code_verifier")
if codeVerifier == "" {
return nil, &oauth.Error{
Status: http.StatusBadRequest,
Code: oauth.ErrorCodeInvalidRequest,
Description: "missing code verifier",
}
}
// The store only ever sees the digest of the code.
digest := pro.Digest(code)
// Retrieve the authorization code state from the session store.
c, found, err := pro.Tokens.AuthCodes.Get(ctx, digest)
if err != nil {
return nil, &oauth.Error{
Status: http.StatusInternalServerError,
Code: oauth.ErrorCodeServerError,
Description: "failed to retrieve authorization code",
Cause: err,
}
}
// Ensure the code exists.
if !found {
return nil, &oauth.Error{
Status: http.StatusBadRequest,
Code: oauth.ErrorCodeInvalidGrant,
Description: "invalid or expired authorization code",
}
}
// Delete the code immediately to prevent replay attacks. Any failure
// past this point intentionally burns the code. If the code was already
// gone, a concurrent request won the race and this one must not issue
// tokens.
deleted, err := pro.Tokens.AuthCodes.Delete(ctx, digest)
if err != nil {
return nil, &oauth.Error{
Status: http.StatusInternalServerError,
Code: oauth.ErrorCodeServerError,
Description: "failed to delete authorization code",
Cause: err,
}
}
if !deleted {
return nil, &oauth.Error{
Status: http.StatusBadRequest,
Code: oauth.ErrorCodeInvalidGrant,
Description: "invalid or expired authorization code",
}
}
// Enforce expiry locally in addition to the store's TTL contract.
if pro.Now().After(c.ExpiresAt) {
return nil, &oauth.Error{
Status: http.StatusBadRequest,
Code: oauth.ErrorCodeInvalidGrant,
Description: "invalid or expired authorization code",
}
}
// Validate that the client making the request is the one who requested it.
if c.ClientID != pro.Client.ID() {
return nil, &oauth.Error{
Status: http.StatusBadRequest,
Code: oauth.ErrorCodeInvalidGrant,
Description: "client mismatch",
}
}
// Validate the redirect URI if one was provided in the initial
// authorization request.
redirectURI := pro.Get("redirect_uri")
if c.RedirectURI != "" {
if redirectURI == "" {
return nil, &oauth.Error{
Status: http.StatusBadRequest,
Code: oauth.ErrorCodeInvalidRequest,
Description: "missing redirect uri",
}
}
if c.RedirectURI != redirectURI {
return nil, &oauth.Error{
Status: http.StatusBadRequest,
Code: oauth.ErrorCodeInvalidGrant,
Description: "redirect uri mismatch",
}
}
}
// Perform PKCE verification to protect against interception.
if !pkce.Verify(
codeVerifier,
c.CodeChallenge,
c.CodeChallengeMethod,
) {
return nil, &oauth.Error{
Status: http.StatusBadRequest,
Code: oauth.ErrorCodeInvalidGrant,
Description: "pkce verification failed",
}
}
return &oauth.Issuance{
UserID: c.UserID,
Scope: strings.Fields(c.Scope),
Nonce: c.Nonce,
Refreshable: true,
}, nil
}
var _ oauth.Grant = (*authCode)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package grant
import (
"context"
"net/http"
"strings"
"github.com/deep-rent/nexus/eco/iam/oauth"
)
// clientCredentials implements the [oauth.Grant] interface for
// machine-to-machine authentication.
type clientCredentials struct{}
// ClientCredentials returns a new grant implementation for the Client
// Credentials flow.
//
// Register the result on the IAM server via [iam.WithGrant] to enable this
// grant.
//
// [iam.WithGrant]: github.com/deep-rent/nexus/eco/iam#WithGrant
func ClientCredentials() oauth.Grant {
return clientCredentials{}
}
// Type implements [oauth.Grant].
func (clientCredentials) Type() oauth.GrantType {
return oauth.GrantTypeClientCredentials
}
// Authorize implements [oauth.Grant].
func (clientCredentials) Authorize(
_ context.Context,
pro *oauth.Proposal,
) (*oauth.Issuance, error) {
// Validate that the client is permitted to use the requested scopes.
scope := pro.Get("scope")
if scope != "" && !oauth.CanUseScope(pro.Client, scope) {
return nil, &oauth.Error{
Status: http.StatusBadRequest,
Code: oauth.ErrorCodeInvalidScope,
Description: "scope is not allowed for client",
}
}
// The zero UserID marks the client itself as the token subject.
return &oauth.Issuance{
Scope: strings.Fields(scope),
Refreshable: false,
}, nil
}
var _ oauth.Grant = (*clientCredentials)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package grant
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/deep-rent/nexus/eco/iam/oauth"
"github.com/deep-rent/nexus/sys/log"
)
// deviceCode implements the [oauth.Grant] interface for the Device
// Authorization flow (RFC 8628).
type deviceCode struct{}
// DeviceCode returns a new grant implementation for the Device
// Authorization flow.
//
// Register the result on the IAM server via [iam.WithGrant] to enable this
// grant. Bear in mind that it requires the server's verification URI to be
// configured.
//
// [iam.WithGrant]: github.com/deep-rent/nexus/eco/iam#WithGrant
func DeviceCode() oauth.Grant {
return deviceCode{}
}
// Type implements [oauth.Grant].
func (deviceCode) Type() oauth.GrantType {
return oauth.GrantTypeDeviceCode
}
// Authorize implements [oauth.Grant].
func (deviceCode) Authorize(
ctx context.Context,
pro *oauth.Proposal,
) (*oauth.Issuance, error) {
code := pro.Get("device_code")
if code == "" {
return nil, &oauth.Error{
Status: http.StatusBadRequest,
Code: oauth.ErrorCodeInvalidRequest,
Description: "missing device code",
}
}
// The store only ever sees the digest of the code.
digest := pro.Digest(code)
c, found, err := pro.Tokens.DeviceCodes.Get(ctx, digest)
if err != nil {
return nil, &oauth.Error{
Status: http.StatusInternalServerError,
Code: oauth.ErrorCodeServerError,
Description: "failed to retrieve device code",
Cause: err,
}
}
if !found {
return nil, &oauth.Error{
Status: http.StatusBadRequest,
Code: oauth.ErrorCodeInvalidGrant,
Description: "invalid device code",
}
}
if c.ClientID != pro.Client.ID() {
return nil, &oauth.Error{
Status: http.StatusBadRequest,
Code: oauth.ErrorCodeInvalidGrant,
Description: "client mismatch",
}
}
now := pro.Now()
// Expired codes are of no further use; remove them as a best effort.
if now.After(c.ExpiresAt) {
if _, err := pro.Tokens.DeviceCodes.Delete(ctx, digest); err != nil {
pro.Logger.Error(
ctx,
"Failed to delete expired device code",
log.Error(err),
)
}
return nil, &oauth.Error{
Status: http.StatusBadRequest,
Code: oauth.ErrorCodeExpiredToken,
Description: "device code has expired",
}
}
// RFC 8628 Section 3.5: clients polling faster than the announced
// interval must back off.
if c.Interval > 0 &&
now.Sub(c.LastPolledAt) < time.Duration(c.Interval)*time.Second {
return nil, &oauth.Error{
Status: http.StatusBadRequest,
Code: oauth.ErrorCodeSlowDown,
Description: "polling too frequently",
}
}
switch status := c.Status; status {
case oauth.DeviceCodeStatusPending:
// TouchDeviceCode only records the poll time, so a concurrent
// approval via the verification endpoint can never be overwritten.
if err := pro.Tokens.DeviceCodes.Touch(ctx, digest, now); err != nil {
pro.Logger.Error(
ctx,
"Failed to record device code poll",
log.Error(err),
)
}
return nil, &oauth.Error{
Status: http.StatusBadRequest,
Code: oauth.ErrorCodeAuthorizationPending,
Description: "authorization pending",
}
case oauth.DeviceCodeStatusDenied:
// The decision is final; remove the code as a best effort.
if _, err := pro.Tokens.DeviceCodes.Delete(ctx, digest); err != nil {
pro.Logger.Error(
ctx,
"Failed to delete denied device code",
log.Error(err),
)
}
return nil, &oauth.Error{
Status: http.StatusBadRequest,
Code: oauth.ErrorCodeAccessDenied,
Description: "resource owner denied the request",
}
case oauth.DeviceCodeStatusAuthorized:
// Proceed to token issuance below.
default:
return nil, &oauth.Error{
Status: http.StatusInternalServerError,
Code: oauth.ErrorCodeServerError,
Description: "illegal device code status",
Cause: fmt.Errorf("unexpected status %q", status),
}
}
// Delete the code immediately upon successful authorization to prevent
// reuse. If the code was already gone, a concurrent redemption won the
// race and this request must not issue tokens.
deleted, err := pro.Tokens.DeviceCodes.Delete(ctx, digest)
if err != nil {
return nil, &oauth.Error{
Status: http.StatusInternalServerError,
Code: oauth.ErrorCodeServerError,
Description: "failed to delete device code",
Cause: err,
}
}
if !deleted {
return nil, &oauth.Error{
Status: http.StatusBadRequest,
Code: oauth.ErrorCodeInvalidGrant,
Description: "invalid device code",
}
}
return &oauth.Issuance{
UserID: c.UserID,
Scope: strings.Fields(c.Scope),
Refreshable: true,
}, nil
}
var _ oauth.Grant = (*deviceCode)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package grant
import (
"context"
"net/http"
"slices"
"strings"
"github.com/deep-rent/nexus/eco/iam/oauth"
"github.com/deep-rent/nexus/sec/auth"
"github.com/deep-rent/nexus/sys/log"
)
// refreshToken implements the [oauth.Grant] interface for token rotation.
type refreshToken struct{}
// RefreshToken returns a new grant implementation for the Refresh Token
// flow.
//
// Register the result on the IAM server via [iam.WithGrant] to enable this
// grant.
//
// [iam.WithGrant]: github.com/deep-rent/nexus/eco/iam#WithGrant
func RefreshToken() oauth.Grant {
return refreshToken{}
}
// Type implements [oauth.Grant].
func (refreshToken) Type() oauth.GrantType {
return oauth.GrantTypeRefreshToken
}
// Authorize implements [oauth.Grant].
func (refreshToken) Authorize(
ctx context.Context,
pro *oauth.Proposal,
) (*oauth.Issuance, error) {
token := pro.Get("refresh_token")
if token == "" {
return nil, &oauth.Error{
Status: http.StatusBadRequest,
Code: oauth.ErrorCodeInvalidRequest,
Description: "missing refresh token",
}
}
// The store only ever sees the digest of the token.
digest := pro.Digest(token)
// Retrieve the refresh token details from the session store.
r, found, err := pro.Tokens.RefreshTokens.Get(ctx, digest)
if err != nil {
return nil, &oauth.Error{
Status: http.StatusInternalServerError,
Code: oauth.ErrorCodeServerError,
Description: "failed to retrieve refresh token",
Cause: err,
}
}
// Ensure the token exists.
if !found {
return nil, &oauth.Error{
Status: http.StatusBadRequest,
Code: oauth.ErrorCodeInvalidGrant,
Description: "invalid or expired refresh token",
}
}
// A token that was already rotated out is being replayed: whoever
// holds it is not the party that rotated it, so the lineage has
// leaked. RFC 9700 Section 4.14.2 requires the whole family to go —
// revoking only the presented token would leave the attacker (or the
// victim, whichever rotated last) holding a live chain.
//
// The spent record is kept precisely so this case can be told apart
// from an unknown token; retention reaps it at the family's expiry.
if r.Spent {
pro.Logger.Warn(
ctx,
"Refresh token replay detected; revoking the token family",
log.UUID("client_id", r.ClientID),
log.UUID("user_id", r.UserID),
log.UUID("family", r.Family),
)
if err := pro.Tokens.RefreshTokens.DeleteForFamily(
ctx, r.Family,
); err != nil {
return nil, &oauth.Error{
Status: http.StatusInternalServerError,
Code: oauth.ErrorCodeServerError,
Description: "failed to revoke the token family",
Cause: err,
}
}
return nil, &oauth.Error{
Status: http.StatusBadRequest,
Code: oauth.ErrorCodeInvalidGrant,
Description: "invalid or expired refresh token",
}
}
// Enforce expiry locally in addition to the store's TTL contract. An
// expired token is removed as a best effort.
if pro.Now().After(r.ExpiresAt) {
if _, err := pro.Tokens.RefreshTokens.Delete(ctx, digest); err != nil {
pro.Logger.Error(
ctx,
"Failed to delete expired refresh token",
log.Error(err),
)
}
return nil, &oauth.Error{
Status: http.StatusBadRequest,
Code: oauth.ErrorCodeInvalidGrant,
Description: "invalid or expired refresh token",
}
}
// Ensure the token belongs to the client attempting to use it.
if r.ClientID != pro.Client.ID() {
return nil, &oauth.Error{
Status: http.StatusBadRequest,
Code: oauth.ErrorCodeInvalidGrant,
Description: "client mismatch",
}
}
// RFC 6749 Section 6: the client may request a narrower scope than
// originally granted, but never a broader one.
granted := auth.Scope(strings.Fields(r.Scope))
scope := granted
if requested := pro.Get("scope"); requested != "" {
narrowed := auth.Scope(strings.Fields(requested))
for _, sc := range narrowed {
if !slices.Contains(granted, sc) {
return nil, &oauth.Error{
Status: http.StatusBadRequest,
Code: oauth.ErrorCodeInvalidScope,
Description: "requested scope exceeds original grant",
}
}
}
scope = narrowed
}
// Spend the presented token. The atomic delete is what makes rotation
// single-use: of two concurrent requests exactly one wins it, and the
// loser must not issue tokens.
deleted, err := pro.Tokens.RefreshTokens.Delete(ctx, digest)
if err != nil {
return nil, &oauth.Error{
Status: http.StatusInternalServerError,
Code: oauth.ErrorCodeServerError,
Description: "failed to revoke old refresh token",
Cause: err,
}
}
if !deleted {
return nil, &oauth.Error{
Status: http.StatusBadRequest,
Code: oauth.ErrorCodeInvalidGrant,
Description: "invalid or expired refresh token",
}
}
// Having won the race, leave a spent record in the token's place, so
// that presenting it again is recognizable as a replay rather than as
// an unknown token. It keeps the original expiry, so the retention
// sweep reaps it on the schedule it would have had anyway.
//
// A failure here costs detection, not correctness: the token is
// already spent and cannot be exchanged. Log it and carry on rather
// than failing an otherwise valid refresh.
r.Spent = true
if err := pro.Tokens.RefreshTokens.Create(ctx, r); err != nil {
pro.Logger.Error(
ctx,
"Failed to record a spent refresh token; "+
"replay of this token will not be detected",
log.UUID("family", r.Family),
log.Error(err),
)
}
// RefreshScope carries the original grant scope so that a one-time
// narrowing does not permanently downgrade the rotated refresh token.
// The replacement continues this token's lineage.
return &oauth.Issuance{
UserID: r.UserID,
Scope: scope,
RefreshScope: granted,
Refreshable: true,
RefreshFamily: r.Family,
}, nil
}
var _ oauth.Grant = (*refreshToken)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package oauth
import (
"context"
"net/url"
"slices"
"strings"
"time"
"uuid"
"github.com/deep-rent/nexus/eco/iam/artifact"
"github.com/deep-rent/nexus/sec/auth"
"github.com/deep-rent/nexus/sec/digest"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/sys/log"
)
// GrantType defines the various flows for obtaining an access token.
type GrantType string
const (
// GrantTypeAuthorizationCode refers to the Authorization Code grant.
GrantTypeAuthorizationCode GrantType = "authorization_code"
// GrantTypeClientCredentials refers to the Client Credentials grant.
GrantTypeClientCredentials GrantType = "client_credentials"
// GrantTypeRefreshToken refers to the Refresh Token grant.
GrantTypeRefreshToken GrantType = "refresh_token"
// GrantTypeDeviceCode refers to the Device Code grant.
GrantTypeDeviceCode GrantType = "urn:ietf:params:oauth:grant-type:device_code"
// GrantTypeWebAuthn refers to the custom WebAuthn grant, which exchanges
// a passkey assertion directly for tokens. It is not defined by any RFC;
// the URN follows the naming convention of RFC 8628 for extension
// grants.
GrantTypeWebAuthn GrantType = "urn:ietf:params:oauth:grant-type:webauthn"
)
// Client represents an OAuth 2.0 registered client application.
//
// Implementations are responsible for determining which grant types and scopes
// a specific client is authorized to use, as well as managing redirect URI
// whitelists and secrets.
type Client interface {
// ID returns the unique identifier for the client.
ID() uuid.UUID
// Public indicates if the client is capable of keeping a secret (e.g.,
// false for SPAs, true for confidential services).
Public() bool
// Audience returns the audience for the client. This value will be included
// in the "aud" claim of access tokens issued to this client. If an empty
// slice or nil is returned, the claim will be omitted during issuance.
Audience() []string
// VerifySecret checks if the provided secret matches the client's
// registered secret.
//
// Implementations must compare in constant time and should persist only
// a cryptographic hash of the secret, so that neither timing nor a leaked
// client registry reveals usable credentials.
VerifySecret(secret string) bool
// VerifyRedirectURI checks if the specified URI is an allowed redirect
// destination for the client. The [redirect] package implements the
// exact matching backing this check.
//
// [redirect]: github.com/deep-rent/nexus/eco/iam/oauth/redirect
VerifyRedirectURI(uri string) bool
// CanUseGrant checks if the client is authorized to use the given grant
// type.
CanUseGrant(grant GrantType) bool
// CanUseScope checks if the client is allowed to request the specified
// scope. It receives a single scope token (never a space-delimited list);
// the authorization server splits requested scopes and consults this
// method per token.
CanUseScope(scope string) bool
}
// CanUseScope reports whether the client may use every scope token in the
// space-delimited scope string.
func CanUseScope(c Client, scope string) bool {
for s := range strings.FieldsSeq(scope) {
if !c.CanUseScope(s) {
return false
}
}
return true
}
// ClientStore provides data access for registered OAuth 2.0 clients.
//
// Implementations must bridge the library to the underlying persistence layer.
type ClientStore interface {
// GetClient retrieves a client by its unique ID.
//
// If the client is found, it must return the client and nil.
// If the client is not found, it must return nil and nil.
// It should return an error only if the underlying storage lookup fails.
GetClient(ctx context.Context, id uuid.UUID) (Client, error)
}
// Scope tokens with protocol-defined meaning (OpenID Connect Core 1.0
// Sections 5.4 and 11).
const (
// ScopeOpenID requests OpenID Connect processing: token responses for
// a user-delegated grant carry an ID token.
ScopeOpenID = "openid"
// ScopeProfile requests the profile claim family (name, nickname,
// locale, zoneinfo, updated_at).
ScopeProfile = "profile"
// ScopeEmail requests the email and email_verified claims.
ScopeEmail = "email"
// ScopePhone requests the phone_number and phone_number_verified
// claims.
ScopePhone = "phone"
// ScopeOfflineAccess requests a refresh token usable while the user is
// no longer present. Under [ScopeOpenID] a refresh token is minted
// only when this scope was granted; a plain OAuth 2.0 issuance keeps
// refreshing by grant and client policy alone, since the scope is an
// OpenID Connect vocabulary word that such clients never speak.
ScopeOfflineAccess = "offline_access"
)
// ProfileResolver supplies the OpenID Connect identity claims of a user,
// already filtered down to what the granted scope permits. The returned map
// is embedded into ID tokens and served by the userinfo endpoint; the
// server contributes the reserved claims (sub, iss, aud, and friends)
// itself, so resolvers stick to identity data. A nil map is valid and
// yields the bare sub claim.
type ProfileResolver func(
ctx context.Context,
id uuid.UUID,
scope auth.Scope,
) (map[string]any, error)
// ProfileClaims names the claims a [ProfileResolver] yields, keyed by the
// scope token that unlocks them:
//
// oauth.ProfileClaims{
// oauth.ScopeProfile: {"name", "nickname", "updated_at"},
// oauth.ScopeEmail: {"email", "email_verified"},
// }
//
// It is what the discovery document advertises under scopes_supported and
// claims_supported. Declaring it separately from the resolver would invite
// the two to drift, so derive both from one source — the service composing
// them owns the claim vocabulary, and the server only republishes it.
//
// A claim a resolver emits only sometimes still belongs here: the field is
// what clients may ask for, not what any one user happens to carry.
type ProfileClaims map[string][]string
// scopes returns the scope tokens the profile claims are unlocked by,
// sorted, with [ScopeOpenID] always among them — it is what enables the
// provider role in the first place.
func (p ProfileClaims) scopes() []string {
out := make([]string, 0, len(p)+1)
out = append(out, ScopeOpenID)
for scope := range p {
if scope != ScopeOpenID {
out = append(out, scope)
}
}
slices.Sort(out)
return out
}
// names returns every claim the profile claims declare, deduplicated and
// sorted, led by the reserved "sub" the server contributes itself.
func (p ProfileClaims) names() []string {
seen := map[string]bool{"sub": true}
out := []string{"sub"}
for _, claims := range p {
for _, claim := range claims {
if !seen[claim] {
seen[claim] = true
out = append(out, claim)
}
}
}
slices.Sort(out)
return out
}
// MembershipResolver supplies the identifiers of the teams a user belongs
// to, populating the "teams" claim of issued access tokens (see
// [auth.Claims.Teams]). A nil resolver leaves the claim off; resolution
// failures fail token issuance, since a token silently missing its
// memberships would grant less than the user holds.
//
// [auth.Claims.Teams]: github.com/deep-rent/nexus/sec/auth#Claims.Teams
type MembershipResolver func(
ctx context.Context,
id uuid.UUID,
) ([]uuid.UUID, error)
// EventKind names a token lifecycle event.
type EventKind string
const (
// EventTokenIssued marks a successful token issuance at the token
// endpoint, whatever the grant.
EventTokenIssued EventKind = "token_issued"
// EventTokenRevoked marks a refresh token revoked at the revocation
// endpoint.
EventTokenRevoked EventKind = "token_revoked"
)
// Event is a token lifecycle notification handed to the [Observer]. It is
// advisory and carries no secrets.
type Event struct {
// Kind states what happened.
Kind EventKind
// Grant is the grant type that authorized an issuance. It is empty for
// revocations.
Grant GrantType
// ClientID identifies the client the tokens belong to.
ClientID uuid.UUID
// UserID identifies the user on whose behalf the tokens stand. It is
// the zero UUID for tokens minted to the client itself.
UserID uuid.UUID
// Scope is the space-delimited scope bound to the tokens.
Scope string
}
// Observer consumes token lifecycle events, typically feeding an audit
// trail. Observers run synchronously on the request path and must not
// block; hand slow work to a bus or queue.
type Observer func(Event)
// Digest is the fingerprint of a bearer artifact (authorization code, refresh
// token, device code, user code, OTP challenge, or one-time password), encoded
// as an unpadded base64url string.
//
// The authorization server hashes every artifact before it crosses a store
// boundary, so implementations never see plaintext bearer secrets: a leaked
// datastore cannot be replayed against the server. Implementations should
// treat digests as opaque keys and persist them as-is.
type Digest string
// AuthCode holds the temporary state bound to an authorization code.
//
// These objects should have a short lifespan (usually 1–10 minutes) and
// must be deleted immediately after a single use to prevent replay attacks.
type AuthCode struct {
// Code is the digest of the high-entropy code sent to the client. The
// plaintext value never reaches the store.
Code Digest `json:"code"`
// ClientID is the ID of the client that requested the code.
ClientID uuid.UUID `json:"client_id"`
// RedirectURI is the URI provided during the initial authorization
// request. It must be stored to ensure the token exchange request
// uses the exact same URI.
RedirectURI string `json:"redirect_uri"`
// Scope is the list of permissions approved by the resource owner.
Scope string `json:"scope"`
// UserID is the unique identifier of the authenticated resource owner.
UserID uuid.UUID `json:"user_id"`
// CodeChallenge is the challenge string used for PKCE validation.
CodeChallenge string `json:"code_challenge"`
// CodeChallengeMethod is the hashing algorithm used for PKCE validation.
CodeChallengeMethod string `json:"code_challenge_method"`
// Nonce is the OpenID Connect nonce bound to the authorization request,
// echoed inside the ID token so the client can tie the token to its
// session. Empty when the client sent none.
Nonce string `json:"nonce,omitzero"`
// ExpiresAt defines when this code expires.
ExpiresAt time.Time `json:"expires_at"`
}
// RefreshToken holds the state bound to a refresh token.
//
// Refresh tokens allow clients to obtain new access tokens without
// re-authenticating the user. They generally have a much longer
// lifespan than authorization codes.
type RefreshToken struct {
// Token is the digest of the high-entropy refresh token issued to the
// client. The plaintext value never reaches the store.
Token Digest `json:"token"`
// ClientID is the identifier of the client authorized to use this token.
ClientID uuid.UUID `json:"client_id"`
// UserID identifies the user who authorized the initial request.
// This remains the zero UUID for Client Credentials grants.
UserID uuid.UUID `json:"user_id,omitzero"`
// Scope represents the permissions granted for the duration of
// this session.
Scope string `json:"scope"`
// Family identifies the rotation lineage this token belongs to: the
// original issuance and every token rotated out of it share one
// family. It is what lets a replayed token revoke the whole chain
// rather than just itself; see [RefreshTokenStore].
Family uuid.UUID `json:"family"`
// Spent marks a token that has already been rotated out. The record
// outlives its usefulness on purpose — a spent token presented again
// is the signal that the lineage leaked, and a deleted record could
// not be told apart from one that never existed.
Spent bool `json:"spent,omitzero"`
// ExpiresAt defines when this specific token expires.
ExpiresAt time.Time `json:"expires_at"`
}
// RefreshTokenStore persists refresh tokens and the rotation lineage they
// belong to.
//
// The family-scoped deletion backs replay detection: rotation is
// single-use, so a token presented after it was spent means the chain is
// in someone else's hands, and RFC 9700 Section 4.14.2 requires the whole
// family to go rather than the presented token alone.
type RefreshTokenStore interface {
artifact.Store[Digest, RefreshToken]
// DeleteForFamily removes every token of the given rotation lineage,
// spent or live. It is a no-op for an unknown family.
DeleteForFamily(ctx context.Context, family uuid.UUID) error
}
// RefreshTokenMap is an in-memory [RefreshTokenStore] over an
// [artifact.OwnedMap] scoped by family. It carries that type's caveat —
// nothing evicts expired records — so it serves tests and local
// development rather than production.
type RefreshTokenMap struct {
*artifact.OwnedMap[Digest, RefreshToken, uuid.UUID]
}
// NewRefreshTokenMap creates an empty [RefreshTokenMap].
func NewRefreshTokenMap() *RefreshTokenMap {
return &RefreshTokenMap{artifact.NewOwnedMap(
func(r RefreshToken) Digest { return r.Token },
func(r RefreshToken) uuid.UUID { return r.Family },
)}
}
// DeleteForFamily implements [RefreshTokenStore].
func (m *RefreshTokenMap) DeleteForFamily(
ctx context.Context,
family uuid.UUID,
) error {
return m.DeleteForOwner(ctx, family)
}
var _ RefreshTokenStore = (*RefreshTokenMap)(nil)
// DeviceCodeStatus represents the state of a device authorization request
// during the polling process of a Device Authorization Grant.
type DeviceCodeStatus string
const (
// DeviceCodeStatusPending indicates the authorization request is still
// active and the user has not yet completed the verification steps.
// The client should continue to poll the token endpoint.
DeviceCodeStatusPending DeviceCodeStatus = "pending"
// DeviceCodeStatusDenied indicates the authorization request was rejected
// by the user or the authorization server. The client must stop polling.
DeviceCodeStatusDenied DeviceCodeStatus = "denied"
// DeviceCodeStatusAuthorized indicates the user has successfully approved
// the request. The client can now proceed to use the device code to
// obtain tokens.
DeviceCodeStatusAuthorized DeviceCodeStatus = "authorized"
)
// DeviceCode holds the state bound to a device authorization request.
//
// Unlike authorization codes, device codes are polled by the client over a
// longer period until the resource owner completes the authorization on a
// separate device.
type DeviceCode struct {
// DeviceCode is the digest of the high-entropy code polled by the client.
// The plaintext value never reaches the store.
DeviceCode Digest `json:"device_code"`
// UserCode is the digest of the short, user-friendly code entered by the
// resource owner. The plaintext value never reaches the store.
UserCode Digest `json:"user_code"`
// ClientID is the ID of the client that requested the code.
ClientID uuid.UUID `json:"client_id"`
// UserID is the unique identifier of the authenticated resource owner.
// It remains the zero UUID until the user authorizes the request.
UserID uuid.UUID `json:"user_id,omitzero"`
// Scope is the list of permissions approved by the resource owner.
Scope string `json:"scope"`
// Status indicates the current state: "pending", "authorized", or "denied".
Status DeviceCodeStatus `json:"status"`
// ExpiresAt defines when this code is no longer valid.
ExpiresAt time.Time `json:"expires_at"`
// Interval is the minimum number of seconds the client must wait between
// polling attempts (RFC 8628 Section 3.5). Zero disables rate limiting.
// It stays in seconds because the protocol quotes it to the client that
// way, in the device authorization response's "interval".
Interval int64 `json:"interval,omitzero"`
// LastPolledAt records the client's most recent poll, enforcing
// [DeviceCode.Interval] between attempts. The zero instant is a poll far
// enough in the past that the interval has always elapsed, which is what
// a code not yet polled carries.
LastPolledAt time.Time `json:"last_polled_at,omitzero"`
}
// DeviceCodeStore persists device authorization requests. Beyond the generic
// [artifact.Store] lifecycle it carries the two operations specific to the
// Device Authorization Grant.
type DeviceCodeStore interface {
artifact.Store[Digest, DeviceCode]
// GetByUserCode retrieves a device code by the digest of its associated
// user code. It is used by the verification endpoint where the resource
// owner enters the user code displayed on the device. found is false
// when no such code exists; the error is reserved for storage failures.
GetByUserCode(
ctx context.Context,
userCode Digest,
) (v DeviceCode, found bool, err error)
// Touch records a client polling attempt by updating only
// [DeviceCode.LastPolledAt] for the given code. It is deliberately
// separate from Update so that concurrent polling can never overwrite a
// status transition performed by the verification endpoint. Touching an
// absent code is a no-op.
//
// It should return an error only if the persistence operation fails.
Touch(ctx context.Context, code Digest, lastPolledAt time.Time) error
}
// TokenStores bundles the persistence backends for the ephemeral artifacts
// of token issuance. All records are keyed by their [Digest]; see
// [artifact.Store] for the contract, notably the atomic deletion that
// enforces single use of codes and rotation of refresh tokens under
// concurrent redemption.
type TokenStores struct {
// AuthCodes persists authorization codes, keyed by [AuthCode.Code].
AuthCodes artifact.Store[Digest, AuthCode]
// RefreshTokens persists refresh tokens, keyed by [RefreshToken.Token].
RefreshTokens RefreshTokenStore
// DeviceCodes persists device authorization requests, keyed by
// [DeviceCode.DeviceCode]. It may be nil when the Device Authorization
// Grant is not offered.
DeviceCodes DeviceCodeStore
}
const (
// ErrorCodeAccessDenied indicates user or server denied the request.
ErrorCodeAccessDenied = "access_denied"
// ErrorCodeInvalidClient indicates client authentication failed.
ErrorCodeInvalidClient = "invalid_client"
// ErrorCodeInvalidGrant indicates provided grant is invalid or expired.
ErrorCodeInvalidGrant = "invalid_grant"
// ErrorCodeInvalidRequest indicates request is missing a parameter.
ErrorCodeInvalidRequest = "invalid_request"
// ErrorCodeInvalidScope indicates requested scope is invalid.
ErrorCodeInvalidScope = "invalid_scope"
// ErrorCodeServerError indicates an internal server error occurred.
ErrorCodeServerError = "server_error"
// ErrorCodeTemporarilyUnavailable signals the server is overloaded.
ErrorCodeTemporarilyUnavailable = "temporarily_unavailable"
// ErrorCodeUnauthorizedClient indicates client is not authorized for grant.
ErrorCodeUnauthorizedClient = "unauthorized_client"
// ErrorCodeUnsupportedGrantType indicates grant type is not supported.
ErrorCodeUnsupportedGrantType = "unsupported_grant_type"
// ErrorCodeUnsupportedResponseType indicates response type is not
// supported.
ErrorCodeUnsupportedResponseType = "unsupported_response_type"
// ErrorCodeAuthorizationPending indicates the user hasn't authorized yet.
ErrorCodeAuthorizationPending = "authorization_pending"
// ErrorCodeSlowDown indicates the client is polling too fast.
ErrorCodeSlowDown = "slow_down"
// ErrorCodeExpiredToken indicates the device code has expired.
ErrorCodeExpiredToken = "expired_token"
)
// Error represents an RFC 6749 compliant error response.
type Error struct {
// Status is the HTTP status code (e.g., 400, 401) to send when returning
// this error.
Status int `json:"-"`
// Code is the machine-readable error identifier (e.g., "invalid_grant").
Code string `json:"error"`
// Description is an optional human-readable explanation providing
// additional context for developers.
Description string `json:"error_description,omitempty"`
// URI is an optional link to a web page providing further information about
// the error type.
URI string `json:"error_uri,omitempty"`
// ID is a trace identifier for the specific occurrence of the error.
// This field is not part of the specification.
ID string `json:"error_id,omitempty"`
// Cause is the underlying error that triggered this one. It is logged
// when the response is written, but never serialized, so it may carry
// internal detail that must not reach the client.
Cause error `json:"-"`
}
// Unwrap returns the underlying cause, if any.
func (e Error) Unwrap() error { return e.Cause }
// Error implements the standard [error] interface. It builds a formatted string
// suitable for logging.
func (e Error) Error() string {
if e.Description == "" {
return e.Code
}
return e.Code + ": " + e.Description
}
var _ error = Error{}
// Proposal represents the raw input of an OAuth 2.0 grant request. It
// encapsulates the verified identity of the requesting client and the
// unvalidated parameters provided in the request body.
type Proposal struct {
// Client is the authenticated entity making the request (read-only).
Client Client
// Tokens provides access to the [TokenStores] for managing authorization
// codes, refresh tokens, and device codes.
Tokens TokenStores
// Logger provides a context-aware logger for the grant handler.
Logger *log.Logger
// Now returns the current time. Grants must use it instead of [time.Now]
// so that temporal checks stay consistent with the server clock.
Now clock.Clock
// hasher fingerprints bearer artifacts; see [Proposal.Digest].
hasher *digest.Hasher
// data contains the raw form values.
data url.Values
}
// NewProposal assembles a [Proposal] for the given authenticated client. The
// authorization server calls it once per token request; tests use it to feed
// crafted form values into a [Grant].
//
// The hasher fingerprints bearer artifacts via [Proposal.Digest]; nil falls
// back to [digest.DefaultHasher]. A nil logger falls back to [log.Discard],
// and a nil clock to [clock.System].
func NewProposal(
client Client,
tokens TokenStores,
form url.Values,
hasher *digest.Hasher,
logger *log.Logger,
now clock.Clock,
) *Proposal {
if hasher == nil {
hasher = digest.DefaultHasher
}
if logger == nil {
logger = log.Discard()
}
if now == nil {
now = clock.System
}
return &Proposal{
Client: client,
Tokens: tokens,
Logger: logger,
Now: now,
hasher: hasher,
data: form,
}
}
// Get retrieves a grant-specific field from the HTTP request body.
// If no such field exists, an empty string is returned.
func (p *Proposal) Get(key string) string { return p.data.Get(key) }
// Digest fingerprints the given artifact value with the hasher the
// authorization server was configured with. Grants must use it to look up and
// mint bearer artifacts, so that a custom hasher applies consistently across
// the server and its grants.
func (p *Proposal) Digest(value string) Digest {
return Digest(p.hasher.String(value))
}
// Issuance defines the parameters for issuing tokens after a successful grant
// authorization.
type Issuance struct {
// UserID identifies the user on whose behalf the tokens are issued. For
// machine-to-machine requests, this field should be left as the zero UUID
// to treat the client itself as the token subject.
UserID uuid.UUID
// Scope is the finalized set of permissions granted to the client. This
// may be a subset of the requested scopes based on server policy or user
// consent.
Scope auth.Scope
// RefreshScope is the scope bound to a replacement refresh token, if one
// is issued. It defaults to Scope when empty. The Refresh Token grant
// sets it to the original grant scope so that a one-time narrowing of
// the access token (RFC 6749 Section 6) does not permanently downgrade
// the grant chain.
RefreshScope auth.Scope
// Nonce is the OpenID Connect nonce carried over from the
// authorization request, echoed inside the issued ID token. Only the
// Authorization Code grant sets it.
Nonce string
// Refreshable determines if the authorization server should generate
// a refresh token. While usually determined by the grant type, this allows
// for granular control based on client policy or requested offline access.
Refreshable bool
// RefreshFamily continues an existing rotation lineage, set by the
// Refresh Token grant to the family of the token it rotated out. The
// zero UUID — every other grant — starts a fresh lineage, since the
// issuance is a new authorization rather than the continuation of
// one. See [RefreshTokenStore] for what the lineage buys.
RefreshFamily uuid.UUID
}
// Grant defines the logic for a specific OAuth 2.0 grant type (e.g.,
// Authorization Code, Client Credentials, or Refresh Token).
//
// Implementations are responsible for verifying the grant-specific credentials
// provided in the [Proposal] and determining the identity and permissions
// associated with the resulting tokens.
type Grant interface {
// Type returns the grant type associated with the implementation.
Type() GrantType
// Authorize validates the incoming proposal against the requirements of the
// specific grant type.
//
// If the credentials are valid, it returns a result object containing the
// user and scope. If validation fails due to invalid credentials,
// expired codes, or insufficient permissions, it returns nil and an
// [Error].
// Other types of errors will be handled as unexpected failures.
Authorize(ctx context.Context, pro *Proposal) (*Issuance, error)
}
// TokenResponse outlines the payload returned after a successful token grant.
type TokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in,omitzero"`
RefreshToken string `json:"refresh_token,omitempty"`
Scope string `json:"scope,omitempty"`
// IDToken carries the OpenID Connect ID token (OIDC Core Section
// 3.1.3.3). The server fills it in for user-delegated issuances under
// the openid scope, provided a [ProfileResolver] is configured; it is
// also populated by external OIDC providers and consumed when the
// library acts as a client during social login exchanges.
IDToken string `json:"id_token,omitempty"`
}
// DeviceAuthorizationResponse outlines the payload returned from the device
// authorization endpoint.
type DeviceAuthorizationResponse struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
VerificationURIComplete string `json:"verification_uri_complete,omitempty"`
ExpiresIn int64 `json:"expires_in"`
Interval int64 `json:"interval,omitempty"`
}
// IntrospectionResponse outlines the RFC 7662 compliant JSON payload returned
// from the token introspection endpoint. All timestamps are UNIX epoch
// integers in seconds.
type IntrospectionResponse struct {
Active bool `json:"active"`
ClientID string `json:"client_id,omitempty"`
TokenType string `json:"token_type,omitempty"`
Scope string `json:"scope,omitempty"`
Jti string `json:"jti,omitempty"`
Iss string `json:"iss,omitempty"`
Aud []string `json:"aud,omitempty"`
Sub string `json:"sub,omitempty"`
Iat int64 `json:"iat,omitzero"`
Exp int64 `json:"exp,omitzero"`
Nbf int64 `json:"nbf,omitzero"`
}
// ServerMetadata represents the OAuth 2.0 Authorization Server Metadata
// payload (RFC 8414).
type ServerMetadata struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
KeySetURI string `json:"jwks_uri,omitempty"`
IntrospectionEndpoint string `json:"introspection_endpoint,omitempty"`
RevocationEndpoint string `json:"revocation_endpoint,omitempty"`
DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint,omitempty"`
GrantTypesSupported []string `json:"grant_types_supported,omitempty"`
ResponseTypesSupported []string `json:"response_types_supported,omitempty"`
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported,omitempty"`
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"`
// The remaining fields describe the OpenID Connect provider role and
// are only populated when a [ProfileResolver] is configured.
UserinfoEndpoint string `json:"userinfo_endpoint,omitempty"`
ScopesSupported []string `json:"scopes_supported,omitempty"`
SubjectTypesSupported []string `json:"subject_types_supported,omitempty"`
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported,omitempty"`
ClaimsSupported []string `json:"claims_supported,omitempty"`
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package oauth
import (
"context"
"errors"
"fmt"
"maps"
"net/http"
"slices"
"time"
"uuid"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/sec/auth"
"github.com/deep-rent/nexus/sec/jose/jwt"
)
// ReasonInsufficientScope is the reason code returned when a token is valid
// but does not carry the scope the endpoint requires. It mirrors the error
// code of the WWW-Authenticate challenge (RFC 6750 Section 3), as does
// [auth.ReasonInvalidToken].
//
// [auth.ReasonInvalidToken]:
// github.com/deep-rent/nexus/sec/auth#ReasonInvalidToken
const ReasonInsufficientScope router.Reason = "insufficient_scope"
// signingAlgs collects the distinct signature algorithms of the vault's
// public keys, announced in the discovery document.
func (s *Server) signingAlgs() []string {
var algs []string
for key := range s.vault.Keys().Keys() {
if alg := key.Algorithm(); !slices.Contains(algs, alg) {
algs = append(algs, alg)
}
}
slices.Sort(algs)
return algs
}
// mintIDToken signs the OpenID Connect ID token accompanying a
// user-delegated token issuance. The resolver contributes the identity
// claims; reserved claims always win over resolver output.
func (s *Server) mintIDToken(
ctx context.Context,
sub string,
clientID uuid.UUID,
iss *Issuance,
now time.Time,
) (string, error) {
claims := map[string]any{}
profile, err := s.profiles(ctx, iss.UserID, iss.Scope)
if err != nil {
return "", fmt.Errorf("failed to resolve profile: %w", err)
}
maps.Copy(claims, profile)
claims["iss"] = s.issuer
claims["sub"] = sub
claims["aud"] = clientID.String()
claims["azp"] = clientID.String()
claims["iat"] = now.Unix()
claims["exp"] = now.Add(s.accessTokenLifetime).Unix()
if iss.Nonce != "" {
claims["nonce"] = iss.Nonce
}
key := s.vault.Next()
if key == nil {
return "", errors.New("vault returned no signing key")
}
token, err := jwt.Sign(ctx, key, claims)
if err != nil {
return "", fmt.Errorf("failed to sign ID token: %w", err)
}
return string(token), nil
}
// UserInfo serves the OpenID Connect userinfo endpoint: the identity claims
// of the user behind a Bearer access token carrying the openid scope
// (OpenID Connect Core 1.0 Section 5.3).
func (s *Server) UserInfo(e *router.Exchange) error {
token := auth.BearerExtractor(e.R)
if token == "" {
// RFC 6750 Section 3: the challenge names why the token was
// refused.
e.SetHeader(
"WWW-Authenticate",
`Bearer error="invalid_token", error_description="missing bearer token"`,
)
return &router.Error{
Status: http.StatusUnauthorized,
Reason: auth.ReasonInvalidToken,
Description: "missing bearer token",
}
}
claims, err := s.introspector.Verify([]byte(token))
if err != nil {
// RFC 6750 Section 3: the challenge names why the token was
// refused.
e.SetHeader(
"WWW-Authenticate",
`Bearer error="invalid_token", error_description="token verification failed"`,
)
return &router.Error{
Status: http.StatusUnauthorized,
Reason: auth.ReasonInvalidToken,
Description: "token verification failed",
}
}
// Only user-delegated tokens carrying the openid scope reach identity
// data; a machine token has no identity to describe.
id := claims.UserID()
if !claims.HasScope(ScopeOpenID) || id == uuid.Nil() {
// RFC 6750 Section 3: the challenge names why the token was
// refused.
e.SetHeader(
"WWW-Authenticate",
`Bearer error="insufficient_scope", error_description="token does not grant the openid scope"`,
)
return &router.Error{
Status: http.StatusForbidden,
Reason: ReasonInsufficientScope,
Description: "token does not grant the openid scope",
}
}
profile, err := s.profiles(e.Context(), id, claims.Scope)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to resolve profile",
Cause: err,
}
}
body := map[string]any{}
maps.Copy(body, profile)
body["sub"] = claims.Sub
return e.JSON(http.StatusOK, body)
}
// OpenIDConfiguration serves the OpenID Connect discovery document (OpenID
// Connect Discovery 1.0), sharing the metadata of [Server.WellKnown].
func (s *Server) OpenIDConfiguration(prefix string) router.Handler {
meta := s.metadata(prefix)
return router.HandlerFunc(func(e *router.Exchange) error {
return e.JSON(http.StatusOK, meta)
})
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package oauth
import (
"time"
"github.com/deep-rent/nexus/net/throttle"
"github.com/deep-rent/nexus/sec/digest"
"github.com/deep-rent/nexus/sec/nonce"
"github.com/deep-rent/nexus/sec/vault"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/sys/log"
)
// Default values applied by [NewServer] for optional [ServerConfig] fields.
const (
// DefaultRealm is the authentication realm announced in WWW-Authenticate
// challenges.
DefaultRealm = "oauth"
// DefaultAccessTokenLifetime is the validity period of access tokens.
//
// It is a security parameter, not merely a convenience one. Access
// tokens are self-contained JWTs: a resource server validates one by
// checking its signature, without asking this server anything, which
// is what keeps verification local and cheap. The cost is that
// nothing can recall a token already issued — revoking a grant,
// disabling an account, or deleting a user stops the *next* issuance
// but cannot reach into tokens already in flight.
//
// The lifetime is therefore the revocation window: the longest a
// withdrawn authorization can still be exercised. An hour is the
// deliberate trade — short enough to bound that window, long enough
// to keep the refresh path off the hot path. Raising it widens the
// window by exactly the same amount, so deployments that need
// immediate revocation should lower it rather than expect the
// revocation endpoints to do more than they can.
//
// What revocation does reach immediately: refresh tokens (deleted
// from the store, so no further access token is minted), sessions,
// and device trust. See [Server.Revoke] and the IAM service's
// account and admin revocation paths.
DefaultAccessTokenLifetime = 1 * time.Hour
// DefaultRefreshTokenLifetime is the validity period of refresh tokens.
DefaultRefreshTokenLifetime = 30 * 24 * time.Hour
// DefaultAuthCodeLifetime is the validity period of authorization codes.
DefaultAuthCodeLifetime = 10 * time.Minute
// DefaultDeviceCodeLifetime is the validity period of device codes.
DefaultDeviceCodeLifetime = 15 * time.Minute
// DefaultDevicePollInterval is the minimum delay between device code
// polling attempts.
DefaultDevicePollInterval = 5 * time.Second
// DefaultThrottlePenalty is the number of tokens charged against a
// throttle bucket for a single failed authentication attempt.
DefaultThrottlePenalty = 10
)
// ServerConfig carries the mandatory dependencies and tunable settings for a
// [Server]. Zero values for optional fields are replaced with the package
// defaults by [NewServer].
type ServerConfig struct {
// Vault supplies the signing keys for access tokens and the public key
// set served at the JWKS endpoint. Required.
Vault vault.Vault
// Clients bridges the server to the client registry. Required.
Clients ClientStore
// Tokens bundles the persistence backends for the token-issuance
// artifacts. AuthCodes and RefreshTokens are always required;
// DeviceCodes is required when VerificationURI is set.
Tokens TokenStores
// Sessions authenticates the resource owner behind a request. Required
// when the Authorization Code grant is offered or VerificationURI is
// set; a machine-to-machine deployment may leave it nil.
Sessions SessionResolver
// Owners resolves resource owners for claim minting. Required whenever a
// registered grant names a resource owner (every grant except Client
// Credentials).
Owners OwnerResolver
// Profiles supplies OpenID Connect identity claims and enables the
// provider role: ID tokens on the openid scope, the userinfo endpoint,
// and the OpenID Connect discovery document. Nil confines the server to
// plain OAuth 2.0.
Profiles ProfileResolver
// ProfileClaims names what Profiles yields, per scope, for the
// discovery document to advertise. Derive it from the same source as
// the resolver, so the document cannot promise claims the resolver
// does not issue. Empty advertises the openid scope and the sub claim
// alone, which is what a resolver declaring nothing is entitled to.
ProfileClaims ProfileClaims
// Memberships supplies team identifiers for the "teams" claim of
// user-delegated access tokens. Nil leaves the claim off.
Memberships MembershipResolver
// Issuer is the canonical HTTPS URL of this authorization server. It is
// embedded in the "iss" claim of issued tokens and announced in the
// server metadata. Required.
Issuer string
// Realm is the authentication realm announced in WWW-Authenticate
// challenges. Defaults to [DefaultRealm].
Realm string
// VerificationURI locates the frontend page where resource owners enter
// device user codes. Setting it enables the device authorization
// endpoints.
VerificationURI string
// AccessTokenLifetime overrides [DefaultAccessTokenLifetime].
AccessTokenLifetime time.Duration
// RefreshTokenLifetime overrides [DefaultRefreshTokenLifetime].
RefreshTokenLifetime time.Duration
// AuthCodeLifetime overrides [DefaultAuthCodeLifetime].
AuthCodeLifetime time.Duration
// DeviceCodeLifetime overrides [DefaultDeviceCodeLifetime].
DeviceCodeLifetime time.Duration
// DevicePollInterval overrides [DefaultDevicePollInterval].
DevicePollInterval time.Duration
// Throttle rate limits the credential-verifying endpoints and applies
// escalating penalties to failed authentication attempts. When set,
// [Server.Mount] guards those routes with per-address limiting
// automatically. A nil value disables throttling. When this server is
// composed with a login system, both should share one throttle so
// penalties draw down the same buckets.
//
// The server derives its own bucket keys, so any [throttle.Config.Key]
// is ignored; configure only the rate and burst.
Throttle *throttle.Throttle
// ThrottlePenalty is the number of tokens a single failed authentication
// attempt charges against its buckets. Larger values lock out
// brute-force attempts sooner. It should stay below the throttle's burst
// so that one failure does not exhaust a bucket outright. Ignored when
// Throttle is nil. Defaults to [DefaultThrottlePenalty].
ThrottlePenalty int
// Logger receives structured diagnostics. Defaults to [log.Discard],
// keeping the server silent unless a logger is injected.
Logger *log.Logger
}
// ServerOption customizes a [Server] during construction with [NewServer].
type ServerOption func(*Server)
// WithGrant registers a [Grant] implementation, enabling its grant type at
// the token endpoint.
func WithGrant(g Grant) ServerOption {
return func(s *Server) { s.grants[g.Type()] = g }
}
// WithObserver registers a callback receiving token lifecycle [Event]
// notifications. A nil observer is ignored; without one, no events are
// emitted.
func WithObserver(observe Observer) ServerOption {
return func(s *Server) {
if observe != nil {
s.observe = observe
}
}
}
// WithHasher sets the hasher that fingerprints every bearer artifact before
// it crosses a store boundary — authorization codes, refresh tokens, and
// device and user codes. A nil hasher is ignored. Defaults to
// [digest.DefaultHasher] (SHA-256, base64url).
//
// The hasher is wired through to the grants via [Proposal.Digest], so a
// single configuration applies consistently across the server and its
// grants. Changing it invalidates every previously stored artifact.
func WithHasher(h *digest.Hasher) ServerOption {
return func(s *Server) {
if h != nil {
s.hasher = h
}
}
}
// WithClock overrides the server's time source. This is primarily useful for
// deterministic testing.
func WithClock(now clock.Clock) ServerOption {
return func(s *Server) {
if now != nil {
s.now = now
}
}
}
// WithNonceSource sets the entropy source for every opaque bearer artifact
// the server mints — authorization codes, refresh tokens, and device and
// user codes — all of which are drawn from a single [nonce.Generator] (a
// [nonce.Sampler] for user codes). It defaults to [nonce.DefaultSource]
// (crypto/rand); provide a deterministic source for testing or a
// hardware/remote source in specialized deployments. A nil source is
// ignored.
func WithNonceSource(src nonce.Source) ServerOption {
return func(s *Server) {
if src != nil {
s.nonceSource = src
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package pkce
import (
"context"
"errors"
"fmt"
"github.com/deep-rent/nexus/sec/digest"
"github.com/deep-rent/nexus/sec/nonce"
"github.com/deep-rent/nexus/std/ascii"
)
const (
// MethodS256 represents the SHA-256 challenge method. This is the strongly
// recommended method by RFC 7636 as it prevents the verifier from being
// intercepted in the authorization request.
MethodS256 = "S256"
// MethodPlain represents the plain challenge method. This should only be
// used if the client is highly constrained and cannot support [MethodS256],
// as it provides less security against interception.
MethodPlain = "plain"
)
const (
// MinVerifierLength is the minimum allowed length for a code verifier per
// RFC 7636 (43 characters).
MinVerifierLength = 43
// MaxVerifierLength is the maximum allowed length for a code verifier per
// RFC 7636 (128 characters).
MaxVerifierLength = 128
// DefaultVerifierLength is the length for a code verifier generated
// by [Verifier].
DefaultVerifierLength = 96
)
var (
// ErrInvalidLength indicates that the requested verifier length is outside
// the RFC 7636 bounds defined by [MinVerifierLength] and
// [MaxVerifierLength].
ErrInvalidLength = fmt.Errorf(
"verifier length must be between %d and %d characters",
MinVerifierLength,
MaxVerifierLength,
)
// ErrUnsupportedMethod indicates that the provided challenge method is not
// supported. Valid methods are [MethodS256] and [MethodPlain].
ErrUnsupportedMethod = errors.New("unsupported challenge method")
// ErrInvalidVerifier indicates that the provided code verifier contains
// characters that are not allowed by RFC 7636.
ErrInvalidVerifier = errors.New("code verifier contains invalid characters")
)
// Supports checks if the provided challenge method string is supported by this
// package. It returns true for [MethodS256] and [MethodPlain].
func Supports(method string) bool {
return method == MethodS256 || method == MethodPlain
}
// Alphabet is the set of unreserved characters allowed in a PKCE code verifier,
// as defined in RFC 7636 Section 4.1.
const Alphabet = ascii.Uppers + ascii.Lowers + ascii.Digits + "-._~"
// IsUnreserved reports whether the given string contains only unreserved
// ASCII characters.
//
// According to RFC 7636 Section 4.1, unreserved characters are:
// [A-Z], [a-z], [0-9], "-", ".", "_", "~".
func IsUnreserved(s string) bool { return ascii.All(s, isUnreserved) }
func isUnreserved(c byte) bool {
return ascii.IsAlphaNum(c) || c == '-' || c == '.' || c == '_' || c == '~'
}
// verifier is a global nonce sampler for generating code verifiers.
var verifier = nonce.NewSampler(nil, Alphabet, DefaultVerifierLength)
// Verifier creates a cryptographically secure random string to serve as a PKCE
// code verifier.
//
// The resulting string contains [DefaultVerifierLength] characters, sampled
// uniformly from [Alphabet] via a [nonce.Sampler].
func Verifier(ctx context.Context) (string, error) {
return verifier.Draw(ctx)
}
// Challenge computes a code challenge from a given code verifier and challenge
// method. For [MethodS256], it returns the Base64URL-encoded SHA-256 hash of
// the verifier (its [digest.DefaultHasher] fingerprint). For [MethodPlain], it
// returns the verifier exactly as provided. It returns [ErrInvalidLength] if
// the verifier length is non-compliant.
func Challenge(verifier, method string) (string, error) {
if len(verifier) < MinVerifierLength || len(verifier) > MaxVerifierLength {
return "", ErrInvalidLength
}
if !IsUnreserved(verifier) {
return "", ErrInvalidVerifier
}
switch method {
case MethodS256:
return digest.DefaultHasher.String(verifier), nil
case MethodPlain:
return verifier, nil
default:
return "", ErrUnsupportedMethod
}
}
// Verify validates an incoming code verifier against the originally stored
// challenge. It returns true if the verifier matches the challenge for the
// specified method, comparing in constant time via [digest] to mitigate timing
// attacks.
func Verify(verifier, challenge, method string) bool {
if len(challenge) == 0 || len(verifier) == 0 {
return false
}
if len(verifier) < MinVerifierLength || len(verifier) > MaxVerifierLength {
return false
}
if !IsUnreserved(verifier) {
return false
}
switch method {
case MethodS256:
// Match fingerprints the verifier and compares it to the stored
// challenge in constant time; a length mismatch is a non-match.
return digest.DefaultHasher.Match(verifier, challenge)
case MethodPlain:
if len(challenge) < MinVerifierLength {
return false
}
if len(challenge) > MaxVerifierLength {
return false
}
// Fingerprint both values so the constant-time compare sees
// equal-length inputs, mitigating length-based timing leaks.
return digest.Equal(
digest.DefaultHasher.String(verifier),
digest.DefaultHasher.String(challenge),
)
default:
return false
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package redirect
import (
"net/url"
"strings"
)
// Verify reports whether the URI is one of the registered redirect URIs.
// An empty registration list rejects every URI.
func Verify(uri string, registered []string) bool {
for _, r := range registered {
if Match(uri, r) {
return true
}
}
return false
}
// Match compares a redirect URI against a single registered one by exact
// string matching, with two deviations from plain equality:
//
// - A URI carrying a fragment is rejected outright, even against a
// registered value spelling the same fragment: redirect URIs must not
// have one, and a registration mistake must not launder it.
// - The port is ignored for loopback URIs; see [Loopback].
//
// There is deliberately no other flexibility — no wildcards, no prefix
// rules, no normalization. Anything short of exact matching hands part of
// the redirect target to the attacker who finds the corner case, which is
// why exact matching is what the OAuth security best practice demands.
func Match(uri, registered string) bool {
if uri == "" || strings.Contains(uri, "#") {
return false
}
if uri == registered {
return true
}
return Loopback(uri, registered)
}
// Loopback reports whether the two URIs are loopback redirect URIs that
// are equal up to the port.
//
// A native app listens on an ephemeral loopback port it cannot know at
// registration time, so this is the one place the best practice permits
// the comparison to relax: for "http" URIs whose host is the loopback
// literal "127.0.0.1" or "::1", any port satisfies the registered value.
// The hostname must still be the same literal on both sides, and path and
// query still match exactly.
//
// "localhost" is deliberately not honored here: it resolves through the
// name system and is not guaranteed to stay on the host, which is exactly
// the property the loopback literals exist to pin down. Register the
// literals for local development instead.
func Loopback(uri, registered string) bool {
u, err := url.Parse(uri)
if err != nil {
return false
}
r, err := url.Parse(registered)
if err != nil {
return false
}
if u.Scheme != "http" || r.Scheme != "http" {
return false
}
host := u.Hostname()
if host != "127.0.0.1" && host != "::1" {
return false
}
if r.Hostname() != host {
return false
}
if u.User != nil || r.User != nil {
return false
}
return u.Path == r.Path && u.RawQuery == r.RawQuery
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package oauth
import (
"cmp"
"context"
"errors"
"fmt"
"net/http"
"net/url"
"slices"
"strings"
"time"
"uuid"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/eco/iam/limit"
"github.com/deep-rent/nexus/eco/iam/oauth/pkce"
"github.com/deep-rent/nexus/eco/iam/oauth/usercode"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/sec/auth"
"github.com/deep-rent/nexus/sec/digest"
"github.com/deep-rent/nexus/sec/jose/jwt"
"github.com/deep-rent/nexus/sec/nonce"
"github.com/deep-rent/nexus/sec/vault"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/sys/log"
)
// NonceSize is the byte length of an opaque bearer artifact drawn from the
// server's [nonce.Generator]. 32 bytes yield 256 bits of entropy and a
// 43-character base64url string.
const NonceSize = 32
// Path constants define the endpoints managed by the [Server].
const (
PathAuthorize = "/authorize"
PathDeviceAuthorization = "/device_authorization"
PathDeviceVerify = "/device"
PathIntrospect = "/introspect"
PathKeySet = "/jwks.json"
PathRevoke = "/revoke"
PathToken = "/token"
PathWellKnown = "/.well-known/oauth-authorization-server"
// PathOpenIDConfiguration is the OpenID Connect discovery route
// (OpenID Connect Discovery 1.0), served when the provider role is
// enabled.
PathOpenIDConfiguration = "/.well-known/openid-configuration"
// PathUserinfo is the OpenID Connect userinfo route, served when the
// provider role is enabled.
PathUserinfo = "/userinfo"
)
const (
// DeviceVerificationApprove signals that the resource owner approves the
// pending device authorization request.
DeviceVerificationApprove = "approve"
// DeviceVerificationDeny signals that the resource owner rejects the
// pending device authorization request.
DeviceVerificationDeny = "deny"
)
// DeviceVerificationRequest represents the payload for the device
// verification endpoint (RFC 8628 Section 3.3).
//
// It is consumed by [Server.DeviceVerify] to let an authenticated resource
// owner approve or deny a pending device authorization request identified by
// its user code.
type DeviceVerificationRequest struct {
// UserCode is the code displayed on the device, entered by the resource
// owner exactly as issued, in the canonical XXXX-XXXX format; see
// [usercode.Pattern]. No case folding or whitespace normalization is
// applied.
UserCode string `json:"user_code"`
// Action is either [DeviceVerificationApprove] or
// [DeviceVerificationDeny].
Action string `json:"action"`
}
// Validate implements the [valid.Validatable] interface.
func (r *DeviceVerificationRequest) Validate(v *valid.Validator) {
v.NotEmpty("user_code", r.UserCode)
v.Match("user_code", r.UserCode, usercode.Pattern)
v.Whitelist(
"action",
r.Action,
DeviceVerificationApprove,
DeviceVerificationDeny,
)
}
var _ valid.Validatable = (*DeviceVerificationRequest)(nil)
// Owner is the authenticated resource owner as the token machinery sees it:
// just enough identity to mint claims and bind artifacts. The IAM server's
// User satisfies it structurally.
type Owner interface {
// ID returns the unique identifier for the owner.
ID() uuid.UUID
// Roles returns the list of roles assigned to the owner, used to
// populate the roles claim in access tokens.
Roles() []string
}
// SessionResolver authenticates the resource owner behind a request — for
// this server, typically via a session cookie established by a login system
// it knows nothing about.
//
// It must return nil, nil when no valid session exists, and an error only
// when the underlying lookup fails. The authorization and device
// verification endpoints consult it.
type SessionResolver func(e *router.Exchange) (Owner, error)
// OwnerResolver resolves an owner by their unique ID, used by the token
// endpoint to populate the claims of delegated tokens.
//
// It must return nil, nil when no such owner exists, and an error only when
// the underlying lookup fails.
type OwnerResolver func(ctx context.Context, id uuid.UUID) (Owner, error)
// Server implements the endpoints of an OAuth 2.0 authorization server: the
// token, authorization, introspection, revocation, and device authorization
// machinery, together with the RFC 8414 metadata and JWKS documents.
//
// It is deliberately login-agnostic: everything it knows about resource
// owners arrives through the [SessionResolver] and [OwnerResolver] seams, so
// it can stand alone in a machine-to-machine deployment or be composed with
// a login system such as the IAM server. Create instances with [NewServer]
// and attach them to a router via [Server.Mount].
type Server struct {
grants map[GrantType]Grant
vault vault.Vault
clients ClientStore
tokens TokenStores
sessions SessionResolver
owners OwnerResolver
introspector jwt.Verifier[*auth.Claims]
profiles ProfileResolver
profileClaims ProfileClaims
memberships MembershipResolver
issuer string
realm string
verificationURI string
accessTokenLifetime time.Duration
refreshTokenLifetime time.Duration
authCodeLifetime time.Duration
deviceCodeLifetime time.Duration
devicePollInterval time.Duration
nonceSource nonce.Source
nonce *nonce.Generator
userCodes *usercode.Generator
hasher *digest.Hasher
limit limit.Limiter
logger *log.Logger
observe Observer
now clock.Clock
}
// NewServer assembles a [Server] from the given configuration and options.
//
// It panics if a required [ServerConfig] field is missing, or if a
// registered grant depends on a resolver that was not provided. Server
// construction happens once at startup, so misconfiguration is a programmer
// error rather than a recoverable runtime condition.
func NewServer(cfg ServerConfig, opts ...ServerOption) *Server {
switch {
case cfg.Vault == nil:
panic("vault is required")
case cfg.Clients == nil:
panic("clients is required")
case cfg.Tokens.AuthCodes == nil:
panic("auth code store is required")
case cfg.Tokens.RefreshTokens == nil:
panic("refresh token store is required")
case cfg.Issuer == "":
panic("issuer is required")
}
if _, err := url.Parse(cfg.Issuer); err != nil {
panic("issuer is not a valid URL: " + err.Error())
}
if cfg.VerificationURI != "" {
if _, err := url.Parse(cfg.VerificationURI); err != nil {
panic("verification URI is not a valid URL: " + err.Error())
}
if cfg.Tokens.DeviceCodes == nil {
panic("verification URI requires a device code store")
}
}
logger := cfg.Logger
if logger == nil {
logger = log.Discard()
}
s := &Server{
grants: make(map[GrantType]Grant),
vault: cfg.Vault,
clients: cfg.Clients,
tokens: cfg.Tokens,
sessions: cfg.Sessions,
owners: cfg.Owners,
issuer: cfg.Issuer,
realm: cmp.Or(cfg.Realm, DefaultRealm),
accessTokenLifetime: cmp.Or(
cfg.AccessTokenLifetime,
DefaultAccessTokenLifetime,
),
refreshTokenLifetime: cmp.Or(
cfg.RefreshTokenLifetime,
DefaultRefreshTokenLifetime,
),
authCodeLifetime: cmp.Or(
cfg.AuthCodeLifetime,
DefaultAuthCodeLifetime,
),
deviceCodeLifetime: cmp.Or(
cfg.DeviceCodeLifetime,
DefaultDeviceCodeLifetime,
),
devicePollInterval: cmp.Or(
cfg.DevicePollInterval,
DefaultDevicePollInterval,
),
verificationURI: cfg.VerificationURI,
limit: limit.New(
cfg.Throttle,
cmp.Or(cfg.ThrottlePenalty, DefaultThrottlePenalty),
),
hasher: digest.DefaultHasher,
logger: logger,
now: clock.System,
}
for _, opt := range opts {
opt(s)
}
// Grants that name a resource owner need the owner resolver to mint
// claims; the session-bound endpoints need the session resolver.
delegated := false
for gt := range s.grants {
if gt != GrantTypeClientCredentials {
delegated = true
}
}
if delegated && s.owners == nil {
panic(
"owner resolver is required for grants that name a resource owner",
)
}
if (s.Supports(GrantTypeAuthorizationCode) || s.verificationURI != "") &&
s.sessions == nil {
panic(
"session resolver is required for the authorization and " +
"device verification endpoints",
)
}
// Every opaque bearer artifact is drawn from one generator, and every
// user code from one user code generator, both fed by the configured
// source (crypto/rand by default). They are built after the options so
// they observe the final source.
s.nonce = nonce.NewGenerator(s.nonceSource, NonceSize)
s.userCodes = usercode.NewGenerator(s.nonceSource)
s.profiles = cfg.Profiles
s.profileClaims = cfg.ProfileClaims
s.memberships = cfg.Memberships
s.introspector = jwt.NewVerifier[*auth.Claims](
s.vault.Keys(),
jwt.WithIssuers(s.issuer),
jwt.WithClock(s.now),
)
return s
}
// Supports checks whether the given grant type has been registered.
func (s *Server) Supports(grant GrantType) bool {
_, ok := s.grants[grant]
return ok
}
// Mount registers the server's endpoints on the registrar — the router
// itself for a root mount, or a [router.Group] to nest the server under a
// path prefix: the well-known metadata and JWKS documents, the
// authorization, token, introspection, and revocation endpoints, and —
// when a verification URI is configured — the device authorization
// endpoints. The issuer-derived well-known locations always register at
// the server root, as the RFCs mandate.
//
// When [ServerConfig.Throttle] is set, every endpoint that verifies a
// credential is wrapped in the throttle middleware.
func (s *Server) Mount(r router.Registrar) {
prefix := r.Prefix()
// guarded additionally protects endpoints that accept credential
// guesses.
guarded := r
if s.limit.Enabled() {
guarded = r.Group("", s.limit.Middleware())
}
wellKnown := s.WellKnown(prefix)
r.Handle(http.MethodGet, PathWellKnown, wellKnown)
// RFC 8414 Section 3: clients derive the metadata URL by inserting the
// well-known path between the issuer's authority and path components.
// Serve that location too whenever it differs from the prefixed route;
// it lives at the server root by definition.
if u, err := url.Parse(s.issuer); err == nil {
root := PathWellKnown + strings.TrimSuffix(u.Path, "/")
if root != prefix+PathWellKnown {
r.Unwrap().Handle(http.MethodGet, root, wellKnown)
}
}
if s.profiles != nil {
oidc := s.OpenIDConfiguration(prefix)
r.Handle(http.MethodGet, PathOpenIDConfiguration, oidc)
// OpenID Connect Discovery 1.0 Section 4: the well-known suffix is
// appended to the issuer URL. Serve that location too whenever it
// differs from the prefixed route.
if u, err := url.Parse(s.issuer); err == nil {
root := strings.TrimSuffix(u.Path, "/") + PathOpenIDConfiguration
if root != prefix+PathOpenIDConfiguration {
r.Unwrap().Handle(http.MethodGet, root, oidc)
}
}
r.HandleFunc(http.MethodGet, PathUserinfo, s.UserInfo)
r.HandleFunc(http.MethodPost, PathUserinfo, s.UserInfo)
}
r.Handle(http.MethodGet, PathKeySet, vault.Handler(s.vault))
r.HandleFunc(http.MethodGet, PathAuthorize, s.Authorize)
r.HandleFunc(http.MethodPost, PathAuthorize, s.Authorize)
guarded.HandleFunc(http.MethodPost, PathToken, s.Token)
guarded.HandleFunc(http.MethodPost, PathRevoke, s.Revoke)
guarded.HandleFunc(http.MethodPost, PathIntrospect, s.Introspect)
if s.verificationURI != "" {
guarded.HandleFunc(
http.MethodPost, PathDeviceAuthorization,
s.DeviceAuthorization,
)
guarded.HandleFunc(
http.MethodPost, PathDeviceVerify,
s.DeviceVerify,
)
}
}
// WellKnown serves the OAuth 2.0 Authorization Server Metadata (RFC 8414)
// derived from the server configuration and the registered grants.
func (s *Server) WellKnown(prefix string) router.Handler {
meta := s.metadata(prefix)
return router.HandlerFunc(func(e *router.Exchange) error {
return e.JSON(http.StatusOK, meta)
})
}
// metadata assembles the server metadata shared by the RFC 8414 and OpenID
// Connect discovery documents.
func (s *Server) metadata(prefix string) ServerMetadata {
base := strings.TrimSuffix(s.issuer, "/") + prefix
meta := ServerMetadata{
Issuer: s.issuer,
AuthorizationEndpoint: base + PathAuthorize,
TokenEndpoint: base + PathToken,
KeySetURI: base + PathKeySet,
IntrospectionEndpoint: base + PathIntrospect,
RevocationEndpoint: base + PathRevoke,
TokenEndpointAuthMethodsSupported: []string{
"client_secret_basic",
"client_secret_post",
"none",
},
}
for g := range s.grants {
meta.GrantTypesSupported = append(
meta.GrantTypesSupported,
string(g),
)
}
slices.Sort(meta.GrantTypesSupported)
if s.Supports(GrantTypeAuthorizationCode) {
meta.ResponseTypesSupported = []string{"code"}
meta.CodeChallengeMethodsSupported = []string{
pkce.MethodS256,
pkce.MethodPlain,
}
}
if s.verificationURI != "" && s.Supports(GrantTypeDeviceCode) {
meta.DeviceAuthorizationEndpoint = base + PathDeviceAuthorization
}
if s.profiles != nil {
meta.UserinfoEndpoint = base + PathUserinfo
meta.SubjectTypesSupported = []string{"public"}
meta.IDTokenSigningAlgValuesSupported = s.signingAlgs()
// Both lists come from the resolver's own declaration, so the
// document cannot advertise a claim the resolver never issues.
// offline_access unlocks a refresh token rather than claims, so
// it joins the scopes exactly when refreshing is on the menu.
meta.ScopesSupported = s.profileClaims.scopes()
if s.Supports(GrantTypeRefreshToken) {
meta.ScopesSupported = append(
meta.ScopesSupported, ScopeOfflineAccess,
)
slices.Sort(meta.ScopesSupported)
}
meta.ClaimsSupported = s.profileClaims.names()
}
return meta
}
// digest fingerprints a bearer artifact with the server's configured hasher;
// see [WithHasher].
func (s *Server) digest(value string) Digest {
return Digest(s.hasher.String(value))
}
// challenge sets the WWW-Authenticate header to signal to the client that
// HTTP Basic authentication is required, as mandated by RFC 6749 Section 5.2
// for client authentication failures.
func (s *Server) challenge(e *router.Exchange) {
e.SetHeader("WWW-Authenticate", fmt.Sprintf("Basic realm=%q", s.realm))
}
// authenticate verifies the requesting client's identity (HTTP Basic or POST
// parameters) and assembles the [Proposal] handed to grants.
func (s *Server) authenticate(e *router.Exchange) (*Proposal, error) {
data, err := e.ReadForm()
if err != nil {
return nil, &Error{
Status: http.StatusBadRequest,
Code: ErrorCodeInvalidRequest,
Description: "failed to parse request body",
}
}
clientID, clientSecret, ok := e.R.BasicAuth()
if !ok {
clientID = data.Get("client_id")
clientSecret = data.Get("client_secret")
} else {
if data.Has("client_secret") {
// RFC 6749 Section 2.3.1: MUST NOT use more than one auth method.
return nil, &Error{
Status: http.StatusBadRequest,
Code: ErrorCodeInvalidRequest,
Description: "multiple client authentication methods used",
}
}
var err error
clientID, err = url.QueryUnescape(clientID)
if err != nil {
s.challenge(e)
return nil, &Error{
Status: http.StatusUnauthorized,
Code: ErrorCodeInvalidClient,
Description: "invalid basic auth client id encoding",
}
}
clientSecret, err = url.QueryUnescape(clientSecret)
if err != nil {
s.challenge(e)
return nil, &Error{
Status: http.StatusUnauthorized,
Code: ErrorCodeInvalidClient,
Description: "invalid basic auth client secret encoding",
}
}
// Many client libraries redundantly include client_id in the body
// alongside HTTP Basic authentication; tolerate it as long as it
// names the same client.
if id := data.Get("client_id"); id != "" && id != clientID {
return nil, &Error{
Status: http.StatusBadRequest,
Code: ErrorCodeInvalidRequest,
Description: "mismatched client id",
}
}
}
if clientID == "" {
s.challenge(e)
return nil, &Error{
Status: http.StatusUnauthorized,
Code: ErrorCodeInvalidClient,
Description: "missing client id",
}
}
// Repeated guesses against one client identity are locked out before
// the store is consulted, regardless of the address they arrive from.
clientKey := limit.ScopeClient + clientID
if s.limit.Throttled(e, clientKey) {
// Build an OAuth-shaped rejection returned once the endpoint has
// exhausted its throttle allowance.
//
// RFC 6749 defines no error code for rate limiting, so the device-flow
// "slow_down" code (RFC 8628 Section 3.5) is reused: its semantics
// match exactly, and clients that do not recognize it still honor the
// 429 status and the accompanying Retry-After header.
return nil, &Error{
Status: http.StatusTooManyRequests,
Code: ErrorCodeSlowDown,
Description: "too many failed attempts",
}
}
// deny records a failed credential attempt before returning the
// (deliberately uniform) rejection.
deny := func(desc string) (*Proposal, error) {
s.limit.Penalize(clientKey, s.limit.Addr(e))
s.challenge(e)
return nil, &Error{
Status: http.StatusUnauthorized,
Code: ErrorCodeInvalidClient,
Description: desc,
}
}
// Client identifiers are UUIDs; a malformed value is indistinguishable
// from an unknown client.
id, err := uuid.Parse(clientID)
if err != nil {
return deny("unknown client")
}
client, err := s.clients.GetClient(e.Context(), id)
if err != nil {
return nil, &Error{
Status: http.StatusInternalServerError,
Code: ErrorCodeServerError,
Description: "failed to retrieve client",
Cause: err,
}
}
if client == nil {
return deny("unknown client")
}
if clientSecret == "" && !client.Public() {
return deny("client requires a secret")
}
if clientSecret != "" && !client.VerifySecret(clientSecret) {
return deny("invalid client secret")
}
// The credential is proven; drop any penalty from earlier attempts.
s.limit.Clear(clientKey)
return NewProposal(
client,
s.tokens,
data,
s.hasher,
s.logger,
s.now,
), nil
}
// Introspect handles token introspection requests (RFC 7662).
//
// It allows authorized resource servers to query the metadata and active
// status of a given access token. The handler authenticates the client making
// the request and checks the provided token's validity against the server's
// key set. Public clients are rejected, as they could otherwise probe tokens
// they do not own.
func (s *Server) Introspect(e *router.Exchange) error {
return s.wrap(e, s.introspect)
}
// introspect contains the logic for the token introspection endpoint.
func (s *Server) introspect(e *router.Exchange) error {
pro, err := s.authenticate(e)
if err != nil {
return err
}
// RFC 7662 Section 2.1: introspection is reserved for protected
// resources holding credentials.
if pro.Client.Public() {
return &Error{
Status: http.StatusForbidden,
Code: ErrorCodeUnauthorizedClient,
Description: "public clients may not introspect tokens",
}
}
token := pro.Get("token")
if token == "" {
return &Error{
Status: http.StatusBadRequest,
Code: ErrorCodeInvalidRequest,
Description: "missing token",
}
}
var res IntrospectionResponse
if claims, err := s.introspector.Verify([]byte(token)); err != nil {
s.logger.Debug(
e.Context(),
"Token verification failed during introspection",
log.Error(err),
)
} else {
res = IntrospectionResponse{
Active: true,
TokenType: auth.Scheme,
Scope: claims.Scope.String(),
Jti: claims.Jti,
Iss: claims.Iss,
Aud: claims.Aud,
Iat: epoch(claims.IssuedAt()),
Exp: epoch(claims.ExpiresAt()),
Nbf: epoch(claims.NotBefore()),
}
if claims.Azp != "" {
res.ClientID = claims.Azp
}
res.Sub = claims.Sub
}
return e.JSON(http.StatusOK, res)
}
// epoch converts a time to UNIX seconds, mapping the zero time to 0.
func epoch(t time.Time) int64 {
if t.IsZero() {
return 0
}
return t.Unix()
}
// Authorize handles requests to the authorization endpoint (RFC 6749
// Section 3.1).
//
// It supports both GET and POST requests. The handler validates the client
// identity, redirect URI, and requested scopes. If the resource owner has an
// active session (resolved via the configured [SessionResolver]), it
// generates an authorization code and redirects the user-agent back to the
// client's redirect URI.
func (s *Server) Authorize(e *router.Exchange) error {
return s.wrap(e, s.authorize)
}
// authorize contains the logic for the authorization endpoint.
func (s *Server) authorize(e *router.Exchange) error {
var data url.Values
if e.Method() == http.MethodPost {
form, err := e.ReadForm()
if err != nil {
return &Error{
Status: http.StatusBadRequest,
Code: ErrorCodeInvalidRequest,
Description: "failed to parse request body",
}
}
data = form
} else {
data = e.Query()
}
clientID := data.Get("client_id")
if clientID == "" {
return &Error{
Status: http.StatusBadRequest,
Code: ErrorCodeInvalidRequest,
Description: "missing client id",
}
}
// Client identifiers are UUIDs; a malformed value is indistinguishable
// from an unknown client.
id, err := uuid.Parse(clientID)
if err != nil {
return &Error{
Status: http.StatusUnauthorized,
Code: ErrorCodeInvalidClient,
Description: "client not found",
}
}
client, err := s.clients.GetClient(e.Context(), id)
if err != nil {
return &Error{
Status: http.StatusInternalServerError,
Code: ErrorCodeServerError,
Description: "failed to retrieve client",
Cause: err,
}
}
if client == nil {
return &Error{
Status: http.StatusUnauthorized,
Code: ErrorCodeInvalidClient,
Description: "client not found",
}
}
// If the redirect URI is missing or invalid, we MUST NOT redirect the
// user-agent back to the client.
// Instead, we inform the resource owner directly.
redirectURI := data.Get("redirect_uri")
if redirectURI == "" {
return &Error{
Status: http.StatusBadRequest,
Code: ErrorCodeInvalidRequest,
Description: "missing redirect uri",
}
}
u, err := url.Parse(redirectURI)
if err != nil {
return &Error{
Status: http.StatusBadRequest,
Code: ErrorCodeInvalidRequest,
Description: "invalid redirect uri",
}
}
if !client.VerifyRedirectURI(redirectURI) {
return &Error{
Status: http.StatusBadRequest,
Code: ErrorCodeInvalidRequest,
Description: "redirect uri not allowed for client",
}
}
responseType := data.Get("response_type")
scope := data.Get("scope")
state := data.Get("state")
codeChallenge := data.Get("code_challenge")
codeChallengeMethod := data.Get("code_challenge_method")
// Everything the client got wrong from here on reaches it as a redirect
// rather than a response body, so the checks classify the failure and
// the single redirect below delivers it.
var failure, detail string
switch {
case responseType != "code":
failure = ErrorCodeUnsupportedResponseType
detail = "unsupported response type"
case !s.Supports(GrantTypeAuthorizationCode):
// Without the grant registered, an issued code could never be
// redeemed at the token endpoint, so refuse up front.
failure = ErrorCodeUnsupportedResponseType
detail = "authorization code grant is not supported"
case !client.CanUseGrant(GrantTypeAuthorizationCode):
failure = ErrorCodeUnauthorizedClient
detail = "client is not allowed to use authorization code grant"
case scope != "" && !CanUseScope(client, scope):
failure = ErrorCodeInvalidScope
detail = "requested scope is not allowed for this client"
case codeChallenge == "":
failure = ErrorCodeInvalidRequest
detail = "code challenge is required"
case codeChallengeMethod == "":
failure = ErrorCodeInvalidRequest
detail = "code challenge method is required"
case !pkce.Supports(codeChallengeMethod):
failure = ErrorCodeInvalidRequest
detail = "unsupported code challenge method"
}
// The resource owner is authenticated only for a request that is
// otherwise sound, via the session resolver [NewServer] guarantees to be
// present whenever the grant is offered. An unauthenticated owner is the
// last of the redirected failures.
var owner Owner
if failure == "" {
var err error
if owner, err = s.sessions(e); err != nil {
return &Error{
Status: http.StatusInternalServerError,
Code: ErrorCodeServerError,
Description: "failed to look up user",
Cause: err,
}
}
if owner == nil {
failure = ErrorCodeAccessDenied
detail = "resource owner is not authenticated"
}
}
if failure != "" {
q := u.Query()
q.Set("error", failure)
q.Set("error_description", detail)
// RFC 6749 Section 4.1.2.1: The state parameter is REQUIRED if it
// was present in the client authorization request.
if state != "" {
q.Set("state", state)
}
u.RawQuery = q.Encode()
return e.Redirect(u.String(), http.StatusFound)
}
code, err := s.nonce.Draw(e.Context())
if err != nil {
return &Error{
Status: http.StatusInternalServerError,
Code: ErrorCodeServerError,
Description: "failed to generate authorization code",
Cause: err,
}
}
if err := s.tokens.AuthCodes.Create(
e.Context(),
AuthCode{
Code: s.digest(code),
ClientID: client.ID(),
RedirectURI: redirectURI,
Scope: scope,
UserID: owner.ID(),
CodeChallenge: codeChallenge,
CodeChallengeMethod: codeChallengeMethod,
Nonce: data.Get("nonce"),
ExpiresAt: s.now().Add(s.authCodeLifetime),
},
); err != nil {
return &Error{
Status: http.StatusInternalServerError,
Code: ErrorCodeServerError,
Description: "failed to store authorization code",
Cause: err,
}
}
q := u.Query()
q.Set("code", code)
if state != "" {
q.Set("state", state)
}
u.RawQuery = q.Encode()
return e.Redirect(u.String(), http.StatusFound)
}
// Token handles requests to the token endpoint (RFC 6749 Section 3.2).
//
// It authenticates the requesting client (via HTTP Basic or POST parameters)
// and processes the specified grant type using the [Grant] implementations
// previously registered via [WithGrant]. Returns a JSON response containing an
// access token and optional refresh token.
func (s *Server) Token(e *router.Exchange) error {
return s.wrap(e, s.token)
}
// token contains the logic for the token endpoint.
func (s *Server) token(e *router.Exchange) error {
pro, err := s.authenticate(e)
if err != nil {
return err
}
grantType := GrantType(pro.Get("grant_type"))
if grantType == "" {
return &Error{
Status: http.StatusBadRequest,
Code: ErrorCodeInvalidRequest,
Description: "missing grant type",
}
}
grant, ok := s.grants[grantType]
if !ok {
return &Error{
Status: http.StatusBadRequest,
Code: ErrorCodeUnsupportedGrantType,
Description: "unsupported grant type",
}
}
if !pro.Client.CanUseGrant(grantType) {
return &Error{
Status: http.StatusBadRequest,
Code: ErrorCodeUnauthorizedClient,
Description: "client is not allowed to use this grant type",
}
}
iss, err := grant.Authorize(e.Context(), pro)
if err != nil {
return err
}
now := s.now()
clientID := pro.Client.ID()
claims := &auth.Claims{
Azp: clientID.String(),
Scope: iss.Scope,
Jti: uuid.New().String(),
Iss: s.issuer,
Aud: pro.Client.Audience(),
Iat: now,
Nbf: now,
Exp: now.Add(s.accessTokenLifetime),
}
// Populate claims based on the context of the grant.
if iss.UserID == uuid.Nil() {
claims.Sub = clientID.String() // The subject is the client itself
} else if owner, err := s.owners(
e.Context(),
iss.UserID,
); err != nil {
return &Error{
Status: http.StatusInternalServerError,
Code: ErrorCodeServerError,
Description: "failed to retrieve user",
Cause: err,
}
} else if owner != nil {
claims.Sub = owner.ID().String()
claims.Roles = owner.Roles()
// Memberships travel as the teams claim. A resolution failure
// fails the issuance: a token silently missing its memberships
// would grant less than the user holds.
if s.memberships != nil {
teams, err := s.memberships(e.Context(), iss.UserID)
if err != nil {
return &Error{
Status: http.StatusInternalServerError,
Code: ErrorCodeServerError,
Description: "failed to resolve memberships",
Cause: err,
}
}
claims.Teams = teams
}
} else {
s.logger.Error(
e.Context(),
"User not found for claims",
log.UUID("user_id", iss.UserID),
)
return &Error{
Status: http.StatusBadRequest,
Code: ErrorCodeInvalidGrant,
Description: "user no longer available",
}
}
key := s.vault.Next()
if key == nil {
return &Error{
Status: http.StatusInternalServerError,
Code: ErrorCodeServerError,
Description: "unable to obtain signing key",
Cause: errors.New("vault returned no signing key"),
}
}
token, err := jwt.Sign(e.Context(), key, claims)
if err != nil {
return &Error{
Status: http.StatusInternalServerError,
Code: ErrorCodeServerError,
Description: "failed to mint access token",
Cause: err,
}
}
res := TokenResponse{
AccessToken: string(token),
TokenType: auth.Scheme,
ExpiresIn: int64(s.accessTokenLifetime.Seconds()),
Scope: iss.Scope.String(),
}
// An OpenID Connect issuance refreshes only by explicit request: under
// the openid scope, a refresh token requires offline_access (OpenID
// Connect Core 1.0 Section 11). A plain OAuth 2.0 issuance keeps
// refreshing by grant and client policy alone. The rule reads the
// scope the refresh token would carry — the grant scope, where the
// Refresh Token grant preserves it — so a one-time narrowing of the
// access token does not sever the refresh chain.
refreshScope := iss.RefreshScope
if len(refreshScope) == 0 {
refreshScope = iss.Scope
}
offline := !slices.Contains(refreshScope, ScopeOpenID) ||
slices.Contains(refreshScope, ScopeOfflineAccess)
if iss.Refreshable && offline &&
s.Supports(GrantTypeRefreshToken) &&
pro.Client.CanUseGrant(GrantTypeRefreshToken) {
token, err := s.nonce.Draw(e.Context())
if err != nil {
return &Error{
Status: http.StatusInternalServerError,
Code: ErrorCodeServerError,
Description: "failed to generate refresh token",
Cause: err,
}
}
// The Refresh Token grant hands back the lineage it rotated out;
// every other grant is a new authorization and starts its own.
family := iss.RefreshFamily
if family == uuid.Nil() {
family = uuid.NewV7()
}
if err := s.tokens.RefreshTokens.Create(e.Context(), RefreshToken{
Token: s.digest(token),
ClientID: clientID,
UserID: iss.UserID,
Scope: cmp.Or(iss.RefreshScope.String(), iss.Scope.String()),
Family: family,
ExpiresAt: now.Add(s.refreshTokenLifetime),
}); err != nil {
return &Error{
Status: http.StatusInternalServerError,
Code: ErrorCodeServerError,
Description: "failed to save refresh token",
Cause: err,
}
}
res.RefreshToken = token
}
// A user-delegated issuance under the openid scope additionally
// carries an ID token (OpenID Connect Core 1.0 Section 3.1.3.6).
if s.profiles != nil &&
iss.UserID != uuid.Nil() &&
slices.Contains(iss.Scope, ScopeOpenID) {
idToken, err := s.mintIDToken(
e.Context(), claims.Sub, clientID, iss, now,
)
if err != nil {
return &Error{
Status: http.StatusInternalServerError,
Code: ErrorCodeServerError,
Description: "failed to mint ID token",
Cause: err,
}
}
res.IDToken = idToken
}
if s.observe != nil {
s.observe(Event{
Kind: EventTokenIssued,
Grant: grantType,
ClientID: clientID,
UserID: iss.UserID,
Scope: iss.Scope.String(),
})
}
return e.JSON(http.StatusOK, res)
}
// Revoke handles token revocation requests per RFC 7009.
//
// It allows clients to signal that a previously obtained refresh token is no
// longer needed. The handler authenticates the client and, if the provided
// token is a valid refresh token belonging to that client, removes it from
// [TokenStores.RefreshTokens].
func (s *Server) Revoke(e *router.Exchange) error {
return s.wrap(e, s.revoke)
}
// revoke contains the logic for token revocation.
func (s *Server) revoke(e *router.Exchange) error {
pro, err := s.authenticate(e)
if err != nil {
return err
}
token := pro.Get("token")
if token == "" {
return &Error{
Status: http.StatusBadRequest,
Code: ErrorCodeInvalidRequest,
Description: "missing token",
}
}
// The store only ever sees the digest of the token.
digest := s.digest(token)
// Validate token ownership before revocation per RFC 7009 Section 2.1
r, found, err := s.tokens.RefreshTokens.Get(e.Context(), digest)
if err != nil {
return &Error{
Status: http.StatusInternalServerError,
Code: ErrorCodeServerError,
Description: "failed to retrieve token",
Cause: err,
}
}
if !found || r.ClientID != pro.Client.ID() {
// Token not found or belongs to another client. Return 200 OK.
e.Status(http.StatusOK)
return nil
}
if _, err := s.tokens.RefreshTokens.Delete(
e.Context(),
digest,
); err != nil {
s.logger.Error(
e.Context(),
"Failed to delete refresh token during revocation",
log.Error(err),
)
} else if s.observe != nil {
s.observe(Event{
Kind: EventTokenRevoked,
ClientID: r.ClientID,
UserID: r.UserID,
Scope: r.Scope,
})
}
e.Status(http.StatusOK)
return nil
}
// DeviceAuthorization handles requests to the device authorization endpoint
// (RFC 8628 Section 3.1).
//
// It authenticates the client and issues a device code and a user code,
// which the client displays to the resource owner.
//
// Note: This endpoint requires a valid [ServerConfig.VerificationURI] to be
// provided during server initialization.
func (s *Server) DeviceAuthorization(e *router.Exchange) error {
return s.wrap(e, s.deviceAuthorization)
}
// deviceAuthorization contains the logic for device authorization requests.
func (s *Server) deviceAuthorization(e *router.Exchange) error {
if s.verificationURI == "" {
e.Status(http.StatusNotFound)
return nil
}
pro, err := s.authenticate(e)
if err != nil {
return err
}
if !pro.Client.CanUseGrant(GrantTypeDeviceCode) {
return &Error{
Status: http.StatusBadRequest,
Code: ErrorCodeUnauthorizedClient,
Description: "client is not allowed to use device code grant",
}
}
scope := pro.Get("scope")
if scope != "" && !CanUseScope(pro.Client, scope) {
return &Error{
Status: http.StatusBadRequest,
Code: ErrorCodeInvalidScope,
Description: "scope is not allowed for client",
}
}
deviceCode, err := s.nonce.Draw(e.Context())
if err != nil {
return &Error{
Status: http.StatusInternalServerError,
Code: ErrorCodeServerError,
Description: "failed to generate device code",
Cause: err,
}
}
userCode, err := s.userCodes.Draw(e.Context())
if err != nil {
return &Error{
Status: http.StatusInternalServerError,
Code: ErrorCodeServerError,
Description: "failed to generate user code",
Cause: err,
}
}
interval := int64(s.devicePollInterval.Seconds())
// The user code is digested in the canonical form the generator renders;
// [Server.DeviceVerify] matches submitted codes strictly against that
// form, so the lookup digests always align.
if err := s.tokens.DeviceCodes.Create(e.Context(), DeviceCode{
DeviceCode: s.digest(deviceCode),
UserCode: s.digest(userCode),
ClientID: pro.Client.ID(),
Scope: scope,
Status: DeviceCodeStatusPending,
ExpiresAt: s.now().Add(s.deviceCodeLifetime),
Interval: interval,
}); err != nil {
return &Error{
Status: http.StatusInternalServerError,
Code: ErrorCodeServerError,
Description: "failed to store device code",
Cause: err,
}
}
// ServerConfig.VerificationURI is validated during construction, so
// parsing cannot fail here. Building the complete URI through url.Values
// keeps it correct even when the configured URI already carries a query.
complete, err := url.Parse(s.verificationURI)
if err != nil {
return &Error{
Status: http.StatusInternalServerError,
Code: ErrorCodeServerError,
Description: "invalid verification URI",
Cause: err,
}
}
q := complete.Query()
q.Set("user_code", userCode)
complete.RawQuery = q.Encode()
res := DeviceAuthorizationResponse{
DeviceCode: deviceCode,
UserCode: userCode,
VerificationURI: s.verificationURI,
VerificationURIComplete: complete.String(),
ExpiresIn: int64(s.deviceCodeLifetime.Seconds()),
Interval: interval,
}
return e.JSON(http.StatusOK, res)
}
// DeviceVerify lets an authenticated resource owner approve or deny a
// pending device authorization request (RFC 8628 Section 3.3).
//
// The resource owner is identified via the configured [SessionResolver]. The
// request payload is a [DeviceVerificationRequest] carrying the user code
// displayed on the device and the desired action.
//
// Note that the user code is not canonicalized, it is matched as-is.
func (s *Server) DeviceVerify(e *router.Exchange) error {
if s.verificationURI == "" {
e.Status(http.StatusNotFound)
return nil
}
owner, err := s.sessions(e)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to look up user",
Cause: err,
}
}
if owner == nil {
return &router.Error{
Status: http.StatusUnauthorized,
Reason: auth.ReasonAuthenticationFailed,
Description: "login required",
}
}
var req DeviceVerificationRequest
if err := e.BindJSON(&req); err != nil {
return err
}
// RFC 8628 Section 5.1: user codes are short enough to be guessed, so
// the verification endpoint must be rate limited. The session holder is
// throttled rather than the code, since an attacker guessing codes
// controls their own session but not the codes they hit.
userKey := limit.ScopeCode + owner.ID().String()
if s.limit.Throttled(e, userKey) {
return &router.Error{
Status: http.StatusTooManyRequests,
Reason: router.ReasonRateLimit,
Description: "too many failed attempts; try again later",
}
}
code, found, err := s.tokens.DeviceCodes.GetByUserCode(
e.Context(),
s.digest(req.UserCode),
)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to retrieve device code",
Cause: err,
}
}
if !found ||
s.now().After(code.ExpiresAt) {
s.limit.Penalize(userKey, s.limit.Addr(e))
return &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "unknown or expired user code",
}
}
if code.Status != DeviceCodeStatusPending {
return &router.Error{
Status: http.StatusConflict,
Reason: router.ReasonValidationFailed,
Description: "device authorization request is no longer pending",
}
}
if req.Action == DeviceVerificationApprove {
code.Status = DeviceCodeStatusAuthorized
code.UserID = owner.ID()
} else {
code.Status = DeviceCodeStatusDenied
}
if err := s.tokens.DeviceCodes.Update(e.Context(), code); err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to update device code",
Cause: err,
}
}
e.NoContent()
return nil
}
// wrap executes the handler and translates any returned [Error] into an HTTP
// JSON response using the error's defined status code.
//
// This is the error boundary for the RFC 6749 error shape, and therefore the
// one place that logs it: handlers and grants return errors, they do not
// report them. Errors that are not an [Error] fall through to the router,
// which logs them the same way.
func (s *Server) wrap(
e *router.Exchange,
handler func(*router.Exchange) error,
) error {
// RFC 6749 Sections 5.1 and 5.2: responses containing tokens or error
// details must not be cached. This applies to error responses as well,
// so the headers are set up front.
e.SetHeader("Cache-Control", "no-store")
e.SetHeader("Pragma", "no-cache")
err := handler(e)
oerr, ok := errors.AsType[*Error](err)
if !ok {
return err
}
// A server error is the kind a client may quote back in a bug report, so
// it always carries an identifier that can be found in the logs.
if oerr.ID == "" && oerr.Status >= http.StatusInternalServerError {
oerr.ID = router.ErrorID()
}
s.record(e, oerr)
return e.JSON(oerr.Status, oerr)
}
// record logs a failed OAuth exchange. Server errors are reported at error
// level; the protocol errors that make up normal traffic (invalid_grant,
// invalid_client and friends) are recorded at debug level so they do not
// drown the logs.
func (s *Server) record(e *router.Exchange, oerr *Error) {
ctx := e.Context()
level := log.LevelDebug
if oerr.Status >= http.StatusInternalServerError {
level = log.LevelError
}
if !s.logger.Enabled(ctx, level) {
return
}
attrs := []log.Arg{
log.Int("status", oerr.Status),
log.String("code", oerr.Code),
log.String("path", e.Path()),
}
if oerr.ID != "" {
attrs = append(attrs, log.String(log.ErrorIDKey, oerr.ID))
}
if oerr.Cause != nil {
attrs = append(attrs, log.Error(oerr.Cause))
}
s.logger.Log(ctx, level, oerr.Description, attrs...)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package usercode
import (
"context"
"regexp"
"github.com/deep-rent/nexus/sec/nonce"
)
const (
// Alphabet is the character set user codes are sampled from, as
// recommended by RFC 8628 Section 6.1: uppercase consonants only,
// avoiding vowels and visually ambiguous characters.
Alphabet = "BCDFGHJKLMNPQRSTVWXZ"
// Length is the number of characters sampled for a user code. Including
// the hyphen separator of the canonical format, a rendered code is one
// character longer.
Length = 8
)
// Pattern matches a user code rendered in the canonical XXXX-XXXX format:
// uppercase letters from [Alphabet], a hyphen separator, and no whitespace.
var Pattern = regexp.MustCompile(
`^[` + Alphabet + `]{4}-[` + Alphabet + `]{4}$`,
)
// Generator draws random user codes in the canonical XXXX-XXXX format.
//
// A Generator is safe for concurrent use.
type Generator struct {
sampler *nonce.Sampler
}
// NewGenerator creates a [Generator] fed by the given entropy source. A nil
// source falls back to [nonce.DefaultSource] (crypto/rand).
func NewGenerator(src nonce.Source) *Generator {
return &Generator{sampler: nonce.NewSampler(src, Alphabet, Length)}
}
// Draw returns a fresh user code in the canonical XXXX-XXXX format. An error
// is returned only if the entropy source fails.
func (g *Generator) Draw(ctx context.Context) (string, error) {
raw, err := g.sampler.Draw(ctx)
if err != nil {
return "", err
}
return raw[:4] + "-" + raw[4:], nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package oidc
import (
"context"
"encoding/json/v2"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/deep-rent/nexus/eco/iam/idp"
"github.com/deep-rent/nexus/eco/iam/oauth"
"github.com/deep-rent/nexus/sec/jose/jwt"
)
// Boolish is a bool that additionally accepts the JSON string forms "true"
// and "false". Some providers (notably Apple) encode boolean claims such as
// email_verified as strings.
type Boolish bool
// UnmarshalJSON parses both native and stringified boolean values.
func (b *Boolish) UnmarshalJSON(data []byte) error {
v, err := strconv.ParseBool(strings.Trim(string(data), `"`))
if err != nil {
return fmt.Errorf("expected a boolean value: %w", err)
}
*b = Boolish(v)
return nil
}
var _ json.Unmarshaler = (*Boolish)(nil)
// IDToken models the claims of an OIDC ID token issued by an external
// provider. The embedded [jwt.Reserved] carries the registered claims —
// sub holding the provider-scoped unique identifier of the user — and
// implements [jwt.Claims], so the token can be validated with a
// [jwt.Verifier].
type IDToken struct {
jwt.Reserved
// Email is the user's email address at the provider.
Email string `json:"email,omitempty"`
// EmailVerified indicates whether the provider verified the email.
EmailVerified Boolish `json:"email_verified,omitzero"`
// Name is the user's full name, if the provider shares one.
Name string `json:"name,omitempty"`
// GivenName is the user's given name, if shared. It only matters where
// Name is absent; see [IDToken.Claimant].
GivenName string `json:"given_name,omitempty"`
// FamilyName is the user's family name, if shared. It only matters
// where Name is absent; see [IDToken.Claimant].
FamilyName string `json:"family_name,omitempty"`
// Picture is the URL of the user's profile picture, if shared.
Picture string `json:"picture,omitempty"`
// Locale is the user's preferred locale as a BCP 47 language tag, if
// shared.
Locale string `json:"locale,omitempty"`
// Zoneinfo is the user's time zone as an IANA Time Zone Database name,
// if shared.
Zoneinfo string `json:"zoneinfo,omitempty"`
}
var _ jwt.Claims = (*IDToken)(nil)
// Claimant converts the verified ID token into the [idp.Claimant] shape
// consumed by the IAM server. The full name is the name claim; a provider
// sharing only the split given_name and family_name claims has them
// concatenated in that order.
func (t *IDToken) Claimant() idp.Claimant {
name := t.Name
if name == "" {
name = strings.TrimSpace(t.GivenName + " " + t.FamilyName)
}
return idp.Claimant{
Subject: t.Sub,
Email: t.Email,
EmailVerified: bool(t.EmailVerified),
Name: name,
Picture: t.Picture,
Locale: t.Locale,
Zone: t.Zoneinfo,
}
}
// Exchange posts the given form to a provider's token endpoint and decodes
// the JSON response into an [oauth.TokenResponse].
//
// Non-200 responses are converted into descriptive errors, surfacing the
// provider's "error" and "error_description" fields when present.
func Exchange(
ctx context.Context,
client *http.Client,
endpoint string,
form url.Values,
) (oauth.TokenResponse, error) {
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
endpoint,
strings.NewReader(form.Encode()),
)
if err != nil {
return oauth.TokenResponse{}, fmt.Errorf(
"failed to build token request: %w",
err,
)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
res, err := client.Do(req)
if err != nil {
return oauth.TokenResponse{}, fmt.Errorf(
"token request failed: %w",
err,
)
}
defer func() {
_ = res.Body.Close()
}()
// The client caps response body size, so this read is bounded.
body, err := io.ReadAll(res.Body)
if err != nil {
return oauth.TokenResponse{}, fmt.Errorf(
"failed to read token response: %w",
err,
)
}
if res.StatusCode != http.StatusOK {
var e oauth.Error
if err := json.Unmarshal(body, &e); err == nil && e.Code != "" {
if e.Description != "" {
return oauth.TokenResponse{}, fmt.Errorf(
"token endpoint returned %q: %s",
e.Code,
e.Description,
)
}
return oauth.TokenResponse{}, fmt.Errorf(
"token endpoint returned %q",
e.Code,
)
}
return oauth.TokenResponse{}, fmt.Errorf(
"token endpoint returned status %d",
res.StatusCode,
)
}
var tok oauth.TokenResponse
if err := json.Unmarshal(body, &tok); err != nil {
return oauth.TokenResponse{}, fmt.Errorf(
"failed to decode token response: %w",
err,
)
}
return tok, nil
}
// Callback drives the relying-party callback pipeline shared by all
// providers: it validates the authorization response parameters carried by
// req (covering both query and form_post response modes), exchanges the
// authorization code at the provider's token endpoint, and verifies the
// returned ID token.
//
// The form must already contain the provider's client credentials
// (client_id plus client_secret or equivalent); Callback fills in
// grant_type, code, and redirect_uri.
func Callback(
ctx context.Context,
client *http.Client,
endpoint string,
req *http.Request,
form url.Values,
redirectURI string,
verifier jwt.Verifier[*IDToken],
) (*IDToken, error) {
if e := req.FormValue("error"); e != "" {
return nil, fmt.Errorf("authorization failed: %s", e)
}
code := req.FormValue("code")
if code == "" {
return nil, errors.New("missing authorization code")
}
form.Set("grant_type", "authorization_code")
form.Set("code", code)
form.Set("redirect_uri", redirectURI)
tok, err := Exchange(ctx, client, endpoint, form)
if err != nil {
return nil, err
}
if tok.IDToken == "" {
return nil, errors.New("token response is missing the id_token")
}
claims, err := verifier.Verify([]byte(tok.IDToken))
if err != nil {
return nil, fmt.Errorf("id token verification failed: %w", err)
}
return claims, nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package otp
import (
"context"
"time"
"uuid"
"github.com/deep-rent/nexus/eco/iam/artifact"
"github.com/deep-rent/nexus/sec/nonce"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/sys/log"
)
// Status is the logical result of a [Challenger.Verify] or [Challenger.Resend]
// call. It is distinct from a Go error, which is reserved for storage and
// delivery failures the caller cannot recover from.
type Status int
const (
// StatusOK indicates the operation succeeded.
StatusOK Status = iota
// StatusInvalid indicates the challenge does not exist, belongs to another
// purpose, has expired, or was burned by too many attempts. The reasons are
// deliberately collapsed into one status so that callers cannot leak which
// applies.
StatusInvalid
// StatusWrongCode indicates a live challenge whose submitted code did not
// match. Returned only by [Challenger.Verify].
StatusWrongCode
// StatusResendLimit indicates the challenge has already been resent the
// maximum number of times. Returned only by [Challenger.Resend].
StatusResendLimit
)
// Outcome carries the result of a verify or resend operation.
type Outcome struct {
// Status is the logical result.
Status Status
// Owner is the user stored with the challenge. It is set only when
// [Challenger.Verify] returns [StatusOK].
Owner uuid.UUID
// ExpiresIn is the number of seconds until the challenge expires. It is set
// only when [Challenger.Resend] returns [StatusOK].
ExpiresIn int64
}
// OK reports whether the outcome represents success.
func (o Outcome) OK() bool { return o.Status == StatusOK }
// Challenge is the persisted state of a pending one-time password. All secrets
// are stored as digests, never in the clear: a stolen store yields no usable
// codes or handles.
type Challenge struct {
// ID is the digest of the client-facing handle and the storage key.
ID string `json:"id"`
// Code is the digest of the current one-time password.
Code string `json:"code"`
// Owner identifies the user the challenge authenticates. It is
// returned verbatim on successful verification.
Owner uuid.UUID `json:"owner"`
// Purpose namespaces distinct flows (e.g. "2fa", "verify:email") so that a
// handle minted for one flow cannot complete another.
Purpose string `json:"purpose"`
// MethodID records the [Method] that last delivered the code, so a resend
// can default to the same channel.
MethodID string `json:"method_id,omitzero"`
// ExpiresAt is the expiry. A resend does not extend it.
ExpiresAt time.Time `json:"expires_at"`
// Attempts is the number of confirmations tried so far.
Attempts int `json:"attempts,omitzero"`
// Resends is the number of times the code has been redelivered.
Resends int `json:"resends,omitzero"`
}
// Store persists pending challenges keyed by [Challenge.ID]. See
// [artifact.Store] for the storage contract, notably the atomic deletion the
// engine relies on to enforce single use under concurrent confirmations.
type Store = artifact.Store[string, Challenge]
// Challenger runs the lifecycle of one-time password challenges — minting,
// delivering, verifying, and resending them — over a [Store]. It is
// transport-agnostic: it neither speaks HTTP nor throttles, leaving those to
// the caller, which maps the returned [Outcome] onto its own protocol.
//
// A Challenger is safe for concurrent use if its [Store] is.
type Challenger struct {
store Store
lifetime time.Duration
maxAttempts int
maxResends int
now clock.Clock
secrets artifact.Digester
codes *nonce.Sampler
logger *log.Logger
}
// New creates a [Challenger] backed by the given [Store]. It panics if store
// is nil, since that is a startup configuration error.
func New(store Store, opts ...Option) *Challenger {
if store == nil {
panic("store is required")
}
c := &Challenger{
store: store,
lifetime: DefaultLifetime,
maxAttempts: DefaultMaxAttempts,
maxResends: DefaultMaxResends,
now: clock.System,
codes: defaultCodes,
logger: log.Discard(),
}
for _, opt := range opts {
opt(c)
}
return c
}
// Begin mints a challenge for the given purpose and owner, delivers a fresh
// code through the method, and returns the client-facing handle together with
// the number of seconds until the challenge expires.
//
// The handle is the only reference the caller should hand to the client; the
// code travels solely over the method's side channel. If delivery fails, the
// challenge is removed (best-effort; expiry is the backstop) so the next
// attempt starts clean, and the delivery error is returned.
func (c *Challenger) Begin(
ctx context.Context,
purpose string,
owner uuid.UUID,
m Method,
) (handle string, expiresIn int64, err error) {
handle, err = c.secrets.Draw(ctx)
if err != nil {
return "", 0, err
}
expiresIn, err = c.Start(ctx, purpose, owner, handle, m)
if err != nil {
return "", 0, err
}
return handle, expiresIn, nil
}
// Start mints a challenge under a caller-provided handle instead of a generated
// one, and otherwise behaves exactly like [Challenger.Begin]. It lets a caller
// derive the handle deterministically — for example, a login step keying its
// challenge on an outer flow handle so the client holds a single token.
//
// The handle MUST be unpredictable: the challenge is only as unguessable as the
// handle it is keyed on. Deriving it from a high-entropy secret satisfies this;
// a low-entropy handle would make the code brute-forceable through the store.
func (c *Challenger) Start(
ctx context.Context,
purpose string,
owner uuid.UUID,
handle string,
m Method,
) (expiresIn int64, err error) {
code, err := c.codes.Draw(ctx)
if err != nil {
return 0, err
}
ch := Challenge{
ID: c.secrets.Key(handle),
Code: c.secrets.Key(code),
Owner: owner,
Purpose: purpose,
MethodID: m.ID,
ExpiresAt: c.now().Add(c.lifetime),
}
if err := c.store.Create(ctx, ch); err != nil {
return 0, err
}
if err := m.Deliver(ctx, code); err != nil {
c.deleteBestEffort(ctx, ch.ID, "undeliverable challenge")
return 0, err
}
return int64(c.lifetime.Seconds()), nil
}
// Verify confirms a challenge against the code the owner received. The purpose
// must match the one the challenge was minted with.
//
// Codes are short enough to guess, so the method is deliberately hostile: each
// attempt is counted and persisted before the comparison (so a crash cannot
// hand out free guesses), the comparison is constant-time, and a correct code
// deletes the challenge atomically — of two racing confirmations, only the one
// that performs the deletion wins. The error return is reserved for storage
// failures; all logical results are conveyed by the [Outcome].
func (c *Challenger) Verify(
ctx context.Context,
purpose, handle, code string,
) (Outcome, error) {
id := c.secrets.Key(handle)
ch, found, err := c.store.Get(ctx, id)
if err != nil {
return Outcome{}, err
}
if !found || ch.Purpose != purpose || c.expired(ch) {
return Outcome{Status: StatusInvalid}, nil
}
if ch.Attempts >= c.maxAttempts {
// Burned: delete best-effort; expiry cleans up on failure.
c.deleteBestEffort(ctx, id, "burned challenge")
return Outcome{Status: StatusInvalid}, nil
}
// Record the attempt before comparing, so that a crash between compare and
// update cannot hand out free guesses.
ch.Attempts++
if err := c.store.Update(ctx, ch); err != nil {
return Outcome{}, err
}
// Match hashes the submitted code and compares it against the stored digest
// in constant time.
if !c.secrets.Match(code, ch.Code) {
return Outcome{Status: StatusWrongCode}, nil
}
// The atomic delete enforces single use: of two concurrent requests
// carrying the correct code, only the one that performed the deletion wins.
deleted, err := c.store.Delete(ctx, id)
if err != nil {
return Outcome{}, err
}
if !deleted {
return Outcome{Status: StatusInvalid}, nil
}
return Outcome{Status: StatusOK, Owner: ch.Owner}, nil
}
// Resend rotates the code of a pending challenge and redelivers it through the
// method, which may differ from the original to switch channels. The purpose
// must match, and the challenge's handle, expiry, and attempt budget are
// preserved — resending can neither extend a login nor reset the guess budget.
//
// The fresh code is persisted before delivery, so it invalidates the previous
// one the moment it is issued: only the latest delivery can confirm the login.
func (c *Challenger) Resend(
ctx context.Context,
purpose, handle string,
m Method,
) (Outcome, error) {
id := c.secrets.Key(handle)
ch, found, err := c.store.Get(ctx, id)
if err != nil {
return Outcome{}, err
}
if !found || ch.Purpose != purpose || c.expired(ch) {
return Outcome{Status: StatusInvalid}, nil
}
if c.maxResends < 0 || ch.Resends >= c.maxResends {
return Outcome{Status: StatusResendLimit}, nil
}
code, err := c.codes.Draw(ctx)
if err != nil {
return Outcome{}, err
}
ch.Code = c.secrets.Key(code)
ch.Resends++
ch.MethodID = m.ID
if err := c.store.Update(ctx, ch); err != nil {
return Outcome{}, err
}
if err := m.Deliver(ctx, code); err != nil {
return Outcome{}, err
}
return Outcome{
Status: StatusOK,
ExpiresIn: secondsUntil(ch.ExpiresAt, c.now()),
}, nil
}
// Peek returns the owner recorded for a live challenge without modifying it, so
// a caller can resolve delivery details (for example, re-read a user's
// enrollment) before a [Challenger.Resend]. ok is false when the challenge is
// absent, expired, or belongs to another purpose; the error is reserved for
// storage failures.
func (c *Challenger) Peek(
ctx context.Context,
purpose, handle string,
) (owner uuid.UUID, ok bool, err error) {
ch, found, err := c.store.Get(ctx, c.secrets.Key(handle))
if err != nil {
return uuid.Nil(), false, err
}
if !found || ch.Purpose != purpose || c.expired(ch) {
return uuid.Nil(), false, nil
}
return ch.Owner, true, nil
}
// Cancel removes a pending challenge, reporting whether one existed. Holding
// the handle is sufficient authority to cancel, so no purpose is required. It
// is a no-op, returning (false, nil), when no such challenge exists.
func (c *Challenger) Cancel(
ctx context.Context,
handle string,
) (bool, error) {
return c.store.Delete(ctx, c.secrets.Key(handle))
}
// expired reports whether the challenge has passed its expiry.
func (c *Challenger) expired(ch Challenge) bool {
return c.now().After(ch.ExpiresAt)
}
// secondsUntil is how many whole seconds remain from now until the deadline,
// never negative. It is what [Outcome.ExpiresIn] reports, the resend
// response quoting a remaining lifetime in seconds rather than an instant.
func secondsUntil(deadline, now time.Time) int64 {
return max(int64(deadline.Sub(now).Seconds()), 0)
}
// deleteBestEffort removes a challenge, logging but not returning a failure:
// the challenge's expiry is the backstop for a failed deletion.
func (c *Challenger) deleteBestEffort(ctx context.Context, id, what string) {
if _, err := c.store.Delete(ctx, id); err != nil {
c.logger.Error(ctx, "Failed to delete "+what, log.Error(err))
}
}
// defaultCodes samples [DefaultLength] digits from [Digits], matching the
// format users know from TOTP authenticator apps.
var defaultCodes = nonce.NewSampler(nil, Digits, DefaultLength)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package otp
import (
"time"
"github.com/deep-rent/nexus/sec/digest"
"github.com/deep-rent/nexus/sec/nonce"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/sys/log"
)
// Default policy values applied by [New] when the corresponding option is not
// given. They match the historical oauth two-factor defaults.
const (
// DefaultLifetime is the validity period of a challenge.
DefaultLifetime = 5 * time.Minute
// DefaultMaxAttempts is the number of failed confirmations after which a
// challenge is burned.
DefaultMaxAttempts = 5
// DefaultMaxResends is the number of times a single challenge may have its
// code redelivered. A negative value disables resending entirely.
DefaultMaxResends = 3
)
// Option configures a [Challenger].
type Option func(*Challenger)
// WithCodeSampler overrides the source of one-time passwords. A nil sampler
// is ignored. The default samples [DefaultLength] digits from [Digits]; build
// a custom sampler with [nonce.NewSampler] to change the length or alphabet.
func WithCodeSampler(s *nonce.Sampler) Option {
return func(c *Challenger) {
if s != nil {
c.codes = s
}
}
}
// WithHasher sets the hasher that fingerprints handles and codes before they
// reach the store. A nil hasher is ignored. Defaults to
// [digest.DefaultHasher].
func WithHasher(h *digest.Hasher) Option {
return func(c *Challenger) {
if h != nil {
c.secrets.Hasher = h
}
}
}
// WithLifetime sets the validity period of a challenge. Nonpositive values are
// ignored. Defaults to [DefaultLifetime].
func WithLifetime(d time.Duration) Option {
return func(c *Challenger) {
if d > 0 {
c.lifetime = d
}
}
}
// WithMaxAttempts sets the number of failed confirmations after which a
// challenge is burned. Values below 1 are ignored. Defaults to
// [DefaultMaxAttempts].
func WithMaxAttempts(n int) Option {
return func(c *Challenger) {
if n > 0 {
c.maxAttempts = n
}
}
}
// WithMaxResends sets how many times a challenge's code may be redelivered. A
// negative value disables resending. Defaults to [DefaultMaxResends].
func WithMaxResends(n int) Option {
return func(c *Challenger) { c.maxResends = n }
}
// WithClock overrides the time source, primarily for testing. A nil function
// is ignored. Defaults to [clock.System].
func WithClock(now clock.Clock) Option {
return func(c *Challenger) {
if now != nil {
c.now = now
}
}
}
// WithHandleGenerator overrides the source of client-facing challenge
// handles. A nil generator is ignored. Defaults to [nonce.DefaultGenerator]
// (256-bit handles).
func WithHandleGenerator(g *nonce.Generator) Option {
return func(c *Challenger) {
if g != nil {
c.secrets.Source = g
}
}
}
// WithLogger injects a structured logger for best-effort cleanup diagnostics.
// A nil logger is ignored. Defaults to [log.Discard], keeping the engine
// silent unless a logger is injected.
func WithLogger(logger *log.Logger) Option {
return func(c *Challenger) {
if logger != nil {
c.logger = logger
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package otp
import (
"context"
"errors"
"fmt"
"strings"
"github.com/deep-rent/nexus/net/notify"
"github.com/deep-rent/nexus/net/notify/mail"
"github.com/deep-rent/nexus/net/notify/push"
"github.com/deep-rent/nexus/net/notify/text"
)
// Digits is the alphabet a one-time password is sampled from. Use it to build
// a custom-length code sampler for [WithCodeSampler]:
//
// otp.WithCodeSampler(nonce.NewSampler(nil, otp.Digits, 8))
const Digits = "0123456789"
const (
// DefaultLength is the conventional code length used when consumers do
// not specify their own. Six digits match the format users know from
// TOTP authenticator apps and carrier-grade verification flows.
DefaultLength = 6
// DefaultFormat is the message body used by [ViaPush] when no custom
// format is given. It contains a single %s verb for the code.
DefaultFormat = "Your verification code is %s."
// DefaultTemplateDataKey is the template variable name under which
// [ViaMail] and [ViaText] expose the code when no custom key is given.
DefaultTemplateDataKey = "code"
)
var (
// ErrMissingTo is returned by a [Deliverer] when its destination is empty.
ErrMissingTo = errors.New("destination is needed")
// ErrMissingCode is returned by a [Deliverer] when the code is empty.
ErrMissingCode = errors.New("code is needed")
)
// Deliverer sends an already-generated code to a preconfigured destination.
//
// It replaces the former Channel interface: because the consumer builds a
// Deliverer with full knowledge of the recipient, it can localize copy or pick
// a template per user without the challenge engine knowing any of that. The
// [ViaText], [ViaMail], and [ViaPush] helpers construct the common cases, but
// any closure of this shape is a valid delivery mechanism.
//
// Implementations should be safe for concurrent use and honor the context.
type Deliverer func(ctx context.Context, code string) error
// Method is one enrolled way to reach a user with a challenge, such as an
// SMS to a specific number or an email in the user's locale.
type Method struct {
// ID is a stable identifier the client uses to select this method — for
// example on resend to switch channels (e.g. "sms", "email", "push"). It
// is opaque to the engine.
ID string
// Label is an optional human-facing hint for pickers, such as a masked
// address ("+1 ••• ••09"). It never carries a secret.
Label string
// Deliver sends the code. It is built by whoever knows the user, so it
// owns all destination, formatting, template, and locale choices.
Deliver Deliverer
}
// ViaText returns a [Deliverer] that sends the code as a text message through
// the given [text.Sender].
//
// Every text is rendered from the template identified by the template ID, in
// the locale named by the language tag (empty falls back to the template's
// default locale), with the code exposed under dataKey; an empty dataKey
// falls back to [DefaultTemplateDataKey]. The message is classified as an
// authentication message. It panics if the sender is nil or the template ID
// is empty — both static configuration errors.
func ViaText(
sender text.Sender,
to, template, language, dataKey string,
) Deliverer {
if sender == nil {
panic("text sender is required")
}
if template == "" {
panic("template ID is required")
}
if dataKey == "" {
dataKey = DefaultTemplateDataKey
}
return func(ctx context.Context, code string) error {
if to == "" {
return ErrMissingTo
}
if code == "" {
return ErrMissingCode
}
return sender.Send(ctx, text.NewMessage(template, to).
WithLanguage(language).
WithCategory(notify.CategoryAuthentication).
AddParameter(dataKey, code))
}
}
// ViaMail returns a [Deliverer] that sends the code as a transactional email
// through the given [mail.Sender].
//
// Every email is rendered from the template identified by the template ID,
// in the locale named by the language tag (empty falls back to the
// template's default locale), with the code exposed under dataKey; an empty
// dataKey falls back to [DefaultTemplateDataKey]. The message is classified
// as an authentication message. It panics if the sender is nil or the
// template ID is empty — both static configuration errors.
func ViaMail(
sender mail.Sender,
to, template, language, dataKey string,
) Deliverer {
if sender == nil {
panic("sender is required")
}
if template == "" {
panic("template ID is required")
}
if dataKey == "" {
dataKey = DefaultTemplateDataKey
}
return func(ctx context.Context, code string) error {
if to == "" {
return ErrMissingTo
}
if code == "" {
return ErrMissingCode
}
return sender.Send(ctx, mail.NewMessage(template, to).
WithLanguage(language).
WithCategory(notify.CategoryAuthentication).
AddParameter(dataKey, code))
}
}
// ViaPush returns a [Deliverer] that sends the code as a push notification
// through the given [push.Sender].
//
// The notification carries the given title and a body rendered from format,
// which must contain exactly one %s verb for the code; an empty format falls
// back to [DefaultFormat]. It panics if the sender is nil or the format lacks a
// %s verb — both static configuration errors.
func ViaPush(
sender push.Sender,
target push.Target,
title, format string,
) Deliverer {
if sender == nil {
panic("sender is required")
}
if format == "" {
format = DefaultFormat
}
if !strings.Contains(format, "%s") {
panic("format must contain a %s verb for the code")
}
return func(ctx context.Context, code string) error {
if code == "" {
return ErrMissingCode
}
return sender.Send(ctx, push.NewMessage(
title, fmt.Sprintf(format, code), target,
))
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package passkey
import (
"time"
"uuid"
)
// EventKind names a lifecycle event published by the [Server].
type EventKind string
const (
// EventCredentialRegistered marks a successfully registered passkey.
EventCredentialRegistered EventKind = "passkey_registered"
// EventAssertionFailed marks a refused login ceremony: an invalid or
// expired handle, or an assertion that failed verification. A
// successful passkey login surfaces as a login event on the login core
// instead.
EventAssertionFailed EventKind = "passkey_assertion_failed"
)
// Event is a lifecycle notification delivered to the observer configured on
// [ServerConfig]. Events are advisory and carry no secrets or credential
// material.
type Event struct {
// Kind states what happened.
Kind EventKind
// UserID identifies the affected user. It is the zero UUID when the
// ceremony failed before an account was resolved.
UserID uuid.UUID
// Label is a human-facing hint at the acting user agent.
Label string
// Addr is the remote address the request originated from.
Addr string
// At is when the event occurred.
At time.Time
}
// Observer receives lifecycle events. It runs synchronously on the request
// that produced the event, so it must stay cheap and must not block; hand
// events to a bus or queue for anything heavier.
type Observer func(Event)
// publish delivers an event to the configured observer, if any. Publishing
// is advisory: the ceremony path never fails on an observer.
func (s *Server) publish(e Event) {
if s.observer == nil {
return
}
e.At = s.now()
s.observer(e)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package passkey
import (
"context"
"net/http"
"strings"
"github.com/deep-rent/nexus/eco/iam/oauth"
"github.com/deep-rent/nexus/sys/log"
)
// Grant returns the custom [oauth.GrantTypeWebAuthn] grant over the given
// relying party. It lets a client exchange a passkey assertion directly for
// tokens, bypassing the browser-bound authorization code flow. The client
// obtains ceremony options from [Server.BeginLogin], performs the platform
// passkey ceremony, and submits the resulting assertion to the token
// endpoint:
//
// grant_type=urn:ietf:params:oauth:grant-type:webauthn
// handle=... (ceremony reference from the options endpoint)
// assertion=... (JSON-encoded PublicKeyCredential)
// scope=... (optional)
//
// Register it on the authorization server via [oauth.WithGrant]; clients
// must additionally be allowed to use it via [oauth.Client.CanUseGrant]. It
// panics if rp is nil, since that is a startup configuration error.
func Grant(rp *RelyingParty) oauth.Grant {
if rp == nil {
panic("relying party is required")
}
return &webAuthnGrant{rp: rp}
}
// webAuthnGrant implements the WebAuthn token grant; see [Grant].
type webAuthnGrant struct {
rp *RelyingParty
}
var _ oauth.Grant = (*webAuthnGrant)(nil)
// Type implements the [oauth.Grant] interface.
func (*webAuthnGrant) Type() oauth.GrantType { return oauth.GrantTypeWebAuthn }
// Authorize implements the [oauth.Grant] interface.
func (g *webAuthnGrant) Authorize(
ctx context.Context,
pro *oauth.Proposal,
) (*oauth.Issuance, error) {
handle := pro.Get("handle")
if handle == "" {
return nil, &oauth.Error{
Status: http.StatusBadRequest,
Code: oauth.ErrorCodeInvalidRequest,
Description: "missing handle",
}
}
assertion := pro.Get("assertion")
if assertion == "" {
return nil, &oauth.Error{
Status: http.StatusBadRequest,
Code: oauth.ErrorCodeInvalidRequest,
Description: "missing assertion",
}
}
scope := pro.Get("scope")
if scope != "" && !oauth.CanUseScope(pro.Client, scope) {
return nil, &oauth.Error{
Status: http.StatusBadRequest,
Code: oauth.ErrorCodeInvalidScope,
Description: "scope is not allowed for client",
}
}
out, err := g.rp.FinishLogin(ctx, handle, []byte(assertion))
if err != nil {
return nil, &oauth.Error{
Status: http.StatusInternalServerError,
Code: oauth.ErrorCodeServerError,
Description: "failed to finish login ceremony",
Cause: err,
}
}
switch out.Status {
case StatusInvalid:
return nil, &oauth.Error{
Status: http.StatusBadRequest,
Code: oauth.ErrorCodeInvalidGrant,
Description: "invalid or expired handle",
}
case StatusRejected:
pro.Logger.Debug(
ctx,
"WebAuthn assertion rejected",
log.Error(out.Reason),
)
return nil, &oauth.Error{
Status: http.StatusBadRequest,
Code: oauth.ErrorCodeInvalidGrant,
Description: "assertion verification failed",
}
}
return &oauth.Issuance{
UserID: out.Owner,
Scope: strings.Fields(scope),
Refreshable: true,
}, nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package passkey
import (
"time"
"github.com/deep-rent/nexus/sec/digest"
"github.com/deep-rent/nexus/sec/nonce"
"github.com/deep-rent/nexus/std/clock"
)
// DefaultLifetime is the validity period of a ceremony applied by [New] when
// [Config.Lifetime] is not given: the time a client may take between
// requesting ceremony options and submitting the authenticator's response.
const DefaultLifetime = 5 * time.Minute
// Config carries the relying party settings for [New].
type Config struct {
// RPID is the relying party identifier: the effective domain that
// passkeys are scoped to (e.g., "example.com"). Native apps must be
// associated with this domain (apple-app-site-association on iOS,
// assetlinks.json on Android) to use the same passkeys. Required.
RPID string
// RPDisplayName is the human-palatable relying party name shown by
// authenticators during ceremonies. Required.
RPDisplayName string
// RPOrigins lists the origins allowed to answer challenges. Web clients
// appear as regular origins (e.g., "https://app.example.com"); Android
// apps appear as "android:apk-key-hash:..." origins and must be listed
// explicitly. Required.
RPOrigins []string
// Lifetime overrides [DefaultLifetime].
Lifetime time.Duration
}
// Option configures a [RelyingParty].
type Option func(*RelyingParty)
// WithHasher sets the hasher that fingerprints ceremony handles before they
// reach the store. A nil hasher is ignored. Defaults to
// [digest.DefaultHasher].
func WithHasher(h *digest.Hasher) Option {
return func(p *RelyingParty) {
if h != nil {
p.secrets.Hasher = h
}
}
}
// WithHandleGenerator overrides the source of client-facing ceremony
// handles. A nil generator is ignored. Defaults to [nonce.DefaultGenerator]
// (256-bit handles).
func WithHandleGenerator(g *nonce.Generator) Option {
return func(p *RelyingParty) {
if g != nil {
p.secrets.Source = g
}
}
}
// WithClock overrides the time source, primarily for testing. A nil function
// is ignored. Defaults to [clock.System].
func WithClock(now clock.Clock) Option {
return func(p *RelyingParty) {
if now != nil {
p.now = now
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package passkey
import (
"bytes"
"cmp"
"context"
"encoding/json/v2"
"errors"
"time"
"uuid"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/deep-rent/nexus/eco/iam/artifact"
"github.com/deep-rent/nexus/std/clock"
)
// Credential is a passkey credential record as verified and consumed by the
// underlying WebAuthn implementation. Store implementations should treat it
// as an opaque, JSON-serializable blob keyed by its ID field; see
// [CredentialStore].
type Credential = webauthn.Credential
// Kind distinguishes the two ceremonies whose state a [Ceremony] tracks.
type Kind string
const (
// KindRegistration marks a credential registration (attestation)
// ceremony.
KindRegistration Kind = "registration"
// KindLogin marks an authentication (assertion) ceremony.
KindLogin Kind = "login"
)
// Ceremony holds the server-side state of a WebAuthn ceremony between its
// begin and finish steps, most importantly the challenge the authenticator
// response must answer.
//
// The handle is the client's reference to the pending ceremony; the
// serialized state is produced and consumed by the engine and is opaque to
// the store.
type Ceremony struct {
// ID is the digest of the client-facing handle and the storage key. The
// plaintext handle never reaches the store.
ID string `json:"id"`
// Kind states which ceremony this state belongs to. A ceremony begun for
// one kind cannot finish another.
Kind Kind `json:"kind"`
// Owner identifies the account that began a registration ceremony. It
// is the zero UUID for login ceremonies, where the account is only
// discovered from the assertion itself.
Owner uuid.UUID `json:"owner,omitzero"`
// Data carries the serialized ceremony state. Implementations must
// persist it verbatim.
Data []byte `json:"data"`
// ExpiresAt is the expiry.
ExpiresAt time.Time `json:"expires_at"`
}
// Store persists pending ceremonies keyed by [Ceremony.ID]. See
// [artifact.Store] for the storage contract, notably the atomic deletion the
// engine relies on to make every ceremony single use.
type Store = artifact.Store[string, Ceremony]
// Passkey pairs a registered credential with the management metadata a
// credential list surfaces to the account holder.
type Passkey struct {
// Name is the optional human-readable label chosen by the account
// holder, such as "MacBook Touch ID".
Name string `json:"name,omitzero"`
// CreatedAt is when the credential was registered. The zero instant
// marks a backend that does not track it.
CreatedAt time.Time `json:"created_at,omitzero"`
// Credential is the stored WebAuthn credential.
Credential Credential `json:"credential"`
}
// Credentials projects the raw WebAuthn credentials out of a passkey list.
func Credentials(keys []Passkey) []Credential {
creds := make([]Credential, len(keys))
for i, k := range keys {
creds[i] = k.Credential
}
return creds
}
// CredentialStore persists registered passkey credentials per account.
//
// Unlike the ceremony [Store], credentials are durable identity data: they
// live until the account holder removes them. Implementations should persist
// them as opaque records (e.g., JSON blobs keyed by owner and credential ID)
// and must not modify them.
type CredentialStore interface {
// List returns every credential registered by the owner. An account
// without any yields an empty slice. The error is reserved for storage
// failures.
List(ctx context.Context, owner uuid.UUID) ([]Passkey, error)
// Create stores a newly registered credential for the owner. The name is
// an optional human-readable label chosen by the account holder (e.g.,
// "MacBook Touch ID"); implementations may ignore it.
Create(
ctx context.Context,
owner uuid.UUID,
name string,
cred Credential,
) error
// Update replaces a stored credential, keyed by the owner and the
// credential's ID field. The engine calls it after every successful
// assertion to persist the updated signature counter and backup flags,
// which future assertions are validated against. Updating an absent
// credential is a no-op.
Update(ctx context.Context, owner uuid.UUID, cred Credential) error
// Delete removes the owner's credential with the given credential ID,
// reporting whether this call removed it.
Delete(
ctx context.Context,
owner uuid.UUID,
credentialID []byte,
) (deleted bool, err error)
// Rename relabels the owner's credential with the given credential ID.
// Renaming an absent credential is a no-op.
Rename(
ctx context.Context,
owner uuid.UUID,
credentialID []byte,
name string,
) error
}
// Account describes a credential-owning account to the engine.
//
// The handle is the WebAuthn user handle: an opaque byte string that
// discoverable credentials store on the authenticator and return with every
// assertion, which is what lets a login resolve the account without a
// username prompt. It must be stable and unique per account (e.g., the raw
// bytes of the account's UUID) and must not carry personal information.
type Account struct {
// Handle is the WebAuthn user handle.
Handle []byte
// Owner identifies the account whose credentials are keyed by it, and
// is returned in login outcomes.
Owner uuid.UUID
// Username is the human-palatable identifier shown by authenticators
// during ceremonies, such as an email address.
Username string
// Credentials lists the account's registered credentials. It is consulted
// during registration to exclude re-registering authenticators; logins
// load credentials through the [Directory] instead.
Credentials []Credential
}
// Directory resolves the account behind a WebAuthn user handle during a
// login ceremony.
type Directory interface {
// Lookup returns the account owning the given user handle, including its
// registered credentials. found is false when no such account exists;
// the error is reserved for storage failures.
Lookup(ctx context.Context, userHandle []byte) (Account, bool, error)
}
// Status is the logical result of finishing a ceremony. It is distinct from
// a Go error, which is reserved for storage failures the caller cannot
// recover from.
type Status int
const (
// StatusOK indicates the ceremony finished successfully.
StatusOK Status = iota
// StatusInvalid indicates the ceremony handle is unknown, expired, of the
// wrong kind, already claimed, or bound to another account. The reasons
// are deliberately collapsed into one status so that callers cannot leak
// which applies.
StatusInvalid
// StatusRejected indicates a live ceremony whose authenticator response
// failed verification. The ceremony is burned; the client must begin a
// fresh one.
StatusRejected
)
// Outcome carries the result of finishing a ceremony.
type Outcome struct {
// Status is the logical result.
Status Status
// Owner identifies the account the ceremony proved. It is set only
// when [RelyingParty.FinishLogin] returns [StatusOK].
Owner uuid.UUID
// Credential is the verified credential record. On a finished
// registration it is the newly created credential; on a finished login
// it carries the updated signature counter and backup flags.
Credential *Credential
// Reason explains a [StatusRejected] outcome for the caller's logs. It
// may carry verifier detail and must not reach the client.
Reason error
}
// OK reports whether the outcome represents success.
func (o Outcome) OK() bool { return o.Status == StatusOK }
// RelyingParty runs the lifecycle of WebAuthn ceremonies — beginning them,
// persisting their state, and verifying the authenticator responses that
// finish them — over a [Store]. It is safe for concurrent use if its stores
// are.
type RelyingParty struct {
rp *webauthn.WebAuthn
store Store
credentials CredentialStore
directory Directory
lifetime time.Duration
secrets artifact.Digester
now clock.Clock
}
// New creates a [RelyingParty] from the given configuration.
//
// It panics if the store, credential store, or directory is missing, or if
// the configuration is rejected by the underlying WebAuthn implementation —
// relying party settings are startup configuration, so misconfiguration is a
// programmer error.
func New(
cfg Config,
store Store,
credentials CredentialStore,
directory Directory,
opts ...Option,
) *RelyingParty {
switch {
case store == nil:
panic("ceremony store is required")
case credentials == nil:
panic("credential store is required")
case directory == nil:
panic("directory is required")
}
rp, err := webauthn.New(&webauthn.Config{
RPID: cfg.RPID,
RPDisplayName: cfg.RPDisplayName,
RPOrigins: cfg.RPOrigins,
AuthenticatorSelection: protocol.AuthenticatorSelection{
ResidentKey: protocol.ResidentKeyRequirementRequired,
UserVerification: protocol.VerificationRequired,
},
})
if err != nil {
panic("invalid WebAuthn configuration: " + err.Error())
}
p := &RelyingParty{
rp: rp,
store: store,
credentials: credentials,
directory: directory,
lifetime: cmp.Or(cfg.Lifetime, DefaultLifetime),
now: clock.System,
}
for _, opt := range opts {
opt(p)
}
return p
}
// Lifetime returns the configured ceremony lifetime.
func (p *RelyingParty) Lifetime() time.Duration { return p.lifetime }
// user adapts an [Account] to the user model of the underlying WebAuthn
// implementation.
type user struct {
acct Account
}
func (u *user) WebAuthnID() []byte { return u.acct.Handle }
func (u *user) WebAuthnName() string { return u.acct.Username }
func (u *user) WebAuthnDisplayName() string { return u.acct.Username }
func (u *user) WebAuthnCredentials() []Credential { return u.acct.Credentials }
var _ webauthn.User = (*user)(nil)
// BeginRegistration starts a credential registration ceremony for the
// account, persisting the ceremony state under a fresh handle.
//
// It returns the handle the client must echo back to
// [RelyingParty.FinishRegistration], the credential creation options for the
// client-side WebAuthn API (navigator.credentials.create or the platform
// equivalent), and the ceremony lifetime in seconds. The options exclude the
// account's already registered credentials, require a discoverable
// credential, and demand user verification.
func (p *RelyingParty) BeginRegistration(
ctx context.Context,
acct Account,
) (handle string, options any, expiresIn int64, err error) {
creation, data, err := p.rp.BeginRegistration(
&user{acct: acct},
webauthn.WithExclusions(
webauthn.Credentials(acct.Credentials).CredentialDescriptors(),
),
)
if err != nil {
return "", nil, 0, err
}
handle, err = p.secrets.Draw(ctx)
if err != nil {
return "", nil, 0, err
}
if err := p.begin(
ctx, handle, KindRegistration, acct.Owner, data,
); err != nil {
return "", nil, 0, err
}
return handle, creation, int64(p.lifetime.Seconds()), nil
}
// FinishRegistration verifies the authenticator's attestation response and
// persists the new credential for the account via [CredentialStore.Create],
// under the given optional human-readable name.
//
// The ceremony must have been begun by the same account; a handle begun for
// another account yields [StatusInvalid]. Ceremonies are single use: any
// finish attempt, successful or not, burns the handle. The error return is
// reserved for storage failures; all logical results are conveyed by the
// [Outcome].
func (p *RelyingParty) FinishRegistration(
ctx context.Context,
acct Account,
name, handle string,
response []byte,
) (Outcome, error) {
data, err := p.take(ctx, handle, KindRegistration, acct.Owner)
if err != nil {
return Outcome{}, err
}
if data == nil {
return Outcome{Status: StatusInvalid}, nil
}
parsed, err := protocol.ParseCredentialCreationResponseBody(
bytes.NewReader(response),
)
if err != nil {
// A ceremony the browser got wrong is a verdict, not a failure
// of this service; the reason travels inside the outcome.
return Outcome{Status: StatusRejected, Reason: err}, nil //nolint:nilerr
}
cred, err := p.rp.CreateCredential(&user{acct: acct}, *data, parsed)
if err != nil {
// A ceremony the browser got wrong is a verdict, not a failure
// of this service; the reason travels inside the outcome.
return Outcome{Status: StatusRejected, Reason: err}, nil //nolint:nilerr
}
if err := p.credentials.Create(ctx, acct.Owner, name, *cred); err != nil {
return Outcome{}, err
}
return Outcome{Status: StatusOK, Credential: cred}, nil
}
// BeginLogin starts an account-discovering login ceremony, persisting the
// ceremony state under a fresh handle.
//
// It returns the handle the client must echo back to
// [RelyingParty.FinishLogin], the assertion options for the client-side
// WebAuthn API (navigator.credentials.get or the platform equivalent), and
// the ceremony lifetime in seconds. The options carry no credential
// allowlist; the account is discovered from the user handle embedded in the
// assertion.
func (p *RelyingParty) BeginLogin(
ctx context.Context,
) (handle string, options any, expiresIn int64, err error) {
handle, err = p.secrets.Draw(ctx)
if err != nil {
return "", nil, 0, err
}
options, expiresIn, err = p.StartLogin(ctx, handle)
if err != nil {
return "", nil, 0, err
}
return handle, options, expiresIn, nil
}
// StartLogin begins a login ceremony under a caller-provided handle instead
// of a generated one, and otherwise behaves exactly like
// [RelyingParty.BeginLogin]. It lets a caller derive the handle
// deterministically — for example, a login step keying its ceremony on an
// outer flow handle so the client holds a single token.
//
// The handle MUST be unpredictable: the ceremony is only as unguessable as
// the handle it is keyed on. Deriving it from a high-entropy secret
// satisfies this.
func (p *RelyingParty) StartLogin(
ctx context.Context,
handle string,
) (options any, expiresIn int64, err error) {
assertion, data, err := p.rp.BeginDiscoverableLogin(
webauthn.WithUserVerification(protocol.VerificationRequired),
)
if err != nil {
return nil, 0, err
}
if err := p.begin(ctx, handle, KindLogin, uuid.Nil(), data); err != nil {
return nil, 0, err
}
return assertion, int64(p.lifetime.Seconds()), nil
}
// FinishLogin verifies a discoverable-credential assertion against the
// pending ceremony, resolves the asserting account from the user handle via
// the [Directory], and persists the updated credential record (signature
// counter and backup flags) via [CredentialStore.Update].
//
// A regressed signature counter (a cloned-authenticator indicator) rejects
// the assertion, and a failure to persist the updated record fails the login
// closed, since it would blind future clone detection. Ceremonies are single
// use: any finish attempt, successful or not, burns the handle. The error
// return is reserved for storage failures; all logical results are conveyed
// by the [Outcome], whose Owner reports the authenticated account.
func (p *RelyingParty) FinishLogin(
ctx context.Context,
handle string,
response []byte,
) (Outcome, error) {
data, err := p.take(ctx, handle, KindLogin, uuid.Nil())
if err != nil {
return Outcome{}, err
}
if data == nil {
return Outcome{Status: StatusInvalid}, nil
}
parsed, err := protocol.ParseCredentialRequestResponseBody(
bytes.NewReader(response),
)
if err != nil {
// A ceremony the browser got wrong is a verdict, not a failure
// of this service; the reason travels inside the outcome.
return Outcome{Status: StatusRejected, Reason: err}, nil //nolint:nilerr
}
// Storage failures inside the lookup callback must not masquerade as
// verification failures, so they are captured out-of-band.
var (
lookupErr error
owner uuid.UUID
)
handler := func(_, userHandle []byte) (webauthn.User, error) {
acct, found, err := p.directory.Lookup(ctx, userHandle)
if err != nil {
lookupErr = err
return nil, err
}
if !found {
return nil, errors.New("unknown account")
}
owner = acct.Owner
return &user{acct: acct}, nil
}
cred, err := p.rp.ValidateDiscoverableLogin(handler, *data, parsed)
if lookupErr != nil {
return Outcome{}, lookupErr
}
if err != nil {
// A ceremony the browser got wrong is a verdict, not a failure
// of this service; the reason travels inside the outcome.
return Outcome{Status: StatusRejected, Reason: err}, nil //nolint:nilerr
}
if cred.Authenticator.CloneWarning {
return Outcome{Status: StatusRejected, Reason: errors.New(
"signature counter regressed; authenticator may be cloned",
)}, nil
}
// The updated record carries the new signature counter; failing to
// persist it would blind future clone detection, so the login fails
// closed.
if err := p.credentials.Update(ctx, owner, *cred); err != nil {
return Outcome{}, err
}
return Outcome{Status: StatusOK, Owner: owner, Credential: cred}, nil
}
// begin persists fresh ceremony state under the digest of the given handle.
func (p *RelyingParty) begin(
ctx context.Context,
handle string,
kind Kind,
owner uuid.UUID,
data *webauthn.SessionData,
) error {
raw, err := json.Marshal(data)
if err != nil {
return err
}
return p.store.Create(ctx, Ceremony{
ID: p.secrets.Key(handle),
Kind: kind,
Owner: owner,
Data: raw,
ExpiresAt: p.now().Add(p.lifetime),
})
}
// take claims the ceremony bound to the given handle for a single finish
// attempt: it loads the state, checks kind, owner, and expiry, and deletes
// it atomically so a concurrent attempt cannot claim it again.
//
// It returns nil data (with a nil error) when the handle is unknown,
// expired, mismatched, or already claimed. The error is reserved for storage
// access and state deserialization failures.
func (p *RelyingParty) take(
ctx context.Context,
handle string,
kind Kind,
owner uuid.UUID,
) (*webauthn.SessionData, error) {
id := p.secrets.Key(handle)
c, found, err := p.store.Get(ctx, id)
if err != nil {
return nil, err
}
if !found ||
c.Kind != kind ||
c.Owner != owner ||
p.now().After(c.ExpiresAt) {
return nil, nil
}
deleted, err := p.store.Delete(ctx, id)
if err != nil {
return nil, err
}
if !deleted {
return nil, nil
}
var data webauthn.SessionData
if err := json.Unmarshal(c.Data, &data); err != nil {
return nil, err
}
return &data, nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package passkey
import (
"context"
"encoding/json/jsontext"
"net/http"
"uuid"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/eco/iam/limit"
"github.com/deep-rent/nexus/eco/iam/login"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/net/throttle"
"github.com/deep-rent/nexus/sec/auth"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/sys/log"
)
// Path constants define the WebAuthn endpoints managed by the [Server].
const (
PathLogin = "/webauthn/login"
PathLoginOptions = "/webauthn/login/options"
PathRegister = "/webauthn/register"
PathRegisterOptions = "/webauthn/register/options"
)
// Users resolves resource owners for WebAuthn ceremonies: registration binds
// a credential to an authenticated user, and a discoverable login resolves
// the account behind an asserted user handle.
type Users interface {
// GetUser retrieves a user by their unique ID. If the user is not
// found, it must return nil, nil; an error signals a storage failure.
GetUser(ctx context.Context, id uuid.UUID) (login.User, error)
}
// Introspect verifies a first-party access token and returns the resource
// owner it was delegated to.
//
// It backs the bearer-token fallback of the registration endpoints, for
// native apps that authenticated via a token grant and hold no session
// cookie. Implementations must return the zero UUID (with a nil error) for
// an invalid token or one minted to a client acting on its own behalf, and
// an error only for infrastructure failures.
type Introspect func(ctx context.Context, token string) (uuid.UUID, error)
// OptionsResponse is the payload returned by the endpoints that begin a
// WebAuthn ceremony.
//
// The options are handed to the client-side WebAuthn API
// (navigator.credentials.create or .get); the handle references the pending
// ceremony and must be echoed back when the ceremony is finished.
type OptionsResponse struct {
// Handle is the opaque reference to the pending ceremony.
Handle string `json:"handle"`
// ExpiresIn is the lifetime of the ceremony session in seconds.
ExpiresIn int64 `json:"expires_in"`
// Options carries the credential creation options (registration) or
// credential request options (login) for the client-side WebAuthn API.
Options any `json:"options"`
}
// RegistrationRequest represents the payload finishing a passkey
// registration ceremony.
//
// It is consumed by [Server.FinishRegistration].
type RegistrationRequest struct {
// Handle is the ceremony reference returned by the options endpoint.
Handle string `json:"handle"`
// Name is an optional human-readable label for the new passkey (e.g.,
// "MacBook Touch ID").
Name string `json:"name,omitzero"`
// Credential is the JSON-encoded PublicKeyCredential produced by
// navigator.credentials.create (or the platform equivalent).
Credential jsontext.Value `json:"credential"`
}
// Validate implements the [valid.Validatable] interface.
func (r *RegistrationRequest) Validate(v *valid.Validator) {
v.NotEmpty("handle", r.Handle)
if len(r.Credential) == 0 {
v.Fail("credential", "must not be empty")
}
}
var _ valid.Validatable = (*RegistrationRequest)(nil)
// LoginRequest represents the payload finishing a passkey login ceremony.
//
// It is consumed by [Server.FinishLogin].
type LoginRequest struct {
// Handle is the ceremony reference returned by the options endpoint.
Handle string `json:"handle"`
// Credential is the JSON-encoded PublicKeyCredential produced by
// navigator.credentials.get (or the platform equivalent).
Credential jsontext.Value `json:"credential"`
}
// Validate implements the [valid.Validatable] interface.
func (r *LoginRequest) Validate(v *valid.Validator) {
v.NotEmpty("handle", r.Handle)
if len(r.Credential) == 0 {
v.Fail("credential", "must not be empty")
}
}
var _ valid.Validatable = (*LoginRequest)(nil)
// account describes the user to the passkey engine. The user's UUID
// bytes double as the WebAuthn user handle, which discoverable credentials
// store on the authenticator and return with every assertion; this is what
// lets the login endpoints resolve the account without a username prompt.
func account(usr login.User, creds []Credential) Account {
id := usr.ID()
return Account{
Handle: id[:],
Owner: id,
Username: usr.Username(),
Credentials: creds,
}
}
// directory adapts a [Users] seam and a [CredentialStore] to the
// [Directory] interface, resolving accounts from WebAuthn user handles
// during login ceremonies.
type directory struct {
users Users
creds CredentialStore
}
// NewDirectory builds the [Directory] a [RelyingParty] resolves accounts
// through: user handles minted by this package are raw user UUIDs, looked up
// via the users seam and joined with the stored credentials.
func NewDirectory(users Users, creds CredentialStore) Directory {
return directory{users: users, creds: creds}
}
var _ Directory = directory{}
// Lookup implements [Directory].
func (d directory) Lookup(
ctx context.Context,
userHandle []byte,
) (Account, bool, error) {
// User handles minted by this package are raw user UUIDs; anything
// else cannot belong to a known account.
if len(userHandle) != len(uuid.UUID{}) {
return Account{}, false, nil
}
id := uuid.UUID(userHandle)
usr, err := d.users.GetUser(ctx, id)
if err != nil {
return Account{}, false, err
}
if usr == nil {
return Account{}, false, nil
}
keys, err := d.creds.List(ctx, id)
if err != nil {
return Account{}, false, err
}
return account(usr, Credentials(keys)), true, nil
}
// ServerConfig holds the parameters for constructing a [Server].
type ServerConfig struct {
// RelyingParty runs the WebAuthn ceremonies. Required.
RelyingParty *RelyingParty
// Login is the authentication core: passkey logins establish their
// sessions through it, and registration resolves the acting user from
// its session cookie. Required.
Login *login.Manager
// Users resolves resource owners. Required.
Users Users
// Credentials persists registered passkeys, listed when registration
// options exclude already-enrolled credentials. Required.
Credentials CredentialStore
// Introspect resolves a bearer access token to the user it was
// delegated to, as a fallback for registration calls without a session
// cookie. Without it, registration requires a session.
Introspect Introspect
// Limiter charges failed login ceremonies against a shared throttle.
// The zero limiter disables limiting entirely.
Limiter limit.Limiter
// Observer receives lifecycle [Event] notifications. A nil observer
// disables publishing.
Observer Observer
// Clock is the time source stamping events. Defaults to
// [clock.System].
Clock clock.Clock
// Logger receives structured diagnostics. Defaults to [log.Discard].
Logger *log.Logger
}
// Server mounts the WebAuthn HTTP endpoints over a [RelyingParty]: passkey
// registration for authenticated users and discoverable passkey logins that
// establish the same session a password login would.
//
// Create instances with [NewServer] and attach them to a router via
// [Server.Mount]. Native apps that exchange assertions for tokens directly
// use the [Grant] at the token endpoint instead.
type Server struct {
rp *RelyingParty
login *login.Manager
users Users
creds CredentialStore
introspect Introspect
limit limit.Limiter
observer Observer
logger *log.Logger
now clock.Clock
}
// NewServer assembles a [Server] from the given configuration. It panics if
// a required field is missing, since that is a startup configuration error.
func NewServer(cfg ServerConfig) *Server {
switch {
case cfg.RelyingParty == nil:
panic("relying party is required")
case cfg.Login == nil:
panic("login manager is required")
case cfg.Users == nil:
panic("users is required")
case cfg.Credentials == nil:
panic("credential store is required")
}
logger := cfg.Logger
if logger == nil {
logger = log.Discard()
}
now := cfg.Clock
if now == nil {
now = clock.System
}
return &Server{
rp: cfg.RelyingParty,
login: cfg.Login,
users: cfg.Users,
creds: cfg.Credentials,
introspect: cfg.Introspect,
limit: cfg.Limiter,
observer: cfg.Observer,
logger: logger,
now: now,
}
}
// Mount registers the WebAuthn endpoints on the registrar — the router
// itself for a root mount, or a [router.Group] to nest the server under a
// path prefix or shared middleware. When a limiter is configured, every
// endpoint is additionally wrapped in the throttle middleware, since each
// accepts credential material.
func (s *Server) Mount(r router.Registrar) {
guarded := r
if s.limit.Enabled() {
guarded = r.Group("", s.limit.Middleware())
}
guarded.HandleFunc(
http.MethodPost, PathRegisterOptions,
s.BeginRegistration,
)
guarded.HandleFunc(http.MethodPost, PathRegister, s.FinishRegistration)
guarded.HandleFunc(http.MethodPost, PathLoginOptions, s.BeginLogin)
guarded.HandleFunc(http.MethodPost, PathLogin, s.FinishLogin)
}
// user resolves the user on behalf of whom a registration ceremony runs. It
// accepts the session cookie established by the login core (first-party web
// apps) or, as a fallback, a Bearer access token issued by this deployment
// (native apps that authenticated via a token grant and have no cookie
// jar).
//
// It returns nil (with a nil error) if neither credential identifies a
// user, and an error only if a storage lookup fails.
func (s *Server) user(e *router.Exchange) (login.User, error) {
usr, err := s.login.Resolve(e)
if err != nil || usr != nil {
return usr, err
}
if s.introspect == nil {
return nil, nil
}
token := auth.BearerExtractor(e.R)
if token == "" {
return nil, nil
}
id, err := s.introspect(e.Context(), token)
if err != nil {
return nil, err
}
if id == uuid.Nil() {
return nil, nil
}
return s.users.GetUser(e.Context(), id)
}
// BeginRegistration begins a passkey registration ceremony for an
// authenticated user.
//
// The user is identified via the session cookie or a Bearer access
// token; see [Server.FinishRegistration] for the accompanying finish step.
// The returned options exclude already registered credentials, require a
// discoverable credential, and demand user verification.
func (s *Server) BeginRegistration(e *router.Exchange) error {
usr, err := s.user(e)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to lookup user",
Cause: err,
}
}
if usr == nil {
return &router.Error{
Status: http.StatusUnauthorized,
Reason: auth.ReasonAuthenticationFailed,
Description: "login required",
}
}
keys, err := s.creds.List(e.Context(), usr.ID())
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to retrieve credentials",
Cause: err,
}
}
handle, options, expiresIn, err := s.rp.BeginRegistration(
e.Context(),
account(usr, Credentials(keys)),
)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to begin registration",
Cause: err,
}
}
return e.JSON(http.StatusOK, OptionsResponse{
Handle: handle,
ExpiresIn: expiresIn,
Options: options,
})
}
// FinishRegistration finishes a passkey registration ceremony by verifying
// the authenticator's attestation response and persisting the new
// credential via the credential store.
//
// It expects a [RegistrationRequest] carrying the handle returned by
// [Server.BeginRegistration] and must be called by the same user that began
// the ceremony. Ceremony sessions are single use: any finish attempt,
// successful or not, burns the handle.
func (s *Server) FinishRegistration(e *router.Exchange) error {
usr, err := s.user(e)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to lookup user",
Cause: err,
}
}
if usr == nil {
return &router.Error{
Status: http.StatusUnauthorized,
Reason: auth.ReasonAuthenticationFailed,
Description: "login required",
}
}
var req RegistrationRequest
if err := e.BindJSON(&req); err != nil {
return err
}
out, err := s.rp.FinishRegistration(
e.Context(),
account(usr, nil),
req.Name,
req.Handle,
req.Credential,
)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to finish registration",
Cause: err,
}
}
switch out.Status {
case StatusInvalid:
return &router.Error{
Status: http.StatusUnauthorized,
Reason: auth.ReasonAuthenticationFailed,
Description: "invalid or expired handle",
}
case StatusRejected:
s.logger.Debug(
e.Context(),
"WebAuthn attestation rejected",
log.Error(out.Reason),
)
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: "credential verification failed",
}
}
s.publish(Event{
Kind: EventCredentialRegistered,
UserID: usr.ID(),
Label: e.R.UserAgent(),
Addr: throttle.RemoteAddr(e.R),
})
e.NoContent()
return nil
}
// BeginLogin begins a passkey login ceremony.
//
// The endpoint is anonymous: the returned options carry no credential
// allowlist, and the account is discovered from the user handle embedded in
// the assertion. The ceremony can be finished either via [Server.FinishLogin]
// (first-party web login ending in a session cookie) or via the [Grant] at
// the token endpoint (native apps exchanging the assertion directly for
// tokens).
func (s *Server) BeginLogin(e *router.Exchange) error {
handle, options, expiresIn, err := s.rp.BeginLogin(e.Context())
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to begin login",
Cause: err,
}
}
return e.JSON(http.StatusOK, OptionsResponse{
Handle: handle,
ExpiresIn: expiresIn,
Options: options,
})
}
// FinishLogin finishes a passkey login ceremony and establishes a session.
//
// It expects a [LoginRequest] carrying the handle returned by
// [Server.BeginLogin] and the authenticator's assertion. On success, the
// same session cookie as after a password login is set; the standard
// authorization code flow can proceed from there. A passkey assertion with
// user verification is inherently multi-factor, so no OTP confirmation
// follows.
func (s *Server) FinishLogin(e *router.Exchange) error {
var req LoginRequest
if err := e.BindJSON(&req); err != nil {
return err
}
out, err := s.rp.FinishLogin(e.Context(), req.Handle, req.Credential)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to finish login",
Cause: err,
}
}
switch out.Status {
case StatusInvalid:
s.limit.Penalize(s.limit.Addr(e))
s.publish(Event{
Kind: EventAssertionFailed,
Label: e.R.UserAgent(),
Addr: throttle.RemoteAddr(e.R),
})
return &router.Error{
Status: http.StatusUnauthorized,
Reason: auth.ReasonAuthenticationFailed,
Description: "invalid or expired handle",
}
case StatusRejected:
s.limit.Penalize(s.limit.Addr(e))
s.logger.Debug(
e.Context(),
"WebAuthn assertion rejected",
log.Error(out.Reason),
)
s.publish(Event{
Kind: EventAssertionFailed,
Label: e.R.UserAgent(),
Addr: throttle.RemoteAddr(e.R),
})
return &router.Error{
Status: http.StatusUnauthorized,
Reason: auth.ReasonAuthenticationFailed,
Description: "assertion verification failed",
}
}
usr, err := s.users.GetUser(e.Context(), out.Owner)
if err != nil {
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to lookup user",
Cause: err,
}
}
if usr == nil {
return &router.Error{
Status: http.StatusUnauthorized,
Reason: auth.ReasonAuthenticationFailed,
Description: "invalid or expired login",
}
}
if err := s.login.Establish(e, usr, false); err != nil {
return err
}
e.NoContent()
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package passkey
import (
"context"
"fmt"
"github.com/deep-rent/nexus/eco/iam/flow"
)
// prompt is the client-facing prompt a WebAuthn step returns: the assertion
// options the authenticator signs and the ceremony's lifetime.
type prompt struct {
// Options are the WebAuthn assertion options for the authenticator.
Options any `json:"options"`
// ExpiresIn is the remaining lifetime of the ceremony in seconds.
ExpiresIn int64 `json:"expires_in"`
}
// step is a [flow.Step] that confirms the login's user with a passkey
// assertion; see [FlowStep].
type step struct {
id string
rp *RelyingParty
}
var _ flow.Step = (*step)(nil)
// FlowStep returns a [flow.Step] that confirms the login's user with a
// passkey assertion. It is intended as a step-up factor after the user is
// identified; the presented passkey must belong to that user. A login
// planner composes it alongside the other factor steps.
//
// It panics if id is empty or rp is nil — both startup configuration
// errors.
func FlowStep(rp *RelyingParty, id string) flow.Step {
if id == "" {
panic("step ID is required")
}
if rp == nil {
panic("relying party is required")
}
return &step{id: id, rp: rp}
}
// ID implements [flow.Step].
func (w *step) ID() string { return w.id }
// handle derives the per-step ceremony handle from the outer flow handle. The
// flow handle is high-entropy, so the derived value is too.
func (w *step) handle(flowHandle string) string {
return flowHandle + ":" + w.id
}
// Begin implements [flow.Step]: it starts a discoverable-login ceremony keyed
// on the derived handle and returns the assertion options to sign.
func (w *step) Begin(
ctx context.Context,
_ *flow.Transaction,
handle string,
) (any, error) {
options, expiresIn, err := w.rp.StartLogin(ctx, w.handle(handle))
if err != nil {
return nil, err
}
return prompt{Options: options, ExpiresIn: expiresIn}, nil
}
// Verify implements [flow.Step]: it finishes the ceremony and requires the
// asserted account to match the login's user.
//
// A WebAuthn challenge is single use, so a failed or mismatched assertion burns
// the ceremony and fails the step (the client restarts the login) rather than
// retrying against a spent challenge.
func (w *step) Verify(
ctx context.Context,
t *flow.Transaction,
handle string,
in flow.Input,
) (flow.Verdict, error) {
out, err := w.rp.FinishLogin(ctx, w.handle(handle), in.Raw)
if err != nil {
return 0, err
}
if !out.OK() {
return flow.VerdictFail, nil
}
// The passkey must belong to the user the login already identified, so a
// valid assertion for another account cannot complete this login.
if out.Owner != t.Owner {
return flow.VerdictFail, nil
}
return flow.VerdictOK, nil
}
// Act implements [flow.Step]: a WebAuthn ceremony supports no out-of-band
// actions.
func (*step) Act(
_ context.Context,
_ *flow.Transaction,
_ string,
_ flow.Action,
) (any, error) {
return nil, fmt.Errorf(
"%w: webauthn step supports no actions",
flow.ErrRejected,
)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
// Package plan decides what a login has to prove beyond its first factor.
//
// It is the service's [login.Planner]: a trusted device skips second factors
// entirely, and otherwise every enrolled factor with a verified contact point
// and a configured delivery channel becomes a one-time password method the
// user may pick from. A user with no usable factor signs in on the first
// factor alone, so enrolling a factor the deployment cannot deliver to never
// locks anyone out.
//
// login.NewManager(login.Config{
// Planner: plan.New(plan.Config{
// Steps: func() plan.Steps { return manager },
// Post: mailer,
// }),
// })
//
// # Construction order
//
// [Config.Steps] arrives as a supplier rather than a value because the login
// manager is built with the planner in hand, so the planner cannot hold the
// manager yet. The supplier is called per plan, by which time the manager
// exists.
package plan
import (
"context"
"uuid"
"github.com/deep-rent/nexus/eco/iam/flow"
"github.com/deep-rent/nexus/eco/iam/login"
"github.com/deep-rent/nexus/eco/iam/mask"
"github.com/deep-rent/nexus/eco/iam/otp"
"github.com/deep-rent/nexus/eco/iam/post"
"github.com/deep-rent/nexus/eco/iam/trust"
"github.com/deep-rent/nexus/eco/iam/user"
"github.com/deep-rent/nexus/net/notify/text"
"github.com/deep-rent/nexus/std/i18n"
)
// StepID names the one-time password step a plan contributes. It is the
// identifier the client echoes back when answering the step.
const StepID = "otp"
// Steps builds the flow steps a course is assembled from, satisfied by
// [*login.Manager].
type Steps interface {
// OTPStep builds a step that delivers a one-time password over one of
// the given methods and verifies the answer.
OTPStep(id string, methods []otp.Method) flow.Step
// FactorStep builds a step satisfied by a delivered code or by a
// factor the user carries.
FactorStep(
id string,
methods []otp.Method,
carried []login.Carried,
) flow.Step
}
// Authenticators is the seam onto the factors a user carries, satisfied
// by [authn.Manager]. Without one, a plan offers delivered codes alone.
//
// [authn.Manager]: github.com/deep-rent/nexus/eco/iam/authn#Manager
type Authenticators interface {
// Enrolled reports whether the user holds a confirmed authenticator.
Enrolled(ctx context.Context, userID uuid.UUID) (bool, error)
// Verify checks a code from the user's authenticator.
Verify(ctx context.Context, userID uuid.UUID, code string) (bool, error)
// RemainingCodes reports how many unredeemed recovery codes the user
// holds.
RemainingCodes(ctx context.Context, userID uuid.UUID) (int, error)
// RedeemCode spends one of the user's recovery codes.
RedeemCode(
ctx context.Context,
userID uuid.UUID,
code string,
) (ok bool, remaining int, err error)
}
// Config configures a planner.
type Config struct {
// Steps supplies the step builder; see the package documentation for
// why it is a supplier. Required.
Steps func() Steps
// Post delivers mailed codes. Without it, an enrolled mail factor
// contributes no method.
Post *post.Mailer
// Text delivers texted codes. Without it, an enrolled text factor
// contributes no method.
Text text.Sender
// TextTemplate is the ID of the template project rendering texted
// codes. Required alongside Text: without it, an enrolled text factor
// contributes no method either.
TextTemplate string
// TextLanguages lists the locales the text template publishes, as
// BCP 47 tags. The planner picks the best match against the user's
// preferences, falling back to the first entry. Empty sends every text
// in the template's default locale.
TextLanguages []string
// Authn supplies the factors a user carries: an authenticator app and
// recovery codes. Without it, a plan offers delivered codes alone.
Authn Authenticators
}
// Identifiers of the carried factors a plan contributes.
const (
// FactorTOTP names the authenticator app factor.
FactorTOTP = "totp"
// FactorRecovery names the recovery code fallback.
FactorRecovery = "recovery"
)
// New builds the planner. It panics without [Config.Steps], since that is a
// startup configuration error.
func New(cfg Config) login.Planner {
if cfg.Steps == nil {
panic("step builder is required")
}
return func(
ctx context.Context,
usr login.User,
dev trust.Device,
) (flow.Course, error) {
// A device the user has already proven a second factor on is not
// asked to prove one again.
if dev.Trusted {
return nil, nil
}
p, ok := usr.(user.Principal)
if !ok {
return nil, nil
}
u := p.U
methods := cfg.methods(u)
carried, err := cfg.carried(ctx, u)
if err != nil {
return nil, err
}
// A user with no usable factor signs in on the first factor
// alone: enrolling one the deployment cannot honor must never
// lock anyone out.
if len(methods) == 0 && len(carried) == 0 {
return nil, nil
}
return flow.Course{
cfg.Steps().FactorStep(StepID, methods, carried),
}, nil
}
}
// carried is the factors the user holds rather than receives. Recovery
// codes are offered whenever any are left — they are the way back in for
// every factor, not only the authenticator, and a user whose mailbox is
// unreachable needs them just as much.
func (cfg Config) carried(
ctx context.Context,
u *user.User,
) ([]login.Carried, error) {
if cfg.Authn == nil {
return nil, nil
}
var out []login.Carried
enrolled, err := cfg.Authn.Enrolled(ctx, u.ID)
if err != nil {
return nil, err
}
if enrolled {
out = append(out, login.Carried{
ID: FactorTOTP,
Label: "Authenticator app",
Verify: cfg.Authn.Verify,
})
}
remaining, err := cfg.Authn.RemainingCodes(ctx, u.ID)
if err != nil {
return nil, err
}
if remaining > 0 {
out = append(out, login.Carried{
ID: FactorRecovery,
Label: "Recovery code",
Verify: func(
ctx context.Context,
userID uuid.UUID,
value string,
) (bool, error) {
ok, _, err := cfg.Authn.RedeemCode(ctx, userID, value)
return ok, err
},
})
}
return out, nil
}
// methods is the delivery channels this login may answer its second factor
// over: those the user enrolled, proved a contact point for, and the
// deployment can actually reach them on.
func (cfg Config) methods(u *user.User) []otp.Method {
var methods []otp.Method
if u.HasFactor(user.FactorMail) && u.EmailVerified && cfg.Post != nil {
methods = append(methods, cfg.Post.OTPMethod(
string(user.FactorMail),
mask.Email(u.Email),
post.Recipient{
Addr: u.Email,
Name: u.Name,
DisplayName: u.DisplayName,
Locales: u.Locales,
},
))
}
if u.HasFactor(user.FactorText) && u.PhoneVerified &&
cfg.Text != nil && cfg.TextTemplate != "" {
methods = append(methods, otp.Method{
ID: string(user.FactorText),
Label: mask.Phone(u.Phone),
Deliver: otp.ViaText(
cfg.Text,
u.Phone,
cfg.TextTemplate,
cfg.language(u.Locales),
"", // the package default template variable
),
})
}
return methods
}
// language picks the text template locale for the user: the best match of
// their preferences against the published languages, the deployment
// default otherwise, or none at all when no languages are configured.
func (cfg Config) language(locales []string) string {
if len(cfg.TextLanguages) == 0 {
return ""
}
if tag, ok := i18n.Match(locales, cfg.TextLanguages); ok {
return tag
}
return cfg.TextLanguages[0]
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package post
import (
"context"
"errors"
"maps"
"net/url"
"time"
"github.com/deep-rent/nexus/eco/iam/otp"
"github.com/deep-rent/nexus/net/notify"
"github.com/deep-rent/nexus/net/notify/mail"
"github.com/deep-rent/nexus/std/i18n"
)
// Templates names the provider-side dynamic templates behind each mail
// occasion. An occasion whose template ID is empty is disabled: sending it
// returns [ErrDisabled], so deployments may roll mails out gradually.
type Templates struct {
// VerifyEmail confirms ownership of an email address via a link.
VerifyEmail string
// ResetPassword starts a password recovery via a link.
ResetPassword string
// LoginAlert notifies about a login from an unfamiliar device.
LoginAlert string
// PasswordChanged notifies that the account password was replaced.
PasswordChanged string
// TeamJoined notifies that the recipient was added to a team.
TeamJoined string
// TeamLeft notifies that the recipient was removed from a team.
TeamLeft string
// OTP delivers a one-time password code.
OTP string
// TeamInvite asks the recipient to join a team via a link.
TeamInvite string
}
// Config bundles the required construction parameters of a [Mailer].
// There is no sender identity here: the from address belongs to the mail
// channel behind the [mail.Sender].
type Config struct {
// Templates are the per-occasion template IDs.
Templates Templates
// Languages lists the locales the template projects publish, as BCP 47
// tags. The mailer picks the best match against a recipient's
// preferences, falling back to the first entry, so the first language
// is the deployment's default. Empty sends every mail in each
// template's own default locale.
Languages []string
// VerifyURL is the frontend URL that redeems email confirmation
// tickets; the token is appended as a query parameter. Required when
// [Templates.VerifyEmail] is set.
VerifyURL string
// ResetURL is the frontend URL that redeems password reset tickets; the
// token is appended as a query parameter. Required when
// [Templates.ResetPassword] is set.
ResetURL string
// InviteURL is the frontend URL that accepts or rejects team
// invitations; the token is appended as a query parameter. Required
// when [Templates.TeamInvite] is set.
InviteURL string
}
// ErrDisabled reports a mail occasion whose template is not configured.
var ErrDisabled = errors.New("mail occasion disabled")
// Recipient is who a message goes to: an address and, when the service
// knows it, the name of the person behind it. Every template receives all
// of it, so a mail can open with "Hi Alice" rather than with an address.
//
// The names are optional. An invitation reaches an address whose owner has
// no account yet, and a template written for that occasion must render
// without one.
type Recipient struct {
// Addr is the email address the message goes to. Required.
Addr string
// Name is the recipient's full name, if known.
Name string
// DisplayName is the name the recipient prefers to be addressed by, if
// known.
DisplayName string
// Locales lists the recipient's preferred locales as BCP 47 tags, most
// preferred first, if known. The mailer matches them against
// [Config.Languages] to pick the template locale.
Locales []string
}
// Display returns the name a message should address the recipient by: the
// display name when set, the full name otherwise, or the empty string
// where the service knows neither.
func (r Recipient) Display() string {
if r.DisplayName != "" {
return r.DisplayName
}
return r.Name
}
// data seeds the template variables every occasion shares.
func (r Recipient) data() map[string]any {
return map[string]any{
"email": r.Addr,
"name": r.Name,
"display_name": r.DisplayName,
"salutation": r.Display(),
}
}
// LoginInfo summarizes a login for an alert mail. It must never carry a
// secret.
type LoginInfo struct {
// Device is a human-facing hint at the acting user agent.
Device string
// Addr is the remote address the login originated from.
Addr string
// At is when the login happened.
At time.Time
}
// Inviter identifies who sent a team invitation.
//
// The invitee is shown both the name and the address, so that an invitation
// out of the blue can be judged on where it came from: a display name alone
// is whatever the inviter typed, and says nothing about whether the invitee
// knows them. This discloses the inviter's address to the invitee by
// design, which is the point — the inviter chose to write to them.
type Inviter struct {
// Name is how the inviter is addressed in the message.
Name string
// Addr is the inviter's email address, for the invitee to recognize.
Addr string
}
// Mailer dispatches the IAM service's transactional mails. It is stateless
// and safe for concurrent use.
type Mailer struct {
sender mail.Sender
cfg Config
}
// New creates a [Mailer] sending through the given sender. It panics on a
// nil sender or a link-carrying occasion without its URL, since those are
// startup configuration errors.
func New(sender mail.Sender, cfg Config) *Mailer {
switch {
case sender == nil:
panic("sender is required")
case cfg.Templates.VerifyEmail != "" && cfg.VerifyURL == "":
panic("verify URL is required with a verify email template")
case cfg.Templates.ResetPassword != "" && cfg.ResetURL == "":
panic("reset URL is required with a reset password template")
}
return &Mailer{sender: sender, cfg: cfg}
}
// link appends the ticket token to the base URL as a query parameter.
func link(base, token string) string {
sep := "?"
if u, err := url.Parse(base); err == nil && u.RawQuery != "" {
sep = "&"
}
return base + sep + "token=" + url.QueryEscape(token)
}
// send dispatches one templated message to a single recipient. The
// occasion's own variables ride on top of the recipient's, overriding one
// of the same name.
func (m *Mailer) send(
ctx context.Context,
to Recipient,
template string,
category notify.Category,
data map[string]any,
) error {
if template == "" {
return ErrDisabled
}
vars := to.data()
maps.Copy(vars, data)
return m.sender.Send(ctx, mail.NewMessage(template, to.Addr).
WithLanguage(m.language(to)).
WithCategory(category).
SetVariables(vars))
}
// language picks the template locale for the recipient: the best match of
// their preferences against the published languages, the deployment
// default otherwise, or none at all when no languages are configured.
func (m *Mailer) language(to Recipient) string {
if len(m.cfg.Languages) == 0 {
return ""
}
if tag, ok := i18n.Match(to.Locales, m.cfg.Languages); ok {
return tag
}
return m.cfg.Languages[0]
}
// VerifyEmail sends an ownership confirmation link for the recipient
// address. The token must be a ticket redeemable for the pending email
// change or verification.
func (m *Mailer) VerifyEmail(
ctx context.Context,
to Recipient,
token string,
) error {
return m.send(ctx, to, m.cfg.Templates.VerifyEmail,
notify.CategoryAuthentication, map[string]any{
"link": link(m.cfg.VerifyURL, token),
})
}
// ResetPassword sends a password recovery link. The token must be a ticket
// redeemable for a password reset.
func (m *Mailer) ResetPassword(
ctx context.Context,
to Recipient,
token string,
) error {
return m.send(ctx, to, m.cfg.Templates.ResetPassword,
notify.CategoryAuthentication, map[string]any{
"link": link(m.cfg.ResetURL, token),
})
}
// LoginAlert notifies the recipient about a login from an unfamiliar
// device.
func (m *Mailer) LoginAlert(
ctx context.Context,
to Recipient,
info LoginInfo,
) error {
return m.send(ctx, to, m.cfg.Templates.LoginAlert,
notify.CategoryTransactional, map[string]any{
"device": info.Device,
"address": info.Addr,
"time": info.At.UTC().Format(time.RFC3339),
})
}
// PasswordChanged notifies the recipient that their password was replaced.
// It carries no link: a recipient who did not make the change needs the
// recovery flow, not a one-click action a thief could follow too.
func (m *Mailer) PasswordChanged(
ctx context.Context,
to Recipient,
at time.Time,
) error {
return m.send(ctx, to, m.cfg.Templates.PasswordChanged,
notify.CategoryTransactional, map[string]any{
"time": at.UTC().Format(time.RFC3339),
})
}
// TeamJoined notifies the recipient that they were added to the named team.
func (m *Mailer) TeamJoined(
ctx context.Context,
to Recipient,
teamName string,
at time.Time,
) error {
return m.send(ctx, to, m.cfg.Templates.TeamJoined,
notify.CategoryTransactional, map[string]any{
"team": teamName,
"time": at.UTC().Format(time.RFC3339),
})
}
// TeamLeft notifies the recipient that they were removed from the named
// team, whether they left of their own accord or were evicted.
func (m *Mailer) TeamLeft(
ctx context.Context,
to Recipient,
teamName string,
at time.Time,
) error {
return m.send(ctx, to, m.cfg.Templates.TeamLeft,
notify.CategoryTransactional, map[string]any{
"team": teamName,
"time": at.UTC().Format(time.RFC3339),
})
}
// TeamInvite asks the recipient to join the named team. The token must be
// an invitation token redeemable at the teams API; see [Inviter] for what
// the message says about who sent it.
func (m *Mailer) TeamInvite(
ctx context.Context,
to Recipient,
team string,
from Inviter,
token string,
) error {
return m.send(ctx, to, m.cfg.Templates.TeamInvite,
notify.CategoryTransactional, map[string]any{
"link": link(m.cfg.InviteURL, token),
"team": team,
"inviter": from.Name,
"inviter_email": from.Addr,
})
}
// OTP delivers a one-time password code.
func (m *Mailer) OTP(ctx context.Context, to Recipient, code string) error {
return m.send(ctx, to, m.cfg.Templates.OTP,
notify.CategoryAuthentication, map[string]any{
"code": code,
})
}
// OTPMethod adapts the mailer to an [otp.Method] delivering codes to the
// given recipient. The label should be a masked rendition of the address
// (see [mask.Email]); it never carries the full address to the client.
//
// [mask.Email]: github.com/deep-rent/nexus/eco/iam/mask#Email
func (m *Mailer) OTPMethod(id, label string, to Recipient) otp.Method {
return otp.Method{
ID: id,
Label: label,
Deliver: func(ctx context.Context, code string) error {
return m.OTP(ctx, to, code)
},
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package profile
import (
"context"
"slices"
"uuid"
"github.com/deep-rent/nexus/eco/iam/oauth"
"github.com/deep-rent/nexus/eco/iam/user"
"github.com/deep-rent/nexus/sec/auth"
)
// Users is the narrow seam onto the user directory, satisfied by
// [user.Store]. A resolver reads the account whose claims it is minting and
// nothing else.
type Users interface {
// Get returns the user with the given ID, or nil when none exists.
Get(ctx context.Context, id uuid.UUID) (*user.User, error)
}
// claim is one OpenID Connect claim: its name, and how it reads off a user
// record. Returning nil omits the claim, which is how the optional ones
// stay absent rather than being sent empty.
type claim struct {
// name is the claim name as it appears in an ID token.
name string
// read extracts the claim's value, or nil to omit it.
read func(u *user.User) any
}
// claims maps each scope token onto the claims it unlocks.
var claims = map[string][]claim{
oauth.ScopeProfile: {
{"name", func(u *user.User) any {
return u.Name
}},
{"nickname", func(u *user.User) any {
if name := u.DisplayName; name != "" {
return name
}
return nil // Omit if empty
}},
{"locale", func(u *user.User) any {
// The claim is a single tag; the most preferred locale speaks
// for the stored list.
if len(u.Locales) == 0 {
return nil
}
return u.Locales[0]
}},
{"zoneinfo", func(u *user.User) any {
if zone := u.Zone; zone != "" {
return zone
}
return nil // Omit if empty
}},
{"updated_at", func(u *user.User) any {
return u.UpdatedAt.Unix()
}},
},
oauth.ScopeEmail: {
{"email", func(u *user.User) any {
return u.Email
}},
{"email_verified", func(u *user.User) any {
return u.EmailVerified
}},
},
oauth.ScopePhone: {
{"phone_number", func(u *user.User) any {
if phone := u.Phone; phone != "" {
return phone
}
return nil // Omit if empty
}},
{"phone_number_verified", func(u *user.User) any {
// The verification claim only means something next to a
// number; an account without one omits both.
if u.Phone == "" {
return nil
}
return u.PhoneVerified
}},
},
}
// Option extends the claim table beyond the static claims — for the
// claims whose rendering needs deployment configuration. Pass the same
// options to [Schema] and [Resolver], or the discovery document and the
// issued tokens drift apart.
type Option func(map[string][]claim)
// WithPicture adds the standard picture claim under the profile scope,
// rendering the user's avatar key into a public URL — [avatar.Manager.URL]
// has the right shape. Users without an avatar omit the claim.
//
// [avatar.Manager.URL]:
// github.com/deep-rent/nexus/eco/iam/avatar#Manager.URL
func WithPicture(url func(key string) string) Option {
return func(m map[string][]claim) {
m[oauth.ScopeProfile] = append(m[oauth.ScopeProfile], claim{
"picture", func(u *user.User) any {
if u.Avatar == "" {
return nil
}
return url(u.Avatar)
},
})
}
}
// table assembles the effective claim table: the static claims plus
// whatever the options add, on a copy so the package-level table stays
// untouched.
func table(opts []Option) map[string][]claim {
if len(opts) == 0 {
return claims
}
m := make(map[string][]claim, len(claims))
for scope, cs := range claims {
m[scope] = slices.Clone(cs)
}
for _, opt := range opts {
opt(m)
}
return m
}
// Schema projects the claim table into the claim names the discovery
// document advertises, so that scopes_supported and claims_supported
// describe exactly what [Resolver] can issue.
func Schema(opts ...Option) oauth.ProfileClaims {
effective := table(opts)
schema := make(oauth.ProfileClaims, len(effective))
for scope, cs := range effective {
names := make([]string, len(cs))
for i, c := range cs {
names[i] = c.name
}
schema[scope] = names
}
return schema
}
// Resolver returns the [oauth.ProfileResolver] reading claims out of the
// directory. An unknown user yields no claims, leaving the ID token with its
// reserved claims only. It panics if users is nil, since that is a startup
// configuration error.
func Resolver(users Users, opts ...Option) oauth.ProfileResolver {
if users == nil {
panic("user directory is required")
}
effective := table(opts)
return func(
ctx context.Context,
id uuid.UUID,
scope auth.Scope,
) (map[string]any, error) {
u, err := users.Get(ctx, id)
if err != nil || u == nil {
return nil, err
}
out := map[string]any{}
for _, token := range scope {
for _, c := range effective[token] {
if v := c.read(u); v != nil {
out[c.name] = v
}
}
}
return out, nil
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package relay
import (
"context"
"encoding/json/jsontext"
"encoding/json/v2"
"slices"
"time"
"uuid"
"github.com/deep-rent/nexus/eco/iam/team"
"github.com/deep-rent/nexus/eco/iam/topic"
"github.com/deep-rent/nexus/eco/iam/user"
"github.com/deep-rent/nexus/net/notify/hook"
"github.com/deep-rent/nexus/sys/event"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/metrics"
)
// DefaultTimeout bounds one publication: the fan-out query and the
// delivery rows it writes. It is generous relative to both, since a
// publication runs off the request path where nothing waits on it.
const DefaultTimeout = 10 * time.Second
// The topics the relay publishes. Every name carries the service that
// raised it, so an endpoint subscribed to several of this deployment's
// services can tell "a user was deleted here" from the same sentence
// spoken elsewhere.
//
// These strings are a PUBLIC contract: a subscriber stores them in its
// own configuration, so they outlive the internal constants they are
// mapped from and must not be renamed to follow one.
const (
// TopicUserEnabled fires when a disabled account is unlocked.
TopicUserEnabled = "iam.user.enabled"
// TopicUserDisabled fires when an account is locked out.
TopicUserDisabled = "iam.user.disabled"
// TopicUserDeleted fires when an account is removed, after the
// record is gone.
TopicUserDeleted = "iam.user.deleted"
// TopicTeamFounded fires when a team is created.
TopicTeamFounded = "iam.team.founded"
// TopicTeamDissolved fires when a team is deleted.
TopicTeamDissolved = "iam.team.dissolved"
// TopicTeamJoined fires when a user enters a team.
TopicTeamJoined = "iam.team.joined"
// TopicTeamLeft fires when a user exits a team.
TopicTeamLeft = "iam.team.left"
// TopicTeamPromoted fires when a member is granted the owner role.
TopicTeamPromoted = "iam.team.promoted"
// TopicTeamDemoted fires when an owner is reduced to a member.
TopicTeamDemoted = "iam.team.demoted"
)
// directoryTopics maps the directory's event kinds onto the topics they
// are published as. A kind that is absent is not published.
var directoryTopics = map[user.EventKind]string{
user.EventUserEnabled: TopicUserEnabled,
user.EventUserDisabled: TopicUserDisabled,
user.EventUserDeleted: TopicUserDeleted,
}
// teamTopics maps the team module's event kinds onto their topics.
var teamTopics = map[team.EventKind]string{
team.EventFounded: TopicTeamFounded,
team.EventDissolved: TopicTeamDissolved,
team.EventJoined: TopicTeamJoined,
team.EventLeft: TopicTeamLeft,
team.EventPromoted: TopicTeamPromoted,
team.EventDemoted: TopicTeamDemoted,
}
// Topics lists every topic the relay publishes, for the registration
// surface to offer and for a subscriber to check its configuration
// against. It is DERIVED from the publish maps above, so a kind mapped
// to a topic is registrable by construction — a hand-kept list here
// would be a third copy that could drift.
//
// A password change is deliberately absent. It is the one directory
// event that reports a credential, and a subscriber that learns when
// passwords change learns when to look — the account API mails the
// account holder instead, which is who the news is for.
var Topics = func() []string {
out := make([]string, 0, len(directoryTopics)+len(teamTopics))
for _, topic := range directoryTopics {
out = append(out, topic)
}
for _, topic := range teamTopics {
out = append(out, topic)
}
slices.Sort(out)
return out
}()
// payload is the body of every event this relay publishes: identifiers,
// and nothing else. A receiver that needs the account behind an
// identifier reads it back through the API under its own authority,
// which keeps names and addresses out of third-party request logs and
// leaves a captured delivery worth nothing.
type payload struct {
// UserID is the account the event concerns, absent on team-level
// events that name no member.
UserID uuid.UUID `json:"user_id,omitzero"`
// TeamID is the team the event concerns, absent on directory events.
TeamID uuid.UUID `json:"team_id,omitzero"`
// ActorID is who caused the event, absent when an administrative
// machine client did, since that names no user.
ActorID uuid.UUID `json:"actor_id,omitzero"`
}
// Publisher is the narrow seam onto the webhook system, satisfied by
// [hook.Engine] over any driver. The relay reacts to events that have
// already been committed, so it publishes in a transaction of its own
// and needs nothing wider.
type Publisher interface {
// Emit publishes one event, returning how many endpoints it was
// queued for.
Emit(ctx context.Context, event hook.Event) (int, error)
}
// Config configures a [Relay]. Every field is optional.
type Config struct {
// Logger reports publications that failed. It defaults to
// [log.Discard].
Logger *log.Logger
// Registry receives the publication counters. It defaults to
// [metrics.DefaultRegistry].
Registry *metrics.Registry
// Timeout bounds one publication. It defaults to [DefaultTimeout].
Timeout time.Duration
}
// Relay republishes the directory and team lifecycle events onto the
// webhook system, so a service outside this one hears about them without
// polling.
//
// Handlers run on the bus's dispatch goroutine, detached from the
// request that produced the event.
type Relay struct {
// ctx anchors a publication's logging; the publication itself runs
// detached from it, so the shutdown drain can still publish what the
// bus buffered. See [Relay.publish].
ctx context.Context
hooks Publisher
logger *log.Logger
reg *metrics.Registry
timeout time.Duration
}
// New builds a relay over the given webhook publisher. It publishes
// nothing until [Relay.Attach] puts it behind a broker's topics, and
// panics without a publisher, since that is a startup configuration
// error.
//
// The ctx spans the service lifetime and anchors the relay's logging;
// publications themselves run detached from it (see [Relay.publish]),
// bounded by [Config.Timeout] alone, so the broker's shutdown drain can
// finish publishing what the bus still buffered.
func New(ctx context.Context, hooks Publisher, cfg Config) *Relay {
if hooks == nil {
panic("webhook publisher is required")
}
r := &Relay{
ctx: ctx,
hooks: hooks,
logger: cfg.Logger,
reg: cfg.Registry,
timeout: cfg.Timeout,
}
if r.logger == nil {
r.logger = log.Discard()
}
if r.reg == nil {
r.reg = metrics.DefaultRegistry
}
if r.timeout <= 0 {
r.timeout = DefaultTimeout
}
return r
}
// Attach subscribes the relay to the topics it republishes: the user
// directory and team memberships. Events published before this returns
// reach no handler.
func (r *Relay) Attach(b *event.Broker) {
topic.Directory(b).Subscribe(r.onDirectory)
topic.Teams(b).Subscribe(r.onTeam)
}
// onDirectory republishes a user lifecycle event.
func (r *Relay) onDirectory(e user.Event) {
name, ok := directoryTopics[e.Kind]
if !ok {
return
}
r.publish(name, e.At, payload{UserID: e.UserID})
}
// onTeam republishes a team lifecycle event.
func (r *Relay) onTeam(e team.Event) {
name, ok := teamTopics[e.Kind]
if !ok {
return
}
// Team events carry no time of their own; the engine stamps the
// event with its own clock when the zero value arrives.
r.publish(name, time.Time{}, payload{
UserID: e.UserID,
TeamID: e.TeamID,
ActorID: e.Actor,
})
}
// publish hands one event to the webhook engine, counting the outcome.
// A failure is logged and dropped: the directory has already committed
// what happened, and refusing to return would not undo it.
func (r *Relay) publish(name string, at time.Time, p payload) {
body, err := json.Marshal(p)
if err != nil {
// The payload is three identifiers; failing to render it is a
// defect in this package, not a runtime condition.
r.count(name, "failed")
r.logger.Error(r.ctx, "Failed to render a webhook payload",
log.String("topic", name), log.Error(err))
return
}
// Detached from the service context deliberately: the broker's
// shutdown drain runs these handlers AFTER that context is canceled,
// and an event the bus still buffered must reach the queue rather
// than fail with context.Canceled — that drain is the entire point
// of closing the broker after the producers. The timeout keeps a
// hung write from stalling the drain.
ctx, cancel := context.WithTimeout(
context.WithoutCancel(r.ctx), r.timeout,
)
defer cancel()
fanned, err := r.hooks.Emit(ctx, hook.Event{
Topic: name,
Data: jsontext.Value(body),
At: at,
})
if err != nil {
r.count(name, "failed")
r.logger.Error(r.ctx, "Failed to publish a webhook event",
log.String("topic", name), log.Error(err))
return
}
if fanned == 0 {
// Nobody subscribed. Counted apart from a delivery, so that a
// silent topic reads as unsubscribed rather than broken.
r.count(name, "unsubscribed")
return
}
r.count(name, "published")
r.logger.Debug(r.ctx, "Published a webhook event",
log.String("topic", name), log.Int("endpoints", fanned))
}
// count records one publication outcome.
func (r *Relay) count(name, result string) {
r.reg.Counter(
"iam_webhook_events_total",
metrics.T("topic", name),
metrics.T("result", result),
).Inc()
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package iam
import (
"context"
"fmt"
"net/http"
"os"
"slices"
"time"
"uuid"
"golang.org/x/time/rate"
"github.com/deep-rent/nexus/eco/attest"
"github.com/deep-rent/nexus/eco/iam/account"
"github.com/deep-rent/nexus/eco/iam/admin"
"github.com/deep-rent/nexus/eco/iam/alert"
"github.com/deep-rent/nexus/eco/iam/artifact"
"github.com/deep-rent/nexus/eco/iam/audit"
"github.com/deep-rent/nexus/eco/iam/authn"
"github.com/deep-rent/nexus/eco/iam/avatar"
"github.com/deep-rent/nexus/eco/iam/client"
"github.com/deep-rent/nexus/eco/iam/config"
"github.com/deep-rent/nexus/eco/iam/directory"
"github.com/deep-rent/nexus/eco/iam/driver/mock"
"github.com/deep-rent/nexus/eco/iam/driver/postgres"
"github.com/deep-rent/nexus/eco/iam/flow"
"github.com/deep-rent/nexus/eco/iam/idp"
"github.com/deep-rent/nexus/eco/iam/idp/apple"
"github.com/deep-rent/nexus/eco/iam/idp/google"
"github.com/deep-rent/nexus/eco/iam/invite"
"github.com/deep-rent/nexus/eco/iam/limit"
"github.com/deep-rent/nexus/eco/iam/login"
"github.com/deep-rent/nexus/eco/iam/oauth"
"github.com/deep-rent/nexus/eco/iam/oauth/grant"
"github.com/deep-rent/nexus/eco/iam/otp"
"github.com/deep-rent/nexus/eco/iam/passkey"
"github.com/deep-rent/nexus/eco/iam/plan"
"github.com/deep-rent/nexus/eco/iam/post"
"github.com/deep-rent/nexus/eco/iam/profile"
"github.com/deep-rent/nexus/eco/iam/relay"
"github.com/deep-rent/nexus/eco/iam/session"
"github.com/deep-rent/nexus/eco/iam/team"
"github.com/deep-rent/nexus/eco/iam/ticket"
"github.com/deep-rent/nexus/eco/iam/topic"
"github.com/deep-rent/nexus/eco/iam/trust"
"github.com/deep-rent/nexus/eco/iam/user"
"github.com/deep-rent/nexus/eco/iam/witness"
push "github.com/deep-rent/nexus/eco/notify"
"github.com/deep-rent/nexus/net/aws4"
"github.com/deep-rent/nexus/net/notify/mail"
"github.com/deep-rent/nexus/net/notify/text"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/net/s3"
"github.com/deep-rent/nexus/net/throttle"
"github.com/deep-rent/nexus/net/turnstile"
"github.com/deep-rent/nexus/sec/auth"
"github.com/deep-rent/nexus/sec/digest"
"github.com/deep-rent/nexus/sec/jose/jwt"
"github.com/deep-rent/nexus/sec/vault"
"github.com/deep-rent/nexus/sec/vault/source/file"
"github.com/deep-rent/nexus/sec/vault/source/okms"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/std/rotor"
"github.com/deep-rent/nexus/sys/app"
"github.com/deep-rent/nexus/sys/boot"
"github.com/deep-rent/nexus/sys/event"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/queue"
"github.com/deep-rent/nexus/sys/schedule"
)
// TokenRevoker revokes standing refresh tokens in bulk, per user and per
// client. It is the one mutation the service asks of a driver directly:
// unlike the per-record artifact stores, a bulk revocation cuts across
// everything a principal holds, so it belongs to the driver rather than
// to any single module's store.
type TokenRevoker interface {
// DeleteRefreshTokensForUser clears all outstanding refresh tokens for a
// given user.
DeleteRefreshTokensForUser(
ctx context.Context,
userID uuid.UUID,
) error
// DeleteRefreshTokensForClient clears all outstanding refresh tokens for a
// given client.
DeleteRefreshTokensForClient(
ctx context.Context,
clientID uuid.UUID,
) error
}
// Container is the driver-agnostic persistence surface the service
// composes over: a container handing out one store per module, plus the
// [TokenRevoker] facet that cuts across them. Both the PostgreSQL and the
// in-memory driver satisfy it.
type Container interface {
Users() user.Store
Clients() client.Store
Teams() team.Store
Tickets() ticket.Store
Sessions() session.Store
Challenges() otp.Store
Flows() flow.Store
Trust() trust.Store
Ceremonies() passkey.Store
Credentials() passkey.CredentialStore
Enrollments() authn.Store
RecoveryCodes() authn.Codes
AvatarUploads() avatar.Store
AuthCodes() artifact.Store[oauth.Digest, oauth.AuthCode]
RefreshTokens() oauth.RefreshTokenStore
DeviceCodes() oauth.DeviceCodeStore
TokenRevoker
}
// Tuning constants of the assembled service. They are deliberately not
// configuration: each is a property of the service's own shape rather than
// of the deployment around it. The connection timeouts, which do depend on
// the deployment, live in [boot.Timeouts] instead, alongside what every
// service shares: the header cap, the probe cadences, and the shutdown
// margin.
const (
// MaxBodySize caps a request body at 64 KiB. The largest legitimate
// body in the service is a passkey ceremony's WebAuthn attestation or
// assertion, base64-encoded and occasionally carrying a certificate
// chain (TPM or Android SafetyNet attestation); even that stays under
// 32 KiB in practice. Every other endpoint consumes JSON documents of a
// few short fields. The cap bounds what an unauthenticated caller can
// make the server buffer.
MaxBodySize = 64 << 10
// AttestRetries is the budget one assertion has before it
// dead-letters. Generous, because a dead-lettered assertion means
// history has a hole and the queue's alerting should say so loudly
// rather than soon.
AttestRetries = 8
// AttestTimeout bounds one attempt at asserting.
AttestTimeout = 15 * time.Second
// RetentionInterval is how often expired artifacts are swept. The
// records it reaps are already refused on read once expired, so the
// sweep reclaims space rather than enforcing the expiry, and an hour
// keeps the write amplification negligible.
RetentionInterval = time.Hour
// AvatarSweepInterval is how often expired picture upload grants are
// reaped. Grants live for the avatar engine's confirmation window (an
// hour by default), so a quarter-hourly sweep keeps the orphan
// backlog to a handful of objects.
AvatarSweepInterval = 15 * time.Minute
// VaultReloadInterval is how often the signing key file is re-read.
// The file is a Kubernetes Secret mount that the kubelet itself only
// refreshes on the order of a minute, so polling faster would not
// speed up a rotation; an unchanged file costs one read and a hash.
VaultReloadInterval = time.Minute
// HookConcurrency is how many deliveries one replica attempts at
// once. Deliveries are IO on someone else's server, so the bound is
// about how much of this service's own outbound capacity webhooks
// may take rather than about CPU.
HookConcurrency = 8
)
// Service is the fully assembled IAM provider. Create instances with [New],
// serve them with [Service.Run], or embed [Service.Handler] into a custom
// server.
type Service struct {
cfg config.Config
rt *boot.Runtime
logger *log.Logger
// vault holds the token signing keys, reloaded from their source
// rather than read once, so a rotation is an edit every replica
// converges on; see [Service.watcher].
vault vault.Watcher
store Container
retention *postgres.Retention // nil on the in-memory driver
users *user.Manager
authn *authn.Manager // absent without a configured sealing key
avatars *avatar.Manager // absent without picture storage
clients *client.Manager
teams *team.Manager
login *login.Manager
post *post.Mailer // absent without messaging provider
text text.Sender // absent without messaging provider
broker *event.Broker
// The social providers are kept beyond assembly because their signing
// key caches must be dispatched to the scheduler; see
// [Service.background]. A provider whose keys are never fetched
// refuses every ID token with a key-not-found error.
google *google.Provider
apple *apple.Provider
}
// New assembles the service from its configuration. It returns an error for
// unusable external inputs (an unreadable vault file, an unreachable
// database configuration); inconsistent wiring inside the configuration
// surfaces as panics from the underlying constructors, since those are
// deployment errors to fail fast on.
//
// The version identifies this build to the providers the service calls
// out to, riding in the User-Agent header of every outbound request;
// resolve it via [cli.Version].
//
// [cli.Version]: github.com/deep-rent/nexus/sys/cli#Version
func New(
ctx context.Context,
cfg config.Config,
version string,
) (*Service, error) {
// The delivery queue is durable, so webhooks exist only alongside a
// database; the section is withheld rather than disabled, which is
// what keeps the runtime from building an engine with nothing to
// run it on.
var (
database *boot.Database
sender *boot.Sender
)
if cfg.Database.Enabled() {
database = &cfg.Database
sender = &cfg.Hooks
}
rt, err := boot.New(ctx, boot.Spec{
Name: "iam",
Version: version,
Contact: cfg.Issuer,
Core: cfg.Core,
Database: database,
Sender: sender,
},
boot.WithMaxBody(MaxBodySize),
boot.WithWorkers(HookConcurrency),
// The browser clients of this service carry a session cookie,
// so cross-origin requests must be allowed to send it. No
// sibling service needs the wider grant: their authentication
// travels in the Authorization header, which cross-origin
// JavaScript attaches itself.
boot.WithCredentialedCORS(),
// Credentials pass through this service that pass through no
// other, and every one of them must stay out of the log.
boot.WithRedact("set-cookie", "password", "secret", "code"),
)
if err != nil {
return nil, err
}
s := &Service{cfg: cfg, rt: rt, logger: rt.Logger()}
s.broker = event.NewBroker(event.WithLogger(s.logger.Child("events")))
// Lifecycle events are asserted to the audit trail through the
// queue, so an unwell trail costs retries rather than records. The
// handler runs with the full budget: a dead-lettered assertion is
// an operator's signal that history has a hole.
if cfg.Attest.Enabled() {
rt.Handle(witness.Kind,
witness.Handler(attest.Open(cfg.Attest, rt.Client())),
queue.HandlerTimeout(AttestTimeout),
queue.HandlerRetries(AttestRetries),
)
} else {
s.logger.Info(ctx,
"No audit trail configured; lifecycle events go unattested",
)
}
// The signing keys are the one outbound integration that stays off
// the shared client: an OKMS vault authenticates over mutual TLS and
// must not share transport state with anything else.
httpClient := rt.Client()
v, err := s.watcher(ctx, cfg.Vault)
if err != nil {
return nil, fmt.Errorf("failed to load signing keys: %w", err)
}
s.vault = v
rt.Tick("vault", s.vault)
if rt.Pool() == nil {
// The mock driver backs local development and the service's own
// end-to-end tests. It is never a production configuration, so an
// empty DATABASE_URL is worth shouting about.
s.store = mock.New()
s.logger.Warn(
ctx,
"No DATABASE_URL: running on the mock driver, "+
"which keeps all state in memory and loses it on restart",
)
} else {
store := postgres.New(
rt.Pool(),
postgres.WithLogger(s.logger.Child("store")),
)
s.store = store
s.retention = postgres.NewRetention(store)
rt.Migrate(postgres.Migrator)
rt.Every("retention", RetentionInterval, s.retention)
}
s.users = user.New(
s.store.Users(),
user.WithObserver(event.Emitter(topic.Directory(s.broker))),
user.WithLogger(s.logger.Child("user")),
)
s.clients = client.New(
s.store.Clients(),
client.WithLogger(s.logger.Child("client")),
)
s.teams = team.New(
s.store.Teams(),
team.WithObserver(event.Emitter(topic.Teams(s.broker))),
)
if cfg.Bird.Enabled() && cfg.Mail.Enabled() {
s.post = post.New(
mail.NewSender(
cfg.Bird.AccessKey,
cfg.Bird.WorkspaceID,
cfg.Mail.ChannelID,
mail.WithClient(httpClient),
mail.WithLogger(s.logger.Child("mail")),
),
post.Config{
Templates: post.Templates{
VerifyEmail: cfg.Mail.TemplateVerify,
ResetPassword: cfg.Mail.TemplateReset,
LoginAlert: cfg.Mail.TemplateAlert,
OTP: cfg.Mail.TemplateOTP,
TeamInvite: cfg.Mail.TemplateInvite,
},
Languages: cfg.Mail.Languages,
VerifyURL: cfg.Mail.VerifyURL,
ResetURL: cfg.Mail.ResetURL,
InviteURL: cfg.Mail.InviteURL,
},
)
}
if cfg.Bird.Enabled() && cfg.Text.Enabled() {
s.text = text.NewSender(
cfg.Bird.AccessKey,
cfg.Bird.WorkspaceID,
cfg.Text.ChannelID,
text.WithClient(httpClient),
text.WithLogger(s.logger.Child("text")),
)
}
// The picture engine grants direct uploads for user avatars and team
// logos, verifying every object before it becomes visible. Without
// configured storage the endpoints simply do not mount.
if cfg.Avatars.Enabled() {
s.avatars = avatar.New(avatar.Config{
Store: s.store.AvatarUploads(),
Storage: s3.New(
cfg.Avatars.Bucket,
aws4.New(aws4.Credentials{
AccessKey: cfg.Avatars.AccessKey,
SecretKey: cfg.Avatars.SecretKey,
}, cfg.Avatars.Region),
s3.WithClient(httpClient),
),
PublicURL: cfg.Avatars.PublicURL,
Users: s.users.SetAvatar,
Teams: s.teams.SetLogo,
MaxSize: cfg.Avatars.MaxSize,
},
avatar.WithLogger(s.logger.Child("avatar")),
avatar.WithObserver(event.Emitter(topic.Avatars(s.broker))),
)
}
var limiter *throttle.Throttle
if !cfg.Throttle.Disabled {
limiter = throttle.New(throttle.Config{
Limit: rate.Limit(cfg.Throttle.Limit),
Burst: cfg.Throttle.Burst,
})
}
lim := limit.New(limiter, oauth.DefaultThrottlePenalty)
// The login core is the convergence point of every authentication
// method: the password endpoints, passkey logins, and external
// callbacks all establish their sessions through it, and the sibling
// APIs resolve those sessions back into users.
// The authenticator engine backs the factors a user carries. A shared
// secret cannot be stored as a digest, so it needs a sealing key;
// a deployment that configures none simply offers no authenticator
// rather than storing secrets in the clear.
if cfg.Authenticator.Enabled() {
ring, err := cfg.Authenticator.Keyring()
if err != nil {
return nil, fmt.Errorf("authenticator keyring: %w", err)
}
s.authn = authn.New(authn.Config{
Store: s.store.Enrollments(),
Codes: s.store.RecoveryCodes(),
Keyring: ring,
Issuer: cfg.Issuer,
}, authn.WithObserver(event.Emitter(topic.Accounts(s.broker))))
} else {
s.logger.Warn(
ctx,
"No authenticator sealing key: authenticator apps and "+
"recovery codes are unavailable",
)
}
s.login = login.NewManager(login.Config{
Users: s.users,
Sessions: s.store.Sessions(),
Planner: plan.New(plan.Config{
// The manager is built with the planner, so the planner
// reaches it lazily; see the plan package.
Steps: func() plan.Steps { return s.login },
Post: s.post,
Text: s.text,
TextTemplate: cfg.Text.Template,
TextLanguages: cfg.Text.Languages,
// A typed nil would satisfy the interface and then panic on
// use, so the seam is handed over only when it exists.
Authn: func() plan.Authenticators {
if s.authn == nil {
return nil
}
return s.authn
}(),
}),
Flows: s.store.Flows(),
Challenges: s.store.Challenges(),
Trust: s.store.Trust(),
Logger: s.logger.Child("login"),
}, login.WithObserver(event.Emitter(topic.Logins(s.broker))))
loginOpts := []login.ServerOption{login.WithLimiter(lim)}
if cfg.Captcha.Enabled() {
loginOpts = append(loginOpts, login.WithCaptcha(login.Captcha{
Verifier: turnstile.New(
cfg.Captcha.Secret,
turnstile.WithClient(httpClient),
turnstile.WithLogger(s.logger.Child("captcha")),
),
Action: cfg.Captcha.Action,
Hostname: cfg.Captcha.Hostname,
Required: cfg.Captcha.Required,
}))
}
logins := login.NewServer(s.login, loginOpts...)
// The verifier introspects first-party access tokens for the admin API
// and the passkey registration fallback.
verifier := jwt.NewVerifier[*auth.Claims](
v.Keys(),
jwt.WithIssuers(cfg.Issuer),
)
// The token machinery: grants, seams into the login core and the user
// directory, and the OIDC provider role.
srvOpts := []oauth.ServerOption{
oauth.WithGrant(grant.AuthCode()),
oauth.WithGrant(grant.ClientCredentials()),
oauth.WithGrant(grant.RefreshToken()),
oauth.WithObserver(event.Emitter(topic.Tokens(s.broker))),
}
if cfg.VerificationURI != "" {
srvOpts = append(srvOpts, oauth.WithGrant(grant.DeviceCode()))
}
// Passkeys: the relying party runs the ceremonies, the server mounts
// the WebAuthn endpoints, and the grant lets native apps exchange
// assertions for tokens.
var passkeys *passkey.Server
if cfg.Passkeys.Enabled() {
rp := passkey.New(
passkey.Config{
RPID: cfg.Passkeys.ID,
RPDisplayName: cfg.Passkeys.Name,
RPOrigins: cfg.Passkeys.Origins,
},
s.store.Ceremonies(),
s.store.Credentials(),
passkey.NewDirectory(s.users, s.store.Credentials()),
)
srvOpts = append(srvOpts, oauth.WithGrant(passkey.Grant(rp)))
passkeys = passkey.NewServer(passkey.ServerConfig{
RelyingParty: rp,
Login: s.login,
Users: s.users,
Credentials: s.store.Credentials(),
Introspect: func(
ctx context.Context,
token string,
) (uuid.UUID, error) {
claims, err := verifier.Verify([]byte(token))
if err != nil {
s.logger.Debug(
ctx,
"Token verification failed during "+
"WebAuthn registration",
log.Error(err),
)
return uuid.Nil(), nil
}
// Only delegated tokens name a resource owner; a
// client-credentials token cannot enroll passkeys.
return claims.UserID(), nil
},
Limiter: lim,
Observer: event.Emitter(topic.Passkeys(s.broker)),
Logger: s.logger.Child("passkey"),
})
}
// The picture claim renders avatar keys into public URLs; resolver
// and schema share the options so discovery matches issuance.
var profileOpts []profile.Option
if s.avatars != nil {
profileOpts = append(
profileOpts, profile.WithPicture(s.avatars.URL),
)
}
oauthSrv := oauth.NewServer(oauth.ServerConfig{
Vault: v,
Clients: s.clients,
Tokens: oauth.TokenStores{
AuthCodes: s.store.AuthCodes(),
RefreshTokens: s.store.RefreshTokens(),
DeviceCodes: s.store.DeviceCodes(),
},
Sessions: func(e *router.Exchange) (oauth.Owner, error) {
usr, err := s.login.Resolve(e)
if err != nil || usr == nil {
return nil, err
}
return usr, nil
},
Owners: func(
ctx context.Context,
id uuid.UUID,
) (oauth.Owner, error) {
usr, err := s.users.GetUser(ctx, id)
if err != nil || usr == nil {
return nil, err
}
return usr, nil
},
Profiles: profile.Resolver(s.store.Users(), profileOpts...),
ProfileClaims: profile.Schema(profileOpts...),
Memberships: s.store.Teams().ListTeamIDs,
Issuer: cfg.Issuer,
VerificationURI: cfg.VerificationURI,
Throttle: limiter,
Logger: s.logger.Child("oauth"),
}, srvOpts...)
// The identity provider broker turns verified external identities into
// local sessions through the login core.
providers := make(map[string]idp.Provider)
if cfg.Google.Enabled() {
s.google = google.New(google.Config{
ClientID: cfg.Google.ClientID,
ClientSecret: cfg.Google.ClientSecret,
RedirectURI: cfg.Google.RedirectURI,
Client: httpClient,
})
providers["google"] = s.google
// A provider whose keys are never fetched refuses every ID
// token with a key-not-found error, so the refresh is dispatched
// and the first fetch waited for.
rt.Tick("google.keys", s.google.Keys())
rt.Await("Google", s.google.Keys().Ready())
}
if cfg.Apple.Enabled() {
key, err := os.ReadFile(cfg.Apple.KeyFile)
if err != nil {
return nil, fmt.Errorf("failed to read Apple key: %w", err)
}
s.apple = apple.New(apple.Config{
ClientID: cfg.Apple.ClientID,
TeamID: cfg.Apple.TeamID,
KeyID: cfg.Apple.KeyID,
PrivateKey: key,
RedirectURI: cfg.Apple.RedirectURI,
Client: httpClient,
})
providers["apple"] = s.apple
rt.Tick("apple.keys", s.apple.Keys())
rt.Await("Apple", s.apple.Keys().Ready())
}
var broker *idp.Server
if len(providers) > 0 {
broker = idp.NewServer(idp.ServerConfig{
Providers: providers,
Login: s.login,
Users: s.users,
TerminalURI: cfg.LoginTerminalURI,
RedirectURI: cfg.LoginRedirectURI,
Observer: event.Emitter(topic.Federations(s.broker)),
Logger: s.logger.Child("idp"),
})
}
accountOpts := []account.Option{
account.WithRevoker(s.store),
account.WithAuthenticator(s.authn),
account.WithPasskeys(s.store.Credentials()),
account.WithAvatars(s.avatars),
}
if s.post != nil {
accountOpts = append(
accountOpts,
account.WithMail(s.store.Tickets(), s.post),
)
}
accounts := account.New(account.Config{
Login: s.login,
Users: s.users,
Throttle: limiter,
Logger: s.logger.Child("account"),
}, accountOpts...)
adminOpts := []admin.Option{
admin.WithPasskeys(s.store.Credentials()),
admin.WithTeams(s.teams),
}
if s.post != nil {
adminOpts = append(
adminOpts,
admin.WithMail(s.store.Tickets(), s.post),
)
}
if h := rt.Hooks(); h != nil {
adminOpts = append(adminOpts, admin.WithHooks(h))
}
admins := admin.New(admin.Config{
Login: s.login,
Users: s.users,
Clients: s.clients,
Verifier: verifier,
Revoker: s.store,
Logger: s.logger.Child("admin"),
}, adminOpts...)
// The directory answers the identity questions sibling services ask
// — who is this, how do we reach them, who holds this role — under
// a permission far narrower than user administration.
people := directory.New(directory.Config{
Users: s.users,
Teams: s.teams.Store(),
Verifier: verifier,
})
if s.avatars != nil {
rt.Every(
"avatars", AvatarSweepInterval,
schedule.TaskFn(s.avatars.Sweep),
)
}
rt.Once("provision", s.provision)
rt.Background("consumers", s.consumers)
r := rt.Router()
// The OAuth 2.0 and OpenID Connect endpoints stay unversioned too.
// Their shape is fixed by the specifications, clients locate them
// through the discovery document rather than by convention, and the
// two well-known documents are required to sit at the issuer root.
oauthSrv.Mount(r)
// Everything below is this service's own contract.
logins.Mount(r)
if passkeys != nil {
passkeys.Mount(r)
}
if broker != nil {
broker.Mount(r)
}
accounts.Mount(r)
admins.Mount(r)
people.Mount(r)
// The team API hinges on mailed invitation links, so it mounts only
// alongside a configured mailer.
if s.post != nil {
// An invitation is mailed; a push additionally reaches an
// invitee who already has an account and a phone.
var pusher invite.Pusher
if cfg.Notify.Enabled() {
pusher = push.Open(cfg.Notify.Config, rt.Client())
}
rosters := invite.New(invite.Config{
Login: s.login,
Users: s.users,
Teams: s.teams,
Post: s.post,
Pusher: pusher,
Category: cfg.Notify.Category,
Throttle: limiter,
Logger: s.logger.Child("invite"),
}, invite.WithLogos(s.avatars))
rosters.Mount(r)
}
return s, nil
}
// Handler returns the assembled HTTP handler, for embedding the service
// into a custom server.
func (s *Service) Handler() http.Handler { return s.rt.Handler() }
// Run serves the IAM provider until the context is canceled or a
// termination signal arrives.
func (s *Service) Run(ctx context.Context) error { return s.rt.Run(ctx) }
// watcher builds the signing key vault over whichever source the
// configuration names. Either way the result is a watcher rather than a
// one-off load, so a key rotation is an edit to the backing source that
// every replica converges on within [VaultReloadInterval] — no restart
// involved.
func (s *Service) watcher(
ctx context.Context,
cfg config.Vault,
) (vault.Watcher, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
opts := []vault.WatchOption{
vault.WithInterval(VaultReloadInterval),
vault.WithLogger(s.logger.Child("vault")),
}
if !cfg.OKMS.Enabled() {
// The file is a Kubernetes Secret mount, so the reload interval
// tracks the kubelet's own refresh cadence.
return file.Watch(cfg.File, rotor.Sequential, opts...)
}
client, err := okms.NewClient(cfg.OKMS.CertFile, cfg.OKMS.KeyFile)
if err != nil {
return nil, err
}
backend := okms.NewBackend(okms.Config{
Endpoint: cfg.OKMS.Endpoint,
Domain: cfg.OKMS.Domain,
Client: client,
Logger: s.logger.Child("vault"),
})
// Signing now costs a KMS round trip on the token path, which is worth
// saying out loud once: it is a new runtime dependency of issuance,
// though verification stays local.
s.logger.Info(
ctx,
"Signing keys are held in OVHcloud KMS",
log.String("endpoint", cfg.OKMS.Endpoint),
log.String("domain", cfg.OKMS.Domain),
)
return okms.Watch(ctx, backend, rotor.Sequential, opts...)
}
// provision idempotently creates the configured admin machine client,
// so a fresh deployment has an operator to act through before anybody
// has signed in. It runs once the schema is in place and before the
// service serves.
func (s *Service) provision(ctx context.Context) error {
cfg := s.cfg.Admin
if !cfg.Enabled() {
return nil
}
existing, err := s.store.Clients().Get(ctx, cfg.ClientID)
if err != nil {
return fmt.Errorf("failed to look up bootstrap client: %w", err)
}
if existing != nil {
return nil
}
now := clock.System.Now()
if err := s.store.Clients().Create(ctx, &client.Client{
ID: cfg.ClientID,
Name: "admin",
SecretDigest: digest.DefaultHasher.String(cfg.ClientSecret),
Grants: []oauth.GrantType{oauth.GrantTypeClientCredentials},
Scopes: slices.Concat(admin.Permissions, directory.Permissions),
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
return fmt.Errorf("failed to create bootstrap client: %w", err)
}
s.logger.Info(
ctx,
"Provisioned bootstrap admin client",
log.UUID("client_id", cfg.ClientID),
)
return nil
}
// consumers attaches the consumers of what the modules publish, and
// holds the event broker open for the serving lifetime.
//
// They attach here rather than in New so that their handlers hang off
// that lifetime: cancelling the context aborts a lookup or a notice in
// flight, instead of letting it outlive the service. The broker closes
// last, after every producer above it has stopped.
func (s *Service) consumers(ctx context.Context) error {
defer s.broker.Close()
audit.New(ctx, audit.Config{
Logger: s.logger.Child("audit"),
}).Attach(s.broker)
// The witness carries lifecycle events to the audit trail, through
// the queue so the assertion gets a full retry budget.
if s.cfg.Attest.Enabled() {
witness.New(ctx, witness.Config{
Jobs: s.rt.Jobs(),
Logger: s.logger.Child("witness"),
}).Attach(s.broker)
}
// Notifications need a mailer, so a deployment without a messaging
// provider audits and counts but tells nobody.
if s.post != nil {
alert.New(ctx, alert.Config{
Post: s.post,
Users: s.store.Users(),
Teams: s.store.Teams(),
Logger: s.logger.Child("alert"),
}).Attach(s.broker)
}
// Webhooks carry the same events out to the services that subscribe
// to them.
if h := s.rt.Hooks(); h != nil {
relay.New(ctx, h, relay.Config{
Logger: s.logger.Child("relay"),
}).Attach(s.broker)
}
app.Ready(ctx)
<-ctx.Done()
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package session
import (
"github.com/deep-rent/nexus/sec/digest"
"github.com/deep-rent/nexus/sec/nonce"
"github.com/deep-rent/nexus/std/clock"
)
// Option configures a [Manager].
type Option func(*Manager)
// WithHasher sets the hasher that fingerprints session keys before they
// reach the store. A nil hasher is ignored. Defaults to
// [digest.DefaultHasher].
func WithHasher(h *digest.Hasher) Option {
return func(m *Manager) {
if h != nil {
m.secrets.Hasher = h
}
}
}
// WithGenerator overrides the source of session keys. A nil generator is
// ignored. Defaults to [nonce.DefaultGenerator] (256-bit keys).
func WithGenerator(g *nonce.Generator) Option {
return func(m *Manager) {
if g != nil {
m.secrets.Source = g
}
}
}
// WithClock overrides the time source, primarily for testing. A nil function
// is ignored. Defaults to [clock.System].
func WithClock(now clock.Clock) Option {
return func(m *Manager) {
if now != nil {
m.now = now
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package session
import (
"context"
"slices"
"time"
"uuid"
"github.com/deep-rent/nexus/eco/iam/artifact"
"github.com/deep-rent/nexus/std/clock"
)
// Record is the persisted state of a login session. The key is stored only
// as its digest, never in the clear.
type Record struct {
// ID is the digest of the session key and the storage key. The plaintext
// session key never reaches the store.
ID string `json:"id"`
// Owner identifies the authenticated user behind the session. It is
// returned verbatim on successful resolution.
Owner uuid.UUID `json:"owner"`
// ExpiresAt is when the session lapses. Every session carries one; a
// record without it has already expired.
ExpiresAt time.Time `json:"expires_at,omitzero"`
// CreatedAt is when the session was established.
CreatedAt time.Time `json:"created_at,omitzero"`
// Label is an optional human-facing hint for a session list, such as a
// summary of the user agent. It never carries a secret.
Label string `json:"label,omitzero"`
}
// Store persists sessions keyed by [Record.ID]. See [artifact.Store] for the
// storage contract.
type Store interface {
artifact.Store[string, Record]
// ListForOwner returns every session held by the given owner, including
// expired ones not yet evicted; the [Manager] filters those out. An
// owner without sessions yields an empty slice.
ListForOwner(ctx context.Context, owner uuid.UUID) ([]Record, error)
// DeleteForOwner removes every session held by the given owner. It backs
// a "sign out everywhere" or a credential-change revocation, and is a
// no-op when the owner holds no sessions.
DeleteForOwner(ctx context.Context, owner uuid.UUID) error
}
// Manager runs the lifecycle of login sessions — establishing, resolving,
// and destroying them — over a [Store]. It is safe for concurrent use if its
// [Store] is.
type Manager struct {
store Store
secrets artifact.Digester
now clock.Clock
}
// New creates a [Manager] backed by the given [Store]. It panics if store is
// nil, since that is a startup configuration error.
func New(store Store, opts ...Option) *Manager {
if store == nil {
panic("store is required")
}
m := &Manager{
store: store,
now: clock.System,
}
for _, opt := range opts {
opt(m)
}
return m
}
// Establish mints a session key for the owner, persists its digest, and
// returns the raw key for the caller to set on the client. The label is an
// optional human-facing hint stored alongside the record.
//
// The session expires after the given lifetime. A nonpositive lifetime
// yields a session that has already expired and so resolves to nobody:
// sessions are bearer credentials, and one that never lapses is not
// something a caller should be able to ask for by passing a zero value.
func (m *Manager) Establish(
ctx context.Context,
owner uuid.UUID,
label string,
lifetime time.Duration,
) (key string, err error) {
key, id, err := m.secrets.Mint(ctx)
if err != nil {
return "", err
}
now := m.now()
if err := m.store.Create(ctx, Record{
ID: id,
Owner: owner,
ExpiresAt: now.Add(lifetime),
CreatedAt: now,
Label: label,
}); err != nil {
return "", err
}
return key, nil
}
// ID returns the storage ID (the key digest) the raw session key maps to.
// It lets management surfaces correlate a caller's own session with a
// [Manager.List] result without raw keys ever circulating.
func (m *Manager) ID(key string) string {
return m.secrets.Key(key)
}
// Resolve returns the owner behind the raw session key. ok is false when the
// key is empty, unknown, or expired, so a wrong or stale key simply reads as
// "not logged in". The error is reserved for storage failures.
func (m *Manager) Resolve(
ctx context.Context,
key string,
) (owner uuid.UUID, ok bool, err error) {
if key == "" {
return uuid.Nil(), false, nil
}
r, found, err := m.store.Get(ctx, m.secrets.Key(key))
if err != nil {
return uuid.Nil(), false, err
}
if !found ||
m.now().After(r.ExpiresAt) {
return uuid.Nil(), false, nil
}
return r.Owner, true, nil
}
// Destroy removes the session behind the raw key, reporting whether one
// existed. It is a no-op, returning (false, nil), for an empty or unknown
// key.
func (m *Manager) Destroy(ctx context.Context, key string) (bool, error) {
if key == "" {
return false, nil
}
return m.store.Delete(ctx, m.secrets.Key(key))
}
// DestroyByID removes the session with the given record ID (the key digest),
// reporting whether one existed. It backs management APIs that revoke a
// session picked from a [Manager.List] result, where only digests circulate.
func (m *Manager) DestroyByID(ctx context.Context, id string) (bool, error) {
if id == "" {
return false, nil
}
return m.store.Delete(ctx, id)
}
// DestroyAll removes every session held by the owner. Call it when the
// owner's credentials change — for example on a password reset — so that no
// stolen session outlives the credential it was established with.
func (m *Manager) DestroyAll(
ctx context.Context,
owner uuid.UUID,
) error {
return m.store.DeleteForOwner(ctx, owner)
}
// List returns the owner's live sessions, most recent first. Expired records
// awaiting eviction are filtered out. The records carry only digests, never
// raw session keys.
func (m *Manager) List(
ctx context.Context,
owner uuid.UUID,
) ([]Record, error) {
records, err := m.store.ListForOwner(ctx, owner)
if err != nil {
return nil, err
}
now := m.now()
live := records[:0]
for _, r := range records {
if !now.After(r.ExpiresAt) {
live = append(live, r)
}
}
slices.SortFunc(live, func(a, b Record) int {
return b.CreatedAt.Compare(a.CreatedAt)
})
return live, nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package team
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"time"
"uuid"
"github.com/deep-rent/nexus/eco/iam/artifact"
"github.com/deep-rent/nexus/std/clock"
)
// Defaults applied by [New] where no option overrides them.
const (
// DefaultInvitationLifetime is how long a pending invitation stays
// acceptable.
DefaultInvitationLifetime = 7 * 24 * time.Hour // 1 week
// DefaultMinCooldown is the wait imposed by the first rejection. Every
// further rejection doubles it, up to [DefaultMaxCooldown].
DefaultMinCooldown = 24 * time.Hour // 1 day
// DefaultMaxCooldown caps the growing re-invitation cooldown.
DefaultMaxCooldown = 30 * 24 * time.Hour // 30 days
)
// Domain refusals returned by the [Manager]. Surfaces map them onto their
// protocol; storage failures travel as ordinary errors alongside.
var (
// ErrLimit refuses founding a team beyond the founder's team limit.
ErrLimit = errors.New("team limit reached")
// ErrMemberLimit refuses a self-service join — accepting an
// invitation, or founding — that would put the user in more teams
// than their membership limit allows.
ErrMemberLimit = errors.New("membership limit reached")
// ErrNotMember refuses an operation on someone who is not a member of
// the team.
ErrNotMember = errors.New("no such member")
// ErrOwner refuses removing an owner from their team. Ownership is
// surrendered by stepping down (see [Manager.Demote]) or by deleting
// the team, not by eviction.
ErrOwner = errors.New("owners cannot be removed")
// ErrLastOwner refuses an action that would leave a team without any
// owner: the last owner can neither leave nor step down. They appoint
// a successor first, or dissolve the team.
ErrLastOwner = errors.New("a team cannot be left without an owner")
// ErrPending refuses re-inviting an address with a live pending
// invitation; resend it instead.
ErrPending = errors.New("invitation already pending")
// ErrSeatLimit refuses an invitation that would grow the team past
// its founder's seat limit. Seats count members and pending
// invitations alike, so withdrawing a stale invitation frees one.
ErrSeatLimit = errors.New("seat limit reached")
// ErrState refuses resending an invitation that is not pending.
ErrState = errors.New("invitation is not pending")
)
// CooldownError refuses an invitation during the rejection cooldown,
// carrying when the address becomes invitable again.
type CooldownError struct {
// Until is when the cooldown lapses.
Until time.Time
}
// Error implements the error interface.
func (e *CooldownError) Error() string {
return "address is in invitation cooldown until " +
e.Until.UTC().Format(time.RFC3339)
}
// Manager drives team lifecycle, membership, and invitations over a
// [Store], holding the invariants: the founding limit, the at-least-one-
// owner rule, and the invitation state machine with its growing rejection
// cooldown. It is safe for concurrent use if its [Store] is.
type Manager struct {
store Store
secrets artifact.Digester
now clock.Clock
observe Observer
lifetime time.Duration
cooldown time.Duration
maximum time.Duration
}
// publish hands an event to the observer, if any.
func (m *Manager) publish(e Event) {
if m.observe != nil {
m.observe(e)
}
}
// New creates a [Manager] backed by the given [Store]. It panics if store
// is nil, since that is a startup configuration error.
func New(store Store, opts ...Option) *Manager {
if store == nil {
panic("store is required")
}
m := &Manager{
store: store,
now: clock.System,
lifetime: DefaultInvitationLifetime,
cooldown: DefaultMinCooldown,
maximum: DefaultMaxCooldown,
}
for _, opt := range opts {
opt(m)
}
return m
}
// Store exposes the underlying persistence, for surfaces that read teams
// without invariant logic.
func (m *Manager) Store() Store { return m.store }
// Limits carries a user's per-user team quotas into the self-service
// methods; the caller reads them off the account record.
type Limits struct {
// Teams is how many teams the user may found (see
// [user.User.TeamLimit]). Zero forbids founding: it is an
// administrator-granted privilege.
//
// [user.User.TeamLimit]:
// github.com/deep-rent/nexus/eco/iam/user#User.TeamLimit
Teams int
// Memberships is how many teams the user may belong to (see
// [user.User.MembershipLimit]). Zero leaves membership uncapped.
//
// [user.User.MembershipLimit]:
// github.com/deep-rent/nexus/eco/iam/user#User.MembershipLimit
Memberships int
}
// SetLogo atomically exchanges the team's stored logo key, returning the
// key it displaced and whether the team exists. The swap is silent — no
// team event is published — because the avatar engine publishes its own
// richer event for the same change, and two records of one change would
// double every audit trail.
func (m *Manager) SetLogo(
ctx context.Context,
id uuid.UUID,
key string,
) (prior string, found bool, err error) {
return m.store.SetLogo(ctx, id, key)
}
// Found creates a team with the founder as its first owner. Founding is
// refused with [ErrLimit] once the founder's live teams exhaust their
// team limit, and with [ErrMemberLimit] where the membership the founder
// takes on would exceed their membership limit.
func (m *Manager) Found(
ctx context.Context,
name string,
founder uuid.UUID,
limits Limits,
) (*Team, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, errors.New("team name is required")
}
founded, err := m.store.CountFounded(ctx, founder)
if err != nil {
return nil, err
}
if founded >= limits.Teams {
return nil, fmt.Errorf(
"%w: %d of %d used", ErrLimit, founded, limits.Teams,
)
}
if err := m.guardMemberLimit(
ctx, founder, limits.Memberships, uuid.Nil(),
); err != nil {
return nil, err
}
now := m.now()
t := &Team{
ID: uuid.NewV7(),
Name: name,
Founder: founder,
CreatedAt: now,
UpdatedAt: now,
}
if err := m.store.CreateTeam(ctx, t); err != nil {
return nil, err
}
if err := m.store.AddMember(ctx, Membership{
TeamID: t.ID,
UserID: founder,
Owner: true,
CreatedAt: now,
}); err != nil {
// Without its founding owner the team is unreachable garbage;
// collect it rather than strand it.
_, _ = m.store.DeleteTeam(ctx, t.ID)
return nil, err
}
m.publish(Event{
Kind: EventFounded,
TeamID: t.ID,
UserID: founder,
Actor: founder,
})
return t, nil
}
// Install provisions a team administratively: the given user becomes its
// first owner, no founder is recorded, and no founding limit applies, so
// the team burdens nobody's quota. The actor attributes the event; the
// zero UUID marks a machine client.
func (m *Manager) Install(
ctx context.Context,
name string,
owner, actor uuid.UUID,
) (*Team, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, errors.New("team name is required")
}
now := m.now()
t := &Team{
ID: uuid.NewV7(),
Name: name,
CreatedAt: now,
UpdatedAt: now,
}
if err := m.store.CreateTeam(ctx, t); err != nil {
return nil, err
}
if err := m.store.AddMember(ctx, Membership{
TeamID: t.ID,
UserID: owner,
Owner: true,
CreatedAt: now,
}); err != nil {
_, _ = m.store.DeleteTeam(ctx, t.ID)
return nil, err
}
m.publish(Event{
Kind: EventFounded,
TeamID: t.ID,
UserID: owner,
Actor: actor,
})
return t, nil
}
// Dissolve deletes a team with its memberships and standing invitations,
// reporting whether this call removed it. The actor attributes the event.
func (m *Manager) Dissolve(
ctx context.Context,
teamID, actor uuid.UUID,
) (bool, error) {
deleted, err := m.store.DeleteTeam(ctx, teamID)
if err != nil || !deleted {
return deleted, err
}
m.publish(Event{
Kind: EventDissolved,
TeamID: teamID,
Actor: actor,
})
return true, nil
}
// RemoveMember evicts a member from the team on behalf of the acting
// owner. Owners cannot be evicted ([ErrOwner]): an owner exits by
// stepping down first (see [Manager.Demote]) or through [Manager.Leave].
// Removing a non-member reports [ErrNotMember].
func (m *Manager) RemoveMember(
ctx context.Context,
teamID, userID, actor uuid.UUID,
) error {
mem, err := m.store.GetMembership(ctx, teamID, userID)
if err != nil {
return err
}
if mem == nil {
return ErrNotMember
}
if mem.Owner {
return ErrOwner
}
deleted, err := m.store.RemoveMember(ctx, teamID, userID)
if err != nil || !deleted {
return err
}
m.publish(Event{
Kind: EventLeft,
TeamID: teamID,
UserID: userID,
Actor: actor,
})
return nil
}
// Leave exits the team voluntarily. The last owner cannot leave
// ([ErrLastOwner]): they appoint a successor first, or dissolve the team.
// Leaving a team one does not belong to reports [ErrNotMember].
func (m *Manager) Leave(
ctx context.Context,
teamID, userID uuid.UUID,
) error {
mem, err := m.store.GetMembership(ctx, teamID, userID)
if err != nil {
return err
}
if mem == nil {
return ErrNotMember
}
if mem.Owner {
if err := m.guardLastOwner(ctx, teamID); err != nil {
return err
}
}
deleted, err := m.store.RemoveMember(ctx, teamID, userID)
if err != nil || !deleted {
return err
}
m.publish(Event{
Kind: EventLeft,
TeamID: teamID,
UserID: userID,
Actor: userID,
})
return nil
}
// Promote appoints an existing member as an owner on behalf of the acting
// owner. Promoting a non-member reports [ErrNotMember]; promoting an
// owner is a no-op that publishes nothing.
func (m *Manager) Promote(
ctx context.Context,
teamID, userID, actor uuid.UUID,
) error {
mem, err := m.store.GetMembership(ctx, teamID, userID)
if err != nil {
return err
}
if mem == nil {
return ErrNotMember
}
if mem.Owner {
return nil
}
if _, err := m.store.SetOwner(ctx, teamID, userID, true); err != nil {
return err
}
m.publish(Event{
Kind: EventPromoted,
TeamID: teamID,
UserID: userID,
Actor: actor,
})
return nil
}
// Demote reduces an owner to a plain member, on their own initiative or a
// fellow owner's. The last owner cannot step down ([ErrLastOwner]).
// Demoting a non-member reports [ErrNotMember]; demoting a plain member
// is a no-op that publishes nothing.
func (m *Manager) Demote(
ctx context.Context,
teamID, userID, actor uuid.UUID,
) error {
mem, err := m.store.GetMembership(ctx, teamID, userID)
if err != nil {
return err
}
if mem == nil {
return ErrNotMember
}
if !mem.Owner {
return nil
}
if err := m.guardLastOwner(ctx, teamID); err != nil {
return err
}
if _, err := m.store.SetOwner(ctx, teamID, userID, false); err != nil {
return err
}
m.publish(Event{
Kind: EventDemoted,
TeamID: teamID,
UserID: userID,
Actor: actor,
})
return nil
}
// SetRole installs a user on the team or sets an existing member's role,
// without the invariants of the self-service methods: administrators may
// demote or install freely, since they can always re-appoint. It reports
// what it did through the published event — joined, promoted, demoted, or
// nothing for a role that already held.
func (m *Manager) SetRole(
ctx context.Context,
teamID, userID uuid.UUID,
owner bool,
actor uuid.UUID,
) error {
mem, err := m.store.GetMembership(ctx, teamID, userID)
if err != nil {
return err
}
if mem == nil {
if err := m.store.AddMember(ctx, Membership{
TeamID: teamID,
UserID: userID,
Owner: owner,
CreatedAt: m.now(),
}); err != nil {
return err
}
m.publish(Event{
Kind: EventJoined,
TeamID: teamID,
UserID: userID,
Actor: actor,
})
return nil
}
if mem.Owner == owner {
return nil
}
if _, err := m.store.SetOwner(ctx, teamID, userID, owner); err != nil {
return err
}
kind := EventPromoted
if !owner {
kind = EventDemoted
}
m.publish(Event{
Kind: kind,
TeamID: teamID,
UserID: userID,
Actor: actor,
})
return nil
}
// Evict removes any membership, owner or not, without the invariants of
// the self-service methods. Evicting a non-member reports [ErrNotMember].
func (m *Manager) Evict(
ctx context.Context,
teamID, userID, actor uuid.UUID,
) error {
deleted, err := m.store.RemoveMember(ctx, teamID, userID)
if err != nil {
return err
}
if !deleted {
return ErrNotMember
}
m.publish(Event{
Kind: EventLeft,
TeamID: teamID,
UserID: userID,
Actor: actor,
})
return nil
}
// guardMemberLimit refuses adding the user to a further team once their
// memberships exhaust the limit; zero and negative limits leave
// membership uncapped. A membership in exempt does not count as further:
// re-joining a team one already belongs to must stay idempotent (the zero
// UUID exempts nothing). The count rides on ListTeamIDs, which is kept
// cheap for the token path and so is more than cheap enough here.
func (m *Manager) guardMemberLimit(
ctx context.Context,
userID uuid.UUID,
limit int,
exempt uuid.UUID,
) error {
if limit <= 0 {
return nil
}
ids, err := m.store.ListTeamIDs(ctx, userID)
if err != nil {
return err
}
if len(ids) < limit {
return nil
}
if slices.Contains(ids, exempt) {
return nil
}
return fmt.Errorf(
"%w: %d of %d used", ErrMemberLimit, len(ids), limit,
)
}
// guardSeatLimit refuses an invitation once the team's occupied seats —
// members plus pending invitations — exhaust the limit; zero and
// negative limits leave size uncapped.
func (m *Manager) guardSeatLimit(
ctx context.Context,
teamID uuid.UUID,
limit int,
) error {
if limit <= 0 {
return nil
}
seats, err := m.store.CountSeats(ctx, teamID)
if err != nil {
return err
}
if seats >= limit {
return fmt.Errorf(
"%w: %d of %d used", ErrSeatLimit, seats, limit,
)
}
return nil
}
// guardLastOwner refuses the action when the team has a single owner
// left.
func (m *Manager) guardLastOwner(
ctx context.Context,
teamID uuid.UUID,
) error {
owners, err := m.store.CountOwners(ctx, teamID)
if err != nil {
return err
}
if owners <= 1 {
return ErrLastOwner
}
return nil
}
// Invite extends (or, after a lapsed cooldown, renews) the team's
// invitation for the address and returns the raw token for the caller to
// deliver by mail — the only place it ever exists in the clear.
//
// A live pending invitation is refused with [ErrPending]; resend it
// instead. A rejected address is refused with a [*CooldownError] until its
// cooldown lapses, and re-invited with its rejection count intact after,
// so repeated rejections keep raising the bar.
//
// seats is the seat limit of the team's FOUNDER (see
// [user.User.SeatLimit]); zero or negative leaves size uncapped. An
// invitation that would grow the team past it — counting members and
// pending invitations both, so a seat cannot be promised twice — is
// refused with [ErrSeatLimit]. Renewing an expired pending invitation
// is exempt: its seat is already held. Acceptance never re-checks,
// because the invitation is the reservation.
//
// [user.User.SeatLimit]:
// github.com/deep-rent/nexus/eco/iam/user#User.SeatLimit
func (m *Manager) Invite(
ctx context.Context,
teamID uuid.UUID,
email string,
seats int,
) (token string, inv *Invitation, err error) {
email = normalize(email)
if email == "" {
return "", nil, errors.New("email is required")
}
now := m.now()
prior, err := m.store.GetInvitationByEmail(ctx, teamID, email)
if err != nil {
return "", nil, err
}
if prior != nil {
switch prior.Status {
case StatusPending:
if !now.After(prior.ExpiresAt) {
return "", nil, ErrPending
}
// An expired pending invitation renews in place, on the
// seat it already holds.
case StatusRejected:
if now.Before(prior.CooldownUntil) {
return "", nil, &CooldownError{Until: prior.CooldownUntil}
}
// A rejection freed the seat, so renewal claims it anew.
if err := m.guardSeatLimit(ctx, teamID, seats); err != nil {
return "", nil, err
}
}
token, err := m.rotate(ctx, prior, now)
if err != nil {
return "", nil, err
}
return token, prior, nil
}
if err := m.guardSeatLimit(ctx, teamID, seats); err != nil {
return "", nil, err
}
token, digest, err := m.secrets.Mint(ctx)
if err != nil {
return "", nil, err
}
inv = &Invitation{
ID: uuid.NewV7(),
TeamID: teamID,
Email: email,
Token: digest,
Status: StatusPending,
ExpiresAt: now.Add(m.lifetime),
CreatedAt: now,
UpdatedAt: now,
}
if err := m.store.CreateInvitation(ctx, inv); err != nil {
return "", nil, err
}
return token, inv, nil
}
// Resend rotates the token of a pending invitation and restarts its
// acceptance window, returning the fresh raw token for delivery. The old
// link stops working. Resending a non-pending invitation reports
// [ErrState]; a rejected address goes back through [Manager.Invite] and
// its cooldown.
func (m *Manager) Resend(
ctx context.Context,
id uuid.UUID,
) (token string, inv *Invitation, err error) {
inv, err = m.store.GetInvitation(ctx, id)
if err != nil {
return "", nil, err
}
if inv == nil || inv.Status != StatusPending {
return "", nil, ErrState
}
token, err = m.rotate(ctx, inv, m.now())
if err != nil {
return "", nil, err
}
return token, inv, nil
}
// rotate mints a fresh token onto the invitation, flips it back to
// pending, and restarts the acceptance window.
func (m *Manager) rotate(
ctx context.Context,
inv *Invitation,
now time.Time,
) (string, error) {
token, digest, err := m.secrets.Mint(ctx)
if err != nil {
return "", err
}
inv.Token = digest
inv.Status = StatusPending
inv.CooldownUntil = time.Time{}
inv.ExpiresAt = now.Add(m.lifetime)
inv.UpdatedAt = now
if err := m.store.UpdateInvitation(ctx, inv); err != nil {
return "", err
}
return token, nil
}
// Withdraw removes an invitation, reporting whether this call removed it.
// The emailed link stops working; a withdrawn address may be re-invited
// immediately.
func (m *Manager) Withdraw(
ctx context.Context,
id uuid.UUID,
) (bool, error) {
return m.store.DeleteInvitation(ctx, id)
}
// Accept redeems the raw invitation token for the authenticated user, who
// becomes a member of the inviting team. Possession of the emailed link is
// the proof of invitation, so the accepting account need not hold the
// invited address. The limit is the user's membership limit (see
// [user.User.MembershipLimit]); joining a further team beyond it is
// refused with [ErrMemberLimit], the invitation left standing.
//
// ok is false for an unknown token, an expired or rejected invitation, or
// when a concurrent acceptance won the race; the reasons are deliberately
// collapsed so a caller cannot probe invitations. Accepting into a team
// the user already belongs to consumes the invitation and reports
// success, regardless of the limit — re-joining one's own team must stay
// idempotent rather than being refused at the cap. The error is otherwise
// reserved for storage failures.
//
// [user.User.MembershipLimit]:
// github.com/deep-rent/nexus/eco/iam/user#User.MembershipLimit
func (m *Manager) Accept(
ctx context.Context,
token string,
userID uuid.UUID,
limit int,
) (t *Team, ok bool, err error) {
inv, valid, err := m.redeemable(ctx, token)
if err != nil || !valid {
return nil, false, err
}
t, err = m.store.GetTeam(ctx, inv.TeamID)
if err != nil {
return nil, false, err
}
if t == nil {
// The team vanished under a straggling invitation; consume it.
_, _ = m.store.DeleteInvitation(ctx, inv.ID)
return nil, false, nil
}
if err := m.guardMemberLimit(
ctx, userID, limit, inv.TeamID,
); err != nil {
return nil, false, err
}
err = m.store.AddMember(ctx, Membership{
TeamID: inv.TeamID,
UserID: userID,
CreatedAt: m.now(),
})
joined := err == nil
if err != nil && !errors.Is(err, ErrDuplicate) {
return nil, false, err
}
// The invitation is consumed exactly once: whichever concurrent accept
// deletes the record is the one that reports success.
deleted, err := m.store.DeleteInvitation(ctx, inv.ID)
if err != nil {
return nil, false, err
}
if !deleted {
return nil, false, nil
}
if joined {
m.publish(Event{
Kind: EventJoined,
TeamID: inv.TeamID,
UserID: userID,
Actor: userID,
})
}
return t, true, nil
}
// Reject declines the invitation behind the raw token. The record survives
// as rejected, and each rejection doubles the cooldown before the address
// may be re-invited, up to the configured maximum.
//
// ok mirrors [Manager.Accept]: false for an unknown token or an
// invitation that is not pending, with the reasons collapsed.
func (m *Manager) Reject(
ctx context.Context,
token string,
) (ok bool, err error) {
inv, valid, err := m.redeemable(ctx, token)
if err != nil || !valid {
return false, err
}
now := m.now()
inv.Status = StatusRejected
inv.Rejections++
inv.CooldownUntil = now.Add(m.backoff(inv.Rejections))
inv.ExpiresAt = time.Time{}
inv.UpdatedAt = now
if err := m.store.UpdateInvitation(ctx, inv); err != nil {
return false, err
}
return true, nil
}
// redeemable resolves the raw token to a live pending invitation. valid is
// false for an empty or unknown token and for invitations that are
// rejected or expired.
func (m *Manager) redeemable(
ctx context.Context,
token string,
) (inv *Invitation, valid bool, err error) {
if token == "" {
return nil, false, nil
}
inv, err = m.store.GetInvitationByToken(ctx, m.secrets.Key(token))
if err != nil {
return nil, false, err
}
if inv == nil ||
inv.Status != StatusPending ||
m.now().After(inv.ExpiresAt) {
return nil, false, nil
}
return inv, true, nil
}
// backoff returns the cooldown imposed by the n-th rejection: the base
// doubled per prior rejection, capped at the maximum.
func (m *Manager) backoff(n int) time.Duration {
d := m.cooldown
for i := 1; i < n && d < m.maximum; i++ {
d *= 2
}
return min(d, m.maximum)
}
// normalize canonicalizes an email address for invitation bookkeeping,
// matching [user.Normalize].
//
// [user.Normalize]: github.com/deep-rent/nexus/eco/iam/user#Normalize
func normalize(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package team
import (
"time"
"github.com/deep-rent/nexus/sec/digest"
"github.com/deep-rent/nexus/sec/nonce"
"github.com/deep-rent/nexus/std/clock"
)
// Option configures a [Manager].
type Option func(*Manager)
// WithInvitationLifetime sets the acceptance window of a freshly extended
// invitation. Nonpositive values are ignored. Defaults to
// [DefaultInvitationLifetime].
func WithInvitationLifetime(d time.Duration) Option {
return func(m *Manager) {
if d > 0 {
m.lifetime = d
}
}
}
// WithCooldown sets the re-invitation cooldown imposed by the first
// rejection and the cap it doubles toward. Nonpositive values are ignored
// individually. Defaults to [DefaultMinCooldown] and [DefaultMaxCooldown].
func WithCooldown(base, maximum time.Duration) Option {
return func(m *Manager) {
if base > 0 {
m.cooldown = base
}
if maximum > 0 {
m.maximum = maximum
}
}
}
// WithHasher sets the hasher that fingerprints invitation tokens before
// they reach the store. A nil hasher is ignored. Defaults to
// [digest.DefaultHasher].
func WithHasher(h *digest.Hasher) Option {
return func(m *Manager) {
if h != nil {
m.secrets.Hasher = h
}
}
}
// WithGenerator overrides the source of invitation tokens. A nil generator
// is ignored. Defaults to [nonce.DefaultGenerator] (256-bit tokens).
func WithGenerator(g *nonce.Generator) Option {
return func(m *Manager) {
if g != nil {
m.secrets.Source = g
}
}
}
// WithClock overrides the time source, primarily for testing. A nil clock
// is ignored. Defaults to [clock.System].
func WithClock(c clock.Clock) Option {
return func(m *Manager) {
if c != nil {
m.now = c
}
}
}
// WithObserver registers a synchronous observer for team lifecycle
// events, typically feeding an audit trail. A nil observer is ignored.
func WithObserver(fn Observer) Option {
return func(m *Manager) {
if fn != nil {
m.observe = fn
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package ticket
import (
"time"
"github.com/deep-rent/nexus/sec/digest"
"github.com/deep-rent/nexus/sec/nonce"
"github.com/deep-rent/nexus/std/clock"
)
// DefaultLifetime is the redemption window applied by [New] when
// [WithLifetime] is not given.
const DefaultLifetime = 1 * time.Hour
// Option configures a [Manager].
type Option func(*Manager)
// WithLifetime sets the redemption window of a freshly issued ticket.
// Nonpositive values are ignored. Defaults to [DefaultLifetime].
func WithLifetime(d time.Duration) Option {
return func(m *Manager) {
if d > 0 {
m.lifetime = d
}
}
}
// WithHasher sets the hasher that fingerprints action tokens before they
// reach the store. A nil hasher is ignored. Defaults to
// [digest.DefaultHasher].
func WithHasher(h *digest.Hasher) Option {
return func(m *Manager) {
if h != nil {
m.secrets.Hasher = h
}
}
}
// WithGenerator overrides the source of action tokens. A nil generator is
// ignored. Defaults to [nonce.DefaultGenerator] (256-bit tokens).
func WithGenerator(g *nonce.Generator) Option {
return func(m *Manager) {
if g != nil {
m.secrets.Source = g
}
}
}
// WithClock overrides the time source, primarily for testing. A nil function
// is ignored. Defaults to [clock.System].
func WithClock(now clock.Clock) Option {
return func(m *Manager) {
if now != nil {
m.now = now
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package ticket
import (
"context"
"time"
"uuid"
"github.com/deep-rent/nexus/eco/iam/artifact"
"github.com/deep-rent/nexus/std/clock"
)
// Ticket is the persisted state of an action token. The token is stored only
// as its digest, never in the clear.
type Ticket struct {
// ID is the digest of the action token and the storage key. The plaintext
// token never reaches the store.
ID string `json:"id"`
// Owner identifies the user the deferred action concerns. It is
// returned verbatim on successful redemption.
Owner uuid.UUID `json:"owner"`
// Purpose names the single action the token authorizes, such as
// "verify:email". Redemption must present the same purpose.
Purpose string `json:"purpose"`
// Payload is optional state the deferred action needs, such as the new
// address of a pending email change. It must never carry a secret.
Payload string `json:"payload,omitzero"`
// ExpiresAt is when the ticket lapses.
ExpiresAt time.Time `json:"expires_at"`
}
// Store persists tickets keyed by [Ticket.ID]. See [artifact.Store] for the
// storage contract.
type Store interface {
artifact.Store[string, Ticket]
// DeleteForOwner removes every ticket issued to the given owner,
// whatever its purpose. It backs credential-change revocation: a
// standing ticket authorizes a deferred action on the account, so it
// outlives the credentials it was issued under unless revoked
// alongside them. It is a no-op when the owner holds no tickets.
DeleteForOwner(ctx context.Context, owner uuid.UUID) error
}
// Manager mints and redeems single-use action tokens over a [Store]. It is
// safe for concurrent use if its [Store] is.
//
// A Manager applies one lifetime to every ticket it issues; actions with
// different windows (say, hour-lived password resets and day-lived email
// confirmations) use separate Managers over a shared store, since the purpose
// travels with the record.
type Manager struct {
store Store
lifetime time.Duration
secrets artifact.Digester
now clock.Clock
}
// New creates a [Manager] backed by the given [Store]. It panics if store is
// nil, since that is a startup configuration error.
func New(store Store, opts ...Option) *Manager {
if store == nil {
panic("store is required")
}
m := &Manager{
store: store,
lifetime: DefaultLifetime,
now: clock.System,
}
for _, opt := range opts {
opt(m)
}
return m
}
// Issue mints an action token for the owner and purpose, persists its digest,
// and returns the raw token for the caller to deliver. The payload is stored
// alongside the record and returned on redemption.
func (m *Manager) Issue(
ctx context.Context,
owner uuid.UUID,
purpose, payload string,
) (token string, err error) {
token, id, err := m.secrets.Mint(ctx)
if err != nil {
return "", err
}
if err := m.store.Create(ctx, Ticket{
ID: id,
Owner: owner,
Purpose: purpose,
Payload: payload,
ExpiresAt: m.now().Add(m.lifetime),
}); err != nil {
return "", err
}
return token, nil
}
// Redeem consumes the raw token, returning the underlying [Ticket] exactly
// once.
//
// ok is false for an empty or unknown token, an expired ticket, a purpose
// mismatch, or when a concurrent redemption won the race; the failure reasons
// are deliberately collapsed so a caller cannot distinguish them. A ticket
// presented under the wrong purpose is left intact — it remains redeemable by
// its rightful action. The error is reserved for storage failures.
func (m *Manager) Redeem(
ctx context.Context,
token, purpose string,
) (t Ticket, ok bool, err error) {
if token == "" {
return Ticket{}, false, nil
}
t, found, err := m.store.Get(ctx, m.secrets.Key(token))
if err != nil {
return Ticket{}, false, err
}
if !found || t.Purpose != purpose {
return Ticket{}, false, nil
}
// The atomic delete decides the winner among concurrent redemptions
// before the ticket is honored; expiry is checked afterwards so a lapsed
// record is reaped on the way out.
deleted, err := m.store.Delete(ctx, t.ID)
if err != nil {
return Ticket{}, false, err
}
if !deleted || m.now().After(t.ExpiresAt) {
return Ticket{}, false, nil
}
return t, true, nil
}
// Revoke deletes the ticket for the raw token, if any. It is a no-op for an
// empty or unknown token.
func (m *Manager) Revoke(ctx context.Context, token string) error {
if token == "" {
return nil
}
_, err := m.store.Delete(ctx, m.secrets.Key(token))
return err
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package topic
import (
"github.com/deep-rent/nexus/eco/iam/authn"
"github.com/deep-rent/nexus/eco/iam/avatar"
"github.com/deep-rent/nexus/eco/iam/idp"
"github.com/deep-rent/nexus/eco/iam/login"
"github.com/deep-rent/nexus/eco/iam/oauth"
"github.com/deep-rent/nexus/eco/iam/passkey"
"github.com/deep-rent/nexus/eco/iam/team"
"github.com/deep-rent/nexus/eco/iam/user"
"github.com/deep-rent/nexus/sys/event"
)
// The names of the topics, each carrying one module's own event type.
const (
// LoginName carries [login.Event].
LoginName = "iam.login"
// TokenName carries [oauth.Event].
TokenName = "iam.token"
// PasskeyName carries [passkey.Event].
PasskeyName = "iam.passkey"
// FederationName carries [idp.Event].
FederationName = "iam.federation"
// TeamName carries [team.Event].
TeamName = "iam.team"
// DirectoryName carries [user.Event].
DirectoryName = "iam.directory"
// AccountName carries [authn.Event]: the second factors a user
// carries, enrolled, removed, or spent. They are the steps of an
// account takeover, so an incident responder needs them recorded.
AccountName = "iam.account"
// AvatarName carries [avatar.Event]: user avatars and team logos put
// into or taken out of service.
AvatarName = "iam.avatar"
)
// Logins returns the bus carrying the login core's events.
func Logins(b *event.Broker) *event.Bus[login.Event] {
return event.Topic[login.Event](b, LoginName)
}
// Tokens returns the bus carrying OAuth token lifecycle events.
func Tokens(b *event.Broker) *event.Bus[oauth.Event] {
return event.Topic[oauth.Event](b, TokenName)
}
// Passkeys returns the bus carrying WebAuthn ceremony events.
func Passkeys(b *event.Broker) *event.Bus[passkey.Event] {
return event.Topic[passkey.Event](b, PasskeyName)
}
// Federations returns the bus carrying external identity provider events.
func Federations(b *event.Broker) *event.Bus[idp.Event] {
return event.Topic[idp.Event](b, FederationName)
}
// Teams returns the bus carrying team lifecycle events.
func Teams(b *event.Broker) *event.Bus[team.Event] {
return event.Topic[team.Event](b, TeamName)
}
// Directory returns the bus carrying user directory events.
func Directory(b *event.Broker) *event.Bus[user.Event] {
return event.Topic[user.Event](b, DirectoryName)
}
// Accounts returns the bus carrying authenticator and recovery code
// events.
func Accounts(b *event.Broker) *event.Bus[authn.Event] {
return event.Topic[authn.Event](b, AccountName)
}
// Avatars returns the bus carrying picture lifecycle events.
func Avatars(b *event.Broker) *event.Bus[avatar.Event] {
return event.Topic[avatar.Event](b, AvatarName)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package trust
import (
"time"
"github.com/deep-rent/nexus/sec/digest"
"github.com/deep-rent/nexus/sec/nonce"
"github.com/deep-rent/nexus/std/clock"
)
// DefaultLifetime is the trust window applied by [New] when [WithLifetime] is
// not given.
const DefaultLifetime = 30 * 24 * time.Hour
// Option configures a [Manager].
type Option func(*Manager)
// WithLifetime sets the trust window of a freshly issued token. Nonpositive
// values are ignored. Defaults to [DefaultLifetime].
func WithLifetime(d time.Duration) Option {
return func(m *Manager) {
if d > 0 {
m.lifetime = d
}
}
}
// WithHasher sets the hasher that fingerprints trust tokens before they reach
// the store. A nil hasher is ignored. Defaults to [digest.DefaultHasher].
func WithHasher(h *digest.Hasher) Option {
return func(m *Manager) {
if h != nil {
m.secrets.Hasher = h
}
}
}
// WithGenerator overrides the source of trust tokens. A nil generator is
// ignored. Defaults to [nonce.DefaultGenerator] (256-bit tokens).
func WithGenerator(g *nonce.Generator) Option {
return func(m *Manager) {
if g != nil {
m.secrets.Source = g
}
}
}
// WithClock overrides the time source, primarily for testing. A nil function
// is ignored. Defaults to [clock.System].
func WithClock(now clock.Clock) Option {
return func(m *Manager) {
if now != nil {
m.now = now
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package trust
import (
"context"
"slices"
"time"
"uuid"
"github.com/deep-rent/nexus/eco/iam/artifact"
"github.com/deep-rent/nexus/std/clock"
)
// Record is the persisted state of a device trust. The token is stored only
// as its digest, never in the clear.
type Record struct {
// ID is the digest of the trust token and the storage key. The plaintext
// token never reaches the store.
ID string `json:"id"`
// Owner identifies the user who trusts the device. Trust is honored
// only for the same owner.
Owner uuid.UUID `json:"owner"`
// ExpiresAt is when the trust lapses. Every record carries one; a
// record without it has already expired.
ExpiresAt time.Time `json:"expires_at"`
// CreatedAt is when the trust was enrolled.
CreatedAt time.Time `json:"created_at,omitzero"`
// Label is an optional human-facing hint for a device list, such as a
// summary of the user agent. It never carries a secret.
Label string `json:"label,omitzero"`
}
// Device is the result of a trust [Manager.Check]: whether the presented
// token proves the device trusted, and under which stable identifier.
type Device struct {
// Trusted reports whether the device presented a live trust token bound
// to the owner.
Trusted bool
// ID identifies the trust record (the token digest). It is stable across
// checks, so callers may use it to key per-device state.
ID string
}
// Store persists trust records keyed by [Record.ID]. See [artifact.Store]
// for the storage contract.
type Store interface {
artifact.Store[string, Record]
// ListForOwner returns every trust record enrolled by the given owner,
// including expired ones not yet evicted; the [Manager] filters those
// out. An owner without trusted devices yields an empty slice.
ListForOwner(ctx context.Context, owner uuid.UUID) ([]Record, error)
// DeleteForOwner removes every trust record enrolled by the given owner.
// It backs a "sign out everywhere" or a credential-change revocation, and
// is a no-op when the owner trusts no devices.
DeleteForOwner(ctx context.Context, owner uuid.UUID) error
}
// Manager runs the lifecycle of device trust tokens — minting, checking, and
// revoking them — over a [Store]. It is safe for concurrent use if its
// [Store] is.
type Manager struct {
store Store
lifetime time.Duration
secrets artifact.Digester
now clock.Clock
}
// New creates a [Manager] backed by the given [Store]. It panics if store is
// nil, since that is a startup configuration error.
func New(store Store, opts ...Option) *Manager {
if store == nil {
panic("store is required")
}
m := &Manager{
store: store,
lifetime: DefaultLifetime,
now: clock.System,
}
for _, opt := range opts {
opt(m)
}
return m
}
// Issue mints a trust token for the owner, persists its digest, and returns
// the raw token for the caller to set on the client. The label is an optional
// human-facing hint stored alongside the record.
func (m *Manager) Issue(
ctx context.Context,
owner uuid.UUID,
label string,
) (token string, err error) {
token, id, err := m.secrets.Mint(ctx)
if err != nil {
return "", err
}
if err := m.store.Create(ctx, Record{
ID: id,
Owner: owner,
ExpiresAt: m.now().Add(m.lifetime),
CreatedAt: m.now(),
Label: label,
}); err != nil {
return "", err
}
return token, nil
}
// Check reports whether the raw token proves the requesting device is trusted
// for the given owner.
//
// The trust is bound to the owner: a token issued for one owner never trusts
// a device for another. An empty token, an unknown or expired record, or a
// record for a different owner all yield an untrusted [Device], so a wrong or
// stale token simply falls back to full authentication. The error is reserved
// for storage failures.
func (m *Manager) Check(
ctx context.Context,
token string,
owner uuid.UUID,
) (Device, error) {
if token == "" {
return Device{}, nil
}
r, found, err := m.store.Get(ctx, m.secrets.Key(token))
if err != nil {
return Device{}, err
}
if !found ||
r.Owner != owner ||
m.now().After(r.ExpiresAt) {
return Device{}, nil
}
return Device{Trusted: true, ID: r.ID}, nil
}
// Revoke deletes the trust record for the raw token, if any. It is a no-op
// for an empty or unknown token.
func (m *Manager) Revoke(ctx context.Context, token string) error {
if token == "" {
return nil
}
_, err := m.store.Delete(ctx, m.secrets.Key(token))
return err
}
// RevokeByID removes the trust record with the given record ID (the token
// digest), reporting whether one existed. It backs management APIs that
// revoke a device picked from a [Manager.List] result, where only digests
// circulate.
func (m *Manager) RevokeByID(ctx context.Context, id string) (bool, error) {
if id == "" {
return false, nil
}
return m.store.Delete(ctx, id)
}
// RevokeAll removes every device trust enrolled by the owner. Call it when
// the owner's credentials change — for example on a password reset — so that
// no previously trusted device can skip authentication factors.
func (m *Manager) RevokeAll(
ctx context.Context,
owner uuid.UUID,
) error {
return m.store.DeleteForOwner(ctx, owner)
}
// List returns the owner's live device trusts, most recently enrolled first.
// Expired records awaiting eviction are filtered out. The records carry only
// digests, never raw trust tokens.
func (m *Manager) List(
ctx context.Context,
owner uuid.UUID,
) ([]Record, error) {
records, err := m.store.ListForOwner(ctx, owner)
if err != nil {
return nil, err
}
now := m.now()
live := records[:0]
for _, r := range records {
if !now.After(r.ExpiresAt) {
live = append(live, r)
}
}
slices.SortFunc(live, func(a, b Record) int {
return b.CreatedAt.Compare(a.CreatedAt)
})
return live, nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package user
import (
"time"
"uuid"
)
// EventKind names a lifecycle event published by the user directory.
type EventKind string
const (
// EventPasswordChanged marks a replaced password hash, whichever path
// replaced it: the account API's own change and recovery endpoints, or
// an administrative reset. It is the signal a "your password was
// changed" notice hangs off, so that a change the account holder did
// not make does not pass unnoticed.
//
// An outdated hash re-encoded during a successful login is not a change
// of password and raises nothing.
EventPasswordChanged EventKind = "password_changed"
// EventUserDisabled marks an account locked out by an update, which is
// the transition an audit trail and an alerting consumer both care
// about. Re-saving an already disabled account raises nothing.
EventUserDisabled EventKind = "user_disabled"
// EventUserEnabled marks a disabled account unlocked again.
EventUserEnabled EventKind = "user_enabled"
// EventUserDeleted marks an account removed, with everything owned by
// it. It is raised after the record is gone, so a consumer cannot read
// the account back.
EventUserDeleted EventKind = "user_deleted"
)
// Event is a lifecycle notification delivered to the [Observer] registered
// with [WithObserver].
//
// Events are advisory and carry no secrets: password hashes, and the
// passwords behind them, never appear in them.
type Event struct {
// Kind states what happened.
Kind EventKind
// UserID identifies the affected account.
UserID uuid.UUID
// At is when the event occurred.
At time.Time
}
// Observer receives lifecycle events. It runs synchronously on the request
// that produced the event, so it must stay cheap and must not block; hand
// events to a bus or queue for anything heavier.
type Observer func(Event)
// publish delivers an event to the configured observer, if any. Publishing
// is advisory: the directory never fails on an observer.
func (m *Manager) publish(e Event) {
if m.observer == nil {
return
}
if e.At.IsZero() {
e.At = m.now.Now()
}
m.observer(e)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package user
import (
"context"
"errors"
"fmt"
"strings"
"time"
"unicode/utf8"
"uuid"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/eco/iam/idp"
"github.com/deep-rent/nexus/eco/iam/login"
"github.com/deep-rent/nexus/eco/iam/passkey"
"github.com/deep-rent/nexus/sec/pass"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/std/i18n"
"github.com/deep-rent/nexus/std/text"
"github.com/deep-rent/nexus/sys/log"
)
// Manager is the credential and federation engine over a [Store].
//
// It implements [login.Users], [idp.Users], and [passkey.Users], so it
// serves as the identity backend of the authorization server, and
// additionally exposes the account mutations that involve credentials, so
// password hashing policy stays in one place. It is safe for concurrent use
// if its [Store] is.
type Manager struct {
store Store
hasher *pass.Hasher
federation Federation
now clock.Clock
observer Observer
logger *log.Logger
}
// New creates a [Manager] backed by the given [Store]. It panics if store is
// nil, since that is a startup configuration error.
func New(store Store, opts ...Option) *Manager {
if store == nil {
panic("store is required")
}
m := &Manager{
store: store,
hasher: pass.New(),
federation: DefaultFederation,
now: clock.System,
logger: log.Discard(),
}
for _, opt := range opts {
opt(m)
}
return m
}
// Store exposes the underlying persistence, for management surfaces that
// read accounts without credential logic — listings, lookups, and the
// identity-link queries.
//
// Write through [Manager.Update], [Manager.Delete], and
// [Manager.SetPassword] rather than through the store returned here: those
// raise the directory's lifecycle events, and a write that bypasses them
// is invisible to every audit and alerting consumer.
func (m *Manager) Store() Store { return m.store }
// Register provisions a new account: it assigns a fresh UUIDv7, normalizes
// the email addresses, stamps the timestamps, and hashes the password. An
// empty password creates a passwordless account, which can only sign in
// through external identity providers or passkeys.
//
// The name and the email address (the login identifier) must be non-empty
// and the names must fit [MaxNameLength]; whether the address is occupied
// surfaces as [ErrDuplicate] from the store. Preferred locales must be
// well-formed BCP 47 language tags — they are stored in canonical case —
// and the time zone must be an IANA Time Zone Database name.
//
// Names are stripped of surrounding white space before any of that, so a
// padded name is stored as the name it is rather than refused, and one
// made of nothing but space is refused as the empty name it is. Their
// length is checked rather than shortened: a caller registering an
// account can be asked to send a name that fits.
func (m *Manager) Register(
ctx context.Context,
u *User,
password string,
) error {
u.Name = strings.TrimSpace(u.Name)
u.DisplayName = strings.TrimSpace(u.DisplayName)
if u.Name == "" {
return errors.New("name is required")
}
if utf8.RuneCountInString(u.Name) > MaxNameLength ||
utf8.RuneCountInString(u.DisplayName) > MaxNameLength {
return fmt.Errorf(
"names are limited to %d characters", MaxNameLength,
)
}
if Normalize(u.Email) == "" {
return errors.New("email is required")
}
for i, tag := range u.Locales {
if !valid.Lang(tag) {
return fmt.Errorf("invalid locale %q", tag)
}
u.Locales[i] = i18n.Canonical(tag)
}
if u.Zone != "" && !valid.Timezone(u.Zone) {
return fmt.Errorf("invalid time zone %q", u.Zone)
}
u.ID = uuid.NewV7()
u.Email = Normalize(u.Email)
u.RecoveryEmail = Normalize(u.RecoveryEmail)
now := m.now()
u.Password = nil
u.PasswordChangedAt = time.Time{}
if password != "" {
hash, err := m.hasher.Hash(password)
if err != nil {
return err
}
u.Password = hash
u.PasswordChangedAt = now
}
u.CreatedAt = now
u.UpdatedAt = now
return m.store.Create(ctx, u)
}
// Update persists changes to an existing account, keyed by [User.ID], and
// raises the events the change implies — today [EventUserDisabled] and
// [EventUserEnabled] for a lockout transition.
//
// It reads the stored record first so that it publishes a transition
// rather than a state: a consumer must be able to tell "this update
// locked the account" from "this update touched an account that was
// already locked". The extra read is deliberate — account administration
// is not a hot path, and the alternative is making every caller carry the
// prior record.
//
// Mutations must go through here rather than through [Manager.Store], so
// that no write path is invisible to the directory's observers.
func (m *Manager) Update(ctx context.Context, u *User) error {
prior, err := m.store.Get(ctx, u.ID)
if err != nil {
return err
}
if err := m.store.Update(ctx, u); err != nil {
return err
}
if prior != nil && prior.Disabled != u.Disabled {
kind := EventUserEnabled
if u.Disabled {
kind = EventUserDisabled
}
m.publish(Event{Kind: kind, UserID: u.ID})
}
return nil
}
// Delete removes the account and everything owned by it, reporting whether
// this call removed the record, and raises [EventUserDeleted] when it did.
//
// Callers must revoke the account's standing credentials separately;
// deletion cascades the durable dependents only.
func (m *Manager) Delete(
ctx context.Context,
id uuid.UUID,
) (bool, error) {
deleted, err := m.store.Delete(ctx, id)
if err != nil || !deleted {
return deleted, err
}
m.publish(Event{Kind: EventUserDeleted, UserID: id})
return true, nil
}
// SetPassword replaces the account's password without further checks. It
// backs administrative resets and ticket-authorized password recovery;
// interactive changes should first prove the current password via
// [Manager.VerifyPassword].
//
// Callers must revoke standing credentials (sessions, trusted devices,
// refresh tokens) alongside, so a credential change locks out whoever held
// the old password.
//
// It raises [EventPasswordChanged] once the hash is stored. Every path that
// replaces a password runs through here, which is why the event hangs off
// this method rather than off its callers.
func (m *Manager) SetPassword(
ctx context.Context,
id uuid.UUID,
password string,
) error {
hash, err := m.hasher.Hash(password)
if err != nil {
return err
}
if err := m.store.SetPassword(ctx, id, hash, m.now()); err != nil {
return err
}
m.publish(Event{Kind: EventPasswordChanged, UserID: id})
return nil
}
// SetAvatar atomically exchanges the user's stored avatar key, returning
// the key it displaced and whether the user exists. The swap is silent —
// no directory event is published — because the avatar engine publishes
// its own richer event for the same change, and two records of one
// change would double every audit trail.
func (m *Manager) SetAvatar(
ctx context.Context,
id uuid.UUID,
key string,
) (prior string, found bool, err error) {
return m.store.SetAvatar(ctx, id, key)
}
// VerifyPassword reports whether the password matches the user's stored
// hash. It always fails for passwordless or disabled accounts. The error is
// reserved for corrupted hash records.
//
// Unlike [Manager.Authenticate] it does not upgrade outdated hashes, since
// its callers (interactive password changes) immediately replace the hash
// anyway.
func (m *Manager) VerifyPassword(u *User, password string) (bool, error) {
if u.Disabled || len(u.Password) == 0 {
return false, nil
}
return m.hasher.Verify(u.Password, password)
}
// Authenticate implements [login.Users].
//
// The login identifier is the email address. It verifies the password in
// constant time and refuses disabled or passwordless accounts. While the
// proven plaintext is at hand, hashes predating the current hashing
// configuration are transparently upgraded; an upgrade failure is logged
// and ignored, since the login itself succeeded.
func (m *Manager) Authenticate(
ctx context.Context,
username, password string,
) (login.User, error) {
u, err := m.store.GetByEmail(ctx, Normalize(username))
if err != nil || u == nil {
return nil, err
}
if u.Disabled || len(u.Password) == 0 {
return nil, nil
}
ok, err := m.hasher.Verify(u.Password, password)
if err != nil {
return nil, err
}
if !ok {
return nil, nil
}
// The password is proven; converge its stored hash to the strongest
// configuration without a mass reset.
if stale, _ := m.hasher.Outdated(u.Password); stale {
if err := m.rehash(ctx, u, password); err != nil {
m.logger.Warn(
ctx,
"Failed to upgrade an outdated password hash",
log.UUID("user_id", u.ID),
log.Error(err),
)
}
}
return Principal{u}, nil
}
// rehash replaces the user's stored hash with one minted under the current
// hashing configuration. The change timestamp is preserved: the password
// itself stays the same, only its encoding converges.
func (m *Manager) rehash(
ctx context.Context,
u *User,
password string,
) error {
hash, err := m.hasher.Hash(password)
if err != nil {
return err
}
if err := m.store.SetPassword(
ctx, u.ID, hash, u.PasswordChangedAt,
); err != nil {
return err
}
u.Password = hash
return nil
}
// GetUser implements [login.Users]. Disabled accounts resolve to no user,
// so standing grants stop minting tokens the moment an account is locked.
func (m *Manager) GetUser(
ctx context.Context,
id uuid.UUID,
) (login.User, error) {
u, err := m.store.Get(ctx, id)
if err != nil || u == nil || u.Disabled {
return nil, err
}
return Principal{u}, nil
}
// GetUserByUsername implements [login.Users]: the login identifier is
// the email address. Disabled accounts resolve to no user.
func (m *Manager) GetUserByUsername(
ctx context.Context,
username string,
) (login.User, error) {
u, err := m.store.GetByEmail(ctx, Normalize(username))
if err != nil || u == nil || u.Disabled {
return nil, err
}
return Principal{u}, nil
}
// GetUserByExternalID implements [idp.Users], applying the configured
// [Federation] policy: a previously linked identity resolves directly, an
// existing account may be linked by matching verified email, and unknown
// identities may be provisioned just in time. Any refusal — disabled
// account, unverified email, policy switched off — yields nil, nil, so a
// caller cannot tell the reasons apart.
func (m *Manager) GetUserByExternalID(
ctx context.Context,
provider string,
identity idp.Claimant,
) (login.User, error) {
u, err := m.resolveExternal(ctx, provider, identity)
if err != nil || u == nil || u.Disabled {
return nil, err
}
return Principal{u}, nil
}
// resolveExternal runs the three federation stages: linked identity, email
// linking, and just-in-time provisioning.
func (m *Manager) resolveExternal(
ctx context.Context,
provider string,
identity idp.Claimant,
) (*User, error) {
ident, err := m.store.GetIdentity(ctx, provider, identity.Subject)
if err != nil {
return nil, err
}
if ident != nil {
return m.store.Get(ctx, ident.UserID)
}
// The provider's email claim is only trusted when the provider vouches
// for it; an attacker-controlled unverified claim must never attach to
// an existing account.
email := Normalize(identity.Email)
verified := identity.EmailVerified && email != ""
if m.federation.Link && verified {
u, err := m.store.GetByEmail(ctx, email)
if err != nil {
return nil, err
}
if u != nil {
// Both sides must have proven the address: linking to a local
// account that never confirmed the email would let whoever
// registered it first capture the federated login.
if !u.EmailVerified {
return nil, nil
}
if err := m.link(
ctx,
provider,
identity.Subject,
u.ID,
); err != nil {
return nil, err
}
return u, nil
}
}
if !m.federation.Provision {
return nil, nil
}
return m.provision(ctx, provider, identity, email, verified)
}
// link persists an identity link, tolerating a concurrent callback having
// established the same link already.
func (m *Manager) link(
ctx context.Context,
provider, subject string,
userID uuid.UUID,
) error {
err := m.store.LinkIdentity(ctx, Identity{
Provider: provider,
Subject: subject,
UserID: userID,
CreatedAt: m.now(),
})
if errors.Is(err, ErrDuplicate) {
return nil
}
return err
}
// provision creates a fresh account for a first-time federated login and
// links the external identity to it.
func (m *Manager) provision(
ctx context.Context,
provider string,
identity idp.Claimant,
email string,
verified bool,
) (*User, error) {
// The email address is the login identifier; a provider withholding it
// cannot provision an account.
if email == "" {
return nil, nil
}
// The provider's name claim addresses the user in notifications; a
// provider withholding it leaves the address's local part. It is
// trimmed rather than validated: the login must not fail over a name
// the user cannot correct from here.
name := strings.TrimSpace(identity.Name)
if name == "" {
name, _, _ = strings.Cut(email, "@")
}
u := &User{
Name: text.Fit(name, MaxNameLength),
Email: email,
EmailVerified: verified,
}
// Locale and time zone claims are display metadata; a malformed one is
// dropped rather than refusing the login over a value the user cannot
// correct from here.
if tag := strings.TrimSpace(identity.Locale); valid.Lang(tag) {
u.Locales = []string{tag}
}
if zone := strings.TrimSpace(identity.Zone); valid.Timezone(zone) {
u.Zone = zone
}
if err := m.Register(ctx, u, ""); err != nil {
if !errors.Is(err, ErrDuplicate) {
return nil, err
}
// A concurrent callback may have provisioned the account between
// the identity lookup and the insert; resolve it once. Any other
// duplicate (say, an unverified local account occupying the email)
// refuses the login.
ident, err := m.store.GetIdentity(ctx, provider, identity.Subject)
if err != nil || ident == nil {
return nil, err
}
return m.store.Get(ctx, ident.UserID)
}
if err := m.link(ctx, provider, identity.Subject, u.ID); err != nil {
return nil, err
}
return u, nil
}
// Normalize canonicalizes an email address for storage and comparison:
// trimmed of surrounding whitespace and lowered in case.
func Normalize(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
var (
_ login.Users = (*Manager)(nil)
_ idp.Users = (*Manager)(nil)
_ passkey.Users = (*Manager)(nil)
)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package user
import (
"github.com/deep-rent/nexus/sec/pass"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/sys/log"
)
// Federation controls how external identities map to local users; see
// [Manager.GetUserByExternalID] for where each stage applies.
type Federation struct {
// Link attaches a first-seen external identity to an existing local
// account when the provider-verified email matches the account's
// verified email. When off, unlinked identities skip straight to
// provisioning.
Link bool
// Provision creates a local account just in time for an external
// identity that resolves to no existing user. When off, unknown
// identities are refused.
Provision bool
}
// DefaultFederation is the [Federation] policy applied by [New] when
// [WithFederation] is not given: both linking and provisioning enabled.
var DefaultFederation = Federation{Link: true, Provision: true}
// Option configures a [Manager].
type Option func(*Manager)
// WithHasher sets the password hasher. A nil hasher is ignored. Defaults to
// a [pass.New] hasher with its default algorithm configuration.
func WithHasher(h *pass.Hasher) Option {
return func(m *Manager) {
if h != nil {
m.hasher = h
}
}
}
// WithFederation sets the [Federation] policy for external identities.
// Defaults to [DefaultFederation].
func WithFederation(f Federation) Option {
return func(m *Manager) {
m.federation = f
}
}
// WithClock overrides the time source, primarily for testing. A nil function
// is ignored. Defaults to [clock.System].
func WithClock(now clock.Clock) Option {
return func(m *Manager) {
if now != nil {
m.now = now
}
}
}
// WithObserver registers the observer receiving the directory's lifecycle
// events; see [Event]. A nil observer is ignored, and without one the
// directory raises nothing.
func WithObserver(o Observer) Option {
return func(m *Manager) {
if o != nil {
m.observer = o
}
}
}
// WithLogger sets the logger for background failures that do not surface as
// errors, such as a failed password hash upgrade. A nil logger is ignored.
// Defaults to [log.Discard].
func WithLogger(logger *log.Logger) Option {
return func(m *Manager) {
if logger != nil {
m.logger = logger
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package user
import (
"context"
"errors"
"slices"
"time"
"uuid"
"github.com/deep-rent/nexus/dat/page"
"github.com/deep-rent/nexus/dat/search"
"github.com/deep-rent/nexus/eco/iam/login"
)
// Factor identifies an enrolled second-factor delivery channel.
type Factor string
const (
// FactorMail delivers one-time passwords to the user's verified email
// address.
FactorMail Factor = "mail"
// FactorText delivers one-time passwords to the user's verified phone
// number.
FactorText Factor = "text"
)
// User is a durable identity record.
//
// The zero value is not valid; create records through [Manager.Register] so
// identifiers, timestamps, and credentials are assigned consistently. The
// password record never serializes to JSON, making the struct safe to
// return from management APIs as-is.
type User struct {
// ID is the immutable primary key (a UUIDv7).
ID uuid.UUID `json:"id"`
// Name is the user's full name as a single field, imposing no cultural
// ordering of its parts. Required, at most [MaxNameLength] characters.
Name string `json:"name"`
// DisplayName is the optional name the user prefers to be addressed
// by — a short or informal variant of Name. At most [MaxNameLength]
// characters.
DisplayName string `json:"display_name,omitzero"`
// Email is the user's contact address and unique login identifier,
// normalized to lower case. Required.
Email string `json:"email"`
// EmailVerified reports whether ownership of Email was proven, for
// example by redeeming a confirmation ticket. Only a verified address
// may receive one-time passwords or take part in federation linking.
EmailVerified bool `json:"email_verified,omitzero"`
// RecoveryEmail is a fallback contact address for account recovery,
// normalized to lower case. It is unique across users' recovery
// addresses while set, and may freely coincide with primary addresses.
RecoveryEmail string `json:"recovery_email,omitzero"`
// RecoveryEmailVerified reports whether ownership of RecoveryEmail was
// proven. Only a verified recovery address may receive recovery mail.
RecoveryEmailVerified bool `json:"recovery_email_verified,omitzero"`
// Phone is the user's phone number in E.164 format.
Phone string `json:"phone,omitzero"`
// PhoneVerified indicates whether ownership of Phone was proven.
PhoneVerified bool `json:"phone_verified,omitzero"`
// Locales lists the user's preferred locales as canonical BCP 47
// language tags, most preferred first (e.g. "de-CH", "en").
Locales []string `json:"locales,omitzero"`
// Zone is the user's time zone as an IANA Time Zone Database name
// (e.g. "Europe/Berlin"), the OIDC zoneinfo claim.
Zone string `json:"zone,omitzero"`
// Avatar is the object key of the user's verified avatar, empty for
// none. The avatar engine owns the lifecycle behind it (see [avatar]);
// the key renders into a public URL for the OIDC picture claim.
//
// [avatar]: github.com/deep-rent/nexus/eco/iam/avatar
Avatar string `json:"avatar,omitzero"`
// Password is the self-contained password record produced by [pass]: a
// hash together with the parameters it was minted under, never the
// plaintext. Empty marks a passwordless account, for which password
// logins always fail.
//
// [pass]: github.com/deep-rent/nexus/sec/pass
Password []byte `json:"-"`
// PasswordChangedAt is when the password was last set or replaced,
// whether by the user, by recovery, or by an administrator. The zero
// value marks an account that never held a password. A transparent
// hash upgrade preserves it: the password itself did not change.
PasswordChangedAt time.Time `json:"password_changed_at,omitzero"`
// Roles are the role names granted to the user, populating the roles
// claim of issued access tokens.
Roles []string `json:"roles,omitzero"`
// Factors are the second-factor channels the user enrolled for
// multi-factor logins.
Factors []Factor `json:"factors,omitzero"`
// TeamLimit caps how many teams the user may found; see
// [team.Manager.Found]. Founding is a granted privilege, not a default
// right, so the zero value — the default — forbids it outright rather
// than leaving it uncapped.
//
// [team.Manager.Found]:
// github.com/deep-rent/nexus/eco/iam/team#Manager.Found
TeamLimit int `json:"team_limit,omitzero"`
// MembershipLimit caps how many teams the user may belong to through
// the self-service paths, founding included; administrative installs
// ignore it. Unlike TeamLimit, belonging to a team is ordinary rather
// than a granted privilege, so the zero value — the default — leaves
// membership uncapped instead of forbidding it.
MembershipLimit int `json:"membership_limit,omitzero"`
// SeatLimit caps how many seats each team this user founds may hold:
// members plus standing pending invitations, the founder included.
// Like MembershipLimit, growth is ordinary rather than a granted
// privilege, so the zero value — the default — leaves team size
// uncapped. The limit binds the FOUNDER's teams, whoever extends the
// invitation: a seat entitlement is something the founder pays for,
// so it travels with their account; see [team.Manager.Invite].
//
// [team.Manager.Invite]:
// github.com/deep-rent/nexus/eco/iam/team#Manager.Invite
SeatLimit int `json:"seat_limit,omitzero"`
// Disabled locks the account: authentication, token issuance, and
// federated logins all refuse a disabled user.
Disabled bool `json:"disabled,omitzero"`
// Alerts is which notifications the user asked to receive; see
// [Alerts].
Alerts Alerts `json:"alerts,omitzero"`
// CreatedAt is when the account was provisioned.
CreatedAt time.Time `json:"created_at,omitzero"`
// UpdatedAt is when the record last changed.
UpdatedAt time.Time `json:"updated_at,omitzero"`
}
// Alerts is the set of notifications a user asked to be mailed about.
// Every one is off by default: an account is opted in only where the user
// (or an administrator on their behalf) said so, so a fresh account is
// silent until someone asks for the mail.
//
// The settings live on the account rather than in a table of their own
// because the consumer that reads them already has to load the account for
// the address to mail, which keeps a notification decision to a single row
// read.
type Alerts struct {
// Login asks for a notice when the account is signed in to from a
// device it has not been used on before. It is the one alert that
// reports a possible compromise rather than an action the user took,
// so it is the one worth recommending they enable.
Login bool `json:"login,omitzero"`
// PasswordChange asks for a notice when the account password is
// replaced, whether by the user, by recovery, or by an administrator.
PasswordChange bool `json:"password_change,omitzero"`
// TeamJoin asks for a notice when the user is added to a team.
TeamJoin bool `json:"team_join,omitzero"`
// TeamLeave asks for a notice when the user is removed from a team.
TeamLeave bool `json:"team_leave,omitzero"`
}
// MaxNameLength bounds the full and display names at 64 characters each,
// matching the columns behind them. It counts characters rather than bytes,
// so that a name written in a non-Latin script is not penalized for the
// width of its encoding.
const MaxNameLength = 64
// Bounds on the account's repeated fields, matching the columns behind
// them and counted in characters like [MaxNameLength].
const (
// MaxLocaleLength bounds one BCP 47 language tag at 64 characters.
// The longest tags in practice pair a language with a script, a
// region, and a variant or two, well inside this; the grammar admits
// longer ones only through extension and private-use sections.
MaxLocaleLength = 64
// MaxZoneLength bounds the IANA time zone name at 64 characters. The
// longest name in the database is well under half of that.
MaxZoneLength = 64
// MaxRoleLength bounds one role name at 64 characters. Roles are
// identifiers granted by an administrator, not free text.
MaxRoleLength = 64
)
// Display returns the name notifications address the user by: the display
// name when set, the full name otherwise.
func (u *User) Display() string {
if u.DisplayName != "" {
return u.DisplayName
}
return u.Name
}
// HasFactor reports whether the user enrolled the given second factor.
func (u *User) HasFactor(f Factor) bool { return slices.Contains(u.Factors, f) }
// Identity links a local user to an account at an external identity
// provider. The pair (Provider, Subject) is unique across all users.
type Identity struct {
// Provider names the external identity provider, matching the provider
// key registered on the authorization server (e.g. "google").
Provider string `json:"provider"`
// Subject is the stable identifier of the account at the provider (the
// OIDC sub claim).
Subject string `json:"subject"`
// UserID is the local user the external account is linked to.
UserID uuid.UUID `json:"user_id"`
// CreatedAt is when the link was established.
CreatedAt time.Time `json:"created_at,omitzero"`
}
// Sortable and filterable fields of the user listing; see [Search].
const (
// ByName sorts by the user's full name.
ByName = "name"
// ByCreated sorts by the account's creation timestamp.
ByCreated = "created_at"
// FieldVerified filters on whether the primary email address has been
// confirmed.
FieldVerified = "verified"
// FieldDisabled filters on whether the account is locked out.
FieldDisabled = "disabled"
)
// Search is the searchable surface of the user listing. The free-text term
// matches a user's name or email address; the listing sorts by creation
// time (newest first by default) or name, and filters on the two account
// states:
//
// GET /admin/users?q=alice&sort=+name&verified=eq:true
var Search = search.Schema{
Sorts: []string{ByName, ByCreated},
Order: []search.Sort{{Field: ByCreated, Desc: true}},
Fields: map[string]search.Field{
FieldVerified: {Kind: search.Bool},
FieldDisabled: {Kind: search.Bool},
},
}
// Query addresses one page of the user listing, spoken in the vocabulary
// of [Search].
type Query = search.Query
// ErrDuplicate is returned by [Store] mutations that would violate a
// uniqueness constraint: an occupied email address, or an already linked
// identity.
var ErrDuplicate = errors.New("duplicate record")
// Store is the persistence contract for user accounts and their external
// identity links.
//
// Lookups return nil and a nil error when no record matches; errors are
// reserved for storage failures. Mutations that would violate uniqueness
// return an error wrapping [ErrDuplicate]. Implementations must be safe for
// concurrent use.
type Store interface {
// Create persists a new user.
Create(ctx context.Context, u *User) error
// Get retrieves a user by ID.
Get(ctx context.Context, id uuid.UUID) (*User, error)
// GetByEmail retrieves a user by normalized email address, the unique
// login identifier.
GetByEmail(ctx context.Context, email string) (*User, error)
// GetByRecoveryEmail retrieves a user by normalized recovery email
// address.
GetByRecoveryEmail(ctx context.Context, email string) (*User, error)
// Update persists changes to an existing user, keyed by [User.ID]. It is
// a no-op if the user does not exist.
Update(ctx context.Context, u *User) error
// SetPassword replaces the stored password record of the given user
// and records changedAt as the time of the change. It is a no-op if
// the user does not exist.
SetPassword(
ctx context.Context,
id uuid.UUID,
hash []byte,
changedAt time.Time,
) error
// SetAvatar atomically exchanges the stored avatar key of the given
// user, returning the key it displaced ("" when none) and whether
// the user exists.
SetAvatar(ctx context.Context, id uuid.UUID, key string) (
prior string, found bool, err error,
)
// Delete removes a user and everything owned by them, reporting whether
// this call removed the record.
Delete(ctx context.Context, id uuid.UUID) (deleted bool, err error)
// List returns the requested page of users matching the query, along
// with the total number of matches across all pages.
List(ctx context.Context, q Query) (page.Page[*User], error)
// ListByRole returns the users carrying the named role, ordered by
// name and capped at limit.
//
// It stands apart from List because roles are a set-valued column,
// which the scalar comparisons of the query vocabulary do not speak,
// and because its caller asks a different question: not "which page
// of the directory matches these filters" but "who holds this role".
// Role populations are small — the staff of one deployment — so the
// answer is a bounded slice rather than a page.
ListByRole(ctx context.Context, role string, limit int) ([]*User, error)
// GetIdentity retrieves an external identity link by provider and
// provider-side subject.
GetIdentity(
ctx context.Context,
provider, subject string,
) (*Identity, error)
// LinkIdentity persists a new external identity link.
LinkIdentity(ctx context.Context, id Identity) error
// UnlinkIdentity removes the link between a user and a provider,
// reporting whether this call removed it.
UnlinkIdentity(
ctx context.Context,
provider string,
userID uuid.UUID,
) (deleted bool, err error)
// ListIdentities returns every external identity linked to the user.
ListIdentities(ctx context.Context, userID uuid.UUID) ([]Identity, error)
}
// Principal adapts a concrete [User] record to the [login.User] interface
// consumed by the authorization server.
//
// Callers holding an [login.User] produced by a [Manager] may type-assert it
// back to Principal to reach the full record, for example to plan login
// factors from [User.Factors].
type Principal struct {
// U is the underlying user record.
U *User
}
// ID implements [login.User].
func (p Principal) ID() uuid.UUID { return p.U.ID }
// Username implements [login.User]: the login identifier is the user's email
// address.
func (p Principal) Username() string { return p.U.Email }
// Roles implements [login.User].
func (p Principal) Roles() []string { return p.U.Roles }
var _ login.User = Principal{}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package witness
import (
"context"
"encoding/json/v2"
"fmt"
"strconv"
"time"
"uuid"
"github.com/deep-rent/nexus/eco/attest"
"github.com/deep-rent/nexus/eco/iam/team"
"github.com/deep-rent/nexus/eco/iam/topic"
"github.com/deep-rent/nexus/eco/iam/user"
"github.com/deep-rent/nexus/eco/identity"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/sys/event"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/queue"
)
// Kind is the queue kind carrying one assertion to the trail.
const Kind = "iam.attest"
// SubmitTimeout bounds the enqueue an event handler performs on the
// bus's dispatch goroutine, so an unwell database delays other
// consumers by a bound rather than a hang.
const SubmitTimeout = 5 * time.Second
// directoryActions maps directory lifecycle kinds onto recorded
// actions. Where a public webhook topic exists the action IS the
// topic; password changes are not published as webhooks and carry a
// name of the same grammar.
var directoryActions = map[user.EventKind]string{
user.EventUserDisabled: identity.TopicUserDisabled,
user.EventUserEnabled: identity.TopicUserEnabled,
user.EventUserDeleted: identity.TopicUserDeleted,
user.EventPasswordChanged: "iam.user.password_changed",
}
// teamActions maps team lifecycle kinds onto recorded actions.
var teamActions = map[team.EventKind]string{
team.EventFounded: identity.TopicTeamFounded,
team.EventDissolved: identity.TopicTeamDissolved,
team.EventJoined: identity.TopicTeamJoined,
team.EventLeft: identity.TopicTeamLeft,
team.EventPromoted: identity.TopicTeamPromoted,
team.EventDemoted: identity.TopicTeamDemoted,
}
// Jobs is the durable outbox, satisfied by [queue.Queue].
type Jobs interface {
Submit(ctx context.Context, r queue.Request) (
queue.Job, bool, error)
}
// Config bundles the collaborators of a [Witness].
type Config struct {
// Jobs is the queue the assertions ride to the trail. Required.
Jobs Jobs
// Logger receives diagnostics. Defaults to [log.Discard].
Logger *log.Logger
// Clock stamps team events, which carry no time of their own.
// Defaults to [clock.System].
Clock clock.Clock
}
// Witness enqueues one durable assertion per lifecycle event. Create
// instances with [New] and subscribe them with [Witness.Attach].
type Witness struct {
ctx context.Context
cfg Config
}
// New assembles a [Witness] under the given lifetime context; handlers
// run detached from the requests that raised their events, so this is
// the context the enqueue runs under. It panics without a queue.
func New(ctx context.Context, cfg Config) *Witness {
if cfg.Jobs == nil {
panic("a queue is required")
}
if cfg.Logger == nil {
cfg.Logger = log.Discard()
}
if cfg.Clock == nil {
cfg.Clock = clock.System
}
return &Witness{ctx: ctx, cfg: cfg}
}
// Attach subscribes the witness to the lifecycle topics.
func (w *Witness) Attach(b *event.Broker) {
topic.Directory(b).Subscribe(w.onDirectory)
topic.Teams(b).Subscribe(w.onTeam)
}
// Job is the queue payload: the assertion, minus what the trail
// derives. It is thin on purpose — identifiers and a stamp.
type Job struct {
// Action is the recorded action, in the topic grammar.
Action string `json:"action"`
// Actor is who caused it; zero for the person's own act or a
// machine's.
Actor uuid.UUID `json:"actor,omitzero"`
// Subject is whom or what it concerns.
Subject uuid.UUID `json:"subject,omitzero"`
// OccurredAt is when it happened.
OccurredAt time.Time `json:"occurred_at"`
// Key names this occurrence.
Key string `json:"key"`
}
// onDirectory witnesses a directory lifecycle event.
func (w *Witness) onDirectory(e user.Event) {
action, ok := directoryActions[e.Kind]
if !ok {
return
}
at := e.At
if at.IsZero() {
at = w.cfg.Clock()
}
w.enqueue(Job{
Action: action,
Subject: e.UserID,
OccurredAt: at,
Key: action + ":" + e.UserID.String() + ":" +
strconv.FormatInt(at.UnixNano(), 10),
})
}
// onTeam witnesses a team lifecycle event.
func (w *Witness) onTeam(e team.Event) {
action, ok := teamActions[e.Kind]
if !ok {
return
}
// The member is the subject where the event names one; the team
// itself is the subject of its founding and dissolution.
subject := e.UserID
if subject == uuid.Nil() {
subject = e.TeamID
}
at := w.cfg.Clock()
w.enqueue(Job{
Action: action,
Actor: e.Actor,
Subject: subject,
OccurredAt: at,
Key: action + ":" + e.TeamID.String() + ":" +
subject.String() + ":" +
strconv.FormatInt(at.UnixNano(), 10),
})
}
// enqueue puts one durable assertion job on the queue. The relay
// publishes on this same goroutine, so a bounded submit here follows
// the established pattern; an unwell queue costs a warning rather than
// a hang, and the trail's own dedup makes any later replay harmless.
func (w *Witness) enqueue(j Job) {
payload, err := json.Marshal(j)
if err != nil {
w.cfg.Logger.Error(w.ctx, "Failed to encode an assertion job",
log.Error(err))
return
}
ctx, cancel := context.WithTimeout(
context.WithoutCancel(w.ctx), SubmitTimeout,
)
defer cancel()
if _, _, err := w.cfg.Jobs.Submit(ctx, queue.Request{
Kind: Kind,
Payload: payload,
Key: Kind + ":" + j.Key,
}); err != nil {
w.cfg.Logger.Error(w.ctx,
"Failed to enqueue an assertion; history has a hole",
log.String("action", j.Action),
log.Error(err),
)
}
}
// Handler builds the queue handler asserting one job to the trail.
//
// A refusal no retry mends aborts; everything else costs an attempt
// and comes back on the queue's backoff, to the full budget — a
// dead-lettered assertion is an operator's signal that history has a
// hole, and quiet settlement would file the hole under success.
func Handler(p *attest.Publisher) queue.Handler {
if p == nil {
panic("a publisher is required")
}
return func(ctx context.Context, job queue.Job) error {
var j Job
if err := json.Unmarshal(job.Payload, &j); err != nil {
return queue.Abort(fmt.Errorf(
"failed to parse an assertion job: %w", err,
))
}
_, err := p.Record(ctx, attest.Request{
Action: j.Action,
Actor: j.Actor,
Subject: j.Subject,
OccurredAt: j.OccurredAt,
Key: j.Key,
})
if attest.Permanent(err) {
return queue.Abort(err)
}
return err
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package identity
import (
"net/http"
"strings"
"time"
"github.com/deep-rent/nexus/eco/client"
)
// Config declares how a service reads people from the identity service.
//
// It lives here rather than in each service's own configuration because
// every consumer needs exactly the same six values, and a copy per
// service is a copy that drifts. Bind it under a prefix of the service's
// choosing — conventionally DIRECTORY_:
//
// type Config struct {
// boot.Core `env:",inline"`
// Directory identity.Config `env:",prefix:DIRECTORY_"`
// // ... the service's own sections
// }
//
// The credentials are the calling service's own, held in its
// environment: a machine client acts on its vetted scopes alone, so the
// token it receives carries exactly the directory permission and nothing
// more. They belong to the service rather than to a signed-in person
// because the reads that need them — a notification fan-out, a nightly
// sweep — run with nobody signed in.
//
// # Whether a directory is optional
//
// No field carries a required tag: whether a deployment may run without
// a directory is the SERVICE's judgment, not the section's. A service
// that cannot manage without one checks [Config.Enabled] during assembly
// and refuses to start; one that degrades gracefully — reading a
// language it could have negotiated, say — logs the absence and carries
// on. That is the same division [boot.Database] draws.
//
// [boot.Database]: github.com/deep-rent/nexus/sys/boot#Database
type Config struct {
// URL is the identity service's base URL. Empty leaves the
// directory unread; see [Config.Enabled].
URL string
// Credentials are the calling service's own machine credentials.
// Unlike the outbound clients, TokenURL may be left empty here:
// the identity service issues the tokens it also accepts, so the
// endpoint derives from URL — see [Config.Tokens].
client.Credentials `env:",inline"`
// Scope is what the minted token asks for. The default is the
// narrow read permission the directory API demands; see
// [iam/identity.PermDirectoryRead].
//
// [iam/identity.PermDirectoryRead]:
// github.com/deep-rent/nexus/eco/iam/directory#PermDirectoryRead
Scope string `env:",default:'iam:directory:read'"`
// TTL is how long a resolved person is reused before being read
// again. Zero keeps [DefaultTTL].
TTL time.Duration
}
// Enabled reports whether a directory is configured. A service reads it
// during assembly to decide whether to build one — and, if it cannot
// manage without, whether to refuse to start.
func (c Config) Enabled() bool {
return c.URL != "" && c.ClientID != "" && c.ClientSecret != ""
}
// Tokens returns the token endpoint, deriving it from the base URL when
// none is configured.
func (c Config) Tokens() string {
if c.TokenURL != "" {
return c.TokenURL
}
return strings.TrimSuffix(c.URL, "/") + "/token"
}
// Open builds a directory client from its configuration, minting tokens
// through the client-credentials grant.
//
// It is the one-line form of [New] paired with [Grant], which is what
// every consumer wants: the six configured values and the service's own
// HTTP client. It panics on a configuration [Config.Enabled] rejects,
// since assembling a client that can reach nothing is a programmer
// error — check first, and decide there whether the absence is fatal.
func Open(
cfg Config,
client *http.Client,
opts ...Option,
) *Directory {
if !cfg.Enabled() {
panic("directory URL and machine credentials are required")
}
// The token endpoint derives from the base URL when the deployment
// names none: the identity service issues the tokens it accepts.
creds := cfg.Credentials
creds.TokenURL = cfg.Tokens()
return New(cfg.URL, creds.Source(cfg.Scope, client), append([]Option{
WithClient(client),
WithTTL(cfg.TTL),
}, opts...)...)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package identity
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"sync"
"time"
"uuid"
"github.com/deep-rent/nexus/eco/client"
"github.com/deep-rent/nexus/sec/token"
"github.com/deep-rent/nexus/std/clock"
)
// MaxCache bounds how many people are held at once. A service resolves
// the same few hundred names over and over, so this is a guard against
// a process that runs for months rather than a working limit: reaching
// it drops what has expired, and failing that, the lot.
const MaxCache = 4096
// DefaultTTL is how long a resolved person is reused before being
// read again. Names and languages are cosmetic and change rarely, so
// a short window costs nothing; the identity service publishes no
// event for a rename, which is why this is a timer rather than an
// invalidation.
const DefaultTTL = 10 * time.Minute
// Person is what the directory says about someone. It is the identity
// service's answer, held only as long as [DefaultTTL] and never written
// to the calling service's own tables.
//
// The shape is the identity service's published contract, carried here
// as an external subscriber would carry it; see the package
// documentation.
type Person struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name,omitzero"`
Email string `json:"email,omitzero"`
Locales []string `json:"locales,omitzero"`
// Zone is the preferred time zone as an IANA name — what a sender
// deciding WHEN to reach somebody reads, as Locales is what it reads
// to decide in which language. The identity service carries it on
// resolution answers only, so a listing leaves it empty.
Zone string `json:"zone,omitzero"`
Roles []string `json:"roles,omitzero"`
}
// Addressed reports whether the person can be mailed.
func (p Person) Addressed() bool { return p.Email != "" }
// Salutation is how to address them: the name they chose, falling
// back to the name they gave.
func (p Person) Salutation() string {
if p.DisplayName != "" {
return p.DisplayName
}
return p.Name
}
// Option configures a [Directory].
type Option func(*Directory)
// WithClient sets the HTTP client used to reach the identity service.
func WithClient(c *http.Client) Option {
return func(d *Directory) {
if c != nil {
d.http = c
}
}
}
// WithTTL sets how long a resolved person is reused.
func WithTTL(ttl time.Duration) Option {
return func(d *Directory) {
if ttl > 0 {
d.ttl = ttl
}
}
}
// WithClock injects the time source, which is primarily useful for
// testing. A nil clock is ignored.
func WithClock(now clock.Clock) Option {
return func(d *Directory) {
if now != nil {
d.now = now
}
}
}
// Directory reads people from the identity service's directory API.
//
// It is the calling service's only door to identity: no name, address,
// or language is stored behind it, so a person renamed or deleted in the
// identity service is renamed or deleted everywhere, and the caller
// holds nothing it would have to be asked to erase.
//
// Safe for concurrent use.
type Directory struct {
peer *client.Client
http *http.Client
ttl time.Duration
now clock.Clock
mu sync.Mutex
cache map[uuid.UUID]entry
}
// entry is one cached person and when it goes stale.
type entry struct {
person Person
until time.Time
}
// New builds a directory client against the identity service at base,
// authenticating with tokens minted by src.
func New(base string, src *token.Source, opts ...Option) *Directory {
d := &Directory{
ttl: DefaultTTL,
now: clock.System,
cache: map[uuid.UUID]entry{},
}
for _, opt := range opts {
opt(d)
}
// Built last, so an option supplying the HTTP client is honoured.
// A missing base or source panics here, in [client.New].
d.peer = client.New(base, src, client.WithHTTP(d.http))
return d
}
// Resolve reads one person, reporting whether they exist.
func (d *Directory) Resolve(
ctx context.Context,
id uuid.UUID,
) (Person, bool, error) {
found, err := d.ResolveAll(ctx, []uuid.UUID{id})
if err != nil {
return Person{}, false, err
}
p, ok := found[id]
return p, ok, nil
}
// ResolveAll reads several people at once, answering from the cache
// where it can and asking for the rest in one request. Identifiers
// that match nobody are simply absent.
func (d *Directory) ResolveAll(
ctx context.Context,
ids []uuid.UUID,
) (map[uuid.UUID]Person, error) {
out := make(map[uuid.UUID]Person, len(ids))
var missing []uuid.UUID
now := d.now()
d.mu.Lock()
for _, id := range ids {
if e, ok := d.cache[id]; ok && now.Before(e.until) {
out[id] = e.person
continue
}
missing = append(missing, id)
}
d.mu.Unlock()
if len(missing) == 0 {
return out, nil
}
people, err := d.fetch(ctx, "/directory/users/resolve",
map[string]any{"ids": missing})
if err != nil {
return nil, err
}
d.mu.Lock()
d.evict(now)
until := now.Add(d.ttl)
for _, p := range people {
out[p.ID] = p
d.cache[p.ID] = entry{person: p, until: until}
}
d.mu.Unlock()
return out, nil
}
// evict keeps the cache bounded. The caller holds the lock.
func (d *Directory) evict(now time.Time) {
if len(d.cache) < MaxCache {
return
}
for id, e := range d.cache {
if !now.Before(e.until) {
delete(d.cache, id)
}
}
if len(d.cache) >= MaxCache {
// Everything held is still live, so there is nothing to
// choose between: start over rather than grow. The cost is a
// round trip per name, which is what the cache saves rather
// than what it guarantees.
clear(d.cache)
}
}
// Lookup resolves an address to a person, reporting whether one holds
// it.
//
// It deliberately does not read the cache: the caller is deciding
// whether to give someone access to something, and that decision is
// worth a fresh answer even though a stale name is not.
func (d *Directory) Lookup(
ctx context.Context,
email string,
) (Person, bool, error) {
people, err := d.fetch(ctx, "/directory/users/resolve",
map[string]any{"emails": []string{email}})
if err != nil {
return Person{}, false, err
}
if len(people) == 0 {
return Person{}, false, nil
}
p := people[0]
now := d.now()
d.mu.Lock()
d.evict(now)
d.cache[p.ID] = entry{person: p, until: now.Add(d.ttl)}
d.mu.Unlock()
return p, true, nil
}
// Membership is one user's standing in one team, as the identity
// service answers it.
type Membership struct {
// Owner reports whether the member holds the management role.
Owner bool `json:"owner"`
// Since is when the membership was established.
Since time.Time `json:"since,omitzero"`
}
// Member answers one user's standing in one team, reporting whether
// they belong to it at all. A team that does not exist reads exactly
// like a team the user is not in, because that is how the identity
// service answers.
//
// Like [Directory.Lookup], it deliberately never caches: the caller is
// deciding whether somebody may act for a team — direct a purchase to
// it, cancel its subscription — and that decision is worth a fresh
// answer.
func (d *Directory) Member(
ctx context.Context,
teamID, userID uuid.UUID,
) (Membership, bool, error) {
var m Membership
err := d.peer.Do(ctx, client.Call{
Path: "/directory/teams/" + teamID.String() +
"/members/" + userID.String(),
Into: &m,
})
if err != nil {
var fault *client.APIError
if errors.As(err, &fault) {
if fault.Status == http.StatusNotFound {
return Membership{}, false, nil
}
return Membership{}, false, fmt.Errorf(
"the directory answered %d", fault.Status,
)
}
return Membership{}, false, fmt.Errorf(
"failed to reach the directory: %w", err,
)
}
return m, true, nil
}
// Staff lists the holders of one role — who a deployment may hand work
// to, or treat as privileged.
func (d *Directory) Staff(
ctx context.Context,
role string,
) ([]Person, error) {
return d.fetch(ctx, "/directory/users?role="+url.QueryEscape(role), nil)
}
// Forget drops someone from the cache, for when the identity service
// says they are gone.
func (d *Directory) Forget(id uuid.UUID) {
d.mu.Lock()
defer d.mu.Unlock()
delete(d.cache, id)
}
// fetch performs one authenticated call returning people. A nil body
// sends a GET, which is what the role listing is.
func (d *Directory) fetch(
ctx context.Context,
path string,
body any,
) ([]Person, error) {
call := client.Call{Path: path}
if body != nil {
call.Method = http.MethodPost
call.Body = body
}
var out struct {
Users []Person `json:"users"`
}
call.Into = &out
if err := d.peer.Do(ctx, call); err != nil {
var fault *client.APIError
if errors.As(err, &fault) {
return nil, fmt.Errorf(
"the directory answered %d", fault.Status,
)
}
return nil, fmt.Errorf("failed to reach the directory: %w", err)
}
return out.Users, nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package identity
import (
"uuid"
"github.com/deep-rent/nexus/net/notify/hook"
)
// The identity service's webhook topics, as its subscribers name them.
//
// The strings are that service's PUBLIC contract (see eco/iam/relay).
// They are carried here rather than imported from it for the same reason
// [Person] duplicates the directory's answer shape: a sibling holds
// identity's published contract exactly as an external subscriber would,
// so a change to how identity models a user internally is not free to
// reach across the ecosystem.
//
// What this package adds over three services each spelling the strings
// out for themselves is that they can no longer drift apart, and that a
// new topic is documented once.
//
// A subscriber registers only the topics it acts on. The full vocabulary
// is listed because a receiver is registered against it at the identity
// service, and an operator writing that registration should not have to
// read another service's source to learn what may be subscribed to.
const (
// TopicUserEnabled fires when a disabled account is unlocked.
TopicUserEnabled = "iam.user.enabled"
// TopicUserDisabled fires when an account is locked out.
TopicUserDisabled = "iam.user.disabled"
// TopicUserDeleted fires when an account is removed, after the
// record is gone. It is the one every service holding personal data
// should subscribe to: hearing it is what turns a deletion in the
// identity service into a deletion everywhere.
TopicUserDeleted = "iam.user.deleted"
// TopicTeamFounded fires when a team is created.
TopicTeamFounded = "iam.team.founded"
// TopicTeamDissolved fires when a team is deleted.
TopicTeamDissolved = "iam.team.dissolved"
// TopicTeamJoined fires when a user enters a team.
TopicTeamJoined = "iam.team.joined"
// TopicTeamLeft fires when a user exits a team.
TopicTeamLeft = "iam.team.left"
// TopicTeamPromoted fires when a member is granted the owner role.
TopicTeamPromoted = "iam.team.promoted"
// TopicTeamDemoted fires when an owner is reduced to a member.
TopicTeamDemoted = "iam.team.demoted"
)
// Topics lists every topic the identity service publishes.
var Topics = []string{
TopicUserEnabled,
TopicUserDisabled,
TopicUserDeleted,
TopicTeamFounded,
TopicTeamDissolved,
TopicTeamJoined,
TopicTeamLeft,
TopicTeamPromoted,
TopicTeamDemoted,
}
// Event is the body of every event the identity service publishes:
// identifiers, and nothing else.
//
// A receiver that needs the account behind an identifier reads it back
// through [Directory] under its own authority, which keeps names and
// addresses out of third-party request logs and leaves a captured
// delivery worth nothing.
//
// Every field is optional on the wire, because one shape serves nine
// topics: a directory event names no team, a team event may name no
// member, and an administrative machine client is an actor that names no
// user. A handler checks the identifiers its own topic guarantees; see
// [Event.User] and [Event.Team].
type Event struct {
// UserID is the account the event concerns, absent on team-level
// events that name no member.
UserID uuid.UUID `json:"user_id,omitzero"`
// TeamID is the team the event concerns, absent on directory events.
TeamID uuid.UUID `json:"team_id,omitzero"`
// ActorID is who caused the event, absent when an administrative
// machine client did, since that names no user.
ActorID uuid.UUID `json:"actor_id,omitzero"`
}
// User reads the account an event names, reporting whether it named one.
//
// The boolean is the whole point: an event of a subscribed topic that
// names nobody is a sender-side defect, and a handler should refuse the
// delivery rather than act on the nil identifier.
func (e Event) User() (uuid.UUID, bool) {
return e.UserID, e.UserID != uuid.Nil()
}
// Team reads the team an event names, reporting whether it named one.
// See [Event.User].
func (e Event) Team() (uuid.UUID, bool) {
return e.TeamID, e.TeamID != uuid.Nil()
}
// Decode reads an event out of a webhook delivery.
//
// It is the one line every subscriber's handler opens with, and it
// exists so the wire shape is decoded in exactly one place rather than
// redeclared per service:
//
// rcv.On(identity.TopicUserDeleted, func(
// e *router.Exchange, d hook.Delivery,
// ) error {
// ev, err := identity.Decode(d)
// if err != nil {
// return err
// }
// id, ok := ev.User()
// ...
// })
func Decode(d hook.Delivery) (Event, error) {
var ev Event
if err := d.Decode(&ev); err != nil {
return Event{}, err
}
return ev, nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package anomaly
import (
"context"
"encoding/json/v2"
"fmt"
"math"
"sync"
"time"
"github.com/jackc/pgx/v5"
"github.com/deep-rent/nexus/eco/mma/ingest"
"github.com/deep-rent/nexus/eco/mma/store"
"github.com/deep-rent/nexus/net/notify/hook"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/std/stat"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/metrics"
)
// The webhook topics the engine publishes; see the package
// documentation for the payload shape.
const (
// TopicOpened announces an anomaly that was just established.
TopicOpened = "mma.anomaly.opened"
// TopicResolved announces one whose condition cleared.
TopicResolved = "mma.anomaly.resolved"
)
// Topics are the webhook topics this engine publishes — the whole
// vocabulary a subscriber may register for.
var Topics = []string{TopicOpened, TopicResolved}
// The engine's fixed time geometry and model tuning. Slots quantize
// the seasonal profile; sweeps stay at the collector's cadence.
const (
// SlotDuration quantizes the model updates and the seasonal
// profile: 48 slots cover a day.
SlotDuration = 30 * time.Minute
dailyPeriod = 48
// devWindow sizes the residual ring behind the robust score: a
// few hours of sweeps at the default cadence. devReady is the
// engine's own readiness floor over it, low enough that a replay
// at slot resolution (one residual per slot) can satisfy it.
devWindow = 512
devReady = 64
// The Holt–Winters gains: a responsive level, a cautious trend,
// and a season that absorbs shape without chasing noise.
gainLevel = 0.3
gainTrend = 0.05
gainSeason = 0.2
// clearSweeps is how many consecutive calm sweeps resolve a spike
// or bounds anomaly; clearSlots does the same for slot-paced
// detectors (drift, ceiling).
clearSweeps = 10
clearSlots = 2
// openSlots is how many consecutive within-horizon projections
// establish a ceiling anomaly.
openSlots = 2
// TrainWindow is how much history the engine replays at start to
// re-warm its models: two full seasonal periods.
TrainWindow = 48 * time.Hour
// maxScore caps the recorded evidence: a flat baseline calls any
// departure infinitely unusual, and a ledger row cannot carry
// infinity.
maxScore = 1e9
)
// Ledger is the slice of the store the engine works through.
// Implemented by [store.Store].
type Ledger interface {
// Exec runs fn within a single transaction.
Exec(ctx context.Context,
fn func(ctx context.Context, tx pgx.Tx) error) error
// OpenAnomaly records a fresh anomaly.
OpenAnomaly(ctx context.Context, tx pgx.Tx, a *store.Anomaly) (
int64, error)
// ResolveAnomaly stamps an open anomaly resolved.
ResolveAnomaly(ctx context.Context, tx pgx.Tx, id int64,
at time.Time) error
// Anomalies lists the ledger under a filter.
Anomalies(ctx context.Context, tx pgx.Tx, f store.AnomalyFilter) (
[]store.Anomaly, error)
// Catalog lists the series under a filter.
Catalog(ctx context.Context, tx pgx.Tx, f store.Filter) (
[]store.Info, error)
// Range serves step-bucketed aggregates for the replay.
Range(ctx context.Context, tx pgx.Tx, ids []int64, from, to time.Time,
step time.Duration, buckets bool) ([]store.Slice, error)
}
// Publisher is the slice of the webhook engine the detector publishes
// through. Implemented by [hook.Engine]; nil disables publishing.
type Publisher interface {
Publish(ctx context.Context, tx pgx.Tx, event hook.Event) (int, error)
}
// Option configures an [Engine].
type Option func(*Engine)
// WithLogger sets the logger narrating openings, resolutions, and
// publish failures. A nil logger is ignored.
func WithLogger(logger *log.Logger) Option {
return func(e *Engine) {
if logger != nil {
e.logger = logger
}
}
}
// WithClock injects the time source, which is primarily useful for
// testing. A nil clock is ignored.
func WithClock(now clock.Clock) Option {
return func(e *Engine) {
if now != nil {
e.now = now
}
}
}
// WithPublisher routes anomaly events onto the webhook engine. A nil
// publisher keeps detection local: ledger and log only.
func WithPublisher(p Publisher) Option {
return func(e *Engine) { e.hooks = p }
}
// WithRegistry registers the engine's own instruments with reg instead
// of [metrics.DefaultRegistry]. A nil registry is ignored.
func WithRegistry(reg *metrics.Registry) Option {
return func(e *Engine) {
if reg != nil {
e.reg = reg
}
}
}
// An Engine turns the recorder's committed observations into anomaly
// verdicts: it keeps one set of streaming models per matched series,
// opens an anomaly when a detector's condition is established, and
// resolves it when the condition clears. Openings and resolutions land
// in the ledger and — when a publisher is attached — on the webhook
// topics, both in one transaction.
//
// An Engine is safe for concurrent use; the collector invokes sinks
// concurrently across targets.
type Engine struct {
db Ledger
hooks Publisher
logger *log.Logger
now clock.Clock
reg *metrics.Registry
rules []Rule
mu sync.Mutex
states map[string]*state // series fingerprint -> model state
skips map[string]bool // fingerprints no rule matches
open int // open anomalies, mirrored to the gauge
}
// state carries everything the engine knows about one watched series.
type state struct {
id int64
series store.Series
rule *Rule
// Counter series derive a rate from consecutive raw values.
counter bool
lastAt time.Time
lastRaw float64
hasRaw bool
// The models: forecast, robust score, and shift evidence.
sm *stat.Smoother
dev *stat.Deviation
dr *stat.Drift
// The slot under accumulation.
slotStart time.Time
slotSum float64
slotN int
// Detector bookkeeping: consecutive hits and calms, and the open
// anomaly per detector.
spikeRun, spikeCalm int
boundsRun, boundsCalm int
ceilRun, ceilCalm int
driftCalm int
live map[string]*live
}
// live is one open anomaly held by a state.
type live struct{ id int64 }
// New assembles an engine over the given ledger and validated rules.
func New(db Ledger, rules []Rule, opts ...Option) *Engine {
if db == nil {
panic("ledger is required")
}
e := &Engine{
db: db,
logger: log.Discard(),
now: clock.System,
reg: metrics.DefaultRegistry,
rules: rules,
states: make(map[string]*state),
skips: make(map[string]bool),
}
for _, opt := range opts {
opt(e)
}
return e
}
// Observer returns the sink to register with the recorder
// ([ingest.WithObserver]).
func (e *Engine) Observer() func(context.Context, []ingest.Observation) {
return e.Feed
}
// Feed digests one committed batch of observations.
func (e *Engine) Feed(ctx context.Context, obs []ingest.Observation) {
e.mu.Lock()
defer e.mu.Unlock()
for i := range obs {
e.feed(ctx, &obs[i])
}
}
// feed digests a single observation. The caller holds the mutex.
func (e *Engine) feed(ctx context.Context, o *ingest.Observation) {
st := e.state(o)
if st == nil {
return
}
at := o.Point.At
x, ok := st.signal(at, o.Point.Value)
if !ok {
return
}
e.closeSlots(ctx, st, at)
e.sweep(ctx, st, x)
st.slotSum += x
st.slotN++
}
// state resolves the model state for an observation, creating one the
// first time a rule matches the series and remembering the misses.
func (e *Engine) state(o *ingest.Observation) *state {
print := o.Series.Fingerprint()
if st, ok := e.states[print]; ok {
st.id = o.Point.SeriesID
return st
}
if e.skips[print] {
return nil
}
kind := o.Series.Kind
if kind != metrics.KindGauge && kind != metrics.KindCounter {
e.skips[print] = true
return nil
}
for i := range e.rules {
if e.rules[i].Matches(o.Series) {
st := e.mint(&e.rules[i], o.Series)
st.id = o.Point.SeriesID
e.states[print] = st
return st
}
}
e.skips[print] = true
return nil
}
// mint builds a fresh state for a series under a rule.
func (*Engine) mint(r *Rule, s store.Series) *state {
st := &state{
series: s,
rule: r,
counter: s.Kind == metrics.KindCounter,
dev: stat.NewDeviation(devWindow),
live: make(map[string]*live),
}
if r.Spike != nil && r.Spike.Season == "daily" {
st.sm = stat.NewSeasonalSmoother(
gainLevel, gainTrend, gainSeason, dailyPeriod,
)
} else {
st.sm = stat.NewSmoother(gainLevel, gainTrend)
}
if r.Drift != nil {
slack, decision := r.Drift.Slack, r.Drift.Decision
if slack == 0 {
slack = DefaultSlack
}
if decision == 0 {
decision = DefaultDecision
}
st.dr = stat.NewDrift(slack, decision)
}
return st
}
// signal derives the modeled quantity from a raw observation: the
// value itself for gauges, a reset-aware rate for counters.
func (st *state) signal(at time.Time, raw float64) (float64, bool) {
if math.IsNaN(raw) || math.IsInf(raw, 0) {
return 0, false
}
if !st.counter {
return raw, true
}
defer func() { st.lastAt, st.lastRaw, st.hasRaw = at, raw, true }()
if !st.hasRaw {
return 0, false
}
dt := at.Sub(st.lastAt).Seconds()
if dt <= 0 {
return 0, false
}
delta := raw - st.lastRaw
if delta < 0 {
delta = raw // The counter reset; the fresh value is the delta.
}
return delta / dt, true
}
// closeSlots folds finished slots into the models: the accumulated
// mean observes, empty slots skip, and the slot-paced detectors run.
func (e *Engine) closeSlots(ctx context.Context, st *state, at time.Time) {
slot := at.Truncate(SlotDuration)
if st.slotStart.IsZero() {
st.slotStart = slot
return
}
for st.slotStart.Before(slot) {
if st.slotN > 0 {
st.sm.Observe(st.slotSum / float64(st.slotN))
} else {
st.sm.Skip(1)
}
st.slotSum, st.slotN = 0, 0
st.slotStart = st.slotStart.Add(SlotDuration)
e.projectCeiling(ctx, st)
}
st.slotStart = slot
}
// projectCeiling runs the exhaustion check at a slot boundary.
func (e *Engine) projectCeiling(ctx context.Context, st *state) {
c := st.rule.Ceiling
if c == nil || !st.sm.Ready() {
return
}
level, trend := st.sm.Level(), st.sm.Trend()
within := false
var eta time.Duration
switch {
case level >= c.Limit:
within, eta = true, 0
case trend > 0:
slots := (c.Limit - level) / trend
eta = time.Duration(slots * float64(SlotDuration))
within = eta <= time.Duration(c.Horizon)
}
if within {
st.ceilRun++
st.ceilCalm = 0
if st.ceilRun >= openSlots {
e.raise(ctx, st, "ceiling", level, c.Limit, eta.Hours())
}
return
}
st.ceilRun = 0
st.ceilCalm++
if st.ceilCalm >= clearSlots {
e.clear(ctx, st, "ceiling")
}
}
// sweep runs the per-sweep detectors on one signal value.
func (e *Engine) sweep(ctx context.Context, st *state, x float64) {
// The residual feeds the yardstick whenever any model-based
// detector wants it — a drift-only rule needs scores too.
if st.rule.Spike != nil || st.dr != nil {
f := st.sm.Forecast()
r := x - f
// Drift accumulates at sweep cadence: the level gain absorbs
// a step within a few slots, so slot-paced evidence would be
// eaten by its own baseline. The raised default decision
// threshold pays for the sweeps' correlation.
if st.dr != nil && st.ready() {
z := st.score(r)
switch {
case st.dr.Observe(z):
e.raise(ctx, st, "drift", x, f, z)
case math.Abs(z) < 1:
st.driftCalm++
if st.driftCalm >= clearSweeps {
e.clear(ctx, st, "drift")
}
default:
st.driftCalm = 0
}
}
if s := st.rule.Spike; s != nil && st.ready() {
sens := s.Sensitivity
if sens == 0 {
sens = DefaultSensitivity
}
sustain := s.Sustain
if sustain == 0 {
sustain = DefaultSustain
}
z := st.score(r)
switch {
case math.Abs(z) >= sens:
st.spikeRun++
st.spikeCalm = 0
if st.spikeRun >= sustain {
e.raise(ctx, st, "spike", x, f, z)
}
case math.Abs(z) <= sens/2:
st.spikeRun = 0
st.spikeCalm++
if st.spikeCalm >= clearSweeps {
e.clear(ctx, st, "spike")
}
default:
st.spikeRun = 0
}
}
st.dev.Observe(r)
}
if b := st.rule.Bounds; b != nil {
e.checkBounds(ctx, st, b, x)
}
}
// checkBounds runs the static threshold detector on one value.
func (e *Engine) checkBounds(
ctx context.Context, st *state, b *Bounds, x float64,
) {
sustain := b.Sustain
if sustain == 0 {
sustain = DefaultSustain
}
bound, over := 0.0, 0.0
switch {
case b.Min != nil && x < *b.Min:
bound, over = *b.Min, *b.Min-x
case b.Max != nil && x > *b.Max:
bound, over = *b.Max, x-*b.Max
default:
st.boundsRun = 0
st.boundsCalm++
if st.boundsCalm >= clearSweeps {
e.clear(ctx, st, "bounds")
}
return
}
st.boundsRun++
st.boundsCalm = 0
if st.boundsRun >= sustain {
e.raise(ctx, st, "bounds", x, bound, over)
}
}
// ready reports whether the models carry enough history for verdicts.
// The residual floor is the engine's own, set low enough that a slot-
// resolution replay can satisfy it.
func (st *state) ready() bool {
return st.sm.Ready() && st.dev.Len() >= devReady
}
// score standardizes a residual about zero — the forecast is the
// center, the ring supplies only the scale. Centering on the ring's
// own median would let a sustained shift re-center itself away before
// the drift detector could accumulate it.
func (st *state) score(r float64) float64 {
sigma := st.dev.Sigma()
switch {
case r == 0:
return 0
case sigma == 0:
return math.Copysign(math.Inf(1), r)
default:
return r / sigma
}
}
// payload is the webhook body: identifiers and numbers, nothing else.
type payload struct {
Target string `json:"target"`
Metric string `json:"metric"`
Tags map[string]string `json:"tags,omitempty"`
Rule string `json:"rule"`
Detector string `json:"detector"`
Severity string `json:"severity"`
Observed float64 `json:"observed"`
Expected float64 `json:"expected"`
Score float64 `json:"score"`
OpenedAt time.Time `json:"opened_at"`
ResolvedAt time.Time `json:"resolved_at,omitzero"`
}
// raise opens an anomaly unless the detector already holds one. The
// ledger row and the webhook event commit together; on failure the
// anomaly stays unopened and the persisting condition retries on the
// next sweep.
func (e *Engine) raise(
ctx context.Context,
st *state,
detector string,
observed, expected, score float64,
) {
if _, ok := st.live[detector]; ok {
return
}
// A flat baseline scores any departure as infinite, which neither
// JSON nor a reader needs; the ledger caps the evidence instead.
if math.IsInf(score, 0) {
score = math.Copysign(maxScore, score)
}
now := e.now().UTC()
a := &store.Anomaly{
SeriesID: st.id,
Rule: st.rule.Name,
Detector: detector,
Severity: st.rule.severity(),
OpenedAt: now,
Observed: observed,
Expected: expected,
Score: score,
}
err := e.db.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
id, err := e.db.OpenAnomaly(ctx, tx, a)
if err != nil {
return err
}
a.ID = id
return e.publish(ctx, tx, TopicOpened, st, a, now)
})
if err != nil {
e.logger.Warn(ctx, "Failed to open an anomaly",
log.String("rule", st.rule.Name),
log.String("detector", detector),
log.Error(err),
)
return
}
st.live[detector] = &live{id: a.ID}
e.open++
e.gauge()
e.reg.Counter("mma_anomalies_total",
metrics.T("detector", detector),
metrics.T("severity", a.Severity),
).Inc()
e.logger.Warn(ctx, "Anomaly opened",
log.String("rule", st.rule.Name),
log.String("detector", detector),
log.String("target", st.series.Target),
log.String("metric", st.series.Name),
log.Float64("observed", observed),
log.Float64("expected", expected),
log.Float64("score", score),
)
}
// clear resolves the detector's open anomaly, if it holds one.
func (e *Engine) clear(ctx context.Context, st *state, detector string) {
l, ok := st.live[detector]
if !ok {
return
}
now := e.now().UTC()
err := e.db.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
if err := e.db.ResolveAnomaly(ctx, tx, l.id, now); err != nil {
return err
}
a := &store.Anomaly{
ID: l.id,
Rule: st.rule.Name,
Detector: detector,
Severity: st.rule.severity(),
ResolvedAt: now,
}
return e.publish(ctx, tx, TopicResolved, st, a, now)
})
if err != nil {
e.logger.Warn(ctx, "Failed to resolve an anomaly",
log.String("rule", st.rule.Name),
log.String("detector", detector),
log.Error(err),
)
return
}
delete(st.live, detector)
e.open--
e.gauge()
e.logger.Info(ctx, "Anomaly resolved",
log.String("rule", st.rule.Name),
log.String("detector", detector),
log.String("target", st.series.Target),
log.String("metric", st.series.Name),
)
}
// publish emits one anomaly event, when a publisher is attached.
func (e *Engine) publish(
ctx context.Context,
tx pgx.Tx,
topic string,
st *state,
a *store.Anomaly,
at time.Time,
) error {
if e.hooks == nil {
return nil
}
body, err := json.Marshal(payload{
Target: st.series.Target,
Metric: st.series.Name,
Tags: st.series.Tags,
Rule: a.Rule,
Detector: a.Detector,
Severity: a.Severity,
Observed: a.Observed,
Expected: a.Expected,
Score: a.Score,
OpenedAt: a.OpenedAt,
ResolvedAt: a.ResolvedAt,
})
if err != nil {
return fmt.Errorf("failed to encode the payload: %w", err)
}
_, err = e.hooks.Publish(ctx, tx, hook.Event{
Topic: topic,
At: at,
Data: body,
})
return err
}
// gauge mirrors the open count onto the engine's gauge.
func (e *Engine) gauge() {
e.reg.Gauge("mma_anomalies_open").Set(float64(e.open))
}
// Start reconciles the engine with the ledger and re-warms its models
// from history, so a restart neither re-opens what is already open nor
// alerts off a cold model. It runs before the first sweep.
func (e *Engine) Start(ctx context.Context) error {
e.mu.Lock()
defer e.mu.Unlock()
if err := e.adopt(ctx); err != nil {
return err
}
return e.replay(ctx)
}
// adopt takes over the anomalies that were open when the previous
// process stopped. One whose rule vanished from the file resolves at
// once: nothing will ever clear it again.
func (e *Engine) adopt(ctx context.Context) error {
byName := make(map[string]*Rule, len(e.rules))
for i := range e.rules {
byName[e.rules[i].Name] = &e.rules[i]
}
var open []store.Anomaly
err := e.db.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
var err error
open, err = e.db.Anomalies(ctx, tx, store.AnomalyFilter{Open: true})
return err
})
if err != nil {
return fmt.Errorf("failed to list open anomalies: %w", err)
}
for _, a := range open {
rule, ok := byName[a.Rule]
if !ok || !rule.Matches(a.Series) {
e.orphan(ctx, a)
continue
}
print := a.Series.Fingerprint()
st, tracked := e.states[print]
if !tracked {
st = e.mint(rule, a.Series)
st.id = a.SeriesID
e.states[print] = st
}
st.live[a.Detector] = &live{id: a.ID}
e.open++
}
e.gauge()
return nil
}
// orphan resolves an open anomaly no current rule stands behind.
func (e *Engine) orphan(ctx context.Context, a store.Anomaly) {
now := e.now().UTC()
err := e.db.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
return e.db.ResolveAnomaly(ctx, tx, a.ID, now)
})
if err != nil {
e.logger.Warn(ctx, "Failed to resolve an orphaned anomaly",
log.Error(err))
return
}
e.logger.Info(ctx, "Resolved an anomaly whose rule is gone",
log.String("rule", a.Rule),
log.String("detector", a.Detector),
)
}
// replay feeds the training window through the models at slot
// resolution, so detection resumes warm instead of relearning the
// fleet from nothing.
func (e *Engine) replay(ctx context.Context) error {
now := e.now().UTC()
var infos []store.Info
err := e.db.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
var err error
infos, err = e.db.Catalog(ctx, tx, store.Filter{})
return err
})
if err != nil {
return fmt.Errorf("failed to list the catalog: %w", err)
}
ids := make([]int64, 0, len(infos))
series := make(map[int64]store.Series, len(infos))
for _, info := range infos {
s := store.Series{
Target: info.Target,
Name: info.Name,
Kind: info.Kind,
Tags: info.Tags,
}
if s.Kind != metrics.KindGauge && s.Kind != metrics.KindCounter {
continue
}
for i := range e.rules {
if e.rules[i].Matches(s) {
ids = append(ids, info.ID)
series[info.ID] = s
break
}
}
}
if len(ids) == 0 {
return nil
}
var slices []store.Slice
err = e.db.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
var err error
slices, err = e.db.Range(
ctx, tx, ids, now.Add(-TrainWindow), now, SlotDuration, false,
)
return err
})
if err != nil {
return fmt.Errorf("failed to read the training window: %w", err)
}
warmed := 0
for i := 0; i < len(slices); {
id := slices[i].SeriesID
j := i
for j < len(slices) && slices[j].SeriesID == id {
j++
}
if st := e.warm(series[id], slices[i:j]); st != nil {
st.id = id
warmed++
}
i = j
}
e.logger.Info(ctx, "Replayed the training window",
log.Int("series", warmed),
log.Duration("window", TrainWindow),
)
return nil
}
// warm drives one series' slot history through its models.
func (e *Engine) warm(s store.Series, slices []store.Slice) *state {
print := s.Fingerprint()
st, ok := e.states[print]
if !ok {
for i := range e.rules {
if e.rules[i].Matches(s) {
st = e.mint(&e.rules[i], s)
e.states[print] = st
break
}
}
}
if st == nil {
return nil
}
prev := time.Time{}
for _, sl := range slices {
if !prev.IsZero() {
gap := int(sl.At.Sub(prev)/SlotDuration) - 1
st.sm.Skip(gap)
}
prev = sl.At
x, ok := 0.0, false
if st.counter {
// The slot's closing raw value continues the delta chain
// at slot cadence.
x, ok = st.signal(sl.At, sl.Last)
} else {
x, ok = sl.Avg, true
}
if !ok {
continue
}
st.dev.Observe(x - st.sm.Forecast())
st.sm.Observe(x)
}
st.slotStart = time.Time{}
return st
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package anomaly
import (
"encoding/json/v2"
"fmt"
"os"
"path"
"time"
"github.com/deep-rent/nexus/eco/mma/store"
)
// Default values applied to rule fields left zero.
const (
// DefaultSensitivity is the robust z-score a spike must reach.
DefaultSensitivity = 5.0
// DefaultSustain is how many consecutive sweeps a spike or bounds
// violation must persist before it opens.
DefaultSustain = 3
// DefaultSlack is the drift detector's per-sample allowance.
DefaultSlack = 0.5
// DefaultDecision is the drift detector's decision threshold.
DefaultDecision = 8.0
// DefaultSeverity labels rules that do not choose one.
DefaultSeverity = "warn"
)
// A Rule selects series and names the checks to run on them. Every
// field of the selection must match for a series to be picked up.
type Rule struct {
// Name identifies the rule in anomalies, payloads, and logs.
Name string `json:"name"`
// Match selects the series the rule watches.
Match Match `json:"match"`
// Severity labels the anomalies the rule opens: info, warn, or
// critical. Empty means [DefaultSeverity].
Severity string `json:"severity,omitempty"`
// Spike enables the robust z-score check against the forecast.
Spike *Spike `json:"spike,omitempty"`
// Drift enables the CUSUM check for sustained level shifts.
Drift *Drift `json:"drift,omitempty"`
// Ceiling enables the exhaustion projection.
Ceiling *Ceiling `json:"ceiling,omitempty"`
// Bounds enables the static threshold check.
Bounds *Bounds `json:"bounds,omitempty"`
}
// A Match narrows the series a rule applies to. Target and Metric are
// path globs ("api-*"); empty matches everything. Tags must equal the
// series' values for every listed key.
type Match struct {
Target string `json:"target,omitempty"`
Metric string `json:"metric,omitempty"`
Tags map[string]string `json:"tags,omitempty"`
}
// Spike configures the robust z-score check: how many sigmas from the
// forecast a value must stray ([DefaultSensitivity]), for how many
// consecutive sweeps ([DefaultSustain]), and whether the forecast
// carries a daily seasonal profile (season "daily") or none (empty).
type Spike struct {
Sensitivity float64 `json:"sensitivity,omitempty"`
Sustain int `json:"sustain,omitempty"`
Season string `json:"season,omitempty"`
}
// Drift configures the CUSUM check, in the sigma units of the spike
// check: the per-sweep slack ([DefaultSlack]) and the accumulated
// evidence that opens an anomaly ([DefaultDecision]).
type Drift struct {
Slack float64 `json:"slack,omitempty"`
Decision float64 `json:"decision,omitempty"`
}
// Ceiling configures the exhaustion projection: an anomaly opens while
// the series is projected to reach Limit within Horizon at its current
// trend. Both fields are required.
type Ceiling struct {
Limit float64 `json:"limit"`
Horizon Duration `json:"horizon"`
}
// Bounds configures the static threshold check: an anomaly opens once
// the value sits below Min or above Max for Sustain consecutive sweeps
// ([DefaultSustain]). At least one bound is required.
type Bounds struct {
Min *float64 `json:"min,omitempty"`
Max *float64 `json:"max,omitempty"`
Sustain int `json:"sustain,omitempty"`
}
// A Duration is a [time.Duration] that reads from JSON in the familiar
// "6h" spelling.
type Duration time.Duration
// UnmarshalJSON implements [json.Unmarshaler].
func (d *Duration) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
parsed, err := time.ParseDuration(s)
if err != nil {
return err
}
*d = Duration(parsed)
return nil
}
// MarshalJSON implements [json.Marshaler].
func (d Duration) MarshalJSON() ([]byte, error) {
return json.Marshal(time.Duration(d).String())
}
// Load reads and validates a rules file: {"rules": [...]}.
func Load(file string) ([]Rule, error) {
raw, err := os.ReadFile(file)
if err != nil {
return nil, err
}
var doc struct {
Rules []Rule `json:"rules"`
}
if err := json.Unmarshal(raw, &doc); err != nil {
return nil, fmt.Errorf("failed to parse the rules file: %w", err)
}
if err := Validate(doc.Rules); err != nil {
return nil, err
}
return doc.Rules, nil
}
// Validate checks a rule set for the mistakes a typo makes: missing
// names, empty selections, out-of-range tuning, malformed globs.
func Validate(rules []Rule) error {
names := make(map[string]bool, len(rules))
for i, r := range rules {
if r.Name == "" {
return fmt.Errorf("rule %d has no name", i)
}
if names[r.Name] {
return fmt.Errorf("rule %q is defined twice", r.Name)
}
names[r.Name] = true
fail := func(format string, args ...any) error {
return fmt.Errorf(
"rule %q: %s", r.Name, fmt.Sprintf(format, args...),
)
}
for _, glob := range []string{r.Match.Target, r.Match.Metric} {
if _, err := path.Match(glob, ""); glob != "" && err != nil {
return fail("malformed glob %q", glob)
}
}
switch r.Severity {
case "", "info", "warn", "critical":
default:
return fail("unknown severity %q", r.Severity)
}
if r.Spike == nil && r.Drift == nil &&
r.Ceiling == nil && r.Bounds == nil {
return fail("no detector is enabled")
}
if s := r.Spike; s != nil {
if s.Sensitivity < 0 || s.Sustain < 0 {
return fail("spike tuning must not be negative")
}
if s.Season != "" && s.Season != "daily" {
return fail("unknown season %q", s.Season)
}
}
if d := r.Drift; d != nil {
if d.Slack < 0 || d.Decision < 0 {
return fail("drift tuning must not be negative")
}
}
if c := r.Ceiling; c != nil {
if c.Horizon <= 0 {
return fail("ceiling horizon must be positive")
}
}
if b := r.Bounds; b != nil {
if b.Min == nil && b.Max == nil {
return fail("bounds name neither min nor max")
}
if b.Min != nil && b.Max != nil && *b.Min >= *b.Max {
return fail("bounds min must sit below max")
}
if b.Sustain < 0 {
return fail("bounds sustain must not be negative")
}
}
}
return nil
}
// Matches reports whether the rule's selection picks up the series.
func (r *Rule) Matches(s store.Series) bool {
if !matchGlob(r.Match.Target, s.Target) {
return false
}
if !matchGlob(r.Match.Metric, s.Name) {
return false
}
for k, v := range r.Match.Tags {
if s.Tags[k] != v {
return false
}
}
return true
}
// matchGlob applies a path glob, with the empty pattern matching all.
func matchGlob(glob, v string) bool {
if glob == "" {
return true
}
ok, err := path.Match(glob, v)
return err == nil && ok
}
// severity returns the rule's severity label with the default applied.
func (r *Rule) severity() string {
if r.Severity == "" {
return DefaultSeverity
}
return r.Severity
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package api
import (
"context"
"net/http"
"strconv"
"time"
"github.com/jackc/pgx/v5"
"github.com/deep-rent/nexus/eco/mma/store"
"github.com/deep-rent/nexus/net/router"
)
// anomaly is the wire shape of one ledger row.
type anomaly struct {
ID int64 `json:"id"`
Target string `json:"target"`
Metric string `json:"metric"`
Tags map[string]string `json:"tags,omitempty"`
Rule string `json:"rule"`
Detector string `json:"detector"`
Severity string `json:"severity"`
OpenedAt time.Time `json:"opened_at"`
ResolvedAt time.Time `json:"resolved_at,omitzero"`
Observed float64 `json:"observed"`
Expected float64 `json:"expected"`
Score float64 `json:"score"`
}
// Anomalies serves "GET /anomalies": the detector's ledger, newest
// first. "status=open" keeps only what is currently open, "target"
// narrows to one instance, "since" (RFC 3339) drops older openings,
// and "limit" caps the page below [store.MaxAnomalies].
func Anomalies(db Querier) router.HandlerFunc {
if db == nil {
panic("querier is required")
}
return func(e *router.Exchange) error {
q := e.Query()
f := store.AnomalyFilter{
Open: q.Get("status") == "open",
Target: q.Get("target"),
}
if since := q.Get("since"); since != "" {
at, err := time.Parse(time.RFC3339, since)
if err != nil {
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: "since must be an RFC 3339 stamp",
}
}
f.Since = at
}
if limit := q.Get("limit"); limit != "" {
n, err := strconv.Atoi(limit)
if err != nil || n < 1 {
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: "limit must be a positive integer",
}
}
f.Limit = n
}
var rows []store.Anomaly
err := db.Exec(e.Context(), func(
ctx context.Context, tx pgx.Tx,
) error {
var err error
rows, err = db.Anomalies(ctx, tx, f)
return err
})
if err != nil {
return err
}
out := make([]anomaly, len(rows))
for i, a := range rows {
out[i] = anomaly{
ID: a.ID,
Target: a.Series.Target,
Metric: a.Series.Name,
Tags: a.Series.Tags,
Rule: a.Rule,
Detector: a.Detector,
Severity: a.Severity,
OpenedAt: a.OpenedAt,
ResolvedAt: a.ResolvedAt,
Observed: a.Observed,
Expected: a.Expected,
Score: a.Score,
}
}
e.NoStore()
return e.JSON(http.StatusOK, map[string][]anomaly{"anomalies": out})
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package api
import (
"context"
"net/http"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/eco/mma/store"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/sys/metrics"
"github.com/deep-rent/nexus/sys/metrics/scrape"
)
// Bounds on a range query, guarding the store against one request
// scanning the world.
const (
// MaxSeries caps how many series one range query may resolve;
// narrower filters split larger questions.
MaxSeries = 500
// MaxBuckets caps the step buckets per series in one range query.
MaxBuckets = 2000
// DefaultRange is the window applied when the request names no
// from.
DefaultRange = time.Hour
// DefaultStep is the bucket width applied when the request names no
// step.
DefaultStep = time.Minute
)
// ReasonRangeTooWide indicates a range query resolving more series or
// buckets than the caps allow; narrow the filter, shrink the window, or
// widen the step.
const ReasonRangeTooWide router.Reason = "range_too_wide"
// Querier is the slice of the store the API reads through. Implemented
// by [store.Store].
type Querier interface {
// Exec runs fn within a single transaction.
Exec(ctx context.Context,
fn func(ctx context.Context, tx pgx.Tx) error) error
// Catalog returns the series matching the filter.
Catalog(ctx context.Context, tx pgx.Tx, f store.Filter) (
[]store.Info, error)
// Anomalies lists the detector ledger under a filter.
Anomalies(ctx context.Context, tx pgx.Tx, f store.AnomalyFilter) (
[]store.Anomaly, error)
// Range returns step-bucketed aggregates of the given series,
// carrying the distributions only when buckets is set.
Range(ctx context.Context, tx pgx.Tx, ids []int64,
from, to time.Time, step time.Duration, buckets bool) (
[]store.Slice, error)
}
// Live is the collector view behind the endpoints that answer from
// memory. Implemented by [scrape.Collector].
type Live interface {
// Summary assembles the merged view of the latest snapshots.
Summary() scrape.Summary
}
// Mount registers the query API following the mount convention of this
// framework:
//
// GET /targets live scrape status of every registered endpoint
// GET /series the stored series catalog
// GET /latest current values, straight from memory
// GET /range step-bucketed history
//
// Pass the auth guard (and any additional route middleware) as mws.
func Mount(
r *router.Router,
db Querier,
live Live,
mws ...router.Middleware,
) {
r.HandleFunc(http.MethodGet, "/targets", Targets(live), mws...)
r.HandleFunc(http.MethodGet, "/series", Series(db), mws...)
r.HandleFunc(http.MethodGet, "/latest", Latest(live), mws...)
r.HandleFunc(http.MethodGet, "/range", Range(db), mws...)
r.HandleFunc(http.MethodGet, "/anomalies", Anomalies(db), mws...)
}
// Targets builds the live status handler: the scrape state of every
// registered endpoint, straight from the collector. It panics on a nil
// live view (programmer error).
func Targets(live Live) router.HandlerFunc {
if live == nil {
panic("live view is required")
}
return func(e *router.Exchange) error {
summary := live.Summary()
e.NoStore()
return e.JSON(http.StatusOK, map[string]any{
"time": summary.Time,
"targets": summary.Targets,
})
}
}
// filter extracts the shared identity filter of the read endpoints:
// name, target, and any number of tag.<key>=<value> pairs.
func filter(e *router.Exchange) store.Filter {
q := e.Query()
f := store.Filter{
Name: q.Get("name"),
Target: q.Get("target"),
}
for key, values := range q {
name, ok := strings.CutPrefix(key, "tag.")
if !ok || name == "" || len(values) == 0 {
continue
}
if f.Tags == nil {
f.Tags = make(map[string]string)
}
f.Tags[name] = values[0]
}
return f
}
// Series builds the catalog handler: every stored series identity
// matching the filter. It panics on a nil querier (programmer error).
func Series(db Querier) router.HandlerFunc {
if db == nil {
panic("querier is required")
}
return func(e *router.Exchange) error {
f := filter(e)
var infos []store.Info
err := db.Exec(e.Context(), func(
ctx context.Context, tx pgx.Tx,
) error {
var err error
infos, err = db.Catalog(ctx, tx, f)
return err
})
if err != nil {
return err
}
out := make([]series, len(infos))
for i, info := range infos {
out[i] = series{
ID: info.ID,
Target: info.Target,
Name: info.Name,
Kind: info.Kind,
Tags: info.Tags,
}
}
e.NoStore()
return e.JSON(http.StatusOK, map[string][]series{"series": out})
}
}
// series is one catalog entry of the API's vocabulary.
type series struct {
ID int64 `json:"id"`
Target string `json:"target"`
Name string `json:"name"`
Kind metrics.Kind `json:"kind"`
Tags map[string]string `json:"tags,omitempty"`
}
// Latest builds the current-values handler, answering from the
// collector's retained snapshots without touching the database — the
// feed for auto-refreshing dashboard tiles. The filter matches sample
// names exactly, targets by the instance tag, and tags by pair. It
// panics on a nil live view (programmer error).
func Latest(live Live) router.HandlerFunc {
if live == nil {
panic("live view is required")
}
return func(e *router.Exchange) error {
f := filter(e)
summary := live.Summary()
out := make([]metrics.Sample, 0, len(summary.Metrics))
for _, s := range summary.Metrics {
if f.Name != "" && s.Name != f.Name {
continue
}
if f.Target != "" && s.Tags[scrape.InstanceTag] != f.Target {
continue
}
match := true
for k, v := range f.Tags {
if s.Tags[k] != v {
match = false
break
}
}
if !match {
continue
}
out = append(out, s)
}
e.NoStore()
return e.JSON(http.StatusOK, map[string]any{
"time": summary.Time,
"metrics": out,
})
}
}
// bad renders one 400 with a field violation.
func bad(field, hint string) error {
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: "invalid query parameter",
Context: valid.Single(field, hint),
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package api
import (
"context"
"fmt"
"net/http"
"strconv"
"time"
"github.com/jackc/pgx/v5"
"github.com/deep-rent/nexus/eco/mma/store"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/sys/metrics"
)
// Agg names a range aggregation. Which aggregations a series supports
// follows its kind: the value statistics apply to gauges (and the
// odd counter inspected raw), the difference family to the cumulative
// kinds, and the quantile to series carrying a distribution.
type Agg string
// The aggregation vocabulary.
const (
// AggAvg averages the values within each bucket (gauges).
AggAvg Agg = "avg"
// AggMin takes the bucket minimum (gauges).
AggMin Agg = "min"
// AggMax takes the bucket maximum (gauges).
AggMax Agg = "max"
// AggLast takes the latest value in the bucket (gauges, or the raw
// cumulative level of a counter).
AggLast Agg = "last"
// AggDelta is the reset-aware increase of the cumulative value
// across each bucket (counters).
AggDelta Agg = "delta"
// AggRate is [AggDelta] divided by the step, in events per second
// (counters).
AggRate Agg = "rate"
// AggCount is the reset-aware increase of the observation count
// (histograms, meters, timers).
AggCount Agg = "count"
// AggSum is the reset-aware increase of the observation sum
// (histograms, timers).
AggSum Agg = "sum"
// AggQuantile interpolates the q-quantile from the bucket
// distribution observed within each step (histograms, timers).
AggQuantile Agg = "quantile"
)
// known reports whether the aggregation names a member of the
// vocabulary at all; kind compatibility is [allows]'s question.
func known(agg Agg) bool {
switch agg {
case AggAvg, AggMin, AggMax, AggLast,
AggDelta, AggRate, AggCount, AggSum, AggQuantile:
return true
default:
return false
}
}
// defaultAgg picks the aggregation a kind most usefully answers with.
func defaultAgg(kind metrics.Kind) Agg {
switch kind {
case metrics.KindCounter:
return AggRate
case metrics.KindGauge:
return AggAvg
default:
return AggCount
}
}
// allows reports whether the aggregation is meaningful for the kind.
func allows(kind metrics.Kind, agg Agg) bool {
switch agg {
case AggAvg, AggMin, AggMax:
return kind == metrics.KindGauge
case AggLast:
return kind == metrics.KindGauge || kind == metrics.KindCounter
case AggDelta, AggRate:
return kind == metrics.KindCounter
case AggCount:
return kind == metrics.KindHistogram ||
kind == metrics.KindMeter || kind == metrics.KindTimer
case AggSum, AggQuantile:
return kind == metrics.KindHistogram || kind == metrics.KindTimer
default:
return false
}
}
// point is one answered bucket.
type point struct {
At time.Time `json:"at"`
Value float64 `json:"value"`
}
// answered is one series' worth of range output.
type answered struct {
series
Points []point `json:"points"`
}
// Range builds the history handler, serving step-bucketed aggregates:
//
// GET /range?name=...&target=...&tag.route=/a
// &from=RFC3339&to=RFC3339&step=30s&agg=rate&q=0.95
//
// The name is required; target and tags narrow the resolved series.
// From defaults to now-1h, to defaults to now, step to 1m. The agg
// defaults by kind (gauges average, counters rate, distributions
// count); q applies to agg=quantile only and defaults to 0.95. Buckets
// without observations are omitted — gaps mean the target was not
// scraped, and interpolating them would fabricate data. It panics on a
// nil querier (programmer error).
func Range(db Querier) router.HandlerFunc {
if db == nil {
panic("querier is required")
}
return func(e *router.Exchange) error {
q := e.Query()
f := filter(e)
if f.Name == "" {
return bad("name", "must name a metric")
}
now := time.Now().UTC()
from, to := now.Add(-DefaultRange), now
var err error
if v := q.Get("from"); v != "" {
if from, err = time.Parse(time.RFC3339, v); err != nil {
return bad("from", "must be an RFC 3339 timestamp")
}
}
if v := q.Get("to"); v != "" {
if to, err = time.Parse(time.RFC3339, v); err != nil {
return bad("to", "must be an RFC 3339 timestamp")
}
}
if !to.After(from) {
return bad("to", "must lie after from")
}
step := DefaultStep
if v := q.Get("step"); v != "" {
if step, err = time.ParseDuration(v); err != nil || step <= 0 {
return bad("step", "must be a positive duration")
}
}
if buckets := to.Sub(from) / step; buckets > MaxBuckets {
return &router.Error{
Status: http.StatusBadRequest,
Reason: ReasonRangeTooWide,
Description: fmt.Sprintf(
"the window spans %d buckets, above the %d cap; "+
"widen the step or shrink the window",
buckets, MaxBuckets,
),
}
}
quantile := 0.95
if v := q.Get("q"); v != "" {
if quantile, err = strconv.ParseFloat(v, 64); err != nil ||
quantile <= 0 || quantile >= 1 {
return bad("q", "must lie in (0, 1)")
}
}
// An unknown spelling fails regardless of what the filter
// resolves; only kind compatibility waits for the series.
agg := Agg(q.Get("agg"))
if agg != "" && !known(agg) {
return bad("agg", "must name a known aggregation")
}
var infos []store.Info
var picks []Agg
var slices []store.Slice
err = db.Exec(e.Context(), func(
ctx context.Context, tx pgx.Tx,
) error {
var err error
infos, err = db.Catalog(ctx, tx, f)
if err != nil {
return err
}
if len(infos) == 0 {
return nil
}
if len(infos) > MaxSeries {
return &router.Error{
Status: http.StatusBadRequest,
Reason: ReasonRangeTooWide,
Description: fmt.Sprintf(
"the filter resolves %d series, above the %d "+
"cap; narrow it by target or tags",
len(infos), MaxSeries,
),
}
}
// Resolve every series' aggregation up front: it decides
// whether the scan must carry the distributions — the wide
// column only quantiles read.
picks = make([]Agg, len(infos))
distributions := false
for i, info := range infos {
pick := agg
if pick == "" {
pick = defaultAgg(info.Kind)
}
if !allows(info.Kind, pick) {
return bad("agg", fmt.Sprintf(
"%q does not apply to a %s series",
pick, info.Kind,
))
}
picks[i] = pick
if pick == AggQuantile {
distributions = true
}
}
ids := make([]int64, len(infos))
for i, info := range infos {
ids[i] = info.ID
}
// One step of lead-in seeds the difference family, so the
// first requested bucket answers too; the seed bucket
// itself never surfaces.
slices, err = db.Range(
ctx, tx, ids, from.Add(-step), to, step, distributions,
)
return err
})
if err != nil {
return err
}
bySeries := make(map[int64][]store.Slice, len(infos))
for _, s := range slices {
bySeries[s.SeriesID] = append(bySeries[s.SeriesID], s)
}
out := make([]answered, 0, len(infos))
for i, info := range infos {
out = append(out, answered{
ID: info.ID,
Target: info.Target,
Name: info.Name,
Kind: info.Kind,
Tags: info.Tags,
Points: derive(
bySeries[info.ID], picks[i], from, step, quantile,
),
})
}
e.NoStore()
return e.JSON(http.StatusOK, map[string]any{
"from": from,
"to": to,
"step": step.Seconds(),
"series": out,
})
}
}
// derive shapes one series' slices into answered points. Slices arrive
// ordered by bucket and may include one lead-in bucket before from,
// which seeds the difference family and is never emitted.
func derive(
slices []store.Slice,
agg Agg,
from time.Time,
step time.Duration,
q float64,
) []point {
out := make([]point, 0, len(slices))
var prev *store.Slice
for i := range slices {
s := &slices[i]
if s.At.Before(from) {
prev = s
continue
}
var v float64
switch agg {
case AggAvg:
v = s.Avg
case AggMin:
v = s.Min
case AggMax:
v = s.Max
case AggLast:
v = s.Last
case AggDelta:
v = delta(s.Last, prev, func(p *store.Slice) float64 {
return p.Last
})
case AggRate:
v = delta(s.Last, prev, func(p *store.Slice) float64 {
return p.Last
}) / step.Seconds()
case AggCount:
v = delta(float64(s.Count), prev, func(p *store.Slice) float64 {
return float64(p.Count)
})
case AggSum:
v = delta(s.Sum, prev, func(p *store.Slice) float64 {
return p.Sum
})
case AggQuantile:
buckets, degraded := distribution(s, prev)
total := uint64(delta(
float64(s.Count), prev,
func(p *store.Slice) float64 { return float64(p.Count) },
))
if degraded {
// The diff fell back to cumulative state, so the total
// must be cumulative too.
total = s.Count
}
v = bucketQuantile(q, buckets, total)
}
out = append(out, point{At: s.At, Value: v})
prev = s
}
return out
}
// delta returns the reset-aware increase from the previous bucket's
// cumulative level to the current one: a shrinking cumulative value
// means the target restarted, and the current level IS the increase.
// The very first bucket, with nothing before it, reports the current
// level for the same reason.
func delta(
current float64,
prev *store.Slice,
level func(*store.Slice) float64,
) float64 {
if prev == nil {
return current
}
if before := level(prev); current >= before {
return current - before
}
return current
}
// distribution diffs the cumulative bucket layout of two adjacent
// slices into the distribution observed within the current step. A
// missing or mismatched predecessor — first bucket, target restart,
// changed layout — degrades to the current cumulative distribution and
// says so, since the caller's total must then be cumulative too.
func distribution(s, prev *store.Slice) ([]metrics.Bucket, bool) {
out := make([]metrics.Bucket, len(s.Buckets))
copy(out, s.Buckets)
if prev == nil || len(prev.Buckets) != len(out) {
return out, true
}
for i := range out {
if prev.Buckets[i].Bound != out[i].Bound ||
prev.Buckets[i].Count > out[i].Count {
// A changed layout or a shrinking count means restart;
// the cumulative state is the step's distribution.
return s.Buckets, true
}
out[i].Count -= prev.Buckets[i].Count
}
return out, false
}
// bucketQuantile interpolates the q-quantile from a bucket distribution
// with the given observation total, in the manner of Prometheus'
// histogram_quantile: linear within a bucket, the highest finite bound
// when the quantile falls beyond it (the layout lists no +Inf bucket;
// total carries what lies above), and NaN-free zero when the
// distribution is empty.
func bucketQuantile(q float64, buckets []metrics.Bucket, total uint64) float64 {
if len(buckets) == 0 || total == 0 {
return 0
}
rank := q * float64(total)
var lowerBound float64
var lowerCount uint64
for _, b := range buckets {
if float64(b.Count) >= rank {
span := float64(b.Count - lowerCount)
if span == 0 {
return b.Bound
}
frac := (rank - float64(lowerCount)) / span
return lowerBound + (b.Bound-lowerBound)*frac
}
lowerBound = b.Bound
lowerCount = b.Count
}
// The quantile lies beyond the highest finite bound; that bound is
// the most honest answer a bounded layout can give.
return buckets[len(buckets)-1].Bound
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package config
import (
"errors"
"fmt"
"time"
"github.com/deep-rent/nexus/sys/boot"
"github.com/deep-rent/nexus/sys/env"
)
// Prefix namespaces every environment variable of the service.
const Prefix = "MMA_"
// DefaultTargets is the targets file path applied when the environment
// names none. It must match the struct tag default on
// [Config.Targets]; the config test pins the two together.
const DefaultTargets = "targets.json"
// Floors on the tunable windows. Load rejects values below them: a
// sweep interval under the floor hammers every target for no analytical
// gain, and a retention window under a day cannot hold even the current
// partition plus its predecessor.
const (
// MinInterval is the tightest allowed sweep cadence.
MinInterval = 5 * time.Second
// MinRetention is the shortest allowed history window.
MinRetention = 24 * time.Hour
)
// Config declares the deployment configuration of the metrics
// monitoring agent. Bind it from the environment with [Load].
type Config struct {
// Core is the listen addresses, CORS origins, log level, and
// connection timeouts every service carries. The operational
// listener carries the live merged summary besides the probes and
// the agent's own metrics.
boot.Core `env:",inline"`
// Targets is the path to the JSON file declaring the scrape targets;
// see [LoadTargets] for its shape.
Targets string `env:",default:'targets.json'"`
// Interval is the sweep cadence: how often every target is scraped.
Interval time.Duration `env:",default:30s"`
// ScrapeTimeout bounds a single target fetch, for slow targets
// behind long links. Zero derives a sensible bound from the
// interval; the effective timeout never exceeds the interval, since
// a fetch outliving its sweep helps nobody.
ScrapeTimeout time.Duration
// Retention is the rollover time frame: history older than this is
// dropped. Rollover happens in whole days.
Retention time.Duration `env:",default:720h"`
// MaxSeries is the per-target series count above which the agent
// warns about a cardinality explosion. Zero disables the warning.
MaxSeries int `env:",default:10000"`
// Rules is the path to the anomaly rules file; see the README for
// its shape. A missing file at the default path leaves detection
// off; a missing file at an explicit path is a startup error.
Rules string `env:",default:'rules.json'"`
// Hook configures the webhook engine carrying anomaly events to
// registered receivers. It only runs while detection does.
Hook boot.Sender `env:",prefix:HOOK_"`
// Database configures the PostgreSQL connection holding the metric
// history. Required: the agent's whole purpose is durable history.
Database boot.Database `env:",prefix:DATABASE_"`
// Auth declares the identity provider whose tokens the query API
// accepts.
Auth Auth `env:",prefix:AUTH_"`
// TLS configures the client certificate presented to the scrape
// targets, which are expected to demand mutual TLS.
TLS TLS `env:",prefix:TLS_"`
// Rate bounds each staff user's request rate on the query API.
// Zero disables the meter.
Rate Rate `env:",prefix:RATE_"`
}
// DefaultRules is the rules path assumed when none is configured.
const DefaultRules = "rules.json"
// Auth declares the identity provider whose access tokens the query API
// verifies, plus the roles this service reads out of them. The service
// issues no tokens of its own.
type Auth struct {
boot.Auth `env:",inline"`
// Roles names the IAM roles whose members may read the query API
// with a delegated token. Machine tokens need only the mma:read
// scope; a delegated token needs the scope AND one of these roles.
// Empty defaults to "admin" at assembly.
Roles []string
}
// TLS configures the client certificate the scrape client presents.
// One credential serves every target: the deployment's CA issues a
// single monitoring identity. The pair reloads on rotation; see
// [transport.MutualTLS].
//
// [transport.MutualTLS]: github.com/deep-rent/nexus/net/transport#MutualTLS
type TLS struct {
// Cert is the path to the PEM-encoded client certificate. Empty
// scrapes without a client certificate — for local development
// against unguarded targets.
Cert string
// Key is the path to the certificate's private key.
Key string
// CA is the path to the CA bundle the targets' server certificates
// are verified against. Empty applies the system roots.
CA string
}
// Enabled reports whether a client certificate is configured.
func (c TLS) Enabled() bool { return c.Cert != "" && c.Key != "" }
// Rate bounds each delegated user's request rate on the query API,
// protecting the store from a runaway dashboard. It meters per replica;
// machine clients — registered and vetted — stay unmetered.
type Rate struct {
// PerSecond is the sustained per-user request rate. Zero disables
// the meter.
PerSecond float64
// Burst is the instantaneous allowance beyond the sustained rate;
// zero scales it with the rate.
Burst int
}
// Enabled reports whether the meter is configured.
func (c Rate) Enabled() bool { return c.PerSecond > 0 }
// Load binds a [Config] from the environment under [Prefix]. It demands
// a database and rejects values below the documented floors.
func Load(opts ...env.Option) (Config, error) {
cfg, err := boot.Load[Config](Prefix, opts...)
if err != nil {
return cfg, err
}
if !cfg.Database.Enabled() {
// Named rather than tagged required, so the message says which
// variable to set rather than which field failed to bind.
return cfg, fmt.Errorf("%sDATABASE_URL is not set", Prefix)
}
if cfg.Interval < MinInterval {
return cfg, fmt.Errorf(
"interval %v is below the %v floor", cfg.Interval, MinInterval,
)
}
if cfg.Retention < MinRetention {
return cfg, fmt.Errorf(
"retention %v is below the %v floor", cfg.Retention, MinRetention,
)
}
if cfg.MaxSeries < 0 {
return cfg, fmt.Errorf(
"max series %d must not be negative", cfg.MaxSeries,
)
}
if (cfg.TLS.Cert == "") != (cfg.TLS.Key == "") {
return cfg, errors.New(
"the TLS certificate and key must be configured together",
)
}
return cfg, nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package config
import (
"encoding/json/v2"
"fmt"
"net/url"
"os"
)
// Target is one registered scrape endpoint.
type Target struct {
// Name tags the target's samples and must be unique across the file.
// It is the "instance" a dashboard groups by, so pick stable,
// human-meaningful names ("iam-1", not a pod hash).
Name string `json:"name"`
// URL is the collection endpoint, typically the target's monitor
// listener: "https://10.0.1.10:9090/metrics".
URL string `json:"url"`
}
// targetsFile is the shape of the targets JSON file.
type targetsFile struct {
Targets []Target `json:"targets"`
}
// LoadTargets reads and validates the targets file:
//
// {
// "targets": [
// { "name": "iam-1", "url": "https://10.0.1.10:9090/metrics" },
// { "name": "dse-1", "url": "https://10.0.1.20:9090/metrics" }
// ]
// }
//
// The file is static: it is read once at startup, and changing the
// monitoring topology is a redeploy. An empty target list is rejected —
// a monitoring agent with nothing to monitor is a misconfiguration
// better caught loudly.
func LoadTargets(path string) ([]Target, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read targets file: %w", err)
}
var file targetsFile
if err := json.Unmarshal(data, &file); err != nil {
return nil, fmt.Errorf("failed to parse targets file %q: %w",
path, err)
}
if len(file.Targets) == 0 {
return nil, fmt.Errorf("targets file %q declares no targets", path)
}
seen := make(map[string]struct{}, len(file.Targets))
for i, t := range file.Targets {
if t.Name == "" {
return nil, fmt.Errorf(
"target %d of %q has no name", i+1, path,
)
}
if _, dup := seen[t.Name]; dup {
return nil, fmt.Errorf(
"target name %q appears twice in %q", t.Name, path,
)
}
seen[t.Name] = struct{}{}
u, err := url.Parse(t.URL)
if err != nil || u.Scheme != "http" && u.Scheme != "https" ||
u.Host == "" {
return nil, fmt.Errorf(
"target %q has no valid http(s) URL: %q", t.Name, t.URL,
)
}
}
return file.Targets, nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package ingest
import (
"context"
"maps"
"strconv"
"sync"
"time"
"github.com/jackc/pgx/v5"
"github.com/deep-rent/nexus/eco/mma/store"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/std/sketch/hll"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/metrics"
"github.com/deep-rent/nexus/sys/metrics/scrape"
)
// Synthetic sample names the recorder mints per scrape attempt,
// following the scraper convention: they describe the scrape, not the
// target's own registry, and exist for every target — including ones
// that never answered.
const (
// MetricUp is the availability gauge: 1 for a successful attempt,
// 0 for a failed one.
MetricUp = "up"
// MetricDuration is the attempt duration gauge, in seconds.
MetricDuration = "scrape_duration_seconds"
)
// DefaultTimeout bounds the database work of one recorded attempt.
const DefaultTimeout = 30 * time.Second
// DefaultMaxSeries is the per-target series count above which the
// recorder warns, unless overridden via [WithMaxSeries]. A target
// exposes a few hundred series in healthy operation; ten thousand
// means a tag is carrying unbounded values.
const DefaultMaxSeries = 10_000
// seriesPrecision sizes the per-target cardinality estimate: 2^12
// one-byte registers cost 4 KB per target and estimate within about
// 1.6% — plenty to spot an explosion.
const seriesPrecision = 12
// MaxSkew bounds how far a target's snapshot stamp may deviate from the
// recorder's clock before it is distrusted. The stamp routes the insert
// into a day partition, and only today's and tomorrow's exist — a
// target clock running a day ahead would fail its ingestion on every
// sweep until fixed. Within the tolerance the target's stamp wins (it
// knows when it measured); beyond it, the local clock does.
const MaxSkew = 5 * time.Minute
// Persister is the slice of the store the recorder writes through.
// Implemented by [store.Store].
type Persister interface {
// Exec runs fn within a single transaction.
Exec(ctx context.Context,
fn func(ctx context.Context, tx pgx.Tx) error) error
// EnsureSeries resolves series identities to IDs, creating fresh
// ones; the batch must not repeat a fingerprint.
EnsureSeries(ctx context.Context, tx pgx.Tx, series []store.Series) (
map[string]int64, error)
// InsertPoints appends observations in bulk.
InsertPoints(ctx context.Context, tx pgx.Tx, points []store.Point) error
}
// Recorder turns scrape results into stored history. It implements the
// collector's sink seam ([Recorder.Sink]) and is safe for concurrent
// use — sweeps invoke sinks concurrently across targets.
type Recorder struct {
db Persister
logger *log.Logger
timeout time.Duration
now clock.Clock
// observer receives every committed batch; nil when nobody cares.
observer func(ctx context.Context, obs []Observation)
mu sync.Mutex
cache map[string]int64 // series fingerprint -> id
series map[string]*hll.Sketch // per-target distinct-series estimate
warned map[string]bool // targets already past the threshold
limit uint64 // series count that triggers the warning
// The self-instruments: how many observations landed, how many
// attempts failed to record (should sit at zero), and — per target,
// a bounded tag — how many scrapes failed outright, so the agent's
// own /metrics reflects a down target without querying history.
reg *metrics.Registry
points *metrics.Counter
failures *metrics.Counter
}
// Option configures a [Recorder].
type Option func(*Recorder)
// WithLogger sets the logger receiving recording failures. If not
// provided, the recorder stays silent. A nil logger is ignored.
func WithLogger(logger *log.Logger) Option {
return func(r *Recorder) {
if logger != nil {
r.logger = logger
}
}
}
// WithTimeout bounds the database work of one recorded attempt,
// defaulting to [DefaultTimeout]. Values of zero or less are ignored.
func WithTimeout(d time.Duration) Option {
return func(r *Recorder) {
if d > 0 {
r.timeout = d
}
}
}
// WithClock injects the time source, which is primarily useful for
// testing. A nil clock is ignored.
func WithClock(now clock.Clock) Option {
return func(r *Recorder) {
if now != nil {
r.now = now
}
}
}
// WithObserver registers a function receiving every committed batch of
// observations, after the write landed and outside any lock. The
// anomaly detector feeds on this seam. A nil observer is ignored;
// the observer must not retain the slice past the call.
func WithObserver(fn func(ctx context.Context, obs []Observation)) Option {
return func(r *Recorder) {
if fn != nil {
r.observer = fn
}
}
}
// WithMaxSeries sets the per-target series count above which the
// recorder warns about a cardinality explosion, defaulting to
// [DefaultMaxSeries]. Zero disables the warning; the
// mma_series_estimate gauge is kept either way. Negative values are
// ignored.
func WithMaxSeries(n int) Option {
return func(r *Recorder) {
if n >= 0 {
r.limit = uint64(n)
}
}
}
// WithRegistry registers the recorder's own instruments with reg
// instead of [metrics.DefaultRegistry]. A nil registry is ignored.
func WithRegistry(reg *metrics.Registry) Option {
return func(r *Recorder) {
if reg != nil {
r.reg = reg
r.points = reg.Counter("mma_ingested_points_total")
r.failures = reg.Counter("mma_ingest_failures_total")
}
}
}
// New creates a [Recorder] over the given persister. It panics on a nil
// persister (programmer error).
func New(db Persister, opts ...Option) *Recorder {
if db == nil {
panic("persister is required")
}
r := &Recorder{
db: db,
logger: log.Discard(),
timeout: DefaultTimeout,
now: clock.System,
cache: make(map[string]int64),
series: make(map[string]*hll.Sketch),
warned: make(map[string]bool),
limit: DefaultMaxSeries,
}
WithRegistry(metrics.DefaultRegistry)(r)
for _, opt := range opts {
opt(r)
}
return r
}
// Sink returns the hook to register with the collector
// ([scrape.WithSink]). Every attempt records the synthetic availability
// samples; successful ones additionally record every sample of the
// snapshot. Recording failures are logged and counted, never raised —
// one lost sweep is noise, and the next sweep heals the gap.
func (r *Recorder) Sink() scrape.Sink {
return func(ctx context.Context, res scrape.Result) {
if res.Err != nil {
r.reg.Counter(
"mma_scrape_failures_total",
metrics.T("target", res.Target),
).Inc()
}
// The attempt context carries the scrape timeout and dies with
// the sweep; the database work deserves its own budget.
ctx, cancel := context.WithTimeout(
context.WithoutCancel(ctx), r.timeout,
)
defer cancel()
if err := r.record(ctx, res); err != nil {
r.failures.Inc()
r.logger.Error(ctx, "Failed to record scrape",
log.String("target", res.Target),
log.Error(err),
)
}
}
}
// An Observation pairs a series identity with the point recorded
// against it in one attempt. The recorder hands each committed batch
// to the observer registered via [WithObserver].
type Observation struct {
// Series identifies where the point belongs.
Series store.Series
// Point is the recorded observation.
Point store.Point
}
// flatten expands a summary sample into series the query API already
// speaks: one gauge per reported quantile, tagged with its rank under
// "q", plus a counter each for the lifetime count and sum, suffixed
// "_count" and "_sum". Quantiles from different sweeps cannot be
// aggregated after the fact, so they are stored as the point-in-time
// gauges they behave like, while the counters keep rates and means
// derivable through the counter vocabulary.
func flatten(target string, at time.Time, s metrics.Sample) []Observation {
out := make([]Observation, 0, len(s.Quantiles)+2)
for _, q := range s.Quantiles {
tags := make(map[string]string, len(s.Tags)+1)
maps.Copy(tags, s.Tags)
tags["q"] = strconv.FormatFloat(q.Q, 'g', -1, 64)
out = append(out, Observation{
Series: store.Series{
Target: target,
Name: s.Name,
Kind: metrics.KindGauge,
Tags: tags,
},
Point: store.Point{At: at, Value: q.V},
})
}
counter := func(suffix string, value float64) Observation {
return Observation{
Series: store.Series{
Target: target,
Name: s.Name + suffix,
Kind: metrics.KindCounter,
Tags: s.Tags,
},
Point: store.Point{At: at, Value: value},
}
}
return append(out,
counter("_count", float64(s.Count)),
counter("_sum", s.Sum),
)
}
// record persists one attempt.
func (r *Recorder) record(ctx context.Context, res scrape.Result) error {
at := r.now().UTC()
up := 0.0
if res.Err == nil {
up = 1.0
if res.Snapshot != nil && !res.Snapshot.Time.IsZero() {
// The snapshot's own stamp beats the sink's clock — it is
// when the target actually measured — but only within the
// skew tolerance: the stamp routes the partition, and a
// runaway remote clock must not break its own ingestion.
stamp := res.Snapshot.Time.UTC()
if skew := stamp.Sub(at); skew < -MaxSkew || skew > MaxSkew {
r.logger.Warn(ctx,
"Snapshot clock skewed beyond tolerance; "+
"stamping with local time",
log.String("target", res.Target),
log.Duration("skew", skew),
)
} else {
at = stamp
}
}
}
observations := []Observation{
{
Series: store.Series{
Target: res.Target,
Name: MetricUp,
Kind: metrics.KindGauge,
},
Point: store.Point{At: at, Value: up},
},
{
Series: store.Series{
Target: res.Target,
Name: MetricDuration,
Kind: metrics.KindGauge,
},
Point: store.Point{At: at, Value: res.Took.Seconds()},
},
}
if res.Snapshot != nil {
for _, s := range res.Snapshot.Metrics {
if s.Kind == metrics.KindSummary {
observations = append(observations,
flatten(res.Target, at, s)...)
continue
}
observations = append(observations, Observation{
Series: store.Series{
Target: res.Target,
Name: s.Name,
Kind: s.Kind,
Tags: s.Tags,
},
Point: store.Point{
At: at,
Value: s.Value,
Count: s.Count,
Sum: s.Sum,
Buckets: s.Buckets,
},
})
}
}
// Resolve every series from the cache; the misses — first sweep,
// or a target growing a fresh instrument — resolve inside the write
// transaction. The cache learns their IDs only after the commit: a
// rolled-back EnsureSeries mints IDs whose rows never materialize,
// and caching those would break every later sweep.
prints := make([]string, len(observations))
resolved := make(map[string]int64, len(observations))
var misses []store.Series
r.mu.Lock()
for i, o := range observations {
prints[i] = o.Series.Fingerprint()
if id, ok := r.cache[prints[i]]; ok {
resolved[prints[i]] = id
continue
}
if _, dup := resolved[prints[i]]; dup {
continue
}
resolved[prints[i]] = 0 // claimed below; dedupes the batch
misses = append(misses, o.Series)
}
r.mu.Unlock()
var fresh map[string]int64
err := r.db.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
if len(misses) > 0 {
var err error
fresh, err = r.db.EnsureSeries(ctx, tx, misses)
if err != nil {
return err
}
maps.Copy(resolved, fresh)
}
points := make([]store.Point, len(observations))
for i := range observations {
// Resolve in place, so the observer receives the batch
// with its series identities attached.
observations[i].Point.SeriesID = resolved[prints[i]]
points[i] = observations[i].Point
}
return r.db.InsertPoints(ctx, tx, points)
})
if err != nil {
return err
}
r.mu.Lock()
maps.Copy(r.cache, fresh)
est, crossed := r.watch(res.Target, prints)
r.mu.Unlock()
r.points.Add(uint64(len(observations)))
r.reg.Gauge(
"mma_series_estimate", metrics.T("target", res.Target),
).Set(float64(est))
if crossed {
r.logger.Warn(ctx,
"Target series cardinality exceeds the threshold; "+
"a tag is likely carrying unbounded values",
log.String("target", res.Target),
log.Int("series", int(est)),
log.Int("threshold", int(r.limit)),
)
}
if r.observer != nil {
r.observer(ctx, observations)
}
return nil
}
// watch folds a sweep's stored series fingerprints into the target's
// cardinality estimate, reporting the estimate and whether it just
// crossed the warning threshold. Each target warns once; a catalog
// does not shrink, so repeating the warning every sweep would only
// drown the log. The caller must hold the mutex.
func (r *Recorder) watch(target string, prints []string) (uint64, bool) {
h, ok := r.series[target]
if !ok {
h = hll.New(seriesPrecision)
r.series[target] = h
}
for _, p := range prints {
h.Add(p)
}
est := h.Count()
if r.limit == 0 || est <= r.limit || r.warned[target] {
return est, false
}
r.warned[target] = true
return est, true
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package mma
import (
"context"
"errors"
"fmt"
"io/fs"
"net/http"
"strings"
"time"
"uuid"
"github.com/deep-rent/nexus/eco/mma/anomaly"
"github.com/deep-rent/nexus/eco/mma/api"
"github.com/deep-rent/nexus/eco/mma/config"
"github.com/deep-rent/nexus/eco/mma/ingest"
"github.com/deep-rent/nexus/eco/mma/store"
"github.com/deep-rent/nexus/net/middleware/limit"
hookadmin "github.com/deep-rent/nexus/net/notify/hook/admin"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/net/transport"
"github.com/deep-rent/nexus/sec/auth"
"github.com/deep-rent/nexus/sys/boot"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/metrics"
"github.com/deep-rent/nexus/sys/metrics/scrape"
"github.com/deep-rent/nexus/sys/schedule"
)
// PermissionRead is the permission the query API demands. Machine
// clients need the scope alone; delegated (staff) tokens need the scope
// plus one of the roles named by [config.Auth.Roles].
const PermissionRead = "mma:read"
// PermissionAdmin is the permission the webhook management surface
// demands, granted to the same staff roles as reads; machine clients
// need the scope itself.
const PermissionAdmin = "mma:admin"
// HookOwner is the owner every endpoint of this registry is filed
// under: the agent keeps one flat, staff-managed subscriber list.
const HookOwner = "mma"
// DefaultRole is the IAM role admitted to the query API when the
// configuration names none.
const DefaultRole = "admin"
// Tuning constants of the assembled service. They are deliberately not
// configuration: each is a property of the service's own shape rather
// than of the deployment around it. What every service shares — the
// header cap, the probe cadences, the shutdown margin — belongs to
// [boot] instead.
const (
// MaxBodySize caps a request body at 64 KiB: the API is read-only,
// so bodies carry nothing legitimate.
MaxBodySize = 64 << 10
// RolloverInterval is how often the partition rollover runs. Daily
// would suffice mechanically; hourly keeps the pass cheap and heals
// a missed run quickly.
RolloverInterval = time.Hour
)
// Service is the fully assembled metrics monitoring agent. Create
// instances with [New], serve them with [Service.Run], or embed
// [Service.Handler] into a custom server.
type Service struct {
cfg config.Config
rt *boot.Runtime
logger *log.Logger
store *store.Store
// collector sweeps the targets; the scheduler drives it at the
// configured interval.
collector *scrape.Collector
// engine watches the ingest stream, nil while no rules file is
// loaded. Its verdicts reach subscribers through the runtime's
// webhook engine.
engine *anomaly.Engine
// The rollover instruments: failures should sit at zero, drops are
// the retention doing its job.
rolloverFailures *metrics.Counter
partitionsGone *metrics.Counter
}
// New assembles the service from its configuration and the loaded
// scrape targets (see [config.LoadTargets]). It returns an error for
// unusable external inputs — an unreachable database, an unreadable
// client certificate, an empty target list.
//
// The version identifies this build in the User-Agent of every outbound
// request; resolve it via [cli.Version].
//
// [cli.Version]: github.com/deep-rent/nexus/sys/cli#Version
func New(
ctx context.Context,
cfg config.Config,
targets []config.Target,
version string,
) (*Service, error) {
if len(targets) == 0 {
return nil, errors.New("no scrape targets configured")
}
// Detection runs only when a rules file stands behind it: an empty
// path or an absent file at the default path means the operator has
// not opted in; an absent file at an explicit path is a mistake
// worth failing on. It is read before anything is built, because a
// deployment without rules publishes nothing and so needs no
// webhook engine.
rules, err := loadRules(cfg)
if err != nil {
return nil, err
}
var sender *boot.Sender
if len(rules) > 0 {
sender = &cfg.Hook
}
rt, err := boot.New(ctx, boot.Spec{
Name: "mma",
Version: version,
Core: cfg.Core,
Database: &cfg.Database,
Auth: &cfg.Auth.Auth,
Sender: sender,
}, boot.WithMaxBody(MaxBodySize))
if err != nil {
return nil, err
}
s := &Service{
cfg: cfg,
rt: rt,
logger: rt.Logger(),
rolloverFailures: metrics.DefaultRegistry.Counter(
"mma_rollover_failures_total",
),
partitionsGone: metrics.DefaultRegistry.Counter(
"mma_partitions_dropped_total",
),
}
s.store = store.New(rt.Pool())
rt.Migrate(store.Migrator)
rt.Every("rollover", RolloverInterval, schedule.TaskFn(s.rollover))
// The day partitions must exist before the first sweep can write
// into them, and the scheduler's start jitter is no guarantee of
// that, so the first rollover is one-shot startup work.
rt.Once("rollover", func(ctx context.Context) error {
_, _, err := s.store.Rollover(ctx, time.Now(), cfg.Retention)
if err != nil {
return fmt.Errorf("failed the initial rollover: %w", err)
}
return nil
})
// The scrape client carries the monitoring identity: one client —
// one mutual-TLS credential — serves every target, and the pair
// reloads on rotation (see transport.MutualTLS). It stays off the
// runtime's shared client, which must not carry that credential.
scrapeTransport := []transport.Option{
transport.WithHeader(rt.Agent()),
}
if cfg.TLS.Enabled() {
tlsCfg, err := transport.MutualTLS(
cfg.TLS.Cert, cfg.TLS.Key, cfg.TLS.CA,
)
if err != nil {
return nil, err
}
scrapeTransport = append(
scrapeTransport, transport.WithTLSConfig(tlsCfg),
)
} else {
s.logger.Warn(ctx,
"No client certificate configured; scraping without mutual "+
"TLS — fine locally, wrong in production",
)
}
if len(rules) > 0 {
engOpts := []anomaly.Option{
anomaly.WithLogger(s.logger.Child("anomaly")),
}
if h := rt.Hooks(); h != nil {
engOpts = append(engOpts, anomaly.WithPublisher(h))
}
s.engine = anomaly.New(s.store, rules, engOpts...)
// The engine reconciles its ledger and re-warms its models
// before the first sweep can feed it.
rt.Once("detect", s.engine.Start)
s.logger.Info(ctx, "Anomaly detection enabled",
log.Int("rules", len(rules)),
log.String("path", cfg.Rules),
)
} else {
s.logger.Info(ctx, "No rules file; anomaly detection is off",
log.String("path", cfg.Rules))
}
ingestOpts := []ingest.Option{
ingest.WithLogger(s.logger.Child("ingest")),
ingest.WithMaxSeries(cfg.MaxSeries),
}
if s.engine != nil {
ingestOpts = append(ingestOpts,
ingest.WithObserver(s.engine.Observer()))
}
recorder := ingest.New(s.store, ingestOpts...)
s.collector = scrape.New(
scrape.WithClient(&http.Client{
Timeout: transport.DefaultTimeout,
Transport: transport.New(scrapeTransport...),
}),
scrape.WithLogger(s.logger.Child("scrape")),
scrape.WithTimeout(scrapeTimeout(cfg)),
scrape.WithSink(recorder.Sink()),
)
for _, t := range targets {
s.collector.Add(t.Name, t.URL)
}
// The read rule: the scope alone for machine clients, the scope
// through one of the configured roles for staff tokens.
roles := cfg.Auth.Roles
if len(roles) == 0 {
roles = []string{DefaultRole}
}
grants := auth.Grants{}
for _, role := range roles {
grants[role] = []string{PermissionRead, PermissionAdmin}
}
// The per-user meter guards the store against a runaway dashboard.
// It keys on the delegated user; machine clients — registered and
// vetted, like dse's admin callers — resolve to an empty key and
// pass unmetered. nil when disabled — Chain skips it.
var meter router.Middleware
if cfg.Rate.Enabled() {
meter = limit.New(
limit.WithRate(cfg.Rate.PerSecond),
limit.WithBurst(cfg.Rate.Burst),
limit.WithKey(func(e *router.Exchange) string {
if claims, ok := auth.From(e); ok {
if id := claims.UserID(); id != uuid.Nil() {
return id.String()
}
}
return ""
}),
)
}
r := rt.Router()
guard := rt.Guard()
api.Mount(r, s.store, s.collector,
guard.Secure(grants.Require(PermissionRead)), meter)
if h := rt.Hooks(); h != nil {
// One flat, staff-managed subscriber list: the agent names the
// owner itself, only its own topics may be subscribed to, and
// registering a receiver is an administrative act the read
// scope does not cover. Internal endpoints are admissible
// because the surface never faces customers.
hookadmin.Mount(r, hookadmin.Config{
Hooks: h,
Owner: hookadmin.Fixed(HookOwner),
Topics: anomaly.Topics,
Internal: true,
Read: []router.Middleware{
guard.Secure(grants.Require(PermissionAdmin)),
},
})
}
// Beside the probes and the agent's own instruments, the
// operational listener carries the collector's live merged summary
// — what the last sweep saw, before it reached the store.
rt.Ops().Handle(http.MethodGet, "/summary", s.collector.Handler())
rt.Every("sweep", cfg.Interval, s.collector)
s.logger.Info(ctx, "Assembled MMA service",
log.Int("targets", len(targets)),
log.Duration("interval", cfg.Interval),
log.Duration("retention", cfg.Retention),
log.Bool("mtls", cfg.TLS.Enabled()),
log.String("issuer", cfg.Auth.Issuer),
log.String("jwks", cfg.Auth.Keys()),
)
return s, nil
}
// Handler returns the assembled HTTP handler, for embedding the query
// API into a custom server.
func (s *Service) Handler() http.Handler { return s.rt.Handler() }
// Run serves the agent until the context is canceled or a termination
// signal arrives.
func (s *Service) Run(ctx context.Context) error { return s.rt.Run(ctx) }
// loadRules reads the anomaly rules the detection stack runs on. A
// missing file at the default path is an operator who has not opted
// in; a missing file at an explicit path is a mistake worth failing on.
func loadRules(cfg config.Config) ([]anomaly.Rule, error) {
if cfg.Rules == "" {
return nil, nil
}
rules, err := anomaly.Load(cfg.Rules)
switch {
case err == nil:
return rules, nil
case errors.Is(err, fs.ErrNotExist) && cfg.Rules == config.DefaultRules:
return nil, nil
default:
return nil, fmt.Errorf("failed to load the rules: %w", err)
}
}
// scrapeTimeout resolves the per-fetch bound: the configured value, or
// a default derived from the sweep cadence — and never more than the
// interval, since a fetch outliving its sweep helps nobody.
func scrapeTimeout(cfg config.Config) time.Duration {
timeout := cfg.ScrapeTimeout
if timeout <= 0 {
timeout = scrape.DefaultTimeout
}
return min(timeout, cfg.Interval)
}
// rollover runs one partition lifecycle pass, counting and logging what
// it changed; see [store.Store.Rollover].
func (s *Service) rollover(ctx context.Context) {
created, dropped, err := s.store.Rollover(
ctx, time.Now(), s.cfg.Retention,
)
if err != nil {
s.rolloverFailures.Inc()
s.logger.Error(ctx, "Partition rollover failed", log.Error(err))
return
}
if len(created) > 0 {
s.logger.Info(ctx, "Created day partitions",
log.String("partitions", strings.Join(created, ",")))
}
if len(dropped) > 0 {
s.partitionsGone.Add(uint64(len(dropped)))
s.logger.Info(ctx, "Dropped expired day partitions",
log.String("partitions", strings.Join(dropped, ",")))
}
if s.engine != nil {
gone, err := s.store.PurgeAnomalies(
ctx, time.Now().Add(-s.cfg.Retention),
)
if err != nil {
s.logger.Error(ctx, "Anomaly purge failed", log.Error(err))
} else if gone > 0 {
s.logger.Info(ctx, "Purged resolved anomalies",
log.Int("count", int(gone)))
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package store
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// An Anomaly is one detector verdict on one series: opened when the
// condition was established, resolved when it cleared, and carrying
// the numbers that justified it.
type Anomaly struct {
// ID is the ledger key.
ID int64
// Series identifies the series the verdict is about.
Series Series
// SeriesID references the series row.
SeriesID int64
// Rule names the rule that matched the series.
Rule string
// Detector names the check that fired: spike, drift, ceiling, or
// bounds.
Detector string
// Severity carries the rule's severity label.
Severity string
// OpenedAt is when the condition was established.
OpenedAt time.Time
// ResolvedAt is when the condition cleared; zero while open.
ResolvedAt time.Time
// Observed is the value that established the condition.
Observed float64
// Expected is what the model expected instead: the forecast, the
// bound, or the ceiling horizon, per detector.
Expected float64
// Score is the detector's evidence: sigmas for spike and drift,
// hours for ceiling, the overshoot for bounds.
Score float64
}
// AnomalyFilter narrows an anomaly listing.
type AnomalyFilter struct {
// Open keeps only unresolved anomalies when true.
Open bool
// Target keeps only one target's anomalies when set.
Target string
// Since drops anomalies opened before it when set.
Since time.Time
// Limit caps the listing; zero means [MaxAnomalies].
Limit int
}
// MaxAnomalies bounds one anomaly listing.
const MaxAnomalies = 500
// OpenAnomaly records a fresh anomaly and returns its ledger ID. The
// live index admits one open anomaly per series and detector, so a
// duplicate open reports an error rather than a second row.
func (*Store) OpenAnomaly(
ctx context.Context,
tx pgx.Tx,
a *Anomaly,
) (int64, error) {
var id int64
err := tx.QueryRow(ctx, `
INSERT INTO anomalies (
series_id, rule, detector, severity,
opened_at, observed, expected, score
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id`,
a.SeriesID, a.Rule, a.Detector, a.Severity,
a.OpenedAt.UTC(), a.Observed, a.Expected, a.Score,
).Scan(&id)
if err != nil {
return 0, fmt.Errorf("failed to open the anomaly: %w", err)
}
return id, nil
}
// ResolveAnomaly stamps an open anomaly resolved. Resolving one that
// is already resolved, or gone, does nothing.
func (*Store) ResolveAnomaly(
ctx context.Context,
tx pgx.Tx,
id int64,
at time.Time,
) error {
_, err := tx.Exec(ctx, `
UPDATE anomalies SET resolved_at = $2
WHERE id = $1 AND resolved_at IS NULL`,
id, at.UTC(),
)
if err != nil {
return fmt.Errorf("failed to resolve the anomaly: %w", err)
}
return nil
}
// Anomalies lists the ledger newest-first under the given filter, each
// row joined with its series identity.
func (*Store) Anomalies(
ctx context.Context,
tx pgx.Tx,
f AnomalyFilter,
) ([]Anomaly, error) {
query := `
SELECT a.id, a.series_id, a.rule, a.detector, a.severity,
a.opened_at, COALESCE(a.resolved_at, 'epoch'::timestamptz),
a.observed, a.expected, a.score,
s.target, s.name, s.kind, s.tags
FROM anomalies a JOIN series s ON s.id = a.series_id
WHERE TRUE`
args := []any{}
if f.Open {
query += " AND a.resolved_at IS NULL"
}
if f.Target != "" {
args = append(args, f.Target)
query += fmt.Sprintf(" AND s.target = $%d", len(args))
}
if !f.Since.IsZero() {
args = append(args, f.Since.UTC())
query += fmt.Sprintf(" AND a.opened_at >= $%d", len(args))
}
limit := f.Limit
if limit <= 0 || limit > MaxAnomalies {
limit = MaxAnomalies
}
args = append(args, limit)
query += fmt.Sprintf(
" ORDER BY a.opened_at DESC, a.id DESC LIMIT $%d", len(args),
)
rows, err := tx.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("failed to list anomalies: %w", err)
}
defer rows.Close()
var out []Anomaly
for rows.Next() {
var (
a Anomaly
kind string
)
if err := rows.Scan(
&a.ID, &a.SeriesID, &a.Rule, &a.Detector, &a.Severity,
&a.OpenedAt, &a.ResolvedAt, &a.Observed, &a.Expected,
&a.Score, &a.Series.Target, &a.Series.Name, &kind,
&a.Series.Tags,
); err != nil {
return nil, fmt.Errorf("failed to scan an anomaly: %w", err)
}
if err := a.Series.Kind.UnmarshalText([]byte(kind)); err != nil {
return nil, fmt.Errorf("failed to read a series kind: %w", err)
}
if a.ResolvedAt.Unix() == 0 {
a.ResolvedAt = time.Time{}
}
out = append(out, a)
}
return out, rows.Err()
}
// PurgeAnomalies drops resolved anomalies opened before the given
// horizon, returning how many rows went. Open anomalies stay whatever
// their age: an unresolved condition is state, not history.
func (s *Store) PurgeAnomalies(
ctx context.Context,
before time.Time,
) (int64, error) {
tag, err := s.pool.Exec(ctx, `
DELETE FROM anomalies
WHERE resolved_at IS NOT NULL AND opened_at < $1`,
before.UTC(),
)
if err != nil {
return 0, fmt.Errorf("failed to purge anomalies: %w", err)
}
return tag.RowsAffected(), nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package store
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json/v2"
"fmt"
"maps"
"slices"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/deep-rent/nexus/sys/metrics"
)
// Series is the identity of one time series: everything that
// distinguishes it except the observations themselves.
type Series struct {
// Target is the instance name of the scraped endpoint.
Target string
// Name is the metric name.
Name string
// Kind identifies the instrument type.
Kind metrics.Kind
// Tags qualify the series; nil and empty are equivalent.
Tags map[string]string
}
// Fingerprint renders the series identity into its stable hash: the
// SHA-256 over target, name, kind, and the sorted tags. It is the
// upsert key of the series table.
func (s Series) Fingerprint() string {
var b strings.Builder
b.WriteString(s.Target)
b.WriteByte(0)
b.WriteString(s.Name)
b.WriteByte(0)
b.WriteString(s.Kind.String())
for _, k := range slices.Sorted(maps.Keys(s.Tags)) {
b.WriteByte(0)
b.WriteString(k)
b.WriteByte(1)
b.WriteString(s.Tags[k])
}
sum := sha256.Sum256([]byte(b.String()))
return hex.EncodeToString(sum[:])
}
// Point is one observation of a series. Which fields carry meaning
// depends on the series kind — value for counters and gauges; count,
// sum, and buckets for the distribution kinds — mirroring
// [metrics.Sample].
type Point struct {
// SeriesID references the series the observation belongs to.
SeriesID int64
// At is when the snapshot carrying the observation was taken.
At time.Time
// Value is the counter or gauge level.
Value float64
// Count is the number of recorded observations or events.
Count uint64
// Sum is the sum of all recorded observations.
Sum float64
// Buckets are cumulative histogram buckets; nil for kinds without
// a distribution.
Buckets []metrics.Bucket
}
// EnsureSeries upserts the given series identities and returns their
// IDs keyed by fingerprint. Existing series resolve without a write;
// fresh ones are created. The batch must not repeat a fingerprint — a
// single statement cannot upsert one row twice. Callers cache the
// result — in steady state a sweep resolves every series from memory
// and never calls this.
func (*Store) EnsureSeries(
ctx context.Context,
tx pgx.Tx,
series []Series,
) (map[string]int64, error) {
out := make(map[string]int64, len(series))
if len(series) == 0 {
return out, nil
}
prints := make([]string, len(series))
targets := make([]string, len(series))
names := make([]string, len(series))
kinds := make([]string, len(series))
tags := make([][]byte, len(series))
for i, sr := range series {
prints[i] = sr.Fingerprint()
targets[i] = sr.Target
names[i] = sr.Name
kinds[i] = sr.Kind.String()
enc, err := json.Marshal(sr.Tags)
if err != nil {
return nil, fmt.Errorf("failed to encode tags: %w", err)
}
tags[i] = enc
}
// One statement for the whole batch: insert the fresh identities,
// and let the no-op update turn the conflicting rows into returned
// ones, so every fingerprint resolves in a single round trip.
rows, err := tx.Query(ctx, `
INSERT INTO series (fingerprint, target, name, kind, tags)
SELECT * FROM unnest(
$1::text[], $2::text[], $3::text[], $4::text[], $5::jsonb[]
)
ON CONFLICT (fingerprint) DO UPDATE SET target = EXCLUDED.target
RETURNING fingerprint, id`,
prints, targets, names, kinds, tags,
)
if err != nil {
return nil, fmt.Errorf("failed to ensure series: %w", err)
}
defer rows.Close()
for rows.Next() {
var (
print string
id int64
)
if err := rows.Scan(&print, &id); err != nil {
return nil, fmt.Errorf("failed to ensure series: %w", err)
}
out[print] = id
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to ensure series: %w", err)
}
return out, nil
}
// InsertPoints appends the given observations in one bulk copy. The
// day partitions covering every At must exist; see [Store.Rollover].
func (*Store) InsertPoints(
ctx context.Context,
tx pgx.Tx,
points []Point,
) error {
if len(points) == 0 {
return nil
}
_, err := tx.CopyFrom(
ctx,
pgx.Identifier{"points"},
[]string{"series_id", "at", "value", "count", "sum", "buckets"},
pgx.CopyFromSlice(len(points), func(i int) ([]any, error) {
p := points[i]
var buckets []byte
if p.Buckets != nil {
enc, err := json.Marshal(p.Buckets)
if err != nil {
return nil, fmt.Errorf(
"failed to encode buckets: %w", err,
)
}
buckets = enc
}
return []any{
p.SeriesID, p.At, p.Value, int64(p.Count), p.Sum, buckets,
}, nil
}),
)
if err != nil {
return fmt.Errorf("failed to insert points: %w", err)
}
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package store
import (
"context"
"encoding/json/v2"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/deep-rent/nexus/sys/metrics"
)
// Info is one catalog entry: a series identity together with its ID.
type Info struct {
// ID is the series identifier, referenced by [Store.Range].
ID int64
// Series is the identity.
Series
}
// Filter narrows a catalog lookup. Zero fields do not filter.
type Filter struct {
// Name matches the exact metric name.
Name string
// Target matches the exact instance name.
Target string
// Tags requires every given tag pair to be present on the series.
Tags map[string]string
}
// Catalog returns the series matching the filter, ordered by name,
// target, and ID — the API's picker feed and the resolution step ahead
// of a range query.
func (*Store) Catalog(
ctx context.Context,
tx pgx.Tx,
f Filter,
) ([]Info, error) {
query := `
SELECT id, target, name, kind, tags FROM series WHERE TRUE`
args := []any{}
if f.Name != "" {
args = append(args, f.Name)
query += fmt.Sprintf(" AND name = $%d", len(args))
}
if f.Target != "" {
args = append(args, f.Target)
query += fmt.Sprintf(" AND target = $%d", len(args))
}
if len(f.Tags) > 0 {
enc, err := json.Marshal(f.Tags)
if err != nil {
return nil, fmt.Errorf("failed to encode tag filter: %w", err)
}
args = append(args, enc)
query += fmt.Sprintf(" AND tags @> $%d::jsonb", len(args))
}
query += " ORDER BY name, target, id"
rows, err := tx.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("failed to query the catalog: %w", err)
}
defer rows.Close()
var out []Info
for rows.Next() {
var (
info Info
kind string
tags []byte
)
if err := rows.Scan(
&info.ID, &info.Target, &info.Name, &kind, &tags,
); err != nil {
return nil, fmt.Errorf("failed to query the catalog: %w", err)
}
if err := info.Kind.UnmarshalText([]byte(kind)); err != nil {
return nil, fmt.Errorf("failed to query the catalog: %w", err)
}
if err := json.Unmarshal(tags, &info.Tags); err != nil {
return nil, fmt.Errorf("failed to query the catalog: %w", err)
}
if len(info.Tags) == 0 {
info.Tags = nil
}
out = append(out, info)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to query the catalog: %w", err)
}
return out, nil
}
// Slice is one step bucket of one series: the SQL-side aggregates over
// every observation falling into the bucket. The API derives the final
// answer from these — averages for gauges, reset-aware deltas for
// counters, bucket-diff quantiles for distributions.
type Slice struct {
// SeriesID references the series the bucket belongs to.
SeriesID int64
// At is the bucket's inclusive lower bound.
At time.Time
// Samples is how many observations fell into the bucket.
Samples int64
// Avg, Min, and Max aggregate the value column across the bucket.
Avg, Min, Max float64
// Last carries the value of the latest observation in the bucket.
Last float64
// Count and Sum carry the latest cumulative count and sum.
Count uint64
// Sum is the latest cumulative sum.
Sum float64
// Buckets carries the latest cumulative distribution, when the
// series records one.
Buckets []metrics.Bucket
}
// Range returns the step-bucketed aggregates of the given series over
// [from, to), ordered by series and bucket. Buckets without
// observations are absent — gaps are the API's to interpret. The
// distributions ride along only when buckets is set: they are the wide
// column, and only quantile queries read them.
func (*Store) Range(
ctx context.Context,
tx pgx.Tx,
ids []int64,
from, to time.Time,
step time.Duration,
buckets bool,
) ([]Slice, error) {
if len(ids) == 0 {
return nil, nil
}
distribution := "NULL::jsonb"
if buckets {
distribution = "(array_agg(buckets ORDER BY at DESC))[1]"
}
rows, err := tx.Query(ctx, `
SELECT
series_id,
date_bin($4, at, $2) AS bucket,
count(*),
avg(value), min(value), max(value),
(array_agg(value ORDER BY at DESC))[1],
(array_agg(count ORDER BY at DESC))[1],
(array_agg(sum ORDER BY at DESC))[1],
`+distribution+`
FROM points
WHERE series_id = ANY($1) AND at >= $2 AND at < $3
GROUP BY series_id, bucket
ORDER BY series_id, bucket`,
ids, from.UTC(), to.UTC(), step,
)
if err != nil {
return nil, fmt.Errorf("failed to query the range: %w", err)
}
defer rows.Close()
var out []Slice
for rows.Next() {
var (
slice Slice
count int64
buckets []byte
)
if err := rows.Scan(
&slice.SeriesID, &slice.At, &slice.Samples,
&slice.Avg, &slice.Min, &slice.Max, &slice.Last,
&count, &slice.Sum, &buckets,
); err != nil {
return nil, fmt.Errorf("failed to query the range: %w", err)
}
slice.Count = uint64(max(count, 0))
if buckets != nil {
if err := json.Unmarshal(buckets, &slice.Buckets); err != nil {
return nil, fmt.Errorf(
"failed to decode buckets: %w", err,
)
}
}
out = append(out, slice)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to query the range: %w", err)
}
return out, nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package store
import (
"context"
"fmt"
"strings"
"time"
)
// partitionPrefix names the day partitions: points_20260810 covers
// 2026-08-10 UTC.
const partitionPrefix = "points_"
// partitionDay is the naming layout of one partition's day.
const partitionDay = "20060102"
// Rollover enforces the retention window through partition lifecycle:
// it creates the day partitions for today and tomorrow (UTC) when they
// do not exist yet, and drops every partition lying entirely before
// now-keep. Dropping a partition is instant and vacuum-free, which is
// the whole point of partitioning the history by day — retention never
// DELETEs.
//
// The pass is idempotent and must run before the first sweep of a fresh
// deployment: inserting an observation with no partition for its day
// fails. Assembly runs it at startup and on a schedule; overlapping
// runs from several replicas are safe (IF NOT EXISTS / IF EXISTS).
//
// This is the one place the service issues DDL at runtime — partition
// management, not schema evolution; the shape of the tables stays owned
// by the migration stream.
func (s *Store) Rollover(
ctx context.Context,
now time.Time,
keep time.Duration,
) (created, dropped []string, err error) {
today := now.UTC().Truncate(24 * time.Hour)
names, err := s.partitions(ctx)
if err != nil {
return nil, nil, err
}
have := make(map[string]struct{}, len(names))
for _, name := range names {
have[name] = struct{}{}
}
// Tomorrow rides along so the midnight boundary never races the
// sweeps: when a day ends, its successor already exists.
for _, day := range []time.Time{today, today.Add(24 * time.Hour)} {
name := partitionPrefix + day.Format(partitionDay)
if _, ok := have[name]; ok {
continue
}
_, err := s.pool.Exec(ctx, fmt.Sprintf(
`CREATE TABLE IF NOT EXISTS %s PARTITION OF points
FOR VALUES FROM ('%s') TO ('%s')`,
name,
day.Format(time.DateOnly),
day.Add(24*time.Hour).Format(time.DateOnly),
))
if err != nil {
return created, dropped, fmt.Errorf(
"failed to create partition %s: %w", name, err,
)
}
created = append(created, name)
}
// A partition covering [day, day+1) is expired when its whole range
// lies before the floor.
floor := now.UTC().Add(-keep)
for _, name := range names {
day, err := time.ParseInLocation(
partitionDay,
strings.TrimPrefix(name, partitionPrefix),
time.UTC,
)
if err != nil {
// Not one of ours (a manually attached partition, say);
// never drop what the rollover did not create.
continue
}
if day.Add(24 * time.Hour).After(floor) {
continue
}
if _, err := s.pool.Exec(
ctx,
"DROP TABLE IF EXISTS "+name,
); err != nil {
return created, dropped, fmt.Errorf(
"failed to drop partition %s: %w", name, err,
)
}
dropped = append(dropped, name)
}
return created, dropped, nil
}
// partitions lists the current day partitions of the points table.
func (s *Store) partitions(ctx context.Context) ([]string, error) {
rows, err := s.pool.Query(ctx, `
SELECT c.relname
FROM pg_inherits i
JOIN pg_class c ON c.oid = i.inhrelid
JOIN pg_class p ON p.oid = i.inhparent
WHERE p.relname = 'points'
ORDER BY c.relname`,
)
if err != nil {
return nil, fmt.Errorf("failed to list partitions: %w", err)
}
defer rows.Close()
var names []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, fmt.Errorf("failed to list partitions: %w", err)
}
names = append(names, name)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to list partitions: %w", err)
}
return names, nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package store
import (
"context"
"database/sql"
"embed"
"io/fs"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/deep-rent/nexus/dat/migrate"
driver "github.com/deep-rent/nexus/dat/migrate/driver/postgres"
source "github.com/deep-rent/nexus/dat/migrate/source/file"
)
//go:embed migrations
var migrations embed.FS
// Module is the migration stream the history schema lives in.
const Module = "mma"
// Migrations exposes the embedded schema migrations for [migrate]
// paired with its PostgreSQL driver.
func Migrations() fs.FS {
sub, err := fs.Sub(migrations, "migrations")
if err != nil {
// The subdirectory is embedded at compile time; failing to open
// it is a build defect, not a runtime condition.
panic(err)
}
return sub
}
// Migrator wires a [migrate.Migrator] for the history schema over an
// existing database handle — typically the [database/sql] view of the
// service's pool via [stdlib.OpenDBFromPool]. The module, source, and
// driver are this schema's to declare; opts carry what the caller
// legitimately varies, such as a logger.
//
// [stdlib.OpenDBFromPool]: github.com/jackc/pgx/v5/stdlib#OpenDBFromPool
func Migrator(db *sql.DB, opts ...migrate.Option) *migrate.Migrator {
return migrate.New(append([]migrate.Option{
migrate.WithModule(Module),
migrate.WithSource(source.New(Migrations())),
migrate.WithDriver(driver.New(db)),
}, opts...)...)
}
// Open connects a migrator for the history schema to the database at
// url, for commands that only run migrations and have no use for a
// native pool. The returned close function releases the connection.
func Open(
url string,
opts ...migrate.Option,
) (*migrate.Migrator, func() error, error) {
return driver.Open(url, append([]migrate.Option{
migrate.WithModule(Module),
migrate.WithSource(source.New(Migrations())),
}, opts...)...)
}
// Store persists and serves the metric history. It is safe for
// concurrent use. It carries no logger deliberately: every operation
// returns its error, and narrating outcomes is its callers' business —
// the ingest recorder and the service's rollover task both do.
type Store struct {
pool *pgxpool.Pool
}
// New creates a [Store] over the given connection pool. It panics on a
// nil pool (programmer error).
func New(pool *pgxpool.Pool) *Store {
if pool == nil {
panic("pool is required")
}
return &Store{pool: pool}
}
// Exec runs fn within a single transaction, committing on nil and
// rolling back on error.
func (s *Store) Exec(
ctx context.Context,
fn func(ctx context.Context, tx pgx.Tx) error,
) error {
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
return fn(ctx, tx)
})
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package api
import (
"context"
"errors"
"net/http"
"time"
"uuid"
"github.com/jackc/pgx/v5"
"github.com/deep-rent/nexus/eco/nds/catalog"
"github.com/deep-rent/nexus/eco/nds/device"
"github.com/deep-rent/nexus/eco/nds/dispatch"
"github.com/deep-rent/nexus/eco/nds/prefs"
"github.com/deep-rent/nexus/eco/nds/store"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/sec/auth"
"github.com/deep-rent/nexus/std/clock"
)
// PermissionPublish is the permission the publish surface demands.
// Scopes and permissions share a namespace, so a machine client carries
// it as a vetted scope.
//
// It is granted to no role at all, which is the point: a person's token
// carrying it could write to anybody's lock screen.
const PermissionPublish = "nds:publish"
// Permissions lists every permission of this API.
var Permissions = []string{PermissionPublish}
// Paths this API mounts at.
const (
// PathDevices roots the device registry.
PathDevices = "/devices"
// PathPreferences serves what somebody has chosen.
PathPreferences = "/preferences"
// PathCategories serves the vocabulary a settings screen renders.
PathCategories = "/categories"
// PathNotifications is the publish endpoint siblings call.
PathNotifications = "/notifications"
)
// MaxDevices caps how many phones one person may register. It sits far
// above any real answer — a household with a phone, a tablet, and a
// spare — and exists so a broken client cannot fill the table.
const MaxDevices = 20
// ErrTooManyDevices reports a person at [MaxDevices].
var ErrTooManyDevices = errors.New(
"this account has registered too many devices",
)
// Registry is the persistence this API reads and writes, satisfied by
// [store.Store].
//
// [store.Store]: github.com/deep-rent/nexus/eco/nds/store#Store
type Registry interface {
Exec(
ctx context.Context,
fn func(ctx context.Context, tx pgx.Tx) error,
) error
ClaimUser(ctx context.Context, tx pgx.Tx, user uuid.UUID) error
Register(
ctx context.Context,
tx pgx.Tx,
user uuid.UUID,
reg device.Registration,
now time.Time,
) (device.Device, error)
List(ctx context.Context, tx pgx.Tx, user uuid.UUID) (
[]device.Device, error)
Forget(ctx context.Context, tx pgx.Tx, user, id uuid.UUID) error
Settings(ctx context.Context, tx pgx.Tx, user uuid.UUID) (
prefs.Settings, error)
Save(
ctx context.Context,
tx pgx.Tx,
user uuid.UUID,
up prefs.Update,
now time.Time,
) error
}
// Config bundles the collaborators of a [Server].
type Config struct {
// Registry persists devices and preferences. Required.
Registry Registry
// Engine publishes notifications and holds the catalog. Required.
Engine *dispatch.Engine
// Clock is the time source. Defaults to [clock.System].
Clock clock.Clock
}
// Server implements the notification API. Create instances with [New]
// and attach the routes with [Server.Mount] and [Server.MountMachine].
type Server struct {
cfg Config
}
// New assembles a [Server]. It panics if a required [Config] field is
// missing, since startup misconfiguration is a programmer error.
func New(cfg Config) *Server {
switch {
case cfg.Registry == nil:
panic("registry is required")
case cfg.Engine == nil:
panic("engine is required")
}
if cfg.Clock == nil {
cfg.Clock = clock.System
}
return &Server{cfg: cfg}
}
// Mount registers the delegated surface: everything belonging to a
// person. Pass the auth guard (and any additional route middleware) as
// mws.
func (s *Server) Mount(r *router.Router, mws ...router.Middleware) {
g := r.Group("", mws...)
g.HandleFunc(http.MethodPost, PathDevices, s.register)
g.HandleFunc(http.MethodGet, PathDevices, s.devices)
g.HandleFunc(http.MethodDelete, PathDevices+"/{id}", s.forget)
g.HandleFunc(http.MethodGet, PathPreferences, s.preferences)
g.HandleFunc(http.MethodPatch, PathPreferences, s.choose)
g.HandleFunc(http.MethodGet, PathCategories, s.categories)
}
// MountMachine registers the publish surface. It takes its own
// middleware because it is guarded differently: siblings call it with
// machine credentials, and it is closed to delegated tokens entirely.
func (s *Server) MountMachine(
r *router.Router,
mws ...router.Middleware,
) {
g := r.Group("", mws...)
g.HandleFunc(http.MethodPost, PathNotifications, s.publish)
}
// caller resolves who is asking, for the delegated surface.
//
// A machine token names no person, and every route here belongs to one,
// so it is refused rather than served an empty answer.
func caller(e *router.Exchange) (uuid.UUID, error) {
id := auth.Must(e).UserID()
if id == uuid.Nil() {
return uuid.Nil(), &router.Error{
Status: http.StatusForbidden,
Reason: router.ReasonValidationFailed,
Description: "this surface is delegated-only",
}
}
return id, nil
}
// fail maps a domain error onto a status.
//
// A device somebody does not own answers exactly as one that does not
// exist, so a device identifier cannot be probed. Everything else the
// domain refuses is a client-side mistake, and says so.
func fail(err error) error {
switch {
case err == nil:
return nil
case errors.Is(err, store.ErrNotFound):
return &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "no such device",
}
case errors.Is(err, ErrTooManyDevices):
return &router.Error{
Status: http.StatusConflict,
Reason: router.ReasonValidationFailed,
Description: err.Error(),
}
case errors.Is(err, dispatch.ErrUnknownCategory),
errors.Is(err, dispatch.ErrNoRecipients),
errors.Is(err, catalog.ErrVariable),
errors.Is(err, device.ErrNoToken),
errors.Is(err, device.ErrPlatform),
errors.Is(err, prefs.ErrChoice),
errors.Is(err, prefs.ErrQuiet):
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: err.Error(),
}
}
return err
}
// register serves "POST /devices": an app registering or refreshing.
//
// The owner is the caller's own identity, never a body field. A device
// token is a capability for reaching a phone, and letting a body name
// its owner would let anyone point anybody else's notifications at a
// phone they happen to hold.
func (s *Server) register(e *router.Exchange) error {
user, err := caller(e)
if err != nil {
return err
}
var req device.Registration
if err := e.BindJSON(&req); err != nil {
return err
}
if !s.cfg.Engine.Serves(req.Platform) {
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: "this deployment does not deliver to " +
string(req.Platform) + " devices",
}
}
var d device.Device
err = s.cfg.Registry.Exec(e.Context(), func(
ctx context.Context, tx pgx.Tx,
) error {
// Claimed first, so the count and the insert are one decision.
// The check is a read-modify-write and the registry runs at
// READ COMMITTED, so without this two apps registering at once
// both read the same count, both find room, and both insert —
// the cap being the only bound on how many rows one account
// can put in the table.
if err := s.cfg.Registry.ClaimUser(ctx, tx, user); err != nil {
return err
}
held, err := s.cfg.Registry.List(ctx, tx, user)
if err != nil {
return err
}
if len(held) >= MaxDevices && !holds(held, req.Token) {
return ErrTooManyDevices
}
d, err = s.cfg.Registry.Register(
ctx, tx, user, req, s.cfg.Clock(),
)
return err
})
if err != nil {
return fail(err)
}
e.NoStore()
return e.JSON(http.StatusOK, d)
}
// holds reports whether the token is already one of these devices, so a
// refresh is never refused by a cap it does not grow.
//
// It compares fingerprints rather than tokens because a listing
// deliberately carries no tokens; see [device.Device.Fingerprint].
func holds(devices []device.Device, token string) bool {
digest := device.Digest(token)
for _, d := range devices {
if d.Fingerprint == digest {
return true
}
}
return false
}
// devices serves "GET /devices": the caller's own phones.
func (s *Server) devices(e *router.Exchange) error {
user, err := caller(e)
if err != nil {
return err
}
var out []device.Device
err = s.cfg.Registry.Exec(e.Context(), func(
ctx context.Context, tx pgx.Tx,
) error {
var err error
out, err = s.cfg.Registry.List(ctx, tx, user)
return err
})
if err != nil {
return fail(err)
}
e.NoStore()
return e.JSON(http.StatusOK, map[string]any{"devices": out})
}
// forget serves "DELETE /devices/{id}".
func (s *Server) forget(e *router.Exchange) error {
user, err := caller(e)
if err != nil {
return err
}
id, err := uuid.Parse(e.Param("id"))
if err != nil {
// An identifier nobody could have is a device nobody has.
return fail(store.ErrNotFound)
}
err = s.cfg.Registry.Exec(e.Context(), func(
ctx context.Context, tx pgx.Tx,
) error {
return s.cfg.Registry.Forget(ctx, tx, user, id)
})
if err != nil {
return fail(err)
}
e.NoContent()
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package api
import (
"context"
"net/http"
"slices"
"strconv"
"strings"
"github.com/jackc/pgx/v5"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/eco/nds/catalog"
"github.com/deep-rent/nexus/eco/nds/dispatch"
"github.com/deep-rent/nexus/eco/nds/prefs"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/std/text"
)
// settings is what a settings screen reads: what somebody chose, and
// what the catalog would do about the categories they have not touched.
//
// The defaults travel with the answer rather than being looked up
// separately, because a screen rendering a toggle needs both — the
// absence of a choice is not "off", it is "whatever this category
// declares".
type settings struct {
// Quiet is the window they set, absent when they set none.
Quiet *prefs.Quiet `json:"quiet,omitzero"`
// Categories is what they explicitly chose, by category name.
Categories map[string]prefs.Choice `json:"categories"`
// Defaults is what applies to the categories they did not choose.
Defaults map[string]prefs.Choice `json:"defaults"`
}
// preferences serves "GET /preferences".
func (s *Server) preferences(e *router.Exchange) error {
user, err := caller(e)
if err != nil {
return err
}
var held prefs.Settings
err = s.cfg.Registry.Exec(e.Context(), func(
ctx context.Context, tx pgx.Tx,
) error {
var err error
held, err = s.cfg.Registry.Settings(ctx, tx, user)
return err
})
if err != nil {
return fail(err)
}
// Only what the catalog still declares. A choice about a category
// that has since been renamed or retired is kept in the registry —
// destroying somebody's explicit choice on the strength of a
// config file is a trade this service does not make — but it is
// not something a settings screen can draw, so it stays here.
cat := s.cfg.Engine.Catalog()
shown := make(map[string]prefs.Choice, len(held.Categories))
for name, choice := range held.Categories {
if _, ok := cat.Find(name); ok {
shown[name] = choice
}
}
out := settings{
Categories: shown,
Defaults: cat.Defaults(),
}
if held.Quiet.Set() {
out.Quiet = &held.Quiet
}
if out.Categories == nil {
out.Categories = map[string]prefs.Choice{}
}
e.NoStore()
return e.JSON(http.StatusOK, out)
}
// update is the payload of a preferences change. It is a PATCH rather
// than a PUT because a settings screen moves one toggle at a time, and a
// PUT would make every such move a read-modify-write race with the
// person's other phone.
type update struct {
// Quiet replaces the window when present. An unset window clears
// it.
Quiet *prefs.Quiet `json:"quiet,omitzero"`
// Categories are the choices to record. A category mapped to the
// empty string is cleared, which restores the catalog's default —
// a different state from switching it off.
Categories map[string]prefs.Choice `json:"categories,omitzero"`
}
// Validate implements the [valid.Validatable] interface.
func (u *update) Validate(v *valid.Validator) {
up := prefs.Update{Quiet: u.Quiet, Categories: u.Categories}
up.Validate(v)
}
// choose serves "PATCH /preferences".
func (s *Server) choose(e *router.Exchange) error {
user, err := caller(e)
if err != nil {
return err
}
var req update
if err := e.BindJSON(&req); err != nil {
return err
}
// A choice about a category the catalog does not declare is refused
// rather than stored. Storing it would leave a row nobody can ever
// see or clear from a settings screen, and would hide a client's
// typo behind a preference that silently does nothing.
cat := s.cfg.Engine.Catalog()
for name := range req.Categories {
if _, ok := cat.Find(name); ok {
continue
}
// The catalog is a closed vocabulary of a few dozen names, so
// the refusal can point at the one that was probably meant
// instead of leaving a client author to diff it by eye.
desc := "no such category: " + name
if near, ok := text.Nearest(name, cat.Names()); ok {
desc += "; did you mean " + strconv.Quote(near) + "?"
}
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: desc,
}
}
err = s.cfg.Registry.Exec(e.Context(), func(
ctx context.Context, tx pgx.Tx,
) error {
return s.cfg.Registry.Save(ctx, tx, user, prefs.Update{
Quiet: req.Quiet,
Categories: req.Categories,
}, s.cfg.Clock())
})
if err != nil {
return fail(err)
}
return s.preferences(e)
}
// category is one entry of the vocabulary, as a settings screen renders
// it. The templates are deliberately absent: a screen needs to know
// which switches to draw, not what the text says.
type category struct {
// Name identifies the category in a preference.
Name string `json:"name"`
// Default is what applies to somebody who has not chosen.
Default prefs.Choice `json:"default"`
// Visibility says how much of it reaches a lock screen, so a
// settings screen can explain the difference rather than leave
// somebody guessing.
Visibility catalog.Visibility `json:"visibility"`
// Urgent marks a category that ignores quiet hours, so nobody is
// surprised by the one notification their night does not stop.
Urgent bool `json:"urgent,omitzero"`
}
// categories serves "GET /categories": the vocabulary a settings screen
// renders, so a client draws the choices rather than hard-coding them.
func (s *Server) categories(e *router.Exchange) error {
if _, err := caller(e); err != nil {
return err
}
held := s.cfg.Engine.Catalog().Categories()
out := make([]category, 0, len(held))
for _, c := range held {
out = append(out, category{
Name: c.Name,
Default: c.Choice(),
Visibility: c.Visibility,
Urgent: c.Urgent,
})
}
slices.SortFunc(out, func(a, b category) int {
return strings.Compare(a.Name, b.Name)
})
return e.JSON(http.StatusOK, map[string]any{"categories": out})
}
// publish serves "POST /notifications": the sibling services' door.
//
// It is closed to delegated tokens. A person's access token carrying
// this permission would let whoever holds it write to anybody's lock
// screen, and the callers — the help desk, the purchase service, the
// identity service — all hold machine credentials already.
func (s *Server) publish(e *router.Exchange) error {
var req dispatch.Request
if err := e.BindJSON(&req); err != nil {
return err
}
out, err := s.cfg.Engine.Publish(e.Context(), req)
if err != nil {
return fail(err)
}
e.NoStore()
return e.JSON(http.StatusAccepted, out)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package catalog
import (
"encoding/json/v2"
"errors"
"fmt"
"maps"
"os"
"slices"
"strings"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/eco/nds/prefs"
"github.com/deep-rent/nexus/std/i18n"
)
// Visibility declares how much of a category is allowed onto a lock
// screen. See the package documentation for what each one buys.
type Visibility string
const (
// VisibilityFull renders the template as written.
VisibilityFull Visibility = "full"
// VisibilityPrivate renders the generic line and carries the detail
// as data the app resolves once unlocked.
VisibilityPrivate Visibility = "private"
// VisibilitySilent renders nothing and carries only data.
VisibilitySilent Visibility = "silent"
)
// Visibilities lists every visibility a category may declare.
var Visibilities = []Visibility{
VisibilityFull, VisibilityPrivate, VisibilitySilent,
}
// Valid reports whether the visibility is one this service knows.
func (v Visibility) Valid() bool {
return slices.Contains(Visibilities, v)
}
// Bounds on a catalog and on what a publisher may substitute into one.
const (
// MaxNameLength caps a category name.
MaxNameLength = 64
// MaxVariables caps how many variables one category declares.
MaxVariables = 16
// MaxValueLength caps one substituted value, in bytes. A push body
// is truncated by the platform well before this, and the cap is what
// stops a caller from using a notification as a data channel.
MaxValueLength = 256
// MaxRendered caps a rendered title or body in bytes, after
// substitution. Both providers refuse a payload above 4 KiB, so this
// leaves ample room for the data and the envelope around the text.
MaxRendered = 1024
)
// Errors reported for a catalog or a render this package refuses.
var (
// ErrUnknownCategory reports a category no catalog entry names.
ErrUnknownCategory = errors.New("unknown notification category")
// ErrNoLanguage reports a catalog publishing no languages at all.
ErrNoLanguage = errors.New("a catalog needs a language")
// ErrVariable reports a value for a variable the category does not
// declare, or a value too long to substitute.
ErrVariable = errors.New("invalid notification variable")
)
// Text is one category rendered in one language.
type Text struct {
// Title is the notification's heading.
Title string `json:"title"`
// Body is its text. Both may carry {name} placeholders.
Body string `json:"body"`
// Generic is what a private category shows on a lock screen
// instead: it becomes the notification's title, and the body is left
// empty, since "You have a new message" needs no second line.
//
// It carries no placeholders — its whole purpose is to say nothing
// specific — and is required exactly when the category is private.
Generic string `json:"generic,omitzero"`
}
// Category is one kind of notification this deployment sends.
type Category struct {
// Name identifies the category in a publish call, in a preference
// row, and in the metrics. Lowercase and dot-separated by
// convention: "ticket.answered".
Name string `json:"name"`
// Default is what applies to somebody who has expressed no
// preference. Empty means [prefs.ChoiceOn]: a deployment that
// declares a category is saying it wants people to receive it, and
// an opt-in nobody knows to look for is a category nobody gets.
Default prefs.Choice `json:"default,omitzero"`
// Visibility declares how much reaches a lock screen. Empty means
// [VisibilityPrivate], which is the safe default: a category that
// forgot to declare should disclose less rather than more.
Visibility Visibility `json:"visibility,omitzero"`
// Urgent pierces quiet hours. A security alert that waits until
// morning is not a security alert; almost nothing else qualifies.
Urgent bool `json:"urgent,omitzero"`
// Collapse groups notifications that supersede one another, so a
// phone that was offline shows the latest rather than all of them.
// Empty sends every notification separately. The publisher's key is
// appended, so two subjects collapse independently.
Collapse string `json:"collapse,omitzero"`
// Variables are the placeholder names the templates may reference
// and a publisher may supply. Anything else is refused on both
// sides: an undeclared placeholder fails startup, an undeclared
// value fails the publish call.
Variables []string `json:"variables,omitzero"`
// Text is the category rendered per language, keyed by BCP 47 tag.
// Every language the catalog declares must be present.
Text map[string]Text `json:"text"`
}
// Silent reports whether the category carries no human-facing text.
func (c Category) Silent() bool {
return c.Visibility == VisibilitySilent
}
// Choice is what applies to somebody who expressed no preference.
func (c Category) Choice() prefs.Choice {
if c.Default == "" {
return prefs.ChoiceOn
}
return c.Default
}
// File is the catalog document as it is written on disk.
type File struct {
// Languages are the locales every category is published in, most
// preferred first. The first is the fallback for a recipient whose
// own languages match nothing.
Languages []string `json:"languages"`
// Categories is the vocabulary itself.
Categories []Category `json:"categories"`
}
// Catalog is the loaded, validated vocabulary.
type Catalog struct {
languages []string
categories []Category
byName map[string]*Category
}
// Load reads and validates a catalog file.
func Load(file string) (*Catalog, error) {
raw, err := os.ReadFile(file)
if err != nil {
return nil, err
}
var doc File
if err := json.Unmarshal(raw, &doc); err != nil {
return nil, fmt.Errorf("failed to parse the catalog: %w", err)
}
return New(doc)
}
// New validates a catalog document.
//
// Everything that could be wrong with a catalog is wrong here, at
// startup, rather than at the moment somebody was supposed to be
// notified: an unknown visibility, a language a category is missing, a
// placeholder no variable declares, a private category with no generic
// line to fall back to.
func New(doc File) (*Catalog, error) {
if len(doc.Languages) == 0 {
return nil, ErrNoLanguage
}
languages := make([]string, 0, len(doc.Languages))
for _, tag := range doc.Languages {
if !valid.Lang(tag) {
return nil, fmt.Errorf("%q is not a language tag", tag)
}
canonical := i18n.Canonical(tag)
if slices.Contains(languages, canonical) {
return nil, fmt.Errorf("language %q is listed twice", tag)
}
languages = append(languages, canonical)
}
c := &Catalog{
languages: languages,
categories: slices.Clone(doc.Categories),
byName: make(map[string]*Category, len(doc.Categories)),
}
for i := range c.categories {
cat := &c.categories[i]
if err := c.check(cat, i); err != nil {
return nil, err
}
c.byName[cat.Name] = cat
}
if len(c.categories) == 0 {
return nil, errors.New("a catalog needs at least one category")
}
return c, nil
}
// check validates one category against the catalog's languages.
func (c *Catalog) check(cat *Category, i int) error {
fail := func(format string, args ...any) error {
return fmt.Errorf(
"category %q: %s", cat.Name, fmt.Sprintf(format, args...),
)
}
switch {
case cat.Name == "":
return fmt.Errorf("category %d has no name", i)
case len(cat.Name) > MaxNameLength:
return fail("the name is longer than %d characters", MaxNameLength)
case c.byName[cat.Name] != nil:
return fail("declared twice")
}
if cat.Visibility == "" {
cat.Visibility = VisibilityPrivate
}
if !cat.Visibility.Valid() {
return fail("unknown visibility %q", cat.Visibility)
}
if cat.Default != "" && !cat.Default.Valid() {
return fail("unknown default %q", cat.Default)
}
if len(cat.Variables) > MaxVariables {
return fail("declares more than %d variables", MaxVariables)
}
for _, name := range cat.Variables {
if name == "" {
return fail("declares an unnamed variable")
}
if strings.ContainsAny(name, "{}") {
return fail("variable %q carries a brace", name)
}
}
if len(cat.Variables) != len(slices.Compact(slices.Sorted(
slices.Values(cat.Variables),
))) {
return fail("declares a variable twice")
}
// A silent category carries no text at all, so it is the one shape
// that may leave the templates out.
if cat.Silent() {
if len(cat.Text) > 0 {
return fail(
"is silent and must carry no text; a category that " +
"renders is not silent",
)
}
return nil
}
for _, lang := range c.languages {
text, ok := cat.Text[lang]
if !ok {
return fail("is not published in %q", lang)
}
if text.Title == "" || text.Body == "" {
return fail("has no title or body in %q", lang)
}
if name, ok := undeclared(cat, text.Title); !ok {
return fail(
"the %q title references {%s}, which the category "+
"does not declare",
lang, name,
)
}
if name, ok := undeclared(cat, text.Body); !ok {
return fail(
"the %q body references {%s}, which the category "+
"does not declare",
lang, name,
)
}
switch {
case cat.Visibility == VisibilityPrivate && text.Generic == "":
return fail(
"is private and has no generic line in %q; a private "+
"category shows the generic line on a lock screen",
lang,
)
case text.Generic != "":
if strings.ContainsAny(text.Generic, "{}") {
return fail(
"%q generic line carries a placeholder; its whole "+
"purpose is to say nothing specific",
lang,
)
}
}
}
// A template may reference every language of the catalog and no
// others, so an extra one is a translation for a language nobody
// will ever be served.
for lang := range cat.Text {
if !slices.Contains(c.languages, lang) {
return fail(
"is published in %q, which the catalog does not declare",
lang,
)
}
}
return nil
}
// undeclared reports the first {name} in a template that the category
// does not declare. The boolean is true when every placeholder checks
// out, so the call site reads as a guard rather than as a search.
func undeclared(cat *Category, tmpl string) (string, bool) {
for name := range names(tmpl) {
if !slices.Contains(cat.Variables, name) {
return name, false
}
}
return "", true
}
// names yields the placeholder names a template references.
func names(tmpl string) func(func(string) bool) {
return func(yield func(string) bool) {
for rest := tmpl; ; {
i := strings.IndexByte(rest, '{')
if i < 0 {
return
}
rest = rest[i+1:]
j := strings.IndexByte(rest, '}')
if j < 0 {
return
}
if !yield(rest[:j]) {
return
}
rest = rest[j+1:]
}
}
}
// Languages are the locales this catalog is published in, most preferred
// first.
func (c *Catalog) Languages() []string { return slices.Clone(c.languages) }
// Categories lists the vocabulary, for the settings screen that renders
// the choices and the subcommand that prints them.
func (c *Catalog) Categories() []Category {
return slices.Clone(c.categories)
}
// Names lists the category names, sorted.
func (c *Catalog) Names() []string {
return slices.Sorted(maps.Keys(c.byName))
}
// Find resolves a category by name, reporting whether the catalog
// declares one.
func (c *Catalog) Find(name string) (Category, bool) {
cat, ok := c.byName[name]
if !ok {
return Category{}, false
}
return *cat, true
}
// Defaults maps every category onto what applies to somebody who has
// expressed no preference — what a settings screen renders as the
// unset state.
func (c *Catalog) Defaults() map[string]prefs.Choice {
out := make(map[string]prefs.Choice, len(c.categories))
for _, cat := range c.categories {
out[cat.Name] = cat.Choice()
}
return out
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package catalog
import (
"fmt"
"maps"
"slices"
"strings"
"unicode/utf8"
"github.com/deep-rent/nexus/std/i18n"
)
// Rendered is one notification, finished: the text that goes on the lock
// screen and the data that goes to the app.
//
// It is what the delivery step hands to a provider, and it is the only
// thing that ever crosses to Apple or Google. Nothing a publisher sent
// reaches a provider except through here.
type Rendered struct {
// Title and Body are the text. Both are empty for a silent
// category, which displays nothing.
Title string
Body string
// Data is the payload the app reads. It is string-valued to match
// [push.Message.Data], which is string-valued because that is what
// both providers accept.
//
// [push.Message.Data]: github.com/deep-rent/nexus/net/notify/push#Message
Data map[string]string
// Language is the tag the text was rendered in, empty when there is
// no text.
Language string
// Silent reports whether this is a background push with no
// human-facing side.
Silent bool
}
// Vars are the values a publisher supplies for one notification.
type Vars map[string]string
// Check reports whether the variables are ones the category declares and
// short enough to substitute.
//
// It is deliberately strict about UNDECLARED names rather than ignoring
// them. A caller sending a variable this category does not take has
// misunderstood something, and the alternative — dropping it silently —
// turns a caller's bug into a notification that renders a placeholder
// verbatim.
func (c Category) Check(vars Vars) error {
for _, name := range slices.Sorted(maps.Keys(vars)) {
if !slices.Contains(c.Variables, name) {
return fmt.Errorf(
"%w: %q takes no variable %q",
ErrVariable, c.Name, name,
)
}
if len(vars[name]) > MaxValueLength {
return fmt.Errorf(
"%w: %q is longer than %d bytes",
ErrVariable, name, MaxValueLength,
)
}
}
return nil
}
// Render turns a category and its variables into a finished
// notification, in the best language available for the given
// preferences.
//
// The visibility declared by the category is what decides how much of it
// is text. A full category renders its template; a private one renders
// its generic line and moves every variable into the data payload; a
// silent one renders nothing at all. See the package documentation for
// why the middle case is a visible notification rather than a silent
// one.
//
// The category name always travels in the data payload, because an app
// woken by a private or silent notification needs to know what it was
// woken for before it can resolve anything.
func (c *Catalog) Render(
cat Category,
vars Vars,
prefer []string,
) (Rendered, error) {
if err := cat.Check(vars); err != nil {
return Rendered{}, err
}
data := make(map[string]string, len(vars)+1)
data[DataCategory] = cat.Name
if cat.Silent() {
// A silent category carries its variables and nothing else: the
// app is being woken, not told.
maps.Copy(data, vars)
return Rendered{Data: data, Silent: true}, nil
}
lang := c.language(prefer)
text := cat.Text[lang]
out := Rendered{Data: data, Language: lang}
switch cat.Visibility {
case VisibilityPrivate:
// The lock screen says something happened; the app says what.
// The variables move wholesale into the payload rather than
// into the text, which is the entire point of the mode.
out.Title = text.Generic
maps.Copy(data, vars)
default:
out.Title = substitute(text.Title, vars)
out.Body = substitute(text.Body, vars)
}
return out.bounded(), nil
}
// DataCategory is the payload key naming the category, present on every
// notification this service sends.
const DataCategory = "category"
// bounded truncates rendered text that a substitution blew past the cap.
// Every value is capped on the way in, so this is the belt to that
// braces: a category with a dozen variables in one line could still
// exceed what a provider accepts, and a truncated notification is better
// than a refused one.
func (r Rendered) bounded() Rendered {
r.Title = clamp(r.Title, MaxRendered)
r.Body = clamp(r.Body, MaxRendered)
return r
}
// clamp cuts a string to at most n bytes, on a rune boundary so the
// result is still valid UTF-8 on a provider that insists.
func clamp(s string, n int) string {
if len(s) <= n {
return s
}
for n > 0 && !utf8.RuneStart(s[n]) {
n--
}
return s[:n]
}
// language negotiates the recipient's preferences against what this
// catalog is published in: the best match by RFC 4647 lookup, and the
// catalog's first language otherwise.
//
// A shared language beats an exact region, which is what the lookup
// scheme gives: somebody asking for de-CH is served de rather than the
// fallback.
func (c *Catalog) language(prefer []string) string {
if tag, ok := i18n.Match(prefer, c.languages); ok {
return tag
}
return c.languages[0]
}
// substitute replaces every {name} the variables carry.
//
// A placeholder with no value is replaced by the empty string rather
// than left standing: the catalog guarantees the name is declared, so an
// absent value means the publisher had nothing for it, and "Your ticket
// {subject} was answered" on a lock screen is worse than the sentence
// without it.
func substitute(tmpl string, vars Vars) string {
if !strings.ContainsRune(tmpl, '{') {
return tmpl
}
var b strings.Builder
b.Grow(len(tmpl))
for rest := tmpl; ; {
i := strings.IndexByte(rest, '{')
if i < 0 {
b.WriteString(rest)
break
}
j := strings.IndexByte(rest[i:], '}')
if j < 0 {
// An unclosed brace is literal text; the catalog validated
// the templates, so this is a brace somebody meant.
b.WriteString(rest)
break
}
b.WriteString(rest[:i])
b.WriteString(vars[rest[i+1:i+j]])
rest = rest[i+j+1:]
}
return strings.TrimSpace(b.String())
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package config
import (
"errors"
"time"
"github.com/deep-rent/nexus/eco/identity"
"github.com/deep-rent/nexus/net/notify/push/apns"
"github.com/deep-rent/nexus/sys/boot"
"github.com/deep-rent/nexus/sys/env"
)
// Prefix namespaces every environment variable of the service.
const Prefix = "NDS_"
// DefaultCatalog is the catalog file read when the environment names
// none.
const DefaultCatalog = "catalog.json"
// ErrNoSender reports a deployment configured to reach neither provider.
// A notification service that can send to nothing is one that would
// accept every publish and deliver none, so it refuses to start.
var ErrNoSender = errors.New(
"configure at least one of APNS_ or FCM_; a notification service " +
"that reaches no provider would accept every publish and " +
"deliver none",
)
// Config declares the deployment configuration of the notification
// service. Bind it from the environment with [Load].
type Config struct {
// Core is the listen addresses, CORS origins, log level, and
// connection timeouts every service carries.
boot.Core `env:",inline"`
// Catalog is the file declaring the categories this deployment may
// send, what they render to, and how much of that reaches a lock
// screen.
Catalog string `env:",default:'catalog.json'"`
// Database configures the PostgreSQL connection holding the
// registry. Required: a registry without one has nowhere to keep a
// device.
Database boot.Database `env:",prefix:DATABASE_"`
// Auth declares the identity provider whose tokens the API accepts.
Auth boot.Auth `env:",prefix:AUTH_"`
// Directory declares where people are read from: the language and
// the time zone a phone did not report. Optional — without it, a
// notification is written in the language the phone reports and
// deferred only by a zone the phone reports.
Directory identity.Config `env:",prefix:DIRECTORY_"`
// APNs configures the Apple sender. Empty leaves iOS unserved, and
// registrations for it are refused rather than silently kept.
APNs APNs `env:",prefix:APNS_"`
// FCM configures the Firebase sender. Empty leaves Android
// unserved.
FCM FCM `env:",prefix:FCM_"`
// Tokens seals the provider tokens at rest.
Tokens Tokens `env:",prefix:TOKEN_"`
// Retention bounds how long the registry keeps what it no longer
// needs.
Retention Retention `env:",prefix:RETENTION_"`
// TTL is how long a notification stays worth delivering. Zero keeps
// the engine's default.
TTL time.Duration
// Intake configures the webhook receiver through which the identity
// service announces deleted accounts, so nobody who asked to be
// forgotten keeps a registered phone.
Intake boot.Intake `env:",prefix:INTAKE_"`
}
// APNs configures the Apple Push Notification service sender.
type APNs struct {
// KeyID is the ES256 key identifier from the Apple developer
// account.
KeyID string `env:"KEY_ID"`
// TeamID is the Apple team identifier.
TeamID string `env:"TEAM_ID"`
// PrivateKey is the PEM-encoded PKCS#8 key, or the path to a file
// holding one; see [APNs.Key].
PrivateKey string `env:"PRIVATE_KEY"`
// KeyFile reads the key from a mounted file instead, which is how a
// deployment keeps a PEM out of its environment.
KeyFile string `env:"KEY_FILE"`
// Topic is the app's bundle identifier, which APNs requires on
// almost every push.
Topic string
// Sandbox routes to Apple's sandbox endpoint, for builds signed
// with a development profile.
Sandbox bool
// BaseURL overrides the endpoint entirely, which is for a test rig
// pointing at a stub and for nothing else. Sandbox is the setting a
// real deployment wants; this one will happily send every
// notification to whatever it names.
BaseURL string `env:"BASE_URL"`
}
// Endpoint resolves the APNs endpoint this deployment sends to.
func (c APNs) Endpoint() string {
switch {
case c.BaseURL != "":
return c.BaseURL
case c.Sandbox:
return apns.SandboxBaseURL
}
return apns.DefaultBaseURL
}
// Enabled reports whether the Apple sender is configured.
func (c APNs) Enabled() bool {
return c.KeyID != "" && c.TeamID != "" &&
(c.PrivateKey != "" || c.KeyFile != "")
}
// FCM configures the Firebase Cloud Messaging sender.
type FCM struct {
// Credentials is the raw JSON of a Google service account key, or
// the path to a file holding one; see [FCM.Account].
Credentials string
// CredentialsFile reads the service account from a mounted file
// instead, which is how a deployment keeps a key out of its
// environment.
CredentialsFile string `env:"CREDENTIALS_FILE"`
// BaseURL and AuthURL override the Firebase endpoints, which is for
// a test rig pointing at a stub and for nothing else; see
// [APNs.BaseURL].
BaseURL string `env:"BASE_URL"`
AuthURL string `env:"AUTH_URL"`
}
// Enabled reports whether the Firebase sender is configured.
func (c FCM) Enabled() bool {
return c.Credentials != "" || c.CredentialsFile != ""
}
// Tokens configures the sealing of provider tokens at rest.
//
// A deployment that leaves this empty stores tokens as they are, which
// is a test-rig setting: a database backup would otherwise carry the
// means to push to every phone this deployment knows about. The service
// warns at startup.
type Tokens struct {
// Keys seal the provider tokens at rest. A token is the whole
// credential for reaching a phone, so a backup without this
// carries the means to push to everybody.
boot.Keys `env:",inline"`
}
// Retention bounds how long the registry keeps what it no longer needs.
type Retention struct {
// Interval is how often the sweep runs.
Interval time.Duration `env:",default:6h"`
// StaleAge is how long a device may go unrefreshed before it is
// dropped. Apps refresh on every launch, so a phone silent this long
// has been wiped, replaced, or had the app removed without the
// provider noticing. Zero keeps the store's default.
StaleAge time.Duration `env:"STALE_AGE"`
// RetiredAge is how long a retired row is kept before it goes. It
// only has to outlast the delivery retry schedule. Zero keeps the
// store's default.
RetiredAge time.Duration `env:"RETIRED_AGE"`
}
// Serves reports whether at least one provider is configured.
func (c Config) Serves() bool {
return c.APNs.Enabled() || c.FCM.Enabled()
}
// Load binds the configuration from the environment, reporting every
// binding problem at once.
func Load(opts ...env.Option) (Config, error) {
return boot.Load[Config](Prefix, opts...)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package device
import (
"crypto/sha256"
"encoding/base64"
"errors"
"time"
"uuid"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/std/i18n"
)
// Platform names the provider a device is reached through. It is the
// one field that decides which sender carries a delivery, so the
// vocabulary is closed: a deployment cannot invent a third.
type Platform string
const (
// PlatformIOS is reached through the Apple Push Notification
// service.
PlatformIOS Platform = "ios"
// PlatformAndroid is reached through Firebase Cloud Messaging.
PlatformAndroid Platform = "android"
)
// Platforms lists every platform this service delivers to.
var Platforms = []Platform{PlatformIOS, PlatformAndroid}
// Valid reports whether the platform is one this service knows.
func (p Platform) Valid() bool {
return p == PlatformIOS || p == PlatformAndroid
}
// Bounds on what a registration may carry. Each is generous against
// what the providers actually mint and exists to bound a mistake or an
// abuse rather than to express a format.
const (
// MaxTokenLength caps a provider token. An APNs token is 64 hex
// characters; an FCM registration token runs to a few hundred and
// is documented as having no fixed length, so the cap sits well
// above both.
MaxTokenLength = 1024
// MaxLocaleLength caps a BCP 47 language tag.
MaxLocaleLength = 35
// MaxZoneLength caps an IANA time zone name, matching the identity
// service's own bound on the same value.
MaxZoneLength = 64
// MaxBuildLength caps the app build string. It is opaque to this
// service — it exists so an operator can tell which release a
// delivery failure belongs to.
MaxBuildLength = 64
)
// Errors reported for a registration this package refuses.
var (
// ErrNoToken reports a registration carrying no provider token.
ErrNoToken = errors.New("a device registration needs a token")
// ErrPlatform reports a platform outside [Platforms].
ErrPlatform = errors.New("unknown device platform")
// ErrNoOwner reports a registration naming nobody.
ErrNoOwner = errors.New("a device belongs to a person")
)
// Device is one installation of one app on one person's phone.
//
// Everything but Token is safe to hand back to its owner; Token is the
// credential for reaching the phone and is populated only where this
// service is about to send. See the package documentation.
type Device struct {
// ID identifies the device (UUIDv7).
ID uuid.UUID `json:"id"`
// User is whose phone it is. Fan-out resolves by this column.
User uuid.UUID `json:"-"`
// Platform decides which sender carries a delivery.
Platform Platform `json:"platform"`
// Token is the provider token. It is sealed at rest and left empty
// on every read that is not a delivery, so it never reaches a
// client or a log.
Token string `json:"-"`
// Fingerprint is [Digest] of the token, carried on every read
// BECAUSE the token is not. It is what lets a caller ask "is this
// token one I already hold?" without the registry handing back a
// credential to answer with.
//
// It stays out of the JSON: a digest discloses little, but it is a
// stable correlator across accounts, and a client has no use for
// one.
Fingerprint string `json:"-"`
// Locale is the language the phone is set to, as a BCP 47 tag —
// what the catalog negotiates a template against. Empty falls back
// to the person's own preferences, and then to the catalog's
// default.
Locale string `json:"locale,omitzero"`
// Zone is the phone's IANA time zone, which is what quiet hours are
// evaluated in. Empty falls back to the zone its owner stated to the
// identity service, and failing that leaves quiet hours
// unenforceable for this device; see [prefs.Quiet].
//
// [prefs.Quiet]: github.com/deep-rent/nexus/eco/nds/prefs#Quiet
Zone string `json:"zone,omitzero"`
// Build names the app release the registration came from, opaque to
// this service and carried so an operator can tell which release a
// wave of failures belongs to.
Build string `json:"build,omitzero"`
// Name is what the person calls this phone, shown in their own
// device list so they can tell two of them apart. It is supplied by
// the app, never by the provider.
Name string `json:"name,omitzero"`
// RetiredAt is when the provider told this service the token was
// dead. A retired device is never delivered to and never resolved;
// it is kept so a re-registration of the same token can revive the
// row rather than race the retention sweep. Zero while live.
RetiredAt time.Time `json:"retired_at,omitzero"`
// CreatedAt is when the device first registered.
CreatedAt time.Time `json:"created_at"`
// SeenAt is when the registration was last refreshed. Apps refresh
// on every launch, so it is both the liveness signal the retention
// sweep reads and the "last used" a person sees in their device
// list.
SeenAt time.Time `json:"seen_at"`
}
// Live reports whether the device is still worth delivering to.
func (d Device) Live() bool { return d.RetiredAt.IsZero() }
// Registration is what an app asks for when it registers or refreshes.
// It names no user: the caller's own access token does that, and a
// body-supplied owner would let anybody point anybody else's
// notifications at a phone they hold.
type Registration struct {
// Platform decides which sender carries a delivery. Required.
Platform Platform `json:"platform"`
// Token is the provider token. Required.
Token string `json:"token"`
// Locale is the language the phone is set to, as a BCP 47 tag.
Locale string `json:"locale,omitzero"`
// Zone is the phone's IANA time zone name.
Zone string `json:"zone,omitzero"`
// Build names the app release.
Build string `json:"build,omitzero"`
// Name is what the person calls this phone.
Name string `json:"name,omitzero"`
}
// Validate implements the [valid.Validatable] interface.
//
// The locale and zone are validated for SHAPE alone: this service
// neither embeds a subtag registry nor a zone database, and a tag it
// cannot match simply falls back rather than failing a registration. A
// phone whose settings this deployment does not speak still gets its
// notifications, in the catalog's default language.
func (r *Registration) Validate(v *valid.Validator) {
v.Whitelist("platform", r.Platform, Platforms...)
v.NotBlank("token", r.Token)
v.MaxLen("token", r.Token, MaxTokenLength)
v.MaxLen("locale", r.Locale, MaxLocaleLength)
if r.Locale != "" {
v.Lang("locale", r.Locale)
}
v.MaxLen("zone", r.Zone, MaxZoneLength)
if r.Zone != "" {
v.Timezone("zone", r.Zone)
}
v.MaxLen("build", r.Build, MaxBuildLength)
v.MaxLen("name", r.Name, MaxBuildLength)
}
// Check reports whether the registration is well-formed, for the paths
// that are not behind a [valid.Validator] — a queue handler, a test, a
// caller assembling one by hand.
func (r *Registration) Check() error {
switch {
case r.Token == "":
return ErrNoToken
case !r.Platform.Valid():
return ErrPlatform
}
return nil
}
// Digest is the lookup key for a provider token: the URL-safe base64 of
// its SHA-256, stored beside the sealed token and unique across the
// registry.
//
// It exists because a sealed value cannot be searched, and two
// operations have only the token to go on — a re-registration, which
// must find the row to move rather than write a second one, and a
// retirement, which the provider reports against the token it refused.
//
// A plain digest is the right construction here rather than a slow hash:
// the input is 256-plus bits of provider-minted randomness, so there is
// no dictionary to run against it, and this runs on every app launch.
func Digest(token string) string {
sum := sha256.Sum256([]byte(token))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
// Language picks the language a notification to this device should be
// written in: the phone's own setting first, then the person's stated
// preferences, negotiated against what the catalog is published in by
// RFC 4647 lookup.
//
// The phone wins because it is the more specific answer — somebody who
// set their phone to French is reading French on it — and because it is
// the only signal a device has before the directory has ever been read.
// The boolean reports whether anything matched at all; a caller with no
// match falls back to the catalog's default.
func (d Device) Language(prefer, supported []string) (
string, bool,
) {
if d.Locale != "" {
if tag, ok := i18n.Match([]string{d.Locale}, supported); ok {
return tag, true
}
}
return i18n.Match(prefer, supported)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package dispatch
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json/v2"
"errors"
"fmt"
"net/http"
"time"
"uuid"
"github.com/jackc/pgx/v5"
"github.com/deep-rent/nexus/eco/identity"
"github.com/deep-rent/nexus/eco/nds/catalog"
"github.com/deep-rent/nexus/eco/nds/device"
"github.com/deep-rent/nexus/eco/nds/store"
"github.com/deep-rent/nexus/net/notify/hook"
"github.com/deep-rent/nexus/net/notify/push"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/metrics"
"github.com/deep-rent/nexus/sys/queue"
)
// Deliver is the queue handler carrying one notification to one device.
//
// Everything beyond the job payload is read at attempt time — the
// device, its language, the catalog entry — which is what makes an
// attempt hours after the push still describe the world as it is. It is
// idempotent in the way the queue demands: sending twice puts the
// notification on the phone twice, which is the honest cost of
// at-least-once delivery and what the collapse key is for.
func (e *Engine) Deliver(ctx context.Context, job queue.Job) error {
var d Delivery
if err := json.Unmarshal(job.Payload, &d); err != nil {
// Nothing about a retry will make this parse.
return queue.Abort(fmt.Errorf(
"failed to parse a delivery: %w", err,
))
}
cat, ok := e.cat.Find(d.Category)
if !ok {
// The catalog dropped the category while the job waited. There
// is nothing to render and nothing a retry would fix.
e.logger.Info(ctx, "Dropped a notification for a retired category",
log.String("category", d.Category),
log.UUID("device", d.Device),
)
return nil
}
var target device.Device
err := e.db.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
var err error
target, err = e.db.Deliverable(ctx, tx, d.Device, d.User)
return err
})
switch {
case errors.Is(err, store.ErrNotFound):
// The device was retired, removed, or handed to somebody else
// while the job waited. All three are settled outcomes rather
// than failures: there is nobody left to tell, and a retry would
// only find the same answer.
return nil
case err != nil:
return err
}
sender, ok := e.senders[target.Platform]
if !ok {
// A platform this deployment stopped serving. The job cannot be
// run by anybody, so it is settled rather than left to
// dead-letter.
e.logger.Warn(ctx, "No sender for a registered platform",
log.String("platform", string(target.Platform)),
log.UUID("device", d.Device),
)
return nil
}
msg, err := e.compose(cat, d, target)
if err != nil {
// The variables no longer fit the category, which a retry
// cannot mend either.
return queue.Abort(err)
}
if err := sender.Send(ctx, msg); err != nil {
return e.classify(ctx, target, err)
}
e.metrics.Counter(MetricDelivered,
metrics.T("platform", string(target.Platform)),
).Inc()
e.logger.Debug(ctx, "Delivered a notification",
log.String("category", d.Category),
log.String("platform", string(target.Platform)),
log.UUID("device", target.ID),
)
return nil
}
// compose renders the notification and wraps it in a provider message.
func (e *Engine) compose(
cat catalog.Category,
d Delivery,
target device.Device,
) (*push.Message, error) {
// The phone's own language wins over the one its owner stated, and
// both are negotiated against what the catalog is published in.
prefer := d.Locales
if lang, ok := target.Language(prefer, e.cat.Languages()); ok {
prefer = []string{lang}
}
out, err := e.cat.Render(cat, d.Vars, prefer)
if err != nil {
return nil, err
}
msg := push.NewMessage(out.Title, out.Body, push.Target{
Token: target.Token,
}).WithTTL(e.ttl)
if len(out.Data) > 0 {
msg = msg.WithData(out.Data)
}
if out.Silent {
msg = msg.AsSilent()
}
// Urgency is the category's judgment, and it is the same judgment
// that pierces quiet hours: something worth waking somebody for is
// worth waking their phone's radio for.
if cat.Urgent {
msg = msg.WithPriority(push.PriorityHigh)
} else {
msg = msg.WithPriority(push.PriorityNormal)
}
if key := collapse(cat, d); key != "" {
msg = msg.WithCollapseID(key)
}
return msg, nil
}
// collapse builds the identifier that lets a newer notification replace
// an older one on the phone. The category's key and the publisher's are
// joined, so two subjects within one category collapse independently.
func collapse(cat catalog.Category, d Delivery) string {
var joined string
switch {
case cat.Collapse == "" && d.Collapse == "":
return ""
case cat.Collapse == "":
joined = d.Collapse
case d.Collapse == "":
joined = cat.Collapse
default:
joined = cat.Collapse + ":" + d.Collapse
}
return BoundCollapse(joined)
}
// MaxCollapseLength is the longest collapse identifier APNs accepts.
// A longer one is refused with BadCollapseId, which is not a verdict
// about the token, so the delivery would retry to exhaustion and
// dead-letter — losing every notification of that category on iOS
// while Android, whose limit is looser, carried on as if nothing were
// wrong.
const MaxCollapseLength = 64
// BoundCollapse keeps a collapse identifier inside what APNs accepts,
// digesting one that does not fit.
//
// A publisher's key may be 128 characters and the category's own key
// is joined in front of it, so the composed value can exceed the limit
// without either half looking unreasonable. Digesting rather than
// truncating matters: two keys that share a 64-character prefix would
// truncate alike, and collapsing is destructive — the second
// notification REPLACES the first on the phone.
func BoundCollapse(key string) string {
if len(key) <= MaxCollapseLength {
return key
}
sum := sha256.Sum256([]byte(key))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
// classify decides what a provider's refusal means.
//
// A dead token retires the device and SETTLES the job: no number of
// retries will deliver to a token whose app is gone, and spending the
// budget on it only delays the dead-letter pile filling with work that
// was never going to succeed. Everything else costs an attempt and comes
// back on the queue's backoff — a provider having a bad afternoon is not
// a reason to forget somebody's phone.
func (e *Engine) classify(
ctx context.Context,
target device.Device,
cause error,
) error {
if !push.Gone(cause) {
e.metrics.Counter(MetricFailed,
metrics.T("platform", string(target.Platform)),
).Inc()
return fmt.Errorf("failed to deliver a notification: %w", cause)
}
if err := e.Retire(ctx, target.ID); err != nil {
// The provider's verdict is worth another attempt if we could
// not record it: a device left live keeps being sent to.
return fmt.Errorf("failed to retire a dead device: %w", err)
}
e.metrics.Counter(MetricRetired,
metrics.T("platform", string(target.Platform)),
).Inc()
e.logger.Info(ctx, "Retired a device the provider no longer knows",
log.String("platform", string(target.Platform)),
log.UUID("device", target.ID),
)
return nil
}
// Retire marks a device dead. It is exported because a provider's
// feedback is not the only way one dies — an operator with a support
// ticket in front of them is another.
func (e *Engine) Retire(ctx context.Context, id uuid.UUID) error {
now := e.now()
return e.db.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
return e.db.Retire(ctx, tx, id, now)
})
}
// Forget drops everything this service holds about a person: their
// devices, their preferences, and their quiet hours.
//
// It is what the identity service's deletion webhook triggers, and it is
// idempotent, so a redelivery costs three no-op statements.
func (e *Engine) Forget(ctx context.Context, user uuid.UUID) error {
err := e.db.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
return e.db.ForgetUser(ctx, tx, user)
})
if err != nil {
return err
}
if e.people != nil {
e.people.Forget(user)
}
e.logger.Info(ctx, "Forgot a deleted account", log.UUID("user", user))
return nil
}
// Accept handles the identity service's deletion webhook, for mounting
// on a [hook.Receiver].
//
// A failure answers 5xx so the sender retries, which is the right trade:
// the alternative is somebody who asked to be forgotten still holding a
// registered phone.
//
// [hook.Receiver]: github.com/deep-rent/nexus/net/notify/hook#Receiver
func (e *Engine) Accept(el *router.Exchange, d hook.Delivery) error {
ev, err := identity.Decode(d)
if err != nil {
return err
}
id, ok := ev.User()
if !ok {
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: "delivery names no user",
}
}
if err := e.Forget(el.Context(), id); err != nil {
return fmt.Errorf("failed to forget a deleted user: %w", err)
}
return nil
}
// Sweep collects what the registry no longer needs and republishes the
// device gauges. It is the scheduled upkeep of this service.
func (e *Engine) Sweep(
ctx context.Context,
sweeper Sweeper,
staleAge, retiredAge time.Duration,
) {
swept, err := sweeper.Sweep(ctx, e.now(), staleAge, retiredAge)
if err != nil {
e.logger.Error(ctx, "Retention sweep failed", log.Error(err))
return
}
if swept.Total() > 0 {
e.logger.Info(ctx, "Swept the notification registry",
log.Int64("stale", swept.Stale),
log.Int64("retired", swept.Retired),
log.Int64("published", swept.Published),
)
}
e.Gauges(ctx, sweeper)
}
// Gauges republishes the device gauges from a fresh count. The sweep
// calls it, and so does startup, so an operator watching a replica
// that has only just come up does not wait for the first sweep.
func (e *Engine) Gauges(ctx context.Context, counter Counter) {
counts, err := counter.Count(ctx)
if err != nil {
e.logger.Warn(ctx, "Could not count devices", log.Error(err))
return
}
// Every platform is reported, including the ones at zero: a gauge
// that disappears when a platform empties reads as "no data" rather
// than as "nobody left".
for _, p := range device.Platforms {
e.metrics.Gauge(MetricDevices,
metrics.T("platform", string(p)),
).Set(float64(counts[string(p)]))
}
}
// Counter is the half of the store that answers how many live devices
// the registry holds, per platform.
type Counter interface {
Count(ctx context.Context) (map[string]int64, error)
}
// Sweeper is the retention half of the store, satisfied by
// [store.Store]. It is separate from [Store] because the sweep runs on
// its own schedule and needs no transaction of the caller's.
//
// [store.Store]: github.com/deep-rent/nexus/eco/nds/store#Store
type Sweeper interface {
Sweep(
ctx context.Context,
now time.Time,
staleAge, retiredAge time.Duration,
) (store.Swept, error)
Count(ctx context.Context) (map[string]int64, error)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package dispatch
import (
"context"
"errors"
"time"
"uuid"
"github.com/jackc/pgx/v5"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/eco/identity"
"github.com/deep-rent/nexus/eco/nds/catalog"
"github.com/deep-rent/nexus/eco/nds/device"
"github.com/deep-rent/nexus/eco/nds/prefs"
"github.com/deep-rent/nexus/net/notify/push"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/metrics"
"github.com/deep-rent/nexus/sys/queue"
)
// KindDeliver is the queue kind carrying one notification to one device.
const KindDeliver = "nds.deliver"
// Bounds on one publish call.
const (
// MaxRecipients is how many people one publish may name. It is a
// fan-out bound rather than a broadcast one: a deployment wanting to
// tell everybody models that as a category, resolved per person.
MaxRecipients = 100
// MaxKeyLength caps a publisher's idempotency key.
MaxKeyLength = 128
)
// DefaultTTL is how long a notification stays worth delivering. Both
// providers hold an undeliverable message for a while and drop it after
// this; a notification about a moment is not worth waking a phone up for
// a day later.
const DefaultTTL = 4 * time.Hour
// Errors reported for a publish this package refuses.
var (
// ErrNoRecipients reports a publish naming nobody.
ErrNoRecipients = errors.New("a notification needs a recipient")
// ErrUnknownCategory reports a category the catalog does not
// declare.
ErrUnknownCategory = catalog.ErrUnknownCategory
)
// Metrics this engine publishes.
const (
// MetricPublished counts notifications accepted, tagged by category.
MetricPublished = "nds_published_total"
// MetricFanout counts jobs materialized, tagged by category. The
// ratio against MetricPublished is how many phones one notification
// actually reaches.
MetricFanout = "nds_fanout_total"
// MetricSuppressed counts recipients a notification did not reach,
// tagged by the reason: "muted", "no_device".
MetricSuppressed = "nds_suppressed_total"
// MetricDeferred counts deliveries held back by quiet hours.
MetricDeferred = "nds_deferred_total"
// MetricDuplicate counts publishes refused as repeats of a key
// already handled, tagged by category. A steady rate here is a
// publisher retrying, which is what the key is for; a sudden one is
// a publisher whose key stopped naming what happened.
MetricDuplicate = "nds_duplicate_total"
// MetricDelivered counts provider acceptances, tagged by platform.
MetricDelivered = "nds_delivered_total"
// MetricRetired counts devices a provider refused for good, tagged
// by platform. A step change here is a release that broke
// registration.
MetricRetired = "nds_retired_total"
// MetricFailed counts deliveries a provider refused for a reason
// worth retrying, tagged by platform.
//
// It is the counterpart of MetricDelivered, and it is tagged by
// platform for the question the queue's own counters cannot answer:
// both providers share one job kind, so a growing backlog says work
// is piling up without saying whether Apple or Google is the one
// having a bad afternoon.
MetricFailed = "nds_failed_total"
// MetricDevices gauges live devices, tagged by platform.
MetricDevices = "nds_devices"
)
// Reasons a recipient was not reached, as they are tagged onto
// [MetricSuppressed].
const (
reasonMuted = "muted"
reasonNoDevice = "no_device"
)
// Store is the persistence this engine reads and writes, satisfied by
// [store.Store].
//
// [store.Store]: github.com/deep-rent/nexus/eco/nds/store#Store
type Store interface {
Exec(
ctx context.Context,
fn func(ctx context.Context, tx pgx.Tx) error,
) error
Resolve(ctx context.Context, tx pgx.Tx, user uuid.UUID) (
[]device.Device, error)
SettingsAll(ctx context.Context, tx pgx.Tx, users []uuid.UUID) (
map[uuid.UUID]prefs.Settings, error)
Deliverable(ctx context.Context, tx pgx.Tx, id, user uuid.UUID) (
device.Device, error)
Retire(ctx context.Context, tx pgx.Tx, id uuid.UUID, at time.Time) error
ForgetUser(ctx context.Context, tx pgx.Tx, user uuid.UUID) error
Claim(
ctx context.Context,
tx pgx.Tx,
category, key, audience string,
receipt []byte,
now time.Time,
) (raw []byte, first bool, err error)
Settle(
ctx context.Context,
tx pgx.Tx,
category, key, audience string,
receipt []byte,
) error
}
// Notifier is the transactional outbox jobs are pushed onto, satisfied
// by [queue.Queue].
type Notifier interface {
Push(ctx context.Context, tx pgx.Tx, r queue.Request) (
queue.Job, bool, error)
}
// Directory reads a recipient's stated languages, satisfied by
// [identity.Directory].
//
// It is optional, and deliberately so: a phone reports its own locale at
// registration, which is the better answer and the only one available
// before the identity service has ever been reached. The directory
// covers the gap — a phone set to a language this catalog is not
// published in, whose owner did state one it is.
//
// [identity.Directory]: github.com/deep-rent/nexus/eco/identity#Directory
type Directory interface {
ResolveAll(ctx context.Context, ids []uuid.UUID) (
map[uuid.UUID]identity.Person, error)
Forget(id uuid.UUID)
}
// Senders are the providers this engine delivers through, one per
// platform. A platform with no sender is one this deployment does not
// serve, and its devices are never materialized.
type Senders map[device.Platform]push.Sender
// Option configures an [Engine].
type Option func(*Engine)
// WithLogger injects a structured logger. Defaults to [log.Discard].
func WithLogger(l *log.Logger) Option {
return func(e *Engine) {
if l != nil {
e.logger = l
}
}
}
// WithRegistry sets where the instruments are published. Defaults to
// [metrics.DefaultRegistry].
func WithRegistry(r *metrics.Registry) Option {
return func(e *Engine) {
if r != nil {
e.metrics = r
}
}
}
// WithClock injects the time source, which is primarily useful for
// testing. A nil clock is ignored.
func WithClock(now clock.Clock) Option {
return func(e *Engine) {
if now != nil {
e.now = now
}
}
}
// WithDirectory reads recipients' stated languages from the identity
// service, for the phones whose own locale this catalog is not published
// in. A nil directory is ignored.
func WithDirectory(d Directory) Option {
return func(e *Engine) {
if d != nil {
e.people = d
}
}
}
// WithTTL overrides how long a notification stays worth delivering.
// Values of zero or less are ignored.
func WithTTL(ttl time.Duration) Option {
return func(e *Engine) {
if ttl > 0 {
e.ttl = ttl
}
}
}
// Engine is the assembled dispatch machinery. Create instances with
// [New]; it is safe for concurrent use.
type Engine struct {
db Store
jobs Notifier
cat *catalog.Catalog
senders Senders
people Directory
ttl time.Duration
logger *log.Logger
metrics *metrics.Registry
now clock.Clock
}
// New assembles an [Engine]. It panics if a required collaborator is
// missing, since startup misconfiguration is a programmer error.
func New(
db Store,
jobs Notifier,
cat *catalog.Catalog,
senders Senders,
opts ...Option,
) *Engine {
switch {
case db == nil:
panic("store is required")
case jobs == nil:
panic("notifier is required")
case cat == nil:
panic("catalog is required")
case len(senders) == 0:
panic("at least one sender is required")
}
e := &Engine{
db: db,
jobs: jobs,
cat: cat,
senders: senders,
ttl: DefaultTTL,
logger: log.Discard(),
metrics: metrics.DefaultRegistry,
now: clock.System,
}
for _, opt := range opts {
opt(e)
}
return e
}
// Catalog is the vocabulary this engine renders from, for the surfaces
// that publish it to a client.
func (e *Engine) Catalog() *catalog.Catalog { return e.cat }
// Serves reports whether this deployment has a sender for the platform.
func (e *Engine) Serves(p device.Platform) bool {
_, ok := e.senders[p]
return ok
}
// Request is what a sibling service asks for.
type Request struct {
// Category names what this is, as the catalog declares it.
// Required.
Category string `json:"category"`
// Recipients are who to tell. Required, and bounded by
// [MaxRecipients]: this is a fan-out, not a broadcast.
Recipients []uuid.UUID `json:"recipients"`
// Vars are the values the category's templates substitute. A
// variable the category does not declare is refused rather than
// ignored.
Vars catalog.Vars `json:"vars,omitzero"`
// Key is the publisher's idempotency key. A publisher calling from
// its own queue job is calling at-least-once, and this is what makes
// the second call harmless; see the package documentation.
//
// It should name what happened rather than when: "ticket:42:closed",
// not a timestamp. Empty means no protection at all, and the service
// says so at startup rather than pretending.
//
// The key is scoped to the category and to the recipients the call
// names, so two categories may use the same one without colliding
// and a publisher calling once per recipient is not collapsed into
// one notification. It is remembered for
// [store.DefaultPublicationAge].
//
// [store.DefaultPublicationAge]:
// github.com/deep-rent/nexus/eco/nds/store#DefaultPublicationAge
Key string `json:"key,omitzero"`
// Collapse groups notifications that supersede one another on the
// phone, appended to the category's own collapse key. Empty leaves
// the category's grouping alone.
Collapse string `json:"collapse,omitzero"`
}
// Validate implements the [valid.Validatable] interface.
func (r *Request) Validate(v *valid.Validator) {
v.NotBlank("category", r.Category)
v.MaxLen("category", r.Category, catalog.MaxNameLength)
v.MinSize("recipients", len(r.Recipients), 1)
v.MaxSize("recipients", len(r.Recipients), MaxRecipients)
v.MaxLen("key", r.Key, MaxKeyLength)
v.MaxLen("collapse", r.Collapse, MaxKeyLength)
v.MaxSize("vars", len(r.Vars), catalog.MaxVariables)
}
// Receipt is what a publisher is told: how far the notification got, and
// why it did not get further.
//
// It is deliberately not a delivery report. The jobs it counts have been
// written, not run — a phone that is off receives nothing for hours, and
// no answer at publish time could say otherwise.
type Receipt struct {
// Queued is how many delivery jobs were materialized.
Queued int `json:"queued"`
// Muted is how many recipients had switched this category off.
Muted int `json:"muted"`
// Unreachable is how many recipients have no live device at all.
Unreachable int `json:"unreachable"`
// Deferred is how many jobs were held back by quiet hours.
Deferred int `json:"deferred"`
// Duplicate reports that this key was published before and nothing
// new was enqueued. The rest of the receipt is what the FIRST call
// answered, so both calls agree about what happened rather than the
// second reporting an empty fan-out.
Duplicate bool `json:"duplicate,omitzero"`
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package dispatch
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json/v2"
"fmt"
"slices"
"time"
"uuid"
"github.com/jackc/pgx/v5"
"github.com/deep-rent/nexus/eco/nds/catalog"
"github.com/deep-rent/nexus/eco/nds/device"
"github.com/deep-rent/nexus/eco/nds/prefs"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/metrics"
"github.com/deep-rent/nexus/sys/queue"
)
// Delivery is the queue payload naming one notification to one device.
//
// It is thin on purpose, and it is thin in the one way that matters
// here: it carries no token. The handler reads the device when it runs,
// which keeps a credential for reaching somebody's phone out of the job
// table, out of the dead-letter listing an operator pages through, and
// out of every log sink downstream.
type Delivery struct {
// Device is who to send to.
Device uuid.UUID `json:"device"`
// User is who the notification was published FOR, carried so the
// delivery can refuse a device that changed hands while the job
// waited.
//
// A phone handed to somebody else re-registers under its new owner,
// and the registry deliberately MOVES the row rather than
// duplicating it — same identifier, same sealed token, new person.
// Without this the queued job would still find that row and put the
// previous owner's notification on the new owner's lock screen. The
// wait is not brief: quiet hours defer to the end of the window.
User uuid.UUID `json:"user"`
// Category is what to send, as the catalog declares it.
Category string `json:"category"`
// Vars are the values the templates substitute.
Vars catalog.Vars `json:"vars,omitzero"`
// Collapse groups notifications that supersede one another.
Collapse string `json:"collapse,omitzero"`
// Locales are the recipient's stated languages, resolved at publish
// time so the handler needs no directory of its own. The device's
// own locale takes precedence over these; see [device.Device.Language].
Locales []string `json:"locales,omitzero"`
}
// Publish resolves who a notification reaches and writes one durable job
// per device.
//
// It runs in a transaction of its own. A publisher that wants the
// notification to share ITS transaction has a better tool: its own queue,
// pushed to inside its own write, calling this from the resulting job.
// That is the answer to the outbox gap, and it is what the sibling
// services already do for notification mail.
func (e *Engine) Publish(
ctx context.Context,
req Request,
) (Receipt, error) {
cat, ok := e.cat.Find(req.Category)
if !ok {
return Receipt{}, fmt.Errorf(
"%w: %q", ErrUnknownCategory, req.Category,
)
}
if len(req.Recipients) == 0 {
return Receipt{}, ErrNoRecipients
}
// The variables are checked before anything is resolved, so a
// caller's mistake costs no queries.
if err := cat.Check(req.Vars); err != nil {
return Receipt{}, err
}
// What the recipients said about themselves is read outside the
// transaction: it is a call to another service, and holding a
// database transaction open across one is how a slow dependency
// becomes a connection-pool outage. A directory that fails is not
// fatal — each phone reports its own language and zone, and this
// only covers the gaps.
stated := e.stated(ctx, req.Recipients)
var out Receipt
err := e.db.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
out = Receipt{}
// The key is claimed inside the same transaction the jobs are
// written in, so a concurrent duplicate loses the insert and
// enqueues nothing rather than racing past it.
if req.Key != "" {
held, first, err := e.claim(ctx, tx, req)
if err != nil {
return err
}
if !first {
out = held
out.Duplicate = true
return nil
}
}
settings, err := e.db.SettingsAll(ctx, tx, req.Recipients)
if err != nil {
return fmt.Errorf("failed to read preferences: %w", err)
}
now := e.now()
// Deduplicated once, so the fan-out matches the audience digest —
// which compacts its own copy. A publisher assembling recipients
// from a join can name the same person twice (reporter AND
// watcher), and without this that person's phone buzzes twice for
// one event while the receipt counts them twice over.
for _, user := range slices.Compact(slices.SortedFunc(
slices.Values(req.Recipients),
func(a, b uuid.UUID) int { return a.Compare(b) },
)) {
r, err := e.fanout(ctx, tx, fanout{
user: user,
cat: cat,
req: req,
settings: settings[user],
stated: stated[user],
now: now,
})
if err != nil {
return err
}
out.Queued += r.Queued
out.Muted += r.Muted
out.Unreachable += r.Unreachable
out.Deferred += r.Deferred
}
// The claim was staked before the fan-out was known, so the
// receipt it holds is written back now — that is what a
// duplicate will be answered with.
if req.Key != "" {
return e.settle(ctx, tx, req, out)
}
return nil
})
if err != nil {
return Receipt{}, err
}
if out.Duplicate {
e.metrics.Counter(MetricDuplicate,
metrics.T("category", req.Category),
).Inc()
e.logger.Debug(ctx, "Ignored a repeated publication",
log.String("category", req.Category),
log.String("key", req.Key),
)
return out, nil
}
e.count(MetricPublished, req.Category)
if out.Queued > 0 {
e.metrics.Counter(MetricFanout,
metrics.T("category", req.Category),
).Add(uint64(out.Queued))
}
e.suppressed(reasonMuted, out.Muted)
e.suppressed(reasonNoDevice, out.Unreachable)
if out.Deferred > 0 {
e.metrics.Counter(MetricDeferred).Add(uint64(out.Deferred))
}
e.logger.Debug(ctx, "Published a notification",
log.String("category", req.Category),
log.Int("recipients", len(req.Recipients)),
log.Int("queued", out.Queued),
log.Int("muted", out.Muted),
log.Int("unreachable", out.Unreachable),
log.Int("deferred", out.Deferred),
)
return out, nil
}
// claim stakes the publication key, reporting whether this call is the
// first to hold it. A later call is handed the receipt the first one
// answered; see [store.Store.Claim].
//
// [store.Store.Claim]: github.com/deep-rent/nexus/eco/nds/store#Store.Claim
func (e *Engine) claim(
ctx context.Context,
tx pgx.Tx,
req Request,
) (Receipt, bool, error) {
// An empty receipt is staked first and written back once the fan-out
// is known, so a duplicate arriving in between is told nothing
// rather than something wrong.
blank, err := json.Marshal(Receipt{})
if err != nil {
return Receipt{}, false, err
}
raw, first, err := e.db.Claim(
ctx, tx, req.Category, req.Key, audience(req.Recipients),
blank, e.now(),
)
if err != nil {
return Receipt{}, false, fmt.Errorf(
"failed to claim a publication key: %w", err,
)
}
if first {
return Receipt{}, true, nil
}
var held Receipt
if err := json.Unmarshal(raw, &held); err != nil {
// A receipt this service wrote itself and cannot read back is a
// defect, not a reason to notify everybody a second time.
e.logger.Warn(ctx, "Could not read a held receipt",
log.String("category", req.Category),
log.Error(err),
)
}
return held, false, nil
}
// settle writes back what the fan-out turned out to be, so a later
// duplicate is answered with it.
func (e *Engine) settle(
ctx context.Context,
tx pgx.Tx,
req Request,
out Receipt,
) error {
raw, err := json.Marshal(out)
if err != nil {
return err
}
if err := e.db.Settle(
ctx, tx, req.Category, req.Key, audience(req.Recipients), raw,
); err != nil {
return fmt.Errorf("failed to record a publication: %w", err)
}
return nil
}
// deliveryKey is the queue's idempotency key for one device's copy of
// one publication: the category, the publisher's key, and the device,
// which together are what must not be enqueued twice.
//
// The three are digested rather than joined, because the queue bounds
// a key at [queue.MaxKeyLength] and the joined form does not fit. A
// category may run to 64 characters and a publisher's key to 128, so
// the prefix, the separators and the device UUID push the total past
// 240 — and the queue answers an over-long key with a plain error, so
// the publish transaction rolls back, the publisher retries into the
// same deterministic failure, and NOBODY is notified. It bit exactly
// the publishers doing the right thing, since a key is what asks for
// protection in the first place.
//
// The digest preserves the only property the key needs, which is that
// equal inputs produce equal keys. What the job is about stays legible
// in its payload.
func deliveryKey(category, key string, device uuid.UUID) string {
sum := sha256.New()
// Length-prefixed, so that two different splits of the same bytes
// cannot digest alike.
for _, part := range []string{category, key} {
_, _ = fmt.Fprintf(sum, "%d:%s", len(part), part)
}
_, _ = sum.Write(device[:])
return KindDeliver + ":" + base64.RawURLEncoding.EncodeToString(
sum.Sum(nil),
)
}
// audience digests the recipients a publish named, so a claim
// identifies the call rather than merely its key.
//
// It is order-insensitive and deduplicated: the same people in a
// different order are the same audience, which is what a publisher
// assembling a list from a query would otherwise trip over. The result
// is a digest rather than the list itself so the column stays bounded
// whatever a hundred identifiers weigh.
func audience(ids []uuid.UUID) string {
sorted := slices.Compact(slices.SortedFunc(
slices.Values(ids),
func(a, b uuid.UUID) int { return a.Compare(b) },
))
sum := sha256.New()
for _, id := range sorted {
_, _ = sum.Write(id[:])
}
return base64.RawURLEncoding.EncodeToString(sum.Sum(nil))
}
// fanout is one recipient's share of a publish.
type fanout struct {
user uuid.UUID
cat catalog.Category
req Request
settings prefs.Settings
stated Stated
now time.Time
}
// fanout resolves one recipient's devices and materializes their jobs.
func (e *Engine) fanout(
ctx context.Context,
tx pgx.Tx,
f fanout,
) (Receipt, error) {
var out Receipt
// The preference is checked before the devices are read: somebody
// who muted the category costs no query at all.
if !f.settings.Allows(f.cat.Name, f.cat.Choice()) {
out.Muted++
return out, nil
}
devices, err := e.db.Resolve(ctx, tx, f.user)
if err != nil {
return Receipt{}, fmt.Errorf("failed to resolve devices: %w", err)
}
if len(devices) == 0 {
out.Unreachable++
return out, nil
}
served := 0
for _, d := range devices {
// A platform this deployment has no sender for is one whose
// devices could never be delivered to; materializing a job for
// one would fill the dead-letter pile with work nobody can run.
if !e.Serves(d.Platform) {
continue
}
served++
// Quiet hours are evaluated per device, because the zone is
// ordinarily the device's: one instant is somebody's night on
// the phone beside their bed and their afternoon on the tablet
// they left at home. A phone that reported none falls back to
// the zone its owner stated, which is better than no night at
// all.
runAt := f.now
if !f.cat.Urgent {
runAt = f.settings.Quiet.Until(f.now, f.zone(d))
}
if runAt.After(f.now) {
out.Deferred++
}
queued, err := e.enqueue(ctx, tx, d, f, runAt)
if err != nil {
return Receipt{}, err
}
if queued {
out.Queued++
}
}
if served == 0 {
// Every device they hold is on a platform this deployment does
// not serve, which reaches them exactly as having none does.
out.Unreachable++
}
return out, nil
}
// enqueue writes one delivery job, reporting whether it was new.
//
// The outbox key is what makes a publisher's retry harmless. It names
// the category, the publisher's key, and the device, so two publishes of
// the same event collapse while two genuinely different notifications to
// one phone do not. A publisher supplying no key gets a unique one and
// therefore no protection, which is the honest outcome rather than a
// silent guess at what they meant.
func (e *Engine) enqueue(
ctx context.Context,
tx pgx.Tx,
d device.Device,
f fanout,
runAt time.Time,
) (bool, error) {
payload, err := json.Marshal(Delivery{
Device: d.ID,
User: f.user,
Category: f.cat.Name,
Vars: f.req.Vars,
Collapse: f.req.Collapse,
Locales: f.stated.Locales,
})
if err != nil {
return false, fmt.Errorf("failed to encode a delivery: %w", err)
}
key := ""
if f.req.Key != "" {
key = deliveryKey(f.cat.Name, f.req.Key, d.ID)
}
_, fresh, err := e.jobs.Push(ctx, tx, queue.Request{
Kind: KindDeliver,
Payload: payload,
Key: key,
RunAt: runAt,
})
if err != nil {
return false, fmt.Errorf("failed to enqueue a delivery: %w", err)
}
return fresh, nil
}
// zone resolves the time zone one device's quiet hours are read in.
//
// The phone wins, because it is where the notification actually arrives
// and it is the only answer available before the directory has ever been
// reached. What its owner stated covers the phone that reported none —
// a device with no zone would otherwise have no night at all.
//
// An empty result mutes nothing: guessing a zone would silence somebody
// at hours that are not theirs, which is worse than not silencing them.
func (f fanout) zone(d device.Device) string {
if d.Zone != "" {
return d.Zone
}
return f.stated.Zone
}
// Stated is what the identity service says a recipient prefers: the
// languages to write in, and the zone their night is measured in.
//
// Both are fallbacks. A phone reports its own locale and zone at
// registration, and those are the better answers; this covers the phone
// that reported neither, and the phone whose language this catalog is
// not published in.
type Stated struct {
// Locales are the preferred languages, most preferred first.
Locales []string
// Zone is the preferred IANA time zone.
Zone string
}
// stated reads what the recipients said about themselves, tolerating a
// directory that is absent or unwell.
//
// A failure here costs a fallback rather than a notification: each phone
// reports its own language and zone, and the catalog has a default
// language. Losing the notification instead would be a strictly worse
// trade, so the error is logged and the fan-out goes on.
func (e *Engine) stated(
ctx context.Context,
ids []uuid.UUID,
) map[uuid.UUID]Stated {
if e.people == nil {
return nil
}
people, err := e.people.ResolveAll(ctx, ids)
if err != nil {
e.logger.Warn(ctx,
"Could not read stated preferences; falling back to what "+
"each phone reports",
log.Error(err),
)
return nil
}
out := make(map[uuid.UUID]Stated, len(people))
for id, p := range people {
if len(p.Locales) > 0 || p.Zone != "" {
out[id] = Stated{Locales: p.Locales, Zone: p.Zone}
}
}
return out
}
// count increments a category-tagged counter.
func (e *Engine) count(name, category string) {
e.metrics.Counter(name,
metrics.T("category", category),
).Inc()
}
// suppressed records recipients a notification did not reach.
func (e *Engine) suppressed(reason string, n int) {
if n > 0 {
e.metrics.Counter(MetricSuppressed,
metrics.T("reason", reason),
).Add(uint64(n))
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package prefs
import (
"errors"
"fmt"
"maps"
"slices"
"strconv"
"strings"
"time"
"uuid"
"github.com/deep-rent/nexus/dat/valid"
)
// Choice is what somebody said about one category.
type Choice string
const (
// ChoiceOn delivers the category to every live device.
ChoiceOn Choice = "on"
// ChoiceOff mutes the category entirely.
ChoiceOff Choice = "off"
)
// Choices lists every choice a person may express.
var Choices = []Choice{ChoiceOn, ChoiceOff}
// Valid reports whether the choice is one this service knows.
func (c Choice) Valid() bool { return c == ChoiceOn || c == ChoiceOff }
// Errors reported for preferences this package refuses.
var (
// ErrChoice reports a choice outside [Choices].
ErrChoice = errors.New("unknown notification choice")
// ErrQuiet reports a quiet window that is not a window: one whose
// ends are equal, or whose minutes lie outside a day.
ErrQuiet = errors.New("invalid quiet hours")
)
// MinutesPerDay is the modulus every quiet-hours comparison runs in.
const MinutesPerDay = 24 * 60
// Quiet is the window, in the recipient's local time, during which they
// would rather not be disturbed. Both ends are minutes past local
// midnight, and the window ordinarily crosses midnight — 22:00 to 07:00
// is From 1320, To 420.
//
// The zero value is no window at all, which is what somebody who never
// set one has.
type Quiet struct {
// From is when the window opens, in minutes past local midnight.
From int `json:"from"`
// To is when it closes, in minutes past local midnight. A value
// below From means the window crosses midnight, which is the
// ordinary case.
To int `json:"to"`
}
// Set reports whether a window was configured at all. Equal ends are not
// a window: they would mean either every hour or none, and neither is
// something somebody meant to say.
func (q Quiet) Set() bool { return q.From != q.To }
// Check reports whether the window is one this package can evaluate.
func (q Quiet) Check() error {
if q.From == 0 && q.To == 0 {
return nil // unset
}
if q.From < 0 || q.From >= MinutesPerDay ||
q.To < 0 || q.To >= MinutesPerDay || q.From == q.To {
return ErrQuiet
}
return nil
}
// Validate implements the [valid.Validatable] interface.
func (q *Quiet) Validate(v *valid.Validator) {
v.Between("from", q.From, 0, MinutesPerDay-1)
v.Between("to", q.To, 0, MinutesPerDay-1)
if q.From == q.To && q.From != 0 {
v.Fail("to", "quiet hours must span a window, not an instant")
}
}
// String renders the window as "22:00-07:00", for logs and for the
// operator reading a preference back.
func (q Quiet) String() string {
if !q.Set() {
return "none"
}
return clock(q.From) + "-" + clock(q.To)
}
// clock renders minutes past midnight as HH:MM.
func clock(m int) string {
h, min := m/60, m%60
return pad(h) + ":" + pad(min)
}
// pad renders a clock component with a leading zero.
func pad(n int) string {
if n < 10 {
return "0" + strconv.Itoa(n)
}
return strconv.Itoa(n)
}
// local resolves the instant into the recipient's wall clock, reporting
// whether the window can be evaluated at all.
//
// It answers false for an unset window, for a device that reported no
// zone, and for a zone this deployment's tzdata does not carry. The last
// is the runtime fallback for a zone that was valid at registration and
// has since been dropped; computing somebody's night in a zone that is
// not theirs would silence them at the wrong hours rather than the right
// ones, so nothing is muted instead.
func (q Quiet) local(at time.Time, zone string) (time.Time, bool) {
if !q.Set() || zone == "" {
return time.Time{}, false
}
loc, err := time.LoadLocation(zone)
if err != nil {
return time.Time{}, false
}
return at.In(loc), true
}
// inside reports whether a wall-clock instant falls in the window.
func (q Quiet) inside(t time.Time) bool {
m := t.Hour()*60 + t.Minute()
if q.From < q.To {
// An ordinary window inside one day: 01:00 to 06:00.
return m >= q.From && m < q.To
}
// A window crossing midnight: 22:00 to 07:00 is late evening OR
// early morning, which is why this is a disjunction rather than a
// range.
return m >= q.From || m < q.To
}
// Muted reports whether the instant falls inside the window, read in the
// given IANA time zone. See [Quiet.Until] for what a caller does with a
// muted instant, and local for the cases that mute nothing.
func (q Quiet) Muted(at time.Time, zone string) bool {
t, ok := q.local(at, zone)
return ok && q.inside(t)
}
// Until answers when the window the instant falls into ends, as an
// absolute instant in UTC — what a deferred delivery is scheduled for.
//
// It returns the instant unchanged when nothing is muted, so a caller can
// apply it unconditionally. The answer is computed by walking forward to
// the local wall-clock time the window closes at, which is what makes it
// correct across a daylight-saving transition: the window ends at 07:00
// local, whatever that turns out to be in UTC.
func (q Quiet) Until(at time.Time, zone string) time.Time {
t, ok := q.local(at, zone)
if !ok || !q.inside(t) {
return at
}
end := time.Date(
t.Year(), t.Month(), t.Day(),
q.To/60, q.To%60, 0, 0, t.Location(),
)
if !end.After(t) {
// The window closes tomorrow: either it crosses midnight and
// the instant is in its evening half, or a transition moved the
// wall clock past the close.
end = end.AddDate(0, 0, 1)
}
return end.UTC()
}
// Preference is one person's stated choice about one category. A person
// who never chose has no row, and the category's own default applies;
// see the package documentation.
type Preference struct {
// User is whose preference it is.
User uuid.UUID `json:"-"`
// Category is what the choice is about, named as the catalog names
// it.
Category string `json:"category"`
// Choice is what they said.
Choice Choice `json:"choice"`
// UpdatedAt is when they last said it.
UpdatedAt time.Time `json:"updated_at"`
}
// Settings is everything one person has said, as their own settings
// screen reads it back.
type Settings struct {
// Quiet is the window they would rather not be disturbed in, unset
// when they named none.
Quiet Quiet `json:"quiet,omitzero"`
// Categories maps a category name onto the choice they made about
// it. Categories they never touched are absent, and the catalog's
// default applies.
Categories map[string]Choice `json:"categories"`
}
// Allows reports whether a category reaches this person, given what the
// category itself defaults to when they have said nothing.
func (s Settings) Allows(category string, byDefault Choice) bool {
if c, ok := s.Categories[category]; ok {
return c == ChoiceOn
}
return byDefault == ChoiceOn
}
// Update is what a person's settings screen submits. Every field is
// optional: a screen that only moves the quiet window sends only that.
type Update struct {
// Quiet replaces the quiet window when non-nil. A zero window
// clears it.
Quiet *Quiet `json:"quiet,omitzero"`
// Categories are the choices to record. A category mapped to the
// empty string is CLEARED — the row goes and the catalog's default
// applies again, which is a different state from being switched on.
Categories map[string]Choice `json:"categories,omitzero"`
}
// Validate implements the [valid.Validatable] interface. The category
// names are checked for shape here and for existence by the engine,
// which is what holds the catalog.
//
// A bad entry is reported against "categories" with the offending name
// in the violation's arguments rather than as a path of its own: a map
// key is not a field path, and a category named with a dot in it would
// otherwise arrive escaped. The keys are walked in order so the same
// update always renders the same message.
func (u *Update) Validate(v *valid.Validator) {
if u.Quiet != nil {
v.Test("quiet", u.Quiet)
}
v.MaxSize("categories", len(u.Categories), MaxCategories)
for _, name := range slices.Sorted(maps.Keys(u.Categories)) {
choice := u.Categories[name]
switch {
case name == "":
v.Report("categories", valid.Violation{
Code: valid.CodeNotBlank,
Text: "a category needs a name",
})
case len(name) > MaxCategoryLength:
// Bounded here rather than left to the handler, which
// searches the catalog for the nearest name to suggest.
// That search is quadratic in the length of what it is
// given and runs once per declared category, so an
// unbounded key turns a single small request into tens of
// milliseconds of CPU and megabytes of garbage — and the
// refusal would quote it back besides.
v.Report("categories", valid.Violation{
Code: valid.CodeMaxLen,
Text: fmt.Sprintf(
"a category name is at most %d characters",
MaxCategoryLength,
),
Args: map[string]any{"max": MaxCategoryLength},
})
case choice == "":
// A clear, which is always well-formed.
case !choice.Valid():
v.Report("categories", valid.Violation{
Code: valid.CodeWhitelist,
Text: fmt.Sprintf(
"the choice for %q must be one of: on, off", name,
),
Args: map[string]any{
"category": name,
"choice": string(choice),
"allowed": Choices,
},
})
}
}
}
// MaxCategoryLength bounds a category name in an update. It matches
// [catalog.MaxNameLength], which is what the catalog itself accepts,
// so nothing nameable is refused here.
//
// [catalog.MaxNameLength]:
// github.com/deep-rent/nexus/eco/nds/catalog#MaxNameLength
const MaxCategoryLength = 64
// MaxCategories bounds how many choices one update may carry. A
// settings screen submits what the catalog holds, so the cap sits above
// any real catalog and exists to bound a mistake.
const MaxCategories = 128
// Check reports whether the update is well-formed, for the paths that
// are not behind a [valid.Validator].
func (u *Update) Check() error {
if u.Quiet != nil {
if err := u.Quiet.Check(); err != nil {
return err
}
}
for name, choice := range u.Categories {
if choice != "" && !choice.Valid() {
return fmt.Errorf("%w: %s=%s", ErrChoice, name, choice)
}
}
return nil
}
// ParseQuiet reads a window written as "22:00-07:00", which is how an
// operator states one in configuration and how the API accepts one from
// a client that would rather not do arithmetic. An empty string is an
// unset window.
func ParseQuiet(s string) (Quiet, error) {
if s == "" {
return Quiet{}, nil
}
from, to, ok := strings.Cut(s, "-")
if !ok {
return Quiet{}, fmt.Errorf("%w: %q is not a window", ErrQuiet, s)
}
f, err := minutes(from)
if err != nil {
return Quiet{}, err
}
t, err := minutes(to)
if err != nil {
return Quiet{}, err
}
q := Quiet{From: f, To: t}
if err := q.Check(); err != nil {
return Quiet{}, err
}
return q, nil
}
// minutes reads one HH:MM end of a window.
func minutes(s string) (int, error) {
h, m, ok := strings.Cut(strings.TrimSpace(s), ":")
if !ok {
return 0, fmt.Errorf("%w: %q is not a time", ErrQuiet, s)
}
hour, err := strconv.Atoi(h)
if err != nil || hour < 0 || hour > 23 {
return 0, fmt.Errorf("%w: %q is not an hour", ErrQuiet, h)
}
min, err := strconv.Atoi(m)
if err != nil || min < 0 || min > 59 {
return 0, fmt.Errorf("%w: %q is not a minute", ErrQuiet, m)
}
return hour*60 + min, nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package nds
import (
"context"
"encoding/json/v2"
"errors"
"fmt"
"net/http"
"os"
"time"
"github.com/deep-rent/nexus/eco/identity"
"github.com/deep-rent/nexus/eco/nds/api"
"github.com/deep-rent/nexus/eco/nds/catalog"
"github.com/deep-rent/nexus/eco/nds/config"
"github.com/deep-rent/nexus/eco/nds/device"
"github.com/deep-rent/nexus/eco/nds/dispatch"
"github.com/deep-rent/nexus/eco/nds/store"
"github.com/deep-rent/nexus/net/notify/push"
"github.com/deep-rent/nexus/net/notify/push/apns"
"github.com/deep-rent/nexus/net/notify/push/fcm"
"github.com/deep-rent/nexus/sec/auth"
"github.com/deep-rent/nexus/sys/boot"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/queue"
"github.com/deep-rent/nexus/sys/schedule"
)
// PathHooks is where the identity service's webhook deliveries arrive;
// see the deployment README for the registration recipe.
const PathHooks = "/hooks/iam"
// Tuning constants of the assembled service. They are deliberately not
// configuration: each is a property of this service's own shape rather
// than of the deployment around it.
const (
// MaxBodySize caps a request body at 64 KiB. A registration is a
// token and four short strings, and a publish is a hundred
// identifiers and a handful of variables, so nothing legitimate
// comes close.
MaxBodySize = 64 << 10
// Workers bounds the job fleet. Deliveries are IO on Apple's and
// Google's servers rather than work of this service's own, so the
// fleet is sized for concurrency rather than for cores.
Workers = 16
// DeliverRetries is how many times a delivery is retried before it
// dead-letters. A dead token settles on the first attempt, so this
// budget is spent only on providers that are genuinely unwell.
DeliverRetries = 5
// DeliverTimeout bounds one attempt at sending. Both providers are
// fast or unavailable, with little in between.
DeliverTimeout = 30 * time.Second
)
// Service is the fully assembled notification service. Create instances
// with [New], serve them with [Service.Run], or embed [Service.Handler]
// into a custom server.
type Service struct {
cfg config.Config
rt *boot.Runtime
logger *log.Logger
store *store.Store
engine *dispatch.Engine
// people is the door to identity, held so a deleted account can be
// dropped from its cache the moment the news arrives. It is nil
// where the deployment left the directory unconfigured.
people *identity.Directory
}
// New assembles the service from its configuration and catalog. It
// returns an error for unusable external inputs — an unreachable
// database, an unreadable provider key, a deployment that reaches no
// provider at all.
//
// The version identifies this build in the User-Agent of every outbound
// request; resolve it via [cli.Version].
//
// [cli.Version]: github.com/deep-rent/nexus/sys/cli#Version
func New(
ctx context.Context,
cfg config.Config,
cat *catalog.Catalog,
version string,
) (*Service, error) {
if cat == nil {
return nil, errors.New(
"the notification service needs a catalog; see the README",
)
}
// A service reaching no provider would accept every publish and
// deliver none, which is worse than refusing to start.
if !cfg.Serves() {
return nil, config.ErrNoSender
}
rt, err := boot.New(ctx, boot.Spec{
Name: "nds",
Version: version,
Core: cfg.Core,
Database: &cfg.Database,
Auth: &cfg.Auth,
// This service publishes nothing: it is the last stop between
// an event and a phone.
Sender: nil,
},
boot.WithMaxBody(MaxBodySize),
boot.WithWorkers(Workers),
// Provider tokens must never reach a log line, whatever a
// future call site passes.
boot.WithRedact("token", "device_token", "private_key"),
)
if err != nil {
return nil, err
}
s := &Service{cfg: cfg, rt: rt, logger: rt.Logger()}
client := rt.Client()
// Provider tokens are capabilities for reaching a phone, so they are
// sealed at rest. A deployment without a key still works, and its
// database backup carries the means to push to everybody.
opts := []store.Option{}
if cfg.Tokens.Sealed() {
ring, err := cfg.Tokens.Keyring()
if err != nil {
return nil, fmt.Errorf(
"failed to load the token sealing keys: %w", err,
)
}
opts = append(opts, store.WithSealer(ring))
} else {
s.logger.Warn(ctx,
"Device tokens are stored unsealed; set NDS_TOKEN_KEY so a "+
"database backup does not carry the means to push to "+
"every registered phone",
)
}
s.store = store.New(rt.Pool(), opts...)
rt.Migrate(store.Migrator)
senders, err := s.senders()
if err != nil {
return nil, err
}
engine := []dispatch.Option{
dispatch.WithLogger(s.logger.Child("dispatch")),
dispatch.WithTTL(cfg.TTL),
}
if cfg.Directory.Enabled() {
s.people = identity.Open(cfg.Directory, client)
engine = append(engine, dispatch.WithDirectory(s.people))
} else {
s.logger.Info(ctx,
"No directory configured; a notification is written in the "+
"language its phone reports and deferred only by a zone "+
"its phone reports",
)
}
s.engine = dispatch.New(s.store, rt.Jobs(), cat, senders, engine...)
rt.Handle(dispatch.KindDeliver, s.engine.Deliver,
queue.HandlerTimeout(DeliverTimeout),
queue.HandlerRetries(DeliverRetries),
)
rt.Every("retention", cfg.Retention.Interval,
schedule.TaskFn(s.sweep))
// The gauges are published once at startup too, so an operator
// watching a fresh replica does not wait for the first sweep.
rt.Once("gauges", func(ctx context.Context) error {
s.sweepGauges(ctx)
return nil
})
r := rt.Router()
guard := rt.Guard()
server := api.New(api.Config{Registry: s.store, Engine: s.engine})
// The delegated surface: everything belonging to a person, taking
// its identity from the caller's own token.
server.Mount(r, guard.Secure())
// The publish surface is guarded by a scope no role grants. A
// person's token carrying it could write to anybody's lock screen,
// so the empty grant map is deliberate: only a machine client's own
// vetted scopes satisfy it, and the handler refuses a delegated
// token besides.
server.MountMachine(r, guard.Secure(
auth.Machine(), auth.Grants{}.Require(api.PermissionPublish),
))
// The identity service tells this one when an account is deleted.
// The receiver carries no bearer guard: the request authenticates
// by its signature, which proves possession of the secret minted
// when this endpoint was registered.
if cfg.Intake.Enabled() {
rcv, err := rt.Receiver(cfg.Intake)
if err != nil {
return nil, err
}
rcv.On(identity.TopicUserDeleted, s.engine.Accept).
Mount(r, PathHooks)
} else {
s.logger.Warn(ctx,
"No identity webhook secret; a deleted account keeps its "+
"registered devices until Forget is called by hand",
)
}
s.logger.Info(ctx, "Assembled NDS service",
log.Int("categories", len(cat.Categories())),
log.Int("languages", len(cat.Languages())),
log.Bool("apns", cfg.APNs.Enabled()),
log.Bool("fcm", cfg.FCM.Enabled()),
log.Bool("sealed", s.store.Sealed()),
log.Bool("directory", s.people != nil),
log.String("issuer", cfg.Auth.Issuer),
)
return s, nil
}
// senders builds the providers this deployment reaches. A platform left
// unconfigured is simply absent, and registrations for it are refused
// rather than kept as devices nothing could deliver to.
func (s *Service) senders() (dispatch.Senders, error) {
out := dispatch.Senders{}
client := s.rt.Client()
if c := s.cfg.APNs; c.Enabled() {
key, err := material(c.PrivateKey, c.KeyFile)
if err != nil {
return nil, fmt.Errorf("failed to read the APNs key: %w", err)
}
sender, err := newAPNs(apns.Credentials{
KeyID: c.KeyID,
TeamID: c.TeamID,
PrivateKey: key,
},
apns.WithBaseURL(c.Endpoint()),
apns.WithTopic(c.Topic),
apns.WithClient(client),
apns.WithLogger(s.logger.Child("apns")),
)
if err != nil {
return nil, err
}
out[device.PlatformIOS] = sender
}
if c := s.cfg.FCM; c.Enabled() {
raw, err := material(c.Credentials, c.CredentialsFile)
if err != nil {
return nil, fmt.Errorf(
"failed to read the FCM service account: %w", err,
)
}
var cred fcm.Credentials
if err := json.Unmarshal(raw, &cred); err != nil {
return nil, fmt.Errorf(
"failed to parse the FCM service account: %w", err,
)
}
sender, err := newFCM(cred,
fcm.WithBaseURL(c.BaseURL),
fcm.WithAuthURL(c.AuthURL),
fcm.WithClient(client),
fcm.WithLogger(s.logger.Child("fcm")),
)
if err != nil {
return nil, err
}
out[device.PlatformAndroid] = sender
}
return out, nil
}
// material reads a credential given either inline or as a file path.
// Mounting a secret is the better habit; carrying a PEM in an
// environment variable is the one every deployment reaches for first.
func material(inline, file string) ([]byte, error) {
if file != "" {
return os.ReadFile(file)
}
return []byte(inline), nil
}
// newAPNs builds the Apple sender, turning its panic on unusable
// credentials into an error.
//
// The constructor panics because a malformed key is a programmer error
// at every other call site; here it is an operator's typo in a mounted
// secret, and a service that crashes on one tells them far less than a
// service that says which credential it could not read.
func newAPNs(
cred apns.Credentials,
opts ...apns.Option,
) (sender push.Sender, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("unusable APNs credentials: %v", r)
}
}()
return apns.New(cred, opts...), nil
}
// newFCM builds the Firebase sender, turning its panic on unusable
// credentials into an error; see [newAPNs].
func newFCM(
cred fcm.Credentials,
opts ...fcm.Option,
) (sender push.Sender, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("unusable FCM credentials: %v", r)
}
}()
return fcm.New(cred, opts...), nil
}
// Handler returns the assembled HTTP handler, for embedding the API into
// a custom server.
func (s *Service) Handler() http.Handler { return s.rt.Handler() }
// Run serves the notification service until the context is canceled or a
// termination signal arrives.
func (s *Service) Run(ctx context.Context) error { return s.rt.Run(ctx) }
// Engine is the assembled dispatch machinery, for a host embedding this
// service rather than running it.
func (s *Service) Engine() *dispatch.Engine { return s.engine }
// sweep collects what the registry no longer needs and republishes the
// device gauges.
func (s *Service) sweep(ctx context.Context) {
s.engine.Sweep(ctx, s.store,
s.cfg.Retention.StaleAge,
s.cfg.Retention.RetiredAge,
)
}
// sweepGauges publishes the device gauges without collecting anything,
// so a fresh replica reports its registry before the first sweep is due.
// sweepGauges publishes the device gauges without collecting anything,
// so a freshly started replica reports a registry size before its first
// retention sweep comes around.
//
// It used to only log the counts, which meant the metric an operator is
// told to watch had one source claiming to be two. The gauges survived
// on the scheduler dispatching its first tick immediately — true today,
// and not something this should rest on.
func (s *Service) sweepGauges(ctx context.Context) {
s.engine.Gauges(ctx, s.store)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package store
import (
"context"
"errors"
"maps"
"slices"
"time"
"uuid"
"github.com/jackc/pgx/v5"
"github.com/deep-rent/nexus/eco/nds/prefs"
)
// Settings reads everything one person has said. Somebody who has said
// nothing reads back as the zero value with an empty map, which is a
// valid answer rather than a missing one: every category then takes its
// own default.
func (*Store) Settings(
ctx context.Context,
tx pgx.Tx,
user uuid.UUID,
) (prefs.Settings, error) {
out := prefs.Settings{Categories: map[string]prefs.Choice{}}
const categories = `SELECT category, choice
FROM preferences WHERE user_id = $1`
rows, err := tx.Query(ctx, categories, user)
if err != nil {
return prefs.Settings{}, err
}
defer rows.Close()
for rows.Next() {
var (
name string
choice prefs.Choice
)
if err := rows.Scan(&name, &choice); err != nil {
return prefs.Settings{}, err
}
out.Categories[name] = choice
}
if err := rows.Err(); err != nil {
return prefs.Settings{}, err
}
const quiet = `SELECT from_minute, to_minute
FROM quiet_hours WHERE user_id = $1`
err = tx.QueryRow(ctx, quiet, user).Scan(&out.Quiet.From, &out.Quiet.To)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return prefs.Settings{}, err
}
return out, nil
}
// SettingsAll reads what several people have said, in one round trip.
//
// It exists for a fan-out that names more than one recipient: resolving
// preferences one person at a time would turn one publish into a query
// per recipient. People who have said nothing are absent from the map,
// and the caller reads that as the categories' own defaults.
func (*Store) SettingsAll(
ctx context.Context,
tx pgx.Tx,
users []uuid.UUID,
) (map[uuid.UUID]prefs.Settings, error) {
out := make(map[uuid.UUID]prefs.Settings, len(users))
if len(users) == 0 {
return out, nil
}
// A person with a preference but no window, and one with a window
// but no preference, must both appear. The two tables are read
// separately rather than joined for exactly that reason: an inner
// join would drop each of them, and an outer join would multiply the
// window across every category row.
touch := func(id uuid.UUID) prefs.Settings {
if s, ok := out[id]; ok {
return s
}
s := prefs.Settings{Categories: map[string]prefs.Choice{}}
out[id] = s
return s
}
const categories = `SELECT user_id, category, choice
FROM preferences WHERE user_id = ANY($1)`
rows, err := tx.Query(ctx, categories, users)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var (
id uuid.UUID
name string
choice prefs.Choice
)
if err := rows.Scan(&id, &name, &choice); err != nil {
return nil, err
}
touch(id).Categories[name] = choice
}
if err := rows.Err(); err != nil {
return nil, err
}
const quiet = `SELECT user_id, from_minute, to_minute
FROM quiet_hours WHERE user_id = ANY($1)`
qrows, err := tx.Query(ctx, quiet, users)
if err != nil {
return nil, err
}
defer qrows.Close()
for qrows.Next() {
var (
id uuid.UUID
q prefs.Quiet
)
if err := qrows.Scan(&id, &q.From, &q.To); err != nil {
return nil, err
}
s := touch(id)
s.Quiet = q
out[id] = s
}
return out, qrows.Err()
}
// Save records what somebody chose.
//
// A category mapped to the empty string is CLEARED rather than switched
// off: the row goes and the category's own default applies again, which
// is a different state from an explicit off. A nil [prefs.Update.Quiet]
// leaves the window alone; an unset one clears it.
func (*Store) Save(
ctx context.Context,
tx pgx.Tx,
user uuid.UUID,
up prefs.Update,
now time.Time,
) error {
if err := up.Check(); err != nil {
return err
}
const set = `
INSERT INTO preferences (user_id, category, choice, updated_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, category) DO UPDATE SET
choice = EXCLUDED.choice, updated_at = EXCLUDED.updated_at`
const clear = `DELETE FROM preferences
WHERE user_id = $1 AND category = $2`
// Sorted, so that two updates touching the same categories take
// their row locks in the same order. Go randomizes map iteration,
// so an unsorted walk let a user double-tapping Save deadlock
// against themselves: one transaction holding (user, a) waiting on
// (user, b) while the other held (user, b) waiting on (user, a).
// Postgres breaks the cycle by aborting one of them, which the
// caller sees as a 500.
for _, name := range slices.Sorted(maps.Keys(up.Categories)) {
choice := up.Categories[name]
var err error
if choice == "" {
_, err = tx.Exec(ctx, clear, user, name)
} else {
_, err = tx.Exec(ctx, set, user, name, choice, now)
}
if err != nil {
return err
}
}
if up.Quiet == nil {
return nil
}
if !up.Quiet.Set() {
const drop = `DELETE FROM quiet_hours WHERE user_id = $1`
_, err := tx.Exec(ctx, drop, user)
return err
}
const window = `
INSERT INTO quiet_hours (user_id, from_minute, to_minute, updated_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id) DO UPDATE SET
from_minute = EXCLUDED.from_minute,
to_minute = EXCLUDED.to_minute,
updated_at = EXCLUDED.updated_at`
_, err := tx.Exec(ctx, window,
user, up.Quiet.From, up.Quiet.To, now,
)
return err
}
// Claim records that a publication key has been handled, reporting
// whether this caller is the first to claim it.
//
// It is the idempotency the queue's own key cannot provide. That key is
// held only while a job is PENDING, and a delivery that already
// succeeded releases it — which is exactly the moment a publisher's
// retry arrives. This claim outlives the delivery.
//
// The claim is scoped by category, key, AND audience — a digest of the
// recipients the call named — so it identifies a publish rather than
// merely a key. That makes both calling styles correct: one call naming
// everybody collapses with its own retry, and one call per recipient
// carrying a shared key does not collapse the recipients into each
// other.
//
// The first caller gets true and an empty receipt, and goes on to
// resolve and materialize. A later caller gets false and the receipt the
// first one answered, so both calls agree about what happened rather
// than the second reporting an empty fan-out.
func (*Store) Claim(
ctx context.Context,
tx pgx.Tx,
category, key, audience string,
receipt []byte,
now time.Time,
) (raw []byte, first bool, err error) {
const q = `
INSERT INTO publications
(category, key, audience, receipt, created_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (category, key, audience) DO NOTHING
RETURNING receipt`
err = tx.QueryRow(
ctx, q, category, key, audience, receipt, now,
).Scan(&raw)
if err == nil {
return raw, true, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return nil, false, err
}
// Somebody claimed it already; answer with what they were told.
const held = `SELECT receipt FROM publications
WHERE category = $1 AND key = $2 AND audience = $3`
err = tx.QueryRow(ctx, held, category, key, audience).Scan(&raw)
if err != nil {
return nil, false, err
}
return raw, false, nil
}
// Settle records the receipt a claim ended up answering, for the first
// caller — which cannot know its own fan-out until it has resolved one.
func (*Store) Settle(
ctx context.Context,
tx pgx.Tx,
category, key, audience string,
receipt []byte,
) error {
const q = `UPDATE publications SET receipt = $4
WHERE category = $1 AND key = $2 AND audience = $3`
_, err := tx.Exec(ctx, q, category, key, audience, receipt)
return err
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package store
import (
"context"
"database/sql"
"embed"
"errors"
"fmt"
"io/fs"
"time"
"uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/deep-rent/nexus/dat/migrate"
driver "github.com/deep-rent/nexus/dat/migrate/driver/postgres"
source "github.com/deep-rent/nexus/dat/migrate/source/file"
"github.com/deep-rent/nexus/eco/nds/device"
"github.com/deep-rent/nexus/sec/seal"
)
//go:embed migrations
var migrations embed.FS
// Module is the migration stream the notification schema lives in.
const Module = "nds"
// Migrations exposes the embedded schema migrations for [migrate]
// paired with its PostgreSQL driver.
func Migrations() fs.FS {
sub, err := fs.Sub(migrations, "migrations")
if err != nil {
// The subdirectory is embedded at compile time; failing to open
// it is a build defect, not a runtime condition.
panic(err)
}
return sub
}
// Migrator wires a [migrate.Migrator] for the notification schema over
// an existing database handle. The module, source, and driver are this
// schema's to declare; opts carry what the caller legitimately varies.
func Migrator(db *sql.DB, opts ...migrate.Option) *migrate.Migrator {
return migrate.New(append([]migrate.Option{
migrate.WithModule(Module),
migrate.WithSource(source.New(Migrations())),
migrate.WithDriver(driver.New(db)),
}, opts...)...)
}
// Open connects a migrator for the notification schema to the database
// at url, for commands that only run migrations. The returned close
// function releases the connection.
func Open(
url string,
opts ...migrate.Option,
) (*migrate.Migrator, func() error, error) {
return driver.Open(url, append([]migrate.Option{
migrate.WithModule(Module),
migrate.WithSource(source.New(Migrations())),
}, opts...)...)
}
// ErrNotFound reports a device no row names, or one belonging to
// somebody else — the two are deliberately indistinguishable, so a
// device identifier cannot be probed for existence.
var ErrNotFound = errors.New("no such device")
// Option configures a [Store].
type Option func(*Store)
// WithSealer encrypts provider tokens at rest under the given keyring.
// Without one they are stored as they are, which is a test-rig setting;
// see the package documentation. A nil keyring is ignored.
func WithSealer(ring *seal.Keyring) Option {
return func(s *Store) {
if ring != nil {
s.ring = ring
}
}
}
// Store persists devices and preferences. It is safe for concurrent use
// and carries no logger: every operation returns its error, and
// narrating outcomes is its callers' business.
type Store struct {
pool *pgxpool.Pool
ring *seal.Keyring
}
// New creates a [Store] over the given connection pool. It panics on a
// nil pool (programmer error).
func New(pool *pgxpool.Pool, opts ...Option) *Store {
if pool == nil {
panic("pool is required")
}
s := &Store{pool: pool}
for _, opt := range opts {
opt(s)
}
return s
}
// Sealed reports whether provider tokens are encrypted at rest.
func (s *Store) Sealed() bool { return s.ring != nil }
// Exec runs fn within a single transaction, so that a change and the
// jobs announcing it either all land or none do.
func (s *Store) Exec(
ctx context.Context,
fn func(ctx context.Context, tx pgx.Tx) error,
) error {
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
return fn(ctx, tx)
})
}
// seal encrypts a provider token, bound to the row that will hold it.
//
// The binding is what stops an attacker with write access from moving
// one person's token onto another person's device: opening it under a
// different identifier fails the authentication tag.
func (s *Store) seal(id uuid.UUID, token string) ([]byte, error) {
if s.ring == nil {
return []byte(token), nil
}
out, err := s.ring.Seal([]byte(token), id[:])
if err != nil {
return nil, fmt.Errorf("failed to seal a device token: %w", err)
}
return out, nil
}
// open decrypts a provider token stored against the given row.
func (s *Store) open(id uuid.UUID, sealed []byte) (string, error) {
if s.ring == nil {
return string(sealed), nil
}
out, err := s.ring.Open(sealed, id[:])
if err != nil {
return "", fmt.Errorf("failed to open a device token: %w", err)
}
return string(out), nil
}
// deviceColumns is the read shape of a device, in scanDevice order. The
// NULL retirement collapses to a sentinel the record models as the zero
// time, so every read scans the same shape.
const deviceColumns = "id, user_id, platform, token_digest, locale, " +
"zone, build, name, COALESCE(retired_at, 'epoch'::timestamptz), " +
"created_at, seen_at"
// scanDevice reads one device row in deviceColumns order. It leaves the
// token empty and fills the fingerprint instead: only
// [Store.Deliverable] reads tokens, and only because it is about to
// send.
func scanDevice(row pgx.Row) (device.Device, error) {
var d device.Device
err := row.Scan(
&d.ID, &d.User, &d.Platform, &d.Fingerprint, &d.Locale, &d.Zone,
&d.Build, &d.Name, &d.RetiredAt, &d.CreatedAt, &d.SeenAt,
)
if err != nil {
return device.Device{}, err
}
// The epoch sentinel stands in for NULL, which the record models as
// the zero time.
if d.RetiredAt.Unix() == 0 {
d.RetiredAt = time.Time{}
}
return d, nil
}
// ClaimUser serializes a user's registrations against other transactions
// registering for the same user, and must be the first statement of
// any such transaction.
//
// The device cap is a read-modify-write — count what the user holds,
// then insert — and the registry runs at READ COMMITTED, where a
// device that does not exist yet has no row to lock. Two concurrent
// registrations therefore both see room and both take it, which is
// how a cap meant to bound abuse stops bounding it.
//
// The lock is advisory and scoped to the transaction, so it costs a
// hash and no row contention, and two different users never wait on
// each other.
func (*Store) ClaimUser(
ctx context.Context,
tx pgx.Tx,
user uuid.UUID,
) error {
if _, err := tx.Exec(ctx,
`SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))`,
user,
); err != nil {
return fmt.Errorf("failed to claim the user: %w", err)
}
return nil
}
// Register records a device, or refreshes the one already holding this
// token.
//
// The upsert keys on the token digest rather than on the pair of person
// and token: a provider mints no new token when a phone changes hands,
// so the same token arrives under a new owner and must MOVE rather than
// be stored twice. Storing both would send one person's notifications to
// another person's phone.
//
// A registration also revives a retired row. The provider has just told
// us this token works, which is better evidence than the refusal that
// retired it.
func (s *Store) Register(
ctx context.Context,
tx pgx.Tx,
user uuid.UUID,
reg device.Registration,
now time.Time,
) (device.Device, error) {
if err := reg.Check(); err != nil {
return device.Device{}, err
}
digest := device.Digest(reg.Token)
// The identifier is minted before the seal, because the seal binds
// to it. On a conflict the existing row keeps its own identifier and
// its own sealed token, which is why the update does not touch
// either column.
id := uuid.NewV7()
sealed, err := s.seal(id, reg.Token)
if err != nil {
return device.Device{}, err
}
const q = `
INSERT INTO devices (
id, user_id, platform, token_digest, token_sealed,
locale, zone, build, name, created_at, seen_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10)
ON CONFLICT (token_digest) DO UPDATE SET
user_id = EXCLUDED.user_id,
platform = EXCLUDED.platform,
locale = EXCLUDED.locale,
zone = EXCLUDED.zone,
build = EXCLUDED.build,
name = EXCLUDED.name,
retired_at = NULL,
seen_at = EXCLUDED.seen_at
RETURNING ` + deviceColumns
return scanDevice(tx.QueryRow(ctx, q,
id, user, reg.Platform, digest, sealed,
reg.Locale, reg.Zone, reg.Build, reg.Name, now,
))
}
// List reads one person's devices, newest first, retired ones included
// so they can tell a phone they lost from one that went quiet.
func (*Store) List(
ctx context.Context,
tx pgx.Tx,
user uuid.UUID,
) ([]device.Device, error) {
const q = `SELECT ` + deviceColumns + `
FROM devices WHERE user_id = $1 ORDER BY created_at DESC`
rows, err := tx.Query(ctx, q, user)
if err != nil {
return nil, err
}
defer rows.Close()
out := []device.Device{}
for rows.Next() {
d, err := scanDevice(rows)
if err != nil {
return nil, err
}
out = append(out, d)
}
return out, rows.Err()
}
// Forget drops one of a person's devices, reporting [ErrNotFound] for a
// device that is not theirs.
//
// It is a delete rather than a retirement: somebody removing a phone
// from their own list is asking for it to be gone, and a retired row
// would keep their identifier against a token they disowned.
func (*Store) Forget(
ctx context.Context,
tx pgx.Tx,
user, id uuid.UUID,
) error {
const q = `DELETE FROM devices WHERE id = $1 AND user_id = $2`
tag, err := tx.Exec(ctx, q, id, user)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// ForgetUser drops everything this service holds about a person: their
// devices, their preferences, and their quiet hours. It is what the
// identity service's deletion webhook triggers, and it is idempotent, so
// a redelivery costs three no-op statements.
func (*Store) ForgetUser(
ctx context.Context,
tx pgx.Tx,
user uuid.UUID,
) error {
for _, q := range []string{
`DELETE FROM devices WHERE user_id = $1`,
`DELETE FROM preferences WHERE user_id = $1`,
`DELETE FROM quiet_hours WHERE user_id = $1`,
} {
if _, err := tx.Exec(ctx, q, user); err != nil {
return err
}
}
return nil
}
// Retire marks a device dead, for when a provider refuses its token.
//
// It is keyed by identifier and is idempotent: a device already retired
// keeps its original timestamp, so two workers classifying the same dead
// token do not fight over it.
func (*Store) Retire(
ctx context.Context,
tx pgx.Tx,
id uuid.UUID,
now time.Time,
) error {
const q = `UPDATE devices SET retired_at = $2
WHERE id = $1 AND retired_at IS NULL`
_, err := tx.Exec(ctx, q, id, now)
return err
}
// Device reads one device by identifier, without its token.
func (*Store) Device(
ctx context.Context,
tx pgx.Tx,
id uuid.UUID,
) (device.Device, error) {
const q = `SELECT ` + deviceColumns + ` FROM devices WHERE id = $1`
d, err := scanDevice(tx.QueryRow(ctx, q, id))
if errors.Is(err, pgx.ErrNoRows) {
return device.Device{}, ErrNotFound
}
return d, err
}
// Deliverable reads one device WITH its token, for the delivery about to
// happen, and only while it still belongs to the person the notification
// was published for.
//
// Both predicates answer [ErrNotFound], and both mean the same thing to
// the caller: by the time a queued job runs there is nobody left to
// tell. A retired device is the obvious case. The owner is the subtle
// one — a phone handed to somebody else re-registers under its new
// owner, and [Store.Register] deliberately MOVES the row rather than
// duplicating it, keeping the same identifier and the same sealed token.
// Without this predicate a job deferred overnight by quiet hours would
// still find that row and deliver the previous owner's notification to
// the new owner's lock screen.
//
// It is the only read that opens a sealed token. See the package
// documentation.
func (s *Store) Deliverable(
ctx context.Context,
tx pgx.Tx,
id, user uuid.UUID,
) (device.Device, error) {
const q = `SELECT ` + deviceColumns + `, token_sealed
FROM devices
WHERE id = $1 AND user_id = $2 AND retired_at IS NULL`
var (
d device.Device
sealed []byte
)
err := tx.QueryRow(ctx, q, id, user).Scan(
&d.ID, &d.User, &d.Platform, &d.Fingerprint, &d.Locale, &d.Zone,
&d.Build, &d.Name, &d.RetiredAt, &d.CreatedAt, &d.SeenAt,
&sealed,
)
if errors.Is(err, pgx.ErrNoRows) {
return device.Device{}, ErrNotFound
}
if err != nil {
return device.Device{}, err
}
if d.RetiredAt.Unix() == 0 {
d.RetiredAt = time.Time{}
}
if d.Token, err = s.open(d.ID, sealed); err != nil {
return device.Device{}, err
}
return d, nil
}
// Resolve reads the live devices of one person — the fan-out read, run
// once per notification. Tokens are left sealed: the fan-out decides WHO
// to send to, and the delivery job reads the token when it is about to
// send, which is what keeps a token out of a queue payload.
func (*Store) Resolve(
ctx context.Context,
tx pgx.Tx,
user uuid.UUID,
) ([]device.Device, error) {
const q = `SELECT ` + deviceColumns + `
FROM devices WHERE user_id = $1 AND retired_at IS NULL`
rows, err := tx.Query(ctx, q, user)
if err != nil {
return nil, err
}
defer rows.Close()
out := []device.Device{}
for rows.Next() {
d, err := scanDevice(rows)
if err != nil {
return nil, err
}
out = append(out, d)
}
return out, rows.Err()
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package store
import (
"context"
"time"
"github.com/jackc/pgx/v5"
)
// Defaults for the retention sweep. Both are the providers' own advice
// rather than this service's invention: Apple and Google both ask
// senders to stop pushing to tokens that have gone quiet, and a registry
// that never forgets is one that keeps trying forever.
const (
// DefaultStaleAge is how long a device may go unrefreshed before it
// is dropped. Apps refresh on every launch, so a phone silent for
// half a year has been wiped, replaced, or had the app removed
// without the provider noticing.
DefaultStaleAge = 180 * 24 * time.Hour
// DefaultRetiredAge is how long a retired row is kept before it
// goes. It only has to outlast the delivery retry schedule, so that
// a job settling against a device the provider refused still finds
// the row it names.
DefaultRetiredAge = 7 * 24 * time.Hour
// DefaultPublicationAge is how long a publication key is remembered,
// which is how long a publisher's retry stays harmless. It must
// outlast any sibling's own retry schedule, and a key is two short
// strings, so the window is generous.
DefaultPublicationAge = 7 * 24 * time.Hour
)
// Swept counts what one retention pass collected.
type Swept struct {
// Stale is the number of devices nobody refreshed in time.
Stale int64
// Retired is the number of retired rows dropped.
Retired int64
// Published is the number of publication keys forgotten.
Published int64
}
// Total is everything the pass collected.
func (s Swept) Total() int64 {
return s.Stale + s.Retired + s.Published
}
// Sweep collects what the registry no longer needs: devices nobody has
// refreshed, retired rows old enough to drop, and publication keys old
// enough that no publisher is still retrying them.
//
// It deliberately does NOT collect preferences naming categories the
// catalog no longer declares, though an earlier version did. The
// catalog is a file, and a file can be wrong: rename a category in it —
// a bad merge, a typo — and the old name reads exactly like a category
// that was retired on purpose. Every user who had switched that
// category OFF would silently lose the choice within one sweep, and
// start receiving what they had asked not to receive. A stale
// preference is inert by comparison: the fan-out only ever looks up
// the category being published, and the settings API shows only what
// the catalog declares. Keeping a few dead rows is the cheaper
// mistake.
//
// Each statement stands alone, so a pass that fails partway has still
// collected what it collected.
func (s *Store) Sweep(
ctx context.Context,
now time.Time,
staleAge, retiredAge time.Duration,
) (Swept, error) {
if staleAge <= 0 {
staleAge = DefaultStaleAge
}
if retiredAge <= 0 {
retiredAge = DefaultRetiredAge
}
var out Swept
err := s.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
const stale = `DELETE FROM devices
WHERE retired_at IS NULL AND seen_at < $1`
tag, err := tx.Exec(ctx, stale, now.Add(-staleAge))
if err != nil {
return err
}
out.Stale = tag.RowsAffected()
const retired = `DELETE FROM devices
WHERE retired_at IS NOT NULL AND retired_at < $1`
if tag, err = tx.Exec(ctx, retired, now.Add(-retiredAge)); err != nil {
return err
}
out.Retired = tag.RowsAffected()
const published = `DELETE FROM publications WHERE created_at < $1`
tag, err = tx.Exec(ctx, published,
now.Add(-DefaultPublicationAge))
if err != nil {
return err
}
out.Published = tag.RowsAffected()
return nil
})
return out, err
}
// Count reports how many live devices the registry holds, per platform.
// It is the gauge an operator watches: a registry that stops growing is
// an app that stopped registering.
func (s *Store) Count(ctx context.Context) (map[string]int64, error) {
const q = `SELECT platform, count(*) FROM devices
WHERE retired_at IS NULL GROUP BY platform`
rows, err := s.pool.Query(ctx, q)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]int64{}
for rows.Next() {
var (
platform string
n int64
)
if err := rows.Scan(&platform, &n); err != nil {
return nil, err
}
out[platform] = n
}
return out, rows.Err()
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package notify
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"uuid"
"github.com/deep-rent/nexus/eco/client"
"github.com/deep-rent/nexus/sec/token"
"github.com/deep-rent/nexus/sec/token/oauth"
)
// PathNotifications is where the notification service accepts a
// publish, relative to its base URL.
const PathNotifications = "/notifications"
// PermissionPublish is the scope a caller's machine token must carry.
// It is spelled out here as the notification service's published
// contract, so an operator registering a client can read it from the
// package that calls it.
const PermissionPublish = "nds:publish"
// Errors a caller is expected to tell apart.
var (
// ErrUnknown reports a category this deployment's catalog does not
// declare. It is a caller-side mistake — a typo, or a category that
// was dropped from the catalog — and no retry will mend it.
ErrUnknown = errors.New("unknown notification category")
// ErrRefused reports a publish the service would not accept: a
// variable the category does not take, a recipient list beyond its
// fan-out bound, a malformed request. Also permanent.
ErrRefused = errors.New("notification refused")
// ErrUnauthorized reports credentials the service would not accept.
// It is an operator's problem — an unregistered client, a missing
// scope, a rotated secret — rather than a transient one.
ErrUnauthorized = errors.New("notification credentials refused")
)
// Request asks for one notification.
//
// It carries no text. The notification service renders from its own
// catalog in the recipient's language and decides how much of it reaches
// a locked phone, which is what keeps a caller's raw domain object off a
// lock screen; see the package documentation.
type Request struct {
// Category names what this is, as the deployment's catalog declares
// it. Required.
Category string `json:"category"`
// Recipients are who to tell. Required, and bounded by the service's
// fan-out limit: this is not a broadcast channel.
Recipients []uuid.UUID `json:"recipients"`
// Vars are the values the category's template substitutes. A
// variable the category does not declare is refused rather than
// ignored, so a caller's typo surfaces instead of rendering blank.
Vars map[string]string `json:"vars,omitzero"`
// Key makes a repeated publish harmless; see the package
// documentation. Empty means no protection at all.
Key string `json:"key,omitzero"`
// Collapse groups notifications that supersede one another on the
// phone, appended to the category's own grouping. A newer one then
// replaces an older rather than stacking beside it.
Collapse string `json:"collapse,omitzero"`
}
// Receipt is how far a notification got.
//
// It is deliberately not a delivery report: the jobs it counts have been
// written, not run, and a phone that is switched off receives nothing
// for hours. A caller that wants to know whether somebody was reached is
// asking a question this contract cannot answer.
type Receipt struct {
// Queued is how many delivery jobs were written.
Queued int `json:"queued"`
// Muted is how many recipients had switched this category off.
Muted int `json:"muted"`
// Unreachable is how many recipients have no live device.
Unreachable int `json:"unreachable"`
// Deferred is how many jobs were held back by quiet hours.
Deferred int `json:"deferred"`
// Duplicate reports that this key was published before and nothing
// new was enqueued; the rest of the receipt is what the first call
// answered.
Duplicate bool `json:"duplicate,omitzero"`
}
// Reached reports whether at least one phone will be sent to. It is what
// a caller logs, since zero is ordinary — everybody may have muted the
// category, or nobody may have registered a phone yet.
func (r Receipt) Reached() bool { return r.Queued > 0 }
// Option configures a [Publisher].
type Option func(*Publisher)
// WithClient sets the HTTP client used to reach the notification
// service. A nil client is ignored.
func WithClient(c *http.Client) Option {
return func(p *Publisher) {
if c != nil {
p.http = c
}
}
}
// Publisher asks the notification service to notify people.
//
// Safe for concurrent use.
type Publisher struct {
peer *client.Client
http *http.Client
}
// New builds a publisher against the notification service at base,
// authenticating with tokens minted by src.
func New(base string, src *token.Source, opts ...Option) *Publisher {
p := &Publisher{}
for _, opt := range opts {
opt(p)
}
// Built last, so an option supplying the HTTP client is honoured.
// A missing base or source panics here, in [client.New].
p.peer = client.New(base, src, client.WithHTTP(p.http))
return p
}
// Publish asks for one notification.
//
// The error is [ErrUnknown], [ErrRefused], or [ErrUnauthorized] for the
// failures no retry will mend, and an ordinary error for the ones a
// caller's own queue should try again.
func (p *Publisher) Publish(
ctx context.Context,
req Request,
) (Receipt, error) {
var out Receipt
err := p.peer.Do(ctx, client.Call{
Method: http.MethodPost,
Path: PathNotifications,
Body: req,
Into: &out,
Accept: []int{http.StatusAccepted},
})
if err != nil {
return Receipt{}, classify(err)
}
return out, nil
}
// classify maps a refusal onto this contract's vocabulary. Anything
// that is not a refusal — a transport failure, an unmintable token —
// passes through as itself, and is worth another attempt.
func classify(err error) error {
var fault *client.APIError
if !errors.As(err, &fault) {
return fmt.Errorf(
"failed to reach the notification service: %w", err,
)
}
switch {
case fault.Status == http.StatusUnauthorized,
fault.Status == http.StatusForbidden:
return fmt.Errorf("%w: %s", ErrUnauthorized, fault.Description)
case fault.Status == http.StatusBadRequest &&
strings.Contains(fault.Description, "category"):
// The service names the category in its refusal, which is the
// only signal separating "you asked for something that does not
// exist" from "you asked wrongly".
return fmt.Errorf("%w: %s", ErrUnknown, fault.Description)
case fault.Status == http.StatusBadRequest,
fault.Status == http.StatusUnprocessableEntity:
return fmt.Errorf("%w: %s", ErrRefused, fault.Description)
}
return fmt.Errorf(
"the notification service answered %d: %s",
fault.Status, fault.Description,
)
}
// Permanent reports whether an error from [Publisher.Publish] is one no
// retry will mend, so a caller can settle its job rather than spend a
// budget on it.
func Permanent(err error) bool {
return errors.Is(err, ErrUnknown) ||
errors.Is(err, ErrRefused) ||
errors.Is(err, ErrUnauthorized)
}
// Config declares how a service reaches the notification service.
//
// No field carries a required tag: whether a deployment may run without
// push notifications is the SERVICE's judgment rather than the section's,
// the same division [identity.Config] and [boot.Database] draw.
//
// [identity.Config]: github.com/deep-rent/nexus/eco/identity#Config
// [boot.Database]: github.com/deep-rent/nexus/sys/boot#Database
type Config struct {
// URL is the notification service's base URL. Empty leaves push
// notifications unsent; see [Config.Enabled].
URL string
// Credentials are this service's own machine credentials; the token
// endpoint is the IDENTITY service's rather than the notification
// service's, since that is who mints the token.
client.Credentials `env:",inline"`
// Scope is what the minted token asks for.
Scope string `env:",default:'nds:publish'"`
}
// Enabled reports whether a notification service is configured.
func (c Config) Enabled() bool {
return c.URL != "" && c.Complete()
}
// Open builds a publisher from its configuration. It panics on a
// configuration [Config.Enabled] rejects, since assembling a client that
// can reach nothing is a programmer error — check first, and decide
// there whether the absence is fatal.
func Open(cfg Config, client *http.Client, opts ...Option) *Publisher {
if !cfg.Enabled() {
panic("notification URL, token URL and credentials are required")
}
return New(cfg.URL, oauth.ClientCredentials(oauth.Client{
Endpoint: cfg.TokenURL,
ID: cfg.ClientID,
Secret: cfg.ClientSecret,
Scope: cfg.Scope,
HTTP: client,
}), append([]Option{WithClient(client)}, opts...)...)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package api
import (
"context"
"errors"
"net/http"
"slices"
"time"
"uuid"
"github.com/jackc/pgx/v5"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/eco/pes/catalog"
"github.com/deep-rent/nexus/eco/pes/entitle"
"github.com/deep-rent/nexus/eco/pes/ledger"
"github.com/deep-rent/nexus/eco/pes/provider"
"github.com/deep-rent/nexus/eco/pes/reconcile"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/sec/auth"
)
// Where a store-managed subscription is canceled, since only Stripe
// cancels server-side: the response points the user at the store's
// own management page instead of pretending.
const (
ManageApple = "https://apps.apple.com/account/subscriptions"
ManageGoogle = "https://play.google.com/store/account/subscriptions"
)
// Querier is the slice of the ledger the read surface needs.
// Implemented by [ledger.Store].
type Querier interface {
Exec(ctx context.Context,
fn func(ctx context.Context, tx pgx.Tx) error) error
Current(ctx context.Context, tx pgx.Tx, subject uuid.UUID) (
[]ledger.Fact, error)
Entitlements(ctx context.Context, tx pgx.Tx, subject uuid.UUID) (
[]ledger.Entitlement, error)
}
// Owners answers whether a user holds the management role in a team —
// the authorization behind directing a purchase to one. Implementations
// bridge to the identity service's directory; see the service assembly.
type Owners interface {
// Owner reports whether the user is an owner of the team.
Owner(ctx context.Context, teamID, userID uuid.UUID) (bool, error)
}
// OwnerFunc adapts a function to the [Owners] interface.
type OwnerFunc func(ctx context.Context, teamID, userID uuid.UUID) (
bool, error)
// Owner implements the [Owners] interface.
func (f OwnerFunc) Owner(
ctx context.Context,
teamID, userID uuid.UUID,
) (bool, error) {
return f(ctx, teamID, userID)
}
// Server binds the surfaces' dependencies.
type Server struct {
db Querier
engine *reconcile.Engine
cat *catalog.Catalog
owners Owners // nil leaves team-directed actions refused
now func() time.Time
}
// Option configures optional collaborators of a [Server].
type Option func(*Server)
// WithOwners enables team-directed actions — linking a purchase to a
// team, canceling a team's subscription — by supplying the authority
// that says who owns which team. Without it, such requests are refused:
// nobody's money should move on a role nobody can check.
func WithOwners(o Owners) Option {
return func(s *Server) { s.owners = o }
}
// New assembles the server. All positional dependencies are required.
func New(
db Querier,
engine *reconcile.Engine,
cat *catalog.Catalog,
now func() time.Time,
opts ...Option,
) *Server {
switch {
case db == nil:
panic("querier is required")
case engine == nil:
panic("engine is required")
case cat == nil:
panic("catalog is required")
case now == nil:
panic("clock is required")
}
s := &Server{db: db, engine: engine, cat: cat, now: now}
for _, opt := range opts {
opt(s)
}
return s
}
// Mount registers the guarded application surface. Pass the auth
// guard (and any additional route middleware) as mws.
func (s *Server) Mount(r *router.Router, mws ...router.Middleware) {
r.HandleFunc(http.MethodGet, "/entitlements",
s.entitlements, mws...)
r.HandleFunc(http.MethodGet, "/subscriptions",
s.subscriptions, mws...)
r.HandleFunc(http.MethodGet, "/purchases", s.purchases, mws...)
r.HandleFunc(http.MethodPost, "/purchases/link", s.link, mws...)
r.HandleFunc(http.MethodPost,
"/subscriptions/{provider}/{ref}/cancel", s.cancel, mws...)
}
// subject resolves whom a read is about. A machine names any subject
// with the "subject" parameter — its vetted scope speaks for it. A
// delegated token reads its own subject by default, and may name one
// of its teams instead: every member may see what the team's plan
// grants, the same way every member sees the team's usage. Naming
// anyone else is refused without confirming whether they exist.
func (*Server) subject(e *router.Exchange) (uuid.UUID, error) {
claims := auth.Must(e)
raw := e.Query().Get("subject")
if id := claims.UserID(); id != uuid.Nil() {
if raw == "" {
return id, nil
}
named, err := uuid.Parse(raw)
if err != nil {
return uuid.Nil(), &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: "subject must be a UUID",
}
}
if named == id ||
slices.Contains(claims.Memberships(), named) {
return named, nil
}
return uuid.Nil(), &router.Error{
Status: http.StatusForbidden,
Reason: auth.ReasonInsufficientPrivileges,
Description: "not one of the caller's teams",
}
}
named, err := uuid.Parse(raw)
if err != nil {
return uuid.Nil(), &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: "machine reads name a subject parameter",
}
}
return named, nil
}
// direct resolves whom a mutation acts for. Without a subject the
// caller acts for themselves. Naming a team demands the management
// role, checked against the identity service: any member may watch the
// team's grants, but only an owner directs its money. Where no owner
// authority is configured, team-directed actions are refused outright.
func (s *Server) direct(
e *router.Exchange,
subject uuid.UUID,
) (uuid.UUID, error) {
claims := auth.Must(e)
caller := claims.UserID()
if subject == uuid.Nil() || subject == caller {
return caller, nil
}
if !slices.Contains(claims.Memberships(), subject) {
return uuid.Nil(), &router.Error{
Status: http.StatusForbidden,
Reason: auth.ReasonInsufficientPrivileges,
Description: "not one of the caller's teams",
}
}
if s.owners == nil {
return uuid.Nil(), &router.Error{
Status: http.StatusForbidden,
Reason: auth.ReasonInsufficientPrivileges,
Description: "team purchases are not enabled",
}
}
owner, err := s.owners.Owner(e.Context(), subject, caller)
if err != nil {
return uuid.Nil(), &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: "failed to check the team role",
Cause: err,
}
}
if !owner {
return uuid.Nil(), &router.Error{
Status: http.StatusForbidden,
Reason: auth.ReasonInsufficientPrivileges,
Description: "directing a purchase to a team is " +
"reserved for its owners",
}
}
return subject, nil
}
// owner resolves the calling subject for an action, refusing machine
// tokens: linking and canceling act on the caller's own purchases.
func owner(e *router.Exchange) (uuid.UUID, error) {
claims := auth.Must(e)
id := claims.UserID()
if id == uuid.Nil() {
return uuid.Nil(), &router.Error{
Status: http.StatusForbidden,
Reason: router.ReasonValidationFailed,
Description: "actions are delegated-only",
}
}
return id, nil
}
// entitlements serves "GET /entitlements": the distinct feature keys
// valid now — the hot path an application gates on.
func (s *Server) entitlements(e *router.Exchange) error {
subject, err := s.subject(e)
if err != nil {
return err
}
var grants []ledger.Entitlement
err = s.db.Exec(e.Context(), func(
ctx context.Context, tx pgx.Tx,
) error {
grants, err = s.db.Entitlements(ctx, tx, subject)
return err
})
if err != nil {
return err
}
keys := entitle.Keys(grants, s.now())
if keys == nil {
keys = []string{}
}
e.NoStore()
return e.JSON(http.StatusOK, map[string][]string{
"entitlements": keys,
})
}
// subscription is the wire shape of one subscription purchase.
type subscription struct {
Product string `json:"product"`
Provider string `json:"provider"`
Ref string `json:"ref"`
Status string `json:"status"`
Renews bool `json:"renews"`
PeriodEnd time.Time `json:"period_end,omitzero"`
ManageURL string `json:"manage_url,omitempty"`
}
// plan is the wire shape of the resolved plan.
type plan struct {
Product string `json:"product"`
Tier int `json:"tier"`
}
// subscriptions serves "GET /subscriptions": the resolved plan plus
// every subscription the ledger currently knows, lapsed ones
// included so a client can render history and renewal state.
func (s *Server) subscriptions(e *router.Exchange) error {
subject, err := s.subject(e)
if err != nil {
return err
}
facts, grants, err := s.load(e, subject)
if err != nil {
return err
}
out := struct {
Plan *plan `json:"plan"`
Subscriptions []subscription `json:"subscriptions"`
}{Subscriptions: []subscription{}}
if p, ok := entitle.Plan(grants, s.cat, s.now()); ok {
out.Plan = &plan{Product: p.ID, Tier: p.Tier}
}
for _, f := range facts {
if f.Kind != ledger.KindSubscription {
continue
}
sub := subscription{
Provider: f.Provider,
Ref: f.Ref,
Status: f.Status,
Renews: f.Renews,
PeriodEnd: f.Ends,
}
if p, ok := s.cat.Find(f.Provider, f.SKU); ok {
sub.Product = p.ID
}
switch f.Provider {
case catalog.ProviderApple:
sub.ManageURL = ManageApple
case catalog.ProviderGoogle:
sub.ManageURL = ManageGoogle
}
out.Subscriptions = append(out.Subscriptions, sub)
}
e.NoStore()
return e.JSON(http.StatusOK, out)
}
// purchase is the wire shape of one owned product.
type purchase struct {
Product string `json:"product"`
Provider string `json:"provider"`
Ref string `json:"ref"`
Since time.Time `json:"since"`
}
// purchases serves "GET /purchases": the one-time products the
// subject currently owns.
func (s *Server) purchases(e *router.Exchange) error {
subject, err := s.subject(e)
if err != nil {
return err
}
facts, _, err := s.load(e, subject)
if err != nil {
return err
}
out := []purchase{}
for _, f := range facts {
if f.Kind != ledger.KindPurchase || !f.Granting() {
continue
}
p := purchase{
Provider: f.Provider,
Ref: f.Ref,
Since: f.Starts,
}
if product, ok := s.cat.Find(f.Provider, f.SKU); ok {
p.Product = product.ID
}
out = append(out, p)
}
e.NoStore()
return e.JSON(http.StatusOK, map[string][]purchase{
"purchases": out,
})
}
// linkRequest is the payload attesting a purchase.
type linkRequest struct {
Provider string `json:"provider"`
Proof string `json:"proof"`
// Subject optionally directs the grant to a team the caller owns;
// absent, the purchase is the caller's own. The proof must be
// bound to whoever the grant is for.
Subject uuid.UUID `json:"subject,omitzero"`
}
// Validate implements the [valid.Validatable] interface.
func (r *linkRequest) Validate(v *valid.Validator) {
v.NotBlank("provider", r.Provider)
v.NotBlank("proof", r.Proof)
}
// link serves "POST /purchases/link": the client submits provider-
// signed proof of a purchase, which is verified server-side and
// granted only if it is bound to whoever the grant is for — the
// caller, or a team the caller owns (see [Server.direct]).
func (s *Server) link(e *router.Exchange) error {
if _, err := owner(e); err != nil {
return err
}
var req linkRequest
if err := e.BindJSON(&req); err != nil {
return err
}
subject, err := s.direct(e, req.Subject)
if err != nil {
return err
}
if _, ok := s.engine.Provider(req.Provider); !ok {
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: "unknown provider",
}
}
err = s.engine.Attest(e.Context(), req.Provider, req.Proof, subject)
switch {
case errors.Is(err, reconcile.ErrForeign):
return &router.Error{
Status: http.StatusConflict,
Reason: router.ReasonValidationFailed,
Description: "the purchase is bound to another account",
}
case errors.Is(err, provider.ErrSignature):
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: "the proof did not verify",
}
case errors.Is(err, provider.ErrUnsupported):
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: err.Error(),
}
case err != nil:
return err
}
e.Status(http.StatusNoContent)
return nil
}
// cancel serves "POST /subscriptions/{provider}/{ref}/cancel". Where
// the provider cancels server-side the subscription lapses at period
// end; where the store owns cancellation the response carries the
// store's management page, because a button that pretends otherwise
// is broken.
func (s *Server) cancel(e *router.Exchange) error {
if _, err := owner(e); err != nil {
return err
}
// An optional subject query parameter cancels on a team's behalf,
// under the same owner rule as directing a purchase to one.
named, _ := uuid.Parse(e.Query().Get("subject"))
subject, err := s.direct(e, named)
if err != nil {
return err
}
var params struct {
Provider string `path:"provider"`
Ref string `path:"ref"`
}
if err := e.BindPath(¶ms); err != nil {
return err
}
// The purchase must be the caller's own; a miss and a foreign
// purchase answer alike, so refs cannot be probed.
facts, _, err := s.load(e, subject)
if err != nil {
return err
}
var owned *ledger.Fact
for i := range facts {
if facts[i].Provider == params.Provider &&
facts[i].Ref == params.Ref &&
facts[i].Kind == ledger.KindSubscription {
owned = &facts[i]
break
}
}
if owned == nil {
return &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
}
}
p, ok := s.engine.Provider(params.Provider)
if !ok {
return &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
}
}
canceler, ok := p.(provider.Canceler)
if !ok {
url := ManageApple
if params.Provider == catalog.ProviderGoogle {
url = ManageGoogle
}
return e.JSON(http.StatusOK, map[string]string{
"managed_by": params.Provider,
"url": url,
})
}
if err := canceler.Cancel(e.Context(), params.Ref); err != nil {
return err
}
return e.JSON(http.StatusAccepted, map[string]string{
"status": "canceling",
})
}
// load reads a subject's current facts and grants in one transaction.
func (s *Server) load(
e *router.Exchange,
subject uuid.UUID,
) ([]ledger.Fact, []ledger.Entitlement, error) {
var (
facts []ledger.Fact
grants []ledger.Entitlement
)
err := s.db.Exec(e.Context(), func(
ctx context.Context, tx pgx.Tx,
) error {
var err error
if facts, err = s.db.Current(ctx, tx, subject); err != nil {
return err
}
grants, err = s.db.Entitlements(ctx, tx, subject)
return err
})
return facts, grants, err
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package api
import (
"errors"
"net/http"
"github.com/deep-rent/nexus/eco/pes/provider"
"github.com/deep-rent/nexus/eco/pes/reconcile"
"github.com/deep-rent/nexus/net/router"
)
// MountIntake registers the provider notification endpoints,
// deliberately UNGUARDED: each request authenticates by its own
// cryptography — Stripe's HMAC, Apple's pinned chain, Google's OIDC
// bearer — which proves more than any shared bearer could. Forgeries
// are refused without detail; processing failures answer 5xx so the
// provider retries, which is the retry story working as designed.
func MountIntake(r *router.Router, engine *reconcile.Engine) {
if engine == nil {
panic("engine is required")
}
r.HandleFunc(http.MethodPost, "/hooks/{provider}",
func(e *router.Exchange) error {
var params struct {
Provider string `path:"provider"`
}
if err := e.BindPath(¶ms); err != nil {
return err
}
if _, ok := engine.Provider(params.Provider); !ok {
return &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
}
}
err := engine.Hear(e.Context(), params.Provider, e.R)
switch {
case errors.Is(err, provider.ErrSignature):
// No detail: at this surface the caller is whoever
// forged the request.
return &router.Error{
Status: http.StatusUnauthorized,
Reason: router.ReasonValidationFailed,
}
case err != nil:
return err
}
e.Status(http.StatusOK)
return nil
})
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package catalog
import (
"encoding/json/v2"
"fmt"
"os"
"slices"
)
// The canonical provider names, exactly as they appear in catalog
// files, ledger facts, and API responses.
const (
ProviderStripe = "stripe"
ProviderApple = "apple"
ProviderGoogle = "google"
)
// Providers lists every payment provider this deployment reconciles.
var Providers = []string{ProviderStripe, ProviderApple, ProviderGoogle}
// Product kinds: what buying the product means.
const (
// KindSubscription is a recurring purchase that lapses.
KindSubscription = "subscription"
// KindPurchase is a one-time purchase, owned once paid.
KindPurchase = "purchase"
)
// A Product is one sellable thing, whatever storefront it was bought
// in.
type Product struct {
// ID names the product in facts, entitlements, and responses.
ID string `json:"id"`
// Kind is [KindSubscription] or [KindPurchase].
Kind string `json:"kind"`
// Tier ranks subscription products; the highest active tier is
// the subject's plan. Zero is the floor.
Tier int `json:"tier,omitzero"`
// Entitlements are the feature keys the product unlocks.
Entitlements []string `json:"entitlements"`
// SKUs maps provider names onto that provider's identifiers for
// this product: Stripe price IDs, App Store product IDs, Play
// Store product IDs.
SKUs map[string][]string `json:"skus"`
}
// A Catalog is the loaded, validated mapping.
type Catalog struct {
products []Product
bySKU map[[2]string]*Product
}
// Load reads and validates a catalog file: {"products": [...]}.
func Load(file string) (*Catalog, error) {
raw, err := os.ReadFile(file)
if err != nil {
return nil, err
}
var doc struct {
Products []Product `json:"products"`
}
if err := json.Unmarshal(raw, &doc); err != nil {
return nil, fmt.Errorf("failed to parse the catalog: %w", err)
}
return New(doc.Products)
}
// New validates products into a [Catalog].
func New(products []Product) (*Catalog, error) {
c := &Catalog{
products: slices.Clone(products),
bySKU: make(map[[2]string]*Product),
}
ids := make(map[string]bool, len(products))
for i := range c.products {
p := &c.products[i]
fail := func(format string, args ...any) error {
return fmt.Errorf(
"product %q: %s", p.ID, fmt.Sprintf(format, args...),
)
}
if p.ID == "" {
return nil, fmt.Errorf("product %d has no id", i)
}
if ids[p.ID] {
return nil, fail("defined twice")
}
ids[p.ID] = true
if p.Kind != KindSubscription && p.Kind != KindPurchase {
return nil, fail("unknown kind %q", p.Kind)
}
if p.Tier < 0 {
return nil, fail("tier must not be negative")
}
if len(p.Entitlements) == 0 {
return nil, fail("unlocks no entitlements")
}
if slices.Contains(p.Entitlements, "") {
return nil, fail("carries an empty entitlement key")
}
// A key listed twice projects two identical grants, and the
// projection's primary key refuses the second — which fails
// every ingest for every subject holding this product, long
// after the deploy that introduced the typo.
if keys := slices.Clone(p.Entitlements); len(
slices.Compact(slices.Sorted(slices.Values(keys))),
) != len(p.Entitlements) {
return nil, fail("lists an entitlement key twice")
}
if len(p.SKUs) == 0 {
return nil, fail("is sold nowhere: no skus")
}
for provider, skus := range p.SKUs {
if !slices.Contains(Providers, provider) {
return nil, fail("unknown provider %q", provider)
}
if len(skus) == 0 {
return nil, fail("names no skus for %s", provider)
}
for _, sku := range skus {
if sku == "" {
return nil, fail("carries an empty %s sku", provider)
}
key := [2]string{provider, sku}
if prior, dup := c.bySKU[key]; dup {
return nil, fmt.Errorf(
"sku %s/%s maps to both %q and %q",
provider, sku, prior.ID, p.ID,
)
}
c.bySKU[key] = p
}
}
}
return c, nil
}
// Find resolves a provider's SKU onto its product.
func (c *Catalog) Find(provider, sku string) (Product, bool) {
p, ok := c.bySKU[[2]string{provider, sku}]
if !ok {
return Product{}, false
}
return *p, true
}
// Products returns the catalog in file order.
func (c *Catalog) Products() []Product {
return slices.Clone(c.products)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package config
import (
"errors"
"fmt"
"time"
"github.com/deep-rent/nexus/eco/identity"
"github.com/deep-rent/nexus/sys/boot"
"github.com/deep-rent/nexus/sys/env"
)
// Prefix namespaces every environment variable of the service.
const Prefix = "PES_"
// DefaultCatalog is the catalog file path applied when the environment
// names none. It must match the struct tag default on
// [Config.Catalog]; the config test pins the two together.
const DefaultCatalog = "catalog.json"
// MinSweepInterval is the tightest allowed reconciliation cadence.
// Load rejects values below it: the sweep exists to catch missed
// webhooks, and re-reading providers more often than this buys no
// correctness the push leg does not already deliver.
const MinSweepInterval = 5 * time.Minute
// MinRawRetention is the shortest allowed raw-evidence window when
// one is set at all: redacting payloads younger than a day would
// gut the support value they are kept for.
const MinRawRetention = 24 * time.Hour
// Config declares the deployment configuration of the purchase and
// entitlement service. Bind it from the environment with [Load].
type Config struct {
// Core is the listen addresses, CORS origins, log level, and
// connection timeouts every service carries.
boot.Core `env:",inline"`
// Catalog is the path to the JSON file mapping provider SKUs onto
// products and feature keys; see the catalog package for its
// shape.
Catalog string `env:",default:'catalog.json'"`
// SweepInterval is the reconciliation cadence: how often facts
// nearing expiry or gone quiet are re-read from their providers.
SweepInterval time.Duration `env:",default:1h"`
// RawRetention is how long each provider's verbatim payload —
// which can carry personal data — stays readable behind its
// fact before being redacted. The normalized facts themselves
// are kept forever. Zero keeps payloads indefinitely; choose
// that deliberately.
RawRetention time.Duration `env:",default:2160h"`
// Stripe enables the Stripe adapter when fully configured.
Stripe Stripe `env:",prefix:STRIPE_"`
// Apple enables the App Store adapter when fully configured.
Apple Apple `env:",prefix:APPLE_"`
// Google enables the Play Store adapter when fully configured.
Google Google `env:",prefix:GOOGLE_"`
// Hook configures the webhook engine carrying entitlement events
// to registered receivers.
Hook boot.Sender `env:",prefix:HOOK_"`
// Database configures the PostgreSQL connection holding the
// ledger. Required: the ledger is the service's whole point.
Database boot.Database `env:",prefix:DATABASE_"`
// Auth declares the identity provider whose tokens the API
// accepts.
Auth Auth `env:",prefix:AUTH_"`
// Directory declares how this service asks the identity service
// about team roles. Optional: without it the service runs, but
// team-directed purchases are refused — nobody's money should
// move on a role nobody can check.
Directory identity.Config `env:",prefix:DIRECTORY_"`
}
// Stripe configures the Stripe adapter. Both fields together enable
// it; both empty leave it off.
type Stripe struct {
// Secret is the webhook endpoint's signing secret ("whsec_..."),
// shown once in the Stripe dashboard when the endpoint is
// created.
Secret string
// Key is the restricted API key ("rk_..." or "sk_...")
// authorizing subscription reads and cancellations. Subscription
// webhooks trigger authoritative reads, so the key is not
// optional.
Key string
// Base overrides the API origin — for test rigs and mocks.
// Empty means Stripe itself.
Base string
}
// Enabled reports whether the section is configured.
func (c Stripe) Enabled() bool { return c.Secret != "" && c.Key != "" }
// Apple configures the App Store adapter. Both fields together enable
// it; both empty leave it off.
type Apple struct {
// Roots is the path to Apple's root certificates (PEM, or a
// single DER file as distributed on Apple's PKI page), which
// every signed payload must chain to.
Roots string
// Bundle is the app's bundle identifier, pinned on every signed
// payload.
Bundle string
}
// Enabled reports whether the section is configured.
func (c Apple) Enabled() bool { return c.Roots != "" && c.Bundle != "" }
// Google configures the Play Store adapter. All three fields together
// enable it; all empty leave it off.
type Google struct {
// Package is the Android application identifier, pinned on every
// notification.
Package string
// Audience is the audience value carried by the OIDC tokens on
// Pub/Sub pushes — the push endpoint URL, exactly as configured
// on the subscription.
Audience string
// Account is the path to the service-account key file (the JSON
// downloaded from the Google Cloud console) authorizing Play
// Developer API reads.
Account string
}
// Enabled reports whether the section is configured.
func (c Google) Enabled() bool {
return c.Package != "" && c.Audience != "" && c.Account != ""
}
// Auth declares the identity provider whose access tokens the API
// verifies, plus the roles this service reads out of them. The service
// issues no tokens of its own.
type Auth struct {
boot.Auth `env:",inline"`
// Roles names the IAM roles whose members may manage webhook
// subscribers with a delegated token. Machine tokens need only
// the pes:admin scope; a delegated token needs the scope AND one
// of these roles. Empty defaults to "admin" at assembly.
Roles []string
}
// Load binds a [Config] from the environment under [Prefix]. It
// demands a database, rejects values below the documented floors, and
// refuses provider sections that are configured halfway — a webhook
// secret without the API key behind it fails here, not at the first
// notification.
func Load(opts ...env.Option) (Config, error) {
cfg, err := boot.Load[Config](Prefix, opts...)
if err != nil {
return cfg, err
}
if !cfg.Database.Enabled() {
// Named rather than tagged required, so the message says which
// variable to set rather than which field failed to bind.
return cfg, fmt.Errorf("%sDATABASE_URL is not set", Prefix)
}
if cfg.SweepInterval < MinSweepInterval {
return cfg, fmt.Errorf(
"sweep interval %v is below the %v floor",
cfg.SweepInterval, MinSweepInterval,
)
}
if cfg.RawRetention != 0 && cfg.RawRetention < MinRawRetention {
return cfg, fmt.Errorf(
"raw retention %v is below the %v floor",
cfg.RawRetention, MinRawRetention,
)
}
if !cfg.Stripe.Enabled() &&
(cfg.Stripe.Secret != "" || cfg.Stripe.Key != "") {
return cfg, errors.New(
"the Stripe secret and key must be configured together",
)
}
if !cfg.Apple.Enabled() &&
(cfg.Apple.Roots != "" || cfg.Apple.Bundle != "") {
return cfg, errors.New(
"the Apple roots and bundle must be configured together",
)
}
if !cfg.Google.Enabled() && (cfg.Google.Package != "" ||
cfg.Google.Audience != "" || cfg.Google.Account != "") {
return cfg, errors.New(
"the Google package, audience, and account must be " +
"configured together",
)
}
return cfg, nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package entitle
import (
"slices"
"strings"
"time"
"github.com/deep-rent/nexus/eco/pes/catalog"
"github.com/deep-rent/nexus/eco/pes/ledger"
)
// Project reduces a subject's current facts — the newest per purchase,
// as [ledger.Store.Current] returns them — to the grants they justify.
// Facts whose SKU the catalog no longer names are skipped: a retired
// product grants nothing, however the ledger remembers it.
//
// The result is deterministic and canonically ordered, so equal
// inputs produce equal projections wherever they run.
func Project(
facts []ledger.Fact,
cat *catalog.Catalog,
) []ledger.Entitlement {
var out []ledger.Entitlement
for _, f := range facts {
if !f.Granting() {
continue
}
product, ok := cat.Find(f.Provider, f.SKU)
if !ok {
continue
}
for _, key := range product.Entitlements {
out = append(out, ledger.Entitlement{
Subject: f.Subject,
Key: key,
Product: product.ID,
Provider: f.Provider,
Ref: f.Ref,
NotBefore: f.Starts,
NotAfter: f.Ends,
})
}
}
slices.SortFunc(out, func(a, b ledger.Entitlement) int {
if c := strings.Compare(a.Key, b.Key); c != 0 {
return c
}
if c := strings.Compare(a.Provider, b.Provider); c != 0 {
return c
}
return strings.Compare(a.Ref, b.Ref)
})
return out
}
// Keys reduces grants to the distinct feature keys valid at the given
// moment — the hot answer the app asks for.
func Keys(grants []ledger.Entitlement, at time.Time) []string {
var out []string
for _, g := range grants {
if !g.Valid(at) {
continue
}
if !slices.Contains(out, g.Key) {
out = append(out, g.Key)
}
}
slices.Sort(out)
return out
}
// Plan resolves the subject's plan: among the subscription products
// standing behind grants valid at the given moment, the one with the
// highest tier — ties broken by product ID for determinism. A subject
// on no subscription has no plan and gets the zero product.
func Plan(
grants []ledger.Entitlement,
cat *catalog.Catalog,
at time.Time,
) (catalog.Product, bool) {
var best catalog.Product
found := false
for _, p := range cat.Products() {
if p.Kind != catalog.KindSubscription {
continue
}
if !covered(grants, p.ID, at) {
continue
}
if !found || p.Tier > best.Tier ||
(p.Tier == best.Tier && p.ID < best.ID) {
best = p
found = true
}
}
return best, found
}
// covered reports whether any grant of the product is valid at the
// given moment.
func covered(
grants []ledger.Entitlement,
product string,
at time.Time,
) bool {
for _, g := range grants {
if g.Product == product && g.Valid(at) {
return true
}
}
return false
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package ledger
import (
"time"
"uuid"
)
// Fact statuses: the unified vocabulary every provider's states reduce
// to. The reduction is deliberately lossy — Apple's billing grace and
// Stripe's past_due both land on [StatusGrace] — which is what makes
// the vocabulary usable; the verbatim provider evidence stays in
// [Fact.Raw] for the cases where the difference matters.
const (
// StatusActive is a paid, running subscription or a completed
// purchase.
StatusActive = "active"
// StatusGrace is a payment hiccup the provider is still retrying;
// access customarily continues.
StatusGrace = "grace"
// StatusHold is a payment failure past its grace; access
// customarily pauses until payment recovers.
StatusHold = "hold"
// StatusCanceled is a subscription set to lapse at period end,
// still paid until then.
StatusCanceled = "canceled"
// StatusExpired is a lapsed subscription.
StatusExpired = "expired"
// StatusRefunded is a purchase or period the provider clawed
// back; access ends at once.
StatusRefunded = "refunded"
)
// Fact kinds.
const (
// KindSubscription marks evidence about a recurring purchase.
KindSubscription = "subscription"
// KindPurchase marks evidence about a one-time purchase.
KindPurchase = "purchase"
)
// A Fact is one normalized observation of provider truth. Every
// ingestion path — webhook, reconciliation sweep, client attestation —
// produces exactly this.
type Fact struct {
// ID is the ledger key, assigned on insert.
ID int64
// Provider is the canonical provider name; see [catalog.Providers].
Provider string
// Ref is the provider's identity for the purchase this fact is
// about: a Stripe subscription ID, an App Store original
// transaction ID, a Play Store purchase token.
//
// The Play token is the live credential the Play Developer API
// reads a purchase by, and it is stored as it is because the pull
// leg needs it. Treat this column as a secret: it is safe in the
// database beside everything else here, and it does not belong in
// a log line or an error string. The Stripe and Apple identifiers
// are opaque references rather than credentials.
Ref string
// Seq orders facts about one Ref by the provider's own clock:
// event creation, signedDate, notification time — whatever the
// provider guarantees monotone per purchase. The highest Seq per
// Ref is current.
Seq int64
// Subject is the IAM principal — user or team — the purchase
// belongs to.
Subject uuid.UUID
// SKU is the provider's product identifier, resolved through the
// catalog.
SKU string
// Kind is [KindSubscription] or [KindPurchase].
Kind string
// Status is the unified state; see the Status constants.
Status string
// Starts is when the covered period began.
Starts time.Time
// Ends is when the covered period runs out; zero for a perpetual
// one-time purchase.
Ends time.Time
// Renews reports whether the provider expects to renew the period.
Renews bool
// Observed is when this service ingested the evidence.
Observed time.Time
// Raw is the verbatim provider evidence — the webhook body, the
// signed transaction — kept for support and replay. It may carry
// personal data; retention applies.
Raw []byte
}
// Granting reports whether the fact's status grants access while its
// period covers the asking moment. Canceled still grants: the period
// is paid through its end.
func (f Fact) Granting() bool {
switch f.Status {
case StatusActive, StatusGrace, StatusCanceled:
return true
default:
return false
}
}
// An Entitlement is one projected grant: a feature key valid for a
// window, traceable to the purchase that granted it.
type Entitlement struct {
// Subject is the IAM principal holding the grant.
Subject uuid.UUID
// Key is the feature key, from the catalog.
Key string
// Product is the catalog product that granted it.
Product string
// Provider and Ref trace the grant to its purchase.
Provider string
Ref string
// NotBefore and NotAfter bound the validity window; a zero
// NotAfter never lapses.
NotBefore time.Time
NotAfter time.Time
}
// Valid reports whether the grant covers the given moment.
func (e Entitlement) Valid(at time.Time) bool {
if at.Before(e.NotBefore) {
return false
}
return e.NotAfter.IsZero() || at.Before(e.NotAfter)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package ledger
import (
"context"
"database/sql"
"embed"
"fmt"
"io/fs"
"time"
"uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/deep-rent/nexus/dat/migrate"
driver "github.com/deep-rent/nexus/dat/migrate/driver/postgres"
source "github.com/deep-rent/nexus/dat/migrate/source/file"
)
//go:embed migrations
var migrations embed.FS
// Module is the migration stream the ledger schema lives in.
const Module = "pes"
// Migrations exposes the embedded schema migrations for [migrate]
// paired with its PostgreSQL driver.
func Migrations() fs.FS {
sub, err := fs.Sub(migrations, "migrations")
if err != nil {
// The subdirectory is embedded at compile time; failing to
// open it is a build defect, not a runtime condition.
panic(err)
}
return sub
}
// Migrator wires a [migrate.Migrator] for the ledger schema over an
// existing database handle. The module, source, and driver are this
// schema's to declare; opts carry what the caller legitimately varies.
func Migrator(db *sql.DB, opts ...migrate.Option) *migrate.Migrator {
return migrate.New(append([]migrate.Option{
migrate.WithModule(Module),
migrate.WithSource(source.New(Migrations())),
migrate.WithDriver(driver.New(db)),
}, opts...)...)
}
// Open connects a migrator for the ledger schema to the database at
// url, for commands that only run migrations. The returned close
// function releases the connection.
func Open(
url string,
opts ...migrate.Option,
) (*migrate.Migrator, func() error, error) {
return driver.Open(url, append([]migrate.Option{
migrate.WithModule(Module),
migrate.WithSource(source.New(Migrations())),
}, opts...)...)
}
// Store persists facts and the entitlement projection. It is safe for
// concurrent use and carries no logger: every operation returns its
// error, and narrating outcomes is its callers' business.
type Store struct {
pool *pgxpool.Pool
}
// New creates a [Store] over the given connection pool. It panics on a
// nil pool (programmer error).
func New(pool *pgxpool.Pool) *Store {
if pool == nil {
panic("pool is required")
}
return &Store{pool: pool}
}
// Exec runs fn within a single transaction.
func (s *Store) Exec(
ctx context.Context,
fn func(ctx context.Context, tx pgx.Tx) error,
) error {
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
return fn(ctx, tx)
})
}
// InsertFacts appends facts, ignoring any whose (provider, ref, seq)
// the ledger already holds — the idempotency every ingestion path
// leans on. It returns how many were fresh.
func (*Store) InsertFacts(
ctx context.Context,
tx pgx.Tx,
facts []Fact,
) (int, error) {
fresh := 0
for _, f := range facts {
var ends *time.Time
if !f.Ends.IsZero() {
at := f.Ends.UTC()
ends = &at
}
tag, err := tx.Exec(ctx, `
INSERT INTO facts (
provider, ref, seq, subject, sku, kind, status,
starts, ends, renews, observed, raw
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (provider, ref, seq) DO NOTHING`,
f.Provider, f.Ref, f.Seq, f.Subject, f.SKU, f.Kind,
f.Status, f.Starts.UTC(), ends, f.Renews,
f.Observed.UTC(), f.Raw,
)
if err != nil {
return fresh, fmt.Errorf("failed to insert a fact: %w", err)
}
fresh += int(tag.RowsAffected())
}
return fresh, nil
}
// Current returns the newest fact per purchase for a subject: the
// highest seq of every (provider, ref) the subject appears in.
func (*Store) Current(
ctx context.Context,
tx pgx.Tx,
subject uuid.UUID,
) ([]Fact, error) {
rows, err := tx.Query(ctx, `
SELECT DISTINCT ON (provider, ref)
id, provider, ref, seq, subject, sku, kind, status,
starts, ends, renews, observed
FROM facts
WHERE subject = $1
ORDER BY provider, ref, seq DESC`,
subject,
)
if err != nil {
return nil, fmt.Errorf("failed to query facts: %w", err)
}
return scanFacts(rows)
}
// History returns every fact recorded for one purchase, newest first,
// including the raw evidence — the support view.
func (*Store) History(
ctx context.Context,
tx pgx.Tx,
provider, ref string,
) ([]Fact, error) {
rows, err := tx.Query(ctx, `
SELECT
id, provider, ref, seq, subject, sku, kind, status,
starts, ends, renews, observed
FROM facts
WHERE provider = $1 AND ref = $2
ORDER BY seq DESC`,
provider, ref,
)
if err != nil {
return nil, fmt.Errorf("failed to query the history: %w", err)
}
return scanFacts(rows)
}
// Stale lists the purchases whose current period ends before the
// horizon or whose newest evidence is older than the given age —
// the reconciliation sweep's worklist. Purchases already expired,
// refunded, or perpetual are not worth re-reading.
//
// providers names the adapters that can actually re-read a purchase;
// rows belonging to anything else are left out. That is not a
// refinement but a correctness requirement: the worklist is the oldest
// rows by observation, and a row only leaves the head once a re-read
// gives it a newer observation. Rows nobody can re-read never do, so
// they accumulate at the head and crowd out every purchase that could
// have been refreshed — silently, since the sweep still reports itself
// as having run. An empty list selects nothing.
func (*Store) Stale(
ctx context.Context,
tx pgx.Tx,
horizon time.Time,
before time.Time,
limit int,
providers []string,
) ([]Fact, error) {
rows, err := tx.Query(ctx, `
SELECT * FROM (
SELECT DISTINCT ON (provider, ref)
id, provider, ref, seq, subject, sku, kind, status,
starts, ends, renews, observed
FROM facts
ORDER BY provider, ref, seq DESC
) latest
WHERE status NOT IN ('expired', 'refunded')
AND ends IS NOT NULL
AND (ends < $1 OR observed < $2)
AND provider = ANY($4)
ORDER BY observed
LIMIT $3`,
horizon.UTC(), before.UTC(), limit, providers,
)
if err != nil {
return nil, fmt.Errorf("failed to query stale purchases: %w", err)
}
return scanFacts(rows)
}
// Subjects lists every subject the ledger has facts about, for a full
// projection rebuild.
func (*Store) Subjects(
ctx context.Context,
tx pgx.Tx,
) ([]uuid.UUID, error) {
rows, err := tx.Query(ctx,
`SELECT DISTINCT subject FROM facts ORDER BY subject`,
)
if err != nil {
return nil, fmt.Errorf("failed to list subjects: %w", err)
}
defer rows.Close()
var out []uuid.UUID
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("failed to scan a subject: %w", err)
}
out = append(out, id)
}
return out, rows.Err()
}
// Claim serializes every projection of one subject against the other
// transactions projecting the same subject, and must be the first
// statement of any such transaction.
//
// Projecting is a read-modify-write — read the grants, read the facts,
// delete, insert — and the ledger runs at READ COMMITTED, where every
// statement takes its own snapshot. Two ingests for one subject
// therefore interleave: each reads facts the other has not committed,
// each deletes rows the other's DELETE could not see, and the survivor
// is a projection matching no consistent set of facts. It is not a
// rare window. Stripe fires several events per checkout within the
// same second, the intake serves them concurrently, and the sweep runs
// beside both.
//
// The lock is an advisory one on the subject, held to the end of the
// transaction, so it costs a hash and no row contention. Subjects that
// differ never wait on each other.
func (*Store) Claim(
ctx context.Context,
tx pgx.Tx,
subject uuid.UUID,
) error {
if _, err := tx.Exec(ctx,
`SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))`,
subject,
); err != nil {
return fmt.Errorf("failed to claim the subject: %w", err)
}
return nil
}
// ReplaceEntitlements swaps a subject's projection for the given
// rows, atomically within the caller's transaction. The caller must
// hold the subject's claim; see [Store.Claim].
func (*Store) ReplaceEntitlements(
ctx context.Context,
tx pgx.Tx,
subject uuid.UUID,
grants []Entitlement,
) error {
if _, err := tx.Exec(ctx,
`DELETE FROM entitlements WHERE subject = $1`, subject,
); err != nil {
return fmt.Errorf("failed to clear the projection: %w", err)
}
for _, g := range grants {
var after *time.Time
if !g.NotAfter.IsZero() {
at := g.NotAfter.UTC()
after = &at
}
if _, err := tx.Exec(ctx, `
INSERT INTO entitlements (
subject, key, product, provider, ref,
not_before, not_after
)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT DO NOTHING`,
subject, g.Key, g.Product, g.Provider, g.Ref,
g.NotBefore.UTC(), after,
); err != nil {
return fmt.Errorf("failed to write a grant: %w", err)
}
}
return nil
}
// Entitlements returns a subject's projected grants, windows and all;
// callers filter by [Entitlement.Valid] for "now".
func (*Store) Entitlements(
ctx context.Context,
tx pgx.Tx,
subject uuid.UUID,
) ([]Entitlement, error) {
rows, err := tx.Query(ctx, `
SELECT subject, key, product, provider, ref,
not_before, COALESCE(not_after, 'epoch'::timestamptz)
FROM entitlements
WHERE subject = $1
ORDER BY key, product`,
subject,
)
if err != nil {
return nil, fmt.Errorf("failed to query entitlements: %w", err)
}
defer rows.Close()
var out []Entitlement
for rows.Next() {
var e Entitlement
if err := rows.Scan(
&e.Subject, &e.Key, &e.Product, &e.Provider, &e.Ref,
&e.NotBefore, &e.NotAfter,
); err != nil {
return nil, fmt.Errorf("failed to scan a grant: %w", err)
}
if e.NotAfter.Unix() == 0 {
e.NotAfter = time.Time{}
}
out = append(out, e)
}
return out, rows.Err()
}
// Raw returns the verbatim evidence behind one fact — the support
// escape hatch, deliberately separate from the listings so payloads
// with personal data are read on purpose, not by the way.
func (*Store) Raw(
ctx context.Context,
tx pgx.Tx,
id int64,
) ([]byte, error) {
var raw []byte
err := tx.QueryRow(ctx,
`SELECT raw FROM facts WHERE id = $1`, id,
).Scan(&raw)
if err != nil {
return nil, fmt.Errorf("failed to read the evidence: %w", err)
}
return raw, nil
}
// Redact clears the verbatim evidence behind facts observed before
// the cutoff, returning how many payloads it cleared. The normalized
// fact — the ledger's actual truth — stays forever; what goes is the
// provider's original payload, which can carry personal data that
// has no business being kept once its support value has lapsed.
func (s *Store) Redact(
ctx context.Context,
before time.Time,
) (int64, error) {
tag, err := s.pool.Exec(ctx, `
UPDATE facts
SET raw = NULL
WHERE observed < $1 AND raw IS NOT NULL`,
before.UTC(),
)
if err != nil {
return 0, fmt.Errorf("failed to redact evidence: %w", err)
}
return tag.RowsAffected(), nil
}
// scanFacts drains a fact query. Raw is deliberately not selected by
// the listings; see [Store.Raw].
//
// The end of a period is read as the nullable column it is rather than
// through a sentinel. An earlier version collapsed NULL onto the epoch
// and mapped Unix()==0 back to the zero time, which made "perpetual"
// and "ended at the epoch" the same value — so a provider that lost a
// period end granted access forever instead of failing loudly.
func scanFacts(rows pgx.Rows) ([]Fact, error) {
defer rows.Close()
var out []Fact
for rows.Next() {
var f Fact
var ends *time.Time
if err := rows.Scan(
&f.ID, &f.Provider, &f.Ref, &f.Seq, &f.Subject, &f.SKU,
&f.Kind, &f.Status, &f.Starts, &ends, &f.Renews,
&f.Observed,
); err != nil {
return nil, fmt.Errorf("failed to scan a fact: %w", err)
}
if ends != nil {
f.Ends = *ends
}
out = append(out, f)
}
return out, rows.Err()
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package apple
import (
"context"
"crypto/x509"
"encoding/json/v2"
"fmt"
"io"
"net/http"
"time"
"uuid"
"github.com/deep-rent/nexus/eco/pes/catalog"
"github.com/deep-rent/nexus/eco/pes/ledger"
"github.com/deep-rent/nexus/eco/pes/provider"
"github.com/deep-rent/nexus/sec/jose/jwa"
"github.com/deep-rent/nexus/sec/jose/x5c"
"github.com/deep-rent/nexus/std/clock"
)
// MaxBody caps an inbound notification body.
const MaxBody = 1 << 20
// kindSubscription is Apple's type label for auto-renewable
// subscriptions; every other type is a one-time product.
const kindSubscription = "Auto-Renewable Subscription"
// Option configures the adapter.
type Option func(*Adapter)
// WithClock injects the time source, which is primarily useful for
// testing. A nil clock is ignored.
func WithClock(now clock.Clock) Option {
return func(a *Adapter) {
if now != nil {
a.now = now
}
}
}
// Adapter reconciles the App Store. Every piece of evidence — server
// notifications and client-attested transactions alike — is a JWS
// carrying its certificate chain, verified against the pinned Apple
// root before a byte of it is believed.
type Adapter struct {
trust *x5c.Trust
bundle string
now clock.Clock
}
// New builds the adapter over the pinned Apple root certificates and
// the deployment's bundle identifier; evidence for any other bundle
// is refused. Both are required.
func New(roots *x509.CertPool, bundle string, opts ...Option) *Adapter {
if roots == nil {
panic("root certificates are required")
}
if bundle == "" {
panic("bundle identifier is required")
}
a := &Adapter{bundle: bundle, now: clock.System}
for _, opt := range opts {
opt(a)
}
// Built once the options are in, since the clock is one of them.
// Apple signs with ECDSA and says so in every payload; naming the
// family here means a header claiming anything else is refused
// before its chain is even parsed.
a.trust = x5c.New(roots,
x5c.WithClock(a.now),
x5c.WithAlgorithms(
jwa.ES256.String(), jwa.ES384.String(), jwa.ES512.String(),
),
)
return a
}
// Name implements [provider.Provider].
func (*Adapter) Name() string { return catalog.ProviderApple }
// The slices of Apple's payloads this adapter reads. Timestamps are
// milliseconds since the epoch throughout.
type (
envelope struct {
SignedPayload string `json:"signedPayload"`
}
notification struct {
NotificationType string `json:"notificationType"`
Subtype string `json:"subtype"`
SignedDate int64 `json:"signedDate"`
Data struct {
BundleID string `json:"bundleId"`
SignedTransaction string `json:"signedTransactionInfo"`
SignedRenewal string `json:"signedRenewalInfo"`
} `json:"data"`
}
transaction struct {
OriginalTransactionID string `json:"originalTransactionId"`
ProductID string `json:"productId"`
BundleID string `json:"bundleId"`
Type string `json:"type"`
AppAccountToken string `json:"appAccountToken"`
PurchaseDate int64 `json:"purchaseDate"`
ExpiresDate int64 `json:"expiresDate"`
RevocationDate int64 `json:"revocationDate"`
SignedDate int64 `json:"signedDate"`
}
renewal struct {
AutoRenewStatus int `json:"autoRenewStatus"`
GracePeriodExpiry int64 `json:"gracePeriodExpiresDate"`
}
)
// Hear implements [provider.Provider]: it opens the notification's
// signed payload, then the signed transaction inside it — each JWS
// verified against the pinned root — and normalizes the result.
func (a *Adapter) Hear(
_ context.Context,
r *http.Request,
) ([]ledger.Fact, error) {
body, err := io.ReadAll(io.LimitReader(r.Body, MaxBody+1))
if err != nil {
return nil, fmt.Errorf("failed to read the body: %w", err)
}
if len(body) > MaxBody {
return nil, fmt.Errorf("%w: oversized body", provider.ErrSignature)
}
var env envelope
if err := json.Unmarshal(body, &env); err != nil {
return nil, fmt.Errorf("%w: unparsable envelope",
provider.ErrSignature)
}
payload, err := a.open([]byte(env.SignedPayload))
if err != nil {
return nil, err
}
var note notification
if err := json.Unmarshal(payload, ¬e); err != nil {
return nil, fmt.Errorf("failed to parse the notification: %w", err)
}
if note.Data.BundleID != a.bundle {
return nil, fmt.Errorf(
"%w: notification for bundle %q",
provider.ErrSignature, note.Data.BundleID,
)
}
if note.Data.SignedTransaction == "" {
// TEST and other transaction-less notifications verify fine
// and mean nothing to the ledger.
return nil, nil
}
rawTxn, err := a.open([]byte(note.Data.SignedTransaction))
if err != nil {
return nil, err
}
var txn transaction
if err := json.Unmarshal(rawTxn, &txn); err != nil {
return nil, fmt.Errorf("failed to parse the transaction: %w", err)
}
var renew *renewal
if note.Data.SignedRenewal != "" {
rawRenew, err := a.open([]byte(note.Data.SignedRenewal))
if err != nil {
return nil, err
}
renew = &renewal{}
if err := json.Unmarshal(rawRenew, renew); err != nil {
return nil, fmt.Errorf("failed to parse the renewal: %w", err)
}
}
f, err := a.fact(txn, renew, ¬e, note.SignedDate, body)
if err != nil {
return nil, err
}
return []ledger.Fact{f}, nil
}
// Attest implements [provider.Provider]: the proof is the signed
// transaction StoreKit hands the app, verified exactly like the one
// inside a notification. Renewal intent is not part of a transaction,
// so an unexpired subscription reads as renewing until the next
// notification says otherwise.
func (a *Adapter) Attest(
_ context.Context,
proof string,
) ([]ledger.Fact, error) {
raw, err := a.open([]byte(proof))
if err != nil {
return nil, err
}
var txn transaction
if err := json.Unmarshal(raw, &txn); err != nil {
return nil, fmt.Errorf("failed to parse the transaction: %w", err)
}
if txn.BundleID != a.bundle {
return nil, fmt.Errorf(
"%w: transaction for bundle %q",
provider.ErrSignature, txn.BundleID,
)
}
f, err := a.fact(txn, nil, nil, txn.SignedDate, []byte(proof))
if err != nil {
return nil, err
}
return []ledger.Fact{f}, nil
}
// This adapter deliberately does not implement [provider.Fetcher]:
// the App Store Server API leg is not written yet, so client
// attestation and notifications carry the reconciliation load. Saying
// so structurally rather than by refusing the call keeps Apple
// purchases out of the sweep's worklist, where they could only ever
// have crowded out work that can be done.
// open verifies one JWS against the pinned roots. The payloads are
// JWS bodies rather than JWT claim sets — a transaction carries no
// issuer, audience, or expiry — so they take the byte-level path
// rather than the resolver.
func (a *Adapter) open(token []byte) ([]byte, error) {
payload, _, err := a.trust.Verify(token)
if err != nil {
return nil, fmt.Errorf("%w: %w", provider.ErrSignature, err)
}
return payload, nil
}
// fact reduces a verified transaction — and, when present, its
// renewal info and enclosing notification — to a ledger fact.
func (a *Adapter) fact(
txn transaction,
renew *renewal,
note *notification,
seq int64,
raw []byte,
) (ledger.Fact, error) {
subject, err := uuid.Parse(txn.AppAccountToken)
if err != nil {
return ledger.Fact{}, fmt.Errorf(
"transaction %s names no subject in appAccountToken: %w",
txn.OriginalTransactionID, err,
)
}
f := ledger.Fact{
Provider: a.Name(),
Ref: txn.OriginalTransactionID,
Seq: seq,
Subject: subject,
SKU: txn.ProductID,
Kind: ledger.KindPurchase,
Status: ledger.StatusActive,
Starts: millis(txn.PurchaseDate),
Observed: a.now().UTC(),
Raw: raw,
}
if txn.Type == kindSubscription {
f.Kind = ledger.KindSubscription
f.Ends = millis(txn.ExpiresDate)
f.Renews = true
if renew != nil {
f.Renews = renew.AutoRenewStatus == 1
if !f.Renews {
f.Status = ledger.StatusCanceled
}
}
}
switch {
case txn.RevocationDate > 0:
f.Status = ledger.StatusRefunded
f.Renews = false
case note != nil:
switch note.NotificationType {
case "EXPIRED":
f.Status = ledger.StatusExpired
f.Renews = false
case "REFUND", "REVOKE":
f.Status = ledger.StatusRefunded
f.Renews = false
case "DID_FAIL_TO_RENEW":
f.Status = ledger.StatusHold
if note.Subtype == "GRACE_PERIOD" {
f.Status = ledger.StatusGrace
if renew != nil && renew.GracePeriodExpiry > f.Ends.
UnixMilli() {
f.Ends = millis(renew.GracePeriodExpiry)
}
}
}
case f.Kind == ledger.KindSubscription &&
!f.Ends.IsZero() && !f.Ends.After(a.now()):
// An attested transaction whose period already ran out.
f.Status = ledger.StatusExpired
f.Renews = false
}
return f, nil
}
// millis converts Apple's millisecond stamps; zero stays zero.
func millis(ms int64) time.Time {
if ms == 0 {
return time.Time{}
}
return time.UnixMilli(ms).UTC()
}
var _ provider.Provider = (*Adapter)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package apple
import (
"crypto/x509"
"errors"
"fmt"
"os"
)
// LoadRoots reads Apple's root certificates into the pool [New] pins
// chains against. The file may hold PEM CERTIFICATE blocks, or a
// single DER certificate exactly as Apple distributes its roots
// (apple.com/certificateauthority): the "Apple Root CA - G3" download
// is such a DER file.
func LoadRoots(file string) (*x509.CertPool, error) {
raw, err := os.ReadFile(file)
if err != nil {
return nil, err
}
pool := x509.NewCertPool()
if pool.AppendCertsFromPEM(raw) {
return pool, nil
}
cert, err := x509.ParseCertificate(raw)
if err != nil {
return nil, errors.Join(fmt.Errorf(
"%s holds neither PEM nor DER certificates", file,
), err)
}
pool.AddCert(cert)
return pool, nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package google
import (
"crypto/rsa"
"crypto/x509"
"encoding/json/v2"
"encoding/pem"
"errors"
"fmt"
"os"
"github.com/deep-rent/nexus/sec/jose/jwa"
"github.com/deep-rent/nexus/sec/jose/jwk"
"github.com/deep-rent/nexus/sec/sign"
)
// LoadAccount reads a service-account key file — the JSON downloaded
// from the Google Cloud console, unaltered — and returns the account
// email and its signing key, the exact pair [New] takes. Google issues
// RSA keys and accepts RS256 grants, so the key comes back bound to
// that algorithm.
func LoadAccount(file string) (string, jwk.KeyPair, error) {
raw, err := os.ReadFile(file)
if err != nil {
return "", nil, err
}
var doc struct {
Email string `json:"client_email"`
KeyID string `json:"private_key_id"`
PEM string `json:"private_key"`
}
if err := json.Unmarshal(raw, &doc); err != nil {
return "", nil, fmt.Errorf(
"failed to parse the service-account file: %w", err,
)
}
if doc.Email == "" || doc.PEM == "" {
return "", nil, errors.New(
"the service-account file names no email or private key",
)
}
block, _ := pem.Decode([]byte(doc.PEM))
if block == nil {
return "", nil, errors.New("the private key is not PEM")
}
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return "", nil, fmt.Errorf(
"failed to parse the private key: %w", err,
)
}
rsaKey, ok := parsed.(*rsa.PrivateKey)
if !ok {
return "", nil, fmt.Errorf(
"the private key is %T; Google issues RSA keys", parsed,
)
}
return doc.Email, jwk.NewKeyPair(
jwa.RS256, doc.KeyID, sign.From(rsaKey),
), nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package google
import (
"context"
"encoding/json/v2"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"uuid"
"github.com/deep-rent/nexus/eco/pes/catalog"
"github.com/deep-rent/nexus/eco/pes/ledger"
"github.com/deep-rent/nexus/eco/pes/provider"
"github.com/deep-rent/nexus/sec/jose/jwk"
"github.com/deep-rent/nexus/sec/jose/jwt"
"github.com/deep-rent/nexus/sec/token"
"github.com/deep-rent/nexus/sec/token/oauth"
"github.com/deep-rent/nexus/std/clock"
)
// Defaults for the endpoints tests inject fakes over.
const (
// DefaultBase is the Android Publisher API origin.
DefaultBase = "https://androidpublisher.googleapis.com"
// DefaultTokenURL exchanges service-account grants for access
// tokens.
DefaultTokenURL = "https://oauth2.googleapis.com/token"
// Scope is the OAuth scope every API read runs under.
Scope = "https://www.googleapis.com/auth/androidpublisher"
)
// MaxBody caps an inbound push body.
const MaxBody = 1 << 20
// Attestation proof prefixes: a client attests a subscription token
// as "s:<token>" and a one-time product as "p:<sku>:<token>", since a
// bare purchase token does not say which API resolves it.
const (
ProofSubscription = "s:"
ProofProduct = "p:"
)
// Pusher verifies the OIDC bearer on a Pub/Sub push request. It is
// the [jwt.Verifier] the service builds over Google's JWKS with the
// push audience pinned; tests substitute their own issuer.
type Pusher interface {
Verify(in []byte) (*jwt.Reserved, error)
}
// Option configures the adapter.
type Option func(*Adapter)
// WithBase points API reads somewhere else, which is how tests stand
// in for Google. A blank base is ignored.
func WithBase(base string) Option {
return func(a *Adapter) {
if base != "" {
a.base = strings.TrimSuffix(base, "/")
}
}
}
// WithTokenURL points the service-account exchange somewhere else.
func WithTokenURL(u string) Option {
return func(a *Adapter) {
if u != "" {
a.tokenURL = u
}
}
}
// WithClient sets the HTTP client used for API reads.
func WithClient(c *http.Client) Option {
return func(a *Adapter) {
if c != nil {
a.client = c
}
}
}
// WithClock injects the time source, which is primarily useful for
// testing. A nil clock is ignored.
func WithClock(now clock.Clock) Option {
return func(a *Adapter) {
if now != nil {
a.now = now
}
}
}
// Adapter reconciles the Play Store. Inbound real-time developer
// notifications arrive as Pub/Sub pushes authenticated by Google-
// signed OIDC tokens; they carry no entitlement detail, so every
// notification turns into an authoritative API read under the
// service account.
type Adapter struct {
pkg string
push Pusher
account string // service-account email; the grant's issuer
key jwk.KeyPair // signs the service-account grant
base string
tokenURL string
client *http.Client
now clock.Clock
// source mints and caches the access token the API reads run
// under; built once the options have settled.
source *token.Source
}
// New builds the adapter for one Android package. The push verifier
// authenticates inbound notifications; the service account (email and
// signing key) authorizes API reads.
func New(
pkg string,
push Pusher,
account string,
key jwk.KeyPair,
opts ...Option,
) *Adapter {
if pkg == "" {
panic("package name is required")
}
if push == nil {
panic("push verifier is required")
}
if account == "" || key == nil {
panic("service account is required")
}
a := &Adapter{
pkg: pkg,
push: push,
account: account,
key: key,
base: DefaultBase,
tokenURL: DefaultTokenURL,
client: http.DefaultClient,
now: clock.System,
}
for _, opt := range opts {
opt(a)
}
a.source = oauth.ServiceAccount(oauth.Account{
Endpoint: a.tokenURL,
Issuer: a.account,
Scope: Scope,
Key: a.key,
Client: a.client,
Clock: a.now,
})
return a
}
// Name implements [provider.Provider].
func (*Adapter) Name() string { return catalog.ProviderGoogle }
// The slices of Google's payloads this adapter reads.
type (
push struct {
Message struct {
Data []byte `json:"data"` // base64 of the notification
} `json:"message"`
}
rtdn struct {
Package string `json:"packageName"`
EventTime string `json:"eventTimeMillis"`
Subscription *struct {
Token string `json:"purchaseToken"`
} `json:"subscriptionNotification"`
OneTime *struct {
Token string `json:"purchaseToken"`
SKU string `json:"sku"`
} `json:"oneTimeProductNotification"`
Test *struct{} `json:"testNotification"`
}
subscriptionV2 struct {
State string `json:"subscriptionState"`
StartTime string `json:"startTime"`
LineItems []struct {
ProductID string `json:"productId"`
Expiry string `json:"expiryTime"`
AutoRenew *struct {
Enabled bool `json:"autoRenewEnabled"`
} `json:"autoRenewingPlan"`
} `json:"lineItems"`
Accounts struct {
Obfuscated string `json:"obfuscatedExternalAccountId"`
} `json:"externalAccountIdentifiers"`
}
productPurchase struct {
State int `json:"purchaseState"` // 0 bought, 1 canceled
Time string `json:"purchaseTimeMillis"`
Obfuscated string `json:"obfuscatedExternalAccountId"`
}
)
// Hear implements [provider.Provider]: it authenticates the push by
// its OIDC bearer, decodes the notification, and — because a
// notification names the purchase but tells nothing about it — reads
// the purchase back from the API to produce facts.
func (a *Adapter) Hear(
ctx context.Context,
r *http.Request,
) ([]ledger.Fact, error) {
bearer, ok := strings.CutPrefix(
r.Header.Get("Authorization"), "Bearer ",
)
if !ok {
return nil, fmt.Errorf("%w: no bearer", provider.ErrSignature)
}
if _, err := a.push.Verify([]byte(bearer)); err != nil {
return nil, fmt.Errorf("%w: %w", provider.ErrSignature, err)
}
body, err := io.ReadAll(io.LimitReader(r.Body, MaxBody+1))
if err != nil {
return nil, fmt.Errorf("failed to read the body: %w", err)
}
if len(body) > MaxBody {
return nil, fmt.Errorf("%w: oversized body", provider.ErrSignature)
}
var p push
if err := json.Unmarshal(body, &p); err != nil {
return nil, fmt.Errorf("failed to parse the push: %w", err)
}
var note rtdn
if err := json.Unmarshal(p.Message.Data, ¬e); err != nil {
return nil, fmt.Errorf("failed to parse the notification: %w", err)
}
if note.Package != a.pkg {
return nil, fmt.Errorf(
"%w: notification for package %q",
provider.ErrSignature, note.Package,
)
}
// The notification is a TRIGGER, never evidence: everything below
// re-reads the purchase authoritatively, so the fact is sequenced
// at the read's own clock exactly as [Adapter.Fetch] does.
//
// Taking the sequence from eventTimeMillis instead would let
// whoever posted the body choose it. That body is not covered by
// the bearer token, so a caller naming their own purchase token
// with a far-future eventTimeMillis could pin an "active" fact
// above every later refund — the ledger reads the highest
// sequence per purchase — and keep the entitlement for good.
seq := a.now().UnixMilli()
switch {
case note.Test != nil:
return nil, nil
case note.Subscription != nil:
return a.subscription(ctx, note.Subscription.Token, seq)
case note.OneTime != nil:
return a.product(ctx, note.OneTime.SKU, note.OneTime.Token, seq)
}
return nil, nil
}
// Fetch implements [provider.Provider]: the ref is a subscription
// purchase token, re-read authoritatively. A re-read sequences at the
// read's own clock, always the newest word.
func (a *Adapter) Fetch(
ctx context.Context,
ref string,
) ([]ledger.Fact, error) {
return a.subscription(ctx, ref, a.now().UnixMilli())
}
// Attest implements [provider.Provider]; see the proof prefixes.
func (a *Adapter) Attest(
ctx context.Context,
proof string,
) ([]ledger.Fact, error) {
seq := a.now().UnixMilli()
if token, ok := strings.CutPrefix(proof, ProofSubscription); ok {
return a.subscription(ctx, token, seq)
}
if rest, ok := strings.CutPrefix(proof, ProofProduct); ok {
sku, token, ok := strings.Cut(rest, ":")
if !ok {
return nil, fmt.Errorf(
"%w: product proof wants \"p:<sku>:<token>\"",
provider.ErrUnsupported,
)
}
return a.product(ctx, sku, token, seq)
}
return nil, fmt.Errorf(
"%w: unknown proof shape", provider.ErrUnsupported,
)
}
// subscription reads one subscription purchase and reduces it.
func (a *Adapter) subscription(
ctx context.Context,
token string,
seq int64,
) ([]ledger.Fact, error) {
raw, err := a.get(ctx, fmt.Sprintf(
"/androidpublisher/v3/applications/%s/purchases/"+
"subscriptionsv2/tokens/%s",
url.PathEscape(a.pkg), url.PathEscape(token),
))
if err != nil {
return nil, err
}
var sub subscriptionV2
if err := json.Unmarshal(raw, &sub); err != nil {
return nil, fmt.Errorf("failed to parse the subscription: %w", err)
}
subject, err := uuid.Parse(sub.Accounts.Obfuscated)
if err != nil {
return nil, fmt.Errorf(
"purchase names no subject in the obfuscated account: %w", err,
)
}
if len(sub.LineItems) == 0 {
return nil, errors.New("purchase carries no line items")
}
item := sub.LineItems[0]
status := ledger.StatusExpired
renews := false
switch sub.State {
case "SUBSCRIPTION_STATE_ACTIVE":
status = ledger.StatusActive
renews = item.AutoRenew != nil && item.AutoRenew.Enabled
case "SUBSCRIPTION_STATE_IN_GRACE_PERIOD":
status, renews = ledger.StatusGrace, true
case "SUBSCRIPTION_STATE_ON_HOLD",
"SUBSCRIPTION_STATE_PAUSED":
status = ledger.StatusHold
case "SUBSCRIPTION_STATE_CANCELED":
status = ledger.StatusCanceled
}
f := ledger.Fact{
Provider: a.Name(),
Ref: token,
Seq: seq,
Subject: subject,
SKU: item.ProductID,
Kind: ledger.KindSubscription,
Status: status,
Starts: stamp(sub.StartTime),
Ends: stamp(item.Expiry),
Renews: renews,
Observed: a.now().UTC(),
Raw: raw,
}
return []ledger.Fact{f}, nil
}
// product reads one one-time purchase and reduces it.
func (a *Adapter) product(
ctx context.Context,
sku, token string,
seq int64,
) ([]ledger.Fact, error) {
raw, err := a.get(ctx, fmt.Sprintf(
"/androidpublisher/v3/applications/%s/purchases/products/%s/"+
"tokens/%s",
url.PathEscape(a.pkg), url.PathEscape(sku), url.PathEscape(token),
))
if err != nil {
return nil, err
}
var p productPurchase
if err := json.Unmarshal(raw, &p); err != nil {
return nil, fmt.Errorf("failed to parse the purchase: %w", err)
}
subject, err := uuid.Parse(p.Obfuscated)
if err != nil {
return nil, fmt.Errorf(
"purchase names no subject in the obfuscated account: %w", err,
)
}
// Play's purchaseState: 0 purchased, 1 canceled, 2 pending. Only
// a completed purchase grants, so anything else — a deferred
// payment awaiting funds, a state Play adds later — is refused by
// default. The subscription mapping above defaults the same way,
// and a one-time purchase carries no period to expire, so a wrong
// grant here would be perpetual.
var status string
switch p.State {
case 0:
status = ledger.StatusActive
case 1:
status = ledger.StatusRefunded
default:
status = ledger.StatusHold
}
ms, _ := strconv.ParseInt(p.Time, 10, 64)
return []ledger.Fact{{
Provider: a.Name(),
Ref: token,
Seq: seq,
Subject: subject,
SKU: sku,
Kind: ledger.KindPurchase,
Status: status,
Starts: time.UnixMilli(ms).UTC(),
Observed: a.now().UTC(),
Raw: raw,
}}, nil
}
// get performs one authorized API read.
func (a *Adapter) get(ctx context.Context, path string) ([]byte, error) {
bearer, err := a.source.Get(ctx)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(
ctx, http.MethodGet, a.base+path, nil,
)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+bearer)
res, err := a.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to reach the API: %w", err)
}
defer res.Body.Close()
body, err := io.ReadAll(io.LimitReader(res.Body, MaxBody))
if err != nil {
return nil, fmt.Errorf("failed to read the response: %w", err)
}
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("the API answered %d", res.StatusCode)
}
return body, nil
}
// stamp parses an RFC 3339 stamp; empty stays zero.
func stamp(s string) time.Time {
if s == "" {
return time.Time{}
}
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return time.Time{}
}
return t.UTC()
}
var _ provider.Provider = (*Adapter)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package stripe
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json/jsontext"
"encoding/json/v2"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"uuid"
"github.com/deep-rent/nexus/eco/pes/catalog"
"github.com/deep-rent/nexus/eco/pes/ledger"
"github.com/deep-rent/nexus/eco/pes/provider"
"github.com/deep-rent/nexus/std/clock"
)
// DefaultBase is the Stripe API origin.
const DefaultBase = "https://api.stripe.com"
// DefaultTolerance bounds how old a webhook's signed timestamp may be
// before it is refused as a replay.
const DefaultTolerance = 5 * time.Minute
// MaxBody caps an inbound notification body.
const MaxBody = 1 << 20
// Option configures the adapter.
type Option func(*Adapter)
// WithBase points the adapter at a different API origin, which is how
// tests stand in for Stripe. A blank base is ignored.
func WithBase(base string) Option {
return func(a *Adapter) {
if base != "" {
a.base = strings.TrimSuffix(base, "/")
}
}
}
// WithClient sets the HTTP client used for API reads.
func WithClient(c *http.Client) Option {
return func(a *Adapter) {
if c != nil {
a.client = c
}
}
}
// WithClock injects the time source, which is primarily useful for
// testing. A nil clock is ignored.
func WithClock(now clock.Clock) Option {
return func(a *Adapter) {
if now != nil {
a.now = now
}
}
}
// WithTolerance overrides the replay window. Values of zero or less
// are ignored.
func WithTolerance(d time.Duration) Option {
return func(a *Adapter) {
if d > 0 {
a.tolerance = d
}
}
}
// Adapter reconciles Stripe. Inbound webhooks authenticate by HMAC
// under the endpoint's signing secret; API reads authenticate by the
// account's secret key. Subscription webhooks are triggers for an
// authoritative API read rather than evidence themselves, so the
// key is required wherever subscriptions are sold.
type Adapter struct {
secret []byte // webhook signing secret
key string // API secret key; empty disables Fetch
base string
client *http.Client
now clock.Clock
tolerance time.Duration
}
// New builds the adapter. The webhook signing secret is required;
// the API key may be empty only in a deployment that sells no
// subscriptions, since processing a subscription event reads the
// API.
func New(secret, key string, opts ...Option) *Adapter {
if secret == "" {
panic("webhook signing secret is required")
}
a := &Adapter{
secret: []byte(secret),
key: key,
base: DefaultBase,
client: http.DefaultClient,
now: clock.System,
tolerance: DefaultTolerance,
}
for _, opt := range opts {
opt(a)
}
return a
}
// Name implements [provider.Provider].
func (*Adapter) Name() string { return catalog.ProviderStripe }
// Hear implements [provider.Provider]: it checks the Stripe-Signature
// header — an HMAC over "timestamp.payload" within the replay window —
// and normalizes the event. Subscription events trigger an
// authoritative API read of the subscription they name; event types
// outside this service's interest verify and return no facts.
func (a *Adapter) Hear(
ctx context.Context,
r *http.Request,
) ([]ledger.Fact, error) {
body, err := io.ReadAll(io.LimitReader(r.Body, MaxBody+1))
if err != nil {
return nil, fmt.Errorf("failed to read the body: %w", err)
}
if len(body) > MaxBody {
return nil, fmt.Errorf("%w: oversized body", provider.ErrSignature)
}
if err := a.verify(r.Header.Get("Stripe-Signature"), body); err != nil {
return nil, err
}
return a.normalize(ctx, body)
}
// verify checks the signature header against the body.
func (a *Adapter) verify(header string, body []byte) error {
var stamp string
var sigs [][]byte
for part := range strings.SplitSeq(header, ",") {
k, v, ok := strings.Cut(strings.TrimSpace(part), "=")
if !ok {
continue
}
switch k {
case "t":
stamp = v
case "v1":
if sig, err := hex.DecodeString(v); err == nil {
sigs = append(sigs, sig)
}
}
}
at, err := strconv.ParseInt(stamp, 10, 64)
if err != nil {
return fmt.Errorf("%w: unstamped header", provider.ErrSignature)
}
if age := a.now().Sub(time.Unix(at, 0)); age > a.tolerance ||
age < -a.tolerance {
return fmt.Errorf("%w: stamp outside the replay window",
provider.ErrSignature)
}
mac := hmac.New(sha256.New, a.secret)
mac.Write([]byte(stamp))
mac.Write([]byte{'.'})
mac.Write(body)
want := mac.Sum(nil)
for _, sig := range sigs {
if hmac.Equal(sig, want) {
return nil
}
}
return provider.ErrSignature
}
// The slices of Stripe's event and object shapes this adapter reads.
type (
event struct {
ID string `json:"id"`
Type string `json:"type"`
Created int64 `json:"created"`
Data struct {
Object jsontext.Value `json:"object"`
} `json:"data"`
}
subscription struct {
ID string `json:"id"`
Status string `json:"status"`
CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
CurrentPeriodStart int64 `json:"current_period_start"`
CurrentPeriodEnd int64 `json:"current_period_end"`
Metadata map[string]string `json:"metadata"`
Items struct {
Data []struct {
Price struct {
ID string `json:"id"`
} `json:"price"`
} `json:"data"`
} `json:"items"`
}
session struct {
Mode string `json:"mode"`
PaymentIntent string `json:"payment_intent"`
ClientReferenceID string `json:"client_reference_id"`
Metadata map[string]string `json:"metadata"`
}
charge struct {
PaymentIntent string `json:"payment_intent"`
Refunded bool `json:"refunded"`
}
)
// normalize turns a verified event body into facts.
func (a *Adapter) normalize(
ctx context.Context,
body []byte,
) ([]ledger.Fact, error) {
var ev event
if err := json.Unmarshal(body, &ev); err != nil {
return nil, fmt.Errorf("failed to parse the event: %w", err)
}
switch {
case strings.HasPrefix(ev.Type, "customer.subscription."):
// The event is only a trigger; its embedded snapshot is not
// trusted for state. Stripe stamps events at second
// resolution and fires several per checkout within the same
// second, so snapshot order is not decidable — but the
// authoritative re-read is the newest truth by construction,
// exactly as on the Play Store. A deleted subscription reads
// back as canceled and maps to expired.
var sub subscription
if err := json.Unmarshal(ev.Data.Object, &sub); err != nil {
return nil, fmt.Errorf(
"failed to parse the subscription: %w", err,
)
}
if sub.ID == "" {
return nil, errors.New("the event names no subscription")
}
return a.Fetch(ctx, sub.ID)
case ev.Type == "checkout.session.completed":
var s session
if err := json.Unmarshal(ev.Data.Object, &s); err != nil {
return nil, fmt.Errorf("failed to parse the session: %w", err)
}
if s.Mode != "payment" {
// Subscription checkouts surface through subscription
// events, which carry the full object.
return nil, nil
}
subject, err := uuid.Parse(s.ClientReferenceID)
if err != nil {
return nil, fmt.Errorf(
"session names no subject in client_reference_id: %w", err,
)
}
sku := s.Metadata["sku"]
if sku == "" {
return nil, errors.New("session carries no sku metadata")
}
return []ledger.Fact{{
Provider: a.Name(),
Ref: s.PaymentIntent,
Seq: ev.Created,
Subject: subject,
SKU: sku,
Kind: ledger.KindPurchase,
Status: ledger.StatusActive,
Starts: time.Unix(ev.Created, 0).UTC(),
Observed: a.now().UTC(),
Raw: body,
}}, nil
case ev.Type == "charge.refunded":
var c charge
if err := json.Unmarshal(ev.Data.Object, &c); err != nil {
return nil, fmt.Errorf("failed to parse the charge: %w", err)
}
if !c.Refunded || c.PaymentIntent == "" {
return nil, nil
}
// The charge names the purchase but not its subject or SKU;
// the intake completes the fact from the ledger's history of
// the same ref.
return []ledger.Fact{{
Provider: a.Name(),
Ref: c.PaymentIntent,
Seq: ev.Created,
Kind: ledger.KindPurchase,
Status: ledger.StatusRefunded,
Starts: time.Unix(ev.Created, 0).UTC(),
Observed: a.now().UTC(),
Raw: body,
}}, nil
}
return nil, nil
}
// subscription maps Stripe's subscription object onto a fact.
func (a *Adapter) subscription(
sub subscription,
seq int64,
raw []byte,
) (ledger.Fact, error) {
subject, err := uuid.Parse(sub.Metadata["subject"])
if err != nil {
return ledger.Fact{}, fmt.Errorf(
"subscription %s names no subject in metadata: %w", sub.ID, err,
)
}
if len(sub.Items.Data) == 0 {
return ledger.Fact{}, fmt.Errorf(
"subscription %s carries no items", sub.ID,
)
}
// A subscription IS a period, so one the payload does not date is
// evidence this adapter cannot interpret — and interpreting it
// anyway would fail open. An absent field unmarshals to zero,
// which the ledger stores as "no end at all": access that never
// lapses, and that the reconciliation sweep skips precisely
// because it has no end to re-read towards.
//
// Refusing is loud on purpose. Stripe moved these two fields off
// the subscription object and onto its items in a later API
// version, so an account whose default version moves past it
// starts sending subscriptions this adapter cannot read. That
// must surface as failing webhooks, not as a silent estate of
// perpetual entitlements.
if sub.CurrentPeriodEnd == 0 {
return ledger.Fact{}, fmt.Errorf(
"subscription %s carries no current_period_end; the "+
"account's Stripe API version may have moved it onto "+
"the subscription items", sub.ID,
)
}
status := ledger.StatusExpired
renews := false
switch sub.Status {
case "active", "trialing":
status = ledger.StatusActive
renews = !sub.CancelAtPeriodEnd
if sub.CancelAtPeriodEnd {
status = ledger.StatusCanceled
}
case "past_due":
status, renews = ledger.StatusGrace, true
case "unpaid", "paused", "incomplete":
status = ledger.StatusHold
}
return ledger.Fact{
Provider: a.Name(),
Ref: sub.ID,
Seq: seq,
Subject: subject,
SKU: sub.Items.Data[0].Price.ID,
Kind: ledger.KindSubscription,
Status: status,
Starts: time.Unix(sub.CurrentPeriodStart, 0).UTC(),
Ends: time.Unix(sub.CurrentPeriodEnd, 0).UTC(),
Renews: renews,
Observed: a.now().UTC(),
Raw: raw,
}, nil
}
// Fetch implements [provider.Provider]: it re-reads one subscription
// from the API. One-time purchases have nothing to reconcile — their
// refunds arrive as webhooks — so only subscription refs are read.
func (a *Adapter) Fetch(
ctx context.Context,
ref string,
) ([]ledger.Fact, error) {
if a.key == "" {
return nil, fmt.Errorf(
"%w: no API key configured", provider.ErrUnsupported,
)
}
if !strings.HasPrefix(ref, "sub_") {
return nil, fmt.Errorf(
"%w: only subscriptions reconcile", provider.ErrUnsupported,
)
}
req, err := http.NewRequestWithContext(
ctx, http.MethodGet, a.base+"/v1/subscriptions/"+ref, nil,
)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+a.key)
res, err := a.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to reach the API: %w", err)
}
defer res.Body.Close()
body, err := io.ReadAll(io.LimitReader(res.Body, MaxBody))
if err != nil {
return nil, fmt.Errorf("failed to read the response: %w", err)
}
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf(
"the API answered %d for %s", res.StatusCode, ref,
)
}
var sub subscription
if err := json.Unmarshal(body, &sub); err != nil {
return nil, fmt.Errorf("failed to parse the subscription: %w", err)
}
// A re-read is always the newest word on the purchase, so it
// sequences at the read's own clock — in milliseconds, so two
// reads within the same second still order.
f, err := a.subscription(sub, a.now().UnixMilli(), body)
if err != nil {
return nil, err
}
return []ledger.Fact{f}, nil
}
// Cancel implements [provider.Canceler]: the subscription lapses at
// period end, paid through what was bought. The confirming fact
// arrives as the webhook Stripe sends in response.
func (a *Adapter) Cancel(ctx context.Context, ref string) error {
if a.key == "" {
return fmt.Errorf(
"%w: no API key configured", provider.ErrUnsupported,
)
}
req, err := http.NewRequestWithContext(
ctx, http.MethodPost, a.base+"/v1/subscriptions/"+ref,
strings.NewReader("cancel_at_period_end=true"),
)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+a.key)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
res, err := a.client.Do(req)
if err != nil {
return fmt.Errorf("failed to reach the API: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return fmt.Errorf("the API answered %d for %s", res.StatusCode, ref)
}
return nil
}
// Attest implements [provider.Provider]. Stripe purchases bind their
// subject at checkout, so there is nothing for a client to attest.
func (*Adapter) Attest(context.Context, string) ([]ledger.Fact, error) {
return nil, provider.ErrUnsupported
}
var _ provider.Provider = (*Adapter)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package reconcile
import (
"context"
"encoding/json/v2"
"errors"
"fmt"
"net/http"
"slices"
"time"
"uuid"
"github.com/jackc/pgx/v5"
"github.com/deep-rent/nexus/eco/pes/catalog"
"github.com/deep-rent/nexus/eco/pes/entitle"
"github.com/deep-rent/nexus/eco/pes/ledger"
"github.com/deep-rent/nexus/eco/pes/provider"
"github.com/deep-rent/nexus/net/notify/hook"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/metrics"
)
// The webhook topics the engine publishes.
const (
// TopicGranted announces an entitlement key a subject gained.
TopicGranted = "pes.entitlement.granted"
// TopicRevoked announces one taken away by fresh evidence.
TopicRevoked = "pes.entitlement.revoked"
)
// Topics are the webhook topics this engine publishes — the whole
// vocabulary a subscriber may register for.
var Topics = []string{TopicGranted, TopicRevoked}
// Sweep tuning.
const (
// SweepHorizon is how far ahead the sweep looks for periods about
// to end.
SweepHorizon = 24 * time.Hour
// SweepAge is how quiet a purchase may go before the sweep
// re-reads it regardless.
SweepAge = 24 * time.Hour
// SweepLimit bounds one sweep's worklist.
SweepLimit = 200
)
// ErrForeign reports an attested purchase bound to a different
// subject than the caller: genuine evidence, wrong owner. It must
// never be granted, and it is worth surfacing distinctly — repeated
// hits are someone replaying another account's receipts.
var ErrForeign = errors.New("purchase belongs to another subject")
// Ledger is the slice of the store the engine writes through.
// Implemented by [ledger.Store].
type Ledger interface {
Exec(ctx context.Context,
fn func(ctx context.Context, tx pgx.Tx) error) error
Claim(ctx context.Context, tx pgx.Tx, subject uuid.UUID) error
InsertFacts(ctx context.Context, tx pgx.Tx, facts []ledger.Fact) (
int, error)
Current(ctx context.Context, tx pgx.Tx, subject uuid.UUID) (
[]ledger.Fact, error)
History(ctx context.Context, tx pgx.Tx, provider, ref string) (
[]ledger.Fact, error)
Stale(ctx context.Context, tx pgx.Tx, horizon, before time.Time,
limit int, providers []string) ([]ledger.Fact, error)
Subjects(ctx context.Context, tx pgx.Tx) ([]uuid.UUID, error)
ReplaceEntitlements(ctx context.Context, tx pgx.Tx,
subject uuid.UUID, grants []ledger.Entitlement) error
Entitlements(ctx context.Context, tx pgx.Tx, subject uuid.UUID) (
[]ledger.Entitlement, error)
}
// Publisher is the slice of the webhook engine the detector publishes
// through; nil disables publishing.
type Publisher interface {
Publish(ctx context.Context, tx pgx.Tx, event hook.Event) (int, error)
}
// Option configures an [Engine].
type Option func(*Engine)
// WithLogger sets the logger narrating ingestion and sweep outcomes.
// A nil logger is ignored.
func WithLogger(logger *log.Logger) Option {
return func(e *Engine) {
if logger != nil {
e.logger = logger
}
}
}
// WithClock injects the time source, which is primarily useful for
// testing. A nil clock is ignored.
func WithClock(now clock.Clock) Option {
return func(e *Engine) {
if now != nil {
e.now = now
}
}
}
// WithPublisher routes entitlement changes onto the webhook engine.
func WithPublisher(p Publisher) Option {
return func(e *Engine) { e.hooks = p }
}
// WithRegistry registers the engine's own instruments with reg
// instead of [metrics.DefaultRegistry]. A nil registry is ignored.
func WithRegistry(reg *metrics.Registry) Option {
return func(e *Engine) {
if reg != nil {
e.reg = reg
}
}
}
// An Engine reconciles provider evidence into the ledger and its
// projection. It is safe for concurrent use.
type Engine struct {
db Ledger
// fetchable names the providers implementing [provider.Fetcher],
// which is the sweep's whole worklist; see [Engine.Sweep].
fetchable []string
cat *catalog.Catalog
providers map[string]provider.Provider
hooks Publisher
logger *log.Logger
now clock.Clock
reg *metrics.Registry
}
// New assembles an engine over the ledger, the catalog, and the
// configured provider adapters.
func New(
db Ledger,
cat *catalog.Catalog,
providers []provider.Provider,
opts ...Option,
) *Engine {
if db == nil {
panic("ledger is required")
}
if cat == nil {
panic("catalog is required")
}
byName := make(map[string]provider.Provider, len(providers))
var fetchable []string
for _, p := range providers {
byName[p.Name()] = p
if _, ok := p.(provider.Fetcher); ok {
fetchable = append(fetchable, p.Name())
}
}
slices.Sort(fetchable)
e := &Engine{
db: db,
cat: cat,
providers: byName,
fetchable: fetchable,
logger: log.Discard(),
now: clock.System,
reg: metrics.DefaultRegistry,
}
for _, opt := range opts {
opt(e)
}
return e
}
// Provider resolves a configured adapter by its canonical name.
func (e *Engine) Provider(name string) (provider.Provider, bool) {
p, ok := e.providers[name]
return p, ok
}
// Hear runs one inbound notification through its provider and ingests
// whatever it proves. [provider.ErrSignature] passes through for the
// intake to refuse.
func (e *Engine) Hear(
ctx context.Context,
name string,
r *http.Request,
) error {
p, ok := e.providers[name]
if !ok {
return fmt.Errorf("unknown provider %q", name)
}
facts, err := p.Hear(ctx, r)
if err != nil {
return err
}
return e.Ingest(ctx, facts)
}
// Attest verifies client-submitted proof and ingests it — but only
// when the proven purchase is bound to the calling subject. Genuine
// evidence for someone else's account is [ErrForeign] and grants
// nothing.
func (e *Engine) Attest(
ctx context.Context,
name, proof string,
subject uuid.UUID,
) error {
p, ok := e.providers[name]
if !ok {
return fmt.Errorf("unknown provider %q", name)
}
facts, err := p.Attest(ctx, proof)
if err != nil {
return err
}
for _, f := range facts {
if f.Subject != subject {
return ErrForeign
}
}
return e.Ingest(ctx, facts)
}
// Ingest appends facts and reprojects every subject they touch, in
// one transaction with whatever the change announces. It is the one
// write path all three legs share.
func (e *Engine) Ingest(ctx context.Context, facts []ledger.Fact) error {
if len(facts) == 0 {
return nil
}
now := e.now().UTC()
err := e.db.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
complete, err := e.complete(ctx, tx, facts)
if err != nil {
return err
}
fresh, err := e.db.InsertFacts(ctx, tx, complete)
if err != nil {
return err
}
if fresh == 0 {
return nil // Everything was already on the ledger.
}
subjects := make(map[uuid.UUID]bool)
for _, f := range complete {
subjects[f.Subject] = true
}
for subject := range subjects {
if err := e.project(ctx, tx, subject, now); err != nil {
return err
}
}
return nil
})
if err != nil {
return err
}
for _, f := range facts {
e.reg.Counter("pes_facts_total",
metrics.T("provider", f.Provider),
).Inc()
}
return nil
}
// complete fills the blanks adapters cannot know: a refund names its
// purchase but not the subject or SKU, which the ledger's history of
// the same ref supplies. A refund for a purchase the ledger never saw
// is dropped with a warning — there is nothing to revoke.
func (e *Engine) complete(
ctx context.Context,
tx pgx.Tx,
facts []ledger.Fact,
) ([]ledger.Fact, error) {
out := make([]ledger.Fact, 0, len(facts))
for _, f := range facts {
if f.Subject == (uuid.UUID{}) {
prior, err := e.db.History(ctx, tx, f.Provider, f.Ref)
if err != nil {
return nil, err
}
if len(prior) == 0 {
e.logger.Warn(ctx,
"Dropping evidence about an unknown purchase",
log.String("provider", f.Provider),
log.String("ref", f.Ref),
)
continue
}
f.Subject = prior[0].Subject
if f.SKU == "" {
f.SKU = prior[0].SKU
}
}
out = append(out, f)
}
return out, nil
}
// project recomputes one subject's entitlements and announces the
// difference against what stood before.
//
// It claims the subject first, so concurrent ingests for one subject
// queue rather than interleave; see [ledger.Store.Claim].
func (e *Engine) project(
ctx context.Context,
tx pgx.Tx,
subject uuid.UUID,
now time.Time,
) error {
if err := e.db.Claim(ctx, tx, subject); err != nil {
return err
}
before, err := e.db.Entitlements(ctx, tx, subject)
if err != nil {
return err
}
current, err := e.db.Current(ctx, tx, subject)
if err != nil {
return err
}
after := entitle.Project(current, e.cat)
if err := e.db.ReplaceEntitlements(ctx, tx, subject, after); err != nil {
return err
}
// Announce by valid keys, not rows: a renewal that extends a
// window changes rows without changing what the subject may do.
was, is := entitle.Keys(before, now), entitle.Keys(after, now)
for _, key := range is {
if !slices.Contains(was, key) {
if err := e.announce(ctx, tx, TopicGranted, subject,
key, now); err != nil {
return err
}
}
}
for _, key := range was {
if !slices.Contains(is, key) {
if err := e.announce(ctx, tx, TopicRevoked, subject,
key, now); err != nil {
return err
}
}
}
return nil
}
// announce publishes one entitlement change, when a publisher is
// attached.
func (e *Engine) announce(
ctx context.Context,
tx pgx.Tx,
topic string,
subject uuid.UUID,
key string,
at time.Time,
) error {
metric, verb := "pes_grants_total", "granted"
if topic == TopicRevoked {
metric, verb = "pes_revocations_total", "revoked"
}
e.reg.Counter(metric).Inc()
e.logger.Info(ctx, "Entitlement "+verb,
log.String("subject", subject.String()),
log.String("key", key),
)
if e.hooks == nil {
return nil
}
body, err := json.Marshal(map[string]string{
"subject": subject.String(),
"key": key,
})
if err != nil {
return fmt.Errorf("failed to encode the payload: %w", err)
}
_, err = e.hooks.Publish(ctx, tx, hook.Event{
Topic: topic,
At: at,
Data: body,
})
return err
}
// Sweep re-reads what is about to expire or has gone quiet — the pull
// leg that makes the ledger correct rather than hopeful. Providers
// without a fetch leg skip quietly; individual failures log and move
// on, since the next sweep retries by construction.
func (e *Engine) Sweep(ctx context.Context) {
if len(e.fetchable) == 0 {
return // No provider can re-read anything.
}
var stale []ledger.Fact
err := e.db.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
var err error
now := e.now().UTC()
stale, err = e.db.Stale(
ctx, tx, now.Add(SweepHorizon), now.Add(-SweepAge),
SweepLimit, e.fetchable,
)
return err
})
if err != nil {
e.logger.Error(ctx, "Sweep worklist failed", log.Error(err))
return
}
e.reg.Counter("pes_sweeps_total").Inc()
refreshed := 0
for _, f := range stale {
p, ok := e.providers[f.Provider]
if !ok {
continue
}
fetcher, ok := p.(provider.Fetcher)
if !ok {
// The worklist is filtered to fetchable providers, so
// this is unreachable short of a configuration change
// mid-sweep.
continue
}
facts, err := fetcher.Fetch(ctx, f.Ref)
switch {
case errors.Is(err, provider.ErrUnsupported):
continue
case err != nil:
e.logger.Warn(ctx, "Sweep re-read failed",
log.String("provider", f.Provider),
log.String("ref", f.Ref),
log.Error(err),
)
continue
}
if err := e.Ingest(ctx, facts); err != nil {
e.logger.Warn(ctx, "Sweep ingestion failed",
log.String("provider", f.Provider),
log.String("ref", f.Ref),
log.Error(err),
)
continue
}
refreshed++
}
if len(stale) > 0 {
e.logger.Info(ctx, "Swept the ledger",
log.Int("stale", len(stale)),
log.Int("refreshed", refreshed),
)
}
}
// Rebuild reprojects every subject from the facts — the recovery
// lever after a projection change or a suspected drift.
func (e *Engine) Rebuild(ctx context.Context) error {
now := e.now().UTC()
return e.db.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
subjects, err := e.db.Subjects(ctx, tx)
if err != nil {
return err
}
for _, subject := range subjects {
if err := e.project(ctx, tx, subject, now); err != nil {
return err
}
}
e.logger.Info(ctx, "Rebuilt the projection",
log.Int("subjects", len(subjects)))
return nil
})
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package pes
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"uuid"
"github.com/deep-rent/nexus/dat/cache"
"github.com/deep-rent/nexus/eco/identity"
"github.com/deep-rent/nexus/eco/pes/api"
"github.com/deep-rent/nexus/eco/pes/catalog"
"github.com/deep-rent/nexus/eco/pes/config"
"github.com/deep-rent/nexus/eco/pes/ledger"
"github.com/deep-rent/nexus/eco/pes/provider"
"github.com/deep-rent/nexus/eco/pes/provider/apple"
"github.com/deep-rent/nexus/eco/pes/provider/google"
"github.com/deep-rent/nexus/eco/pes/provider/stripe"
"github.com/deep-rent/nexus/eco/pes/reconcile"
hookadmin "github.com/deep-rent/nexus/net/notify/hook/admin"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/sec/auth"
"github.com/deep-rent/nexus/sec/jose/jwk"
"github.com/deep-rent/nexus/sec/jose/jwt"
"github.com/deep-rent/nexus/sys/boot"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/schedule"
)
// PermissionRead is the scope a machine client needs to read another
// subject's entitlements. Delegated tokens need no scope: an end user
// asking about their own purchases is what the API is for.
const PermissionRead = "pes:read"
// PermissionAdmin is the permission the webhook management surface
// demands. Machine clients need the scope itself; delegated (staff)
// tokens need the scope plus one of the roles named by
// [config.Auth.Roles].
const PermissionAdmin = "pes:admin"
// HookOwner is the owner every endpoint of this registry is filed
// under: the service keeps one flat, staff-managed subscriber list.
const HookOwner = "pes"
// DefaultRole is the IAM role admitted to the webhook management
// surface when the configuration names none.
const DefaultRole = "admin"
// Google's push-notification identity: the issuer stamped into the
// OIDC tokens on Pub/Sub pushes, and the key set they verify against.
const (
GoogleIssuer = "https://accounts.google.com"
GoogleJWKSURL = "https://www.googleapis.com/oauth2/v3/certs"
)
// Tuning constants of the assembled service. They are deliberately not
// configuration: each is a property of the service's own shape rather
// than of the deployment around it. What every service shares — the
// header cap, the probe cadences, the shutdown margin — belongs to
// [boot] instead.
const (
// MaxBodySize caps a request body at 1 MiB. The intake carries
// provider notifications — Apple's nested certificate chains run
// to tens of kilobytes — and the cap leaves them room while still
// bounding hostile input.
MaxBodySize = 1 << 20
// RedactInterval is how often raw evidence past its retention is
// redacted. Daily would suffice mechanically; twice a day heals
// a missed run quickly.
RedactInterval = 12 * time.Hour
)
// Service is the fully assembled purchase and entitlement service.
// Create instances with [New], serve them with [Service.Run], or embed
// [Service.Handler] into a custom server.
type Service struct {
cfg config.Config
rt *boot.Runtime
store *ledger.Store
engine *reconcile.Engine
// googleKeys caches Google's push-token signing keys, nil unless
// the Play adapter runs. The identity provider's own key set is
// the runtime's business.
googleKeys jwk.CacheSet
}
// New assembles the service from its configuration and the loaded
// product catalog (see [catalog.Load]). It returns an error for
// unusable external inputs — an unreachable database, unreadable
// credentials, no provider configured at all.
//
// The version identifies this build in the User-Agent of every
// outbound request; resolve it via [cli.Version].
//
// [cli.Version]: github.com/deep-rent/nexus/sys/cli#Version
func New(
ctx context.Context,
cfg config.Config,
cat *catalog.Catalog,
version string,
) (*Service, error) {
if cat == nil {
return nil, errors.New("no catalog loaded")
}
rt, err := boot.New(ctx, boot.Spec{
Name: "pes",
Version: version,
Core: cfg.Core,
Database: &cfg.Database,
Auth: &cfg.Auth.Auth,
Sender: &cfg.Hook,
}, boot.WithMaxBody(MaxBodySize))
if err != nil {
return nil, err
}
s := &Service{cfg: cfg, rt: rt}
s.store = ledger.New(rt.Pool())
rt.Migrate(ledger.Migrator)
// The provider adapters, each enabled by its fully configured
// section. Their credentials come from the environment and
// mounted files; none touch the database.
providers, err := s.providers(cfg, rt.Client())
if err != nil {
return nil, err
}
if len(providers) == 0 {
return nil, errors.New("no payment provider configured")
}
engineOpts := []reconcile.Option{
reconcile.WithLogger(rt.Logger().Child("reconcile")),
}
if h := rt.Hooks(); h != nil {
engineOpts = append(engineOpts, reconcile.WithPublisher(h))
}
s.engine = reconcile.New(s.store, cat, providers, engineOpts...)
rt.Every("sweep", cfg.SweepInterval, schedule.TaskFn(s.engine.Sweep))
if cfg.RawRetention > 0 {
rt.Every("redact", RedactInterval, schedule.TaskFn(s.redact))
}
s.mount(cat, cfg)
names := make([]string, len(providers))
for i, p := range providers {
names[i] = p.Name()
}
rt.Logger().Info(ctx, "Assembled PES service",
log.String("providers", strings.Join(names, ",")),
log.Int("products", len(cat.Products())),
log.Duration("sweep", cfg.SweepInterval),
log.String("issuer", cfg.Auth.Issuer),
log.String("jwks", cfg.Auth.Keys()),
log.Bool("teams", cfg.Directory.Enabled()),
)
return s, nil
}
// providers builds the adapters the configuration enables. The Play
// adapter brings a key cache of its own, whose refresh and first fetch
// join the runtime's.
func (s *Service) providers(
cfg config.Config,
client *http.Client,
) ([]provider.Provider, error) {
var providers []provider.Provider
if cfg.Stripe.Enabled() {
providers = append(providers, stripe.New(
cfg.Stripe.Secret, cfg.Stripe.Key,
stripe.WithClient(client),
stripe.WithBase(cfg.Stripe.Base),
))
}
if cfg.Apple.Enabled() {
roots, err := apple.LoadRoots(cfg.Apple.Roots)
if err != nil {
return nil, fmt.Errorf(
"failed to load the Apple roots: %w", err,
)
}
providers = append(providers, apple.New(roots, cfg.Apple.Bundle))
}
if cfg.Google.Enabled() {
account, key, err := google.LoadAccount(cfg.Google.Account)
if err != nil {
return nil, fmt.Errorf(
"failed to load the Google account: %w", err,
)
}
s.googleKeys = jwk.NewCacheSet(
GoogleJWKSURL, cache.WithClient(client),
)
s.rt.Tick("google-keys", s.googleKeys)
s.rt.Await("Google", s.googleKeys.Ready())
push := jwt.NewVerifier[*jwt.Reserved](
s.googleKeys,
jwt.WithIssuers(GoogleIssuer),
jwt.WithAudiences(cfg.Google.Audience),
)
providers = append(providers, google.New(
cfg.Google.Package, push, account, key,
google.WithClient(client),
))
}
return providers, nil
}
// mount registers the HTTP surfaces: the entitlement API, the provider
// intake, and the webhook management endpoints.
func (s *Service) mount(cat *catalog.Catalog, cfg config.Config) {
r := s.rt.Router()
guard := s.rt.Guard()
// The application rule: any delegated token passes — the handlers
// pin it to its own subject and teams — while a machine client
// must hold the read scope to name subjects at will.
user := auth.Any(
auth.Delegated(),
auth.Grants{}.Require(PermissionRead),
)
var opts []api.Option
if cfg.Directory.Enabled() {
// The directory is what makes team-directed purchases
// possible: it answers whether the caller owns the team the
// grant is for. Without it, such requests are refused.
dir := identity.Open(cfg.Directory, s.rt.Client())
opts = append(opts, api.WithOwners(api.OwnerFunc(func(
ctx context.Context,
teamID, userID uuid.UUID,
) (bool, error) {
m, ok, err := dir.Member(ctx, teamID, userID)
return ok && m.Owner, err
})))
}
api.New(s.store, s.engine, cat, time.Now, opts...).
Mount(r, guard.Secure(user))
// The provider intake is deliberately unguarded; each request
// authenticates by its provider's own cryptography. See
// [api.MountIntake].
api.MountIntake(r, s.engine)
hooks := s.rt.Hooks()
if hooks == nil {
return
}
// One flat, staff-managed subscriber list under /admin, well
// apart from the provider intake at /hooks: the service names
// the owner itself, only its own topics may be subscribed to,
// and registering a receiver is an administrative act.
// Internal endpoints are admissible because the surface never
// faces customers.
roles := cfg.Auth.Roles
if len(roles) == 0 {
roles = []string{DefaultRole}
}
grants := auth.Grants{}
for _, role := range roles {
grants[role] = []string{PermissionAdmin}
}
// Internal endpoints bypass the private-address guard, so allowing
// them without an allow-list would make the permission to register
// a subscriber the permission to reach anything this service can —
// and every entitlement change names money moving. The capability
// turns on only once the deployment has named the hosts it means.
hookadmin.Mount(r.Group("/admin"), hookadmin.Config{
Hooks: hooks,
Owner: hookadmin.Fixed(HookOwner),
Topics: reconcile.Topics,
Internal: len(cfg.Hook.InternalHosts) > 0,
Read: []router.Middleware{
guard.Secure(grants.Require(PermissionAdmin)),
},
})
}
// Handler returns the assembled HTTP handler, for embedding the API
// into a custom server.
func (s *Service) Handler() http.Handler { return s.rt.Handler() }
// Run serves the service until the context is canceled or a
// termination signal arrives.
func (s *Service) Run(ctx context.Context) error { return s.rt.Run(ctx) }
// redact clears raw provider payloads past their retention; see
// [ledger.Store.Redact].
func (s *Service) redact(ctx context.Context) {
logger := s.rt.Logger()
n, err := s.store.Redact(
ctx, time.Now().Add(-s.cfg.RawRetention),
)
if err != nil {
logger.Error(ctx, "Evidence redaction failed", log.Error(err))
return
}
if n > 0 {
logger.Info(ctx, "Redacted raw evidence past retention",
log.Int("payloads", int(n)))
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package aws4
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/deep-rent/nexus/std/ascii"
"github.com/deep-rent/nexus/std/clock"
)
// MaxExpiry is the longest lifetime a presigned URL may carry: seven days,
// the cap S3 imposes on the X-Amz-Expires parameter.
const MaxExpiry = 7 * 24 * time.Hour
// DefaultService is the service name a [Signer] scopes its credentials to
// unless [WithService] overrides it: "s3", the service this package
// exists for.
const DefaultService = "s3"
// ErrExpiryRange reports a presigning lifetime outside (0, MaxExpiry], or
// one shorter than the whole second the wire format can express.
var ErrExpiryRange = errors.New(
"expiry must lie between one second and seven days",
)
// algorithm names the one signing algorithm Signature Version 4 defines.
const algorithm = "AWS4-HMAC-SHA256"
// reserved lists the query parameters the presigning process owns. They
// are stripped from an incoming URL before signing, so presigning a URL
// that already carries a (possibly stale) signature replaces it instead of
// stacking a second, contradictory one.
var reserved = []string{
"X-Amz-Algorithm",
"X-Amz-Credential",
"X-Amz-Date",
"X-Amz-Expires",
"X-Amz-Security-Token",
"X-Amz-Signature",
"X-Amz-SignedHeaders",
}
// Credentials is the static key pair a request is signed with, plus the
// session token that accompanies temporary credentials.
type Credentials struct {
// AccessKey is the public half of the key pair; it travels in the
// clear inside the X-Amz-Credential parameter.
AccessKey string
// SecretKey is the private half; it never leaves the signer and only
// shapes the signature.
SecretKey string
// SessionToken accompanies temporary (STS-issued) credentials as the
// X-Amz-Security-Token parameter. Empty for long-lived keys.
SessionToken string
}
// Signer mints presigned URLs for one region and service under one set of
// credentials. Beyond an internal cache of the derived daily signing key,
// it is immutable after construction; it is safe for concurrent use.
type Signer struct {
creds Credentials
region string
service string
now clock.Clock
cache atomic.Pointer[derived]
}
// derived is one day's signing key; see [Signer.signingKey].
type derived struct {
date string
key []byte
}
// New creates a [Signer] for the given credentials and region. The service
// defaults to [DefaultService], and [WithService] overrides it, since the
// signature scheme itself is service-agnostic.
//
// It panics if the access key, the secret key, or the region is empty,
// since those are startup configuration errors.
func New(creds Credentials, region string, opts ...Option) *Signer {
if creds.AccessKey == "" {
panic("access key is required")
}
if creds.SecretKey == "" {
panic("secret key is required")
}
if region == "" {
panic("region is required")
}
s := &Signer{
creds: creds,
region: region,
service: DefaultService,
now: clock.System,
}
for _, opt := range opts {
opt(s)
}
return s
}
// Presign returns a URL that grants the given method on the given URL
// until the expiry elapses, authorized by query parameters alone.
//
// It takes the parsed URL rather than a string, because the caller
// usually parsed it already — to decide whether the requester may have
// this object at all, by looking at the decoded Path and the Host. Keep
// authorizing on that same value: what was inspected is then exactly what
// gets signed, with no second parse in between for the two to diverge
// over. The value is only read, never modified.
//
// The URL must be absolute with an http or https scheme. Its query
// parameters are preserved and covered by the signature — S3 reads
// response-shaping parameters such as response-content-disposition from
// them — so they can no more be tampered with than the path. Any X-Amz-*
// parameters of a previous presigning are replaced.
//
// The signed headers, if any, are covered by the signature on top of the
// always-signed Host: the eventual request must carry each one with
// exactly the signed value, or the provider refuses it as forged. That
// is what turns a header the provider acts on into a constraint of the
// grant — sign x-amz-checksum-sha256 and the provider accepts no body
// but one hashing to that digest, sign Content-Length and it accepts no
// other size. The values do NOT travel in the URL; hand them to whoever
// performs the request alongside it. A Host entry is refused (the URL
// owns the host), and multiple values under one name are joined with
// commas, as the signature scheme prescribes.
//
// The path and query of the returned URL are re-encoded into the exact
// byte form the signature covers, so the URL must be used as returned;
// re-encoding it on the way to the provider breaks the signature. The
// expiry is expressed in whole seconds on the wire and must lie in
// (0, MaxExpiry]; anything else returns [ErrExpiryRange].
func (s *Signer) Presign(
method string,
u *url.URL,
expires time.Duration,
signed http.Header,
) (string, error) {
seconds := int64(expires / time.Second)
if seconds <= 0 || expires > MaxExpiry {
return "", ErrExpiryRange
}
if !validToken(method) {
return "", fmt.Errorf("invalid method %q", method)
}
if u == nil {
return "", errors.New("URL is required")
}
if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return "", fmt.Errorf(
"URL %q is not an absolute http(s) URL", u,
)
}
// Refusing beats silently dropping: credentials in the URL are never
// what an object URL means, and the emitted URL would not carry them.
if u.User != nil {
return "", fmt.Errorf("URL %q must not carry userinfo", u)
}
host := canonicalHost(u.Scheme, u.Host)
headerNames, headerLines, err := canonicalHeaders(host, signed)
if err != nil {
return "", err
}
now := s.now().UTC()
stamp := now.Format("20060102T150405Z")
date := now.Format("20060102")
scope := strings.Join(
[]string{
date,
s.region,
s.service,
"aws4_request",
},
"/",
)
// Assemble the signed query: the caller's parameters plus the
// authorization parameters, minus any prior presigning's. A query the
// parser cannot round-trip is refused rather than partially dropped —
// a parameter the provider would read but the signature not cover
// must never slip through silently.
query, err := url.ParseQuery(u.RawQuery)
if err != nil {
return "", fmt.Errorf("malformed query: %w", err)
}
for _, k := range reserved {
query.Del(k)
}
query.Set("X-Amz-Algorithm", algorithm)
query.Set("X-Amz-Credential", s.creds.AccessKey+"/"+scope)
query.Set("X-Amz-Date", stamp)
query.Set("X-Amz-Expires", strconv.FormatInt(seconds, 10))
query.Set("X-Amz-SignedHeaders", headerNames)
if s.creds.SessionToken != "" {
query.Set("X-Amz-Security-Token", s.creds.SessionToken)
}
path := canonicalPath(u.Path)
canonicalQuery := canonicalQuery(query)
// The canonical request pins method, path, query, and the signed
// headers — always the host, plus whatever the caller constrained.
// The payload is deliberately unsigned: the whole point of presigning
// an upload is that the content arrives later.
canonical := strings.Join([]string{
method,
path,
canonicalQuery,
headerLines,
headerNames,
"UNSIGNED-PAYLOAD",
}, "\n")
digest := sha256.Sum256([]byte(canonical))
msg := strings.Join([]string{
algorithm,
stamp,
scope,
hex.EncodeToString(digest[:]),
}, "\n")
signature := hex.EncodeToString(
sum(s.signingKey(date), []byte(msg)),
)
// Reassemble from the exact canonical bytes so the emitted URL and
// the signed form cannot drift apart.
return u.Scheme + "://" + host + path +
"?" + canonicalQuery +
"&X-Amz-Signature=" + signature, nil
}
// signingKey derives the signing key for the given day: a four-step HMAC
// chain over date, region, service, and the terminal aws4_request marker,
// so the long-lived secret itself never touches a signature directly.
//
// The key only changes with the date, so the last derivation is cached
// and reused for every signature of the same day. The [hmac] package
// copies the key on use, so sharing the slice between goroutines is safe.
func (s *Signer) signingKey(date string) []byte {
if d := s.cache.Load(); d != nil && d.date == date {
return d.key
}
key := sum([]byte("AWS4"+s.creds.SecretKey), []byte(date))
key = sum(key, []byte(s.region))
key = sum(key, []byte(s.service))
key = sum(key, []byte("aws4_request"))
s.cache.Store(&derived{date: date, key: key})
return key
}
// sum is one HMAC-SHA256 link of the derivation chain.
func sum(key, data []byte) []byte {
mac := hmac.New(sha256.New, key)
mac.Write(data)
return mac.Sum(nil)
}
// canonicalHost renders the host the signature covers: lowercased, with
// the scheme's default port stripped. Clients typically omit a default
// port from their Host header — browsers always do — so a signature over
// "host:443" would never verify against the "host" the provider receives.
// An explicit non-default port stays, matching what clients then send.
func canonicalHost(scheme, host string) string {
host = ascii.ToLower(host)
switch scheme {
case "http":
return strings.TrimSuffix(host, ":80")
case "https":
return strings.TrimSuffix(host, ":443")
}
return host
}
// canonicalHeaders renders the signed header set of the canonical
// request: the host plus the caller's headers, each lowercased, sorted by
// name, and holding its canonicalized value. It returns the semicolon
// -joined name list (the X-Amz-SignedHeaders value) and the newline
// -terminated header lines.
//
// Names must be RFC 9110 tokens and unique after lowercasing — an
// http.Header literal can smuggle in two spellings of one name, and
// picking one silently would sign a coin flip. The host is derived from
// the URL, so a caller-supplied Host entry is refused rather than
// second-guessed.
func canonicalHeaders(host string, signed http.Header) (
names, lines string, err error,
) {
type header struct{ name, value string }
headers := make([]header, 1, len(signed)+1)
headers[0] = header{name: "host", value: host}
seen := map[string]bool{"host": true}
for name, values := range signed {
if !validToken(name) {
return "", "", fmt.Errorf("invalid header name %q", name)
}
lower := ascii.ToLower(name)
if seen[lower] {
return "", "", fmt.Errorf("duplicate signed header %q", lower)
}
seen[lower] = true
canon := make([]string, len(values))
for i, v := range values {
canon[i], err = canonicalValue(v)
if err != nil {
return "", "", fmt.Errorf("header %q: %w", lower, err)
}
}
// Multiple values of one name collapse into a comma-joined list,
// the form the provider reconstructs when verifying.
headers = append(headers, header{
name: lower,
value: strings.Join(canon, ","),
})
}
slices.SortFunc(headers, func(a, b header) int {
return strings.Compare(a.name, b.name)
})
nameList := make([]string, len(headers))
var b strings.Builder
for i, h := range headers {
nameList[i] = h.name
b.WriteString(h.name)
b.WriteByte(':')
b.WriteString(h.value)
b.WriteByte('\n')
}
return strings.Join(nameList, ";"), b.String(), nil
}
// canonicalValue renders one header value in its signed form: surrounding
// white space trimmed and internal runs of spaces or tabs collapsed to a
// single space, exactly as the verifier canonicalizes what it receives.
// Other control bytes are refused — a newline would splice bogus lines
// into the canonical request, minting a URL no real request can match.
func canonicalValue(s string) (string, error) {
var b strings.Builder
b.Grow(len(s))
space := false
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c == ' ' || c == '\t':
space = b.Len() > 0
case c < 0x20 || c == 0x7F:
return "", fmt.Errorf("value %q holds a control byte", s)
default:
if space {
b.WriteByte(' ')
space = false
}
b.WriteByte(c)
}
}
return b.String(), nil
}
// validToken reports whether the string is a non-empty RFC 9110 token,
// the shape of both HTTP methods and header names. Anything else — above
// all whitespace or control bytes — would be signed into a canonical
// request no real request can ever match, minting a URL that only fails
// at the provider.
func validToken(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
if !ascii.IsAlphaNum(c) &&
!strings.ContainsRune("!#$%&'*+-.^_`|~", rune(c)) {
return false
}
}
return true
}
// canonicalPath encodes the decoded request path into the byte form the
// signature covers: every segment percent-encoded with the AWS character
// set, slashes preserved, and never normalized — S3 treats the path as an
// opaque object key, so "cleaning" it would sign a different object.
func canonicalPath(path string) string {
if path == "" {
return "/"
}
return encode(path, true)
}
// canonicalQuery renders the query in its signed form: keys and values
// percent-encoded with the AWS character set and sorted by encoded key,
// then encoded value.
func canonicalQuery(query url.Values) string {
type pair struct{ k, v string }
pairs := make([]pair, 0, len(query))
for k, vs := range query {
for _, v := range vs {
pairs = append(pairs, pair{
encode(k, false),
encode(v, false),
})
}
}
slices.SortFunc(pairs, func(a, b pair) int {
if c := strings.Compare(a.k, b.k); c != 0 {
return c
}
// If keys are equal, sort by value.
return strings.Compare(a.v, b.v)
})
parts := make([]string, len(pairs))
for i, p := range pairs {
parts[i] = p.k + "=" + p.v
}
return strings.Join(parts, "&")
}
// hexDigits spells the uppercase hex alphabet percent-escapes use.
const hexDigits = "0123456789ABCDEF"
// isUnreserved reports whether the byte stays literal under the AWS
// encoding rule: the unreserved set of RFC 3986, plus the slash where a
// path keeps its segment structure.
func isUnreserved(c byte, slash bool) bool {
return ascii.IsAlphaNum(c) ||
c == '-' || c == '_' || c == '.' || c == '~' || (slash && c == '/')
}
// encode percent-encodes every byte outside the unreserved set of RFC
// 3986 — the exact rule Signature Version 4 prescribes, which is stricter
// than [url.QueryEscape] (a space becomes %20, never +). A path keeps its
// slashes; a query key or value encodes them too.
//
// A first pass counts the escapes, so the fully-literal common case
// returns its input without allocating and the rest allocates exactly
// once.
func encode(s string, slash bool) string {
escapes := 0
for i := 0; i < len(s); i++ {
if !isUnreserved(s[i], slash) {
escapes++
}
}
if escapes == 0 {
return s
}
var b strings.Builder
b.Grow(len(s) + 2*escapes)
for i := 0; i < len(s); i++ {
c := s[i]
if isUnreserved(c, slash) {
b.WriteByte(c)
} else {
b.WriteByte('%')
b.WriteByte(hexDigits[c>>4])
b.WriteByte(hexDigits[c&15])
}
}
return b.String()
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package aws4
import (
"net/http"
"net/url"
"time"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/std/ascii"
)
// ReasonHostRefused indicates that presigning was requested for a URL
// whose host is not on the handler's allowlist.
const ReasonHostRefused router.Reason = "host_refused"
// Request asks for one presigned URL.
type Request struct {
// Method is the HTTP method the URL is to grant: GET, HEAD, PUT, or
// DELETE. Presigned POST is a different mechanism (a form policy)
// and deliberately out of scope.
Method string `json:"method"`
// URL is the object URL to presign, query parameters included.
URL string `json:"url"`
// Expires is the lifetime of the grant in whole seconds, at most
// [MaxExpiry].
Expires int64 `json:"expires"`
}
// Validate implements the [valid.Validatable] interface.
func (r *Request) Validate(v *valid.Validator) {
v.Whitelist(
"method", r.Method,
http.MethodGet, http.MethodHead,
http.MethodPut, http.MethodDelete,
)
v.NotBlank("url", r.URL)
v.URL("url", r.URL)
v.Between("expires", r.Expires, 1, int64(MaxExpiry/time.Second))
}
var _ valid.Validatable = (*Request)(nil)
// Response carries the presigned URL.
type Response struct {
// URL is the presigned URL. It must be used byte-for-byte; see
// [Signer.Presign].
URL string `json:"url"`
// ExpiresAt is when the grant lapses.
ExpiresAt time.Time `json:"expires_at"`
}
// Handler serves presigning over HTTP: it binds a [Request] from the JSON
// body and answers with a [Response]. Mount it wherever the service hands
// object access to clients:
//
// r.Handle(http.MethodPost, "/files/presign", aws4.Handler(
// signer, "bucket.s3.gra.io.cloud.ovh.net",
// ))
//
// Only URLs on the given hosts are signed (compared case-insensitively);
// anything else is refused with [ReasonHostRefused]. The allowlist is
// required — a handler signing arbitrary hosts would mint grants for
// every bucket the credentials can reach — and it is also the only
// authorization the handler does: anyone who reaches it can obtain a
// grant for any object on the allowed hosts, so mount it behind the
// service's authentication and authorization middleware.
//
// A host allowlist is deliberately coarse. A service that owns a bucket
// and authorizes per object — this user may touch this key — is better
// served writing its own thin endpoint over [s3.Bucket], which speaks
// object keys instead of URLs; this handler covers the generic case
// where whole hosts are the unit of trust. For the same reason it mints
// host-only grants: header-pinned uploads (a signed checksum, say) need
// the per-object knowledge that belongs to such a service, not to this
// generic endpoint.
//
// It panics on a nil signer or an empty allowlist, since those are
// startup configuration errors.
//
// [s3.Bucket]: github.com/deep-rent/nexus/net/s3#Bucket
func Handler(s *Signer, hosts ...string) router.Handler {
if s == nil {
panic("signer is required")
}
if len(hosts) == 0 {
panic("at least one host is required")
}
allowed := make(map[string]bool, len(hosts))
for _, h := range hosts {
allowed[ascii.ToLower(h)] = true
}
return router.HandlerFunc(func(e *router.Exchange) error {
var req Request
if err := e.BindJSON(&req); err != nil {
return err
}
// One parse serves both the host check and the signature, so the
// URL that passed the check is the very value that gets signed.
u, err := url.Parse(req.URL)
if err != nil {
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: "the URL cannot be parsed",
Cause: err,
}
}
if !allowed[ascii.ToLower(u.Host)] {
return &router.Error{
Status: http.StatusForbidden,
Reason: ReasonHostRefused,
Description: "the URL's host is not eligible for presigning",
}
}
expires := time.Duration(req.Expires) * time.Second
signed, err := s.Presign(req.Method, u, expires, nil)
if err != nil {
// Validation and the host check pass anything well-formed, so
// what remains are malformed corners such as an unparseable
// query.
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: "the URL cannot be presigned",
Cause: err,
}
}
return e.JSON(http.StatusOK, Response{
URL: signed,
ExpiresAt: s.now().UTC().Add(expires),
})
})
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package aws4
import "github.com/deep-rent/nexus/std/clock"
// Option configures a [Signer].
type Option func(*Signer)
// WithService overrides the service name inside the credential scope.
// Signature Version 4 itself is service-agnostic; [DefaultService] covers
// the object storage this package exists for. Empty values are ignored.
func WithService(service string) Option {
return func(s *Signer) {
if service != "" {
s.service = service
}
}
}
// WithClock overrides the time source, primarily for testing. A nil
// function is ignored. Defaults to [clock.System].
func WithClock(now clock.Clock) Option {
return func(s *Signer) {
if now != nil {
s.now = now
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package header
import (
"strings"
"github.com/deep-rent/nexus/std/quote"
)
// ETag returns the entity tag carried by the ETag header, or an empty string
// if there is none. The value is returned as sent, including its quotes and
// any weak-comparison prefix.
func ETag(h Getter) string {
return strings.TrimSpace(h.Get("ETag"))
}
// Getter is the subset of [net/http.Header] these helpers read from. Both
// request and response headers satisfy it.
type Getter interface {
// Get returns the first value associated with the given key.
Get(key string) string
}
// Quote wraps an entity tag in the double quotes that RFC 9110 requires,
// unless it already carries them or is marked weak. It is a convenience for
// callers deriving a tag from a version number or hash:
//
// h.Set("ETag", header.Quote(strconv.FormatInt(version, 10)))
func Quote(tag string) string {
tag = strings.TrimSpace(tag)
if tag == "" {
return ""
}
if quote.Has(tag) {
return tag
}
if after, found := strings.CutPrefix(tag, "W/"); found && quote.Has(after) {
return tag
}
return quote.Double(tag)
}
// MatchETag reports whether an If-None-Match header value matches the given
// entity tag.
//
// It applies the weak comparison prescribed for If-None-Match by RFC 9110,
// section 13.1.2: a "W/" prefix on either side is ignored, and "*" matches
// any current representation. A caller that has a tag for the resource can
// answer 304 Not Modified whenever this returns true:
//
// tag := header.Quote(version)
// w.Header().Set("ETag", tag)
// if header.MatchETag(r.Header.Get("If-None-Match"), tag) {
// w.WriteHeader(http.StatusNotModified)
// return
// }
//
// An empty header never matches, so a request that carries no validator is
// always answered in full.
func MatchETag(value, tag string) bool {
value = strings.TrimSpace(value)
if value == "" || tag == "" {
return false
}
if value == "*" {
return true
}
tag = weak(tag)
for candidate := range fields(value, ',') {
if weak(candidate) == tag {
return true
}
}
return false
}
// weak strips the weakness prefix from an entity tag, reducing it to the form
// used for weak comparison.
func weak(tag string) string {
return strings.TrimPrefix(strings.TrimSpace(tag), `W/`)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package header
import (
"mime"
"net/http"
"strings"
)
// Disposition renders a Content-Disposition value that survives a name
// written in any script. [Filename] reads it back.
//
// The disposition is always "attachment": a name worth escaping is a
// name attached to a download, and rendering such a file inline is how
// a stored document becomes stored script. Characters that would end
// the parameter or reach a path -- quotes, backslashes, separators, and
// controls -- are replaced with an underscore.
//
// A name outside ASCII travels twice: reduced to ASCII in the filename
// parameter, which every agent understands, and whole in the RFC 8187
// filename* parameter, which agents that understand it prefer. A name
// already within ASCII carries the one parameter, since the second
// would repeat it. An empty name becomes "attachment".
func Disposition(name string) string {
clean := strings.Map(func(r rune) rune {
if r < 0x20 || r == '"' || r == '\\' || r == '/' || r == 0x7f {
return '_'
}
return r
}, name)
if clean == "" {
clean = "attachment"
}
ascii := strings.Map(func(r rune) rune {
if r > 0x7e {
return '_'
}
return r
}, clean)
out := mime.FormatMediaType("attachment", map[string]string{
"filename": ascii,
})
if ascii == clean {
return out
}
return out + "; filename*=UTF-8''" + extended(clean)
}
// extended percent-encodes a value for an RFC 8187 extended parameter.
//
// It is not url.PathEscape: that one leaves "=", ":", "@" and others
// unescaped, since a path segment may carry them, and a Content-
// Disposition holding an unescaped "=" is one mime.ParseMediaType
// refuses outright -- taking the whole header down with it, filename
// and all.
func extended(s string) string {
const hex = "0123456789ABCDEF"
var b strings.Builder
b.Grow(len(s))
for i := range len(s) {
if c := s[i]; attrChar(c) {
b.WriteByte(c)
} else {
b.WriteByte('%')
b.WriteByte(hex[c>>4])
b.WriteByte(hex[c&0x0f])
}
}
return b.String()
}
// attrChar reports whether a byte may stand unescaped in an extended
// parameter value; see RFC 8187 Section 3.2.1. Every byte of a
// multi-byte rune falls outside the set and is encoded.
func attrChar(c byte) bool {
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
return true
}
switch c {
case '!', '#', '$', '&', '+', '-', '.', '^', '_', '`', '|', '~':
return true
}
return false
}
// Filename extracts the intended filename from a Content-Disposition header.
//
// It automatically handles both the standard "filename" parameter and the
// RFC 6266 "filename*" parameter, which is used for non-ASCII (UTF-8) names.
// It returns an empty string if the header is missing, malformed, or does
// not contain a filename.
//
// The value is chosen by whoever sent the response, so it is reduced to a bare
// base name: directory components are stripped, and names that would resolve
// to a directory or carry a null byte are rejected. Without this, a header
// such as `attachment; filename="../../etc/passwd"` would hand the caller a
// path that escapes the directory it is joined to. The result is still
// untrusted input and should not be used as a path without further checks.
func Filename(h http.Header) string {
v := h.Get("Content-Disposition")
if v == "" {
return ""
}
_, params, err := mime.ParseMediaType(v)
if err != nil {
return ""
}
// The filename* parameter is decoded automatically.
return basename(params["filename"])
}
// basename reduces a filename supplied by a remote party to its last path
// element, rejecting values that cannot name a file.
func basename(name string) string {
// Both separators are stripped regardless of the host platform, since the
// sender may report a Windows path to a server running elsewhere.
if i := strings.LastIndexAny(name, `/\`); i >= 0 {
name = name[i+1:]
}
name = strings.TrimSpace(name)
if name == "." || name == ".." || strings.ContainsRune(name, 0) {
return ""
}
return name
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package header
import (
"iter"
"net/http"
"slices"
"strconv"
"strings"
"time"
"github.com/deep-rent/nexus/std/ascii"
"github.com/deep-rent/nexus/std/clock"
)
// Directives parses a comma-separated header value into an iterator of
// key-value pairs.
//
// For example, parsing "no-cache, max-age=3600" would yield twice: first
// "no-cache", "" and then "max-age", "3600".
//
// Keys are lowercased. Values are unquoted, and commas inside a quoted value
// do not split the header, so `no-cache="Set-Cookie", max-age=60` yields
// "no-cache", "Set-Cookie" followed by "max-age", "60".
func Directives(s string) iter.Seq2[string, string] {
return func(yield func(string, string) bool) {
for kv := range fields(s, ',') {
k, v, ok := strings.Cut(strings.TrimSpace(kv), "=")
k = ascii.ToLower(strings.TrimSpace(k))
if ok {
v = unquote(strings.TrimSpace(v))
}
if !yield(k, v) {
return
}
}
}
}
// Throttle determines the required delay before the next request based on
// rate-limiting headers in the response. It reads the current time from the
// given clock to calculate relative times. If no throttling is indicated, it
// returns a duration of 0.
func Throttle(h http.Header, now clock.Clock) time.Duration {
if v := h.Get("Retry-After"); v != "" {
if d, err := strconv.ParseInt(v, 10, 64); err == nil && d > 0 {
return time.Duration(d) * time.Second
}
if t, err := http.ParseTime(v); err == nil {
if d := t.Sub(now()); d > 0 {
return d
}
}
}
if h.Get("X-Ratelimit-Remaining") == "0" {
if v := h.Get("X-Ratelimit-Reset"); v != "" {
if t, err := strconv.ParseInt(v, 10, 64); err == nil && t > 0 {
if d := time.Unix(t, 0).Sub(now()); d > 0 {
return d
}
}
}
}
return 0
}
// Credentials extracts the credentials from the Authorization header of an
// HTTP request for a specific authentication scheme (e.g., "Basic", "Bearer").
//
// It returns the raw credentials as-is, or an empty string if the header is
// not present, not well-formed, or does not match the specified scheme. The
// scheme comparison is case-insensitive.
func Credentials(h http.Header, scheme string) string {
auth := h.Get("Authorization")
if auth == "" {
return ""
}
prefix, credentials, ok := strings.Cut(auth, " ")
if !ok || !strings.EqualFold(prefix, scheme) {
return ""
}
return credentials
}
// Preferences parses a header value with quality factors (e.g., Accept,
// Accept-Encoding, Accept-Language) into an iterator of quality factors
// (q-value) by name (media range). The values are yielded in the order they
// appear in the header, not sorted by quality. Values without an explicit
// q-factor are assigned a default quality of 1.0. Malformed q-factors are
// also treated as 1.0, while out-of-range values are clamped into the
// [0.0, 1.0] interval.
func Preferences(s string) iter.Seq2[string, float64] {
return func(yield func(string, float64) bool) {
for part := range fields(s, ',') {
part = strings.TrimSpace(part)
if part == "" {
continue
}
params := slices.Collect(fields(part, ';'))
q := 1.0
for i := 1; i < len(params); i++ {
p := strings.TrimSpace(params[i])
k, v, found := strings.Cut(p, "=")
if found && strings.TrimSpace(k) == "q" {
v = unquote(strings.TrimSpace(v))
if f, err := strconv.ParseFloat(v, 64); err == nil {
q = min(1.0, max(0.0, f))
}
break
}
}
if !yield(strings.TrimSpace(params[0]), q) {
return
}
}
}
}
// Accepts checks if the given key is accepted based on a header value with
// quality factors (e.g., Accept, Accept-Encoding, or Accept-Language).
// It properly weights exact matches over partial wildcards (e.g., "text/*")
// and global wildcards ("*/*" or "*"), returning true if the best match has
// a q-value greater than zero.
func Accepts(s, key string) bool {
var (
maxQ float64
maxP int
)
// Extract the major type (e.g., "text" from "text/html") for partial
// wildcards.
major, _, has := strings.Cut(key, "/")
for k, q := range Preferences(s) {
var p int
switch {
case k == key:
p = 3 // Exact match (highest precedence)
case has && k == major+"/*":
p = 2 // Partial wildcard match (e.g., "text/*")
case k == "*/*" || k == "*":
p = 1 // Global wildcard match
}
// Update if we found a more specific match than our current best.
if p > maxP {
maxP = p
maxQ = q
}
}
// It is accepted if we found a valid match and its q-value is greater than
// 0.
return maxP > 0 && maxQ > 0
}
// MediaType extracts and returns the media type from a Content-Type header.
// It returns the media type in lowercase, trimmed of whitespace. If the header
// is empty or malformed, it returns an empty string.
//
// This function is similar to [mime.ParseMediaType] but does not return any
// parameters and ignores parsing errors.
func MediaType(h http.Header) string {
v := h.Get("Content-Type")
if v == "" {
return ""
}
i := strings.IndexByte(v, ';')
if i != -1 {
v = v[:i]
}
return ascii.ToLower(strings.TrimSpace(v))
}
// Links parses an RFC 5988 Link header into an iterator of relation types (rel)
// and their corresponding URLs.
//
// If a link has multiple space-separated relations (e.g., rel="next archive"),
// it yields the URL for each relation separately. Commas inside the angle
// brackets belong to the link target and do not separate links, which matters
// for URLs whose query string enumerates several values.
func Links(s string) iter.Seq2[string, string] {
return func(yield func(string, string) bool) {
for part := range fields(s, ',') {
sidx := strings.IndexByte(part, '<')
eidx := strings.IndexByte(part, '>')
// Ensure the URL brackets are present and valid.
if sidx == -1 || eidx == -1 || sidx >= eidx {
continue
}
url := part[sidx+1 : eidx]
// Parse the parameters following the URL.
params := fields(part[eidx+1:], ';')
for p := range params {
p = strings.TrimSpace(p)
k, v, found := strings.Cut(p, "=")
if found && ascii.ToLower(strings.TrimSpace(k)) == "rel" {
// Remove optional quotes around the relation value.
v = unquote(strings.TrimSpace(v))
// A single link can have multiple relation types.
for rel := range strings.FieldsSeq(v) {
if !yield(ascii.ToLower(rel), url) {
return
}
}
}
}
}
}
}
// Link extracts the URL for a specific relation (e.g., "next" or "last") from
// a Link header. It returns an empty string if the relation is not found.
func Link(s, rel string) string {
rel = ascii.ToLower(rel)
for k, v := range Links(s) {
if k == rel {
return v
}
}
return ""
}
// Header represents a single HTTP header key-value pair.
type Header struct {
// Key is the canonicalized header name.
Key string
// Value is the raw value of the header.
Value string
}
// String formats the header as "Key: Value".
func (h Header) String() string {
return h.Key + ": " + h.Value
}
// New creates a new [Header] with the given key and value. The key is
// automatically canonicalized to the standard HTTP header format.
func New(key, value string) Header {
return Header{
Key: http.CanonicalHeaderKey(key),
Value: value,
}
}
// UserAgent constructs a User-Agent header with the specified name, version,
// and an optional comment. The resulting value follows the format "name/version
// (comment)". The first part is the product token, while the parenthesized
// section provides supplementary information about the client. For external
// calls, it is best practice to include maintainer contact details in the
// comment (such as an URL or email address).
func UserAgent(name, version, comment string) Header {
value := name + "/" + version
if comment != "" {
value += " (" + comment + ")"
}
return Header{
Key: "User-Agent",
Value: value,
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package header
import (
"net/http"
"strconv"
"time"
"github.com/deep-rent/nexus/std/clock"
)
// Lifetime determines the cache lifetime of a response based on caching
// headers. It reads the current time from the given clock to calculate
// relative times. It returns a duration of 0 if the response is not cacheable
// or does not carry any caching information.
//
// Directives are evaluated as a set rather than in the order they appear, so
// a no-store or no-cache anywhere in Cache-Control suppresses the lifetime
// even when a max-age precedes it. A no-cache that names specific fields
// (no-cache="Set-Cookie") only marks those fields for revalidation and leaves
// the lifetime intact.
//
// The time a response has already spent in upstream caches, as reported by
// the Age header, is subtracted from a max-age. No such correction applies to
// Expires, which names an absolute instant and is therefore measured against
// the clock directly.
func Lifetime(h http.Header, now clock.Clock) time.Duration {
// Cache-Control takes precedence over Expires
if v := h.Get("Cache-Control"); v != "" {
var (
maxAge time.Duration
found bool
)
for k, v := range Directives(v) {
switch k {
case "no-store":
return 0
case "no-cache":
// Only the unqualified form forbids reuse outright.
if v == "" {
return 0
}
case "max-age":
if d, err := strconv.ParseInt(v, 10, 64); err == nil {
// A negative age denotes a response that is already
// stale, not one that expired in the past.
maxAge, found = max(0, time.Duration(d)*time.Second), true
}
}
}
if found {
// What remains of the age budget after the time the response
// already spent being relayed.
return max(0, maxAge-Age(h))
}
}
if v := h.Get("Expires"); v != "" {
if t, err := http.ParseTime(v); err == nil {
if d := t.Sub(now()); d > 0 {
return d
}
}
}
return 0
}
// Age reports how long a response has been held in caches on its way to the
// client, as stated by the Age header. It returns 0 if the header is absent,
// malformed, or negative.
func Age(h http.Header) time.Duration {
v := h.Get("Age")
if v == "" {
return 0
}
d, err := strconv.ParseInt(v, 10, 64)
if err != nil || d < 0 {
return 0
}
return time.Duration(d) * time.Second
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package header
import (
"iter"
"strings"
)
// fields splits a header value on the given separator, skipping separators
// that appear inside a quoted string or between angle brackets.
//
// A plain [strings.Split] is not sufficient for header values: RFC 9110 allows
// list members to carry quoted strings, and RFC 8288 wraps link targets in
// angle brackets. Both may legitimately contain the separator, as in
// `no-cache="Set-Cookie", max-age=60` or a link whose query string enumerates
// several identifiers.
//
// Fields are yielded verbatim, including any surrounding whitespace and
// quotes.
func fields(s string, sep byte) iter.Seq[string] {
return func(yield func(string) bool) {
var (
start int // index at which the current field begins
quoted bool // inside a quoted string
escape bool // previous byte was a backslash within quotes
angle bool // inside angle brackets
)
for i := range len(s) {
switch c := s[i]; {
case escape:
// Any byte following a backslash is literal.
escape = false
case quoted && c == '\\':
escape = true
case c == '"':
quoted = !quoted
case quoted:
// Separators inside a quoted string belong to the value.
case c == '<':
angle = true
case c == '>':
angle = false
case c == sep && !angle:
if !yield(s[start:i]) {
return
}
start = i + 1
}
}
// The remainder after the last separator forms the final field. An
// empty input yields a single empty field, matching strings.Split.
yield(s[start:])
}
}
// unquote removes the surrounding double quotes from a header parameter value
// and resolves any backslash escapes within them. Values that are not quoted
// are returned unchanged.
func unquote(s string) string {
if len(s) < 2 || s[0] != '"' || s[len(s)-1] != '"' {
return s
}
s = s[1 : len(s)-1]
if !strings.ContainsRune(s, '\\') {
return s
}
var b strings.Builder
b.Grow(len(s))
for i := 0; i < len(s); i++ {
if s[i] == '\\' && i+1 < len(s) {
i++
}
b.WriteByte(s[i])
}
return b.String()
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package header
import (
"net/http"
"slices"
)
// transport is an internal [http.RoundTripper] that injects static headers.
type transport struct {
// wrapped is the underlying [http.RoundTripper].
wrapped http.RoundTripper
// headers are the static headers to be injected into each request.
headers []Header
}
// RoundTrip clones the request and adds static headers before delegating.
func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) {
clone := req.Clone(req.Context())
for _, h := range t.headers {
clone.Header.Set(h.Key, h.Value)
}
return t.wrapped.RoundTrip(clone)
}
var _ http.RoundTripper = (*transport)(nil)
// NewTransport wraps a base transport and sets a static set of headers on
// each outgoing request. If no headers are provided, the base transport is
// returned unmodified.
//
// The headers are copied, so later changes to the caller's slice do not affect
// the transport. The resulting transport also clones each request before
// delegating, so the original request is not changed either.
func NewTransport(
t http.RoundTripper,
headers ...Header,
) http.RoundTripper {
if len(headers) == 0 {
return t
}
return &transport{
wrapped: t,
// A variadic call site may pass a slice the caller keeps hold of.
headers: slices.Clone(headers),
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package cors
import (
"net/http"
"github.com/deep-rent/nexus/net/middleware"
)
// wildcard is a special value that can be passed in configuration to allow
// requests from any origin.
const wildcard = "*"
// New creates a middleware [middleware.Pipe] that handles CORS requests.
//
// The middleware distinguishes between preflight and actual requests. Preflight
// (OPTIONS) requests are intercepted and terminated with a 204 No Content
// response. For actual requests, it adds the necessary CORS headers to the
// response before passing control to the next handler. Non-CORS requests are
// passed through without modification.
//
// It panics if credentials are enabled without an explicit origin whitelist:
// reflecting arbitrary origins alongside Access-Control-Allow-Credentials
// would let any website perform authenticated requests on behalf of visiting
// users and read the responses.
func New(opts ...Option) middleware.Pipe {
cfg := config{}
for _, opt := range opts {
opt(&cfg)
}
if cfg.allowCredentials && cfg.allowedOrigins == nil {
panic("cors: credentials require explicit allowed origins")
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if proceed := handle(&cfg, w, r); proceed {
next.ServeHTTP(w, r)
}
})
}
}
// handle processes CORS headers for the given request.
//
// It returns true if the request should be passed to the next handler. It
// returns false if the request has been fully handled, such as in a preflight
// request.
func handle(cfg *config, w http.ResponseWriter, r *http.Request) bool {
origin := r.Header.Get("Origin")
// Pass through non-CORS requests.
if origin == "" {
return true
}
// Apply this header immediately to ensure caches respect the difference
// between allowed and disallowed origin responses.
h := w.Header()
h.Add("Vary", "Origin")
preflight := r.Method == http.MethodOptions
// Pass through invalid preflight requests.
if preflight && r.Header.Get("Access-Control-Request-Method") == "" {
return true
}
if preflight {
// Preflight responses also depend on the requested method and
// headers, so caches must key on them as well.
h.Add("Vary", "Access-Control-Request-Method")
h.Add("Vary", "Access-Control-Request-Headers")
}
// Validate origin if not in wildcard mode.
if cfg.allowedOrigins != nil {
if _, ok := cfg.allowedOrigins[origin]; !ok {
return true // Let non-matching origins pass through without CORS headers.
}
}
// With no whitelist and no credentials, reflecting the specific origin
// would needlessly fragment caches; the wildcard is exactly as
// permissive and cacheable across origins.
if !cfg.allowCredentials && cfg.allowedOrigins == nil {
origin = wildcard
}
h.Set("Access-Control-Allow-Origin", origin)
if cfg.allowCredentials {
h.Set("Access-Control-Allow-Credentials", "true")
}
// Handle preflight requests.
if preflight {
if cfg.allowedMethods != "" {
h.Set("Access-Control-Allow-Methods", cfg.allowedMethods)
}
if cfg.allowedHeaders != "" {
h.Set("Access-Control-Allow-Headers", cfg.allowedHeaders)
}
if cfg.maxAge != "" {
h.Set("Access-Control-Max-Age", cfg.maxAge)
}
w.WriteHeader(http.StatusNoContent)
return false // Terminate request chain.
}
// Handle actual requests.
if cfg.exposedHeaders != "" {
h.Set("Access-Control-Expose-Headers", cfg.exposedHeaders)
}
return true
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package cors
import (
"slices"
"strconv"
"strings"
"time"
)
// config stores the pre-computed configuration for internal use.
type config struct {
// allowedOrigins is the whitelist of permitted Origin values.
allowedOrigins map[string]struct{}
// allowedMethods is the pre-joined string for Access-Control-Allow-Methods.
allowedMethods string
// allowedHeaders is the pre-joined string for Access-Control-Allow-Headers.
allowedHeaders string
// exposedHeaders is the pre-joined string for
// Access-Control-Expose-Headers.
exposedHeaders string
// allowCredentials maps to the Access-Control-Allow-Credentials header.
allowCredentials bool
// maxAge is the string representation of Access-Control-Max-Age in seconds.
maxAge string
}
// Option is a function that configures the CORS middleware.
type Option func(*config)
// WithAllowedOrigins sets the allowed origins for CORS requests.
//
// By default, all origins are allowed. The same behavior can be achieved by
// leaving the list empty or by manually including the special wildcard "*". In
// other cases, this option restricts requests to a specific whitelist. If
// credentials are enabled via [WithAllowCredentials], browsers forbid a
// wildcard origin, and this middleware will dynamically reflect the request's
// Origin header if it is in the allowed list.
//
// Origins are compared byte-for-byte against the browser-supplied Origin
// header, so list them exactly as browsers serialize them: lowercase scheme
// and host, no trailing slash, and no port for scheme defaults (e.g.
// "https://app.example.com" or "http://localhost:3000").
func WithAllowedOrigins(origins ...string) Option {
return func(c *config) {
if len(origins) != 0 && !slices.Contains(origins, wildcard) {
c.allowedOrigins = make(map[string]struct{}, len(origins))
for _, origin := range origins {
c.allowedOrigins[origin] = struct{}{}
}
}
}
}
// WithAllowedMethods sets the allowed HTTP methods for CORS requests.
//
// If no methods are provided, this header is omitted by default, and only
// simple methods (GET, POST, HEAD) are implicitly allowed by browsers for
// non-preflighted requests. It is recommended to list all methods your API
// supports, including OPTIONS.
func WithAllowedMethods(methods ...string) Option {
return func(c *config) {
if len(methods) != 0 {
c.allowedMethods = strings.Join(methods, ", ")
}
}
}
// WithAllowedHeaders sets the allowed HTTP headers for CORS requests.
//
// This is necessary for any non-standard headers the client needs to send,
// such as "Authorization" or custom "X-" headers. If not set, browsers will
// only permit requests with CORS-safelisted request headers.
func WithAllowedHeaders(headers ...string) Option {
return func(c *config) {
if len(headers) != 0 {
c.allowedHeaders = strings.Join(headers, ", ")
}
}
}
// WithExposedHeaders sets the HTTP headers safe to expose to the API.
//
// By default, client-side scripts can only access a limited set of simple
// response headers. This option lists additional headers (like a custom
// "X-Pagination-Total" header) that should be made accessible to the script.
func WithExposedHeaders(headers ...string) Option {
return func(c *config) {
if len(headers) != 0 {
c.exposedHeaders = strings.Join(headers, ", ")
}
}
}
// WithAllowCredentials indicates if the response can be exposed with
// credentials.
//
// When used as part of a response to a preflight request, it indicates that the
// actual request can include cookies and other user credentials. This option
// defaults to false. Note that browsers require a specific origin (not a
// wildcard) in the Access-Control-Allow-Origin header when this is enabled;
// consequently, [New] panics if credentials are enabled without an explicit
// origin whitelist configured via [WithAllowedOrigins].
func WithAllowCredentials(allow bool) Option {
return func(c *config) {
c.allowCredentials = allow
}
}
// WithMaxAge indicates how long preflight results can be cached, in seconds.
//
// If set to 0 (the default), the header is omitted. Be aware that browsers
// have a default internal limit (usually 5 seconds) when this header is
// missing. This results in a preflight request for almost every API call, which
// can double the traffic to your server. It is recommended to set this to a
// higher value (e.g., 10 minutes) for stable APIs to reduce latency.
func WithMaxAge(d time.Duration) Option {
return func(c *config) {
if d > 0 {
c.maxAge = strconv.FormatInt(int64(d.Seconds()), 10)
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package gzip
import (
"bufio"
"compress/gzip"
"errors"
"io"
"net"
"net/http"
"strings"
"sync"
"github.com/deep-rent/nexus/net/header"
"github.com/deep-rent/nexus/net/middleware"
)
// interceptor wraps an [http.ResponseWriter] to compress the response body.
//
// It transparently compresses the response body with gzip. It also implements
// [http.Hijacker] and [http.Flusher] to support protocol upgrades and
// streaming.
type interceptor struct {
// ResponseWriter is the underlying writer being wrapped.
http.ResponseWriter
// gz is the active gzip writer for the current response.
gz *gzip.Writer
// exclude is the list of MIME types to skip.
exclude []string
// pool is the sync.Pool used for gzip writer reuse.
pool *sync.Pool
// wrote tracks if WriteHeader has been called.
wrote bool
// hijacked tracks if the connection has been hijacked.
hijacked bool
// skip determines whether to skip compression for this response.
skip bool
}
// WriteHeader sets the Content-Encoding header and deletes Content-Length.
//
// Deleting Content-Length is crucial, as the size of the compressed content is
// unknown until it is fully written.
func (w *interceptor) WriteHeader(statusCode int) {
// Forward informational (1xx) responses without latching any state; the
// final status line and the compression decision are still to come.
if statusCode < 200 {
w.ResponseWriter.WriteHeader(statusCode)
return
}
if w.wrote {
return
}
w.wrote = true
// Responses that must not carry a body would otherwise receive the gzip
// header and footer bytes, which the server rejects.
if statusCode == http.StatusNoContent ||
statusCode == http.StatusResetContent ||
statusCode == http.StatusNotModified {
w.skip = true
}
if w.ResponseWriter.Header().Get("Content-Encoding") != "" {
w.skip = true
}
mime := header.MediaType(w.Header())
if mime != "" {
for _, t := range w.exclude {
if strings.HasSuffix(t, "*") {
if strings.HasPrefix(mime, t[:len(t)-1]) {
w.skip = true
break
}
} else {
if mime == t {
w.skip = true
break
}
}
}
}
if !w.skip {
w.Header().Set("Content-Encoding", "gzip")
w.Header().Del("Content-Length")
w.gz = w.pool.Get().(*gzip.Writer)
w.gz.Reset(w.ResponseWriter)
}
w.ResponseWriter.WriteHeader(statusCode)
}
// Write compresses the data and writes it to the underlying
// [http.ResponseWriter].
//
// It also handles setting the Content-Encoding header on the first write.
func (w *interceptor) Write(b []byte) (int, error) {
if !w.wrote {
w.WriteHeader(http.StatusOK)
}
if w.skip {
return w.ResponseWriter.Write(b)
}
return w.gz.Write(b)
}
// Close flushes buffered data, closes the gzip writer, and returns it to the
// pool.
func (w *interceptor) Close() {
// If the connection was hijacked, don't write the gzip footer.
// Just return the writer to the pool.
if w.gz != nil {
if !w.hijacked {
_ = w.gz.Close()
}
w.gz.Reset(io.Discard)
w.pool.Put(w.gz)
w.gz = nil
}
}
// Hijack implements the [http.Hijacker] interface.
//
// It allows the underlying connection to be taken over for protocol upgrades
// like WebSockets.
func (w *interceptor) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hijacker, ok := w.ResponseWriter.(http.Hijacker)
if !ok {
return nil, nil, errors.New("hijacking not supported")
}
w.hijacked = true
return hijacker.Hijack()
}
// Flush implements the [http.Flusher] interface.
//
// It enables incremental flushing of the response body, which is useful for
// streaming data.
func (w *interceptor) Flush() {
// Flushing transmits the response headers, so the compression decision
// must be made first; otherwise a later Write would start a gzip stream
// whose Content-Encoding header can no longer be announced.
if !w.wrote {
w.WriteHeader(http.StatusOK)
}
if flusher, ok := w.ResponseWriter.(http.Flusher); ok {
if w.gz != nil {
_ = w.gz.Flush()
}
flusher.Flush()
}
}
// Ensure interceptor implements the necessary contracts.
var (
_ http.ResponseWriter = (*interceptor)(nil)
_ http.Hijacker = (*interceptor)(nil)
_ http.Flusher = (*interceptor)(nil)
)
// New creates a middleware [middleware.Pipe] that compresses HTTP responses.
//
// The middleware is a no-op if the client does not send an Accept-Encoding
// header including "gzip" or if the response already has a non-empty
// Content-Encoding header. It adds the "Vary: Accept-Encoding" header to
// responses to prevent cache poisoning.
func New(opts ...Option) middleware.Pipe {
cfg := config{
level: DefaultCompression,
exclude: defaultExcludeList,
}
for _, opt := range opts {
opt(&cfg)
}
pool := &sync.Pool{
New: func() any {
// Errors are ignored as they only occur with an invalid level,
// which we guard against in the option.
gw, _ := gzip.NewWriterLevel(io.Discard, cfg.level)
return gw
},
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip HEAD requests (no body to compress) and clients that do
// not accept gzip compression.
if r.Method == http.MethodHead ||
!header.Accepts(r.Header.Get("Accept-Encoding"), "gzip") ||
w.Header().Get("Content-Encoding") != "" {
next.ServeHTTP(w, r)
return
}
// Create the gzip response writer.
gzw := &interceptor{
ResponseWriter: w,
exclude: cfg.exclude,
pool: pool,
}
defer gzw.Close()
// Indicate that the response is subject to content negotiation.
gzw.Header().Add("Vary", "Accept-Encoding")
next.ServeHTTP(gzw, r)
})
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package gzip
import (
"compress/gzip"
"github.com/deep-rent/nexus/std/ascii"
)
// Mirror constants from the [compress/gzip] package for easy access without
// requiring an extra import.
const (
// BestCompression provides the highest level of compression.
BestCompression = gzip.BestCompression
// BestSpeed provides the fastest compression time.
BestSpeed = gzip.BestSpeed
// DefaultCompression provides a balance between speed and ratio.
DefaultCompression = gzip.DefaultCompression
// NoCompression disables compression entirely.
NoCompression = gzip.NoCompression
)
// defaultExcludeList lists common media types that are already compressed.
var defaultExcludeList = []string{
// Media
"image/*",
"video/*",
"audio/*",
// Fonts
"font/*",
// Archives & Documents
"application/zip",
"application/gzip",
"application/pdf",
"application/wasm",
}
// config holds the middleware configuration.
type config struct {
// level is the compression level.
level int
// exclude is the list of MIME types to skip.
exclude []string
}
// Option is a function that configures the middleware.
type Option func(*config)
// WithCompressionLevel sets the compression level.
//
// It accepts values ranging from [BestSpeed] (1) to [BestCompression] (9). For
// other values, it will fall back to [DefaultCompression], a good balance
// between speed and compression ratio.
func WithCompressionLevel(level int) Option {
return func(c *config) {
if level >= NoCompression && level <= BestCompression {
c.level = level
}
}
}
// WithExcludeMimeTypes adds MIME types to the exclusion list.
//
// This option is additive and can be called multiple times; it appends to the
// default exclusion list rather than replacing it. The matching logic supports
// two formats:
//
// - Exact: Provide the full MIME type (e.g., "application/pdf").
// - Prefix: End the MIME type with a wildcard "*" (e.g., "image/*")
// to exclude all subtypes for that primary type.
func WithExcludeMimeTypes(types ...string) Option {
return func(c *config) {
for _, t := range types {
c.exclude = append(c.exclude, ascii.ToLower(t))
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package limit
import (
"net"
"net/http"
"sync"
"time"
"github.com/deep-rent/nexus/net/router"
)
// ReasonRateLimited is returned when a caller exceeds their request
// rate. The response carries a Retry-After header naming the earliest
// sensible retry.
const ReasonRateLimited router.Reason = "rate_limited"
// sweepInterval bounds how often the bucket map sheds idle entries; the
// sweep runs inline under the lock, so it stays infrequent.
const sweepInterval = time.Minute
// bucket is one key's token bucket.
type bucket struct {
tokens float64
last time.Time
}
// limiter meters keys against a shared rate and burst.
type limiter struct {
mu sync.Mutex
buckets map[string]*bucket
rate float64 // tokens added per second
burst float64 // bucket capacity
swept time.Time
}
// allow reports whether the key may proceed at the given instant,
// consuming one token if so.
func (l *limiter) allow(key string, at time.Time) bool {
l.mu.Lock()
defer l.mu.Unlock()
b, ok := l.buckets[key]
if !ok {
b = &bucket{tokens: l.burst, last: at}
l.buckets[key] = b
}
if elapsed := at.Sub(b.last).Seconds(); elapsed > 0 {
b.tokens = min(l.burst, b.tokens+elapsed*l.rate)
b.last = at
}
// A bucket idle long enough to have refilled completely is
// indistinguishable from a fresh one, so it can be dropped; the
// occasional inline sweep keeps the map bounded by the set of
// recently active keys.
if at.Sub(l.swept) > sweepInterval {
l.swept = at
idle := time.Duration(l.burst / l.rate * float64(time.Second))
for k, o := range l.buckets {
if o != b && at.Sub(o.last) > idle {
delete(l.buckets, k)
}
}
}
if b.tokens < 1 {
return false
}
b.tokens--
return true
}
// New returns a [router.Middleware] that meters requests per key with a
// token bucket: every key sustains [WithRate] requests per second and
// may burst up to [WithBurst] at once. Beyond that, requests answer 429
// with a Retry-After header.
//
// The key defaults to the caller's remote address; route-scoped
// deployments behind an auth guard should key on the authenticated
// subject instead (see [WithKey]). Requests whose key resolves empty
// pass unmetered — an empty key means the dimension does not apply, not
// that the caller is anonymous.
//
// State lives in memory and is per instance: in a multi-replica
// deployment each replica meters its own share of the traffic, so the
// effective ceiling scales with the replica count. That is the right
// shape for its purpose — protecting each process from a hot client —
// not for enforcing precise global quotas.
func New(opts ...Option) router.Middleware {
cfg := defaults()
for _, opt := range opts {
opt(&cfg)
}
l := &limiter{
buckets: make(map[string]*bucket),
rate: cfg.rate,
burst: float64(cfg.burst),
}
retryAfter := "1"
if cfg.rate < 1 {
retryAfter = "60"
}
return func(next router.Handler) router.Handler {
return router.HandlerFunc(func(e *router.Exchange) error {
key := cfg.key(e)
if key == "" {
return next.ServeHTTP(e)
}
if !l.allow(key, cfg.now()) {
e.W.Header().Set("Retry-After", retryAfter)
return &router.Error{
Status: http.StatusTooManyRequests,
Reason: ReasonRateLimited,
Description: "request rate exceeded; slow down",
}
}
return next.ServeHTTP(e)
})
}
}
// remote keys a request by its remote IP, the only dimension available
// without authentication.
func remote(e *router.Exchange) string {
host, _, err := net.SplitHostPort(e.R.RemoteAddr)
if err != nil {
return e.R.RemoteAddr
}
return host
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package limit
import (
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/std/clock"
)
// Defaults of the metering knobs left unset by the options.
const (
// DefaultRate sustains ten requests per second per key — generous
// for interactive clients, tight enough to blunt a hot loop.
DefaultRate = 10.0
// DefaultBurstFactor sizes the default burst allowance as a multiple
// of the sustained rate, absorbing sub-second spikes like an app
// start syncing several document types at once.
DefaultBurstFactor = 3
)
// Key extracts the metering key from a request. An empty key exempts
// the request from metering.
type Key func(e *router.Exchange) string
// config holds the middleware configuration.
type config struct {
rate float64
burst int
key Key
now clock.Clock
}
// defaults returns the baseline configuration.
func defaults() config {
return config{
rate: DefaultRate,
burst: DefaultBurstFactor * DefaultRate,
key: remote,
now: clock.System,
}
}
// Option is a functional option for configuring the middleware.
type Option func(*config)
// WithRate sets the sustained per-key request rate in requests per
// second and scales the default burst with it. Non-positive values are
// ignored.
func WithRate(perSecond float64) Option {
return func(c *config) {
if perSecond > 0 {
c.rate = perSecond
c.burst = max(int(DefaultBurstFactor*perSecond), 1)
}
}
}
// WithBurst overrides the burst allowance: how many requests a key may
// spend at once beyond the sustained rate. Non-positive values are
// ignored. Apply after [WithRate], which resets the burst to its
// rate-scaled default.
func WithBurst(n int) Option {
return func(c *config) {
if n > 0 {
c.burst = n
}
}
}
// WithKey overrides the metering key. Requests whose key resolves empty
// pass unmetered. A nil key is ignored.
func WithKey(k Key) Option {
return func(c *config) {
if k != nil {
c.key = k
}
}
}
// WithClock injects the time source, which is primarily useful for
// testing. A nil clock is ignored.
func WithClock(now clock.Clock) Option {
return func(c *config) {
if now != nil {
c.now = now
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package measure
import (
"context"
"net/http"
"strconv"
"strings"
"time"
"github.com/deep-rent/nexus/net/middleware"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/sys/metrics"
)
// RequestDuration is the name of the summary recorded by [New] and [Pipe].
const RequestDuration = "http_server_request_duration_seconds"
// routeKey is the context key under which the middleware stores its route
// holder.
type routeKey struct{}
// routeHolder carries the matched route pattern by reference.
//
// [http.ServeMux] stamps the pattern onto the request it receives, but any
// middleware between this one and the mux that calls
// [http.Request.WithContext] hands the mux a shallow clone, hiding the
// pattern from the request this middleware holds. A pointer in the context
// survives every clone.
type routeHolder struct {
pattern string
}
// SetRoute records the matched route pattern, which is used to tag the
// request duration summary. It is a no-op if the request is not being
// measured.
//
// [New] calls this with the pattern matched by the router; custom handlers
// only need it when they resolve routes themselves.
func SetRoute(ctx context.Context, pattern string) {
if holder, ok := ctx.Value(routeKey{}).(*routeHolder); ok {
holder.pattern = pattern
}
}
// GetRoute returns the route pattern recorded via [SetRoute], or the empty
// string if none was recorded.
func GetRoute(ctx context.Context) string {
if holder, ok := ctx.Value(routeKey{}).(*routeHolder); ok {
return holder.pattern
}
return ""
}
// config holds the configuration for the middleware.
type config struct {
registry *metrics.Registry
filter func(r *http.Request) bool
skip map[string]struct{}
}
// Option configures the middleware.
type Option func(*config)
// WithRegistry sets the destination registry. It defaults to
// [metrics.DefaultRegistry]. A nil value is ignored.
func WithRegistry(reg *metrics.Registry) Option {
return func(c *config) {
if reg != nil {
c.registry = reg
}
}
}
// WithFilter limits measurement to requests for which the given callback
// returns true. Filtered requests pass through unrecorded. A nil function is
// ignored.
func WithFilter(keep func(r *http.Request) bool) Option {
return func(c *config) {
if keep != nil {
c.filter = keep
}
}
}
// WithSkip excludes requests whose URL path exactly matches one of the
// given paths from measurement. It is a convenience for high-frequency
// probe endpoints, such as the ones mounted by the health package:
//
// measure.New(measure.WithSkip(
// "/health", "/health/live", "/health/ready",
// ))
//
// For more elaborate rules, use [WithFilter].
func WithSkip(paths ...string) Option {
return func(c *config) {
if c.skip == nil {
c.skip = make(map[string]struct{}, len(paths))
}
for _, p := range paths {
c.skip[p] = struct{}{}
}
}
}
// New returns a [router.Middleware] that records every request in the
// [RequestDuration] summary.
//
// On top of what [Pipe] records, it reports the route pattern the router
// matched ([http.Request.Pattern], e.g. "GET /users/{id}") via [SetRoute]
// before the handler runs, so the summary is tagged with the route (e.g.
// "/users/{id}") rather than left untagged. Since [router.Adapt] resolves
// handler errors inside the pipe, the summary also observes the final
// status code produced by the error handler.
//
// A [router.Router] recovers panics from the whole chain, so this middleware
// records the resulting 500 wherever it sits.
func New(opts ...Option) router.Middleware {
adapted := router.Adapt(Pipe(opts...))
return func(next router.Handler) router.Handler {
return adapted(router.HandlerFunc(func(e *router.Exchange) error {
SetRoute(e.Context(), e.R.Pattern)
return next.ServeHTTP(e)
}))
}
}
// Pipe returns a [middleware.Pipe] that records every HTTP request in the
// [RequestDuration] summary, tagged with the method, the matched route
// pattern, and the response status code.
//
// Behind a [router.Router], prefer [New], which resolves the route for you.
// The route becomes known only after the multiplexer has matched: it is
// either recorded via [SetRoute] or read from [http.Request.Pattern] when
// this pipe sits directly in front of the multiplexer, and is empty
// otherwise. Tagging by pattern rather than raw path keeps the metric
// cardinality bounded.
//
// A panic in a downstream handler is recorded as a 500 and re-raised, so
// [middleware.Recover] must still sit outside this pipe in the chain:
//
// middleware.Chain(mux,
// middleware.Recover(logger),
// measure.Pipe(),
// middleware.RequestID(),
// middleware.Log(logger),
// )
func Pipe(opts ...Option) middleware.Pipe {
cfg := config{registry: metrics.DefaultRegistry}
for _, opt := range opts {
opt(&cfg)
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, ok := cfg.skip[r.URL.Path]; ok ||
(cfg.filter != nil && !cfg.filter(r)) {
next.ServeHTTP(w, r)
return
}
start := time.Now()
holder := &routeHolder{}
r = r.WithContext(
context.WithValue(r.Context(), routeKey{}, holder),
)
incptr := middleware.NewInterceptor(w)
defer func() {
rec := recover()
status := incptr.Status()
if rec != nil {
// Recover further up the chain turns the panic into an
// empty 500 response.
status = http.StatusInternalServerError
}
// A mux pattern may carry a leading method ("GET /users"),
// which the method tag already records.
route := holder.pattern
if route == "" {
route = r.Pattern
}
route = strings.TrimPrefix(route, r.Method+" ")
cfg.registry.Summary(RequestDuration, nil, 0,
metrics.T("method", r.Method),
metrics.T("route", route),
metrics.T("status", strconv.Itoa(status)),
).Observe(time.Since(start).Seconds())
if rec != nil {
panic(rec)
}
}()
next.ServeHTTP(incptr, r)
})
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package middleware
import (
"bufio"
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"net"
"net/http"
"runtime/debug"
"slices"
"strings"
"time"
"github.com/deep-rent/nexus/sys/log"
)
// Pipe is a middleware function.
//
// Pipe is an adapter that takes an [http.Handler] and returns a new
// [http.Handler], allowing functionality to be composed in layers.
type Pipe func(http.Handler) http.Handler
// Chain combines a handler with multiple middleware [Pipe]s.
//
// The pipes are applied in reverse order, meaning the first pipe in the list is
// the outermost and executes first. For example, Chain(h, A, B, C) results in a
// handler equivalent to A(B(C(h))). Any nil pipes in the list are safely
// ignored.
func Chain(h http.Handler, pipes ...Pipe) http.Handler {
for _, pipe := range slices.Backward(pipes) {
if pipe != nil {
h = pipe(h)
}
}
return h
}
// Passthrough is a no-op [Pipe] that returns the next handler unchanged.
//
// A no-op factory signals "no middleware" by returning nil, which [Chain] (and
// the router's Adapt) skip. Passthrough is instead a directly-callable
// identity, for callers that build a chain conditionally or need a safe pipe to
// invoke without a nil check.
func Passthrough(next http.Handler) http.Handler { return next }
// Recover produces a middleware [Pipe] that catches panics in downstream
// handlers.
//
// It uses the provided logger to report the exception with a stack trace and
// returns an empty response with status code 500 to the client. The log entry
// also pinpoints the request method and URL that caused the panic. For maximum
// effectiveness, this should be the first (outermost) middleware in the chain.
//
// Panics with [http.ErrAbortHandler] are re-raised untouched: the standard
// library uses this sentinel to abort a response on purpose, and the server
// suppresses its stack trace.
func Recover(logger *log.Logger) Pipe {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(
func(res http.ResponseWriter, req *http.Request) {
defer func() {
if r := recover(); r != nil {
if err, ok := r.(error); ok &&
errors.Is(err, http.ErrAbortHandler) {
panic(r)
}
method, url := req.Method, req.URL.String()
logger.Error(
req.Context(),
"Panic caught by middleware",
log.String("method", method),
log.String("url", url),
log.String("panic", fmt.Sprint(r)),
log.String("stack", string(debug.Stack())),
)
res.WriteHeader(http.StatusInternalServerError)
}
}()
next.ServeHTTP(res, req)
},
)
}
}
// contextKey prevents collisions with other packages.
type contextKey struct{}
// requestIDKey is the key under which the request ID is stored in the request
// context.
var requestIDKey contextKey
// DefaultRequestIDHeader is the header used to transport the request ID
// unless overridden via [WithRequestIDHeader].
const DefaultRequestIDHeader = "X-Request-ID"
// requestIDConfig holds the configuration for the [RequestID] middleware.
type requestIDConfig struct {
// header is the name of the request and response ID header.
header string
// trustClient reuses an inbound ID instead of generating a fresh one.
trustClient bool
}
// RequestIDOption configures the [RequestID] middleware.
type RequestIDOption func(*requestIDConfig)
// WithRequestIDHeader overrides the header used to transport the request ID.
//
// Empty string values are ignored, keeping [DefaultRequestIDHeader].
func WithRequestIDHeader(name string) RequestIDOption {
return func(c *requestIDConfig) {
if name != "" {
c.header = name
}
}
}
// WithTrustClient reuses a request ID supplied by the client.
//
// When enabled and the inbound request carries a syntactically valid ID in
// the configured header, that ID is propagated instead of generating a new
// one. This allows traces to span multiple services behind a gateway that
// assigns IDs. Only enable this behind infrastructure you control: the value
// is attacker-supplied otherwise, so IDs are capped at 64 characters and
// restricted to ASCII letters, digits, and "+-=/._" before being trusted.
func WithTrustClient(trust bool) RequestIDOption {
return func(c *requestIDConfig) {
c.trustClient = trust
}
}
// validRequestID reports whether an inbound ID is safe to propagate.
func validRequestID(id string) bool {
if id == "" || len(id) > 64 {
return false
}
for i := 0; i < len(id); i++ {
c := id[i]
switch {
case c >= 'a' && c <= 'z':
case c >= 'A' && c <= 'Z':
case c >= '0' && c <= '9':
case strings.IndexByte("+-=/._", c) != -1:
default:
return false
}
}
return true
}
// RequestID returns a middleware [Pipe] that injects a unique ID into each
// request.
//
// It adds the ID to the response via the "X-Request-ID" header (configurable
// through [WithRequestIDHeader]) and to the request's context for downstream
// use. Downstream handlers and other middleware can retrieve the ID using
// [GetRequestID]. By default a fresh random ID is generated for every
// request; see [WithTrustClient] for propagating gateway-assigned IDs.
func RequestID(opts ...RequestIDOption) Pipe {
cfg := requestIDConfig{header: DefaultRequestIDHeader}
for _, opt := range opts {
opt(&cfg)
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := ""
if cfg.trustClient {
if v := r.Header.Get(cfg.header); validRequestID(v) {
id = v
}
}
if id == "" {
// Note: crypto/rand.Read is guaranteed not to fail.
b := make([]byte, 16)
_, _ = rand.Read(b)
id = hex.EncodeToString(b)
}
w.Header().Set(cfg.header, id)
next.ServeHTTP(w, r.WithContext(SetRequestID(r.Context(), id)))
})
}
}
// GetRequestID retrieves the request ID from a given context.
//
// It returns an empty string if the ID is not found.
func GetRequestID(ctx context.Context) string {
id, _ := ctx.Value(requestIDKey).(string)
return id
}
// SetRequestID sets the request ID in the provided context.
//
// It returns a new context that carries the ID.
func SetRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey, id)
}
// Interceptor wraps an [http.ResponseWriter] to capture what the handler
// wrote, for middlewares that report on a request only after it has been
// handled.
//
// The [Log] pipe and the [measure] package use it to recover the status code
// and response size once the handler has returned.
//
// [measure]: github.com/deep-rent/nexus/net/middleware/measure
type Interceptor interface {
http.ResponseWriter
// Status returns the captured status code.
Status() int
// Size returns the number of body bytes written, excluding the header.
Size() int64
// Unwrap returns the underlying [http.ResponseWriter].
Unwrap() http.ResponseWriter
}
// NewInterceptor wraps the given [http.ResponseWriter] into an [Interceptor].
//
// The status defaults to [http.StatusOK], matching what the server sends for
// a handler that writes a body without ever calling WriteHeader.
func NewInterceptor(w http.ResponseWriter) Interceptor {
return &interceptor{ResponseWriter: w, code: http.StatusOK}
}
// interceptor is used to wrap the original [http.ResponseWriter] to capture
// the status code.
//
// It forwards the optional [http.Flusher] and [http.Hijacker] interfaces so
// that wrapping a handler does not disable streaming responses or protocol
// upgrades further down the chain.
type interceptor struct {
// ResponseWriter is the original writer.
http.ResponseWriter
// code is the captured HTTP response status code.
code int
// size is the number of body bytes written so far, excluding the header.
size int64
}
// WriteHeader captures the status code before calling the original WriteHeader.
func (i *interceptor) WriteHeader(code int) {
i.code = code
i.ResponseWriter.WriteHeader(code)
}
// Write counts the written bytes before delegating to the original Write.
func (i *interceptor) Write(b []byte) (int, error) {
n, err := i.ResponseWriter.Write(b)
i.size += int64(n)
return n, err
}
// Status returns the captured status code.
func (i *interceptor) Status() int { return i.code }
// Size returns the number of body bytes written, excluding the header.
func (i *interceptor) Size() int64 { return i.size }
// Flush implements [http.Flusher] by delegating to the underlying writer.
func (i *interceptor) Flush() {
if flusher, ok := i.ResponseWriter.(http.Flusher); ok {
flusher.Flush()
}
}
// Unwrap exposes the underlying writer, so that
// [http.NewResponseController] can reach optional interfaces implemented by
// it.
func (i *interceptor) Unwrap() http.ResponseWriter {
return i.ResponseWriter
}
// Hijack implements [http.Hijacker] by delegating to the underlying writer.
func (i *interceptor) Hijack() (net.Conn, *bufio.ReadWriter, error) {
if hijacker, ok := i.ResponseWriter.(http.Hijacker); ok {
return hijacker.Hijack()
}
return nil, nil, errors.New("hijacking not supported")
}
// Ensure interceptor implements the necessary contracts.
var (
_ Interceptor = (*interceptor)(nil)
_ http.ResponseWriter = (*interceptor)(nil)
_ http.Flusher = (*interceptor)(nil)
_ http.Hijacker = (*interceptor)(nil)
)
// Log returns a middleware [Pipe] that logs a summary of each HTTP request.
//
// It captures the final HTTP status code and response size by wrapping the
// [http.ResponseWriter]. The log entry is generated at the debug level after
// the request has been handled. It includes the method, URL, status code,
// response size, duration, and other common attributes. To include a request
// ID in the log, this middleware should be placed after the [RequestID]
// middleware in the chain.
//
// If the logger has the debug level disabled, Log returns nil, which [Chain]
// (and the router's Adapt) skip entirely, so a disabled logger adds no chaining
// or per-request overhead. Enablement is decided once, when the pipe is built,
// so a logger whose level is raised to debug at runtime (e.g. via a
// [log.Cutoff]) will not begin logging; rebuild the chain to pick that up.
func Log(logger *log.Logger) Pipe {
if !logger.Enabled(context.Background(), log.LevelDebug) {
return nil
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
incpt := &interceptor{ResponseWriter: w, code: http.StatusOK}
next.ServeHTTP(incpt, r)
logger.Debug(
r.Context(),
"HTTP request handled",
log.String("id", GetRequestID(r.Context())),
log.String("method", r.Method),
log.String("url", r.URL.String()),
log.String("remote", r.RemoteAddr),
log.String("user_agent", r.UserAgent()),
log.Int("status", incpt.code),
log.Int64("bytes", incpt.size),
log.Duration("duration", time.Since(start)),
)
})
}
}
// Volatile returns a middleware [Pipe] that prevents caching of the response.
//
// It sets standard HTTP headers (Cache-Control, Pragma, Expires) to ensure
// clients and proxies always fetch a fresh copy of the resource.
func Volatile() Pipe {
control := strings.Join([]string{
"no-store",
"no-cache",
"must-revalidate",
"proxy-revalidate",
}, ", ")
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", control)
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
next.ServeHTTP(w, r)
})
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package mtls
import (
"context"
"crypto/x509"
"net/http"
"slices"
"github.com/deep-rent/nexus/net/router"
)
// ReasonCertificateRequired indicates that the route is reachable only
// over a mutually authenticated connection, and this one carried no
// client certificate the server accepted.
const ReasonCertificateRequired router.Reason = "certificate_required"
// ReasonCertificateRejected indicates that a client certificate was
// presented and verified, but names a client this route does not admit.
const ReasonCertificateRejected router.Reason = "certificate_rejected"
// key is the context key the verified certificate is carried under.
type key struct{}
// Peer returns the client certificate the request was authorized by, or
// nil when the request did not pass through [Require].
//
// It is the leaf of a chain [crypto/tls] verified, so its subject may be
// trusted as far as the issuing CA is.
func Peer(ctx context.Context) *x509.Certificate {
cert, _ := ctx.Value(key{}).(*x509.Certificate)
return cert
}
// Authorizer decides whether a verified client certificate may reach the
// route. It runs only for certificates [crypto/tls] already accepted, so
// it answers "which client is this" rather than "is this certificate
// genuine".
type Authorizer func(*x509.Certificate) bool
// Require admits only requests carrying a client certificate the server
// verified during the handshake, and — where an [Authorizer] is
// configured — one naming a client this route admits.
//
// Without options it admits any verified certificate, which is the right
// policy when the listener's ClientCAs pool is itself the allowlist. Add
// an authorizer when one CA issues to several clients and only some of
// them belong here.
//
// A request with no verified certificate is refused with 401 and a
// certificate the authorizer declines with 403 — the first says
// "identify yourself", the second "not you". See the package
// documentation on what the server must be configured with for either to
// be reachable.
func Require(opts ...Option) router.Middleware {
var cfg config
for _, opt := range opts {
opt(&cfg)
}
return func(next router.Handler) router.Handler {
return router.HandlerFunc(func(e *router.Exchange) error {
// VerifiedChains is populated by the handshake and only for
// certificates that chained to the listener's ClientCAs. A
// plain HTTP connection has no TLS state at all, which is
// the terminated-TLS case the package documents.
state := e.R.TLS
if state == nil || len(state.VerifiedChains) == 0 {
return &router.Error{
Status: http.StatusUnauthorized,
Reason: ReasonCertificateRequired,
Description: "a client certificate is required",
}
}
// The leaf of the first verified chain is the client; the
// rest of the chain is the path to the CA.
peer := state.VerifiedChains[0][0]
if cfg.authorize != nil && !cfg.authorize(peer) {
return &router.Error{
Status: http.StatusForbidden,
Reason: ReasonCertificateRejected,
Description: "the client certificate is not authorized",
}
}
e.R = e.R.WithContext(
context.WithValue(e.R.Context(), key{}, peer),
)
return next.ServeHTTP(e)
})
}
}
// config holds the optional policy of [Require].
type config struct {
authorize Authorizer
}
// Option configures [Require].
type Option func(*config)
// WithAuthorizer sets the policy deciding which verified clients the
// route admits. A nil authorizer is ignored, leaving every verified
// certificate admitted.
//
// Later options replace earlier ones rather than composing, so build a
// compound policy in one function rather than layering several.
func WithAuthorizer(a Authorizer) Option {
return func(c *config) {
if a != nil {
c.authorize = a
}
}
}
// WithCommonNames admits only certificates whose subject common name is
// one of those given. It is the simplest useful policy where one CA
// issues to several clients — a scraper, a deploy job, an operator — and
// only some belong on the route.
//
// The common name is a label, not a security boundary: it is the issuing
// CA that makes it trustworthy, so this is only meaningful when
// ClientCAs is a CA you control. Calling it with none admits nothing,
// which fails closed rather than silently admitting everyone.
func WithCommonNames(names ...string) Option {
return WithAuthorizer(func(cert *x509.Certificate) bool {
return slices.Contains(names, cert.Subject.CommonName)
})
}
// WithDNSNames admits only certificates carrying one of the given DNS
// subject alternative names. SANs are where modern certificates carry
// identity, so prefer this to [WithCommonNames] where the issuing
// process populates them.
//
// Calling it with none admits nothing.
func WithDNSNames(names ...string) Option {
return WithAuthorizer(func(cert *x509.Certificate) bool {
return slices.ContainsFunc(cert.DNSNames, func(dns string) bool {
return slices.Contains(names, dns)
})
})
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package secure
import "time"
// DefaultHSTSMaxAge is how long the baseline asks a browser to remember that
// this host is HTTPS-only. A year is what the preload lists expect, and a
// short max-age is close to no HSTS at all: it lapses before most visitors
// return.
const DefaultHSTSMaxAge = 365 * 24 * time.Hour
// config holds the header values before they are rendered.
type config struct {
hstsMaxAge time.Duration
hstsSubdomains bool
hstsPreload bool
noSniff bool
frameOptions string
csp string
referrerPolicy string
permissionsPolicy string
crossOriginOpenerPolicy string
crossOriginEmbedderPolicy string
crossOriginResourcePolicy string
permittedCrossDomainPolicies string
}
// Option configures the middleware. A string option given the empty string
// removes its header.
type Option func(*config)
// WithHSTS sets how long a browser should remember to reach this host over
// HTTPS only, replacing [DefaultHSTSMaxAge]. A nonpositive age removes the
// header, as [WithoutHSTS] does.
func WithHSTS(maxAge time.Duration) Option {
return func(c *config) { c.hstsMaxAge = maxAge }
}
// WithoutHSTS omits the Strict-Transport-Security header. It is for a
// deployment that terminates TLS elsewhere and sets the header there, or one
// served over plain HTTP behind a trusted boundary.
func WithoutHSTS() Option {
return func(c *config) { c.hstsMaxAge = 0 }
}
// WithoutHSTSSubdomains drops the includeSubDomains directive, so the policy
// binds this host alone. The baseline includes subdomains, which is what
// stops a forgotten subdomain from being reachable over plain HTTP.
func WithoutHSTSSubdomains() Option {
return func(c *config) { c.hstsSubdomains = false }
}
// WithHSTSPreload adds the preload directive, signalling consent to
// inclusion in the browser preload lists.
//
// Only set it once the site meets the requirements of
// https://hstspreload.org — a long max-age with subdomains included — since
// removal from those lists is slow, and a subdomain that cannot serve HTTPS
// becomes unreachable rather than insecure.
func WithHSTSPreload() Option {
return func(c *config) { c.hstsPreload = true }
}
// WithoutNoSniff drops X-Content-Type-Options, letting a browser sniff a
// response's type rather than trusting the declared one.
func WithoutNoSniff() Option {
return func(c *config) { c.noSniff = false }
}
// WithFrameOptions sets X-Frame-Options, such as "SAMEORIGIN". The baseline
// is "DENY".
func WithFrameOptions(v string) Option {
return func(c *config) { c.frameOptions = v }
}
// WithCSP sets the Content-Security-Policy. The baseline sets none, since a
// policy that fits every response does not exist.
func WithCSP(policy string) Option {
return func(c *config) { c.csp = policy }
}
// WithReferrerPolicy sets Referrer-Policy. The baseline is "no-referrer".
func WithReferrerPolicy(v string) Option {
return func(c *config) { c.referrerPolicy = v }
}
// WithPermissionsPolicy sets Permissions-Policy, naming the browser features
// a document may use — for example "geolocation=(), microphone=()".
func WithPermissionsPolicy(v string) Option {
return func(c *config) { c.permissionsPolicy = v }
}
// WithCrossOriginOpenerPolicy sets Cross-Origin-Opener-Policy. The baseline
// is "same-origin", which severs the window reference a cross-origin opener
// would otherwise keep.
func WithCrossOriginOpenerPolicy(v string) Option {
return func(c *config) { c.crossOriginOpenerPolicy = v }
}
// WithCrossOriginEmbedderPolicy sets Cross-Origin-Embedder-Policy, such as
// "require-corp". The baseline sets none: paired with the opener policy it
// buys cross-origin isolation, which a service API does not need and which
// refuses any subresource that does not opt in.
func WithCrossOriginEmbedderPolicy(v string) Option {
return func(c *config) { c.crossOriginEmbedderPolicy = v }
}
// WithCrossOriginResourcePolicy sets Cross-Origin-Resource-Policy. The
// baseline is "same-origin".
func WithCrossOriginResourcePolicy(v string) Option {
return func(c *config) { c.crossOriginResourcePolicy = v }
}
// WithPermittedCrossDomainPolicies sets X-Permitted-Cross-Domain-Policies,
// which bounds what an Adobe cross-domain policy file may grant. The
// baseline is "none".
func WithPermittedCrossDomainPolicies(v string) Option {
return func(c *config) { c.permittedCrossDomainPolicies = v }
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package secure
import (
"net/http"
"strconv"
"github.com/deep-rent/nexus/net/header"
"github.com/deep-rent/nexus/net/middleware"
)
// New returns a [middleware.Pipe] that sets the hardened baseline described
// in the package documentation, adjusted by the given options.
func New(opts ...Option) middleware.Pipe {
cfg := config{
hstsMaxAge: DefaultHSTSMaxAge,
hstsSubdomains: true,
noSniff: true,
frameOptions: "DENY",
referrerPolicy: "no-referrer",
permissionsPolicy: "geolocation=(),microphone=(),camera=(),payment=()",
crossOriginOpenerPolicy: "same-origin",
crossOriginResourcePolicy: "same-origin",
permittedCrossDomainPolicies: "none",
}
for _, opt := range opts {
opt(&cfg)
}
headers := cfg.headers()
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
for _, hdr := range headers {
h.Set(hdr.Key, hdr.Value)
}
next.ServeHTTP(w, r)
})
}
}
// headers renders the configuration into the set every response carries.
// None of it depends on the request, so it is computed once.
func (c config) headers() []header.Header {
var out []header.Header
add := func(name, value string) {
if value != "" {
out = append(out, header.New(name, value))
}
}
add("Strict-Transport-Security", c.hsts())
if c.noSniff {
add("X-Content-Type-Options", "nosniff")
}
add("X-Frame-Options", c.frameOptions)
add("Content-Security-Policy", c.csp)
add("Referrer-Policy", c.referrerPolicy)
add("Permissions-Policy", c.permissionsPolicy)
add("Cross-Origin-Opener-Policy", c.crossOriginOpenerPolicy)
add("Cross-Origin-Embedder-Policy", c.crossOriginEmbedderPolicy)
add("Cross-Origin-Resource-Policy", c.crossOriginResourcePolicy)
add("X-Permitted-Cross-Domain-Policies", c.permittedCrossDomainPolicies)
return out
}
// hsts renders the Strict-Transport-Security value, empty where the header
// is omitted. The age is quoted in whole seconds, which is the only unit the
// directive takes.
func (c config) hsts() string {
if c.hstsMaxAge <= 0 {
return ""
}
v := "max-age=" + strconv.FormatInt(int64(c.hstsMaxAge.Seconds()), 10)
if c.hstsSubdomains {
v += "; includeSubDomains"
}
if c.hstsPreload {
v += "; preload"
}
return v
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package shed
import (
"runtime/metrics"
"time"
"github.com/deep-rent/nexus/std/clock"
)
// config holds the configuration options for the shed middleware.
type config struct {
interval time.Duration
fraction float64
retryAfter time.Duration
memory func() uint64
now clock.Clock
}
// Option configures the shed middleware.
type Option func(*config)
const (
// DefaultInterval is the frequency at which the middleware checks memory
// usage.
DefaultInterval = 250 * time.Millisecond
// DefaultThreshold is the fraction of GOMEMLIMIT at which the server begins
// rejecting requests.
DefaultThreshold = 0.90
// DefaultRetryAfter is the default duration clients are asked to wait
// before retrying, sent in the Retry-After header.
DefaultRetryAfter = 5 * time.Second
)
// WithInterval sets the frequency at which the middleware checks memory usage.
// Nonpositive values will be ignored. Defaults to [DefaultInterval].
func WithInterval(d time.Duration) Option {
return func(c *config) {
if d > 0 {
c.interval = d
}
}
}
// WithThreshold sets the fraction of GOMEMLIMIT at which the server begins
// rejecting requests. Numbers outside the interval (0,1] will be ignored.
// Defaults to [DefaultThreshold].
func WithThreshold(fraction float64) Option {
return func(c *config) {
if fraction > 0 && fraction <= 1.0 {
c.fraction = fraction
}
}
}
// WithRetryAfter sets the duration clients should wait before retrying when the
// server sheds load. Nonpositive values will be ignored. Defaults to
// [DefaultRetryAfter].
func WithRetryAfter(d time.Duration) Option {
return func(c *config) {
if d > 0 {
c.retryAfter = d
}
}
}
// WithClock overrides the function used to get the current time. It is
// primarily useful for testing. Defaults to [clock.System] if left as nil.
func WithClock(now clock.Clock) Option {
return func(c *config) {
if now != nil {
c.now = now
}
}
}
// WithMemoryProvider overrides the function used to query the current memory in
// use. It is primarily useful for testing. Defaults to reading the runtime
// metrics.
func WithMemoryProvider(provider func() uint64) Option {
return func(c *config) {
if provider != nil {
c.memory = provider
}
}
}
// memory reads the current memory in use from the runtime metrics.
func memory() uint64 {
samples := []metrics.Sample{
{Name: "/memory/classes/total:bytes"},
{Name: "/memory/classes/heap/released:bytes"},
}
metrics.Read(samples)
total := samples[0].Value.Uint64()
released := samples[1].Value.Uint64()
return max(0, total-released)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package shed
import (
"math"
"net/http"
"runtime/debug"
"strconv"
"sync/atomic"
"time"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/std/clock"
)
// ReasonOverload is returned when the server is rejecting requests due to
// resource exhaustion, such as approaching the memory limit.
const ReasonOverload router.Reason = "server_overload"
// New returns a [router.Middleware] that rejects new requests with a 503
// status when the application is about to run out of memory.
//
// It determines the limit from the active GOMEMLIMIT (via
// [debug.SetMemoryLimit]). If no limit is set, it returns nil, which
// [router.Chain] skips entirely. Otherwise, it monitors memory usage inline and
// sheds load when the active heap size exceeds the configured threshold
// fraction.
func New(opts ...Option) router.Middleware {
limit := debug.SetMemoryLimit(-1)
if limit <= 0 || limit == math.MaxInt64 {
// No memory limit set: return nil so middleware chaining skips
// it entirely.
return nil
}
cfg := config{
interval: DefaultInterval,
fraction: DefaultThreshold,
retryAfter: DefaultRetryAfter,
memory: memory,
now: clock.System,
}
for _, opt := range opts {
opt(&cfg)
}
threshold := uint64(float64(limit) * cfg.fraction)
var overloaded atomic.Bool
var last atomic.Int64 // unix nanos of the most recent sample
after := strconv.Itoa(int(math.Ceil(cfg.retryAfter.Seconds())))
return func(next router.Handler) router.Handler {
return router.HandlerFunc(func(e *router.Exchange) error {
// Sample at most once per interval. The CAS operation claims the
// sampling slot for exactly one goroutine; concurrent requests read
// the last recorded verdict from overloaded.
curr, prev := cfg.now(), last.Load()
if curr.Sub(time.Unix(0, prev)) > cfg.interval &&
last.CompareAndSwap(prev, curr.UnixNano()) {
overloaded.Store(cfg.memory() >= threshold)
}
if overloaded.Load() {
e.W.Header().Set("Retry-After", after)
return &router.Error{
Status: http.StatusServiceUnavailable,
Reason: ReasonOverload,
Description: "the server is currently overloaded; try again later",
}
}
return next.ServeHTTP(e)
})
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package notify
import "errors"
// Category classifies what kind of message a notification is, independent
// of the channel it travels over. The provider routes and rate-shapes
// messages by this classification, and mailbox providers weigh it in
// deliverability decisions, so a message should carry the category that
// honestly describes its occasion.
//
// The constants carry Bird's wire vocabulary ("use case") directly, which
// names two of them differently: [CategoryAuthentication] travels as "otp"
// and [CategoryService] as "conversation".
type Category string
const (
// CategoryTransactional marks messages triggered by an action the
// recipient took or is affected by — receipts, alerts, state changes.
CategoryTransactional Category = "transactional"
// CategoryAuthentication marks one-time passwords and other sign-in
// verification messages. Bird names this use case "otp".
CategoryAuthentication Category = "otp"
// CategoryMarketing marks promotional messages. These are the ones
// consent and frequency capping apply to most strictly.
CategoryMarketing Category = "marketing"
// CategoryService marks two-way conversational messages between the
// recipient and the service. Bird names this use case "conversation".
CategoryService Category = "conversation"
)
// ErrUnknownCategory reports a category outside the defined vocabulary.
// The message validators return it rather than sending, since an unknown
// classification would be silently dropped or misrouted by the provider.
var ErrUnknownCategory = errors.New("unknown message category")
// Known reports whether the category is one of the defined constants.
// The empty category is not known: leaving a message unclassified is
// expressed by not setting one at all.
func (c Category) Known() bool {
switch c {
case CategoryTransactional, CategoryAuthentication,
CategoryMarketing, CategoryService:
return true
}
return false
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package admin
import (
"context"
"errors"
"fmt"
"net/http"
"slices"
"time"
"uuid"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/net/notify/hook"
"github.com/deep-rent/nexus/net/router"
)
// Hooks is the narrow seam onto the webhook registry, satisfied by
// [hook.Engine] over any driver. Management registers and retires
// endpoints; publishing and dispatch are the host's business.
type Hooks interface {
// Register records an endpoint and returns it with its show-once
// signing secret.
Register(ctx context.Context, r hook.Registration) (
hook.Endpoint, string, error)
// Rotate mints a fresh signing secret, returned show-once.
Rotate(ctx context.Context, id uuid.UUID) (string, error)
// Get returns one endpoint.
Get(ctx context.Context, id uuid.UUID) (hook.Endpoint, error)
// List returns an owner's endpoints, or every endpoint for the
// empty owner.
List(ctx context.Context, owner string) ([]hook.Endpoint, error)
// Enable returns an endpoint to service, clearing its failure
// record.
Enable(ctx context.Context, id uuid.UUID) error
// Disable takes an endpoint out of service.
Disable(ctx context.Context, id uuid.UUID) error
// Delete removes an endpoint for good.
Delete(ctx context.Context, id uuid.UUID) error
}
// Config carries what varies between hosts; everything else is the
// same wherever this surface is mounted.
type Config struct {
// Hooks is the registry to manage. Required.
Hooks Hooks
// Owner resolves the owner an endpoint is filed under — a fixed
// name ([Fixed]), or the authenticated principal. When nil, the
// registration payload names the owner instead, and listings
// accept an "owner" query parameter to filter by it.
Owner func(*router.Exchange) (string, error)
// Topics are the topics the host publishes. A registration naming
// anything else is refused at the door, since it would sit in the
// registry looking subscribed and deliver nothing. When empty, any
// syntactically valid topic is accepted.
Topics []string
// Internal admits registrations that bypass the private-address
// guard. Leave it false on any surface customers can reach; see
// [hook.Endpoint].
Internal bool
// Read guards the routes that only read the registry. A surface
// mounted without one is unguarded.
Read []router.Middleware
// Write guards the routes that change it — registering, rotating,
// enabling, disabling, deleting. When nil, [Config.Read] guards
// them too, which is what a host with one permission wants.
Write []router.Middleware
}
// Mounter is the routing surface the management routes register on,
// satisfied by both [router.Router] and [router.Group], so a host
// mounts them at whatever prefix its API already uses.
type Mounter interface {
HandleFunc(
method, path string,
fn func(*router.Exchange) error,
mws ...router.Middleware,
)
}
// Fixed resolves every endpoint to one owner, for a host whose
// registry serves a single subscriber list.
func Fixed(owner string) func(*router.Exchange) (string, error) {
return func(*router.Exchange) (string, error) { return owner, nil }
}
// Endpoint is the wire view of a registered endpoint. It never carries
// a secret: those are shown exactly once, by registration and
// rotation.
type Endpoint struct {
// ID identifies the endpoint.
ID uuid.UUID `json:"id"`
// Owner is the subscriber this endpoint belongs to.
Owner string `json:"owner"`
// URL is the receiver.
URL string `json:"url"`
// Topics are the subscribed event topics.
Topics []string `json:"topics"`
// State is the lifecycle state: enabled, disabled, or suspended.
State string `json:"state"`
// Internal marks an endpoint permitted to resolve to private
// address space.
Internal bool `json:"internal,omitzero"`
// FailingSince is when the endpoint's current failure streak
// began; absent while healthy.
FailingSince time.Time `json:"failing_since,omitzero"`
// CreatedAt and UpdatedAt are bookkeeping timestamps.
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// view renders an endpoint for the wire.
func view(e hook.Endpoint) Endpoint {
return Endpoint{
ID: e.ID,
Owner: e.Owner,
URL: e.URL,
Topics: e.Topics,
State: string(e.State),
Internal: e.Internal,
FailingSince: e.FailingSince,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
}
}
// Secret carries an endpoint together with a freshly minted signing
// secret. It is the only response that ever holds one: the secret is
// not stored in a form anyone can read back, so a caller that loses it
// rotates rather than looks it up.
type Secret struct {
// Endpoint is the endpoint the secret belongs to.
Endpoint Endpoint `json:"endpoint"`
// Secret signs the deliveries, in its portable "whsec_..." form.
Secret string `json:"secret"`
}
// Registration is the payload registering an endpoint.
type Registration struct {
// Owner names the subscriber. It is required when the host does
// not resolve the owner itself, and refused when it does.
Owner string `json:"owner,omitzero"`
// URL is the receiver. It must be https unless the deployment
// permits plain http for a test rig.
URL string `json:"url"`
// Topics are the event topics to subscribe to.
Topics []string `json:"topics"`
// Internal admits an endpoint resolving to private address space,
// for a subscriber inside this deployment's own network. It is
// accepted only where the host allows it.
Internal bool `json:"internal,omitzero"`
}
// binding is a registration under a host's policy: the wire fields
// inline, and the configuration rides along unexported so the payload
// validates through the same [valid.Validatable] path as every other
// bound request, host rules included.
type binding struct {
Registration
cfg *Config
}
// Validate implements the [valid.Validatable] interface.
func (b *binding) Validate(v *valid.Validator) {
if b.cfg.Owner != nil {
// The host names the owner; a payload that also names one is
// refused rather than silently overruled.
if b.Owner != "" {
v.Fail("owner", "is not accepted here")
}
} else {
v.NotBlank("owner", b.Owner)
v.MaxLen("owner", b.Owner, hook.MaxOwnerLength)
}
v.NotBlank("url", b.URL)
v.MaxLen("url", b.URL, hook.MaxURLLength)
if b.Internal && !b.cfg.Internal {
v.Fail("internal", "is not accepted here")
}
switch {
case len(b.Topics) == 0:
v.Fail("topics", "must not be empty")
case len(b.Topics) > hook.MaxTopics:
v.Fail("topics", fmt.Sprintf(
"must not name more than %d topics", hook.MaxTopics,
))
case len(b.cfg.Topics) > 0:
// A topic this host never publishes would sit in the registry
// looking subscribed and deliver nothing.
for _, topic := range b.Topics {
if !slices.Contains(b.cfg.Topics, topic) {
v.Fail("topics", fmt.Sprintf("unknown topic %q", topic))
return
}
}
}
}
var _ valid.Validatable = (*binding)(nil)
// server binds a configuration to the handlers.
type server struct{ cfg Config }
// Mount registers the management routes on r, which may be a router or
// a group rooted at whatever prefix the host's API uses. Guards come
// from [Config.Read] and [Config.Write]: this package imposes no
// authorization, and mounting without either exposes the registry.
//
// Mount panics if the configuration names no registry.
func Mount(r Mounter, cfg Config) {
if cfg.Hooks == nil {
panic("hook registry is required")
}
read, write := cfg.Read, cfg.Write
if write == nil {
write = read
}
s := &server{cfg: cfg}
r.HandleFunc(http.MethodGet, "/hooks", s.list, read...)
r.HandleFunc(http.MethodPost, "/hooks", s.register, write...)
r.HandleFunc(http.MethodGet, "/hooks/{id}", s.get, read...)
r.HandleFunc(http.MethodPost, "/hooks/{id}/rotate", s.rotate, write...)
r.HandleFunc(http.MethodPost, "/hooks/{id}/enable",
s.state(true), write...)
r.HandleFunc(http.MethodPost, "/hooks/{id}/disable",
s.state(false), write...)
r.HandleFunc(http.MethodDelete, "/hooks/{id}", s.remove, write...)
}
// list answers the registry, scoped to the owner the host resolves or
// filtered by the "owner" parameter where the host resolves none.
func (s *server) list(e *router.Exchange) error {
owner := e.Query().Get("owner")
if s.cfg.Owner != nil {
var err error
if owner, err = s.cfg.Owner(e); err != nil {
return err
}
}
endpoints, err := s.cfg.Hooks.List(e.Context(), owner)
if err != nil {
return fail(err, "failed to list webhook endpoints")
}
out := make([]Endpoint, 0, len(endpoints))
for _, endpoint := range endpoints {
out = append(out, view(endpoint))
}
return e.JSON(http.StatusOK, out)
}
// register records an endpoint and answers with its signing secret,
// shown here and never again.
func (s *server) register(e *router.Exchange) error {
req := binding{cfg: &s.cfg}
if err := e.BindJSON(&req); err != nil {
return err
}
owner := req.Owner
if s.cfg.Owner != nil {
var err error
if owner, err = s.cfg.Owner(e); err != nil {
return err
}
}
endpoint, secret, err := s.cfg.Hooks.Register(
e.Context(), hook.Registration{
Owner: owner,
URL: req.URL,
Topics: req.Topics,
Internal: req.Internal && s.cfg.Internal,
},
)
if err != nil {
return fail(err, "failed to register the endpoint")
}
return e.JSON(http.StatusCreated, Secret{
Endpoint: view(endpoint),
Secret: secret,
})
}
// get answers one endpoint.
func (s *server) get(e *router.Exchange) error {
endpoint, err := s.lookup(e)
if err != nil {
return err
}
return e.JSON(http.StatusOK, view(endpoint))
}
// rotate mints a fresh signing secret. The displaced secret keeps
// signing through the grace window, so a receiver switches on its own
// schedule rather than at the instant of the call.
func (s *server) rotate(e *router.Exchange) error {
endpoint, err := s.lookup(e)
if err != nil {
return err
}
secret, err := s.cfg.Hooks.Rotate(e.Context(), endpoint.ID)
if err != nil {
return fail(err, "failed to rotate the signing secret")
}
return e.JSON(http.StatusOK, Secret{
Endpoint: view(endpoint),
Secret: secret,
})
}
// state transitions an endpoint's lifecycle state, answering with the
// endpoint as it now stands.
func (s *server) state(enable bool) router.HandlerFunc {
return func(e *router.Exchange) error {
id, err := identify(e)
if err != nil {
return err
}
if enable {
err = s.cfg.Hooks.Enable(e.Context(), id)
} else {
err = s.cfg.Hooks.Disable(e.Context(), id)
}
if err != nil {
return fail(err, "failed to change the endpoint state")
}
endpoint, err := s.cfg.Hooks.Get(e.Context(), id)
if err != nil {
return fail(err, "failed to read the endpoint back")
}
return e.JSON(http.StatusOK, view(endpoint))
}
}
// remove deletes an endpoint for good, with its secrets and whatever
// was still queued for it. Disabling is the reversible alternative.
func (s *server) remove(e *router.Exchange) error {
id, err := identify(e)
if err != nil {
return err
}
if err := s.cfg.Hooks.Delete(e.Context(), id); err != nil {
return fail(err, "failed to delete the endpoint")
}
e.NoContent()
return nil
}
// lookup resolves the endpoint named by the path.
func (s *server) lookup(e *router.Exchange) (hook.Endpoint, error) {
id, err := identify(e)
if err != nil {
return hook.Endpoint{}, err
}
endpoint, err := s.cfg.Hooks.Get(e.Context(), id)
if err != nil {
return hook.Endpoint{}, fail(err, "failed to look up the endpoint")
}
return endpoint, nil
}
// identify reads the endpoint identifier out of the path.
func identify(e *router.Exchange) (uuid.UUID, error) {
var params struct {
ID uuid.UUID `path:"id"`
}
if err := e.BindPath(¶ms); err != nil {
return uuid.Nil(), err
}
return params.ID, nil
}
// fail maps an engine error onto a response: a missing endpoint is a
// 404, input the caller can fix is a 400 carrying what was wrong, and
// anything else is this service's failure — described plainly, with
// the cause kept for the log rather than the client.
func fail(err error, description string) error {
switch {
case err == nil:
return nil
case errors.Is(err, hook.ErrNotFound):
return &router.Error{
Status: http.StatusNotFound,
Reason: router.ReasonNotFound,
Description: "no such endpoint",
}
case errors.Is(err, hook.ErrInvalid):
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: err.Error(),
}
default:
return &router.Error{
Status: http.StatusInternalServerError,
Reason: router.ReasonServerError,
Description: description,
Cause: err,
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package hook
import (
"bytes"
"context"
"encoding/json/v2"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"sync"
"time"
"uuid"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/metrics"
"github.com/deep-rent/nexus/sys/queue"
)
// KindDeliver is the job kind carrying one delivery: one event bound for
// one endpoint. A worker handling it runs [Engine.Deliver]; see
// [Engine.Handle].
const KindDeliver = "hook.deliver"
// The delivery outcome vocabulary of hook_deliveries_total{result},
// exported so dashboards, alert rules, and tests reference the
// registration spelling.
//
// Whether a failed delivery is retried or dead-lettered is the queue's
// verdict, and queue_jobs_total{kind="hook.deliver"} carries it. These
// count what the RECEIVER did.
const (
// ResultDelivered counts attempts the receiver answered 2xx.
ResultDelivered = "delivered"
// ResultFailed counts attempts that did not arrive, or that the
// receiver refused.
ResultFailed = "failed"
// ResultGone counts deliveries refused with 410: the receiver asked
// to stop, and its endpoint was disabled.
ResultGone = "gone"
// ResultSkipped counts deliveries abandoned before an attempt was
// made — the endpoint is no longer taking them, or the event has
// been pruned out from under the job.
ResultSkipped = "skipped"
// ResultDeferred counts deliveries put back because the endpoint
// already had this worker's share in flight. They cost no attempt,
// so a rate that climbs means one subscriber is slow, not broken.
ResultDeferred = "deferred"
)
// HeaderOrigin carries the event's correlation identifier, when it has
// one. It is opaque to the receiver and useful for exactly one thing:
// quoting it in a support question about a delivery.
const HeaderOrigin = "webhook-origin"
// maxDrain bounds how much of a receiver's response body one attempt
// reads; receivers have no business answering webhooks with content.
const maxDrain = 4 << 10
// settleTimeout bounds a write recording what an attempt did to an
// endpoint's health.
const settleTimeout = 5 * time.Second
// settle derives the context those writes run on. It is detached from
// the attempt deliberately: a shutdown mid-delivery must not leave an
// endpoint marked failing when it answered perfectly well.
func settle(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.WithoutCancel(ctx), settleTimeout)
}
// delivery is the job payload: the pair of identifiers naming what to
// deliver where. Everything else — the event body, the endpoint's URL,
// its signing secrets — is read back at attempt time, so a delivery
// retried an hour later signs with the secret that is current then
// rather than the one that was current when it was queued.
type delivery struct {
Event uuid.UUID `json:"event"`
Endpoint uuid.UUID `json:"endpoint"`
}
// gate bounds how many deliveries one worker has in flight to a single
// endpoint, so that a subscriber with a large backlog cannot take every
// slot the worker has.
type gate struct {
mu sync.Mutex
limit int
inflight map[uuid.UUID]int
}
// enter takes a slot for the endpoint, reporting whether one was free.
func (g *gate) enter(id uuid.UUID) bool {
g.mu.Lock()
defer g.mu.Unlock()
if g.inflight[id] >= g.limit {
return false
}
g.inflight[id]++
return true
}
// leave returns a slot.
func (g *gate) leave(id uuid.UUID) {
g.mu.Lock()
defer g.mu.Unlock()
if n := g.inflight[id]; n > 1 {
g.inflight[id] = n - 1
} else {
// The map holds only endpoints with work in flight, so an idle
// fleet holds nothing.
delete(g.inflight, id)
}
}
// task is everything one attempt needs, read back from the store.
type task struct {
event Event
endpoint Endpoint
secrets []Secret
}
// Handle registers delivery on the worker, under the pacing the engine
// was configured with. It is the whole of the wiring a host needs:
//
// w := jobs.Worker(queue.WithConcurrency(8))
// engine.Handle(w)
// return w.Run(ctx)
//
// Register other kinds on the same worker freely — a fleet that delivers
// webhooks can render PDFs too.
func (e *Engine[Tx]) Handle(w *queue.Worker[Tx]) {
w.Handle(KindDeliver, e.Deliver,
// The handler's budget covers the attempt plus the reads and
// writes around it, so it must outlast the attempt's own timeout
// rather than race it.
queue.HandlerTimeout(e.cfg.timeout+settleTimeout*2),
queue.HandlerRetries(e.cfg.retries),
queue.HandlerBackoff(e.cfg.strategy),
)
}
// Deliver attempts one delivery, satisfying [queue.Handler]. It returns
// nil when the receiver took it, [queue.Abort] when no retry could help,
// and an ordinary error when another attempt might.
//
// It is safe to run from as many workers as desired: the queue leases
// each job to one of them, and a worker that dies lets its lease lapse.
func (e *Engine[Tx]) Deliver(ctx context.Context, job queue.Job) error {
var d delivery
if err := json.Unmarshal(job.Payload, &d); err != nil {
// A payload this handler cannot read was written by a version of
// this handler; no retry will teach it to.
return queue.Abort(fmt.Errorf("unreadable delivery: %w", err))
}
// A busy endpoint waits its turn rather than crowding out the rest.
// Deferring costs no attempt: waiting for capacity is not failing.
// The check comes before the reads below, since the payload already
// names the endpoint and a deferred delivery should cost nothing.
if !e.gate.enter(d.Endpoint) {
e.count(ResultDeferred)
return queue.Defer(0, "endpoint at capacity")
}
defer e.gate.leave(d.Endpoint)
t, err := e.load(ctx, d)
if err != nil {
return err
}
return e.attempt(ctx, t)
}
// load reads back everything the attempt needs, refusing the delivery
// outright when what it names is gone or no longer wants deliveries.
func (e *Engine[Tx]) load(
ctx context.Context,
d delivery,
) (task, error) {
var t task
err := e.store.Exec(ctx, func(ctx context.Context, tx Tx) error {
event, ok, err := e.store.Event(ctx, tx, d.Event)
if err != nil {
return err
}
if !ok {
// Retention outlived the retry schedule, or the event was
// never committed. Either way there is nothing to send.
return queue.Abort(errors.New("event no longer exists"))
}
endpoint, secrets, ok, err := e.store.Endpoint(ctx, tx, d.Endpoint)
if err != nil {
return err
}
if !ok {
return queue.Abort(errors.New("endpoint no longer exists"))
}
opened, err := e.openKeys(secrets, endpoint.ID)
if err != nil {
// A secret that will not open cannot be made to; the
// keyring that sealed it is gone.
return queue.Abort(err)
}
t = task{event: event, endpoint: endpoint, secrets: opened}
return nil
})
if err != nil {
if errors.Is(err, queue.ErrAbort) {
e.count(ResultSkipped)
}
return task{}, err
}
// An endpoint that is disabled or suspended wants nothing, including
// what was queued before it stopped wanting it.
if t.endpoint.State != StateEnabled {
e.count(ResultSkipped)
return task{}, queue.Abort(fmt.Errorf(
"endpoint is %s", t.endpoint.State,
))
}
return t, nil
}
// attempt performs one signed delivery and reports what to do next.
func (e *Engine[Tx]) attempt(ctx context.Context, t task) error {
body, err := json.Marshal(Envelope{
Type: t.event.Topic,
Timestamp: t.event.At,
Data: t.event.Data,
})
if err != nil {
// A payload that cannot render will never render.
return queue.Abort(fmt.Errorf("unrenderable payload: %w", err))
}
now := e.cfg.clock().UTC()
var keys [][]byte
for _, s := range t.secrets {
if s.live(now) {
keys = append(keys, s.Key)
}
}
id := t.event.ID.String()
req, err := http.NewRequestWithContext(
ctx, http.MethodPost, t.endpoint.URL, bytes.NewReader(body),
)
if err != nil {
return queue.Abort(fmt.Errorf("unbuildable request: %w", err))
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set(HeaderID, id)
req.Header.Set(HeaderTimestamp, strconv.FormatInt(now.Unix(), 10))
req.Header.Set(HeaderSignature, Signatures(keys, id, now, body))
if t.event.Origin != "" {
req.Header.Set(HeaderOrigin, t.event.Origin)
}
client := e.external
if t.endpoint.Internal {
client = e.internal
}
start := time.Now()
res, err := client.Do(req)
e.attempts.Observe(time.Since(start).Seconds())
e.cfg.logger.Debug(ctx, "Attempted a delivery",
log.UUID("event", t.event.ID),
log.UUID("endpoint", t.endpoint.ID),
log.String("origin", t.event.Origin),
)
if err != nil {
e.failed(ctx, t)
e.count(ResultFailed)
return fmt.Errorf("delivery failed: %w", err)
}
_, _ = io.Copy(io.Discard, io.LimitReader(res.Body, maxDrain))
_ = res.Body.Close()
switch {
case res.StatusCode >= 200 && res.StatusCode < 300:
e.succeeded(ctx, t)
e.count(ResultDelivered)
return nil
case res.StatusCode == http.StatusGone:
// The receiver said stop: the delivery dies and the endpoint
// goes with it, until its owner explicitly re-enables.
e.gone(ctx, t)
e.count(ResultGone)
return queue.Abort(errors.New("receiver answered 410 Gone"))
default:
e.failed(ctx, t)
e.count(ResultFailed)
return fmt.Errorf("receiver answered %d", res.StatusCode)
}
}
// succeeded heals the endpoint's failure record, if one was running.
func (e *Engine[Tx]) succeeded(ctx context.Context, t task) {
if t.endpoint.FailingSince.IsZero() {
return
}
ctx, cancel := settle(ctx)
defer cancel()
err := e.store.Exec(ctx, func(ctx context.Context, tx Tx) error {
return e.store.MarkHealthy(ctx, tx, t.endpoint.ID)
})
if err != nil {
e.cfg.logger.Error(ctx, "Failed to clear an endpoint's failures",
log.UUID("endpoint", t.endpoint.ID), log.Error(err))
}
}
// failed extends the endpoint's failure streak and, once the streak
// outgrows the suspension window, parks the endpoint in
// [StateSuspended]. The delivery's own fate is the queue's to decide.
func (e *Engine[Tx]) failed(ctx context.Context, t task) {
now := e.cfg.clock().UTC()
since := t.endpoint.FailingSince
// A streak that is already running and not yet over the suspension
// window has nothing to write; opening a transaction to decide that
// would tax the pool exactly when a subscriber outage has the
// delivery path busiest.
if !since.IsZero() && (now.Sub(since) < e.cfg.suspendAfter ||
t.endpoint.State != StateEnabled) {
return
}
ctx, cancel := settle(ctx)
defer cancel()
var suspended bool
err := e.store.Exec(ctx, func(ctx context.Context, tx Tx) error {
if since.IsZero() {
return e.store.MarkFailing(ctx, tx, t.endpoint.ID, now)
}
if now.Sub(since) < e.cfg.suspendAfter {
return nil
}
ok, err := e.store.SetState(
ctx, tx, t.endpoint.ID, StateSuspended, now,
)
suspended = ok
return err
})
if err != nil {
e.cfg.logger.Error(ctx, "Failed to record an endpoint's failure",
log.UUID("endpoint", t.endpoint.ID), log.Error(err))
return
}
if suspended {
e.suspended.Inc()
e.cfg.logger.Warn(ctx,
"Endpoint suspended after unbroken failure streak",
log.UUID("endpoint", t.endpoint.ID),
log.Duration("failing", now.Sub(since)),
)
}
}
// gone disables an endpoint that answered 410.
func (e *Engine[Tx]) gone(ctx context.Context, t task) {
ctx, cancel := settle(ctx)
defer cancel()
err := e.store.Exec(ctx, func(ctx context.Context, tx Tx) error {
_, err := e.store.SetState(
ctx, tx, t.endpoint.ID, StateDisabled, e.cfg.clock().UTC(),
)
return err
})
if err != nil {
e.cfg.logger.Error(ctx, "Failed to disable a gone endpoint",
log.UUID("endpoint", t.endpoint.ID), log.Error(err))
return
}
e.cfg.logger.Info(ctx, "Endpoint answered 410 Gone; disabled",
log.UUID("endpoint", t.endpoint.ID))
}
// count records one delivery outcome.
func (e *Engine[Tx]) count(result string) {
e.cfg.reg.Counter(
"hook_deliveries_total", metrics.T("result", result),
).Inc()
}
// Retention prunes what the windows have aged out: events past the
// retention window, and secret-set members past their retirement. It
// satisfies [schedule.TaskFn]; run it on a modest cadence, from one
// process.
//
// The window must outlast the longest retry schedule. An event is read
// back at every attempt, so pruning one whose deliveries are still being
// retried strands them — they abort with "event no longer exists"
// instead of arriving.
//
// [schedule.TaskFn]: github.com/deep-rent/nexus/sys/schedule#TaskFn
func (e *Engine[Tx]) Retention(ctx context.Context) {
now := e.cfg.clock().UTC()
cutoff := now.Add(-e.cfg.retention)
var events, secrets int64
err := e.store.Exec(ctx, func(ctx context.Context, tx Tx) error {
var err error
if events, err = e.store.PruneEvents(ctx, tx, cutoff); err != nil {
return err
}
secrets, err = e.store.PruneSecrets(ctx, tx, now)
return err
})
if err != nil {
e.cfg.logger.Error(ctx, "Retention pass failed", log.Error(err))
return
}
if events+secrets > 0 {
e.cfg.logger.Info(ctx, "Pruned aged webhook records",
log.Int64("events", events),
log.Int64("secrets", secrets),
)
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
// Package dial is the webhook system's reachability policy: which
// addresses a delivery may connect to, and which URLs a registration
// may name. The two checks work together — registration vets what it
// can vet honestly, while [Guard] enforces the policy where DNS cannot
// lie: at connect time, after resolution, which is the only check that
// survives rebinding.
package dial
import (
"errors"
"fmt"
"net"
"net/netip"
"net/url"
"strings"
"syscall"
"time"
"github.com/deep-rent/nexus/net/transport"
)
// ErrForbidden reports a connection or registration refused because the
// endpoint resolves into address space a registered receiver must never
// reach: the deployment's own network.
var ErrForbidden = errors.New(
"endpoint resolves into forbidden address space",
)
// Ranges that are globally routable on paper but internal in practice,
// and so are refused alongside everything [netip.Addr] already knows to
// be private.
var reserved = []netip.Prefix{
// Carrier-grade NAT (RFC 6598). Go does not count it as private, but
// cloud networks hand it out internally — EKS pods, Tailscale — so a
// registration naming it reaches inside the deployment.
netip.MustParsePrefix("100.64.0.0/10"),
// Shared infrastructure and documentation ranges, which no receiver
// has any business being on (RFC 6890, RFC 5737, RFC 2544).
netip.MustParsePrefix("192.0.0.0/24"),
netip.MustParsePrefix("192.0.2.0/24"),
netip.MustParsePrefix("198.18.0.0/15"),
netip.MustParsePrefix("198.51.100.0/24"),
netip.MustParsePrefix("203.0.113.0/24"),
// IPv6 encodings of an IPv4 address. Each is global unicast in its
// own right, and each carries a v4 address inside it that a
// translating gateway will happily deliver to — 2002:7f00:1:: is
// 127.0.0.1 written the long way round.
netip.MustParsePrefix("2002::/16"), // 6to4
netip.MustParsePrefix("2001::/32"), // Teredo
netip.MustParsePrefix("64:ff9b::/96"), // NAT64
netip.MustParsePrefix("64:ff9b:1::/48"),
// Documentation prefix (RFC 3849).
netip.MustParsePrefix("2001:db8::/32"),
// Deprecated site-local, still routed by some stacks (RFC 3879).
netip.MustParsePrefix("fec0::/10"),
}
// Forbidden reports whether the address belongs to a range a registered
// receiver must never reach: everything that is not global unicast
// (loopback, link-local — including the cloud metadata service —
// multicast, unspecified), the private ranges (RFC 1918 and IPv6
// unique-local), and the reserved ranges above, which route globally but
// address the deployment's own network in practice.
//
// IPv4-mapped IPv6 addresses are unwrapped first, so ::ffff:127.0.0.1
// cannot smuggle the loopback through.
func Forbidden(addr netip.Addr) bool {
addr = addr.Unmap()
if !addr.IsValid() || !addr.IsGlobalUnicast() || addr.IsPrivate() {
return true
}
for _, p := range reserved {
if p.Contains(addr) {
return true
}
}
return false
}
// Guard returns a dialer that vets every address at connect time,
// refusing the [Forbidden] ranges. It backs the delivery client for
// registered receivers; internal endpoints dial unvetted.
func Guard(timeout time.Duration) transport.Dialer {
d := &net.Dialer{
Timeout: timeout,
Control: func(_, address string, _ syscall.RawConn) error {
ap, err := netip.ParseAddrPort(address)
if err != nil {
return fmt.Errorf("unparsable dial address: %w", err)
}
if Forbidden(ap.Addr()) {
return fmt.Errorf("%w: %s", ErrForbidden, ap.Addr())
}
return nil
},
}
return d.DialContext
}
// VetURL validates an endpoint URL at registration. The scheme must be
// https (http only when insecure — the test-rig exception), the host
// must be present, and userinfo and fragments are refused outright. A
// literal IP host of a non-internal endpoint is checked against the
// [Forbidden] ranges immediately — failing at registration beats
// failing on every delivery — while hostnames are left to [Guard],
// which is the check that actually holds.
func VetURL(raw string, internal, insecure bool) error {
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("unparsable endpoint URL: %w", err)
}
switch {
case u.Scheme == "https":
case u.Scheme == "http" && insecure:
default:
return fmt.Errorf("endpoint URL must be https, got %q", u.Scheme)
}
if u.User != nil {
return errors.New("endpoint URL must not carry userinfo")
}
if u.Fragment != "" || strings.Contains(raw, "#") {
return errors.New("endpoint URL must not carry a fragment")
}
host := u.Hostname()
if host == "" {
return errors.New("endpoint URL must name a host")
}
// The insecure switch relaxes this exactly like it relaxes the
// guard: test receivers live on the loopback.
if addr, err := netip.ParseAddr(host); err == nil &&
!internal && !insecure && Forbidden(addr) {
return fmt.Errorf("%w: %s", ErrForbidden, addr)
}
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
// Package mock implements the webhook store in memory: every record is
// lost on restart, and transactions are nominal — operations apply
// immediately. It backs tests and the mock-driver assemblies of host
// services; the semantics that need real transactional machinery
// (publish atomicity, concurrent claim exclusion under load) are the
// PostgreSQL driver's tests to prove.
//
// The store is generic over the transaction type it nominally speaks,
// so that it composes with whatever queue the engine publishes into: a
// transaction it never uses may as well be the queue's.
package mock
import (
"context"
"slices"
"sync"
"time"
"uuid"
"github.com/deep-rent/nexus/net/notify/hook"
)
// Store implements [hook.Store] in memory. It is safe for concurrent
// use.
type Store[Tx any] struct {
mu sync.Mutex
endpoints map[uuid.UUID]hook.Endpoint
secrets map[uuid.UUID][]hook.Secret
events map[uuid.UUID]hook.Event
}
// New creates an empty in-memory store.
func New[Tx any]() *Store[Tx] {
return &Store[Tx]{
endpoints: make(map[uuid.UUID]hook.Endpoint),
secrets: make(map[uuid.UUID][]hook.Secret),
events: make(map[uuid.UUID]hook.Event),
}
}
// Exec implements the [hook.Store] interface. Transactions are
// nominal: fn's effects apply as they happen.
func (*Store[Tx]) Exec(
ctx context.Context,
fn func(ctx context.Context, tx Tx) error,
) error {
var tx Tx
return fn(ctx, tx)
}
// InsertEndpoint implements the [hook.Store] interface.
func (s *Store[Tx]) InsertEndpoint(
_ context.Context, _ Tx, e hook.Endpoint, secret hook.Secret,
) error {
s.mu.Lock()
defer s.mu.Unlock()
e.Topics = slices.Clone(e.Topics)
s.endpoints[e.ID] = e
s.secrets[e.ID] = []hook.Secret{secret}
return nil
}
// Endpoint implements the [hook.Store] interface.
func (s *Store[Tx]) Endpoint(
_ context.Context, _ Tx, id uuid.UUID,
) (hook.Endpoint, []hook.Secret, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
e, ok := s.endpoints[id]
if !ok {
return hook.Endpoint{}, nil, false, nil
}
return e, s.ordered(id), true, nil
}
// ordered returns the endpoint's secret set, current first, then by
// recency. Callers hold the lock.
func (s *Store[Tx]) ordered(id uuid.UUID) []hook.Secret {
set := slices.Clone(s.secrets[id])
slices.SortFunc(set, func(a, b hook.Secret) int {
switch {
case a.RetiresAt.IsZero() && !b.RetiresAt.IsZero():
return -1
case !a.RetiresAt.IsZero() && b.RetiresAt.IsZero():
return 1
default:
return b.CreatedAt.Compare(a.CreatedAt)
}
})
return set
}
// Endpoints implements the [hook.Store] interface.
func (s *Store[Tx]) Endpoints(
_ context.Context, _ Tx, owner string,
) ([]hook.Endpoint, error) {
s.mu.Lock()
defer s.mu.Unlock()
var out []hook.Endpoint
for _, e := range s.endpoints {
if owner == "" || e.Owner == owner {
out = append(out, e)
}
}
slices.SortFunc(out, func(a, b hook.Endpoint) int {
return a.ID.Compare(b.ID)
})
return out, nil
}
// DeleteEndpoint implements the [hook.Store] interface.
func (s *Store[Tx]) DeleteEndpoint(
_ context.Context, _ Tx, id uuid.UUID,
) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.endpoints[id]; !ok {
return false, nil
}
delete(s.endpoints, id)
delete(s.secrets, id)
return true, nil
}
// SetState implements the [hook.Store] interface.
func (s *Store[Tx]) SetState(
_ context.Context, _ Tx, id uuid.UUID, state hook.State,
at time.Time,
) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
e, ok := s.endpoints[id]
if !ok {
return false, nil
}
e.State = state
e.UpdatedAt = at
if state == hook.StateEnabled {
e.FailingSince = time.Time{}
}
s.endpoints[id] = e
return true, nil
}
// ReplaceSecrets implements the [hook.Store] interface.
func (s *Store[Tx]) ReplaceSecrets(
_ context.Context, _ Tx, id uuid.UUID, secrets []hook.Secret,
) error {
s.mu.Lock()
defer s.mu.Unlock()
s.secrets[id] = slices.Clone(secrets)
return nil
}
// InsertEvent implements the [hook.Store] interface.
func (s *Store[Tx]) InsertEvent(
_ context.Context, _ Tx, e hook.Event,
) error {
s.mu.Lock()
defer s.mu.Unlock()
s.events[e.ID] = e
return nil
}
// Event implements the [hook.Store] interface.
func (s *Store[Tx]) Event(
_ context.Context, _ Tx, id uuid.UUID,
) (hook.Event, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
e, ok := s.events[id]
if !ok {
return hook.Event{}, false, nil
}
return e, true, nil
}
// Subscribers implements the [hook.Store] interface.
func (s *Store[Tx]) Subscribers(
_ context.Context, _ Tx, topic string,
) ([]uuid.UUID, error) {
s.mu.Lock()
defer s.mu.Unlock()
var out []uuid.UUID
for _, e := range s.endpoints {
if e.State == hook.StateEnabled &&
slices.Contains(e.Topics, topic) {
out = append(out, e.ID)
}
}
slices.SortFunc(out, func(a, b uuid.UUID) int { return a.Compare(b) })
return out, nil
}
// MarkFailing implements the [hook.Store] interface.
func (s *Store[Tx]) MarkFailing(
_ context.Context, _ Tx, id uuid.UUID, since time.Time,
) error {
s.mu.Lock()
defer s.mu.Unlock()
e, ok := s.endpoints[id]
if ok && e.FailingSince.IsZero() {
e.FailingSince = since
s.endpoints[id] = e
}
return nil
}
// MarkHealthy implements the [hook.Store] interface.
func (s *Store[Tx]) MarkHealthy(
_ context.Context, _ Tx, id uuid.UUID,
) error {
s.mu.Lock()
defer s.mu.Unlock()
e, ok := s.endpoints[id]
if ok && !e.FailingSince.IsZero() {
e.FailingSince = time.Time{}
s.endpoints[id] = e
}
return nil
}
// PruneEvents implements the [hook.Store] interface.
func (s *Store[Tx]) PruneEvents(
_ context.Context, _ Tx, before time.Time,
) (int64, error) {
s.mu.Lock()
defer s.mu.Unlock()
var n int64
for id, e := range s.events {
if e.At.Before(before) {
delete(s.events, id)
n++
}
}
return n, nil
}
// PruneSecrets implements the [hook.Store] interface.
func (s *Store[Tx]) PruneSecrets(
_ context.Context, _ Tx, before time.Time,
) (int64, error) {
s.mu.Lock()
defer s.mu.Unlock()
var n int64
for id, set := range s.secrets {
kept := set[:0]
for _, secret := range set {
if !secret.RetiresAt.IsZero() &&
secret.RetiresAt.Before(before) {
n++
continue
}
kept = append(kept, secret)
}
s.secrets[id] = kept
}
return n, nil
}
var _ hook.Store[struct{}] = (*Store[struct{}])(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
// Package postgres implements the webhook store on PostgreSQL — the
// reference driver. Claims lease through FOR UPDATE SKIP LOCKED, so any
// number of dispatching replicas share the queue without coordination.
package postgres
import (
"context"
"database/sql"
"embed"
"errors"
"fmt"
"io/fs"
"time"
"uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/deep-rent/nexus/dat/migrate"
driver "github.com/deep-rent/nexus/dat/migrate/driver/postgres"
source "github.com/deep-rent/nexus/dat/migrate/source/file"
"github.com/deep-rent/nexus/net/notify/hook"
)
//go:embed migrations
var migrations embed.FS
// Module is the migration stream the webhook schema lives in. Host
// services applying their own streams gate on it via
// "-- requires: hook@1".
const Module = "hook"
// Migrations exposes the embedded schema migrations for [migrate]
// paired with its PostgreSQL driver.
func Migrations() fs.FS {
sub, err := fs.Sub(migrations, "migrations")
if err != nil {
// The subdirectory is embedded at compile time; failing to open
// it is a build defect, not a runtime condition.
panic(err)
}
return sub
}
// Migrator wires a [migrate.Migrator] for the webhook schema over an
// existing database handle. The module, source, and driver are this
// schema's to declare; opts carry what the caller legitimately varies,
// such as a logger.
func Migrator(db *sql.DB, opts ...migrate.Option) *migrate.Migrator {
return migrate.New(append([]migrate.Option{
migrate.WithModule(Module),
migrate.WithSource(source.New(Migrations())),
migrate.WithDriver(driver.New(db)),
}, opts...)...)
}
// Open connects a migrator for the webhook schema to the database at
// url, for commands that only run migrations. The returned close
// function releases the connection.
func Open(
url string,
opts ...migrate.Option,
) (*migrate.Migrator, func() error, error) {
return driver.Open(url, append([]migrate.Option{
migrate.WithModule(Module),
migrate.WithSource(source.New(Migrations())),
}, opts...)...)
}
// Store implements [hook.Store] on PostgreSQL. It is safe for
// concurrent use.
type Store struct {
pool *pgxpool.Pool
}
// New creates a [Store] over the given connection pool. It panics on a
// nil pool (programmer error).
func New(pool *pgxpool.Pool) *Store {
if pool == nil {
panic("pool is required")
}
return &Store{pool: pool}
}
// Exec implements the [hook.Store] interface.
func (s *Store) Exec(
ctx context.Context,
fn func(ctx context.Context, tx pgx.Tx) error,
) error {
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
return fn(ctx, tx)
})
}
// stamp renders a nullable time: NULL for the zero value.
func stamp(t time.Time) *time.Time {
if t.IsZero() {
return nil
}
return &t
}
// unstamp reads a nullable time back into the zero-value convention.
func unstamp(t *time.Time) time.Time {
if t == nil {
return time.Time{}
}
return *t
}
// InsertEndpoint implements the [hook.Store] interface.
func (*Store) InsertEndpoint(
ctx context.Context, tx pgx.Tx, e hook.Endpoint, secret hook.Secret,
) error {
_, err := tx.Exec(ctx, `
INSERT INTO webhook_endpoints
(id, owner, url, topics, state, internal, failing_since,
created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
e.ID, e.Owner, e.URL, e.Topics, string(e.State), e.Internal,
stamp(e.FailingSince), e.CreatedAt, e.UpdatedAt,
)
if err != nil {
return fmt.Errorf("failed to insert endpoint: %w", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO webhook_secrets (endpoint_id, key, created_at,
retires_at)
VALUES ($1, $2, $3, $4)`,
e.ID, secret.Key, secret.CreatedAt, stamp(secret.RetiresAt),
)
if err != nil {
return fmt.Errorf("failed to insert secret: %w", err)
}
return nil
}
// scanEndpoint reads one endpoint row.
func scanEndpoint(row pgx.Row) (hook.Endpoint, error) {
var (
e hook.Endpoint
state string
failing *time.Time
)
err := row.Scan(
&e.ID, &e.Owner, &e.URL, &e.Topics, &state, &e.Internal,
&failing, &e.CreatedAt, &e.UpdatedAt,
)
if err != nil {
return hook.Endpoint{}, err
}
e.State = hook.State(state)
e.FailingSince = unstamp(failing)
return e, nil
}
// endpointColumns is the canonical select list of scanEndpoint.
const endpointColumns = `id, owner, url, topics, state, internal,
failing_since, created_at, updated_at`
// Endpoint implements the [hook.Store] interface.
func (s *Store) Endpoint(
ctx context.Context, tx pgx.Tx, id uuid.UUID,
) (hook.Endpoint, []hook.Secret, bool, error) {
e, err := scanEndpoint(tx.QueryRow(ctx, `
SELECT `+endpointColumns+` FROM webhook_endpoints
WHERE id = $1`, id,
))
if errors.Is(err, pgx.ErrNoRows) {
return hook.Endpoint{}, nil, false, nil
}
if err != nil {
return hook.Endpoint{}, nil, false, fmt.Errorf(
"failed to read endpoint: %w", err,
)
}
secrets, err := s.secrets(ctx, tx, id)
if err != nil {
return hook.Endpoint{}, nil, false, err
}
return e, secrets, true, nil
}
// secrets reads one endpoint's secret set, current first, then by
// recency.
func (*Store) secrets(
ctx context.Context, tx pgx.Tx, id uuid.UUID,
) ([]hook.Secret, error) {
rows, err := tx.Query(ctx, `
SELECT key, created_at, retires_at FROM webhook_secrets
WHERE endpoint_id = $1
ORDER BY retires_at IS NOT NULL, created_at DESC`, id,
)
if err != nil {
return nil, fmt.Errorf("failed to read secrets: %w", err)
}
defer rows.Close()
var out []hook.Secret
for rows.Next() {
var (
secret hook.Secret
retires *time.Time
)
if err := rows.Scan(
&secret.Key, &secret.CreatedAt, &retires,
); err != nil {
return nil, fmt.Errorf("failed to read secrets: %w", err)
}
secret.RetiresAt = unstamp(retires)
out = append(out, secret)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to read secrets: %w", err)
}
return out, nil
}
// DeleteEndpoint implements the [hook.Store] interface. The secret set
// and the queued deliveries cascade with the endpoint.
func (*Store) DeleteEndpoint(
ctx context.Context, tx pgx.Tx, id uuid.UUID,
) (bool, error) {
res, err := tx.Exec(ctx, `
DELETE FROM webhook_endpoints WHERE id = $1`, id,
)
if err != nil {
return false, fmt.Errorf("failed to delete endpoint: %w", err)
}
return res.RowsAffected() > 0, nil
}
// Endpoints implements the [hook.Store] interface. The empty owner
// matches every endpoint.
func (*Store) Endpoints(
ctx context.Context, tx pgx.Tx, owner string,
) ([]hook.Endpoint, error) {
rows, err := tx.Query(ctx, `
SELECT `+endpointColumns+` FROM webhook_endpoints
WHERE $1 = '' OR owner = $1
ORDER BY id`, owner,
)
if err != nil {
return nil, fmt.Errorf("failed to list endpoints: %w", err)
}
defer rows.Close()
var out []hook.Endpoint
for rows.Next() {
e, err := scanEndpoint(rows)
if err != nil {
return nil, fmt.Errorf("failed to list endpoints: %w", err)
}
out = append(out, e)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to list endpoints: %w", err)
}
return out, nil
}
// SetState implements the [hook.Store] interface.
func (*Store) SetState(
ctx context.Context, tx pgx.Tx, id uuid.UUID, state hook.State,
at time.Time,
) (bool, error) {
res, err := tx.Exec(ctx, `
UPDATE webhook_endpoints SET
state = $2::varchar,
updated_at = $3,
failing_since = CASE WHEN $2::varchar = 'enabled'
THEN NULL ELSE failing_since END
WHERE id = $1`,
id, string(state), at,
)
if err != nil {
return false, fmt.Errorf("failed to set state: %w", err)
}
return res.RowsAffected() > 0, nil
}
// ReplaceSecrets implements the [hook.Store] interface.
func (*Store) ReplaceSecrets(
ctx context.Context, tx pgx.Tx, id uuid.UUID, secrets []hook.Secret,
) error {
if _, err := tx.Exec(ctx, `
DELETE FROM webhook_secrets WHERE endpoint_id = $1`, id,
); err != nil {
return fmt.Errorf("failed to replace secrets: %w", err)
}
for _, secret := range secrets {
if _, err := tx.Exec(ctx, `
INSERT INTO webhook_secrets (endpoint_id, key, created_at,
retires_at)
VALUES ($1, $2, $3, $4)`,
id, secret.Key, secret.CreatedAt, stamp(secret.RetiresAt),
); err != nil {
return fmt.Errorf("failed to replace secrets: %w", err)
}
}
return nil
}
// InsertEvent implements the [hook.Store] interface.
func (*Store) InsertEvent(
ctx context.Context, tx pgx.Tx, e hook.Event,
) error {
_, err := tx.Exec(ctx, `
INSERT INTO webhook_events (id, topic, payload, at, origin)
VALUES ($1, $2, $3, $4, $5)`,
e.ID, e.Topic, []byte(e.Data), e.At, e.Origin,
)
if err != nil {
return fmt.Errorf("failed to insert event: %w", err)
}
return nil
}
// Event implements the [hook.Store] interface.
func (*Store) Event(
ctx context.Context, tx pgx.Tx, id uuid.UUID,
) (hook.Event, bool, error) {
var (
e hook.Event
payload []byte
)
err := tx.QueryRow(ctx, `
SELECT id, topic, payload, at, origin FROM webhook_events
WHERE id = $1`, id,
).Scan(&e.ID, &e.Topic, &payload, &e.At, &e.Origin)
if errors.Is(err, pgx.ErrNoRows) {
return hook.Event{}, false, nil
}
if err != nil {
return hook.Event{}, false, fmt.Errorf("failed to read event: %w", err)
}
e.Data = payload
return e, true, nil
}
// Subscribers implements the [hook.Store] interface.
func (*Store) Subscribers(
ctx context.Context, tx pgx.Tx, topic string,
) ([]uuid.UUID, error) {
rows, err := tx.Query(ctx, `
SELECT id FROM webhook_endpoints
WHERE state = 'enabled' AND topics @> ARRAY[$1]::text[]
ORDER BY id`, topic,
)
if err != nil {
return nil, fmt.Errorf("failed to resolve subscribers: %w", err)
}
defer rows.Close()
var out []uuid.UUID
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf(
"failed to resolve subscribers: %w", err,
)
}
out = append(out, id)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to resolve subscribers: %w", err)
}
return out, nil
}
// MarkFailing implements the [hook.Store] interface.
func (*Store) MarkFailing(
ctx context.Context, tx pgx.Tx, id uuid.UUID, since time.Time,
) error {
_, err := tx.Exec(ctx, `
UPDATE webhook_endpoints SET failing_since = $2
WHERE id = $1 AND failing_since IS NULL`,
id, since,
)
if err != nil {
return fmt.Errorf("failed to mark failing: %w", err)
}
return nil
}
// MarkHealthy implements the [hook.Store] interface.
func (*Store) MarkHealthy(
ctx context.Context, tx pgx.Tx, id uuid.UUID,
) error {
_, err := tx.Exec(ctx, `
UPDATE webhook_endpoints SET failing_since = NULL
WHERE id = $1`, id,
)
if err != nil {
return fmt.Errorf("failed to mark healthy: %w", err)
}
return nil
}
// PruneEvents implements the [hook.Store] interface.
func (*Store) PruneEvents(
ctx context.Context, tx pgx.Tx, before time.Time,
) (int64, error) {
res, err := tx.Exec(ctx, `
DELETE FROM webhook_events WHERE at < $1`, before,
)
if err != nil {
return 0, fmt.Errorf("failed to prune events: %w", err)
}
return res.RowsAffected(), nil
}
// PruneSecrets implements the [hook.Store] interface.
func (*Store) PruneSecrets(
ctx context.Context, tx pgx.Tx, before time.Time,
) (int64, error) {
res, err := tx.Exec(ctx, `
DELETE FROM webhook_secrets
WHERE retires_at IS NOT NULL AND retires_at < $1`, before,
)
if err != nil {
return 0, fmt.Errorf("failed to prune secrets: %w", err)
}
return res.RowsAffected(), nil
}
var _ hook.Store[pgx.Tx] = (*Store)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package hook
import (
"context"
"encoding/json/v2"
"fmt"
"net/http"
"net/url"
"slices"
"strings"
"uuid"
"github.com/deep-rent/nexus/net/header"
"github.com/deep-rent/nexus/net/middleware"
"github.com/deep-rent/nexus/net/notify/hook/dial"
"github.com/deep-rent/nexus/net/transport"
"github.com/deep-rent/nexus/sec/nonce"
"github.com/deep-rent/nexus/std/text"
"github.com/deep-rent/nexus/sys/metrics"
"github.com/deep-rent/nexus/sys/queue"
)
// Engine runs the webhook system over a driver: registration and
// rotation, transactional publishing, and the delivery dispatch. It is
// safe for concurrent use.
type Engine[Tx any] struct {
store Store[Tx]
jobs *queue.Queue[Tx]
cfg config
// external delivers to registered receivers through the SSRF guard;
// internal delivers to endpoints flagged [Endpoint.Internal] and
// dials unvetted. Neither follows redirects: a redirect to an
// internal address is the classic second-hop SSRF, and webhooks
// have no legitimate use for one.
external *http.Client
internal *http.Client
// minter draws fresh signing keys from the system's secure source.
minter *nonce.Generator
// gate bounds one endpoint's share of this process's deliveries.
gate *gate
// The fixed instruments, minted off the configured registry.
attempts *metrics.Summary
suspended *metrics.Counter
}
// New creates an [Engine] over the given store, queueing its deliveries
// on jobs. Both are required, and a nil one panics (programmer error).
//
// The queue must be built over the same transaction type as the store,
// because [Engine.Publish] enqueues the fan-out inside the producer's
// transaction — that is what makes the outbox an outbox.
func New[Tx any](
store Store[Tx],
jobs *queue.Queue[Tx],
opts ...Option,
) *Engine[Tx] {
if store == nil {
panic("store is required")
}
if jobs == nil {
panic("job queue is required")
}
cfg := defaults()
for _, opt := range opts {
opt(&cfg)
}
agent := header.UserAgent("nexus.hook", cfg.version, "")
refuse := func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}
// The external client dials NO proxy: an HTTP(S)_PROXY in the
// environment would connect the transport to the proxy instead of
// the target, so the guard's per-address Control hook — which vets
// the RESOLVED destination — would never run, and the whole SSRF
// defense would route around itself.
guarded := []transport.Option{
transport.WithHeader(agent),
transport.WithProxy(noProxy),
}
if !cfg.insecure {
guarded = append(guarded,
transport.WithDialContext(dial.Guard(cfg.timeout)))
}
e := &Engine[Tx]{
store: store,
jobs: jobs,
cfg: cfg,
minter: nonce.NewGenerator(nil, SecretSize),
gate: &gate{
limit: cfg.perEndpoint,
inflight: make(map[uuid.UUID]int),
},
external: &http.Client{
Timeout: cfg.timeout,
Transport: transport.New(guarded...),
CheckRedirect: refuse,
},
internal: &http.Client{
Timeout: cfg.timeout,
Transport: transport.New(
transport.WithHeader(agent),
),
CheckRedirect: refuse,
},
}
e.attempts = cfg.reg.Summary("hook_attempt_seconds", nil, 0)
e.suspended = cfg.reg.Counter("hook_endpoints_suspended_total")
return e
}
// sealKeys encrypts a secret set for storage, binding each member to
// the endpoint it belongs to: a row lifted into another endpoint's set
// will not open. Without a keyring the keys pass through.
func (e *Engine[Tx]) sealKeys(
secrets []Secret,
id uuid.UUID,
) ([]Secret, error) {
if e.cfg.ring == nil {
return secrets, nil
}
out := slices.Clone(secrets)
for i := range out {
sealed, err := e.cfg.ring.Seal(out[i].Key, id[:])
if err != nil {
return nil, fmt.Errorf("failed to seal a secret: %w", err)
}
out[i].Key = sealed
}
return out, nil
}
// openKeys decrypts a secret set read back from storage.
func (e *Engine[Tx]) openKeys(
secrets []Secret,
id uuid.UUID,
) ([]Secret, error) {
if e.cfg.ring == nil {
return secrets, nil
}
out := slices.Clone(secrets)
for i := range out {
opened, err := e.cfg.ring.Open(out[i].Key, id[:])
if err != nil {
return nil, fmt.Errorf("failed to open a secret: %w", err)
}
out[i].Key = opened
}
return out, nil
}
// noProxy forces direct connections, disabling any environment proxy;
// see the external client above.
func noProxy(*http.Request) (*url.URL, error) { return nil, nil }
// mintKey draws a fresh signing key.
func (e *Engine[Tx]) mintKey(ctx context.Context) ([]byte, error) {
key, err := e.minter.Bytes(ctx)
if err != nil {
return nil, fmt.Errorf("failed to mint a secret: %w", err)
}
return key, nil
}
// vetInternal enforces the allow-list on an endpoint that asks to
// bypass the address guard.
func (e *Engine[Tx]) vetInternal(r Registration) error {
if !r.Internal || len(e.cfg.hosts) == 0 {
return nil
}
u, err := url.Parse(r.URL)
if err != nil {
return fmt.Errorf("%w: unparsable endpoint URL: %w",
ErrInvalid, err)
}
host := strings.ToLower(u.Hostname())
if slices.Contains(e.cfg.hosts, host) {
return nil
}
return fmt.Errorf(
"%w: internal endpoints are limited to %s, got %q",
ErrInvalid, strings.Join(e.cfg.hosts, ", "), host,
)
}
// vetTopics validates a subscription's topic list.
func vetTopics(topics []string) error {
if len(topics) == 0 || len(topics) > MaxTopics {
return fmt.Errorf(
"%w: an endpoint subscribes to 1 to %d topics",
ErrInvalid, MaxTopics,
)
}
seen := make(map[string]struct{}, len(topics))
for _, topic := range topics {
if !ValidTopic(topic) {
return fmt.Errorf("%w: invalid topic %q", ErrInvalid, topic)
}
if _, dup := seen[topic]; dup {
return fmt.Errorf("%w: duplicate topic %q", ErrInvalid, topic)
}
seen[topic] = struct{}{}
}
return nil
}
// Register records a fresh endpoint and returns it together with its
// encoded signing secret — the show-once moment: the secret is never
// retrievable afterwards, only rotatable.
func (e *Engine[Tx]) Register(
ctx context.Context,
r Registration,
) (Endpoint, string, error) {
if r.Owner == "" || len(r.Owner) > MaxOwnerLength {
return Endpoint{}, "", fmt.Errorf(
"%w: owner length must be 1 to %d", ErrInvalid, MaxOwnerLength,
)
}
if err := vetTopics(r.Topics); err != nil {
return Endpoint{}, "", err
}
if len(r.URL) == 0 || len(r.URL) > MaxURLLength {
return Endpoint{}, "", fmt.Errorf(
"%w: endpoint URL length must be 1 to %d",
ErrInvalid, MaxURLLength,
)
}
if err := dial.VetURL(r.URL, r.Internal, e.cfg.insecure); err != nil {
return Endpoint{}, "", fmt.Errorf("%w: %w", ErrInvalid, err)
}
if err := e.vetInternal(r); err != nil {
return Endpoint{}, "", err
}
key, err := e.mintKey(ctx)
if err != nil {
return Endpoint{}, "", err
}
now := e.cfg.clock().UTC()
id := uuid.NewV7()
stored, err := e.sealKeys([]Secret{{Key: key, CreatedAt: now}}, id)
if err != nil {
return Endpoint{}, "", err
}
endpoint := Endpoint{
ID: id,
Owner: r.Owner,
URL: r.URL,
Topics: slices.Clone(r.Topics),
State: StateEnabled,
Internal: r.Internal,
CreatedAt: now,
UpdatedAt: now,
}
err = e.store.Exec(ctx, func(ctx context.Context, tx Tx) error {
return e.store.InsertEndpoint(ctx, tx, endpoint, stored[0])
})
if err != nil {
return Endpoint{}, "", err
}
return endpoint, EncodeSecret(key), nil
}
// Rotate mints a fresh current secret and returns its encoded form —
// shown once, like registration. The displaced secret keeps signing
// through the grace window, during which deliveries carry a signature
// per live member, so receivers switch on their own schedule.
func (e *Engine[Tx]) Rotate(
ctx context.Context,
id uuid.UUID,
) (string, error) {
key, err := e.mintKey(ctx)
if err != nil {
return "", err
}
now := e.cfg.clock().UTC()
err = e.store.Exec(ctx, func(ctx context.Context, tx Tx) error {
_, secrets, ok, err := e.store.Endpoint(ctx, tx, id)
if err != nil {
return err
}
if !ok {
return ErrNotFound
}
set := []Secret{{Key: key, CreatedAt: now}}
sealed, err := e.sealKeys(set, id)
if err != nil {
return err
}
// The stored members are already sealed; only the fresh one
// needs it, so the rest are carried across untouched.
for _, s := range secrets {
if s.RetiresAt.IsZero() {
// The displaced current begins its retirement.
s.RetiresAt = now.Add(e.cfg.grace)
}
if s.live(now) {
sealed = append(sealed, s)
}
}
return e.store.ReplaceSecrets(ctx, tx, id, sealed)
})
if err != nil {
return "", err
}
return EncodeSecret(key), nil
}
// Enable transitions an endpoint back into delivery, clearing any
// failure record — the way back from both disabled and suspended.
func (e *Engine[Tx]) Enable(ctx context.Context, id uuid.UUID) error {
return e.setState(ctx, id, StateEnabled)
}
// Disable turns an endpoint off: no fresh event fans out to it, and the
// deliveries already queued for it are abandoned when their turn comes
// rather than delivered. An endpoint that has been turned off wants
// nothing, including what it was sent before.
func (e *Engine[Tx]) Disable(ctx context.Context, id uuid.UUID) error {
return e.setState(ctx, id, StateDisabled)
}
// setState transitions the lifecycle state.
func (e *Engine[Tx]) setState(
ctx context.Context,
id uuid.UUID,
s State,
) error {
return e.store.Exec(ctx, func(ctx context.Context, tx Tx) error {
ok, err := e.store.SetState(ctx, tx, id, s, e.cfg.clock().UTC())
if err != nil {
return err
}
if !ok {
return ErrNotFound
}
return nil
})
}
// Get returns one endpoint, without secrets.
func (e *Engine[Tx]) Get(
ctx context.Context,
id uuid.UUID,
) (Endpoint, error) {
var out Endpoint
err := e.store.Exec(ctx, func(ctx context.Context, tx Tx) error {
endpoint, _, ok, err := e.store.Endpoint(ctx, tx, id)
if err != nil {
return err
}
if !ok {
return ErrNotFound
}
out = endpoint
return nil
})
return out, err
}
// Delete removes an endpoint for good, with its secrets and the
// deliveries queued for it. Prefer [Engine.Disable] for an endpoint that
// may come back: disabling stops the fan-out while leaving the record
// and its delivery history to look at.
//
// It reports [ErrNotFound] when no endpoint carries the identifier.
func (e *Engine[Tx]) Delete(ctx context.Context, id uuid.UUID) error {
return e.store.Exec(ctx, func(ctx context.Context, tx Tx) error {
ok, err := e.store.DeleteEndpoint(ctx, tx, id)
if err != nil {
return err
}
if !ok {
return ErrNotFound
}
return nil
})
}
// List returns an owner's endpoints, without secrets. The empty owner
// lists every endpoint registered, for an operator's view of the whole
// registry.
func (e *Engine[Tx]) List(
ctx context.Context,
owner string,
) ([]Endpoint, error) {
var out []Endpoint
err := e.store.Exec(ctx, func(ctx context.Context, tx Tx) error {
var err error
out, err = e.store.Endpoints(ctx, tx, owner)
return err
})
return out, err
}
// Publish records an event and fans it out to every enabled subscriber
// of its topic, returning how many deliveries were queued. It runs
// INSIDE the producer's transaction — the transactional outbox: if the
// business write commits, the deliveries exist; if it rolls back, no
// ghost notification ever leaves. Fan-out resolves at publish time, so
// later subscription changes affect later events only.
func (e *Engine[Tx]) Publish(
ctx context.Context,
tx Tx,
event Event,
) (int, error) {
if !ValidTopic(event.Topic) {
return 0, fmt.Errorf("%w: invalid topic %q",
ErrInvalid, event.Topic)
}
if len(event.Data) > MaxPayloadSize {
return 0, fmt.Errorf(
"%w: payload exceeds %d bytes — webhook payloads are "+
"thin by design; publish identifiers, not resources",
ErrInvalid, MaxPayloadSize,
)
}
// Reject a malformed payload here, where the producer can react,
// rather than letting it render only at delivery — where it would
// silently dead-letter against every subscriber.
if len(event.Data) > 0 && !event.Data.IsValid() {
return 0, fmt.Errorf("%w: payload is not valid JSON", ErrInvalid)
}
if event.ID == uuid.Nil() {
event.ID = uuid.NewV7()
}
if event.At.IsZero() {
event.At = e.cfg.clock().UTC()
}
if event.Origin == "" {
// The request that published it, when there is one; a producer
// running off a bus or a schedule leaves it empty.
event.Origin = middleware.GetRequestID(ctx)
}
// Bounded by CHARACTERS, as the column is, and on a rune boundary:
// a byte-wise cut through a multi-byte origin would produce invalid
// UTF-8, which PostgreSQL refuses — taking the producer's whole
// transaction down with it.
event.Origin = text.Fit(event.Origin, MaxOriginLength)
// Subscribers resolve first: an event nobody subscribes to is
// recorded nowhere, since only a delivery job ever reads an event
// back and there is none to. The row would be pure churn — written,
// WAL-logged, and deleted by retention without a single read.
subscribers, err := e.store.Subscribers(ctx, tx, event.Topic)
if err != nil {
return 0, err
}
if len(subscribers) == 0 {
return 0, nil
}
if err := e.store.InsertEvent(ctx, tx, event); err != nil {
return 0, err
}
rs := make([]queue.Request, len(subscribers))
for i, endpoint := range subscribers {
payload, err := json.Marshal(delivery{
Event: event.ID, Endpoint: endpoint,
})
if err != nil {
return 0, fmt.Errorf("failed to render a delivery: %w", err)
}
rs[i] = queue.Request{
Kind: KindDeliver,
Payload: payload,
// One pending delivery per event and endpoint: a producer
// that publishes the same event twice — a retried request,
// a redelivered upstream message — fans it out once.
Key: event.ID.String() + ":" + endpoint.String(),
RunAt: event.At,
}
}
return e.jobs.PushBatch(ctx, tx, rs)
}
// Emit is [Engine.Publish] in a transaction of its own, for producers
// with no business write to join — a consumer of an in-process event
// bus, say, reacting to something that has already been committed.
//
// Prefer Publish wherever a transaction is already open. Emit gives up
// the outbox guarantee that makes Publish worth using: the business
// write has committed by the time Emit runs, so a process that dies in
// between leaves the event unpublished and nothing to reconcile it
// from.
func (e *Engine[Tx]) Emit(ctx context.Context, event Event) (int, error) {
var fanned int
err := e.store.Exec(ctx, func(ctx context.Context, tx Tx) error {
var err error
fanned, err = e.Publish(ctx, tx, event)
return err
})
if err != nil {
return 0, err
}
return fanned, nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package hook
import (
"encoding/json/jsontext"
"errors"
"time"
"uuid"
"github.com/deep-rent/nexus/dat/valid"
)
// Bounds on the registered and published inputs.
const (
// MaxOwnerLength bounds the opaque owner identifier.
MaxOwnerLength = 128
// MaxURLLength bounds a registered endpoint URL.
MaxURLLength = 512
// MaxTopicLength bounds a single topic name.
MaxTopicLength = 64
// MaxTopics bounds how many topics one endpoint may subscribe to.
MaxTopics = 32
// MaxPayloadSize bounds an event payload. Payloads are THIN by
// convention — identifiers and a topic, with details fetched on
// demand — so the cap is deliberately tight; see the package
// documentation.
MaxPayloadSize = 64 << 10
// MaxOriginLength bounds an event's correlation identifier.
MaxOriginLength = 64
)
// State is the lifecycle state of an endpoint.
type State string
// The endpoint lifecycle vocabulary.
const (
// StateEnabled receives deliveries.
StateEnabled State = "enabled"
// StateDisabled receives nothing: turned off by its owner, or by a
// receiver answering 410 Gone. Re-enabling is explicit.
StateDisabled State = "disabled"
// StateSuspended receives nothing: delivery gave up after the
// endpoint failed everything sent to it for the suspension window.
// Re-enabling is explicit and clears the failure record.
StateSuspended State = "suspended"
)
// Endpoint is one registered webhook receiver. Its secrets never travel
// with it: they are shown exactly once, at registration and rotation.
type Endpoint struct {
// ID identifies the endpoint.
ID uuid.UUID
// Owner is the host-defined identity the endpoint belongs to — a
// customer identifier, or a name for an internal consumer. Listing
// scopes to it; the package attaches no further meaning.
Owner string
// URL is the receiver, always https (see the engine's test-only
// exception).
URL string
// Topics are the exact-match event topics the endpoint subscribes
// to.
Topics []string
// State is the lifecycle state; only enabled endpoints are fanned
// out to.
State State
// Internal marks an endpoint that may resolve to private address
// space — for subscribers inside the deployment's own network.
// Registration surfaces exposed to customers must never set it.
Internal bool
// FailingSince is when the endpoint's current unbroken failure
// streak began; zero while healthy. Delivery suspends the endpoint
// once the streak outgrows the suspension window.
FailingSince time.Time
// CreatedAt and UpdatedAt are bookkeeping timestamps.
CreatedAt time.Time
UpdatedAt time.Time
}
// Secret is one member of an endpoint's signing secret set. The current
// member has a zero RetiresAt; rotation stamps it and mints a fresh
// current one, and deliveries carry one signature per live member until
// the stamped one retires.
type Secret struct {
// Key is the raw 256-bit HMAC key.
Key []byte
// CreatedAt is when the member was minted.
CreatedAt time.Time
// RetiresAt is when the member stops signing; zero means current.
RetiresAt time.Time
}
// live reports whether the member still signs at the given instant.
func (s Secret) live(now time.Time) bool {
return s.RetiresAt.IsZero() || s.RetiresAt.After(now)
}
// String redacts the key: a secret must never reach a log or an error
// by accident. The one legitimate disclosure — the show-once moment of
// registration and rotation — goes through [EncodeSecret] explicitly.
func (Secret) String() string { return SecretPrefix + "[redacted]" }
// Event is one occurrence a producer publishes. Its identifier travels
// as the webhook-id header of every delivery — stable across retries,
// which is what makes receiver-side deduplication work.
type Event struct {
// ID identifies the event (UUIDv7); [Engine.Publish] assigns one
// when zero.
ID uuid.UUID
// Topic names what happened, lowercase and dot-separated:
// "user.deleted", "document.archived".
Topic string
// Data is the payload. Keep it thin: identifiers, not resources —
// receivers fetch details on demand. May be nil.
Data jsontext.Value
// At is when the event occurred; Publish stamps it when zero.
At time.Time
// Origin correlates the event back to whatever caused it — the
// request identifier of the call that published it, typically. It
// travels to the receiver as the webhook-origin header and into
// this service's own delivery logs, so a support question about one
// delivery can be traced to the request behind it.
//
// [Engine.Publish] fills it from the context's request identifier
// when the producer leaves it empty; a producer with no request
// behind it (a consumer of an event bus, a scheduled sweep) leaves
// it empty and loses nothing but the correlation.
Origin string
}
// Envelope is the delivery body a receiver takes off the wire:
//
// {"type": "user.deleted", "timestamp": "...", "data": {...}}
//
// The engine marshals one per attempt, and a receiver unmarshals into
// it after [VerifyRequest] has accepted the raw bytes — the one struct
// on both sides of the wire, so the contract cannot drift between
// them.
type Envelope struct {
// Type is the event's topic.
Type string `json:"type"`
// Timestamp is when the event occurred — the EVENT's own time, as
// distinct from the attempt's signing time in the
// webhook-timestamp header.
Timestamp time.Time `json:"timestamp"`
// Data is the event payload; absent when the event carried none.
Data jsontext.Value `json:"data,omitzero"`
}
// ValidTopic reports whether the topic is well-formed: a [valid.Topic]
// within the length bound.
func ValidTopic(topic string) bool {
return len(topic) <= MaxTopicLength && valid.Topic(topic)
}
// Registration is the input of [Engine.Register].
type Registration struct {
// Owner is the host-defined identity the endpoint belongs to.
Owner string
// URL is the receiver.
URL string
// Topics are the subscribed topics.
Topics []string
// Internal admits private address space; see [Endpoint.Internal].
Internal bool
}
// ErrNotFound reports an operation on an endpoint that does not exist.
var ErrNotFound = errors.New("endpoint not found")
// ErrInvalid reports input the caller can fix: a malformed or refused
// endpoint URL, an unknown or duplicated topic, an oversized payload.
// It separates the caller's mistakes from the engine's own failures —
// a datastore outage is never ErrInvalid — so a management surface can
// answer 400 where it means it and 500 everywhere else. Test with
// [errors.Is].
var ErrInvalid = errors.New("invalid input")
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package hook
import (
"strings"
"time"
"github.com/deep-rent/nexus/sec/seal"
"github.com/deep-rent/nexus/std/backoff"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/metrics"
)
// Defaults of the engine knobs left unset by the options.
const (
// DefaultTimeout bounds one delivery attempt.
DefaultTimeout = 10 * time.Second
// DefaultGrace is the rotation overlap: how long a displaced secret
// keeps signing so receivers switch on their own schedule.
DefaultGrace = 24 * time.Hour
// DefaultSuspendAfter is the unbroken failure streak after which an
// endpoint is suspended.
DefaultSuspendAfter = 72 * time.Hour
// DefaultRetention is the age after which published events and
// retired secrets are pruned. It must outlast the retry schedule;
// see [Engine.Retention].
DefaultRetention = 30 * 24 * time.Hour
// DefaultRetries is the retry budget beyond the initial attempt.
// With [DefaultStrategy] the budget spans roughly a day end to end.
DefaultRetries = 10
// DefaultEndpointConcurrency bounds how many deliveries one worker
// has in flight to a SINGLE endpoint. It keeps one busy subscriber
// from taking every slot a fleet has; see [WithEndpointConcurrency].
DefaultEndpointConcurrency = 4
)
// DefaultStrategy builds the default retry pacing: exponential from
// five seconds toward a six-hour ceiling, with the backoff package's
// default jitter spreading concurrent retries apart.
func DefaultStrategy() backoff.Strategy {
return backoff.New(
backoff.WithMinDelay(5*time.Second),
backoff.WithMaxDelay(6*time.Hour),
backoff.WithGrowthFactor(3),
)
}
// config holds the engine settings.
type config struct {
ring *seal.Keyring
hosts []string
perEndpoint int
logger *log.Logger
clock clock.Clock
reg *metrics.Registry
strategy backoff.Strategy
retries int
timeout time.Duration
grace time.Duration
suspendAfter time.Duration
retention time.Duration
insecure bool
version string
}
// defaults returns the baseline configuration.
func defaults() config {
return config{
perEndpoint: DefaultEndpointConcurrency,
logger: log.Discard(),
clock: clock.System,
reg: metrics.DefaultRegistry,
strategy: DefaultStrategy(),
retries: DefaultRetries,
timeout: DefaultTimeout,
grace: DefaultGrace,
suspendAfter: DefaultSuspendAfter,
retention: DefaultRetention,
}
}
// Option configures an [Engine].
type Option func(*config)
// WithLogger sets the logger receiving dispatch diagnostics. If not
// provided, the engine stays silent. A nil logger is ignored.
func WithLogger(logger *log.Logger) Option {
return func(c *config) {
if logger != nil {
c.logger = logger
}
}
}
// WithClock injects the time source, which is primarily useful for
// testing. A nil clock is ignored.
func WithClock(now clock.Clock) Option {
return func(c *config) {
if now != nil {
c.clock = now
}
}
}
// WithRegistry registers the engine's instruments with reg instead of
// [metrics.DefaultRegistry]. A nil registry is ignored.
func WithRegistry(reg *metrics.Registry) Option {
return func(c *config) {
if reg != nil {
c.reg = reg
}
}
}
// WithBackoff replaces the retry pacing of a delivery, which the engine
// passes to the queue when [Engine.Handle] registers it; see
// [backoff.New] for the strategy vocabulary. A nil strategy is ignored.
func WithBackoff(s backoff.Strategy) Option {
return func(c *config) {
if s != nil {
c.strategy = s
}
}
}
// WithRetries replaces the retry budget of a delivery beyond its first
// attempt, which the engine passes to the queue when [Engine.Handle]
// registers it. Negative values are ignored; zero means one attempt.
func WithRetries(n int) Option {
return func(c *config) {
if n >= 0 {
c.retries = n
}
}
}
// WithTimeout bounds one delivery attempt — the HTTP request itself,
// inside the wider budget [Engine.Handle] gives the handler. Values of
// zero or less are ignored.
func WithTimeout(d time.Duration) Option {
return func(c *config) {
if d > 0 {
c.timeout = d
}
}
}
// WithGrace overrides the rotation overlap. Values of zero or less are
// ignored.
func WithGrace(d time.Duration) Option {
return func(c *config) {
if d > 0 {
c.grace = d
}
}
}
// WithSuspendAfter overrides the failure streak after which an endpoint
// is suspended. Values of zero or less are ignored.
func WithSuspendAfter(d time.Duration) Option {
return func(c *config) {
if d > 0 {
c.suspendAfter = d
}
}
}
// WithRetention overrides the pruning window. Values of zero or less
// are ignored.
func WithRetention(d time.Duration) Option {
return func(c *config) {
if d > 0 {
c.retention = d
}
}
}
// WithInsecureHTTP admits plain-http endpoint URLs AND disables the
// private-address dial guard — for test setups delivering to local
// listeners, and for nothing else. The production default enforces
// https and vets every dialed address.
func WithInsecureHTTP(insecure bool) Option {
return func(c *config) {
c.insecure = insecure
}
}
// WithSealer encrypts the signing secrets at rest under the given
// keyring, binding each to the endpoint it belongs to. Without one the
// keys are stored as they are — which is fine for a test rig and wrong
// for a deployment, since a database backup would then carry everything
// needed to forge deliveries to every subscriber.
//
// Rotating the keyring needs no migration: a sealed secret names the
// key that sealed it, and [seal.Keyring] opens what its retired keys
// sealed. A nil keyring is ignored.
func WithSealer(ring *seal.Keyring) Option {
return func(c *config) {
if ring != nil {
c.ring = ring
}
}
}
// WithInternalHosts limits which hosts an endpoint flagged
// [Endpoint.Internal] may name. Such an endpoint bypasses the address
// guard entirely, so without a list the permission to register one is
// the permission to reach anything this service can — including a cloud
// metadata service. Naming the handful of in-cluster hosts that
// legitimately subscribe turns that into a bounded grant.
//
// Matching is on the URL host alone, case-insensitively and without the
// port. An empty list leaves internal endpoints unrestricted.
func WithInternalHosts(hosts ...string) Option {
return func(c *config) {
c.hosts = make([]string, 0, len(hosts))
for _, host := range hosts {
if host != "" {
c.hosts = append(c.hosts, strings.ToLower(host))
}
}
}
}
// WithEndpointConcurrency bounds how many deliveries one worker has in
// flight to a single endpoint; beyond it, further deliveries for that
// endpoint are deferred rather than run, at no cost to their retry
// budget.
//
// The bound is per worker, not per fleet: it keeps one busy subscriber
// from taking every slot a worker has, which is what makes a burst for
// one endpoint stop starving the others. Values of zero or less are
// ignored.
func WithEndpointConcurrency(n int) Option {
return func(c *config) {
if n > 0 {
c.perEndpoint = n
}
}
}
// WithVersion stamps the build version into the delivery User-Agent.
func WithVersion(version string) Option {
return func(c *config) {
c.version = version
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package hook
import (
"encoding/json/v2"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/metrics"
)
// The intake outcome vocabulary of hook_intakes_total{result},
// exported so dashboards, alert rules, and tests reference the same
// spelling. Every delivery moves exactly one of them, so a persistent
// store failure cannot read as a healthy, silent hook stream.
//
// They mirror [ResultDelivered] and its siblings, which count what a
// receiver did with what this service sent.
const (
// IntakeRefused is a delivery turned away before it could be acted
// on: its body could not be read, or no accepted secret verified
// it.
IntakeRefused = "refused"
// IntakeIgnored is a delivery on a topic this receiver does not
// act on. It is acknowledged, not refused; see [Receiver].
IntakeIgnored = "ignored"
// IntakeFailed is a verified delivery this receiver could not
// settle, whether because the body was malformed or because the
// work behind it failed.
IntakeFailed = "failed"
// IntakeHandled is a delivery acted on.
IntakeHandled = "handled"
)
// Delivery is one verified inbound delivery: the envelope the sender
// marshalled, and the correlation identifier it travelled under.
type Delivery struct {
Envelope
// Origin is the sender's correlation identifier, from the
// webhook-origin header; empty when the event carried none.
Origin string
}
// Decode unmarshals the event payload into v. An event whose topic
// this receiver subscribes to but whose payload does not parse is a
// sender-side defect, so the error refuses the delivery rather than
// letting a retry deliver the same malformed bytes forever.
//
// An event that carried no payload leaves v untouched and returns nil;
// a handler that needs one checks the fields it requires.
func (d Delivery) Decode(v any) error {
if len(d.Data) == 0 {
return nil
}
if err := json.Unmarshal(d.Data, v); err != nil {
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonParseJSON,
Description: "delivery payload is not valid JSON",
Cause: err,
}
}
return nil
}
// Accept acts on one verified delivery of a subscribed topic. It must
// not write a response: the receiver answers 204 once it returns nil.
//
// Returning an error refuses the delivery, which makes the sender retry
// it on its backoff schedule. That is right for the transient failures
// — a conflicting concurrent change, a store error — and right for a
// malformed payload, which will not fix itself but must not be
// silently dropped either. Handlers should be idempotent: delivery is
// at-least-once, so a replay within the tolerance window costs a
// second pass over work already done.
type Accept func(e *router.Exchange, d Delivery) error
// ReceiverConfig declares one webhook intake.
type ReceiverConfig struct {
// Secrets are the accepted signing secrets ("whsec_..."), each
// validated by [NewReceiver]. Several may be listed so a rotation
// can overlap: rotate at the SENDER first — deliveries then carry
// signatures from both the fresh and the displaced secret through
// its grace window, so the old one here keeps verifying — then add
// the newly shown secret beside it and deploy within that window,
// and drop the old entry at leisure.
Secrets []string
// Tolerance overrides how far a delivery's timestamp may lie from
// now before it is refused as a replay. Zero applies
// [DefaultTolerance].
Tolerance time.Duration
// Logger records why a delivery was refused or ignored, which is
// where an operator looks when a stream goes quiet. Nil discards.
Logger *log.Logger
// Registry counts the outcomes. Nil uses [metrics.DefaultRegistry].
Registry *metrics.Registry
}
// Receiver accepts webhook deliveries somebody else sent: it verifies
// the signature, takes the envelope off the wire, and hands the ones
// it subscribes to over to their handlers. It is the counterpart of
// [Engine], which sends.
//
// A topic the receiver does not subscribe to is ACKNOWLEDGED rather
// than refused, whatever its payload looks like. Refusing one would
// make the sender retry a delivery that will never mean anything here,
// and count the failures against this endpoint's health until it is
// suspended.
//
// The handler carries no bearer guard: the request authenticates by
// its signature, which proves possession of the secret minted when the
// endpoint was registered with the sender.
type Receiver struct {
secrets []string
tolerance time.Duration
logger *log.Logger
reg *metrics.Registry
topics map[string]Accept
}
// NewReceiver builds a receiver over the accepted secrets. A secret
// that does not decode fails HERE, at startup: at runtime it would be
// indistinguishable from a forged signature, and the typo would
// surface as silent refusals long after the deploy that introduced it.
//
// It returns an error when no secret is configured at all. A
// deployment that hears nothing should leave the receiver unmounted
// deliberately rather than mount one that refuses everything.
func NewReceiver(cfg ReceiverConfig) (*Receiver, error) {
if len(cfg.Secrets) == 0 {
return nil, errors.New("no accepted signing secrets")
}
secrets := make([]string, len(cfg.Secrets))
for i, secret := range cfg.Secrets {
// The comma-split of a list keeps surrounding white space.
secret = strings.TrimSpace(secret)
if _, err := DecodeSecret(secret); err != nil {
return nil, fmt.Errorf(
"signing secret %d of %d is malformed: %w",
i+1, len(cfg.Secrets), err,
)
}
secrets[i] = secret
}
logger := cfg.Logger
if logger == nil {
logger = log.Discard()
}
reg := cfg.Registry
if reg == nil {
reg = metrics.DefaultRegistry
}
return &Receiver{
secrets: secrets,
tolerance: cfg.Tolerance,
logger: logger,
reg: reg,
topics: make(map[string]Accept),
}, nil
}
// On subscribes to one topic. It returns the receiver, so a whole
// subscription reads as one expression:
//
// rcv, err := hook.NewReceiver(hook.ReceiverConfig{...})
// if err != nil {
// return nil, err
// }
// rcv.On(TopicUserDeleted, s.forgetUser).
// On(TopicTeamDissolved, s.dissolveTeam).
// Mount(rt.Router(), PathHooks)
//
// Registering the same topic twice panics: the second handler would
// silently replace the first, and both are wired at assembly, where a
// mistake should stop the build rather than the stream.
func (r *Receiver) On(topic string, accept Accept) *Receiver {
if accept == nil {
panic("hook: a topic handler is required")
}
if _, ok := r.topics[topic]; ok {
panic("hook: duplicate handler for topic " + strconv.Quote(topic))
}
r.topics[topic] = accept
return r
}
// Mount registers the intake handler for POST at path.
func (r *Receiver) Mount(reg router.Registrar, path string) {
reg.HandleFunc(http.MethodPost, path, r.intake)
}
// Handler returns the intake handler, for mounting it by hand.
func (r *Receiver) Handler() router.HandlerFunc { return r.intake }
// intake verifies one delivery, dispatches it, and settles it.
func (r *Receiver) intake(e *router.Exchange) error {
// The router bounds the body; a delivery is a thin envelope and the
// read cannot balloon.
body, err := io.ReadAll(e.R.Body)
if err != nil {
r.count(IntakeRefused)
if _, ok := errors.AsType[*http.MaxBytesError](err); ok {
return &router.Error{
Status: http.StatusRequestEntityTooLarge,
Reason: router.ReasonValidationFailed,
Description: "delivery exceeds the body limit",
Cause: err,
}
}
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonValidationFailed,
Description: "failed to read the delivery",
Cause: err,
}
}
origin := e.R.Header.Get(HeaderOrigin)
if err := r.verify(e.R.Header, body); err != nil {
// The refusal answers without detail — the sender learns
// nothing about which check failed — while the log keeps the
// cause, so clock skew ([ErrExpired]) is distinguishable from a
// wrong secret ([ErrSignature]) where an operator looks for it.
r.count(IntakeRefused)
r.logger.Warn(e.Context(), "Refused a webhook delivery",
log.Error(err), log.String("origin", origin))
return &router.Error{
Status: http.StatusUnauthorized,
Reason: router.ReasonValidationFailed,
Description: "delivery verification failed",
}
}
var envelope Envelope
if err := json.Unmarshal(body, &envelope); err != nil {
r.count(IntakeFailed)
return &router.Error{
Status: http.StatusBadRequest,
Reason: router.ReasonParseJSON,
Description: "delivery body is not valid JSON",
Cause: err,
}
}
// The topic decides BEFORE the payload is parsed, so that a foreign
// event is acknowledged whatever its payload looks like.
accept, ok := r.topics[envelope.Type]
if !ok {
r.count(IntakeIgnored)
r.logger.Info(e.Context(), "Ignored a webhook topic",
log.String("topic", envelope.Type),
log.String("origin", origin))
e.NoContent()
return nil
}
if err := accept(e, Delivery{
Envelope: envelope,
Origin: origin,
}); err != nil {
// Counted whatever the shape: a verified delivery that did not
// settle must move a needle somewhere.
r.count(IntakeFailed)
return err
}
r.count(IntakeHandled)
e.NoContent()
return nil
}
// verify checks the delivery against every accepted secret, returning
// nil when one verifies and the LAST failure otherwise — enough for a
// log to tell an expired timestamp from a bad signature.
func (r *Receiver) verify(headers http.Header, body []byte) error {
err := ErrSignature
for _, secret := range r.secrets {
if err = VerifyRequest(
secret, headers, body, r.tolerance,
); err == nil {
return nil
}
}
return err
}
// count records one intake outcome.
func (r *Receiver) count(result string) {
r.reg.Counter(
"hook_intakes_total", metrics.T("result", result),
).Inc()
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package hook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/deep-rent/nexus/sec/digest"
)
// The delivery headers, following the Standard Webhooks convention so
// receivers can verify with off-the-shelf libraries.
const (
// HeaderID carries the EVENT identifier — stable across retries, so
// receivers deduplicate on it.
HeaderID = "webhook-id"
// HeaderTimestamp carries the attempt's signing time as unix
// seconds. Every retry re-signs with a fresh stamp, keeping honest
// retries inside the receiver's tolerance while replayed captures
// age out of it.
HeaderTimestamp = "webhook-timestamp"
// HeaderSignature carries one signature per live secret,
// space-separated, each "v1,<base64>". Multiple signatures appear
// during a rotation's grace overlap; a receiver accepts the
// delivery when ANY of them verifies against its secret.
HeaderSignature = "webhook-signature"
)
// SecretPrefix prefixes the encoded form of a signing secret.
const SecretPrefix = "whsec_"
// SecretSize is the raw key length in bytes.
const SecretSize = 32
// scheme tags the signature version.
const scheme = "v1"
// DefaultTolerance is the recommended receiver-side bound on the age of
// a delivery's timestamp. Within it, honest retries verify; beyond it,
// a captured delivery is a replay and must be refused.
const DefaultTolerance = 5 * time.Minute
// Verification failures. Receivers should treat both identically —
// refuse the delivery — and never echo details back to the caller.
var (
// ErrSignature reports that no presented signature verifies.
ErrSignature = errors.New("signature verification failed")
// ErrExpired reports a timestamp outside the tolerance window.
ErrExpired = errors.New("delivery timestamp outside tolerance")
// ErrHeaders reports missing or malformed delivery headers.
ErrHeaders = errors.New("missing or malformed delivery headers")
// ErrSecret reports a malformed encoded secret.
ErrSecret = errors.New("malformed secret")
)
// EncodeSecret renders a raw key into its portable form,
// "whsec_<base64>". This is the show-once disclosure format of
// registration and rotation.
func EncodeSecret(key []byte) string {
return SecretPrefix + base64.StdEncoding.EncodeToString(key)
}
// DecodeSecret parses the portable form back into the raw key.
func DecodeSecret(secret string) ([]byte, error) {
encoded, ok := strings.CutPrefix(secret, SecretPrefix)
if !ok {
return nil, ErrSecret
}
key, err := base64.StdEncoding.DecodeString(encoded)
if err != nil || len(key) == 0 {
return nil, ErrSecret
}
return key, nil
}
// sign computes one signature over the canonical content
// "{id}.{timestamp}.{body}" — binding identity, time, and payload, so a
// captured delivery can neither be replayed later, transplanted onto
// another event, nor carry an altered body.
//
// The MAC stays on crypto/hmac with STANDARD base64 deliberately: the
// wire format follows the Standard Webhooks convention so off-the-shelf
// receiver libraries verify these deliveries, and sec/digest
// fingerprints encode base64url — right for storage, wrong for this
// wire. Verification borrows digest's constant-time comparison instead.
func sign(key []byte, id string, at time.Time, body []byte) string {
mac := hmac.New(sha256.New, key)
fmt.Fprintf(mac, "%s.%d.", id, at.Unix())
mac.Write(body)
return scheme + "," + base64.StdEncoding.EncodeToString(mac.Sum(nil))
}
// Signatures renders the signature header value: one signature per key,
// space-separated. During a rotation's grace overlap the set carries
// two, so receivers on either secret keep verifying.
func Signatures(
keys [][]byte,
id string,
at time.Time,
body []byte,
) string {
parts := make([]string, len(keys))
for i, key := range keys {
parts[i] = sign(key, id, at, body)
}
return strings.Join(parts, " ")
}
// Verify checks one delivery against a receiver's secret: the timestamp
// must lie within the tolerance of now (both directions — a generous
// future stamp is as suspect as a stale one), and at least one
// presented signature must match. Comparison is constant-time.
//
// The zero tolerance applies [DefaultTolerance].
func Verify(
secret string,
id string,
timestamp string,
signatures string,
body []byte,
now time.Time,
tolerance time.Duration,
) error {
key, err := DecodeSecret(secret)
if err != nil {
return err
}
if id == "" || timestamp == "" || signatures == "" {
return ErrHeaders
}
unix, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return ErrHeaders
}
if tolerance <= 0 {
tolerance = DefaultTolerance
}
at := time.Unix(unix, 0)
if at.Before(now.Add(-tolerance)) || at.After(now.Add(tolerance)) {
return ErrExpired
}
want := sign(key, id, at, body)
for part := range strings.SplitSeq(signatures, " ") {
if digest.Equal(part, want) {
return nil
}
}
return ErrSignature
}
// VerifyRequest checks a received delivery straight from its HTTP
// headers, the convenient form for receiver handlers:
//
// body, _ := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
// if err := hook.VerifyRequest(secret, r.Header, body, 0); err != nil {
// w.WriteHeader(http.StatusUnauthorized)
// return
// }
//
// The zero tolerance applies [DefaultTolerance].
func VerifyRequest(
secret string,
headers http.Header,
body []byte,
tolerance time.Duration,
) error {
return Verify(
secret,
headers.Get(HeaderID),
headers.Get(HeaderTimestamp),
headers.Get(HeaderSignature),
body,
time.Now(),
tolerance,
)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package mail
import (
"bytes"
"context"
"encoding/json/v2"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"time"
"github.com/deep-rent/nexus/net/notify"
"github.com/deep-rent/nexus/net/transport"
"github.com/deep-rent/nexus/sys/log"
)
var (
// ErrNilMessage is returned when a nil [Message] is validated.
ErrNilMessage = errors.New("message cannot be nil")
// ErrMissingRecipients is returned when an email has no recipients.
ErrMissingRecipients = errors.New("at least one recipient is needed")
// ErrMissingTemplate is returned when an email names no template.
ErrMissingTemplate = errors.New("template is needed")
// ErrDispatchFailed is returned when the underlying provider rejects the
// payload.
ErrDispatchFailed = errors.New("dispatching failed")
)
// APIError represents an error returned by the underlying email provider.
type APIError struct {
// Status is the HTTP status code returned by the provider.
Status int
// Body is the raw response body returned by the provider.
Body string
}
// Error implements the [error] interface.
func (e *APIError) Error() string {
return fmt.Sprintf("api returned status %d: %s", e.Status, e.Body)
}
// Unwrap allows [errors.Is] to match against [ErrDispatchFailed].
func (*APIError) Unwrap() error {
return ErrDispatchFailed
}
var _ error = (*APIError)(nil)
// Message represents a transactional email rendered from a template.
//
// There is no sender field: the from address and display name belong to the
// email channel the [Sender] dispatches through, configured once at the
// provider. The subject and reply-to header likewise come from the template
// project, so a message carries only what varies per send — who receives
// it, which template renders it, in which language, and with what data.
type Message struct {
// To lists the primary recipient addresses.
To []string
// CC lists the carbon-copy recipient addresses.
CC []string
// Template is the ID of the template project rendering this message.
Template string
// Version pins a published template version. Empty selects the latest
// published version.
Version string
// Language selects the template locale as a BCP 47 language tag (e.g.
// "en", "de-DE"). It must name a locale the template project publishes;
// empty falls back to the project's default locale.
Language string
// Variables populate the template's placeholders. The keys must match
// the variable names declared in the template.
Variables map[string]any
// Category classifies the message; see [notify.Category]. Empty leaves
// the message unclassified.
Category notify.Category
}
// NewMessage creates a new [Message] rendering the given template for the
// given primary recipients.
func NewMessage(template string, to ...string) *Message {
return &Message{
Template: template,
To: to,
}
}
// AddTo appends one or more primary recipient addresses.
func (m *Message) AddTo(addrs ...string) *Message {
m.To = append(m.To, addrs...)
return m
}
// AddCC appends one or more carbon-copy recipient addresses.
func (m *Message) AddCC(addrs ...string) *Message {
m.CC = append(m.CC, addrs...)
return m
}
// WithVersion pins the template version; see [Message.Version].
func (m *Message) WithVersion(version string) *Message {
m.Version = version
return m
}
// WithLanguage selects the template locale; see [Message.Language].
func (m *Message) WithLanguage(tag string) *Message {
m.Language = tag
return m
}
// WithCategory classifies the message; see [Message.Category].
func (m *Message) WithCategory(c notify.Category) *Message {
m.Category = c
return m
}
// AddParameter adds or replaces a single template variable.
func (m *Message) AddParameter(key string, value any) *Message {
if m.Variables == nil {
m.Variables = make(map[string]any)
}
m.Variables[key] = value
return m
}
// SetVariables replaces [Message.Variables] entirely.
func (m *Message) SetVariables(vars map[string]any) *Message {
m.Variables = vars
return m
}
// Validate checks if the [Message] has the minimum required fields for
// sending, and that a set category is a known one.
func (m *Message) Validate() error {
if m == nil {
return ErrNilMessage
}
if len(m.To) == 0 {
return ErrMissingRecipients
}
if m.Template == "" {
return ErrMissingTemplate
}
if m.Category != "" && !m.Category.Known() {
return notify.ErrUnknownCategory
}
return nil
}
// payload maps the message onto the provider's wire shape.
func (m *Message) payload() payload {
var p payload
for _, addr := range m.To {
p.Receiver.Contacts = append(p.Receiver.Contacts, contact{
IdentifierKey: "emailaddress",
IdentifierValue: addr,
Type: "to",
})
}
for _, addr := range m.CC {
p.Receiver.Contacts = append(p.Receiver.Contacts, contact{
IdentifierKey: "emailaddress",
IdentifierValue: addr,
Type: "cc",
})
}
p.Template = wireTemplate(m.Template, m.Version, m.Language, m.Variables)
p.Meta = wireMeta(m.Category)
return p
}
// Sender is the interface that wraps the Send method.
//
// Implementations of this interface are expected to be safe for concurrent
// use by multiple goroutines. They should respect the provided context for
// timeouts and cancellation.
type Sender interface {
// Send dispatches the provided [Message] payload to the underlying
// provider. It returns an error if the email is invalid, if the network
// request fails, or if the provider rejects the payload.
Send(ctx context.Context, msg *Message) error
}
// sender is a Bird email client that implements the [Sender] interface.
//
// It manages the HTTP client and authentication state required to interact
// with the Bird channels API. Once initialized via [NewSender], a [sender]
// is safe for concurrent use by multiple goroutines.
type sender struct {
// auth stores the Authorization header value for the provider.
auth string
// url is the resolved API endpoint for dispatching requests.
url string
// client holds the configured [http.Client].
client *http.Client
// logger is used for structured diagnostic output.
logger *log.Logger
}
var _ Sender = (*sender)(nil)
// NewSender creates a configured Bird client implementing the [Sender]
// interface.
//
// Messages dispatch through the email channel identified by the workspace
// and channel IDs; the channel is what carries the sender identity, so one
// [Sender] serves exactly one from address. It initializes the client with
// a default base URL and a discarding logger; diagnostics stay silent
// unless [WithLogger] injects a logger. These defaults can be overridden by
// passing one or more [Option] functions. Requests are dispatched through
// [transport.DefaultClient], which applies a sensible timeout, unless
// [WithClient] provides another one. It panics if the access key,
// workspace ID, or channel ID is empty, or if the base URL is invalid.
func NewSender(
accessKey, workspaceID, channelID string,
opts ...Option,
) Sender {
if accessKey == "" {
panic("access key is required")
}
if workspaceID == "" {
panic("workspace ID is required")
}
if channelID == "" {
panic("channel ID is required")
}
cfg := config{
baseURL: DefaultBaseURL,
logger: log.Discard(),
client: transport.DefaultClient,
}
for _, opt := range opts {
opt(&cfg)
}
endpoint, err := url.JoinPath(
cfg.baseURL,
"workspaces", workspaceID,
"channels", channelID,
"messages",
)
if err != nil {
panic(fmt.Errorf("invalid base URL: %w", err))
}
s := &sender{
auth: "AccessKey " + accessKey,
url: endpoint,
logger: cfg.logger,
client: cfg.client,
}
return s
}
// Send executes the HTTP request to the Bird channels API.
//
// It maps the domain [Message] payload into the provider's expected JSON
// structure and dispatches the request. It respects the provided
// [context.Context] for timeouts and cancellation. If the API responds with
// an HTTP status code >= 400, it returns an [*APIError].
func (s *sender) Send(ctx context.Context, msg *Message) error {
if err := msg.Validate(); err != nil {
return err
}
var buf bytes.Buffer
if err := json.MarshalWrite(&buf, msg.payload()); err != nil {
return fmt.Errorf("failed to encode payload: %w", err)
}
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
s.url,
&buf,
)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", s.auth)
req.Header.Set("Content-Type", "application/json")
s.logger.Debug(ctx, "Dispatching message to provider",
log.String("template", msg.Template),
log.Int("recipients", len(msg.To)+len(msg.CC)),
)
start := time.Now()
res, err := s.client.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
delta := time.Since(start)
defer func() {
if _, err := io.Copy(io.Discard, res.Body); err != nil {
s.logger.Warn(
ctx,
"Failed to drain response body",
log.Error(err),
)
}
err := res.Body.Close()
if err != nil {
s.logger.Warn(
ctx,
"Failed to close response body",
log.Error(err),
)
}
}()
if code := res.StatusCode; code >= 400 {
// The client caps response body size, so this read is bounded.
body, _ := io.ReadAll(res.Body)
return &APIError{
Status: code,
Body: string(body),
}
}
s.logger.Debug(
ctx,
"Message dispatched",
log.Duration("duration", delta),
)
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package mail
import (
"net/http"
"github.com/deep-rent/nexus/sys/log"
)
const (
// DefaultBaseURL is the standard endpoint of the Bird API.
DefaultBaseURL = "https://api.bird.com"
)
// config holds the optional configuration for the [sender].
type config struct {
// baseURL overrides the default Bird API endpoint.
baseURL string
// logger specifies the custom structured [log.Logger].
logger *log.Logger
// client is the HTTP client used for outbound API requests.
client *http.Client
}
// Option defines the functional option pattern for configuring the [sender].
type Option func(*config)
// WithClient sets the [http.Client] used for outbound API requests. Defaults
// to [transport.DefaultClient]. Nil values are ignored.
func WithClient(client *http.Client) Option {
return func(c *config) {
if client != nil {
c.client = client
}
}
}
// WithBaseURL allows overriding the Bird API base URL for testing or
// mocking. Empty string values are ignored, since an empty base URL would
// leave the sender pointed at a relative path that fails at send time.
func WithBaseURL(url string) Option {
return func(c *config) {
if url != "" {
c.baseURL = url
}
}
}
// WithLogger injects a structured [log.Logger] into the sender. If not
// provided, the sender stays silent ([log.Discard]). Nil values will be
// ignored.
func WithLogger(logger *log.Logger) Option {
return func(c *config) {
if logger != nil {
c.logger = logger
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package mail
import (
"maps"
"slices"
"github.com/deep-rent/nexus/net/notify"
)
// The types below mirror the JSON shape of the Bird channels API. They are
// deliberately separate from [Message]: the domain type carries what a
// caller decides, and this file is the one place that knows how the
// provider spells it.
// contact is one entry of the receiver's contact list.
type contact struct {
IdentifierKey string `json:"identifierKey"`
IdentifierValue string `json:"identifierValue"`
Type string `json:"type,omitzero"`
}
// parameter is one typed template variable.
type parameter struct {
Type string `json:"type"`
Key string `json:"key"`
Value any `json:"value"`
}
// template references the template project a message is rendered from.
type template struct {
ProjectID string `json:"projectId"`
Version string `json:"version"`
Locale string `json:"locale,omitzero"`
Parameters []parameter `json:"parameters,omitzero"`
}
// meta carries the message classification.
type meta struct {
ExtraInformation struct {
UseCase string `json:"useCase"`
} `json:"extraInformation"`
}
// payload is the request body of a channel message.
type payload struct {
Receiver struct {
Contacts []contact `json:"contacts"`
} `json:"receiver"`
Template template `json:"template"`
Meta *meta `json:"meta,omitzero"`
}
// wireTemplate builds the template reference. An empty version selects the
// latest published one, and the variables become typed parameters in
// deterministic key order, so payloads are stable across sends.
func wireTemplate(
projectID, version, locale string,
vars map[string]any,
) template {
if version == "" {
version = "latest"
}
t := template{
ProjectID: projectID,
Version: version,
Locale: locale,
}
for _, key := range slices.Sorted(maps.Keys(vars)) {
value := vars[key]
t.Parameters = append(t.Parameters, parameter{
Type: parameterType(value),
Key: key,
Value: value,
})
}
return t
}
// wireMeta builds the classification block, or nil when the message is
// unclassified.
func wireMeta(c notify.Category) *meta {
if c == "" {
return nil
}
m := &meta{}
m.ExtraInformation.UseCase = string(c)
return m
}
// parameterType names the provider-side type of a template variable.
// Anything that is not a plain string, boolean, or number travels as an
// object.
func parameterType(value any) string {
switch value.(type) {
case string:
return "string"
case bool:
return "boolean"
case int, int8, int16, int32, int64,
uint, uint8, uint16, uint32, uint64,
float32, float64:
return "number"
default:
return "object"
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package apns
import (
"bytes"
"context"
"encoding/json/v2"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
"github.com/deep-rent/nexus/net/notify/push"
"github.com/deep-rent/nexus/net/transport"
"github.com/deep-rent/nexus/sec/jose/jwa"
"github.com/deep-rent/nexus/sec/jose/jwk"
"github.com/deep-rent/nexus/sec/jose/jwt"
"github.com/deep-rent/nexus/sec/sign"
"github.com/deep-rent/nexus/sec/token"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/sys/log"
)
// Sender implements the [push.Sender] interface for the Apple Push Notification
// service (APNs). It handles authentication, payload construction, and
// dispatching of push notifications to APNs endpoints.
type Sender struct {
source *token.Source
url string
topic string
client *http.Client
logger *log.Logger
now clock.Clock
}
var _ push.Sender = (*Sender)(nil)
// Credentials contains the necessary credentials for authenticating with APNs.
type Credentials struct {
// KeyID specifies the ES256 key ID from your Apple Developer account.
KeyID string
// TeamID is your Apple team ID.
TeamID string
// PrivateKey stores the PEM-encoded PKCS#8 private key contents.
PrivateKey []byte
}
// New creates a configured Apple Push Notification Service client
// implementing the [push.Sender] interface from the given [Credentials].
// Requests are dispatched through [transport.DefaultClient] unless
// [WithClient] provides another one; note that any such client must support
// HTTP/2.
func New(
cred Credentials,
opts ...Option,
) push.Sender {
signer, err := sign.Decode(cred.PrivateKey)
if err != nil {
panic(fmt.Errorf("failed to parse APNs private key: %w", err))
}
key := jwk.NewKeyPair(jwa.ES256, cred.KeyID, signer)
cfg := config{
baseURL: DefaultBaseURL,
logger: log.Discard(),
now: clock.System,
client: transport.DefaultClient,
}
for _, opt := range opts {
opt(&cfg)
}
fetch := func(ctx context.Context) (string, time.Time, error) {
claims := struct {
jwt.Reserved
}{
Iss: cred.TeamID,
Iat: cfg.now(),
}
tok, err := jwt.Sign(ctx, key, claims)
if err != nil {
return "", time.Time{}, err
}
// Apple allows tokens to be used between 20 and 60 minutes so we
// settle in the middle.
return string(tok), cfg.now().Add(45 * time.Minute), nil
}
source := token.NewSource(
fetch,
token.WithBufferTime(5*time.Minute),
token.WithClock(cfg.now),
)
s := &Sender{
source: source,
url: cfg.baseURL,
topic: cfg.topic,
logger: cfg.logger,
client: cfg.client,
now: cfg.now,
}
return s
}
// Send dispatches the HTTP/2 request to the APNs API.
func (s *Sender) Send(ctx context.Context, msg *push.Message) error {
if err := msg.Validate(); err != nil {
return err
}
if msg.Target.Token == "" {
return errors.New("APNs requires a device token target")
}
tok, err := s.source.Get(ctx)
if err != nil {
return fmt.Errorf("failed to get APNs token: %w", err)
}
payload := make(map[string]any, len(msg.Data)+1)
for k, v := range msg.Data {
payload[k] = v
}
aps := make(map[string]any)
if msg.Silent {
aps["content-available"] = 1
} else {
aps["alert"] = map[string]any{
"title": msg.Title,
"body": msg.Body,
}
}
payload["aps"] = aps
var buf bytes.Buffer
if err := json.MarshalWrite(&buf, payload); err != nil {
return fmt.Errorf("failed to encode APNs payload: %w", err)
}
endpoint, err := url.JoinPath(s.url, "3/device", msg.Target.Token)
if err != nil {
return fmt.Errorf("invalid endpoint: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, &buf)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "bearer "+tok)
req.Header.Set("Content-Type", "application/json")
// A per-message topic overrides the sender's configured default.
topic := s.topic
if msg.Target.Topic != "" {
topic = msg.Target.Topic
}
if topic != "" {
req.Header.Set("apns-topic", topic)
}
if msg.Silent {
req.Header.Set("apns-push-type", "background")
req.Header.Set("apns-priority", "5")
} else {
req.Header.Set("apns-push-type", "alert")
if msg.Priority == push.PriorityNormal {
req.Header.Set("apns-priority", "5")
} else {
req.Header.Set("apns-priority", "10") // Default for alert
}
}
if msg.CollapseID != "" {
req.Header.Set("apns-collapse-id", msg.CollapseID)
}
if msg.TTL > 0 {
exp := s.now().Add(msg.TTL).Unix()
req.Header.Set("apns-expiration", strconv.FormatInt(exp, 10))
} else {
req.Header.Set("apns-expiration", "0")
}
s.logger.Debug(
ctx,
"Dispatching APNs message",
log.String("token", msg.Target.Token),
)
return push.Deliver(ctx, s.client, req, s.logger)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package apns
import (
"net/http"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/sys/log"
)
const (
// DefaultBaseURL is the production endpoint for APNs.
DefaultBaseURL = "https://api.push.apple.com"
// SandboxBaseURL is the sandbox endpoint for APNs.
SandboxBaseURL = "https://api.sandbox.push.apple.com"
)
// config holds the optional configuration for the [Sender].
type config struct {
// baseURL overrides the default APNs API endpoint.
baseURL string
// topic overrides the default "apns-topic" header.
topic string
// logger specifies the custom structured [log.Logger].
logger *log.Logger
// now overrides the clock used for JWT generation and caching.
now clock.Clock
// client is the HTTP client used for outbound API requests.
client *http.Client
}
// Option defines the functional option pattern for configuring [Sender].
type Option func(*config)
// WithClient sets the [http.Client] used for outbound API requests. Defaults
// to [transport.DefaultClient]. Nil values are ignored.
func WithClient(client *http.Client) Option {
return func(cfg *config) {
if client != nil {
cfg.client = client
}
}
}
// WithBaseURL allows overriding the APNs API base URL.
// Useful for switching to [SandboxBaseURL] or mocking.
// Empty string values are ignored.
func WithBaseURL(url string) Option {
return func(cfg *config) {
if url != "" {
cfg.baseURL = url
}
}
}
// WithTopic sets the default apns-topic header, which is the app's bundle
// identifier (optionally with a type suffix such as ".voip"). APNs requires it
// for most push types, so it is normally configured once here rather than per
// message. A message may still override it via [push.Target.Topic]. Empty
// string values are ignored.
func WithTopic(topic string) Option {
return func(cfg *config) {
if topic != "" {
cfg.topic = topic
}
}
}
// WithLogger injects a structured [log.Logger] into the sender. If not
// provided, the sender stays silent ([log.Discard]). Nil values are
// ignored.
func WithLogger(logger *log.Logger) Option {
return func(cfg *config) {
if logger != nil {
cfg.logger = logger
}
}
}
// WithClock injects a custom clock function for JWT generation and caching.
// Nil values are ignored.
func WithClock(now clock.Clock) Option {
return func(cfg *config) {
if now != nil {
cfg.now = now
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package fcm
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/rsa"
"encoding/json/v2"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
"github.com/deep-rent/nexus/net/notify/push"
"github.com/deep-rent/nexus/net/transport"
"github.com/deep-rent/nexus/sec/jose/jwa"
"github.com/deep-rent/nexus/sec/jose/jwk"
"github.com/deep-rent/nexus/sec/sign"
"github.com/deep-rent/nexus/sec/token"
"github.com/deep-rent/nexus/sec/token/oauth"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/sys/log"
)
// Sender implements the [push.Sender] interface for Firebase Cloud Messaging
// (FCM). It handles authentication, payload construction, and dispatching of
// push notifications to FCM endpoints.
type Sender struct {
projectID string
source *token.Source
url string
client *http.Client
logger *log.Logger
now clock.Clock
}
var _ push.Sender = (*Sender)(nil)
// Credentials holds the necessary credentials for authenticating with FCM.
// It mirrors the JSON structure of a Google service account file, so the file
// can be unmarshaled into it directly:
//
// var cred fcm.Credentials
// if err := json.Unmarshal(serviceAccount, &cred); err != nil { ... }
type Credentials struct {
// ProjectID specifies your Google Cloud project ID.
ProjectID string `json:"project_id"`
// ClientEmail is the email address of the service account.
ClientEmail string `json:"client_email"`
// PrivateKey is the PEM-encoded PKCS#8 private key. It is a string rather
// than a byte slice because a service account file stores it as a JSON
// string; a byte slice would be decoded as base64 and fail. Only RSA and
// EC P-256 key types are supported.
PrivateKey string `json:"private_key"`
}
// New creates a configured Firebase Cloud Messaging client implementing the
// [push.Sender] interface from the given [Credentials], the raw contents of a
// Google Service Account JSON key file. Only RSA and EC P-256 private keys
// are supported. Requests are dispatched through [transport.DefaultClient]
// unless [WithClient] provides another one.
func New(
cred Credentials,
opts ...Option,
) push.Sender {
signer, err := sign.Decode([]byte(cred.PrivateKey))
if err != nil {
panic(fmt.Errorf("failed to parse FCM private key: %w", err))
}
var key jwk.KeyPair
switch signer.Public().(type) {
case *rsa.PublicKey:
key = jwk.NewKeyPair(jwa.RS256, "", signer)
case *ecdsa.PublicKey:
key = jwk.NewKeyPair(jwa.ES256, "", signer)
default:
panic("unsupported private key type for FCM")
}
cfg := config{
baseURL: DefaultBaseURL,
authURL: DefaultAuthURL,
logger: log.Discard(),
now: clock.System,
client: transport.DefaultClient,
}
for _, opt := range opts {
opt(&cfg)
}
s := &Sender{
projectID: cred.ProjectID,
url: cfg.baseURL,
logger: cfg.logger,
client: cfg.client,
now: cfg.now,
}
s.source = oauth.ServiceAccount(oauth.Account{
Endpoint: cfg.authURL,
Issuer: cred.ClientEmail,
Scope: DefaultScope,
Key: key,
Client: cfg.client,
Clock: cfg.now,
}, token.WithBufferTime(60*time.Second))
return s
}
// Send dispatches the HTTP request to the FCM v1 API.
func (s *Sender) Send(ctx context.Context, msg *push.Message) error {
if err := msg.Validate(); err != nil {
return err
}
tok, err := s.source.Get(ctx)
if err != nil {
return fmt.Errorf("failed to obtain oauth token: %w", err)
}
out := map[string]any{}
if msg.Target.Token != "" {
out["token"] = msg.Target.Token
} else if msg.Target.Topic != "" {
out["topic"] = msg.Target.Topic
}
if !msg.Silent {
out["notification"] = map[string]any{
"title": msg.Title,
"body": msg.Body,
}
}
if len(msg.Data) > 0 {
// The v1 API types this as map<string,string>, which is exactly
// what [push.Message.Data] is, so it passes straight through.
out["data"] = msg.Data
}
android := map[string]any{}
headers := map[string]string{}
// FCM v1 lets a single payload carry both Android-specific fields and an
// "apns" override block, so a message routed to an iOS device through FCM
// (rather than the standalone apns package) still gets matching priority
// semantics.
if msg.Silent || msg.Priority == push.PriorityNormal {
android["priority"] = "NORMAL"
headers["apns-priority"] = "5"
} else if msg.Priority == push.PriorityHigh {
android["priority"] = "HIGH"
headers["apns-priority"] = "10"
}
if msg.CollapseID != "" {
android["collapse_key"] = msg.CollapseID
headers["apns-collapse-id"] = msg.CollapseID
}
if msg.TTL > 0 {
android["ttl"] = strconv.Itoa(int(msg.TTL.Seconds())) + "s"
exp := s.now().Add(msg.TTL).Unix()
headers["apns-expiration"] = strconv.FormatInt(exp, 10)
}
if len(android) > 0 {
out["android"] = android
}
if len(headers) > 0 {
out["apns"] = map[string]any{
"headers": headers,
}
}
payload := map[string]any{
"message": out,
}
var buf bytes.Buffer
if err := json.MarshalWrite(&buf, payload); err != nil {
return fmt.Errorf("failed to encode FCM payload: %w", err)
}
endpoint, err := url.JoinPath(
s.url,
"projects",
s.projectID,
"messages:send",
)
if err != nil {
return fmt.Errorf("invalid endpoint: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, &buf)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Content-Type", "application/json")
// The target is deliberately not rendered: %v on it spells the
// registration token out in full, and "target" is not a key the
// service's log redactor knows to mask. A push token is a
// credential; one debug session should not ship every user's.
s.logger.Debug(ctx, "Dispatching FCM message",
log.String("project", s.projectID),
log.String("topic", msg.Target.Topic),
)
return push.Deliver(ctx, s.client, req, s.logger)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package fcm
import (
"net/http"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/sys/log"
)
const (
// DefaultScope is the default scope for FCM v1 API.
DefaultScope = "https://www.googleapis.com/auth/firebase.messaging"
// DefaultBaseURL is the default base URL for FCM v1 API.
DefaultBaseURL = "https://fcm.googleapis.com/v1"
// DefaultAuthURL is the default authentication URL for FCM v1 API.
DefaultAuthURL = "https://oauth2.googleapis.com/token"
)
// config holds the optional configuration for the [Sender].
type config struct {
// baseURL overrides the default FCM v1 API endpoint.
baseURL string
// authURL overrides the default Google OAuth 2.0 token endpoint.
authURL string
// logger specifies the custom structured [log.Logger].
logger *log.Logger
// now overrides the clock used for JWT generation and caching.
now clock.Clock
// client is the HTTP client used for outbound API requests.
client *http.Client
}
// Option defines the functional option pattern for configuring [Sender].
type Option func(*config)
// WithClient sets the [http.Client] used for outbound API requests. Defaults
// to [transport.DefaultClient]. Nil values are ignored.
func WithClient(client *http.Client) Option {
return func(cfg *config) {
if client != nil {
cfg.client = client
}
}
}
// WithBaseURL allows overriding the FCM API base URL.
// Useful for mocking. Empty string values are ignored.
func WithBaseURL(url string) Option {
return func(cfg *config) {
if url != "" {
cfg.baseURL = url
}
}
}
// WithAuthURL allows overriding the Google OAuth 2.0 token endpoint.
// Useful for mocking. Empty string values are ignored.
func WithAuthURL(url string) Option {
return func(cfg *config) {
if url != "" {
cfg.authURL = url
}
}
}
// WithLogger injects a structured [log.Logger] into the sender. If not
// provided, the sender stays silent ([log.Discard]). Nil values are
// ignored.
func WithLogger(logger *log.Logger) Option {
return func(cfg *config) {
if logger != nil {
cfg.logger = logger
}
}
}
// WithClock injects a custom clock function for JWT generation and caching.
// Nil values are ignored.
func WithClock(now clock.Clock) Option {
return func(cfg *config) {
if now != nil {
cfg.now = now
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package push
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"golang.org/x/sync/errgroup"
"github.com/deep-rent/nexus/sys/log"
)
var (
// ErrNilMessage is returned when a nil [Message] is validated.
ErrNilMessage = errors.New("message cannot be nil")
// ErrMissingTarget is returned when a push notification has no destination.
ErrMissingTarget = errors.New("a target (token or topic) is needed")
// ErrDispatchFailed is returned when the underlying provider rejects the
// payload.
ErrDispatchFailed = errors.New("dispatching failed")
)
// APIError represents an error returned by the underlying push provider.
type APIError struct {
// Status is the HTTP status code returned by the provider.
Status int
// Body is the raw response body returned by the provider.
Body string
}
// Error implements the [error] interface.
func (e *APIError) Error() string {
return fmt.Sprintf("api returned status %d: %s", e.Status, e.Body)
}
// Unwrap allows [errors.Is] to match against [ErrDispatchFailed].
func (*APIError) Unwrap() error {
return ErrDispatchFailed
}
var _ error = (*APIError)(nil)
// Target identifies the destination of the push notification.
//
// The two providers interpret it differently, since their delivery models
// differ:
//
// - FCM delivers either to a device Token or to a publish-subscribe Topic,
// so exactly one of the two should be set.
// - APNs delivers only to a device Token; it has no publish-subscribe
// topics. There, Topic instead overrides the "apns-topic" header (the
// app's bundle identifier, possibly with a type suffix) for that one
// message, and is normally left empty in favor of the sender's configured
// topic.
type Target struct {
// Token is a specific device identifier.
Token string
// Topic is a publish-subscribe channel (FCM) or an apns-topic override
// (APNs); see the type documentation.
Topic string
}
// Priority indicates the delivery priority of a message.
type Priority string
const (
// PriorityNormal indicates the message is delivered when the device is
// awake.
PriorityNormal Priority = "normal"
// PriorityHigh indicates the message should be delivered immediately.
PriorityHigh Priority = "high"
)
// Message represents a generic push notification payload.
type Message struct {
// Title is the short heading of the notification.
Title string
// Body is the main text content of the notification.
Body string
// Data contains optional custom key-value pairs delivered to the
// app, alongside the notification or instead of it.
//
// The values are strings because that is what BOTH providers accept:
// FCM's v1 API types a data payload as map<string,string> and
// refuses anything else, while APNs merges the pairs into a JSON
// payload where strings are perfectly ordinary. A map[string]any
// would compile everywhere and fail at one provider, which is the
// worst place for the constraint to surface — a caller sending a
// number would meet it as a 400 in production rather than as a
// compile error.
//
// A caller with something structured to send encodes it: the app
// decodes on the other side, and the payload cap is a provider limit
// rather than a type-system one.
Data map[string]string
// Target is the destination of the message.
Target Target
// Priority is the delivery urgency of the message.
Priority Priority
// CollapseID is an identifier used to replace existing notifications.
CollapseID string
// TTL is the time-to-live for the message.
TTL time.Duration
// Silent indicates whether the message is a background push.
Silent bool
}
// NewMessage creates a new [Message] with the required fields.
func NewMessage(title, body string, target Target) *Message {
return &Message{
Title: title,
Body: body,
Target: target,
}
}
// WithData adds custom data to the [Message]; see [Message.Data] for
// why the values are strings.
func (m *Message) WithData(data map[string]string) *Message {
m.Data = data
return m
}
// WithPriority sets the delivery priority.
func (m *Message) WithPriority(p Priority) *Message {
m.Priority = p
return m
}
// WithCollapseID sets the collapse identifier.
func (m *Message) WithCollapseID(id string) *Message {
m.CollapseID = id
return m
}
// WithTTL sets the message expiration duration.
func (m *Message) WithTTL(ttl time.Duration) *Message {
m.TTL = ttl
return m
}
// AsSilent marks the message as a background push.
func (m *Message) AsSilent() *Message {
m.Silent = true
return m
}
// Validate checks if the [Message] has the minimum required fields.
func (m *Message) Validate() error {
if m == nil {
return ErrNilMessage
}
if m.Target.Token == "" && m.Target.Topic == "" {
return ErrMissingTarget
}
return nil
}
// Sender represents a push notification provider.
//
// Implementations of this interface are expected to be safe for concurrent
// use by multiple goroutines. They should respect the provided context for
// timeouts and cancellation.
type Sender interface {
// Send dispatches the provided [Message] payload to the underlying
// provider.
Send(ctx context.Context, msg *Message) error
}
// BatchSend concurrently dispatches multiple messages using the provided
// [Sender], with at most the given number of workers in flight at once.
//
// It is best-effort: a failure delivering one message does not abort the
// others, since a single dead token should not hold back an entire broadcast.
// Every message is attempted (unless the context is already cancelled by the
// time its turn comes), and the individual errors are collected and returned
// as a single joined error, nil if every send succeeded. Use [errors.Is] to
// probe it. To learn which specific messages failed, dispatch them
// individually or wrap [Sender.Send].
//
// Cancelling the given context stops further sends from starting and is
// observed by those already in flight, but does not itself count as a batch
// failure beyond the context errors recorded for the messages it prevented.
func BatchSend(
ctx context.Context,
sender Sender,
msgs []*Message,
workers int,
) error {
n := len(msgs)
if n == 0 {
return nil
}
workers = max(1, min(workers, n))
errs := make([]error, n)
var eg errgroup.Group
eg.SetLimit(workers)
for i, msg := range msgs {
eg.Go(func() error {
// A cancelled context skips the remaining sends without
// attempting them, rather than aborting the batch outright.
if err := ctx.Err(); err != nil {
errs[i] = err
return nil //nolint:nilerr // collected per message above.
}
errs[i] = sender.Send(ctx, msg)
return nil
})
}
_ = eg.Wait()
return errors.Join(errs...)
}
// Deliver executes the given request against the given client and interprets
// the response for a [Sender], returning nil on a success status or an
// [*APIError] carrying the status and body on a failure status (400 or
// above).
//
// It is the shared response-handling path for the built-in providers, exported
// so that a custom [Sender] can report failures with the same error shape. The
// response body is always drained and closed so the underlying connection can
// be reused.
func Deliver(
ctx context.Context,
client *http.Client,
req *http.Request,
logger *log.Logger,
) error {
start := time.Now()
// The request is built by the caller against its provider's own
// endpoint constant; no part of it comes from a device.
res, err := client.Do(req) // #nosec G704
if err != nil {
return fmt.Errorf("request failed: %w", scrub(err))
}
defer func() {
if _, err := io.Copy(io.Discard, res.Body); err != nil {
logger.Warn(
ctx,
"Failed to drain response body",
log.Error(err),
)
}
if err := res.Body.Close(); err != nil {
logger.Warn(
ctx,
"Failed to close response body",
log.Error(err),
)
}
}()
logger.Debug(ctx, "Provider responded",
log.Int("status", res.StatusCode),
log.Duration("duration", time.Since(start)),
)
if res.StatusCode >= http.StatusBadRequest {
// The client caps response body size, so this read is bounded.
body, err := io.ReadAll(res.Body)
if err != nil {
logger.Warn(
ctx,
"Failed to read response body",
log.Error(err),
)
}
return &APIError{Status: res.StatusCode, Body: string(body)}
}
return nil
}
// Markers naming a token the provider will never deliver to again. Both
// providers report the condition, and neither reports it the same way:
// APNs answers 410 with {"reason":"Unregistered"} and 400 with
// BadDeviceToken, while FCM answers 404 with UNREGISTERED. The strings
// are matched inside the body rather than parsed out of it, since the
// two providers disagree about the shape around them and agree about
// these words.
const (
// reasonUnregistered is APNs for a token whose app is gone (410),
// and FCM's spelling of the same condition (404).
reasonUnregistered = "Unregistered"
// reasonUnregisteredFCM is FCM's error code for it.
reasonUnregisteredFCM = "UNREGISTERED"
// reasonBadDeviceToken is APNs for a token that never named an
// installation of this app (400).
reasonBadDeviceToken = "BadDeviceToken"
// reasonSenderMismatch is FCM for a token belonging to a different
// sender (403). It is as permanent as the others: no retry from
// this project will ever deliver to it.
reasonSenderMismatch = "SENDER_ID_MISMATCH"
)
// scrub strips the request URL out of a transport failure.
//
// It exists because APNs addresses a device by putting its token in the
// PATH — "3/device/<token>" — and [url.Error] renders the whole URL in
// its message. The standard library masks only userinfo passwords, so
// without this the token travels inside every dial timeout, reset
// connection, and TLS failure a caller then logs, stores on a queue job,
// or returns from an API. A push token is the entire credential for
// reaching somebody's phone; it must not ride along in an error string.
//
// The wrapped cause is kept rather than replaced, so [errors.Is] against
// [context.DeadlineExceeded] and friends still answers, and the
// operation is named in the message the caller composes around it.
func scrub(err error) error {
var uerr *url.Error
if errors.As(err, &uerr) && uerr.Err != nil {
return uerr.Err
}
return err
}
// Gone reports whether the provider refused the message because the
// token no longer names an installed app. It is the signal to retire
// the device rather than retry the delivery.
//
// A provider having a bad afternoon is not gone: a 429, a 5xx, or a
// transport failure all read as false, so the caller retries them on
// its own schedule. Only the four verdicts both providers spell out as
// permanent — an unregistered token, a token that never was one, and a
// token minted for somebody else's project — read as true.
//
// It matches any [*APIError] in the chain, so an error wrapped with
// context on its way up still classifies.
func Gone(err error) bool {
var api *APIError
if !errors.As(err, &api) {
return false
}
switch api.Status {
case http.StatusGone:
// APNs alone answers 410, and only for a token whose app is
// gone. There is no other resource it could mean.
return true
case http.StatusNotFound:
// FCM answers 404 for an unregistered token AND for a project
// it cannot find, and the token is in the BODY of an FCM
// request, not its path — so a stale or mismatched project ID
// looks identical to a dead phone. Retiring on the status
// alone would delete every Android device in the registry the
// first time a service account was mounted from the wrong
// environment, quietly, because retirement settles the job
// rather than failing it.
//
// APNs uses 404 for a bad path, never for a token. So the
// status is never enough on its own: the body has to name the
// token as the thing that is missing.
return strings.Contains(api.Body, reasonUnregisteredFCM) ||
strings.Contains(api.Body, reasonUnregistered)
case http.StatusBadRequest:
return strings.Contains(api.Body, reasonBadDeviceToken) ||
strings.Contains(api.Body, reasonUnregistered) ||
strings.Contains(api.Body, reasonUnregisteredFCM)
case http.StatusForbidden:
// Narrower than the rest on purpose: a 403 is also how a
// provider refuses THIS SENDER's credentials, which is an
// operator's problem and must not retire anybody's device.
return strings.Contains(api.Body, reasonSenderMismatch)
}
return false
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package text
import (
"net/http"
"github.com/deep-rent/nexus/sys/log"
)
const (
// DefaultBaseURL is the standard endpoint of the Bird API.
DefaultBaseURL = "https://api.bird.com"
)
// config holds the optional configuration for the [sender].
type config struct {
// baseURL overrides the default Bird API endpoint.
baseURL string
// logger specifies the custom structured [log.Logger].
logger *log.Logger
// client is the HTTP client used for outbound API requests.
client *http.Client
}
// Option defines the functional option pattern for configuring the [sender].
type Option func(*config)
// WithClient sets the [http.Client] used for outbound API requests. Defaults
// to [transport.DefaultClient]. Nil values are ignored.
func WithClient(client *http.Client) Option {
return func(c *config) {
if client != nil {
c.client = client
}
}
}
// WithBaseURL allows overriding the Bird API base URL for testing or
// mocking. Empty string values are ignored, since an empty base URL would
// leave the sender pointed at a relative path that fails at send time.
func WithBaseURL(url string) Option {
return func(c *config) {
if url != "" {
c.baseURL = url
}
}
}
// WithLogger injects a structured [log.Logger] into the sender. If not
// provided, the sender stays silent ([log.Discard]). Nil values are
// ignored.
func WithLogger(logger *log.Logger) Option {
return func(c *config) {
if logger != nil {
c.logger = logger
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package text
import (
"bytes"
"context"
"encoding/json/v2"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"time"
"github.com/deep-rent/nexus/net/notify"
"github.com/deep-rent/nexus/net/transport"
"github.com/deep-rent/nexus/sys/log"
)
var (
// ErrNilMessage is returned when a nil [Message] is validated.
ErrNilMessage = errors.New("message cannot be nil")
// ErrMissingTo is returned when a text has no destination number.
ErrMissingTo = errors.New("to number is needed")
// ErrMissingTemplate is returned when a text names no template.
ErrMissingTemplate = errors.New("template is needed")
// ErrDispatchFailed is returned when the underlying provider rejects the
// payload.
ErrDispatchFailed = errors.New("dispatching failed")
)
// APIError represents an error returned by the underlying text provider.
type APIError struct {
// Status is the HTTP status code returned by the provider.
Status int
// Body is the raw response body returned by the provider.
Body string
}
// Error implements the [error] interface.
func (e *APIError) Error() string {
return fmt.Sprintf("api returned status %d: %s", e.Status, e.Body)
}
// Unwrap allows [errors.Is] to match against [ErrDispatchFailed].
func (*APIError) Unwrap() error {
return ErrDispatchFailed
}
var _ error = (*APIError)(nil)
// Message represents a transactional text message rendered from a template.
//
// There is no sender field and no body: the sending number belongs to the
// channel the [Sender] dispatches through — an installed SMS number or
// WhatsApp identity, configured once at the provider — and the copy comes
// from the template project, so a message carries only what varies per
// send. Which network the message travels over is likewise the channel's:
// the same payload reaches an SMS channel and a WhatsApp channel alike.
type Message struct {
// To is the destination phone number in E.164 format.
To string
// Template is the ID of the template project rendering this message.
Template string
// Version pins a published template version. Empty selects the latest
// published version.
Version string
// Language selects the template locale as a BCP 47 language tag (e.g.
// "en", "de-DE"). It must name a locale the template project publishes;
// empty falls back to the project's default locale.
Language string
// Variables populate the template's placeholders. The keys must match
// the variable names declared in the template.
Variables map[string]any
// Category classifies the message; see [notify.Category]. Empty leaves
// the message unclassified.
Category notify.Category
}
// NewMessage creates a new [Message] rendering the given template for the
// given destination number.
func NewMessage(template, to string) *Message {
return &Message{
Template: template,
To: to,
}
}
// WithVersion pins the template version; see [Message.Version].
func (m *Message) WithVersion(version string) *Message {
m.Version = version
return m
}
// WithLanguage selects the template locale; see [Message.Language].
func (m *Message) WithLanguage(tag string) *Message {
m.Language = tag
return m
}
// WithCategory classifies the message; see [Message.Category].
func (m *Message) WithCategory(c notify.Category) *Message {
m.Category = c
return m
}
// AddParameter adds or replaces a single template variable.
func (m *Message) AddParameter(key string, value any) *Message {
if m.Variables == nil {
m.Variables = make(map[string]any)
}
m.Variables[key] = value
return m
}
// SetVariables replaces [Message.Variables] entirely.
func (m *Message) SetVariables(vars map[string]any) *Message {
m.Variables = vars
return m
}
// Validate checks if the [Message] has the minimum required fields for
// sending, and that a set category is a known one.
func (m *Message) Validate() error {
if m == nil {
return ErrNilMessage
}
if m.To == "" {
return ErrMissingTo
}
if m.Template == "" {
return ErrMissingTemplate
}
if m.Category != "" && !m.Category.Known() {
return notify.ErrUnknownCategory
}
return nil
}
// payload maps the message onto the provider's wire shape.
func (m *Message) payload() payload {
var p payload
p.Receiver.Contacts = []contact{{
IdentifierKey: "phonenumber",
IdentifierValue: m.To,
}}
p.Template = wireTemplate(m.Template, m.Version, m.Language, m.Variables)
p.Meta = wireMeta(m.Category)
return p
}
// Sender is the interface that wraps the Send method.
//
// Implementations of this interface are expected to be safe for concurrent
// use by multiple goroutines. They should respect the provided context for
// timeouts and cancellation.
type Sender interface {
// Send dispatches the provided [Message] payload to the underlying
// provider. It returns an error if the message is invalid, if the
// network request fails, or if the provider rejects the payload.
Send(ctx context.Context, msg *Message) error
}
// sender is a Bird text client that implements the [Sender] interface.
type sender struct {
// auth stores the Authorization header value for the provider.
auth string
// url is the resolved API endpoint for dispatching requests.
url string
// client holds the configured [http.Client].
client *http.Client
// logger is used for structured diagnostic output.
logger *log.Logger
}
var _ Sender = (*sender)(nil)
// NewSender creates a configured Bird client implementing the [Sender]
// interface.
//
// Messages dispatch through the channel identified by the workspace and
// channel IDs; the channel is what carries the sending number and selects
// the network, so one [Sender] serves exactly one origin — build one per
// channel to serve SMS and WhatsApp side by side. Requests are dispatched
// through [transport.DefaultClient] unless [WithClient] provides another
// one. It panics if the access key, workspace ID, or channel ID is empty,
// or if the base URL is invalid.
func NewSender(
accessKey, workspaceID, channelID string,
opts ...Option,
) Sender {
if accessKey == "" {
panic("access key is required")
}
if workspaceID == "" {
panic("workspace ID is required")
}
if channelID == "" {
panic("channel ID is required")
}
cfg := config{
baseURL: DefaultBaseURL,
logger: log.Discard(),
client: transport.DefaultClient,
}
for _, opt := range opts {
opt(&cfg)
}
endpoint, err := url.JoinPath(
cfg.baseURL,
"workspaces", workspaceID,
"channels", channelID,
"messages",
)
if err != nil {
panic(fmt.Errorf("invalid base URL: %w", err))
}
s := &sender{
auth: "AccessKey " + accessKey,
url: endpoint,
logger: cfg.logger,
client: cfg.client,
}
return s
}
// Send executes the HTTP request to the Bird channels API.
// It returns an [*APIError] when the API responds with an error status.
func (s *sender) Send(ctx context.Context, msg *Message) error {
if err := msg.Validate(); err != nil {
return err
}
var buf bytes.Buffer
if err := json.MarshalWrite(&buf, msg.payload()); err != nil {
return fmt.Errorf("failed to encode payload: %w", err)
}
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
s.url,
&buf,
)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", s.auth)
req.Header.Set("Content-Type", "application/json")
s.logger.Debug(
ctx,
"Dispatching text to provider",
log.String("template", msg.Template),
log.String("to", msg.To),
)
start := time.Now()
res, err := s.client.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
delta := time.Since(start)
defer func() {
if _, err := io.Copy(io.Discard, res.Body); err != nil {
s.logger.Warn(
ctx,
"Failed to drain response body",
log.Error(err),
)
}
if err := res.Body.Close(); err != nil {
s.logger.Warn(
ctx,
"Failed to close response body",
log.Error(err),
)
}
}()
if code := res.StatusCode; code >= http.StatusBadRequest {
// The client caps response body size, so this read is bounded.
body, _ := io.ReadAll(res.Body)
return &APIError{
Status: code,
Body: string(body),
}
}
s.logger.Debug(
ctx,
"Text dispatched",
log.Duration("duration", delta),
)
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package text
import (
"maps"
"slices"
"github.com/deep-rent/nexus/net/notify"
)
// The types below mirror the JSON shape of the Bird channels API. They are
// deliberately separate from [Message]: the domain type carries what a
// caller decides, and this file is the one place that knows how the
// provider spells it.
// contact is one entry of the receiver's contact list.
type contact struct {
IdentifierKey string `json:"identifierKey"`
IdentifierValue string `json:"identifierValue"`
Type string `json:"type,omitzero"`
}
// parameter is one typed template variable.
type parameter struct {
Type string `json:"type"`
Key string `json:"key"`
Value any `json:"value"`
}
// template references the template project a message is rendered from.
type template struct {
ProjectID string `json:"projectId"`
Version string `json:"version"`
Locale string `json:"locale,omitzero"`
Parameters []parameter `json:"parameters,omitzero"`
}
// meta carries the message classification.
type meta struct {
ExtraInformation struct {
UseCase string `json:"useCase"`
} `json:"extraInformation"`
}
// payload is the request body of a channel message.
type payload struct {
Receiver struct {
Contacts []contact `json:"contacts"`
} `json:"receiver"`
Template template `json:"template"`
Meta *meta `json:"meta,omitzero"`
}
// wireTemplate builds the template reference. An empty version selects the
// latest published one, and the variables become typed parameters in
// deterministic key order, so payloads are stable across sends.
func wireTemplate(
projectID, version, locale string,
vars map[string]any,
) template {
if version == "" {
version = "latest"
}
t := template{
ProjectID: projectID,
Version: version,
Locale: locale,
}
for _, key := range slices.Sorted(maps.Keys(vars)) {
value := vars[key]
t.Parameters = append(t.Parameters, parameter{
Type: parameterType(value),
Key: key,
Value: value,
})
}
return t
}
// wireMeta builds the classification block, or nil when the message is
// unclassified.
func wireMeta(c notify.Category) *meta {
if c == "" {
return nil
}
m := &meta{}
m.ExtraInformation.UseCase = string(c)
return m
}
// parameterType names the provider-side type of a template variable.
// Anything that is not a plain string, boolean, or number travels as an
// object.
func parameterType(value any) string {
switch value.(type) {
case string:
return "string"
case bool:
return "boolean"
case int, int8, int16, int32, int64,
uint, uint8, uint16, uint32, uint64,
float32, float64:
return "number"
default:
return "object"
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package buffer
import (
"net/http/httputil"
"sync"
)
// Pool implements [httputil.BufferPool] backed by a [sync.Pool] internally.
//
// It reduces allocations for large response bodies by reusing byte slices,
// thus lowering GC pressure.
//
// Buffers handed out by [Pool.Get] always have a non-zero length, which is
// what consumers such as [io.CopyBuffer] require. A [sync.Pool] may drop
// pooled items at any time, so a buffer put back is not guaranteed to be the
// one handed out next.
type Pool struct {
// pool is the underlying [sync.Pool] storing buffer pointers.
pool sync.Pool
// max is the largest capacity allowed back into the pool.
max int
}
// NewPool creates a new [Pool] that returns buffers of at least the given
// minimum size.
//
// Buffers that grow beyond the given maximum will be discarded during
// [Pool.Put]. Both numbers must be positive, or else the function panics; the
// minimum is clamped by the maximum.
func NewPool(minSize, maxSize int) *Pool {
if minSize <= 0 {
panic("minSize must be positive")
}
if maxSize <= 0 {
panic("maxSize must be positive")
}
minSize = min(minSize, maxSize)
// Store a pointer to a slice to avoid allocations when storing in the
// interface-typed pool.
alloc := func() any {
buf := make([]byte, minSize)
return &buf
}
return &Pool{
pool: sync.Pool{New: alloc},
max: maxSize,
}
}
// Get returns a reusable byte slice from the [Pool]. Its length is always
// greater than zero.
func (b *Pool) Get() []byte {
return *b.pool.Get().(*[]byte)
}
// Put returns the buffer to the [Pool] unless it grew beyond the size limit.
//
// If the capacity of the provided slice exceeds the maximum size defined
// during initialization, the buffer is dropped to allow the GC to reclaim
// memory and prevent the pool from holding onto excessively large slices.
//
// The slice is restored to its full capacity before being stored. Callers
// commonly re-slice a buffer while using it, and a buffer put back as buf[:0]
// would otherwise be handed to the next caller with no room to write into.
func (b *Pool) Put(buf []byte) {
// Avoid holding on to overly large buffers.
if cap(buf) > b.max {
return
}
// Nothing can be read into a zero-length slice; io.CopyBuffer panics on
// one, and that is precisely how httputil.ReverseProxy consumes this pool.
buf = buf[:cap(buf)]
if len(buf) == 0 {
return
}
b.pool.Put(&buf)
}
var _ httputil.BufferPool = (*Pool)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package proxy
import (
"net/http"
"time"
"github.com/deep-rent/nexus/sys/log"
)
const (
// DefaultMinBufferSize is the default minimum size of pooled buffers (32
// KiB).
DefaultMinBufferSize = 32 << 10
// DefaultMaxBufferSize is the default maximum size of pooled buffers (256
// KiB).
DefaultMaxBufferSize = 256 << 10
)
// handlerConfig holds the configurable settings for the proxy handler.
type handlerConfig struct {
// transport handles the network communication with the upstream.
transport *http.Transport
// flushInterval is the periodic flush interval for response body copying.
flushInterval time.Duration
// minBufferSize is the minimum size of pooled buffers.
minBufferSize int
// maxBufferSize is the maximum size of pooled buffers.
maxBufferSize int
// newRewrite is the factory for creating the request rewrite function.
newRewrite RewriteFactory
// newErrorHandler is the factory for creating the error handling function.
newErrorHandler ErrorHandlerFactory
// logger is the structured logger for error reporting.
logger *log.Logger
}
// HandlerOption defines a function for setting reverse proxy options.
type HandlerOption func(*handlerConfig)
// WithTransport sets the [http.Transport] for upstream requests.
//
// Use this option to tune connection pooling, timeouts, and keep-alives. If nil
// is given, this option is ignored.
func WithTransport(t *http.Transport) HandlerOption {
return func(cfg *handlerConfig) {
if t != nil {
cfg.transport = t
}
}
}
// WithFlushInterval specifies the periodic flush interval for the response.
//
// A zero value (default) disables periodic flushing. A negative value tells the
// proxy to flush immediately after each write. Adjust this if you observe high
// latencies for responses buffered by the proxy.
func WithFlushInterval(d time.Duration) HandlerOption {
return func(cfg *handlerConfig) {
cfg.flushInterval = d
}
}
// WithMinBufferSize specifies the minimum size of pooled buffers.
//
// Non-positive values are ignored. The value is capped at the maximum set by
// [WithMaxBufferSize]. Adapt this if you know from profiling that most
// responses are larger than the default 32 KiB.
func WithMinBufferSize(n int) HandlerOption {
return func(cfg *handlerConfig) {
if n > 0 {
cfg.minBufferSize = n
}
}
}
// WithMaxBufferSize specifies the maximum size of buffers to be pooled.
//
// Buffers that grow larger than this size will be discarded after use to
// prevent memory bloat. If your P95 response size is larger than this value,
// the pool will be ineffective.
func WithMaxBufferSize(n int) HandlerOption {
return func(cfg *handlerConfig) {
if n > 0 {
cfg.maxBufferSize = n
}
}
}
// WithRewrite provides a custom [RewriteFactory] for the proxy.
//
// If nil is given, this option is ignored. By default, [NewRewrite] is used.
func WithRewrite(f RewriteFactory) HandlerOption {
return func(cfg *handlerConfig) {
if f != nil {
cfg.newRewrite = f
}
}
}
// WithErrorHandler provides a custom [ErrorHandlerFactory] for the proxy.
//
// If nil is given, this option is ignored. By default, [NewErrorHandler] is
// used.
func WithErrorHandler(f ErrorHandlerFactory) HandlerOption {
return func(cfg *handlerConfig) {
if f != nil {
cfg.newErrorHandler = f
}
}
}
// WithLogger sets the [log.Logger] to be used by the proxy's
// [ErrorHandler].
//
// If nil is given, this option is ignored. The default error handler uses
// this logger for capturing upstream errors; without one, errors stay
// silent ([log.Discard]).
func WithLogger(logger *log.Logger) HandlerOption {
return func(cfg *handlerConfig) {
if logger != nil {
cfg.logger = logger
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package proxy
import (
"context"
"errors"
"net/http"
"net/http/httputil"
"net/url"
"github.com/deep-rent/nexus/net/proxy/buffer"
"github.com/deep-rent/nexus/sys/log"
)
// Handler is an alias of [http.Handler] representing a reverse proxy.
type Handler = http.Handler
// NewHandler creates a new reverse proxy handler that routes to the target URL.
//
// The behavior of the proxy can be customized through the given options. It
// avoids the deprecated [httputil.ReverseProxy.Director] hook in favor of the
// modern [httputil.ReverseProxy.Rewrite] API.
func NewHandler(target *url.URL, opts ...HandlerOption) Handler {
cfg := handlerConfig{
transport: http.DefaultTransport.(*http.Transport).Clone(),
flushInterval: 0,
minBufferSize: DefaultMinBufferSize,
maxBufferSize: DefaultMaxBufferSize,
newRewrite: NewRewrite,
newErrorHandler: NewErrorHandler,
logger: log.Discard(),
}
for _, opt := range opts {
opt(&cfg)
}
if cfg.minBufferSize > cfg.maxBufferSize {
cfg.minBufferSize = cfg.maxBufferSize
}
// Construct ReverseProxy directly to avoid the deprecated Director hook
// set by NewSingleHostReverseProxy.
h := &httputil.ReverseProxy{
ErrorHandler: cfg.newErrorHandler(cfg.logger),
Transport: cfg.transport,
BufferPool: buffer.NewPool(cfg.minBufferSize, cfg.maxBufferSize),
FlushInterval: cfg.flushInterval,
}
defaultRewrite := func(pr *httputil.ProxyRequest) {
pr.SetXForwarded()
pr.SetURL(target)
}
h.Rewrite = cfg.newRewrite(defaultRewrite)
return h
}
// RewriteFunc defines a function to modify requests before they go upstream.
//
// The signature matches [httputil.ReverseProxy.Rewrite].
type RewriteFunc func(*httputil.ProxyRequest)
// RewriteFactory creates a [RewriteFunc] using the provided original
// [RewriteFunc].
//
// The returned [RewriteFunc] may call original to retain its behavior.
type RewriteFactory = func(original RewriteFunc) RewriteFunc
// NewRewrite is the default [RewriteFactory] for the proxy.
//
// It returns the original [RewriteFunc] unmodified. The default rewrite already
// sets X-Forwarded-Host, X-Forwarded-Proto, and X-Forwarded-For headers, and
// correctly rewrites the Host header to match the target.
func NewRewrite(original RewriteFunc) RewriteFunc {
return original
}
// ErrorHandler defines a function for handling proxy operation errors.
//
// The signature matches [httputil.ReverseProxy.ErrorHandler].
type ErrorHandler = func(http.ResponseWriter, *http.Request, error)
// ErrorHandlerFactory creates an [ErrorHandler] using the provided logger.
//
// It receives the configured logger to be used for error reporting.
type ErrorHandlerFactory = func(*log.Logger) ErrorHandler
// NewErrorHandler is the default [ErrorHandlerFactory] for the proxy.
//
// It creates an error handler that logs upstream errors and maps them to
// appropriate HTTP status codes, while silencing client-initiated disconnects.
func NewErrorHandler(logger *log.Logger) ErrorHandler {
return func(w http.ResponseWriter, r *http.Request, err error) {
if errors.Is(err, context.Canceled) {
// Silence client-initiated disconnects; there's nothing useful to
// send
return
}
status := http.StatusBadGateway
method, uri := r.Method, r.RequestURI
if errors.Is(err, context.DeadlineExceeded) ||
errors.Is(err, http.ErrHandlerTimeout) {
status = http.StatusGatewayTimeout
logger.Error(
r.Context(),
"Upstream request timed out",
log.String("method", method),
log.String("uri", uri),
)
} else {
logger.Error(
r.Context(),
"Upstream request failed",
log.String("method", method),
log.String("uri", uri),
log.Error(err),
)
}
w.WriteHeader(status)
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package retry
import (
"github.com/deep-rent/nexus/std/backoff"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/sys/log"
)
// DefaultMaxDrainBytes is the default number of bytes read from the body of a
// failed attempt before the connection is given up on. Draining a body allows
// the underlying connection to be reused, but an unbounded read would let an
// oversized error page stall the retry loop.
const DefaultMaxDrainBytes int64 = 64 << 10 // 64 KB
// config holds the configuration parameters supplied via functional options.
type config struct {
policy Policy // base retry logic
limit int // maximum number of attempts
backoff backoff.Strategy // supplies the delay between attempts
logger *log.Logger // destination for debug output
now clock.Clock // clock used to interpret date headers
drain int64 // bytes read from an abandoned response body
}
// Option is a function that configures the retry transport.
type Option func(*config)
// WithPolicy sets the retry policy used by the transport.
//
// If not provided, [DefaultPolicy] is used. A nil value is ignored.
func WithPolicy(policy Policy) Option {
return func(c *config) {
if policy != nil {
c.policy = policy
}
}
}
// WithAttemptLimit sets the maximum number of attempts for a request.
//
// This includes the initial attempt. A value of 3 means one initial attempt
// and up to two retries. If the value is 0 or less, no limit is enforced,
// which makes the [Policy] and the request context solely responsible for
// ending the loop.
func WithAttemptLimit(n int) Option {
return func(c *config) {
c.limit = n
}
}
// WithBackoff sets the strategy for calculating the delay between retries.
//
// Attempts are counted per request, so a single strategy can be shared by any
// number of concurrent requests without their delays interfering. If not
// provided, there is no delay between attempts. A nil value is ignored.
func WithBackoff(strategy backoff.Strategy) Option {
return func(c *config) {
if strategy != nil {
c.backoff = strategy
}
}
}
// WithLogger sets the [log.Logger] for debug messages.
//
// If not provided, debug output is discarded ([log.Discard]). A nil value
// is ignored.
func WithLogger(logger *log.Logger) Option {
return func(c *config) {
if logger != nil {
c.logger = logger
}
}
}
// WithClock provides a custom time source used to interpret the date-based
// forms of the Retry-After and X-RateLimit-Reset headers, primarily for
// testing. It does not affect the actual waiting between attempts, which
// always follows the real clock.
//
// If not provided, [clock.System] is used. A nil value is ignored.
func WithClock(now clock.Clock) Option {
return func(c *config) {
if now != nil {
c.now = now
}
}
}
// WithMaxDrainBytes limits how much of an abandoned response body is read
// before the next attempt. Draining lets the underlying connection be reused;
// bodies larger than this limit are closed instead, which costs a connection
// but bounds the work spent on a failed attempt.
//
// If not provided, [DefaultMaxDrainBytes] is used. A value of 0 or less
// disables draining entirely.
func WithMaxDrainBytes(n int64) Option {
return func(c *config) {
c.drain = n
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package retry
import (
"context"
"errors"
"io"
"net"
"net/http"
)
// Attempt encapsulates the state of a single HTTP request attempt.
//
// It is passed to a [Policy] to determine whether a retry is warranted. The
// response body, if any, must not be consumed by the policy: it is drained by
// the transport before the next attempt, and handed to the caller intact once
// the retry loop ends.
type Attempt struct {
// Request is the request as it was sent for this attempt.
Request *http.Request
// Response is the result of the attempt, if one was received. It is nil
// whenever Error is non-nil.
Response *http.Response
// Error is the error returned by the underlying transport, if any.
Error error
// Count is the number of the current attempt, starting at 1.
Count int
}
// Idempotent reports whether the request can be safely retried.
//
// It considers the HTTP methods defined as idempotent by RFC 9110, namely GET,
// HEAD, OPTIONS, TRACE, PUT, and DELETE. Note that idempotency is a property
// of the server implementation; a POST endpoint guarded by an idempotency key
// is safe to retry even though this method reports otherwise.
func (a Attempt) Idempotent() bool {
switch a.Request.Method {
case
http.MethodGet,
http.MethodHead,
http.MethodOptions,
http.MethodTrace,
http.MethodPut,
http.MethodDelete:
return true
default:
return false
}
}
// Temporary reports whether the response indicates a server-side temporary
// failure.
//
// This is determined by specific HTTP status codes that suggest the request
// might succeed if retried, such as 408, 429, 500, 502, 503, and 504.
func (a Attempt) Temporary() bool {
if a.Response == nil {
return false
}
switch a.Response.StatusCode {
case
http.StatusRequestTimeout, // 408
http.StatusTooManyRequests, // 429
http.StatusInternalServerError, // 500
http.StatusBadGateway, // 502
http.StatusServiceUnavailable, // 503
http.StatusGatewayTimeout: // 504
return true
default:
return false
}
}
// Transient reports whether the error suggests a temporary network-level
// issue.
//
// It returns true for network timeouts and for connections that were closed
// mid-flight. It returns false for context cancellations ([context.Canceled],
// [context.DeadlineExceeded]), since retrying cannot succeed once the caller
// has given up or its deadline has passed.
func (a Attempt) Transient() bool {
if a.Error == nil ||
errors.Is(a.Error, context.Canceled) ||
errors.Is(a.Error, context.DeadlineExceeded) {
return false
}
if errors.Is(a.Error, io.ErrUnexpectedEOF) || errors.Is(a.Error, io.EOF) {
return true
}
var err net.Error
return errors.As(a.Error, &err) && err.Timeout()
}
// Policy is the decision-making function that determines whether to retry.
//
// It is invoked after each attempt with the corresponding [Attempt] details.
// It returns true to schedule a retry, or false to stop and return the last
// result to the caller. A policy is called from the goroutine driving the
// request and may be invoked concurrently for different requests, so it must
// not rely on shared mutable state.
type Policy func(a Attempt) bool
// LimitAttempts decorates a [Policy] to enforce a maximum attempt limit.
//
// It short-circuits the decision, returning false once the attempt count has
// reached the limit n. Otherwise, it delegates to the wrapped policy. A limit
// of 1 disables retries; a limit of 0 or less leaves the policy unchanged.
func (p Policy) LimitAttempts(n int) Policy {
if n <= 0 {
return p
}
return func(a Attempt) bool {
return a.Count < n && p(a)
}
}
// DefaultPolicy provides a safe and sensible default retry strategy.
//
// It retries only idempotent requests that resulted in a temporary server
// error or a transient network error such as a timeout. Requests that carry a
// body which cannot be rewound are never retried, regardless of the policy;
// see [NewTransport].
func DefaultPolicy() Policy {
return func(a Attempt) bool {
return a.Idempotent() && (a.Temporary() || a.Transient())
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package retry
import (
"context"
"io"
"net/http"
"time"
"github.com/deep-rent/nexus/net/header"
"github.com/deep-rent/nexus/std/backoff"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/sys/log"
)
// transport wraps an underlying [http.RoundTripper] to provide automatic
// retries.
type transport struct {
next http.RoundTripper // underlying transport used to send requests
policy Policy // decides whether another attempt is made
backoff backoff.Strategy // supplies the delay between attempts
logger *log.Logger // destination for debug output
now clock.Clock // clock used to interpret date headers
drain int64 // bytes read from an abandoned response body
}
// NewTransport creates and returns a new retrying [http.RoundTripper].
//
// It wraps an existing transport and retries requests based on the configured
// policy and backoff strategy. Requests that carry a body are only retried if
// that body can be rewound, which is the case when [http.Request.GetBody] is
// set. The helpers in [net/http] set it for the common in-memory body types,
// but not for an arbitrary [io.Reader].
//
// The returned transport is safe for concurrent use if the wrapped transport
// is.
func NewTransport(
next http.RoundTripper,
opts ...Option,
) http.RoundTripper {
cfg := config{
policy: DefaultPolicy(),
limit: 0,
backoff: backoff.Constant(0),
logger: log.Discard(),
now: clock.System,
drain: DefaultMaxDrainBytes,
}
for _, opt := range opts {
opt(&cfg)
}
return &transport{
next: next,
policy: cfg.policy.LimitAttempts(cfg.limit),
backoff: cfg.backoff,
logger: cfg.logger,
now: cfg.now,
drain: cfg.drain,
}
}
// attemptKey carries the 1-based attempt number in a request context.
type attemptKey struct{}
// AttemptCount reports the 1-based number of the attempt a request is
// currently on, as recorded by the retrying transport in the request context.
// It returns 0 if the request is not executing under a retrying transport.
//
// Transports layered below the retry transport can use this to annotate
// per-attempt work, for example to record a resend count on a trace span.
func AttemptCount(ctx context.Context) int {
count, _ := ctx.Value(attemptKey{}).(int)
return count
}
// RoundTrip executes an HTTP transaction, retrying it as directed by the
// configured [Policy].
//
// The caller's request is never modified: retries are sent as clones carrying
// a freshly rewound body. Between attempts, the abandoned response body is
// drained and closed so that the underlying connection can be reused. The
// response handed back to the caller always has its body intact.
//
// The loop honors the request context throughout. If the context carries a
// deadline that would elapse during the next backoff delay, the transport
// stops early and returns the result of the last attempt rather than waiting
// for a cancellation that is certain to happen.
func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) {
ctx := req.Context()
if err := ctx.Err(); err != nil {
return nil, err
}
// A body that cannot be rewound can only be sent once.
rewindable := req.Body == nil || req.GetBody != nil
for count := 1; ; count++ {
actx := context.WithValue(ctx, attemptKey{}, count)
attempt := req.WithContext(actx)
if count > 1 {
var err error
if attempt, err = rewind(actx, req); err != nil {
return nil, err
}
}
res, err := t.next.RoundTrip(attempt)
retry := t.policy(Attempt{
Request: attempt,
Response: res,
Error: err,
Count: count,
})
// The policy is consulted first, so that it observes every attempt
// even when the request turns out not to be repeatable.
if !retry || !rewindable {
if retry {
t.logger.Debug(ctx,
"Not retrying a request with a non-rewindable body",
log.String("method", req.Method),
log.String("url", req.URL.String()),
)
}
return res, err
}
delay := t.delay(count, res)
// Waiting past the deadline would turn a usable response into a
// context error, so the last result is returned while its body is
// still intact.
if deadline, ok := ctx.Deadline(); ok &&
time.Until(deadline) <= delay {
t.logger.Debug(ctx,
"Not retrying, deadline would elapse during backoff",
log.Duration("delay", delay),
log.String("method", req.Method),
log.String("url", req.URL.String()),
)
return res, err
}
t.discard(ctx, res)
t.log(ctx, count, delay, req, res, err)
if err := backoff.Wait(ctx, delay); err != nil {
return nil, err
}
}
}
// rewind clones the given request for another attempt, obtaining a fresh
// reader for its body. The clone carries the given context, which holds the
// current attempt count. The original request is left untouched, as required
// by the [http.RoundTripper] contract.
func rewind(ctx context.Context, req *http.Request) (*http.Request, error) {
clone := req.Clone(ctx)
if req.GetBody == nil {
return clone, nil
}
body, err := req.GetBody()
if err != nil {
return nil, err
}
clone.Body = body
return clone, nil
}
// delay determines how long to wait before the next attempt, reconciling the
// backoff strategy with any throttling hints sent by the server.
func (t *transport) delay(count int, res *http.Response) time.Duration {
delay := t.backoff.Delay(count)
if res == nil {
return delay
}
// Use the longer of the two delays to respect both the server's
// instruction and our own backoff policy.
return max(delay, header.Throttle(res.Header, t.now))
}
// discard drains and closes the body of an abandoned response, allowing the
// underlying connection to be reused. Reading is bounded: a body that exceeds
// the limit is closed without being consumed, which costs a connection but
// keeps a large error page from stalling the retry loop.
func (t *transport) discard(ctx context.Context, res *http.Response) {
if res == nil || res.Body == nil {
return
}
if t.drain > 0 {
// One byte beyond the limit distinguishes a fully drained body from a
// truncated one, which must not be reused.
n, err := io.Copy(io.Discard, io.LimitReader(res.Body, t.drain+1))
switch {
case err != nil:
t.logger.Warn(
ctx,
"Failed to drain response body",
log.Error(err),
)
case n > t.drain:
t.logger.Debug(
ctx,
"Abandoned response body exceeds the drain limit",
log.Int64("limit", t.drain),
)
}
}
if err := res.Body.Close(); err != nil {
t.logger.Warn(
ctx,
"Failed to close response body",
log.Error(err),
)
}
}
// log records a failed attempt and the delay preceding the next one.
func (t *transport) log(
ctx context.Context,
count int,
delay time.Duration,
req *http.Request,
res *http.Response,
err error,
) {
if !t.logger.Enabled(ctx, log.LevelDebug) {
return
}
args := []log.Arg{
log.Int("attempt", count),
log.Duration("delay", delay),
log.String("method", req.Method),
log.String("url", req.URL.String()),
}
if err != nil {
args = append(args, log.Error(err))
}
if res != nil {
args = append(args, log.Int("status", res.StatusCode))
}
t.logger.Debug(ctx, "Request attempt failed, retrying", args...)
}
var _ http.RoundTripper = (*transport)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package router
import (
"context"
"errors"
"fmt"
"net/http"
"uuid"
"github.com/deep-rent/nexus/sys/log"
)
// Reason is a short, machine-readable code identifying the kind of error,
// such as "not_found" or "rate_limit". It is an alias rather than a defined
// type, so that plain string constants satisfy it; its purpose is to mark
// every reason code in the codebase, so they can be collected for the API
// documentation.
//
// Reason codes are lower_snake_case and are declared as exported constants
// named Reason*, next to the handlers that return them. Clients switch on
// them, so a code is part of the public API: rename it only as a breaking
// change.
type Reason = string
// Error describes the standardized shape of API errors returned to clients.
//
// Handlers can return this struct directly to control the HTTP status code
// and error details. If a handler returns a standard Go error, the [Router]
// will wrap it in a generic internal server error.
//
// Handlers should not log the errors they return. The [Router] logs every
// error centrally, with the request attributes attached; see [WithLogger].
type Error struct {
// Status is the HTTP status code (e.g., 400, 404, 500, 503, ...).
Status int `json:"status"`
// Reason is a short code identifying the error type; see [Reason].
Reason Reason `json:"reason"`
// Description is a human-readable explanation of the error cause. It is
// a sentence fragment written in lower case and without a trailing
// period, such as "no such team" or "failed to look up team", so that
// clients can embed it in a message of their own.
//
// The description states what went wrong in the API's own words. It
// never echoes request input back at the client, and never reveals
// internal detail such as a driver message or a stack trace: structured
// detail a client acts on belongs in [Error.Context], and internal
// detail, which is logged but not sent, in [Error.Cause].
Description string `json:"description"`
// ID is a unique identifier of the specific occurrence. The router
// fills it in for server errors, so that the value a client reports
// can be found in the logs.
ID string `json:"id,omitempty"`
// Context contains arbitrary additional data about the error.
// For example, validation errors may include a map of field-level
// diagnostics. This data is serialized and sent to the client, so
// it must not reveal internal system details like stack traces or
// database error messages.
Context any `json:"context,omitempty"`
// Cause is the underlying error that triggered this error. It is logged
// but never serialized, so it may carry internal detail.
Cause error `json:"-"`
}
// Error satisfies the standard [error] interface.
func (e *Error) Error() string {
return e.Reason + ": " + e.Description
}
// Unwrap returns the wrapped error if applicable.
func (e *Error) Unwrap() error {
return e.Cause
}
var _ error = (*Error)(nil)
// ErrorID generates a unique, string-based identifier intended for use
// in the [Error.ID] field.
//
// This identifier helps correlate client-side error reports with server-side
// logs, making it easier to trace the specific occurrence of an issue
// through the system. The [Router] assigns one to every server error, so
// handlers rarely need to call this directly.
func ErrorID() string {
return uuid.NewV7().String()
}
// panicError carries a value recovered from a panicking handler through to
// the error handler, so that it is reported as an opaque internal failure
// rather than crashing the connection. It is unexported: callers observe only
// the resulting 500.
type panicError struct {
value any
stack []byte
}
// Error implements the error interface.
func (e *panicError) Error() string {
return fmt.Sprintf("panic: %v", e.value)
}
// Unwrap exposes the recovered value if it was itself an error, so that a
// handler-level [errors.As] can still inspect the original cause.
func (e *panicError) Unwrap() error {
err, _ := e.value.(error)
return err
}
// defaultErrorHandler centralizes error processing: it normalizes whatever a
// handler returned into an [Error], logs it once with the request attributes
// attached, and writes the JSON response.
//
// Logging every error here, rather than at each site that builds one, is what
// keeps handlers free of logging boilerplate and keeps the log record shape
// consistent across the application.
func defaultErrorHandler(logger *log.Logger) ErrorHandler {
return func(e *Exchange, err error) {
ctx := e.Context()
// Nothing can be sent once the response is on the wire, so the error
// is only recorded.
if e.W.Closed() {
logger.Error(ctx,
"Handler returned error after writing response",
log.Error(err),
log.String("method", e.Method()),
log.String("path", e.Path()),
)
return
}
res := &Error{}
if !errors.As(err, &res) {
// An error that is not an *Error carries no client-facing shape,
// so it is reported as an opaque internal failure.
res = &Error{
Status: http.StatusInternalServerError,
Reason: ReasonServerError,
Description: "an unhandled internal error occurred",
Cause: err,
}
}
// A server error is the kind a client may report back, so it always
// carries an identifier that can be found in the logs.
if res.ID == "" && res.Status >= http.StatusInternalServerError {
res.ID = ErrorID()
}
record(ctx, logger, e, res)
if werr := e.JSON(res.Status, res); werr != nil {
logger.Warn(ctx,
"Failed to write error response",
log.Error(werr),
)
}
}
}
// record logs a failed exchange. Server errors are reported at error level,
// since they demand attention; client errors are ordinary traffic on a public
// API and would otherwise drown the logs, so they are recorded at debug level.
//
// Every failure shares one message, so that the records group; what
// distinguishes them — the reason, the description, the cause — rides along
// as arguments.
func record(
ctx context.Context,
logger *log.Logger,
e *Exchange,
res *Error,
) {
level := log.LevelDebug
if res.Status >= http.StatusInternalServerError {
level = log.LevelError
}
if !logger.Enabled(ctx, level) {
return
}
attrs := []log.Arg{
log.Int("status", res.Status),
log.String("reason", res.Reason),
log.String("description", res.Description),
log.String("method", e.Method()),
log.String("path", e.Path()),
}
// The identifier is attached by hand because the error logged below is
// the cause, not the [Error] itself, so the sink cannot discover it.
if res.ID != "" {
attrs = append(attrs, log.String(log.ErrorIDKey, res.ID))
}
// The cause carries the internal detail that the description withholds
// from the client.
if res.Cause != nil {
attrs = append(attrs, log.Error(res.Cause))
}
// A recovered panic is only useful with the stack that produced it.
if pe, ok := errors.AsType[*panicError](res.Cause); ok {
attrs = append(attrs, log.String("stack", string(pe.stack)))
}
logger.Log(ctx, level, "Request failed", attrs...)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package router
import (
"net/http"
"slices"
"strings"
)
// Group registers routes under a shared path prefix and middleware chain.
//
// A group is a registration-time convenience, not a routing layer: every
// route still lands in the router's single routing tree under its full
// path, so route conflicts and precedence behave exactly as without
// groups. Groups nest; a child group concatenates its prefix onto the
// parent's and appends its middleware after the parent's.
//
// Middleware ordering follows registration nesting, outermost first: the
// router's own middleware (see [WithMiddleware]), then the group chain from
// outermost to innermost group, then the middleware of the individual
// route.
//
// Create groups with [Router.Group]; the zero value is not usable.
type Group struct {
router *Router
prefix string
mws []Middleware
}
// Group creates a route group under the given path prefix, sharing the
// given middleware among its routes. An empty prefix groups by middleware
// alone; a non-empty prefix must start with "/" and not end with one, since
// it is joined verbatim with the routes' paths. It panics on a malformed
// prefix, as route registration happens once at startup.
func (r *Router) Group(prefix string, mws ...Middleware) *Group {
checkPrefix(prefix)
return &Group{
router: r,
prefix: prefix,
mws: slices.Clone(mws),
}
}
// Group creates a nested group: its prefix is appended to the parent's, and
// its middleware runs after the parent's. The prefix rules of
// [Router.Group] apply.
func (g *Group) Group(prefix string, mws ...Middleware) *Group {
checkPrefix(prefix)
return &Group{
router: g.router,
prefix: g.prefix + prefix,
mws: append(slices.Clip(slices.Clone(g.mws)), mws...),
}
}
// checkPrefix validates a group path prefix. It panics on a malformed prefix.
func checkPrefix(prefix string) {
if prefix == "" {
return
}
if !strings.HasPrefix(prefix, "/") {
panic("group prefix must start with a slash: " + prefix)
}
if strings.HasSuffix(prefix, "/") {
panic("group prefix cannot end with a slash: " + prefix)
}
}
// Handle registers a handler under the group: the path is prefixed with
// the group's prefix, and the group's middleware runs before any route
// middleware given here.
//
// An empty path addresses the group's root, i.e. the prefix itself; a
// non-empty path must start with "/", since it is joined verbatim onto
// the prefix.
func (g *Group) Handle(
method, path string,
handler Handler,
mws ...Middleware,
) {
if path != "" && path[0] != '/' {
panic("group route path must be empty or start with a slash: " + path)
}
g.router.Handle(
method,
g.prefix+path,
handler,
append(slices.Clip(slices.Clone(g.mws)), mws...)...,
)
}
// HandleFunc registers a handler function under the group; see
// [Group.Handle].
func (g *Group) HandleFunc(
method, path string,
fn func(*Exchange) error,
mws ...Middleware,
) {
g.Handle(method, path, HandlerFunc(fn), mws...)
}
// Mount registers a plain [http.Handler] under the group, wrapped like
// [Router.Mount]: the group's prefix and middleware apply.
func (g *Group) Mount(method, path string, handler http.Handler) {
g.Handle(method, path, Wrap(handler))
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package router
import (
"math"
"net/http"
"slices"
"strconv"
"golang.org/x/time/rate"
"github.com/deep-rent/nexus/net/middleware"
"github.com/deep-rent/nexus/net/middleware/cors"
"github.com/deep-rent/nexus/net/middleware/gzip"
"github.com/deep-rent/nexus/net/middleware/secure"
"github.com/deep-rent/nexus/sys/log"
)
// Middleware defines a function that wraps a [Handler].
//
// It allows custom logic to be executed before and/or after the next handler.
// Unlike standard HTTP middleware, this natively supports returning API errors.
type Middleware func(Handler) Handler
// Chain combines a handler with multiple [Middleware] functions.
//
// The functions are applied in reverse order, meaning the first middleware in
// the list is the outermost and executes first.
func Chain(h Handler, mws ...Middleware) Handler {
for _, mw := range slices.Backward(mws) {
if mw != nil {
h = mw(h)
}
}
return h
}
// Passthrough is a no-op [Middleware] that returns the next handler unchanged.
//
// A no-op factory signals "no middleware" by returning nil, which [Chain]
// skips. Passthrough is instead a directly-callable identity, for callers that
// build a chain conditionally or need a safe middleware to invoke without a nil
// check.
func Passthrough(next Handler) Handler { return next }
// Wrap converts a standard [http.Handler] into a router [Handler].
func Wrap(h http.Handler) Handler {
return HandlerFunc(func(e *Exchange) error {
h.ServeHTTP(e.W, e.R)
return nil
})
}
// Adapt converts a standard [middleware.Pipe] into a [Middleware].
//
// This bridges low-level HTTP transport middlewares into the router's
// ecosystem, ensuring that any modifications made to the request or response
// writer by the transport middleware are preserved.
//
// A nil pipe means "no middleware": Adapt returns nil so that [Chain] skips it,
// rather than wrapping it into a middleware that does per-request work for
// nothing. This lets no-op pipe factories (e.g. [middleware.Log] with debug
// disabled) collapse away instead of adding an idle layer to the router chain.
func Adapt(pipe middleware.Pipe) Middleware {
if pipe == nil {
return nil
}
return func(next Handler) Handler {
return HandlerFunc(func(e *Exchange) error {
var err error
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
e.R = r
// Avoid double wrapping:
if rw, ok := w.(ResponseWriter); ok {
e.W = rw
} else {
e.W = NewResponseWriter(w)
}
err = next.ServeHTTP(e)
// Resolve the error immediately so transport middlewares (like
// logging interceptors) observe the correct HTTP status code.
if err != nil && e.errorHandler != nil {
e.errorHandler(e, err)
err = nil // Prevent double-handling upstream.
}
})
pipe(h).ServeHTTP(e.W, e.R)
return err
})
}
}
// Recover mirrors [middleware.Recover] for use in the router.
//
// A [Router] already recovers panics from the whole handler chain and turns
// them into a logged JSON 500, so this is rarely needed. It remains for
// parity with the [middleware] package and for the occasional case of
// wanting recovery at a specific point in the chain, so that middleware
// outside it still runs after a panic within.
func Recover(logger *log.Logger) Middleware {
return Adapt(middleware.Recover(logger))
}
// RequestID mirrors [middleware.RequestID] for use in the router.
func RequestID() Middleware {
return Adapt(middleware.RequestID())
}
// Log mirrors [middleware.Log] for use in the router.
func Log(logger *log.Logger) Middleware {
return Adapt(middleware.Log(logger))
}
// There is deliberately no Measure mirror here. The request duration
// middleware needs [metrics], which in turn needs this package for
// [metrics.Registry.Handler], so mirroring it would close an import
// cycle. It lives in [measure] instead and returns a [Middleware]
// directly, the way [shed] does.
//
// [metrics]: github.com/deep-rent/nexus/sys/metrics
// [metrics.Registry.Handler]:
// github.com/deep-rent/nexus/sys/metrics#Registry.Handler
// [measure]: github.com/deep-rent/nexus/net/middleware/measure
// [shed]: github.com/deep-rent/nexus/net/middleware/shed
// Volatile mirrors [middleware.Volatile] for use in the router.
func Volatile() Middleware {
return Adapt(middleware.Volatile())
}
// Secure mirrors the middleware created by [secure.New] for use in the
// router.
func Secure(opts ...secure.Option) Middleware {
return Adapt(secure.New(opts...))
}
// CORS mirrors the middleware created by [cors.New] for use in the router.
func CORS(opts ...cors.Option) Middleware {
return Adapt(cors.New(opts...))
}
// Gzip mirrors the middleware created by [gzip.New] for use in the router.
func Gzip(opts ...gzip.Option) Middleware {
return Adapt(gzip.New(opts...))
}
// RateLimit returns a [Middleware] that applies global rate limiting
// using the provided [rate.Limiter].
//
// If the limit is exceeded, it halts the chain and returns a [*Error] with
// status 429 Too Many Requests. For more complex strategies like per-client
// or per-IP limiting, use [RateLimitFunc].
func RateLimit(limiter *rate.Limiter) Middleware {
return RateLimitFunc(func(*http.Request) *rate.Limiter {
return limiter
})
}
// RateLimitFunc returns a [Middleware] that applies rate limiting using a
// dynamic [rate.Limiter] resolved per-request.
//
// The supplier callback allows callers to implement arbitrary rate limiting
// policies (e.g., per-IP, per-user, or tiered limits). If the callback returns
// nil, the request proceeds without rate limiting. If the limit is exceeded,
// it returns a [*Error] with status 429 Too Many Requests.
func RateLimitFunc(supply func(*http.Request) *rate.Limiter) Middleware {
return func(next Handler) Handler {
return HandlerFunc(func(e *Exchange) error {
if limiter := supply(e.R); limiter != nil {
res := limiter.Reserve()
if !res.OK() {
return &Error{
Status: http.StatusTooManyRequests,
Reason: ReasonRateLimit,
Description: "the rate limit has been exceeded",
}
}
if delay := res.Delay(); delay > 0 {
res.Cancel()
sec := int(math.Ceil(delay.Seconds()))
e.W.Header().Set("Retry-After", strconv.Itoa(sec))
return &Error{
Status: http.StatusTooManyRequests,
Reason: ReasonRateLimit,
Description: "the rate limit has been exceeded; " +
"try again later",
}
}
}
return next.ServeHTTP(e)
})
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package router
import (
"encoding/json/v2"
"github.com/deep-rent/nexus/sys/log"
)
// Option defines a functional configuration option for the [Router].
type Option func(*Router)
// WithMiddleware adds global middleware to the [Router].
func WithMiddleware(mws ...Middleware) Option {
return func(r *Router) {
r.mws = append(r.mws, mws...)
}
}
// WithNotFound replaces the handler serving requests whose path matches
// no route. It runs through the full middleware and error pipeline, like
// any route; the default returns a standardized 404 [Error]. A nil
// handler is ignored.
func WithNotFound(h Handler) Option {
return func(r *Router) {
if h != nil {
r.notFound = h
}
}
}
// WithMethodNotAllowed replaces the handler serving requests whose path is
// registered but whose method is not. The router sets the Allow header
// before the handler runs, so custom handlers inherit it; the default
// returns a standardized 405 [Error]. A nil handler is ignored.
func WithMethodNotAllowed(h Handler) Option {
return func(r *Router) {
if h != nil {
r.methodNotAllowed = h
}
}
}
// WithMaxBodySize sets the maximum allowed size for request bodies.
func WithMaxBodySize(bytes int64) Option {
return func(r *Router) {
r.maxBytes = bytes
}
}
// WithJSONOptions sets custom JSON options for the [Router].
func WithJSONOptions(opts ...json.Options) Option {
return func(r *Router) {
r.jsonOpts = opts
}
}
// WithErrorHandler sets a custom error handler.
func WithErrorHandler(h ErrorHandler) Option {
return func(r *Router) {
if h != nil {
r.errorHandler = h
}
}
}
// WithLogger updates the default error handler to use the given
// [log.Logger]. Without it, the router stays silent.
func WithLogger(logger *log.Logger) Option {
return func(r *Router) {
if logger != nil {
r.errorHandler = defaultErrorHandler(logger)
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package router
import "net/http"
// Registrar is the route registration surface shared by [Router] and
// [Group]. Components exposing a set of endpoints accept a Registrar, so a
// caller decides where they land: pass the router to mount at the root, or
// a group to mount under its prefix and middleware.
//
// server.Mount(r) // at the root
// server.Mount(r.Group("/iam", auth)) // prefixed and guarded
type Registrar interface {
// Handle registers a handler for the method and path; see
// [Router.Handle] and [Group.Handle].
Handle(method, path string, handler Handler, mws ...Middleware)
// HandleFunc registers a handler function for the method and path.
HandleFunc(
method, path string,
fn func(*Exchange) error,
mws ...Middleware,
)
// Mount registers a plain [http.Handler] for the method and path.
Mount(method, path string, handler http.Handler)
// Group creates a route group under the given path prefix; see
// [Router.Group].
Group(prefix string, mws ...Middleware) *Group
// Prefix returns the full path prefix routes register under: the
// accumulated group prefix, or the empty string on the router itself.
// Components building absolute URLs for their routes (say, endpoint
// locations in a discovery document) derive them from it.
Prefix() string
// Unwrap returns the underlying [Router]. It is the escape hatch for
// routes that must live at the server root regardless of any group
// prefix, such as protocol-mandated well-known locations.
Unwrap() *Router
}
// Prefix implements [Registrar]: routes on the router register at the
// root.
func (*Router) Prefix() string { return "" }
// Unwrap implements [Registrar].
func (r *Router) Unwrap() *Router { return r }
// Prefix implements [Registrar].
func (g *Group) Prefix() string { return g.prefix }
// Unwrap implements [Registrar].
func (g *Group) Unwrap() *Router { return g.router }
var (
_ Registrar = (*Router)(nil)
_ Registrar = (*Group)(nil)
)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package router
import (
"cmp"
"context"
"encoding/json/v2"
"errors"
"fmt"
"maps"
"net/http"
"net/url"
"runtime/debug"
"slices"
"strings"
"sync"
"github.com/deep-rent/nexus/dat/bind"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/net/header"
"github.com/deep-rent/nexus/net/router/tree"
"github.com/deep-rent/nexus/std/cases/snake"
"github.com/deep-rent/nexus/sys/log"
)
// Standard error reasons used for machine-readable error codes.
const (
// ReasonWrongType indicates that the request had an unsupported content
// type.
ReasonWrongType Reason = "wrong_type"
// ReasonEmptyBody indicates that the request body was empty.
ReasonEmptyBody Reason = "empty_body"
// ReasonParseJSON indicates that there was an error parsing the JSON body.
ReasonParseJSON Reason = "parse_json"
// ReasonParseForm indicates that there was an error parsing form data.
ReasonParseForm Reason = "parse_form"
// ReasonParseQuery indicates that there was an error parsing query
// parameters.
ReasonParseQuery Reason = "parse_query"
// ReasonParsePath indicates that there was an error parsing path
// parameters.
ReasonParsePath Reason = "parse_path"
// ReasonValidationFailed indicates that input validation failed.
ReasonValidationFailed Reason = "validation_failed"
// ReasonServerError indicates that an unexpected internal error occurred.
ReasonServerError Reason = "server_error"
// ReasonNotFound indicates that the requested resource does not exist.
ReasonNotFound Reason = "not_found"
// ReasonMethodNotAllowed indicates that the route exists but does not
// serve the request's method.
ReasonMethodNotAllowed Reason = "method_not_allowed"
// ReasonRateLimit indicates that the rate limit has been exceeded.
ReasonRateLimit Reason = "rate_limit"
// ReasonCrossSite indicates that a state-changing request arrived from
// another site and was refused; see [Exchange.CrossSite].
ReasonCrossSite Reason = "cross_site_request"
)
// Standard media types used in the Content-Type header.
const (
// MediaTypeJSON is the media type for JSON content.
MediaTypeJSON = "application/json"
// MediaTypeForm is the media type for URL-encoded form data.
MediaTypeForm = "application/x-www-form-urlencoded"
)
var formBinder = bind.New(
"form",
bind.WithCache(true),
bind.WithTransformer(snake.ToLower),
)
var queryBinder = bind.New(
"query",
bind.WithCache(true),
bind.WithTransformer(snake.ToLower),
)
var pathBinder = bind.New(
"path",
bind.WithCache(true),
bind.WithTransformer(snake.ToLower),
)
// pathSource resolves binder lookups against the exchange's captured path
// parameters. A wildcard that did not match (or matched emptily) reads as
// absent, so optional fields keep their zero values.
type pathSource struct {
e *Exchange
}
// Lookup implements [bind.Source].
func (s pathSource) Lookup(key string) ([]string, bool) {
if v := s.e.Param(key); v != "" {
return []string{v}, true
}
return nil, false
}
var _ bind.Source = pathSource{}
type urlSource url.Values
func (s urlSource) Lookup(key string) ([]string, bool) {
v, ok := s[key]
return v, ok
}
var _ bind.Source = (*urlSource)(nil)
// ResponseWriter extends [http.ResponseWriter] with introspection capabilities.
//
// It allows handlers and middleware to check if the response headers have
// already been written, which is crucial for robust error handling.
type ResponseWriter interface {
http.ResponseWriter
// Status returns the HTTP status code written, or 0 if not written yet.
Status() int
// Closed reports whether the headers have already been written.
Closed() bool
// Unwrap returns the underlying [http.ResponseWriter].
Unwrap() http.ResponseWriter
}
// NewResponseWriter wraps an [http.ResponseWriter] into a [ResponseWriter].
func NewResponseWriter(w http.ResponseWriter) ResponseWriter {
return &responseWriter{
ResponseWriter: w,
status: 0,
}
}
// responseWriter is the concrete implementation of [ResponseWriter].
type responseWriter struct {
// ResponseWriter is the underlying standard writer.
http.ResponseWriter
// status stores the HTTP response code once committed.
status int
}
// WriteHeader implements [ResponseWriter].
func (rw *responseWriter) WriteHeader(code int) {
if rw.status != 0 {
return
}
rw.status = code
rw.ResponseWriter.WriteHeader(code)
}
// Write implements [ResponseWriter].
func (rw *responseWriter) Write(b []byte) (int, error) {
if rw.status == 0 {
rw.WriteHeader(http.StatusOK)
}
return rw.ResponseWriter.Write(b)
}
// Status implements [ResponseWriter].
func (rw *responseWriter) Status() int {
return rw.status
}
// Closed implements [ResponseWriter].
func (rw *responseWriter) Closed() bool {
return rw.status != 0
}
// Unwrap implements [ResponseWriter].
func (rw *responseWriter) Unwrap() http.ResponseWriter {
return rw.ResponseWriter
}
var _ http.ResponseWriter = (*responseWriter)(nil)
// Exchange acts as a context object for a single HTTP request/response cycle.
//
// It wraps the underlying [*http.Request] and [http.ResponseWriter] to provide
// convenient helper methods for common API tasks, such as parsing JSON,
// reading parameters, and writing structured responses.
type Exchange struct {
// R is the incoming HTTP request.
R *http.Request
// W is a writer for the outgoing HTTP response.
W ResponseWriter
// rw is the writer W points at by default, embedded by value so an
// exchange costs a single allocation — and none at all once pooled.
rw responseWriter
// params are the path parameters captured by the router's tree.
params tree.Params
// jsonOpts is inherited from the parent Router.
jsonOpts []json.Options
// errorHandler allows middlewares to trigger standardized error resolution.
errorHandler ErrorHandler
}
// exchanges recycles [Exchange] values across requests. Handlers must not
// retain an exchange (or its W) past their return; the router scrubs and
// reuses it.
var exchanges = sync.Pool{New: func() any { return new(Exchange) }}
// Context returns the request's context.
func (e *Exchange) Context() context.Context { return e.R.Context() }
// Method returns the HTTP method (GET, POST, etc.) of the request.
func (e *Exchange) Method() string { return e.R.Method }
// URL returns the full URL of the request.
func (e *Exchange) URL() *url.URL { return e.R.URL }
// Path returns the URL path of the request.
func (e *Exchange) Path() string { return e.R.URL.Path }
// Param retrieves a path parameter by name, as captured by the route's
// {name} and {name...} segments. For an exchange built outside the router
// — in a test, say — it falls back to [http.Request.PathValue].
func (e *Exchange) Param(name string) string {
for i := range e.params.Count() {
if label, value := e.params.At(i); label == name {
return value
}
}
return e.R.PathValue(name)
}
// Query parses the URL query parameters of the request.
func (e *Exchange) Query() url.Values { return e.R.URL.Query() }
// Header returns the HTTP headers of the request.
func (e *Exchange) Header() http.Header { return e.R.Header }
// GetHeader retrieves a specific header value from the request.
func (e *Exchange) GetHeader(key string) string { return e.R.Header.Get(key) }
// SetHeader sets a specific header value in the response.
func (e *Exchange) SetHeader(key, value string) { e.W.Header().Set(key, value) }
// NoStore marks the response uncacheable.
//
// It is the header every API in this repository sets on anything a
// caller reads back after writing it: a ticket moves while somebody is
// reading it, a settings screen must not render a choice that is no
// longer there, and an audit trail grows under the reader. Five
// services had each written the one-line helper.
func (e *Exchange) NoStore() { e.SetHeader("Cache-Control", "no-store") }
// CrossSite reports whether the request demonstrably originates from a
// foreign site. Browsers send Sec-Fetch-Site on every request; "none"
// covers direct navigation, and absence covers non-browser clients, which
// carry no ambient cookie authority to protect.
func (e *Exchange) CrossSite() bool {
switch e.GetHeader("Sec-Fetch-Site") {
case "", "same-origin", "none":
return false
default:
return true
}
}
// BindJSON decodes the request body into the given target.
//
// This method verifies that the media type is "application/json", checks that
// the payload is not empty, unmarshals the JSON, and validates the input using
// [valid.Test].
//
// Every failure carries an [*Error] describing the client-facing response,
// but the declared type is the plain error interface: a handler that
// returns the result of a bind unconditionally would otherwise hand the
// router a non-nil interface wrapping a nil pointer. Reach for the concrete
// error with [errors.AsType] on the rare occasion a handler inspects it.
func (e *Exchange) BindJSON[T any](v *T) error {
if t := header.MediaType(e.R.Header); t != MediaTypeJSON {
return &Error{
Status: http.StatusUnsupportedMediaType,
Reason: ReasonWrongType,
Description: "content-type must be " + MediaTypeJSON,
}
}
if e.R.ContentLength == 0 || e.R.Body == nil || e.R.Body == http.NoBody {
return &Error{
Status: http.StatusBadRequest,
Reason: ReasonEmptyBody,
Description: "empty request body",
}
}
if err := json.UnmarshalRead(e.R.Body, v, e.jsonOpts...); err != nil {
return &Error{
Status: http.StatusBadRequest,
Reason: ReasonParseJSON,
Description: "could not parse JSON body",
}
}
if err, ok := errors.AsType[valid.Error](valid.Test(v)); ok {
return &Error{
Status: http.StatusBadRequest,
Reason: ReasonValidationFailed,
Description: fmt.Sprintf(
"body violates %d constraints",
err.Size(),
),
Context: err,
}
}
return nil
}
// BindQuery decodes URL query parameters into the given target.
//
// Fields resolve by "query" struct tag or, absent one, by the snake_cased
// field name. Decoding validates the result via [valid.Test].
func (e *Exchange) BindQuery[T any](v *T) error {
q := e.R.URL.Query()
if err := queryBinder.Bind(v, "", urlSource(q)); err != nil {
return &Error{
Status: http.StatusBadRequest,
Reason: ReasonParseQuery,
Description: err.Error(),
}
}
if err, ok := errors.AsType[valid.Error](valid.Test(v)); ok {
return &Error{
Status: http.StatusBadRequest,
Reason: ReasonValidationFailed,
Description: fmt.Sprintf(
"query violates %d constraints",
err.Size(),
),
Context: err,
}
}
return nil
}
// BindPath decodes the request's path parameters into the given target.
//
// Fields resolve against the wildcards of the matched route pattern, by
// "path" struct tag or, absent one, by the snake_cased field name: a field
// ID (tagged `path:"id"` or untagged) binds the {id} wildcard. Decoding
// follows the same rules as [Exchange.BindQuery], including validation via
// [valid.Test].
func (e *Exchange) BindPath[T any](v *T) error {
if err := pathBinder.Bind(v, "", pathSource{e}); err != nil {
return &Error{
Status: http.StatusBadRequest,
Reason: ReasonParsePath,
Description: err.Error(),
}
}
if err, ok := errors.AsType[valid.Error](valid.Test(v)); ok {
return &Error{
Status: http.StatusBadRequest,
Reason: ReasonValidationFailed,
Description: fmt.Sprintf(
"path violates %d constraints",
err.Size(),
),
Context: err,
}
}
return nil
}
// BindForm decodes URL-encoded form data from the request body into the
// given target.
func (e *Exchange) BindForm[T any](v *T) error {
form, err := e.ReadForm()
if err != nil {
if rerr, ok := errors.AsType[*Error](err); ok {
return rerr
}
return &Error{
Status: http.StatusBadRequest,
Reason: ReasonParseForm,
Description: err.Error(),
}
}
if err := formBinder.Bind(v, "", urlSource(form)); err != nil {
return &Error{
Status: http.StatusBadRequest,
Reason: ReasonParseForm,
Description: err.Error(),
}
}
if err, ok := errors.AsType[valid.Error](valid.Test(v)); ok {
return &Error{
Status: http.StatusBadRequest,
Reason: ReasonValidationFailed,
Description: fmt.Sprintf(
"body violates %d constraints",
err.Size(),
),
Context: err,
}
}
return nil
}
// ReadForm parses the request body as URL-encoded form data.
//
// Unlike standard [http.Request.FormValue], this strictly accesses the request
// body, ignoring URL query parameters.
func (e *Exchange) ReadForm() (url.Values, error) {
if t := header.MediaType(e.R.Header); t != MediaTypeForm {
return nil, &Error{
Status: http.StatusUnsupportedMediaType,
Reason: ReasonWrongType,
Description: "content-type must be " + MediaTypeForm,
}
}
if err := e.R.ParseForm(); err != nil {
return nil, &Error{
Status: http.StatusBadRequest,
Reason: ReasonParseForm,
Description: "malformed form data",
}
}
return e.R.PostForm, nil
}
// JSON encodes the given value as JSON and writes it to the response.
//
// It automatically sets the Content-Type header to [MediaTypeJSON] if it has
// not already been set.
func (e *Exchange) JSON(code int, v any) error {
buf, err := json.Marshal(v, e.jsonOpts...)
if err != nil {
return err
}
if e.W.Header().Get("Content-Type") == "" {
e.SetHeader("Content-Type", MediaTypeJSON)
}
e.Status(code)
_, err = e.W.Write(buf)
return err
}
// Form writes the values as URL-encoded form data.
//
// It automatically sets the Content-Type header to [MediaTypeForm] if it has
// not already been set.
func (e *Exchange) Form(code int, v url.Values) error {
if e.W.Header().Get("Content-Type") == "" {
e.SetHeader("Content-Type", MediaTypeForm)
}
e.Status(code)
_, err := e.W.Write([]byte(v.Encode()))
return err
}
// Status sends an HTTP response header with the provided status code.
//
// Note: Calling this commits the response headers. It is primarily used for
// empty responses like HTTP 204 (No Content).
func (e *Exchange) Status(code int) {
e.W.WriteHeader(code)
}
// NoContent sends an HTTP 204 No Content response.
func (e *Exchange) NoContent() {
e.Status(http.StatusNoContent)
}
// Redirect replies to the request with a redirect to the given URL.
func (e *Exchange) Redirect(url string, code int) error {
http.Redirect(e.W, e.R, url, code)
return nil
}
// Cookie retrieves a named cookie from the request.
// It returns [http.ErrNoCookie] if no such cookie was found.
// If multiple cookies match the given name, only one cookie will be returned.
func (e *Exchange) Cookie(name string) (*http.Cookie, error) {
return e.R.Cookie(name)
}
// SetCookie adds a Set-Cookie header to the response.
// The provided cookie must have a valid name. Invalid cookies may be silently
// dropped.
func (e *Exchange) SetCookie(cookie *http.Cookie) {
http.SetCookie(e.W, cookie)
}
// NewCookie builds a hardened cookie. A max age of zero yields a
// browser-session cookie; a negative value deletes the cookie on the
// user-agent.
func NewCookie(
name, value string,
maxAge int,
sameSite http.SameSite,
) *http.Cookie {
// #nosec G124 -- hardened right here: Secure and HttpOnly are set,
// and the caller chooses only among the SameSite modes.
return &http.Cookie{
Name: name,
Value: value,
Path: "/",
MaxAge: maxAge,
Secure: true,
HttpOnly: true,
SameSite: sameSite,
}
}
// Handler defines the interface for HTTP request handlers used by the [Router].
type Handler interface {
// ServeHTTP processes an HTTP request encapsulated in the Exchange object.
ServeHTTP(e *Exchange) error
}
// HandlerFunc defines the function signature for HTTP request handlers.
type HandlerFunc func(e *Exchange) error
// ServeHTTP satisfies the [Handler] interface.
func (f HandlerFunc) ServeHTTP(e *Exchange) error { return f(e) }
var _ Handler = HandlerFunc(nil)
// ErrorHandler defines a function that handles errors returned by routes.
type ErrorHandler func(e *Exchange, err error)
// methods are the request methods a route may register for: the [net/http]
// constants, plus the empty string for the wildcard. A method outside this
// set is a typo — a lower-cased verb, or a whole "GET /path" pattern — and
// would otherwise register a route no request could reach, since
// [http.Request.Method] carries the canonical spelling.
var methods = []string{
"",
http.MethodGet,
http.MethodHead,
http.MethodPost,
http.MethodPut,
http.MethodPatch,
http.MethodDelete,
http.MethodConnect,
http.MethodOptions,
http.MethodTrace,
}
// route is one registered handler with the pattern string reported through
// [http.Request.Pattern] for observability.
type route struct {
handler Handler
pattern string
}
// endpoint is the per-path method table stored in the routing tree.
type endpoint struct {
// routes maps HTTP methods to their handlers. The empty method is the
// wildcard, serving any method without a dedicated registration.
routes map[string]route
// allow is the precomputed Allow header for 405 responses.
allow string
}
// pick resolves the route for a method: the exact method first, then GET
// for a HEAD request — a registered GET can answer HEAD, mirroring
// [http.ServeMux] — and the wildcard last. ok is false when the endpoint
// serves other methods only.
func (ep *endpoint) pick(method string) (route, bool) {
if r, ok := ep.routes[method]; ok {
return r, true
}
if method == http.MethodHead {
if r, ok := ep.routes[http.MethodGet]; ok {
return r, true
}
}
r, ok := ep.routes[""]
return r, ok
}
// Router represents an HTTP request router with middleware support.
//
// Routing runs on the segment tree of [tree]: exact matching with {name}
// parameters and trailing {name...} catch-alls, most specific first.
// Unmatched paths answer 404 and unmatched methods 405 (with an Allow
// header), each through a customizable [Handler] that travels the
// ordinary middleware and error pipeline; see [WithNotFound] and
// [WithMethodNotAllowed]. A bare OPTIONS request to a path without an
// explicit OPTIONS route answers 204 with the same Allow set.
//
// Serving allocates nothing: exchanges are pooled and path captures live
// inside them, so a handler must not retain its [Exchange] (or the
// exchange's W) past returning.
//
// [tree]: github.com/deep-rent/nexus/net/router/tree
type Router struct {
// tree resolves request paths to their method tables.
tree tree.Tree[endpoint]
// mws is the global slice of middleware.
mws []Middleware
// maxBytes is the maximum request body size limit.
maxBytes int64
// jsonOpts are the standard JSON options used for I/O.
jsonOpts []json.Options
// errorHandler processes errors returned by routes.
errorHandler ErrorHandler
// notFound serves requests whose path matches no route.
notFound Handler
// methodNotAllowed serves requests whose path is known but whose
// method is not; the Allow header is set before it runs.
methodNotAllowed Handler
// options serves bare OPTIONS requests for paths without an explicit
// OPTIONS route; the Allow header is set before it runs.
options Handler
}
// New creates a new [Router] instance with the provided options.
func New(opts ...Option) *Router {
r := &Router{
errorHandler: defaultErrorHandler(log.Discard()),
notFound: HandlerFunc(func(*Exchange) error {
return &Error{
Status: http.StatusNotFound,
Reason: ReasonNotFound,
Description: "the requested route does not exist",
}
}),
methodNotAllowed: HandlerFunc(func(*Exchange) error {
return &Error{
Status: http.StatusMethodNotAllowed,
Reason: ReasonMethodNotAllowed,
Description: "the requested method is not supported",
}
}),
options: HandlerFunc(func(e *Exchange) error {
e.NoContent()
return nil
}),
}
for _, opt := range opts {
opt(r)
}
// The fallback handlers ride the global middleware like any route, so
// unmatched requests still show up in access logs and metrics.
r.notFound = Chain(r.notFound, r.mws...)
r.methodNotAllowed = Chain(r.methodNotAllowed, r.mws...)
r.options = Chain(r.options, r.mws...)
return r
}
// ServeHTTP satisfies the [http.Handler] interface.
func (r *Router) ServeHTTP(res http.ResponseWriter, req *http.Request) {
if r.maxBytes > 0 {
req.Body = http.MaxBytesReader(res, req.Body, r.maxBytes)
}
e := exchanges.Get().(*Exchange)
e.R = req
e.rw = responseWriter{ResponseWriter: res}
e.W = &e.rw
e.jsonOpts = r.jsonOpts
e.errorHandler = r.errorHandler
handler := r.notFound
if ep := r.tree.Lookup(req.URL.Path, &e.params); ep != nil {
switch rt, ok := ep.pick(req.Method); {
case ok:
handler = rt.handler
req.Pattern = rt.pattern
case req.Method == http.MethodOptions:
// A bare OPTIONS request asks what the path serves; answer
// with the same Allow set a 405 would carry (RFC 9110
// Section 9.3.7). An explicit OPTIONS or wildcard route
// takes this over via the branch above.
res.Header().Set("Allow", ep.allow)
handler = r.options
default:
// RFC 9110 Section 15.5.6: a 405 names the methods that would
// have been allowed. The header is set up front so custom
// handlers inherit it.
res.Header().Set("Allow", ep.allow)
handler = r.methodNotAllowed
}
}
if err := r.serve(handler, e); err != nil {
r.errorHandler(e, err)
}
// Scrub before pooling, so a recycled exchange pins neither the
// request nor the response of a finished one.
e.R = nil
e.W = nil
e.rw = responseWriter{}
e.params.Reset()
e.jsonOpts = nil
e.errorHandler = nil
exchanges.Put(e)
}
// Handle registers a handler for the method and path, wrapped with the
// Router's global middleware and any local middleware provided.
//
// The method must be one of the [net/http] method constants, matched
// verbatim; the empty method registers a wildcard serving any method
// without a dedicated registration. The path follows the syntax of
// [tree]: literal segments, {name} parameters, and a trailing {name...}
// catch-all.
//
// Handle panics on an unknown method, a malformed path, a wildcard
// conflict, or a duplicate method+path registration, since routes are
// registered once at startup and a misregistration is a programmer
// error.
//
// [tree]: github.com/deep-rent/nexus/net/router/tree
func (r *Router) Handle(
method, path string,
handler Handler,
mws ...Middleware,
) {
if !slices.Contains(methods, method) {
panic(fmt.Sprintf(
"unknown method %q: use a net/http method constant, "+
"or the empty string to serve every method",
method,
))
}
ep, err := r.tree.Insert(path)
if err != nil {
panic(err)
}
if _, dup := ep.routes[method]; dup {
panic(fmt.Sprintf(
"duplicate registration for %s %s", cmp.Or(method, "*"), path,
))
}
local := make([]Middleware, 0, len(r.mws)+len(mws))
local = append(local, r.mws...)
local = append(local, mws...)
if ep.routes == nil {
ep.routes = make(map[string]route)
}
ep.routes[method] = route{
handler: Chain(handler, local...),
pattern: strings.TrimSpace(method + " " + path),
}
ep.allow = allow(ep.routes)
}
// allow renders the Allow header for the method table: the registered
// methods sorted, with HEAD alongside a registered GET, since [endpoint.pick]
// serves it. A wildcard registration accepts everything, but by then no
// 405 can occur, so the value never reaches a response.
func allow(routes map[string]route) string {
methods := slices.Sorted(maps.Keys(routes))
if _, ok := routes[http.MethodGet]; ok {
if _, ok := routes[http.MethodHead]; !ok {
methods = append(methods, http.MethodHead)
slices.Sort(methods)
}
}
return strings.Join(methods, ", ")
}
// serve runs the handler chain, converting a panic into an error so that it
// travels the same path as any other failure: a handler that panics yields a
// clean, logged 500 rather than an aborted connection. The recovered value is
// wrapped so the central handler can attach a trace ID and keep the detail
// out of the response.
func (*Router) serve(h Handler, e *Exchange) (err error) {
defer func() {
if rec := recover(); rec != nil {
err = &panicError{value: rec, stack: debug.Stack()}
}
}()
return h.ServeHTTP(e)
}
// HandleFunc registers a handler function for the method and path; see
// [Router.Handle].
func (r *Router) HandleFunc(
method, path string,
fn func(*Exchange) error,
mws ...Middleware,
) {
r.Handle(method, path, HandlerFunc(fn), mws...)
}
// Mount registers a standard [http.Handler] under the method and path.
func (r *Router) Mount(method, path string, handler http.Handler) {
r.Handle(method, path, Wrap(handler))
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package tree
import (
"fmt"
"strings"
)
// Params collects the parameters captured during a [Tree.Lookup], in the
// order their segments appear in the path. The zero value is ready to use;
// reusing one across lookups avoids allocations once its backing arrays
// have grown.
type Params struct {
items []item
}
// item is one captured parameter.
type item struct {
label string
value string
}
// Reset empties the captures while keeping the backing arrays, readying
// the Params for the next lookup. The elements are zeroed, so a reused
// Params does not pin the strings of previous captures.
func (p *Params) Reset() {
clear(p.items)
p.truncate(0)
}
// Count returns the number of captured parameters.
func (p *Params) Count() int { return len(p.items) }
// At returns the i-th captured parameter.
func (p *Params) At(i int) (name, value string) {
return p.items[i].label, p.items[i].value
}
// push records a capture during descent.
func (p *Params) push(name, value string) {
p.items = append(p.items, item{label: name, value: value})
}
// truncate drops captures back to length n when a branch is abandoned.
func (p *Params) truncate(n int) {
p.items = p.items[:n]
}
// child is one static edge of a node.
type child[T any] struct {
seg string
node *node[T]
}
// fewMax is the static fan-out up to which children stay in a slice: a
// linear scan over a handful of segments is cheaper than hashing one.
// Real route tables are mostly narrow — a path segment branches two or
// three ways — with a wide root and a few wide groupings, so the slice
// covers the common node and the map covers the rest.
//
// The value is where BenchmarkLookupFanOut puts the crossover: scanning
// four children costs about what hashing one does, and past that the map
// wins outright. It is deliberately set at the crossover rather than
// above it, so the worst case stays flat as a table grows.
const fewMax = 4
// node is one segment position in the trie.
type node[T any] struct {
// few holds the static children while the fan-out stays at most
// fewMax, scanned linearly.
few []child[T]
// many replaces few beyond fewMax, keyed by segment.
many map[string]*node[T]
// param is the subtree behind a "{name}" segment, if any. A node holds
// at most one: two parameters at the same position could not be told
// apart at lookup time.
param *node[T]
paramName string
// catch terminates a "{name...}" registration, if any.
catch *node[T]
catchName string
// value is the slot handed out by Insert; set marks it live, so a T
// whose zero value is meaningful stays distinguishable from absence.
value T
set bool
}
// slot marks the node as registered and returns its value slot.
func (n *node[T]) slot() *T {
n.set = true
return &n.value
}
// staticChild resolves the static edge for the segment, if any.
func (n *node[T]) staticChild(seg string) *node[T] {
if n.many != nil {
return n.many[seg]
}
for i := range n.few {
if n.few[i].seg == seg {
return n.few[i].node
}
}
return nil
}
// addStatic returns the static edge for the segment, creating it if
// absent and promoting the children into a map once the fan-out outgrows
// the slice.
func (n *node[T]) addStatic(seg string) *node[T] {
if c := n.staticChild(seg); c != nil {
return c
}
c := &node[T]{}
if n.many != nil {
n.many[seg] = c
return c
}
n.few = append(n.few, child[T]{seg: seg, node: c})
if len(n.few) > fewMax {
n.many = make(map[string]*node[T], len(n.few))
for _, e := range n.few {
n.many[e.seg] = e.node
}
n.few = nil
}
return c
}
// Tree maps paths onto values of type T. The zero value is an empty tree
// ready for use.
//
// A tree is safe for concurrent lookups, but registration must not run
// concurrently with anything else: populate it at startup, then serve
// (immutable once constructed).
type Tree[T any] struct {
root node[T]
}
// Insert registers the path and returns a pointer to its value slot,
// creating it if absent — inserting the same path twice returns the same
// slot, so the caller decides whether that is an update or a conflict. It
// returns an error for a malformed path and for a wildcard that collides
// with one registered under a different name.
func (t *Tree[T]) Insert(path string) (*T, error) {
if path == "" || path[0] != '/' {
return nil, fmt.Errorf("path %q must start with a slash", path)
}
if path == "/" {
return t.root.slot(), nil
}
n := &t.root
rest := path[1:]
for i := 0; ; {
var seg string
j := strings.IndexByte(rest[i:], '/')
if j < 0 {
seg = rest[i:]
} else {
seg = rest[i : i+j]
}
switch name, catch, err := wildcard(seg); {
case seg == "":
// Rejecting empty segments keeps every path canonical: there
// is exactly one spelling of a route, and lookups need no
// cleaning pass to honor it.
return nil, fmt.Errorf(
"path %q contains an empty segment", path,
)
case err != nil:
return nil, fmt.Errorf("path %q: %w", path, err)
case catch:
if j >= 0 {
return nil, fmt.Errorf(
"path %q continues past the catch-all %q", path, seg,
)
}
if n.catch == nil {
n.catch = &node[T]{}
n.catchName = name
} else if n.catchName != name {
return nil, fmt.Errorf(
"path %q: catch-all {%s...} conflicts with {%s...}",
path, name, n.catchName,
)
}
return n.catch.slot(), nil
case name != "":
if n.param == nil {
n.param = &node[T]{}
n.paramName = name
} else if n.paramName != name {
return nil, fmt.Errorf(
"path %q: parameter {%s} conflicts with {%s}",
path, name, n.paramName,
)
}
n = n.param
default:
n = n.addStatic(seg)
}
if j < 0 {
return n.slot(), nil
}
i += j + 1
}
}
// wildcard classifies a segment: a literal yields an empty name, "{name}"
// yields the name, and "{name...}" additionally reports catch. The error
// flags a segment that dips into wildcard syntax without exactly matching
// it, so a typo like "{id" fails registration instead of becoming an
// unmatchable literal.
func wildcard(seg string) (name string, catch bool, err error) {
if !strings.ContainsAny(seg, "{}") {
return "", false, nil
}
if len(seg) < 3 || seg[0] != '{' || seg[len(seg)-1] != '}' {
return "", false, fmt.Errorf("malformed segment %q", seg)
}
name = seg[1 : len(seg)-1]
name, catch = strings.CutSuffix(name, "...")
if name == "" || strings.ContainsAny(name, "{}.") {
return "", false, fmt.Errorf("malformed segment %q", seg)
}
return name, catch, nil
}
// Lookup resolves the request path to a registered value slot, appending
// captured parameters to the given [Params]. It returns nil when nothing
// matches, leaving the [Params] holding whatever the caller passed in,
// unextended.
func (t *Tree[T]) Lookup(path string, params *Params) *T {
if path == "" || path[0] != '/' {
return nil
}
if path == "/" && t.root.set {
return &t.root.value
}
// "/" otherwise falls through with an empty remainder, so a root-level
// catch-all still gets its (empty) say.
return t.root.lookup(path[1:], 0, params)
}
// lookup resolves rest[i:] against the subtree, backtracking across the
// static, parameter, and catch-all branches in that order of precedence.
// An index past the end of rest marks the path as fully consumed.
//
// The descent recurses only at nodes that hold a wildcard alternative,
// where a failure below may still be rescued; a purely static node cannot
// backtrack, so the common static run stays a tight loop.
func (n *node[T]) lookup(rest string, i int, params *Params) *T {
for {
if i > len(rest) {
if n.set {
return &n.value
}
return nil
}
var seg string
next := len(rest) + 1
if j := strings.IndexByte(rest[i:], '/'); j < 0 {
seg = rest[i:]
} else {
seg, next = rest[i:i+j], i+j+1
}
if n.param == nil && n.catch == nil {
if n = n.staticChild(seg); n == nil {
return nil
}
i = next
continue
}
if child := n.staticChild(seg); child != nil {
if v := child.lookup(rest, next, params); v != nil {
return v
}
}
if n.param != nil && seg != "" {
mark := params.Count()
params.push(n.paramName, seg)
if v := n.param.lookup(rest, next, params); v != nil {
return v
}
params.truncate(mark)
}
if n.catch != nil && n.catch.set {
params.push(n.catchName, rest[i:])
return &n.catch.value
}
return nil
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package s3
import (
"bytes"
"context"
"crypto/md5" //nolint:gosec // Content-MD5 is protocol-mandated.
"encoding/base64"
"encoding/xml"
"fmt"
"io"
"net/http"
"slices"
)
// MaxDeleteBatch is how many keys one DeleteObjects request may name, per
// the S3 protocol. [Bucket.Delete] chunks larger key lists transparently.
const MaxDeleteBatch = 1000
// DeleteResult reports the per-key outcome of a batch deletion.
type DeleteResult struct {
// Deleted lists the keys the provider removed. S3 deletion is
// idempotent, so a key that never existed is reported here too — the
// object is equally gone either way.
Deleted []string
// Errors lists the keys the provider refused, each with its reason.
Errors []DeleteError
}
// DeleteError is one key a batch deletion could not remove.
type DeleteError struct {
// Key is the object key the deletion failed for.
Key string `xml:"Key"`
// Code is the provider's machine-readable error code, such as
// "AccessDenied".
Code string `xml:"Code"`
// Message is the provider's human-readable explanation.
Message string `xml:"Message"`
}
// Error implements the [error] interface.
func (e DeleteError) Error() string {
return fmt.Sprintf("delete of %q failed: %s (%s)", e.Key, e.Message, e.Code)
}
var _ error = DeleteError{}
// The types below mirror the XML shapes of the DeleteObjects call.
// deleteRequest is the manifest naming the keys to remove.
type deleteRequest struct {
XMLName xml.Name `xml:"http://s3.amazonaws.com/doc/2006-03-01/ Delete"`
Objects []deleteObject `xml:"Object"`
// Quiet false asks for the verbose response naming every removed key,
// which is what fills [DeleteResult.Deleted].
Quiet bool `xml:"Quiet"`
}
// deleteObject names one key of the manifest.
type deleteObject struct {
Key string `xml:"Key"`
}
// deleteResponse is the provider's per-key verdict.
type deleteResponse struct {
// XMLName pins the root element: without it, Unmarshal accepts any
// document and matches children by name, so a 200 answer carrying
// something other than a verdict — a top-level <Error> document, say
// — would silently decode into an empty result that reads as "every
// key already gone". Pinned, it fails loudly instead.
XMLName xml.Name `xml:"DeleteResult"`
Deleted []deleteObject `xml:"Deleted"`
Errors []DeleteError `xml:"Error"`
}
// Delete removes the given objects in batches of [MaxDeleteBatch] and
// reports the per-key outcome: removed keys land in
// [DeleteResult.Deleted] — a key that never existed among them, since
// deletion is idempotent — and refused keys in [DeleteResult.Errors]
// with the provider's reason. A per-key refusal is not an error of the
// call: the returned error is reserved for requests that failed as a
// whole, and the result then still covers every batch that completed.
//
// An empty key list is a no-op; an empty key among the given ones
// returns [ErrMissingKey] before anything is deleted.
func (b *Bucket) Delete(
ctx context.Context,
keys []string,
) (DeleteResult, error) {
var res DeleteResult
if slices.Contains(keys, "") {
return res, ErrMissingKey
}
for batch := range slices.Chunk(keys, MaxDeleteBatch) {
if err := b.delete(ctx, batch, &res); err != nil {
return res, err
}
}
return res, nil
}
// delete runs one DeleteObjects request and merges its verdict into the
// result.
func (b *Bucket) delete(
ctx context.Context,
keys []string,
res *DeleteResult,
) error {
manifest := deleteRequest{
Objects: make([]deleteObject, len(keys)),
}
for i, key := range keys {
manifest.Objects[i] = deleteObject{Key: key}
}
body, err := xml.Marshal(manifest)
if err != nil {
return fmt.Errorf("failed to encode manifest: %w", err)
}
// The grant signs UNSIGNED-PAYLOAD, so the manifest itself is
// covered by the Content-MD5 digest the protocol mandates for
// exactly this call.
digest := md5.Sum(body) //nolint:gosec // See above.
// The delete subresource addresses the bucket itself, so this is the
// one URL minted beside [Bucket.URL]'s object URLs.
u := *b.base
u.RawQuery = "delete="
signed, err := b.signer.Presign(http.MethodPost, &u, grantExpiry, nil)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(
ctx, http.MethodPost, signed, bytes.NewReader(body),
)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set(
"Content-MD5", base64.StdEncoding.EncodeToString(digest[:]),
)
req.Header.Set("Content-Type", "application/xml")
raw, err := b.do(req)
if err != nil {
return err
}
var verdict deleteResponse
if err := xml.Unmarshal(raw, &verdict); err != nil {
return fmt.Errorf("malformed delete result: %w", err)
}
for _, d := range verdict.Deleted {
res.Deleted = append(res.Deleted, d.Key)
}
res.Errors = append(res.Errors, verdict.Errors...)
return nil
}
// do dispatches the request and returns the body of a 200 answer; any
// other status is an [*APIError].
func (b *Bucket) do(req *http.Request) ([]byte, error) {
res, err := b.client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer func() {
_, _ = io.Copy(io.Discard, res.Body)
_ = res.Body.Close()
}()
if res.StatusCode != http.StatusOK {
return nil, &APIError{Status: res.StatusCode}
}
// The client caps response body size, so this read is bounded.
raw, err := io.ReadAll(res.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
return raw, nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package s3
import "net/http"
// Option configures a [Bucket].
type Option func(*Bucket)
// WithClient sets the [http.Client] used for outbound API requests.
// Defaults to [transport.DefaultClient]. Nil values are ignored.
func WithClient(client *http.Client) Option {
return func(b *Bucket) {
if client != nil {
b.client = client
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package s3
import (
"context"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/deep-rent/nexus/net/aws4"
"github.com/deep-rent/nexus/net/transport"
)
// The checksum headers of the S3 protocol. A client PUT carrying
// [HeaderChecksumSHA256] makes the provider hash the arriving body and
// refuse storage on mismatch; a HEAD carrying [HeaderChecksumMode] set to
// "ENABLED" reads the stored digest back. Exported so callers building
// grants speak the same vocabulary as [Bucket.Head].
const (
// HeaderChecksumSHA256 carries a base64-encoded SHA-256 digest of the
// object's content.
HeaderChecksumSHA256 = "x-amz-checksum-sha256"
// HeaderChecksumMode, set to "ENABLED" on a HEAD request, asks the
// provider to include the stored checksum in its answer.
HeaderChecksumMode = "x-amz-checksum-mode"
)
// ErrMissingKey is returned when an operation names no object key.
var ErrMissingKey = errors.New("object key is needed")
// grantExpiry bounds the lifetime of the presigned URLs the bucket
// mints for its own calls ([Bucket.Head], [Bucket.Delete]). Each URL
// exists for exactly one immediate request, so a minute leaves room for
// clock skew and nothing more.
const grantExpiry = time.Minute
// APIError represents an unexpected answer from the provider.
type APIError struct {
// Status is the HTTP status code returned by the provider.
Status int
}
// Error implements the [error] interface.
func (e *APIError) Error() string {
return fmt.Sprintf("s3 returned status %d", e.Status)
}
var _ error = (*APIError)(nil)
// Object is the metadata a HeadObject call reveals about a stored object.
type Object struct {
// Key is the object key within the bucket.
Key string
// Size is the object's size in bytes.
Size int64
// ETag is the object's entity tag without the surrounding quotes. For
// a single-part upload it is conventionally the hex MD5 of the
// content; a multipart upload carries a "-N" suffix instead and no
// longer hashes the whole content. For integrity checks prefer
// SHA256, which is a contract rather than a convention.
ETag string
// SHA256 is the stored object's full-content SHA-256 digest in hex,
// as attested by the provider — the digest it computed itself while
// receiving the upload, not a client claim. Empty when the provider
// records none (the upload announced no checksum, or the provider
// predates the feature) and for composite multipart checksums, which
// hash nothing comparable.
SHA256 string
// ContentType is the MIME type the object was stored under.
ContentType string
// LastModified is when the object was last written, per the provider.
// Zero when the provider sent none.
LastModified time.Time
}
// Bucket binds one bucket's base URL to the signer and HTTP client its
// operations run through. It speaks object keys, not URLs: the caller
// authorizes a key — a prefix per user, say — and the bucket derives the
// URL from it, so there is no raw string between the check and the
// operation. It is immutable after construction and safe for concurrent
// use.
type Bucket struct {
base *url.URL
signer *aws4.Signer
client *http.Client
}
// New creates a [Bucket] rooted at the given base URL, which addresses
// the bucket itself — virtual-hosted style ("https://bucket.endpoint") or
// path style ("https://endpoint/bucket") alike, since keys simply append
// to its path. The base must be an absolute http(s) URL without query or
// fragment.
//
// It panics on a malformed base URL or a nil signer, since those are
// startup configuration errors.
func New(baseURL string, signer *aws4.Signer, opts ...Option) *Bucket {
if signer == nil {
panic("signer is required")
}
base, err := url.Parse(baseURL)
if err != nil {
panic(fmt.Errorf("malformed base URL: %w", err))
}
if (base.Scheme != "http" && base.Scheme != "https") ||
base.Host == "" || base.RawQuery != "" || base.Fragment != "" {
panic(fmt.Errorf(
"base URL %q is not a bare absolute http(s) URL", baseURL,
))
}
b := &Bucket{
base: base,
signer: signer,
client: transport.DefaultClient,
}
for _, opt := range opts {
opt(b)
}
return b
}
// URL returns the object's URL under the bucket: the base with the
// decoded key appended to its path. The result is a fresh value the
// caller may inspect or pass to [aws4.Signer.Presign] directly.
func (b *Bucket) URL(key string) *url.URL {
u := *b.base
u.Path = strings.TrimSuffix(b.base.Path, "/") + "/" + key
return &u
}
// Presign returns a presigned URL granting the given method on the given
// object until the expiry elapses; see [aws4.Signer.Presign] for the
// grant's semantics, including how the optionally signed headers turn
// into constraints the provider enforces — sign [HeaderChecksumSHA256]
// and Content-Length on a PUT, and no body but the announced one is ever
// stored. An empty key returns [ErrMissingKey] — presigning a whole
// bucket is never what a caller meant.
func (b *Bucket) Presign(
method, key string,
expires time.Duration,
signed http.Header,
) (string, error) {
if key == "" {
return "", ErrMissingKey
}
return b.signer.Presign(method, b.URL(key), expires, signed)
}
// Head performs a HeadObject call: it reports whether the object exists
// and, if so, the metadata the provider stores about it. A missing object
// is not an error — it returns nil, nil — since asking is the point.
//
// The call authenticates itself with a short-lived presigned HEAD URL, so
// it runs on the same signature machinery as every grant this package
// mints. It always asks for the stored checksum ([HeaderChecksumMode]);
// providers without the feature simply answer without one, leaving
// [Object.SHA256] empty. Note that a provider may answer 403 rather than
// 404 for a missing object when the credentials lack list permission on
// the bucket; that surfaces as an [*APIError], not as absence, so a
// deployment should grant its verifier credentials read access to the
// keys it checks.
func (b *Bucket) Head(ctx context.Context, key string) (*Object, error) {
if key == "" {
return nil, ErrMissingKey
}
// The mode header must be signed: providers reject an unsigned
// x-amz-* header on a presigned request as a signature mismatch.
mode := http.Header{HeaderChecksumMode: []string{"ENABLED"}}
signed, err := b.signer.Presign(
http.MethodHead, b.URL(key), grantExpiry, mode,
)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(
ctx, http.MethodHead, signed, nil,
)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set(HeaderChecksumMode, "ENABLED")
res, err := b.client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer func() {
// A HEAD response has no body, but drain defensively so the
// connection returns to the pool no matter what was sent.
_, _ = io.Copy(io.Discard, res.Body)
_ = res.Body.Close()
}()
switch {
case res.StatusCode == http.StatusNotFound:
return nil, nil
case res.StatusCode != http.StatusOK:
return nil, &APIError{Status: res.StatusCode}
}
obj := &Object{
Key: key,
Size: res.ContentLength,
ETag: strings.Trim(res.Header.Get("ETag"), `"`),
SHA256: checksum(res.Header.Get(HeaderChecksumSHA256)),
ContentType: res.Header.Get("Content-Type"),
}
if v := res.Header.Get("Last-Modified"); v != "" {
if t, err := http.ParseTime(v); err == nil {
obj.LastModified = t
}
}
return obj, nil
}
// checksum normalizes a provider-attested SHA-256 checksum header into
// lowercase hex, or "" when there is no usable full-content digest: no
// header, a composite multipart value (suffixed "-N", hashing nothing
// comparable), or a malformed encoding — an attestation that cannot be
// decoded attests nothing.
func checksum(v string) string {
if v == "" || strings.Contains(v, "-") {
return ""
}
sum, err := base64.StdEncoding.DecodeString(v)
if err != nil || len(sum) != 32 {
return ""
}
return hex.EncodeToString(sum)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package throttle
import (
"slices"
"strings"
"time"
"github.com/deep-rent/nexus/std/sketch/topk"
)
// boardSlack sizes the internal boards beyond the reported capacity.
// Tracking a few times more keys than reported tightens the top-k
// error bounds and keeps borderline keys from churning off the board;
// entries are small, so the slack costs next to nothing.
const boardSlack = 4
// An Offender is one entry of the leaderboard kept when
// [Config.Offenders] is set: a key and the token charges attributed to
// it within the sliding window.
type Offender struct {
// Key is the bucket key, exactly as charged.
Key string
// Charges is the estimated number of tokens the key attempted to
// spend within the window, penalties included. For a key charging
// steadily it is exact or a bounded overcount; charges made in
// the older half of the window while the key was still too light
// to track may go unattributed.
Charges uint64
}
// offenders tracks the heaviest keys by charge volume on two top-k
// boards ([topk]) rotating every half window, so a listing covers
// between half a window and a full one — recent pressure, not ancient
// history. Keys charging more than their board's guarantee threshold
// are reliably listed; memory is a few dozen entries per board however
// many keys pass through.
//
// The Throttle's mutex guards all access.
type offenders struct {
cap int // reported leaderboard capacity
window time.Duration // total sliding window
curr *topk.Sketch
prev *topk.Sketch
turned time.Time // when curr last rotated
}
// newOffenders builds a tracker for the given leaderboard capacity.
func newOffenders(cap int, window time.Duration, now time.Time) *offenders {
return &offenders{
cap: cap,
window: window,
curr: topk.New(cap * boardSlack),
prev: topk.New(cap * boardSlack),
turned: now,
}
}
// rotate expires charges the clock has moved past.
func (o *offenders) rotate(now time.Time) {
elapsed := now.Sub(o.turned)
switch {
case elapsed >= o.window:
// The whole window expired; both boards restart.
o.curr = topk.New(o.cap * boardSlack)
o.prev = topk.New(o.cap * boardSlack)
case elapsed >= o.window/2:
o.prev = o.curr
o.curr = topk.New(o.cap * boardSlack)
default:
return
}
o.turned = now
}
// track records a charge of n tokens against a key.
func (o *offenders) track(key string, n uint64, now time.Time) {
o.rotate(now)
o.curr.Add(key, n)
}
// list folds the two boards into one window view, ordered by weight
// and truncated to the reported capacity.
func (o *offenders) list(now time.Time) []Offender {
o.rotate(now)
weights := make(map[string]uint64)
for _, e := range o.curr.List() {
weights[e.Key] += e.Count
}
for _, e := range o.prev.List() {
weights[e.Key] += e.Count
}
board := make([]Offender, 0, len(weights))
for key, charges := range weights {
board = append(board, Offender{Key: key, Charges: charges})
}
slices.SortFunc(board, func(a, b Offender) int {
switch {
case a.Charges > b.Charges:
return -1
case a.Charges < b.Charges:
return 1
default:
return strings.Compare(a.Key, b.Key)
}
})
if len(board) > o.cap {
board = board[:o.cap]
}
return board
}
// track feeds the offender tracker, when one is configured. Callers
// must not hold the mutex.
func (t *Throttle) track(key string, n uint64) {
if t.off == nil {
return
}
now := t.now()
t.mu.Lock()
defer t.mu.Unlock()
t.off.track(key, n, now)
}
// Offenders returns the keys charging the most tokens within the
// configured window — attempts and penalties both, spent or refused —
// heaviest first. It returns nil unless [Config.Offenders] enabled
// tracking.
//
// The list answers "who is eating the allowance" without keeping a
// counter per key: memory stays a few dozen entries however many keys
// pass through. Any key charging more than its share of the window's
// volume is reliably listed (the top-k guarantee), listed weights
// never undercount what the key charged while tracked, and a key that
// goes quiet ages out within the window.
func (t *Throttle) Offenders() []Offender {
if t.off == nil {
return nil
}
now := t.now()
t.mu.Lock()
defer t.mu.Unlock()
return t.off.list(now)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package throttle
import (
"net"
"net/http"
"strconv"
"sync"
"time"
"golang.org/x/time/rate"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/sys/metrics"
)
// sweepInterval bounds how often idle buckets are evicted.
const sweepInterval = time.Minute
// Names of the counters recorded by a [Throttle], tagged with the instance
// name from [Config.Name].
const (
// Decisions counts AllowN outcomes, split by the "allowed" tag. Note
// that requests rejected by [Throttle.Middleware] reserve on the
// buckets directly and do not pass through here; they surface as 429s
// in the HTTP server metrics instead.
Decisions = "throttle_decisions_total"
// Penalties counts Penalize charges.
Penalties = "throttle_penalties_total"
)
// Throttle is a set of token buckets keyed by opaque strings.
//
// Each key recovers its allowance at the configured rate up to the configured
// burst. Spending a token that is not available fails; charging a penalty
// pushes a bucket into deficit, so further actions stay blocked until it
// recovers. Buckets whose allowance has fully recovered are indistinguishable
// from new ones and are evicted over time to bound memory.
//
// A Throttle is safe for concurrent use.
type Throttle struct {
limit rate.Limit
burst int
key func(*http.Request) string
now clock.Clock
accepted *metrics.Counter // AllowN spends that succeeded
rejected *metrics.Counter // AllowN spends that were rate limited
penalties *metrics.Counter // Penalize charges
mu sync.Mutex
buckets map[string]*rate.Limiter
swept time.Time
off *offenders // heavy-hitter tracking; nil when disabled
}
// New assembles a [Throttle] from the given configuration. It panics if the
// resolved rate or burst is not positive.
func New(cfg Config) *Throttle {
limit := cfg.Limit
if limit == 0 {
limit = DefaultLimit
}
burst := cfg.Burst
if burst == 0 {
burst = DefaultBurst
}
switch {
case limit <= 0:
panic("limit must be positive")
case burst <= 0:
panic("burst must be positive")
case cfg.Offenders < 0:
panic("offender capacity must not be negative")
case cfg.OffenderWindow < 0:
panic("offender window must not be negative")
}
key := cfg.Key
if key == nil {
key = RemoteAddr
}
now := cfg.Clock
if now == nil {
now = clock.System
}
reg := cfg.Registry
if reg == nil {
reg = metrics.DefaultRegistry
}
name := metrics.T("name", cfg.Name)
var off *offenders
if cfg.Offenders > 0 {
window := cfg.OffenderWindow
if window == 0 {
window = DefaultOffenderWindow
}
off = newOffenders(cfg.Offenders, window, now())
}
return &Throttle{
limit: limit,
burst: burst,
key: key,
now: now,
accepted: reg.Counter(Decisions,
name, metrics.T("allowed", "true")),
rejected: reg.Counter(Decisions,
name, metrics.T("allowed", "false")),
penalties: reg.Counter(Penalties, name),
buckets: make(map[string]*rate.Limiter),
swept: now(),
off: off,
}
}
// RemoteAddr derives a key from the remote address of the request's TCP
// connection, stripping the port so that all connections from one host share
// a bucket. It is the default for [Config.Key].
func RemoteAddr(r *http.Request) string {
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
// limiter returns the bucket for the given key, creating a full one on first
// use. It opportunistically evicts recovered buckets to bound memory.
func (t *Throttle) limiter(key string, now time.Time) *rate.Limiter {
t.mu.Lock()
defer t.mu.Unlock()
if now.Sub(t.swept) >= sweepInterval {
t.sweep(now)
}
l, ok := t.buckets[key]
if !ok {
l = rate.NewLimiter(t.limit, t.burst)
t.buckets[key] = l
}
return l
}
// sweep drops every bucket whose allowance has fully recovered. Such buckets
// are indistinguishable from freshly created ones, so discarding them loses
// no state; buckets still carrying a deficit are retained. The caller must
// hold the mutex.
func (t *Throttle) sweep(now time.Time) {
for key, l := range t.buckets {
if l.TokensAt(now) >= float64(t.burst) {
delete(t.buckets, key)
}
}
t.swept = now
}
// Allow spends a single token from the given key's bucket, reporting whether
// one was available. A false result means the key is currently rate limited.
func (t *Throttle) Allow(key string) bool {
return t.AllowN(key, 1)
}
// AllowN spends the requested number of tokens from the given key's bucket if
// that many are available, reporting whether the spend succeeded. If fewer
// tokens are available than requested, nothing is spent and it returns false.
// A non-positive count spends nothing and returns true.
//
// Every call is counted under [Decisions] with an "allowed" tag.
func (t *Throttle) AllowN(key string, n int) bool {
if n <= 0 {
return true
}
t.track(key, uint64(n))
now := t.now()
ok := t.limiter(key, now).AllowN(now, n)
if ok {
t.accepted.Inc()
} else {
t.rejected.Inc()
}
return ok
}
// Blocked reports whether the given key has exhausted its allowance, along
// with the duration until the next token is available. It does not spend any
// allowance itself, so it is safe to call before deciding how to respond.
func (t *Throttle) Blocked(key string) (bool, time.Duration) {
now := t.now()
tokens := t.limiter(key, now).TokensAt(now)
if tokens >= 1 {
return false, 0
}
wait := (1 - tokens) / float64(t.limit)
return true, time.Duration(wait * float64(time.Second))
}
// Penalize charges extra tokens against the given key, over and above any
// spent by [Throttle.Allow]. Charging a key that is already exhausted pushes
// it further into deficit, extending how long it stays blocked.
//
// Use it to make an unwanted outcome cost more than an ordinary request: a
// failed authentication attempt, an oversized upload, a cache miss that hit
// the origin. A non-positive charge does nothing. A single call charges at
// most [Config.Burst] tokens, since a bucket cannot be driven more than a
// full burst into deficit at once.
func (t *Throttle) Penalize(key string, tokens int) {
if tokens <= 0 {
return
}
if tokens > t.burst {
tokens = t.burst
}
t.track(key, uint64(tokens))
now := t.now()
t.limiter(key, now).ReserveN(now, tokens)
t.penalties.Inc()
}
// Reset restores the full allowance of the given key, discarding any deficit
// it had accrued. Use it once a caller has proven legitimate — a correct
// credential, a completed challenge — so that earlier penalties do not hold
// them back.
func (t *Throttle) Reset(key string) {
t.mu.Lock()
defer t.mu.Unlock()
delete(t.buckets, key)
}
// Middleware returns a [router.Middleware] that spends one token per request
// from the bucket of the key derived by [Config.Key], rejecting the request
// with status 429 and a Retry-After header once the bucket is empty.
//
// It is the one-line way to limit a route by client address. Handlers that
// need to charge only failed attempts, or to key by something other than the
// address, should use the keyed methods directly instead.
func (t *Throttle) Middleware() router.Middleware {
return t.MiddlewareFunc(t.key)
}
// MiddlewareFunc is [Throttle.Middleware] with an explicit key function,
// letting a caller limit by something other than [Config.Key] — a header, a
// route parameter, a namespaced address — while sharing the same buckets as
// its keyed calls. The buckets it spends from are exactly those addressed by
// the string the given function returns, so a handler can later penalize or
// inspect the same key.
func (t *Throttle) MiddlewareFunc(
key func(*http.Request) string,
) router.Middleware {
return router.RateLimitFunc(func(r *http.Request) *rate.Limiter {
k := key(r)
t.track(k, 1)
return t.limiter(k, t.now())
})
}
// RetryAfter writes the Retry-After header on the given header set for the
// given wait duration, rounded up to whole seconds as required by RFC 9110
// Section 10.2.3. A non-positive duration writes nothing. It pairs with the
// duration returned by [Throttle.Blocked].
func RetryAfter(h http.Header, wait time.Duration) {
if wait <= 0 {
return
}
sec := int((wait + time.Second - 1) / time.Second)
h.Set("Retry-After", strconv.Itoa(sec))
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package transport
import (
"errors"
"io"
"net/http"
)
// DefaultMaxResponseBytes specifies the default limit on the size of a response
// body. It is deliberately generous; endpoints returning bulk payloads should
// raise it explicitly via [WithMaxResponseBytes].
const DefaultMaxResponseBytes = 1 << 20 // 1 MB
// ErrBodyTooLarge is returned by reads from a capped response body once the
// configured limit is exceeded.
//
// It is deliberately distinct from [io.EOF] so that a truncated payload can
// never be mistaken for a complete one: decoders that treat [io.EOF] as a
// clean end of input surface this error instead.
var ErrBodyTooLarge = errors.New("response body too large")
// Limit returns an [http.RoundTripper] that caps the size of every response
// body produced by the wrapped transport at the given number of bytes.
//
// Reads up to and including the limit behave normally. A body that carries
// even one byte beyond the limit fails the read with [ErrBodyTooLarge] rather
// than reporting a short but seemingly complete body. Closing the capped body
// closes the underlying one.
//
// A nonpositive limit disables the cap, in which case the wrapped transport
// is returned unchanged.
func Limit(next http.RoundTripper, max int64) http.RoundTripper {
if max <= 0 {
return next
}
return &limitTransport{next: next, max: max}
}
// limitTransport caps the response bodies returned by next.
type limitTransport struct {
// next is the wrapped round tripper.
next http.RoundTripper
// max is the maximum number of body bytes to admit.
max int64
}
// RoundTrip implements [http.RoundTripper].
func (t *limitTransport) RoundTrip(req *http.Request) (*http.Response, error) {
res, err := t.next.RoundTrip(req)
if err != nil || res == nil || res.Body == nil {
return res, err
}
res.Body = &limitReader{
body: res.Body,
left: t.max,
}
return res, nil
}
var _ http.RoundTripper = (*limitTransport)(nil)
// limitReader wraps a response body and fails once more than left bytes have
// been read from it.
type limitReader struct {
// body is the wrapped response body.
body io.ReadCloser
// left counts the bytes still admissible. It drops to -1 once the limit
// has been exceeded.
left int64
}
// Read implements [io.Reader]. It reads at most one byte beyond the remaining
// allowance in order to distinguish a body that ends exactly at the limit from
// one that overruns it.
func (r *limitReader) Read(p []byte) (int, error) {
if r.left < 0 {
return 0, ErrBodyTooLarge
}
if int64(len(p)) > r.left+1 {
p = p[:r.left+1]
}
n, err := r.body.Read(p)
if int64(n) <= r.left {
r.left -= int64(n)
return n, err
}
// The extra byte was consumed, so the body overruns the limit. Hand back
// only the admissible prefix and fail this and every subsequent read.
n = int(r.left)
r.left = -1
return n, ErrBodyTooLarge
}
// Close implements [io.Closer].
func (r *limitReader) Close() error { return r.body.Close() }
var _ io.ReadCloser = (*limitReader)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package transport
import (
"net/http"
"strconv"
"time"
"github.com/deep-rent/nexus/sys/metrics"
)
// RequestDuration is the name of the summary recorded by the metrics
// transport; see [WithMetrics].
const RequestDuration = "http_client_request_duration_seconds"
// metricsTransport wraps an underlying [http.RoundTripper] with request
// measurement.
type metricsTransport struct {
next http.RoundTripper
registry *metrics.Registry
}
// NewMetricsTransport wraps a transport so that every round trip is recorded
// in the [RequestDuration] summary, tagged with the request method, the
// target host, and the response status code — or "error" when the exchange
// failed without a response.
//
// When layered below a [retry.NewTransport] — the placement chosen by
// [WithMetrics] — each retry attempt is recorded as its own observation, so
// the summary reflects wire activity rather than logical requests.
func NewMetricsTransport(
next http.RoundTripper,
opts ...MetricsOption,
) http.RoundTripper {
cfg := metricsConfig{
registry: metrics.DefaultRegistry,
}
for _, opt := range opts {
opt(&cfg)
}
return &metricsTransport{
next: next,
registry: cfg.registry,
}
}
// RoundTrip executes a single HTTP transaction and records its duration.
func (t *metricsTransport) RoundTrip(
req *http.Request,
) (*http.Response, error) {
start := time.Now()
res, err := t.next.RoundTrip(req)
status := "error"
if err == nil {
status = strconv.Itoa(res.StatusCode)
}
t.registry.Summary(RequestDuration, nil, 0,
metrics.T("method", req.Method),
metrics.T("host", req.URL.Hostname()),
metrics.T("status", status),
).Observe(time.Since(start).Seconds())
return res, err
}
var _ http.RoundTripper = (*metricsTransport)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package transport
import (
"crypto/tls"
"net/http"
"time"
"github.com/deep-rent/nexus/net/header"
"github.com/deep-rent/nexus/net/retry"
"github.com/deep-rent/nexus/sys/metrics"
)
// DefaultTimeout specifies the default overall timeout for HTTP clients.
const DefaultTimeout = 5 * time.Second
// DefaultDialTimeout is the maximum amount of time a dial will wait for
// a connect to complete.
const DefaultDialTimeout = 2 * time.Second
// DefaultKeepAlive specifies the interval between keep-alive probes for an
// active network connection.
const DefaultKeepAlive = 30 * time.Second
// DefaultTLSHandshakeTimeout specifies the maximum amount of time to wait for
// a TLS handshake.
const DefaultTLSHandshakeTimeout = 2 * time.Second
// DefaultMaxIdleConns specifies the maximum number of idle (keep-alive)
// connections across all hosts.
const DefaultMaxIdleConns = 1024
// DefaultMaxIdleConnsPerHost specifies the maximum number of idle (keep-alive)
// connections per host.
const DefaultMaxIdleConnsPerHost = 1024
// DefaultIdleConnTimeout specifies the maximum amount of time an idle
// (keep-alive) connection will remain idle before closing itself.
const DefaultIdleConnTimeout = 90 * time.Second
// DefaultExpectContinueTimeout specifies the amount of time to wait for
// a server's first response headers after fully writing the request headers if
// the request has an "Expect: 100-continue" header.
const DefaultExpectContinueTimeout = 1 * time.Second
// DefaultForceAttemptHTTP2 specifies whether to attempt HTTP/2 by default.
const DefaultForceAttemptHTTP2 = true
// DefaultMaxConnsPerHost optionally limits the total number of connections per
// host.
const DefaultMaxConnsPerHost = 1024
// DefaultResponseHeaderTimeout specifies the amount of time to wait for a
// server's response headers.
const DefaultResponseHeaderTimeout = 0
// DefaultMaxResponseHeaderBytes specifies a limit on how many response bytes
// are allowed in the server's response header.
const DefaultMaxResponseHeaderBytes = 64 * 1024 // 64 KB
// DefaultWriteBufferSize specifies the size of the write buffer used.
const DefaultWriteBufferSize = 4 * 1024 // 4 KB
// DefaultReadBufferSize specifies the size of the read buffer used.
const DefaultReadBufferSize = 4 * 1024 // 4 KB
// config is used to hold the configuration for a [Transport].
type config struct {
dialTimeout time.Duration
keepAlive time.Duration
tlsHandshakeTimeout time.Duration
expectContinueTimeout time.Duration
idleConnTimeout time.Duration
tlsConfig *tls.Config
disableKeepAlives bool
forceAttemptHTTP2 bool
disableCompression bool
headers []header.Header
retry []retry.Option
metrics bool
metricsOpts []MetricsOption
maxIdleConns int
maxIdleConnsPerHost int
maxConnsPerHost int
responseHeaderTimeout time.Duration
maxResponseHeaderBytes int64
maxResponseBytes int64
writeBufferSize int
readBufferSize int
http2Config *http.HTTP2Config
protocols *http.Protocols
proxy Proxy
dialer Dialer
}
// Option configures an [http.Transport] via [New].
type Option func(*config)
// WithDialTimeout specifies the maximum amount of time a dial will wait for
// a connect to complete. Defaults to [DefaultDialTimeout].
// Negative values are ignored.
func WithDialTimeout(d time.Duration) Option {
return func(c *config) {
if d >= 0 {
c.dialTimeout = d
}
}
}
// WithKeepAlive specifies the interval between keep-alive probes for an
// active network connection. Defaults to [DefaultKeepAlive].
// Negative values are ignored.
func WithKeepAlive(d time.Duration) Option {
return func(c *config) {
if d >= 0 {
c.keepAlive = d
}
}
}
// WithTLSHandshakeTimeout specifies the maximum amount of time to wait for a
// TLS handshake. Defaults to [DefaultTLSHandshakeTimeout].
// Negative values are ignored.
func WithTLSHandshakeTimeout(d time.Duration) Option {
return func(c *config) {
if d >= 0 {
c.tlsHandshakeTimeout = d
}
}
}
// WithExpectContinueTimeout specifies the amount of time to wait for
// a server's first response headers after fully writing the request headers if
// the request has an "Expect: 100-continue" header. Defaults to
// [DefaultExpectContinueTimeout].
// Negative values are ignored.
func WithExpectContinueTimeout(d time.Duration) Option {
return func(c *config) {
if d >= 0 {
c.expectContinueTimeout = d
}
}
}
// WithIdleConnTimeout specifies the maximum amount of time an idle
// (keep-alive) connection will remain idle before closing itself.
// Defaults to [DefaultIdleConnTimeout]. Negative values are ignored.
func WithIdleConnTimeout(d time.Duration) Option {
return func(c *config) {
if d >= 0 {
c.idleConnTimeout = d
}
}
}
// WithTLSConfig sets the TLS configuration for the transport.
func WithTLSConfig(cfg *tls.Config) Option {
return func(c *config) {
if cfg != nil {
c.tlsConfig = cfg.Clone()
}
}
}
// WithDisableKeepAlives disables HTTP keep-alives.
func WithDisableKeepAlives(disabled bool) Option {
return func(c *config) { c.disableKeepAlives = disabled }
}
// WithForceAttemptHTTP2 enforces HTTP/2 support.
func WithForceAttemptHTTP2(force bool) Option {
return func(c *config) { c.forceAttemptHTTP2 = force }
}
// WithDisableCompression prevents the underlying [http.Transport] from
// requesting compression.
func WithDisableCompression(disable bool) Option {
return func(c *config) { c.disableCompression = disable }
}
// WithResponseHeaderTimeout specifies the amount of time to wait for a server's
// response headers.
func WithResponseHeaderTimeout(d time.Duration) Option {
return func(c *config) {
if d >= 0 {
c.responseHeaderTimeout = d
}
}
}
// WithMaxResponseHeaderBytes specifies a limit on how many response bytes are
// allowed in the server's response header.
func WithMaxResponseHeaderBytes(max int64) Option {
return func(c *config) {
if max >= 0 {
c.maxResponseHeaderBytes = max
}
}
}
// WithMaxResponseBytes caps the size of response bodies. Reading a body beyond
// the limit fails with [ErrBodyTooLarge]. Defaults to
// [DefaultMaxResponseBytes]. Nonpositive values disable the limit.
func WithMaxResponseBytes(max int64) Option {
return func(c *config) { c.maxResponseBytes = max }
}
// WithWriteBufferSize specifies the size of the write buffer used.
func WithWriteBufferSize(size int) Option {
return func(c *config) {
if size >= 0 {
c.writeBufferSize = size
}
}
}
// WithReadBufferSize specifies the size of the read buffer used.
func WithReadBufferSize(size int) Option {
return func(c *config) {
if size >= 0 {
c.readBufferSize = size
}
}
}
// WithHTTP2Config configures HTTP/2 connections.
func WithHTTP2Config(cfg *http.HTTP2Config) Option {
return func(c *config) { c.http2Config = cfg }
}
// WithProtocols specifies the set of protocols supported by the transport.
func WithProtocols(protocols *http.Protocols) Option {
return func(c *config) { c.protocols = protocols }
}
// WithProxy defines a custom proxy function. Passing nil to [http.ProxyURL]
// disables proxying.
func WithProxy(proxy Proxy) Option {
return func(c *config) { c.proxy = proxy }
}
// WithDialContext overrides the default [net.Dialer].
func WithDialContext(dialer Dialer) Option {
return func(c *config) { c.dialer = dialer }
}
// WithHeader defines static headers applied to every request.
func WithHeader(h ...header.Header) Option {
return func(c *config) { c.headers = append(c.headers, h...) }
}
// WithUserAgent defines the User-Agent header applied to every request.
func WithUserAgent(v string) Option {
return WithHeader(header.New("User-Agent", v))
}
// WithRetry configures the HTTP retry mechanism.
func WithRetry(opts ...retry.Option) Option {
return func(c *config) { c.retry = append(c.retry, opts...) }
}
// WithMetrics enables client request measurement; see [NewMetricsTransport]
// for what is recorded.
//
// The measuring layer sits below the retry and header layers, so every
// retry attempt is captured as its own observation.
func WithMetrics(opts ...MetricsOption) Option {
return func(c *config) {
c.metrics = true
c.metricsOpts = append(c.metricsOpts, opts...)
}
}
// WithMaxIdleConns configures the maximum number of idle (keep-alive)
// connections across all hosts. Defaults to [DefaultMaxIdleConns].
// Negative values are ignored.
func WithMaxIdleConns(max int) Option {
return func(c *config) {
if max >= 0 {
c.maxIdleConns = max
}
}
}
// WithMaxIdleConnsPerHost configures the maximum number of idle (keep-alive)
// connections per host. Defaults to [DefaultMaxIdleConnsPerHost].
// Negative values are ignored.
func WithMaxIdleConnsPerHost(max int) Option {
return func(c *config) {
if max >= 0 {
c.maxIdleConnsPerHost = max
}
}
}
// WithMaxConnsPerHost optionally limits the total number of connections per
// host.
// Negative values are ignored.
func WithMaxConnsPerHost(max int) Option {
return func(c *config) {
if max >= 0 {
c.maxConnsPerHost = max
}
}
}
// metricsConfig holds the configuration for a metrics transport.
type metricsConfig struct {
registry *metrics.Registry
}
// MetricsOption configures a metrics transport created by
// [NewMetricsTransport] or enabled via [WithMetrics].
type MetricsOption func(*metricsConfig)
// WithRegistry sets the destination registry. It defaults to
// [metrics.DefaultRegistry]. A nil value is ignored.
func WithRegistry(reg *metrics.Registry) MetricsOption {
return func(c *metricsConfig) {
if reg != nil {
c.registry = reg
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package transport
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"net"
"net/http"
"net/url"
"os"
"sync"
"time"
"github.com/deep-rent/nexus/net/header"
"github.com/deep-rent/nexus/net/retry"
)
// Proxy defines a custom proxy function.
type Proxy func(*http.Request) (*url.URL, error)
// Dialer defines a custom dial function for creating network connections.
type Dialer func(ctx context.Context, network, addr string) (net.Conn, error)
// New creates a new [http.RoundTripper] configured with the provided options.
func New(opts ...Option) http.RoundTripper {
cfg := config{
dialTimeout: DefaultDialTimeout,
keepAlive: DefaultKeepAlive,
tlsHandshakeTimeout: DefaultTLSHandshakeTimeout,
expectContinueTimeout: DefaultExpectContinueTimeout,
idleConnTimeout: DefaultIdleConnTimeout,
maxIdleConns: DefaultMaxIdleConns,
maxIdleConnsPerHost: DefaultMaxIdleConnsPerHost,
forceAttemptHTTP2: DefaultForceAttemptHTTP2,
maxConnsPerHost: DefaultMaxConnsPerHost,
responseHeaderTimeout: DefaultResponseHeaderTimeout,
maxResponseHeaderBytes: DefaultMaxResponseHeaderBytes,
maxResponseBytes: DefaultMaxResponseBytes,
writeBufferSize: DefaultWriteBufferSize,
readBufferSize: DefaultReadBufferSize,
}
for _, opt := range opts {
opt(&cfg)
}
d := &net.Dialer{
Timeout: cfg.dialTimeout,
KeepAlive: cfg.keepAlive,
}
if cfg.disableKeepAlives {
d.KeepAlive = -1
}
proxy := http.ProxyFromEnvironment
if cfg.proxy != nil {
proxy = cfg.proxy
}
dialContext := d.DialContext
if cfg.dialer != nil {
dialContext = cfg.dialer
}
var t http.RoundTripper = &http.Transport{
Proxy: proxy,
DialContext: dialContext,
ForceAttemptHTTP2: cfg.forceAttemptHTTP2,
TLSClientConfig: cfg.tlsConfig,
TLSHandshakeTimeout: cfg.tlsHandshakeTimeout,
ExpectContinueTimeout: cfg.expectContinueTimeout,
MaxIdleConns: cfg.maxIdleConns,
MaxIdleConnsPerHost: cfg.maxIdleConnsPerHost,
MaxConnsPerHost: cfg.maxConnsPerHost,
IdleConnTimeout: cfg.idleConnTimeout,
DisableKeepAlives: cfg.disableKeepAlives,
DisableCompression: cfg.disableCompression,
ResponseHeaderTimeout: cfg.responseHeaderTimeout,
MaxResponseHeaderBytes: cfg.maxResponseHeaderBytes,
WriteBufferSize: cfg.writeBufferSize,
ReadBufferSize: cfg.readBufferSize,
HTTP2: cfg.http2Config,
Protocols: cfg.protocols,
}
// Cap response bodies first so that the limit also applies to the
// intermediate responses observed by the retry transport.
t = Limit(t, cfg.maxResponseBytes)
// The measuring layer sits below retry and header, so that each attempt
// is recorded as its own observation.
if cfg.metrics {
t = NewMetricsTransport(t, cfg.metricsOpts...)
}
// Add headers if any.
if len(cfg.headers) > 0 {
t = header.NewTransport(t, cfg.headers...)
}
// Enable retries if specified.
if len(cfg.retry) > 0 {
t = retry.NewTransport(t, cfg.retry...)
}
return t
}
// DefaultClient is the client used by packages in this module when the caller
// does not supply one of their own. Unlike [http.DefaultClient] it carries
// standard connection hygiene, most importantly an overall [DefaultTimeout]
// and a [DefaultMaxResponseBytes] cap on response bodies.
//
// Consumers that read response bodies may therefore rely on those bodies being
// bounded without applying their own [io.LimitReader]. Callers who pass a
// custom client are responsible for that guarantee themselves; a transport
// from [New] preserves it.
//
// It is shared, so its connection pool is shared too. Do not mutate it. A
// caller needing different settings builds its own client around [New]:
//
// client := &http.Client{
// Timeout: transport.DefaultTimeout,
// Transport: transport.New(transport.WithDisableKeepAlives(true)),
// }
var DefaultClient = &http.Client{
Timeout: DefaultTimeout,
Transport: New(),
}
// MutualTLS builds a [tls.Config] that presents a client certificate,
// for reaching an endpoint that authenticates its callers by certificate
// rather than by a shared secret.
//
// The first two arguments are the paths to the PEM-encoded client
// certificate and its private key. The third is optional: when given, the
// server is verified against that CA alone, which is what a deployment with
// a private CA wants — the system roots would accept certificates it never
// issued. When empty, the system roots apply.
//
// Pair it with [WithTLSConfig]:
//
// cfg, err := transport.MutualTLS(certFile, keyFile, caFile)
// client := &http.Client{
// Timeout: transport.DefaultTimeout,
// Transport: transport.New(transport.WithTLSConfig(cfg)),
// }
//
// The pair is read eagerly once, so a bad path fails at construction —
// and re-read on handshake whenever either file's modification time
// changes, so a long-running client survives certificate renewal without
// a restart. A half-written rotation (the certificate replaced before
// its key, say) keeps serving the previous pair until the files agree
// again.
func MutualTLS(certFile, keyFile, caFile string) (*tls.Config, error) {
pair := &keyPair{certFile: certFile, keyFile: keyFile}
if _, err := pair.load(); err != nil {
return nil, fmt.Errorf("failed to load client certificate: %w", err)
}
cfg := &tls.Config{
GetClientCertificate: func(
*tls.CertificateRequestInfo,
) (*tls.Certificate, error) {
return pair.load()
},
MinVersion: tls.VersionTLS12,
}
if caFile == "" {
return cfg, nil
}
pem, err := os.ReadFile(caFile)
if err != nil {
return nil, fmt.Errorf("failed to read the CA bundle: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
// A bundle that parses to nothing would silently leave the
// system roots in charge, which is the opposite of what naming
// a CA asks for.
return nil, fmt.Errorf(
"the CA bundle %q contains no certificates", caFile,
)
}
cfg.RootCAs = pool
return cfg, nil
}
// keyPair caches a client certificate pair, re-reading it from disk when
// either file's modification time changes. Load failures after the first
// successful read fall back to the cached pair: a rotation writes two
// files, and the moment between them must not break live handshakes.
type keyPair struct {
certFile, keyFile string
mu sync.Mutex
cert *tls.Certificate
certMod time.Time
keyMod time.Time
}
// load returns the current pair, refreshing the cache if the files
// changed since the last read.
func (p *keyPair) load() (*tls.Certificate, error) {
p.mu.Lock()
defer p.mu.Unlock()
certInfo, err := os.Stat(p.certFile)
if err != nil {
return p.cached(err)
}
keyInfo, err := os.Stat(p.keyFile)
if err != nil {
return p.cached(err)
}
if p.cert != nil &&
certInfo.ModTime().Equal(p.certMod) &&
keyInfo.ModTime().Equal(p.keyMod) {
return p.cert, nil
}
cert, err := tls.LoadX509KeyPair(p.certFile, p.keyFile)
if err != nil {
return p.cached(err)
}
p.cert = &cert
p.certMod = certInfo.ModTime()
p.keyMod = keyInfo.ModTime()
return p.cert, nil
}
// cached degrades to the previously loaded pair, or surfaces the error
// when there is none to fall back to.
func (p *keyPair) cached(err error) (*tls.Certificate, error) {
if p.cert != nil {
return p.cert, nil
}
return nil, err
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package turnstile
import (
"net/http"
"time"
"github.com/deep-rent/nexus/sys/log"
)
// config holds the optional configuration of a [Verifier].
type config struct {
// endpoint overrides the siteverify URL.
endpoint string
// timeout bounds a single verification.
timeout time.Duration
// client is the [http.Client] used for outbound requests.
client *http.Client
// logger receives the deployment faults worth shouting about.
logger *log.Logger
}
// Option configures a [Verifier].
type Option func(*config)
// WithEndpoint overrides the siteverify URL, for tests and for
// deployments fronting the API with a proxy. Empty values are ignored.
func WithEndpoint(endpoint string) Option {
return func(c *config) {
if endpoint != "" {
c.endpoint = endpoint
}
}
}
// WithClient sets the [http.Client] used for outbound requests. Defaults
// to [http.DefaultClient]. Nil values are ignored.
func WithClient(client *http.Client) Option {
return func(c *config) {
if client != nil {
c.client = client
}
}
}
// WithTimeout bounds a single verification, defaulting to
// [DefaultTimeout]. A nonpositive value disables the bound, leaving the
// deadline to the caller's context.
func WithTimeout(d time.Duration) Option {
return func(c *config) {
c.timeout = d
}
}
// WithLogger injects a structured [log.Logger]. Without one the verifier
// stays silent ([log.Discard]). Nil values are ignored.
func WithLogger(logger *log.Logger) Option {
return func(c *config) {
if logger != nil {
c.logger = logger
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package turnstile
import (
"context"
"encoding/json/v2"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"slices"
"strings"
"time"
"github.com/deep-rent/nexus/sys/log"
)
// Error codes the siteverify API returns in [Result.Errors]. The set is
// Cloudflare's; the constants exist so callers can branch on a verdict
// without matching strings inline.
const (
// ErrorMissingSecret marks a request that carried no secret key.
ErrorMissingSecret = "missing-input-secret"
// ErrorInvalidSecret marks a secret key the API does not recognize.
// It is a deployment error, not a visitor failure.
ErrorInvalidSecret = "invalid-input-secret"
// ErrorMissingToken marks a request that carried no token, which
// usually means the widget never ran on the page.
ErrorMissingToken = "missing-input-response"
// ErrorInvalidToken marks a token the API cannot parse.
ErrorInvalidToken = "invalid-input-response"
// ErrorBadRequest marks a malformed request.
ErrorBadRequest = "bad-request"
// ErrorTimeoutOrDuplicate marks a token that has expired or was
// already spent. Tokens are single-use and live five minutes.
ErrorTimeoutOrDuplicate = "timeout-or-duplicate"
// ErrorInternal marks a failure inside Cloudflare; the request may be
// retried with a fresh token.
ErrorInternal = "internal-error"
)
// ErrUnverifiable reports that the verdict could not be obtained: the
// siteverify API was unreachable, answered with an unexpected status, or
// returned a body that is not a verdict. Every error from
// [Verifier.Verify] wraps it, so a caller can tell an outage from a
// visitor who simply did not pass — see the package documentation on
// failing open or closed.
var ErrUnverifiable = errors.New("turnstile verdict unavailable")
// DefaultEndpoint is Cloudflare's siteverify endpoint.
const DefaultEndpoint = "https://challenges.cloudflare.com" +
"/turnstile/v0/siteverify"
// DefaultTimeout bounds a single verification. Turnstile sits on the
// login path, so a slow verdict is nearly as bad as no verdict: the
// deadline keeps a stalled API from holding request goroutines rather
// than waiting out an outage.
const DefaultTimeout = 5 * time.Second
// maxBodySize caps what a verdict may occupy. The documented response is a
// few hundred bytes; the cap bounds what a misbehaving or impersonated
// endpoint can make the server buffer.
const maxBodySize = 64 << 10
// Request carries what one verification needs.
type Request struct {
// Token is the single-use token the widget produced, submitted by the
// client (the "cf-turnstile-response" form field). Required.
Token string
// Addr is the visitor's IP address. Optional; supplying it lets
// Cloudflare factor the address into the verdict. Pass the address the
// server trusts, not one the client claims.
Addr string
// Action optionally pins the widget's configured action name, so a
// token minted on one form cannot be replayed on another. When set, it
// must match [Result.Action]; the check is left to the caller, which
// knows which action it expects.
Action string
// Data optionally pins the widget's cData value, the same way
// [Request.Action] pins the action.
Data string
}
// Result is the verdict for one token.
type Result struct {
// Success reports whether the visitor passed. A false value with an
// empty [Result.Errors] slice is possible in principle; treat it as a
// failure.
Success bool `json:"success"`
// Errors lists the API's reason codes when [Result.Success] is false;
// see the Error constants above. It is the "error-codes" field.
Errors []string `json:"error-codes,omitzero"`
// ChallengeAt is when the challenge was solved.
ChallengeAt time.Time `json:"challenge_ts,omitzero"`
// Hostname is the domain the widget ran on. A caller serving one
// origin should compare it against that origin, so a token minted on
// an attacker's page carrying the same site key is refused.
Hostname string `json:"hostname,omitzero"`
// Action echoes the widget's action name; see [Request.Action].
Action string `json:"action,omitzero"`
// Data echoes the widget's cData value.
Data string `json:"cdata,omitzero"`
}
// Failed reports whether the verdict carries the given error code.
func (r Result) Failed(code string) bool {
return slices.Contains(r.Errors, code)
}
// Verifier exchanges Turnstile tokens for verdicts. Implementations must
// be safe for concurrent use.
//
// The interface exists so consumers can depend on the capability rather
// than on this package's client — a service that makes Turnstile optional
// holds a nil Verifier, and a test substitutes a stub.
type Verifier interface {
// Verify exchanges the token for a verdict. It returns an error
// wrapping [ErrUnverifiable] when no verdict could be obtained, which
// is distinct from a verdict of failure.
Verify(ctx context.Context, req Request) (Result, error)
}
// verifier is the siteverify-backed [Verifier].
type verifier struct {
secret string
endpoint string
timeout time.Duration
client *http.Client
logger *log.Logger
}
// New creates a [Verifier] over the given secret key, the server-side half
// of a Turnstile widget's key pair. It panics if the secret key is empty,
// since that is a startup configuration error.
func New(secret string, opts ...Option) Verifier {
if secret == "" {
panic("secret key is required")
}
cfg := config{
endpoint: DefaultEndpoint,
timeout: DefaultTimeout,
client: http.DefaultClient,
logger: log.Discard(),
}
for _, opt := range opts {
opt(&cfg)
}
return &verifier{
secret: secret,
endpoint: cfg.endpoint,
timeout: cfg.timeout,
client: cfg.client,
logger: cfg.logger,
}
}
// Verify implements [Verifier].
func (v *verifier) Verify(
ctx context.Context,
req Request,
) (Result, error) {
if req.Token == "" {
// An absent token needs no round trip: it is the verdict the API
// would return, and skipping the call keeps a client that never
// ran the widget from costing an outbound request each time.
return Result{Errors: []string{ErrorMissingToken}}, nil
}
if v.timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, v.timeout)
defer cancel()
}
form := url.Values{}
form.Set("secret", v.secret)
form.Set("response", req.Token)
if req.Addr != "" {
form.Set("remoteip", req.Addr)
}
if req.Action != "" {
form.Set("action", req.Action)
}
if req.Data != "" {
form.Set("cdata", req.Data)
}
r, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
v.endpoint,
strings.NewReader(form.Encode()),
)
if err != nil {
return Result{}, fmt.Errorf(
"%w: failed to build request: %w", ErrUnverifiable, err,
)
}
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.Header.Set("Accept", "application/json")
res, err := v.client.Do(r)
if err != nil {
return Result{}, fmt.Errorf(
"%w: request failed: %w", ErrUnverifiable, err,
)
}
defer func() {
_ = res.Body.Close()
}()
body, err := io.ReadAll(io.LimitReader(res.Body, maxBodySize))
if err != nil {
return Result{}, fmt.Errorf(
"%w: failed to read response: %w", ErrUnverifiable, err,
)
}
if res.StatusCode != http.StatusOK {
return Result{}, fmt.Errorf(
"%w: endpoint returned status %d",
ErrUnverifiable, res.StatusCode,
)
}
var out Result
if err := json.Unmarshal(body, &out); err != nil {
return Result{}, fmt.Errorf(
"%w: failed to decode verdict: %w", ErrUnverifiable, err,
)
}
// An unrecognized secret is a deployment fault wearing the costume of
// a visitor failure: every request fails identically until someone
// fixes the configuration, so say so once per occurrence rather than
// letting it read as traffic that did not pass.
if out.Failed(ErrorInvalidSecret) || out.Failed(ErrorMissingSecret) {
v.logger.Error(
ctx,
"Turnstile rejected the configured secret key",
log.String("codes", strings.Join(out.Errors, " ")),
)
}
return out, nil
}
var _ Verifier = (*verifier)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package auth
import (
"context"
"net/http"
"github.com/deep-rent/nexus/net/router"
)
// Scheme defines the expected authentication scheme for the Authorization
// header. It is used to extract the JWT token from the request.
const Scheme = "Bearer"
const (
// ReasonAuthenticationFailed serves as a generic fallback for identity
// verification errors that do not map to a more specific reason.
ReasonAuthenticationFailed router.Reason = "authentication_failed"
// ReasonMissingToken indicates that the Authorization header was either
// missing or did not contain a valid Bearer token.
ReasonMissingToken router.Reason = "missing_token"
// ReasonInvalidToken indicates a token was provided but is unusable,
// typically due to expiration, a malformed structure, or a signature
// mismatch.
ReasonInvalidToken router.Reason = "invalid_token"
// ReasonInsufficientPrivileges indicates the user is authenticated, but
// their assigned scopes or roles do not permit access to the resource.
ReasonInsufficientPrivileges router.Reason = "insufficient_privileges"
// ReasonDelegationRequired indicates a machine token where an end-user
// context was required.
ReasonDelegationRequired router.Reason = "delegation_required"
)
const (
// RoleAdmin represents an elevated user role with full administrative
// access.
RoleAdmin = "admin"
)
// contextKey prevents collisions with other packages.
type contextKey struct{}
// claimsKey is the internal context key used to store and retrieve parsed
// JWT claims.
var claimsKey contextKey
// FromContext retrieves the parsed claims from a standard [context.Context].
// It returns the claims and a boolean indicating whether they were found.
//
// The claims arrive as an [Access], which is the whole point of the
// interface: roles, scopes, delegation, the subject and the memberships are
// what an authorization decision is made of, and every one of them is
// reachable without knowing which type carried them. A handler that needs a
// field outside the interface asserts for its own claims type, the way it
// would for any other value pulled from a context.
func FromContext(ctx context.Context) (Access, bool) {
claims, ok := ctx.Value(claimsKey).(Access)
return claims, ok
}
// FromRequest retrieves the parsed claims directly from an [*http.Request].
// It returns the claims and a boolean indicating whether they were found.
func FromRequest(req *http.Request) (Access, bool) {
return FromContext(req.Context())
}
// From retrieves the parsed claims from a [*router.Exchange].
// This is the preferred method for extracting claims within route handlers.
func From(e *router.Exchange) (Access, bool) {
return FromContext(e.Context())
}
// Must retrieves the parsed claims from a [*router.Exchange].
// It panics if the claims are not present in the exchange. This is useful
// when the route is guaranteed to have claims injected by middleware.
func Must(e *router.Exchange) Access {
claims, ok := From(e)
if !ok {
panic("claims missing from exchange")
}
return claims
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package auth
import (
"encoding/json/jsontext"
"encoding/json/v2"
"slices"
"strings"
"uuid"
"github.com/deep-rent/nexus/sec/jose/jwt"
)
// Access is the view of a verified token that authorization decisions
// read: roles, scopes, delegation, the acting user, and team memberships.
// It deliberately does not extend [jwt.Claims] — the reserved claims are
// the verifier's business, and by the time a [Rule] or a handler sees an
// Access, signature and validity have already been checked. [Claims] is
// the implementation this package ships.
type Access interface {
// HasRole reports whether the subject carries the role.
HasRole(name string) bool
// HasScope reports whether the token was delegated the scope.
HasScope(name string) bool
// Delegated reports whether the token was issued to a client acting on
// behalf of an end user rather than to the client itself.
Delegated() bool
// UserID returns the subject claim as the user's UUID.
//
// First-party tokens identify end users by UUID, but the raw subject
// claim is an opaque string, so the value is resolved while the claims
// are decoded. It returns the zero UUID when the token is not delegated
// (the subject is the client itself) or when the subject is not a valid
// UUID.
UserID() uuid.UUID
// Memberships returns the identifiers of all teams the subject belongs
// to, taken from the "teams" claim.
Memberships() []uuid.UUID
}
// Scope represents the "scope" claim of a JWT as defined in RFC 6749.
// It is stored as a space-delimited string in JSON but handled as a slice
// internally to optimize lookup performance.
type Scope []string
// UnmarshalJSON handles the parsing of the space-delimited scope string.
func (s *Scope) UnmarshalJSON(b []byte) error {
var raw string
if err := json.Unmarshal(b, &raw); err != nil {
return err
}
*s = strings.Fields(raw)
return nil
}
// MarshalJSON joins the scopes back into a space-delimited string.
func (s Scope) MarshalJSON() ([]byte, error) {
return json.Marshal(s.String())
}
// String returns the space-delimited string representation.
func (s Scope) String() string {
return strings.Join(s, " ")
}
// Claims is the framework's access token payload: the reserved JWT claims
// it embeds satisfy [jwt.Claims] for verification, and the roles, scopes
// and team memberships it adds satisfy [Access] for authorization.
//
// A Claims describes a token that was received and decoded, so derived
// values such as the one behind [Claims.UserID] are resolved during
// unmarshaling. One assembled field by field in Go carries no such values;
// construct it from a JSON payload whenever those accessors matter.
type Claims struct {
jwt.Reserved
// Azp represents the authorized party to which the token was issued.
// It typically identifies the client application.
Azp string `json:"azp,omitzero"`
// Roles represents the application-specific roles assigned to the subject,
// used for Role-Based Access Control (RBAC).
Roles []string `json:"roles,omitempty"`
// Scope represents the set of granted OAuth2/OIDC scopes as defined in
// RFC 6749, typically used for delegated authorization.
Scope Scope `json:"scope,omitempty"`
// Teams lists the identifiers of all teams the subject is a member of.
Teams []uuid.UUID `json:"teams,omitempty"`
// userID holds the subject parsed as a UUID, resolved once during JSON
// unmarshaling. Its zero value is the nil UUID, which is exactly what
// [Claims.UserID] reports for machine tokens and unparsable subjects.
userID uuid.UUID
}
// UnmarshalJSONFrom implements [json.UnmarshalerFrom]. It decodes the claims
// and then resolves the subject into the user identifier reported by
// [Claims.UserID], so the parse happens once per token rather than on every
// access.
//
// The method takes a [jsontext.Decoder] rather than raw bytes because the
// decoder carries the caller's options. [jwt.Parse] supplies an unmarshaler
// for the numeric timestamps of the reserved claims; a byte-oriented hook
// would decode the nested value with default options and reject them.
func (c *Claims) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
// The local type sheds the method set, so the nested call decodes the
// fields instead of re-entering this method.
type claims Claims
if err := json.UnmarshalDecode(dec, (*claims)(c)); err != nil {
return err
}
if c.Delegated() {
// A subject that is not a UUID leaves the nil UUID in place; the
// token stays valid, it simply names no user. The identifier is
// only assigned on success, since a rejected parse may still have
// written into its result.
if id, err := uuid.Parse(c.Sub); err == nil {
c.userID = id
}
}
return nil
}
// HasRole implements the [Access] interface.
func (c *Claims) HasRole(name string) bool {
return slices.Contains(c.Roles, name)
}
// HasScope implements the [Access] interface.
func (c *Claims) HasScope(name string) bool {
return slices.Contains(c.Scope, name)
}
// Delegated returns true if the token was issued to an authorized party (azp)
// that is different from the subject (sub).
//
// This is useful for distinguishing between a client acting on its own behalf
// (machine-to-machine) and a client acting on behalf of a user (delegation).
func (c *Claims) Delegated() bool {
return c.Azp != "" && c.Azp != c.Sub
}
// UserID implements the [Access] interface. The identifier is resolved
// during unmarshaling, so it is the nil UUID on claims that were never
// decoded from JSON.
func (c *Claims) UserID() uuid.UUID {
return c.userID
}
// Memberships implements the [Access] interface.
func (c *Claims) Memberships() []uuid.UUID {
return c.Teams
}
var (
_ Access = (*Claims)(nil)
_ jwt.Claims = (*Claims)(nil)
)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package auth
import (
"context"
"fmt"
"slices"
)
// Grants maps a role onto the permissions it carries. A permission is a
// fine-grained capability named by a string, such as "iam:users:write";
// endpoints are guarded by permissions, and roles are nothing more than
// named collections of them. Each service defines its own mapping over its
// own permissions — the vocabulary is not global.
//
// Scopes and permissions share one namespace: the "scope" claim of a token
// lists the permissions delegated to it. [Grants.Permits] intersects the
// two dimensions.
type Grants map[string][]string
// Permits reports whether the claims hold the permission.
//
// The token's scope must name the permission — a token never exercises
// authority it was not delegated. On top of that, a delegated token acts
// for an end user, so some role of that subject must grant the permission
// through this mapping: the effective privilege is the intersection of
// what the user can do and what the token was trusted with. A machine
// token has no subject to carry roles; its scopes, vetted when the client
// was registered, express its authority entirely.
func (g Grants) Permits(c Access, perm string) bool {
if !c.HasScope(perm) {
return false
}
if !c.Delegated() {
return true
}
for role, perms := range g {
if slices.Contains(perms, perm) && c.HasRole(role) {
return true
}
}
return false
}
// Require creates a [Rule] that mandates every listed permission, in the
// sense of [Grants.Permits].
func (g Grants) Require(perms ...string) Rule {
return func(_ context.Context, claims Access) error {
for _, p := range perms {
if !g.Permits(claims, p) {
return fmt.Errorf("requires permission %q", p)
}
}
return nil
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package auth
import (
"context"
"errors"
"net/http"
"github.com/deep-rent/nexus/net/header"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/sec/jose/jwt"
)
// Extractor defines a function signature for extracting a token from an HTTP
// request. It returns the extracted token string, or an empty string if no
// token was found.
type Extractor func(r *http.Request) string
// BearerExtractor is the default [Extractor] that attempts to retrieve a token
// from the Authorization header using the Bearer scheme.
func BearerExtractor(r *http.Request) string {
return header.Credentials(r.Header, Scheme)
}
// Guard is responsible for intercepting HTTP requests, validating their JWT
// authentication, and enforcing defined authorization rules.
//
// The guard is not generic. [NewGuard] infers the verifier's claims type
// and leaves it behind, and nothing downstream needs it back: the claims
// keep their dynamic type while held as an [Access], so [From] still
// recovers exactly what the verifier produced. A type holding a Guard is
// therefore free to stay concrete.
type Guard struct {
verify func(token []byte) (Access, error)
extractors []Extractor
}
// NewGuard creates a new [Guard] using the provided JWT verifier and optional
// extractors. If no extractors are provided, it defaults to using
// [BearerExtractor].
//
// The constraint is the conjunction of the guard's two halves: the claims
// type must satisfy [jwt.Claims] for the verifier and [Access] for the
// authorization surface. [Claims] satisfies both.
func NewGuard[T interface {
jwt.Claims
Access
}](
v jwt.Verifier[T],
extractors ...Extractor,
) *Guard {
if len(extractors) == 0 {
extractors = []Extractor{BearerExtractor}
}
return &Guard{
// The closure is where T stops: it widens the verifier's concrete
// result to the interface the rules and the context work with.
verify: func(token []byte) (Access, error) {
return v.Verify(token)
},
extractors: extractors,
}
}
// Secure produces a [router.Middleware] that protects routes.
//
// It extracts a token using the configured extractors, verifies its signature
// and validity, and ensures all provided rules pass. If any step fails, it
// returns a structured [*router.Error] and halts the middleware chain.
//
// If no rules are provided, Secure acts strictly as an authentication check,
// verifying the token's validity without enforcing any specific authorization
// constraints.
func (g *Guard) Secure(rules ...Rule) router.Middleware {
return func(next router.Handler) router.Handler {
return router.HandlerFunc(func(e *router.Exchange) error {
var token string
for _, ext := range g.extractors {
if t := ext(e.R); t != "" {
token = t
break
}
}
if token == "" {
return &router.Error{
Status: http.StatusUnauthorized,
Reason: ReasonMissingToken,
Description: "bearer token is missing or malformed",
}
}
claims, err := g.verify([]byte(token))
if err != nil {
return &router.Error{
Status: http.StatusUnauthorized,
Reason: ReasonInvalidToken,
Description: "provided token is invalid or expired",
Cause: err,
}
}
if err := evaluate(e.Context(), claims, rules); err != nil {
return err
}
// Embed the verified claims into the request context and update
// the Exchange so downstream handlers have access.
e.R = e.R.WithContext(
context.WithValue(e.Context(), claimsKey, claims),
)
return next.ServeHTTP(e)
})
}
}
// Enforce produces authorization-only [router.Middleware]: it evaluates
// the rules against claims that a [Guard] upstream has already verified
// and injected, without touching the token again. Use it to authenticate
// once at a group boundary and demand different permissions per route:
//
// api := r.Group("/admin", guard.Secure())
// api.HandleFunc(http.MethodGet, "/users", listUsers,
// auth.Enforce(grants.Require("iam:users:read")))
//
// A request that carries no claims — no guard ran, or a middleware chain
// was miswired — answers 401 rather than passing unchecked.
func Enforce(rules ...Rule) router.Middleware {
return func(next router.Handler) router.Handler {
return router.HandlerFunc(func(e *router.Exchange) error {
claims, ok := From(e)
if !ok {
return &router.Error{
Status: http.StatusUnauthorized,
Reason: ReasonMissingToken,
Description: "no verified claims accompany the request",
}
}
if err := evaluate(e.Context(), claims, rules); err != nil {
return err
}
return next.ServeHTTP(e)
})
}
}
// evaluate runs the rules against the claims. A failure surfaces as the
// rule's own [*router.Error] when it returns one, and as a standard 403
// otherwise.
func evaluate(ctx context.Context, claims Access, rules []Rule) error {
for _, rule := range rules {
if err := rule(ctx, claims); err != nil {
if re, ok := errors.AsType[*router.Error](err); ok {
return re
}
return &router.Error{
Status: http.StatusForbidden,
Reason: ReasonInsufficientPrivileges,
Description: "access denied by security policy",
Cause: err,
}
}
}
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package auth
import (
"context"
"errors"
)
// Rule defines an authorization condition that must be met for a request to
// proceed. Rules are evaluated after the JWT has been successfully verified.
//
// Returning an error denies the request; the [Guard] answers 403 unless the
// error is a [*router.Error] of its own, which passes through untouched.
//
// A rule is an ordinary function, so a closure can be handed to
// [Guard.Secure] directly. It sees the claims as an [Access] rather than as
// the concrete type the guard was built around; one needing more than the
// interface offers asserts for its own claims type:
//
// guard.Secure(func(_ context.Context, c auth.Access) error {
// if mine, ok := c.(*MyClaims); ok && mine.Tenant == want {
// return nil
// }
// return fmt.Errorf("requires tenant %q", want)
// })
type Rule func(ctx context.Context, claims Access) error
// Machine creates a [Rule] admitting only tokens a client holds in its
// own right, refusing any issued on a person's behalf.
//
// It is for the surfaces where a delegated token would be a capability
// nobody should hold: writing an audit entry is manufacturing history,
// and publishing a notification is writing to a stranger's lock screen.
// Those services carry scopes no role grants, and this is the second
// half of that — a person whose token somehow carries the scope is
// still refused.
//
// Pass it to [Guard.Secure] beside the permission the surface demands,
// so the decision sits with the rest of the authorization rather than
// inside the handler:
//
// guard.Secure(auth.Machine(), grants.Require(PermissionAppend))
func Machine() Rule {
return func(_ context.Context, claims Access) error {
if claims.Delegated() {
return errors.New("requires a machine client")
}
return nil
}
}
// Delegated creates a [Rule] admitting only tokens issued on a person's
// behalf, refusing a client acting in its own right.
//
// It is the counterpart of [Machine], for the surfaces that belong to
// somebody: a ticket, a registered phone, a document. A machine token
// names no person, so the handler behind such a surface would have
// nobody to answer about.
func Delegated() Rule {
return func(_ context.Context, claims Access) error {
if !claims.Delegated() {
return errors.New("requires a signed-in user")
}
return nil
}
}
// All creates a [Rule] that passes only if all the provided rules pass (AND).
// It returns the error from the first rule that fails.
func All(rules ...Rule) Rule {
return func(ctx context.Context, claims Access) error {
for _, r := range rules {
if err := r(ctx, claims); err != nil {
return err
}
}
return nil
}
}
// Any creates a [Rule] that passes if at least one of the provided rules
// passes (OR). If all rules fail, it returns an error combining the reasons.
func Any(rules ...Rule) Rule {
return func(ctx context.Context, claims Access) error {
var errs []error
for _, r := range rules {
err := r(ctx, claims)
if err == nil {
return nil
}
errs = append(errs, err)
}
if len(errs) > 0 {
return errors.Join(errs...)
}
return nil
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package digest
import (
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"hash"
"sync"
)
// Algorithm constructs a new [hash.Hash]. It is the injection point of the
// package.
//
// A [Hasher] pools the hashes an Algorithm returns and calls Reset between
// uses, so an Algorithm need only produce a hash whose Reset restores its
// initial state — every standard-library constructor and [hmac.New] qualifies.
type Algorithm = func() hash.Hash
// DefaultAlgorithm is SHA-256, a 256-bit cryptographic hash suitable for
// fingerprinting secrets and detecting tampering. Its digests encode to 43
// base64url characters.
var DefaultAlgorithm Algorithm = sha256.New
// Hasher computes fingerprints of values using a configurable [Algorithm]. It
// is safe for concurrent use: each fingerprint borrows an independent hash from
// an internal pool, so the underlying algorithm need not be concurrency-safe.
//
// A fingerprint is the hash sum of the input encoded as an unpadded base64url
// string, making it safe for inclusion in URLs, HTTP headers, JSON payloads,
// and database columns. Fingerprints let a raw secret be stored or compared by
// its hash rather than in the clear.
type Hasher struct {
pool sync.Pool
}
// scratch is a reusable, pooled hash together with a buffer for its sum,
// sparing every fingerprint the cost of constructing a hash and allocating a
// destination for [hash.Hash.Sum].
type scratch struct {
hash hash.Hash
sum []byte
}
// New returns a [Hasher] that fingerprints values with the given [Algorithm].
// If algorithm is nil, [DefaultAlgorithm] (SHA-256) is used.
func New(algorithm Algorithm) *Hasher {
if algorithm == nil {
algorithm = DefaultAlgorithm
}
h := &Hasher{}
h.pool.New = func() any { return &scratch{hash: algorithm()} }
return h
}
// Bytes returns the fingerprint of value: its hash sum encoded as an unpadded
// base64url string. With the default SHA-256 algorithm the result is 43
// characters long.
func (h *Hasher) Bytes(value []byte) string {
s := h.pool.Get().(*scratch)
s.hash.Reset()
// This call is documented never to return an error.
_, _ = s.hash.Write(value)
// Reuse the pooled buffer as the sum destination; Sum grows it on first use
// and reuses it thereafter.
s.sum = s.hash.Sum(s.sum[:0])
out := base64.RawURLEncoding.EncodeToString(s.sum)
h.pool.Put(s)
return out
}
// String returns the fingerprint of value. It is shorthand for hashing the
// bytes of a string; see [Hasher.Bytes].
func (h *Hasher) String(value string) string {
return h.Bytes([]byte(value))
}
// Match reports whether value fingerprints to digest under this hasher. It
// hashes value with [Hasher.String] and compares the result against digest in
// constant time via [Equal].
//
// Match is the verification counterpart to String: fingerprint a secret once
// and store it, then check a candidate with Match rather than comparing digests
// with ==, which compares in variable time and leaks how much matched.
func (h *Hasher) Match(value, digest string) bool {
return Equal(h.String(value), digest)
}
// DefaultHasher fingerprints values with [DefaultAlgorithm]. It is a
// ready-to-use replacement for one-off fingerprinting.
var DefaultHasher = New(DefaultAlgorithm)
// Equal reports whether two digest strings are identical, comparing them in
// constant time via [subtle.ConstantTimeCompare] to avoid leaking their
// contents through timing side channels.
//
// The comparison is only meaningful for digests produced by the same
// [Algorithm]; digests of different lengths are never equal.
func Equal(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package jwa
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/asn1"
"errors"
"fmt"
"math/big"
"github.com/deep-rent/nexus/sec/sign"
)
// es implements the ECDSA family of algorithms (ESxxx).
type es struct {
// name is the JWA identifier.
name string
// pool is the internal hash pool for thread-safe operations.
pool *hashPool
// ecrv is the elliptic curve.
ecrv elliptic.Curve
}
// newES creates a new [Algorithm] for ECDSA signatures
// with the given JWA name and hash function.
func newES(
name string,
hash crypto.Hash,
ecrv elliptic.Curve,
) Algorithm[*ecdsa.PublicKey] {
return &es{
name: name,
pool: newHashPool(hash),
ecrv: ecrv,
}
}
// Verify checks an ECDSA signature.
func (a *es) Verify(key *ecdsa.PublicKey, msg, sig []byte) bool {
// Each ESxxx names exactly one curve, and a key on any other curve
// does not match the algorithm however well it verifies. Refusing
// here is what lets a key source that binds an algorithm name to
// arbitrary key material — a certificate chain carried in an "x5c"
// header, say — treat the pairing itself as the check.
if key.Curve != a.ecrv {
return false
}
// The signature is the concatenation of two integers of the same size
// as the curve's order.
n := (a.ecrv.Params().BitSize + 7) / 8
if len(sig) != 2*n {
return false
}
h := a.pool.Get()
defer func() { a.pool.Put(h) }()
h.Write(msg)
digest := h.Sum(nil)
// Split the signature into R and S.
r := new(big.Int).SetBytes(sig[:n])
s := new(big.Int).SetBytes(sig[n:])
return ecdsa.Verify(key, digest, r, s)
}
// Sign creates an ECDSA signature in raw R||S form. Signers following the
// [crypto.Signer] convention emit ASN.1 DER, which is transcoded; signers
// declaring [sign.ECDSARaw] already emit the raw form, which is
// passed through after a length check.
func (a *es) Sign(
ctx context.Context,
s sign.Signer,
msg []byte,
) ([]byte, error) {
h := a.pool.Get()
defer a.pool.Put(h)
h.Write(msg)
digest := h.Sum(nil)
pub, ok := s.Public().(*ecdsa.PublicKey)
if !ok {
return nil, errors.New("signer public key is not ECDSA")
}
n := (pub.Curve.Params().BitSize + 7) / 8
sig, err := s.Sign(ctx, rand.Reader, digest, nil)
if err != nil {
return nil, err
}
format := sign.ECDSADER
if fs, ok := s.(sign.ECDSAFormatSigner); ok {
format = fs.ECDSAFormat()
}
switch format {
case sign.ECDSARaw:
if len(sig) != 2*n {
return nil, fmt.Errorf(
"concat ECDSA signature has %d bytes; want %d",
len(sig), 2*n,
)
}
return sig, nil
case sign.ECDSADER:
default:
return nil, fmt.Errorf(
"unsupported ECDSA signature format %q", format,
)
}
var concat struct{ R, S *big.Int }
if _, err := asn1.Unmarshal(sig, &concat); err != nil {
return nil, fmt.Errorf("failed to parse ECDSA signature: %w", err)
}
if (concat.R.BitLen()+7)/8 > n || (concat.S.BitLen()+7)/8 > n {
return nil, errors.New(
"ECDSA signature values R or S are too large for the curve size",
)
}
out := make([]byte, 2*n)
concat.R.FillBytes(out[:n])
concat.S.FillBytes(out[n:])
return out, nil
}
// Generate creates a new ECDSA key pair.
func (a *es) Generate() (crypto.Signer, error) {
return ecdsa.GenerateKey(a.ecrv, rand.Reader)
}
// String returns the JWA algorithm name.
func (a *es) String() string {
return a.name
}
// ES256 represents the ECDSA signature algorithm using P-256 and SHA-256.
var ES256 = newES("ES256", crypto.SHA256, elliptic.P256())
// ES384 represents the ECDSA signature algorithm using P-384 and SHA-384.
var ES384 = newES("ES384", crypto.SHA384, elliptic.P384())
// ES512 represents the ECDSA signature algorithm using P-521 and SHA-512.
var ES512 = newES("ES512", crypto.SHA512, elliptic.P521())
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package jwa
import (
"context"
"crypto"
"crypto/ed25519"
"crypto/rand"
"github.com/deep-rent/nexus/sec/sign"
)
// ed implements the EdDSA family of algorithms.
type ed struct{}
// Verify checks an EdDSA signature, supporting Ed25519.
func (*ed) Verify(key ed25519.PublicKey, msg, sig []byte) bool {
return ed25519.Verify(key, msg, sig)
}
// Sign creates an EdDSA signature using the provided signer.
func (*ed) Sign(
ctx context.Context,
s sign.Signer,
msg []byte,
) ([]byte, error) {
return s.Sign(ctx, rand.Reader, msg, crypto.Hash(0))
}
// Generate creates a new Ed25519 key pair.
func (*ed) Generate() (crypto.Signer, error) {
_, prv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, err
}
return prv, nil
}
// String returns the JWA algorithm name.
func (*ed) String() string {
return "EdDSA"
}
// EdDSA represents the EdDSA signature algorithm. It supports the Ed25519
// curve.
var EdDSA Algorithm[ed25519.PublicKey] = &ed{}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package jwa
import (
"context"
"crypto"
"hash"
"sync"
"github.com/deep-rent/nexus/sec/sign"
)
// Algorithm represents a JWA digital signature algorithm.
//
// The type parameter PublicKey restricts the key type acceptable for
// verification, enforcing algorithm-to-key-type constraints at compile time
// where possible.
type Algorithm[PublicKey crypto.PublicKey] interface {
// Verify checks whether sig is a valid signature for msg under the given
// public key. It MUST NOT return true if the public key type or parameters
// do not match the algorithm.
Verify(key PublicKey, msg, sig []byte) bool
// Sign creates a digital signature for msg using the provided opaque
// signer. The signer's public key MUST match the algorithm's requirements.
Sign(ctx context.Context, s sign.Signer, msg []byte) ([]byte, error)
// Generate creates a new private key suitable for this algorithm using
// [crypto/rand.Reader] as the entropy source.
Generate() (crypto.Signer, error)
// String returns the JWA algorithm identifier (e.g., "RS256").
String() string
}
// hashPool manages a pool of [hash.Hash] objects to reduce allocations.
type hashPool struct {
// Hash is the underlying hash identifier.
Hash crypto.Hash
// pool is the [sync.Pool] containing initialized [hash.Hash] instances.
pool *sync.Pool
}
// newHashPool creates a new [hashPool] for the given hash function.
func newHashPool(hash crypto.Hash) *hashPool {
pool := &sync.Pool{
New: func() any {
return hash.New()
},
}
return &hashPool{
Hash: hash,
pool: pool,
}
}
// Get retrieves a [hash.Hash] from the pool.
func (p *hashPool) Get() hash.Hash {
h := p.pool.Get()
return h.(hash.Hash)
}
// Put returns a [hash.Hash] to the pool after resetting it.
func (p *hashPool) Put(h hash.Hash) {
h.Reset()
p.pool.Put(h)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package jwa
import (
"fmt"
"maps"
"slices"
)
// algorithms enumerates every algorithm this package implements. It is the
// one place a new algorithm must be listed once declared, and it is held
// as [fmt.Stringer] rather than [Algorithm] because the latter is generic
// over its public key type, which differs per family.
//
// The identifiers themselves are read back off the values, so a name can
// never drift from the algorithm that carries it.
var algorithms = []fmt.Stringer{
RS256, RS384, RS512,
PS256, PS384, PS512,
ES256, ES384, ES512,
EdDSA,
MLDSA44, MLDSA65, MLDSA87,
}
// names indexes [algorithms] by JWA identifier.
var names = func() map[string]struct{} {
set := make(map[string]struct{}, len(algorithms))
for _, alg := range algorithms {
set[alg.String()] = struct{}{}
}
return set
}()
// Supports reports whether name is the JWA identifier of an algorithm this
// package implements. Use it to reject an unusable algorithm where it is
// configured rather than where it is first used to sign.
func Supports(name string) bool {
_, ok := names[name]
return ok
}
// List returns the JWA identifiers of every algorithm this package
// implements, sorted lexicographically. The result is a fresh slice the
// caller may retain and modify.
func List() []string {
return slices.Sorted(maps.Keys(names))
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package jwa
import (
"context"
"crypto"
"crypto/mldsa"
"crypto/rand"
"crypto/sha3"
"errors"
"fmt"
"github.com/deep-rent/nexus/sec/sign"
)
// ml implements the ML-DSA family of algorithms defined in FIPS 204.
type ml struct {
// name is the JWA identifier.
name string
// params is the fixed ML-DSA parameter set.
params mldsa.Parameters
}
// newML creates a new [Algorithm] for ML-DSA signatures with the given JWA
// name and parameter set.
func newML(name string, params mldsa.Parameters) Algorithm[*mldsa.PublicKey] {
return &ml{
name: name,
params: params,
}
}
// Verify checks an ML-DSA signature. It rejects keys whose parameter set does
// not match the algorithm to prevent parameter set confusion.
func (a *ml) Verify(key *mldsa.PublicKey, msg, sig []byte) bool {
if key.Parameters() != a.params {
return false
}
return mldsa.Verify(key, msg, sig, nil) == nil
}
// Sign creates an ML-DSA signature using the provided signer.
//
// The message is not forwarded verbatim: it is pre-hashed into the 64-byte
// μ representative defined in FIPS 204 (external-μ mode, RFC 9881) and
// passed to the signer with [crypto.MLDSAMu]. The resulting signature is
// identical to signing the message directly in "pure" mode with an empty
// context string, as required by the JOSE registration, but remote signers
// (e.g., KMS or HSM backends) receive a constant-size input regardless of
// the message length. The signer's public key must be an [*mldsa.PublicKey]
// whose parameter set matches the algorithm.
func (a *ml) Sign(
ctx context.Context,
s sign.Signer,
msg []byte,
) ([]byte, error) {
pub, ok := s.Public().(*mldsa.PublicKey)
if !ok {
return nil, errors.New("signer public key is not ML-DSA")
}
if pub.Parameters() != a.params {
return nil, fmt.Errorf(
"signer parameter set %s does not match algorithm %s",
pub.Parameters(), a.name,
)
}
return s.Sign(ctx, rand.Reader, mu(pub, msg), crypto.MLDSAMu)
}
// mu computes the pre-hashed message representative μ as defined in FIPS 204
// for "pure" ML-DSA with an empty context string:
//
// tr = SHAKE256(pk, 64)
// μ = SHAKE256(tr || 0x00 || 0x00 || msg, 64)
func mu(pub *mldsa.PublicKey, msg []byte) []byte {
h := sha3.NewSHAKE256()
h.Write(sha3.SumSHAKE256(pub.Bytes(), 64))
// Domain separator (0 = pure ML-DSA) and empty context string length.
h.Write([]byte{0, 0})
h.Write(msg)
out := make([]byte, 64)
h.Read(out)
return out
}
// Generate creates a new ML-DSA key pair.
func (a *ml) Generate() (crypto.Signer, error) {
return mldsa.GenerateKey(a.params)
}
// String returns the JWA algorithm name.
func (a *ml) String() string {
return a.name
}
// MLDSA44 represents the ML-DSA-44 signature algorithm (FIPS 204).
var MLDSA44 = newML("ML-DSA-44", mldsa.MLDSA44())
// MLDSA65 represents the ML-DSA-65 signature algorithm (FIPS 204).
var MLDSA65 = newML("ML-DSA-65", mldsa.MLDSA65())
// MLDSA87 represents the ML-DSA-87 signature algorithm (FIPS 204).
var MLDSA87 = newML("ML-DSA-87", mldsa.MLDSA87())
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package jwa
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"github.com/deep-rent/nexus/sec/sign"
)
// rs implements the RSASSA-PKCS1-v1_5 family of algorithms (RSxxx).
type rs struct {
// name is the JWA identifier.
name string
// pool is the internal hash pool for thread-safe operations.
pool *hashPool
// size is the generated key size in bits.
size int
}
// newRS creates a new [Algorithm] for RSASSA-PKCS1-v1_5 signatures
// with the given JWA name and hash function.
func newRS(name string, hash crypto.Hash, size int) Algorithm[*rsa.PublicKey] {
return &rs{
name: name,
pool: newHashPool(hash),
size: size,
}
}
// Verify checks an RSASSA-PKCS1-v1_5 signature.
func (a *rs) Verify(key *rsa.PublicKey, msg, sig []byte) bool {
h := a.pool.Get()
defer func() { a.pool.Put(h) }()
h.Write(msg)
digest := h.Sum(nil)
return rsa.VerifyPKCS1v15(key, a.pool.Hash, digest, sig) == nil
}
// Sign creates an RSASSA-PKCS1-v1_5 signature using the provided signer.
func (a *rs) Sign(
ctx context.Context,
s sign.Signer,
msg []byte,
) ([]byte, error) {
h := a.pool.Get()
defer a.pool.Put(h)
h.Write(msg)
digest := h.Sum(nil)
return s.Sign(ctx, rand.Reader, digest, a.pool.Hash)
}
func (a *rs) Generate() (crypto.Signer, error) {
return rsa.GenerateKey(rand.Reader, a.size)
}
// String returns the JWA algorithm name.
func (a *rs) String() string {
return a.name
}
// RS256 represents the RSASSA-PKCS1-v1_5 signature algorithm using SHA-256.
var RS256 = newRS("RS256", crypto.SHA256, 3072)
// RS384 represents the RSASSA-PKCS1-v1_5 signature algorithm using SHA-384.
var RS384 = newRS("RS384", crypto.SHA384, 3072)
// RS512 represents the RSASSA-PKCS1-v1_5 signature algorithm using SHA-512.
var RS512 = newRS("RS512", crypto.SHA512, 4096)
// ps implements the RSASSA-PSS family of algorithms (PSxxx).
type ps struct {
// name is the JWA identifier.
name string
// pool is the internal hash pool for thread-safe operations.
pool *hashPool
// size is the generated key size in bits.
size int
}
// newPS creates a new [Algorithm] for RSASSA-PSS signatures
// with the given JWA name and hash function.
func newPS(name string, hash crypto.Hash, size int) Algorithm[*rsa.PublicKey] {
return &ps{
name: name,
pool: newHashPool(hash),
size: size,
}
}
// Verify checks an RSASSA-PSS signature.
func (a *ps) Verify(key *rsa.PublicKey, msg, sig []byte) bool {
h := a.pool.Get()
defer func() { a.pool.Put(h) }()
h.Write(msg)
digest := h.Sum(nil)
// The salt length is set to match the hash size.
opts := &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash}
return rsa.VerifyPSS(key, a.pool.Hash, digest, sig, opts) == nil
}
// Sign creates an RSASSA-PSS signature using the provided signer.
func (a *ps) Sign(
ctx context.Context,
s sign.Signer,
msg []byte,
) ([]byte, error) {
h := a.pool.Get()
defer a.pool.Put(h)
h.Write(msg)
digest := h.Sum(nil)
opts := &rsa.PSSOptions{
SaltLength: rsa.PSSSaltLengthEqualsHash,
Hash: a.pool.Hash,
}
return s.Sign(ctx, rand.Reader, digest, opts)
}
// Generate creates a new RSA key pair.
func (a *ps) Generate() (crypto.Signer, error) {
return rsa.GenerateKey(rand.Reader, a.size)
}
// String returns the JWA algorithm name.
func (a *ps) String() string {
return a.name
}
// PS256 represents the RSASSA-PSS signature algorithm using SHA-256.
var PS256 = newPS("PS256", crypto.SHA256, 3072)
// PS384 represents the RSASSA-PSS signature algorithm using SHA-384.
var PS384 = newPS("PS384", crypto.SHA384, 3072)
// PS512 represents the RSASSA-PSS signature algorithm using SHA-512.
var PS512 = newPS("PS512", crypto.SHA512, 4096)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package jwk
import (
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/mldsa"
"crypto/rsa"
"encoding/base64"
"errors"
"fmt"
"math/big"
)
// reader defines a function that decodes the key material from a [raw] JWK
// and constructs a concrete [Key].
type reader func(r *raw) (Key, error)
// readers maps a JWA algorithm name to the function responsible for parsing
// its key material.
var readers map[string]reader
// decoder decodes the key material for a specific key type T.
type decoder[T crypto.PublicKey] func(*raw) (T, error)
// decodeRSA parses the material for an RSA public key.
func decodeRSA(raw *raw) (*rsa.PublicKey, error) {
if raw.Kty != "RSA" {
return nil, fmt.Errorf("incompatible key type %q", raw.Kty)
}
if len(raw.N) == 0 {
return nil, errors.New("missing modulus")
}
if len(raw.E) == 0 {
return nil, errors.New("missing public exponent")
}
nBytes, err := base64.RawURLEncoding.DecodeString(raw.N)
if err != nil {
return nil, fmt.Errorf("decode modulus: %w", err)
}
eBytes, err := base64.RawURLEncoding.DecodeString(raw.E)
if err != nil {
return nil, fmt.Errorf("decode public exponent: %w", err)
}
// Exponents > 2^31-1 are extremely rare and not recommended.
if len(eBytes) > 4 {
return nil, errors.New("public exponent exceeds 32 bits")
}
n := new(big.Int).SetBytes(nBytes)
e := 0
// The conversion to a big-endian unsigned integer is safe because of the
// length check above.
for _, b := range eBytes {
e = (e << 8) | int(b)
}
return &rsa.PublicKey{N: n, E: e}, nil
}
// decodeECDSA creates a [decoder] for the specified elliptic curve.
func decodeECDSA(crv elliptic.Curve) decoder[*ecdsa.PublicKey] {
return func(raw *raw) (*ecdsa.PublicKey, error) {
if raw.Kty != "EC" {
return nil, fmt.Errorf("incompatible key type %q", raw.Kty)
}
if raw.Crv != crv.Params().Name {
return nil, fmt.Errorf("incompatible curve %q", raw.Crv)
}
if len(raw.X) == 0 {
return nil, errors.New("missing x coordinate")
}
if len(raw.Y) == 0 {
return nil, errors.New("missing y coordinate")
}
xBytes, err := base64.RawURLEncoding.DecodeString(raw.X)
if err != nil {
return nil, fmt.Errorf("decode x coordinate: %w", err)
}
yBytes, err := base64.RawURLEncoding.DecodeString(raw.Y)
if err != nil {
return nil, fmt.Errorf("decode y coordinate: %w", err)
}
// Calculate the required byte size for the curve coordinates.
size := (crv.Params().BitSize + 7) / 8
if len(xBytes) > size || len(yBytes) > size {
return nil, errors.New("coordinate length exceeds curve size")
}
// Construct the SEC 1 uncompressed point format: 0x04 || X || Y.
uncompressed := make([]byte, 1+(2*size))
uncompressed[0] = 4
copy(uncompressed[1+size-len(xBytes):1+size], xBytes)
copy(uncompressed[1+(2*size)-len(yBytes):], yBytes)
pub, err := ecdsa.ParseUncompressedPublicKey(crv, uncompressed)
if err != nil {
return nil, fmt.Errorf("parse public key: %w", err)
}
return pub, nil
}
}
// decodeEdDSA parses the material for an EdDSA public key.
func decodeEdDSA(raw *raw) (ed25519.PublicKey, error) {
if raw.Kty != "OKP" {
return nil, fmt.Errorf("incompatible key type %q", raw.Kty)
}
if raw.Crv != "Ed25519" {
return nil, fmt.Errorf("unsupported curve %q", raw.Crv)
}
n := ed25519.PublicKeySize
x, err := base64.RawURLEncoding.DecodeString(raw.X)
if err != nil {
return nil, fmt.Errorf("decode x coordinate: %w", err)
}
if m := len(x); m != n {
return nil, fmt.Errorf(
"illegal key size for %s curve: got %d, want %d", raw.Crv, m, n,
)
}
return x, nil
}
// decodeMLDSA creates a [decoder] for the specified ML-DSA parameter set.
// ML-DSA keys use the "AKP" (Algorithm Key Pair) key type with the public key
// encoding carried in the "pub" parameter, as defined in
// draft-ietf-cose-dilithium.
func decodeMLDSA(params mldsa.Parameters) decoder[*mldsa.PublicKey] {
return func(raw *raw) (*mldsa.PublicKey, error) {
if raw.Kty != "AKP" {
return nil, fmt.Errorf("incompatible key type %q", raw.Kty)
}
if len(raw.Pub) == 0 {
return nil, errors.New("missing public key")
}
b, err := base64.RawURLEncoding.DecodeString(raw.Pub)
if err != nil {
return nil, fmt.Errorf("decode public key: %w", err)
}
pub, err := mldsa.NewPublicKey(params, b)
if err != nil {
return nil, fmt.Errorf("parse public key: %w", err)
}
return pub, nil
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package jwk
import (
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/mldsa"
"crypto/rsa"
"encoding/base64"
"errors"
"fmt"
"github.com/deep-rent/nexus/sec/jose/jwa"
"github.com/deep-rent/nexus/sec/sign"
)
// writers maps a JWA algorithm name to the function responsible for encoding
// its key material.
var writers map[string]writer
// writer defines a function that encodes the key material into a marshallable
// JWT struct.
type writer func(mat any, r *raw) error
// pairer defines a function that binds a [sign.Signer] to a registered
// algorithm, producing a [KeyPair]. It returns nil if the signer's public key
// type does not match the algorithm.
type pairer func(kid string, s sign.Signer) KeyPair
// pairers maps a JWA algorithm name to the function responsible for building
// key pairs.
var pairers map[string]pairer
// keyer defines a function that binds public key material to a registered
// algorithm, producing a verification-only [Key]. It returns nil if the
// public key type does not match the algorithm.
type keyer func(kid string, pub crypto.PublicKey) Key
// keyers maps a JWA algorithm name to the function responsible for building
// verification-only keys.
var keyers map[string]keyer
// generator defines a function that mints a fresh [KeyPair] for a
// registered algorithm.
type generator func() (KeyPair, error)
// generators maps a JWA algorithm name to the function responsible for
// minting fresh key pairs. It doubles as the registry of supported
// algorithm names behind [Algorithms].
var generators map[string]generator
// register wires up an algorithm's decoding, encoding, and key and key
// pair construction in a type-safe manner. Every supported algorithm must
// be registered exactly once in init.
func register[T crypto.PublicKey](
alg jwa.Algorithm[T],
dec decoder[T],
enc encoder[T],
) {
name := alg.String()
readers[name] = func(r *raw) (Key, error) {
mat, err := dec(r)
if err != nil {
return nil, err
}
return NewKey(alg, r.Kid, mat), nil
}
writers[name] = func(mat any, r *raw) error {
pub, ok := mat.(T)
if !ok {
return fmt.Errorf("invalid key for algorithm %q", name)
}
return enc(pub, r)
}
pairers[name] = func(kid string, s sign.Signer) KeyPair {
return NewKeyPair(alg, kid, s)
}
keyers[name] = func(kid string, pub crypto.PublicKey) Key {
mat, ok := pub.(T)
if !ok {
return nil
}
return NewKey(alg, kid, mat)
}
generators[name] = func() (KeyPair, error) {
return Generate(alg)
}
}
// encoder defines a function that populates the [raw] JWK parameters from the
// algorithm-specific key material.
type encoder[T crypto.PublicKey] func(mat T, r *raw) error
// encodeRSA populates the RSA-specific fields ("n", "e") in the [raw] JWK.
func encodeRSA(key *rsa.PublicKey, r *raw) error {
r.Kty = "RSA"
r.N = base64.RawURLEncoding.EncodeToString(key.N.Bytes())
e := key.E
if e == 0 {
return errors.New("RSA public exponent is zero")
}
var eBytes []byte
if e < 0xFFFFFF {
eBytes = make([]byte, 0, 3)
} else {
eBytes = make([]byte, 0, 4)
}
for e > 0 {
eBytes = append([]byte{byte(e)}, eBytes...)
e >>= 8
}
r.E = base64.RawURLEncoding.EncodeToString(eBytes)
return nil
}
// encodeECDSA populates the ECDSA-specific fields ("crv", "x", "y").
// It enforces fixed-width padding for coordinates as required by RFC 7518.
func encodeECDSA(key *ecdsa.PublicKey, r *raw) error {
r.Kty = "EC"
params := key.Params()
r.Crv = params.Name
// Obtain the SEC 1 uncompressed format: 0x04 || X || Y.
b, err := key.Bytes()
if err != nil {
return fmt.Errorf("encode ecdsa key: %w", err)
}
if len(b) < 1 || b[0] != 4 {
return errors.New("invalid public key format")
}
// Calculate coordinate size dynamically based on the returned slice.
size := (len(b) - 1) / 2
x := b[1 : 1+size]
y := b[1+size : 1+(2*size)]
r.X = base64.RawURLEncoding.EncodeToString(x)
r.Y = base64.RawURLEncoding.EncodeToString(y)
return nil
}
// encodeEdDSA populates the EdDSA-specific fields ("crv", "x").
// It determines the curve name based on the key length.
func encodeEdDSA(key ed25519.PublicKey, r *raw) error {
r.Kty = "OKP"
if len(key) != ed25519.PublicKeySize {
return fmt.Errorf("invalid EdDSA key length: %d", len(key))
}
r.Crv = "Ed25519"
r.X = base64.RawURLEncoding.EncodeToString(key)
return nil
}
// encodeMLDSA populates the ML-DSA-specific field ("pub"). The key type
// "AKP" (Algorithm Key Pair) is defined in draft-ietf-cose-dilithium.
func encodeMLDSA(key *mldsa.PublicKey, r *raw) error {
r.Kty = "AKP"
r.Pub = base64.RawURLEncoding.EncodeToString(key.Bytes())
return nil
}
// init registers all supported algorithms.
func init() {
const size = 13
readers = make(map[string]reader, size)
writers = make(map[string]writer, size)
pairers = make(map[string]pairer, size)
keyers = make(map[string]keyer, size)
generators = make(map[string]generator, size)
register(jwa.RS256, decodeRSA, encodeRSA)
register(jwa.RS384, decodeRSA, encodeRSA)
register(jwa.RS512, decodeRSA, encodeRSA)
register(jwa.PS256, decodeRSA, encodeRSA)
register(jwa.PS384, decodeRSA, encodeRSA)
register(jwa.PS512, decodeRSA, encodeRSA)
register(jwa.ES256, decodeECDSA(elliptic.P256()), encodeECDSA)
register(jwa.ES384, decodeECDSA(elliptic.P384()), encodeECDSA)
register(jwa.ES512, decodeECDSA(elliptic.P521()), encodeECDSA)
register(jwa.EdDSA, decodeEdDSA, encodeEdDSA)
register(jwa.MLDSA44, decodeMLDSA(mldsa.MLDSA44()), encodeMLDSA)
register(jwa.MLDSA65, decodeMLDSA(mldsa.MLDSA65()), encodeMLDSA)
register(jwa.MLDSA87, decodeMLDSA(mldsa.MLDSA87()), encodeMLDSA)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package jwk
import (
"context"
"crypto"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json/v2"
"errors"
"fmt"
"slices"
"github.com/deep-rent/nexus/sec/jose/jwa"
"github.com/deep-rent/nexus/sec/sign"
)
// Media types as registered in RFC 7517.
const (
MediaTypeKey = "application/jwk+json"
MediaTypeSet = "application/jwk-set+json"
)
// Hint represents a reference to a [Key], containing the minimum information
// needed to look one up in a [Set]. It effectively abstracts the JWS header
// fields used to select a key for signature verification.
type Hint interface {
// Algorithm returns the JWA algorithm name that the key is intended for.
// This must match the "alg" parameter in the JWS header.
Algorithm() string
// KeyID returns the unique identifier for the key. This must match the
// "kid" parameter in the JWS header.
KeyID() string
}
// Chained is a [Hint] whose token brought its own signing certificate
// chain along in the "x5c" header, rather than naming a key the
// verifier is expected to have fetched from elsewhere.
//
// A [Resolver] built for such issuers asserts this interface on the
// hint it is given and reads the chain from it; see the [x5c] package.
// The chain is unverified attacker-controlled input until an anchored
// verification says otherwise.
//
// [x5c]: github.com/deep-rent/nexus/sec/jose/x5c
type Chained interface {
Hint
// Chain returns the certificate chain carried by the token, leaf
// first, each certificate in standard (not URL-safe) base64 DER
// exactly as it arrived.
Chain() []string
}
// Key represents a public JSON Web Key (JWK) used for signature verification.
type Key interface {
Hint
// Verify checks a signature against a message using the key's material
// and its associated algorithm. It returns true if the signature is valid.
// It returns false if the signature is invalid.
Verify(msg, sig []byte) bool
// Public returns the raw cryptographic public key for encoding purposes.
// The private key is never exposed here.
Public() crypto.PublicKey
}
// key is a concrete implementation of the [Key] interface, generic over the
// public key type.
type key[T crypto.PublicKey] struct {
// alg is the JWA implementation for this key.
alg jwa.Algorithm[T]
// kid is the unique key identifier.
kid string
// mat is the actual cryptographic public key material.
mat T
}
// Algorithm implements [Hint].
func (k *key[T]) Algorithm() string { return k.alg.String() }
// KeyID implements [Hint].
func (k *key[T]) KeyID() string { return k.kid }
// Public implements [Key].
func (k *key[T]) Public() crypto.PublicKey { return k.mat }
// Verify implements [Key].
func (k *key[T]) Verify(msg, sig []byte) bool {
return k.alg.Verify(k.mat, msg, sig)
}
// KeyPair represents a JSON Web Key that is capable of both verification and
// signing. It embeds the public [Key] interface and wraps a [sign.Signer] for
// the private key operations.
type KeyPair interface {
Key
// Sign generates a signature for the given message.
Sign(ctx context.Context, msg []byte) ([]byte, error)
// Private returns the raw private key material backing the pair, or
// nil if it is not extractable. Software keys created by [Generate]
// or [sign.From] return their standard library private key, which
// callers persisting keys can [sign.Encode]; opaque handles (e.g. a
// KMS) never reveal it.
Private() crypto.PrivateKey
}
// keyPair is the concrete implementation of [KeyPair].
type keyPair[T crypto.PublicKey] struct {
// key is the underlying public key.
key[T]
// signer is the private key handle.
signer sign.Signer
}
// Sign implements [KeyPair].
func (p *keyPair[T]) Sign(ctx context.Context, msg []byte) ([]byte, error) {
return p.alg.Sign(ctx, p.signer, msg)
}
// Private implements [KeyPair], delegating to the underlying signer.
func (p *keyPair[T]) Private() crypto.PrivateKey { return p.signer.Private() }
// NewKey creates a verification-only [Key] programatically from its constituent
// parts. The type parameter T must match the public key type expected by the
// provided algorithm (e.g., [*rsa.PublicKey] for [jwa.RS256]).
func NewKey[T crypto.PublicKey](alg jwa.Algorithm[T], kid string, mat T) Key {
return &key[T]{alg: alg, kid: kid, mat: mat}
}
// NewKeyPair creates a signing-capable [KeyPair] using the specified signer.
// It returns nil if the signer's public key cannot be cast to type T.
func NewKeyPair[T crypto.PublicKey](
alg jwa.Algorithm[T],
kid string,
s sign.Signer,
) KeyPair {
mat, ok := s.Public().(T)
if !ok {
return nil
}
return &keyPair[T]{
alg: alg, kid: kid, mat: mat,
signer: s,
}
}
// NewKeyFor creates a verification-only [Key] by looking up the JWA
// algorithm by its standard name — the verification-only sibling of
// [NewKeyPairFor], for callers that hold the algorithm only at runtime and
// deliberately drop the private half, such as a vault source retiring a
// key.
//
// It returns an error if the algorithm is not supported, or if the public
// key type does not match the algorithm.
func NewKeyFor(alg, kid string, pub crypto.PublicKey) (Key, error) {
key, ok := keyers[alg]
if !ok {
return nil, fmt.Errorf("unsupported algorithm %q", alg)
}
k := key(kid, pub)
if k == nil {
return nil, fmt.Errorf(
"public key type %T does not match algorithm %q", pub, alg,
)
}
return k, nil
}
// NewKeyPairFor creates a signing-capable [KeyPair] by looking up the JWA
// algorithm by its standard name (e.g., "ES256"). This is useful when the
// algorithm is only known at runtime, for instance when loading keys from
// configuration.
//
// It returns an error if the algorithm is not supported, or if the signer's
// public key type does not match the algorithm.
func NewKeyPairFor(alg, kid string, s sign.Signer) (KeyPair, error) {
pair, ok := pairers[alg]
if !ok {
return nil, fmt.Errorf("unsupported algorithm %q", alg)
}
kp := pair(kid, s)
if kp == nil {
return nil, fmt.Errorf(
"public key type %T does not match algorithm %q", s.Public(), alg,
)
}
return kp, nil
}
// ErrIneligibleKey indicates that a key may be syntactically valid but should
// not be used for signature verification according to its "use" or "key_ops"
// parameters.
var ErrIneligibleKey = errors.New("ineligible for signature verification")
var (
errUndefinedKeyType = errors.New("undefined key type")
errUnspecifiedAlgorithm = errors.New("unspecified algorithm")
)
// Parse parses a single [Key] from the provided JSON input.
//
// It first checks if the key is eligible for signature verification. If not,
// it returns [ErrIneligibleKey]. Otherwise, it proceeds to validate the
// presence of required parameters ("kty" and "alg"), whether the algorithm is
// supported, and the integrity of the key material itself.
func Parse(in []byte) (Key, error) {
var raw raw
if err := json.Unmarshal(in, &raw); err != nil {
return nil, fmt.Errorf("invalid json format: %w", err)
}
// Per RFC 7517, a key's purpose is determined by the union of "use" and
// "key_ops". We perform this check first for efficiency, as we only care
// about signature verification keys.
if raw.Use != "sig" && !slices.Contains(raw.Ops, "verify") {
return nil, ErrIneligibleKey
}
if raw.Kty == "" {
return nil, errUndefinedKeyType
}
if raw.Alg == "" {
return nil, errUnspecifiedAlgorithm
}
read := readers[raw.Alg]
if read == nil {
return nil, fmt.Errorf("unknown algorithm %q", raw.Alg)
}
key, err := read(&raw)
if err != nil {
return nil, fmt.Errorf("read %s key material: %w", raw.Kty, err)
}
return key, nil
}
// Resolver provides lookups of keys for signature verification.
type Resolver interface {
// Find looks up a key using the specified hint. A key is returned only
// if both its key id and algorithm match the hint exactly.
// Otherwise, it returns nil.
Find(hint Hint) Key
}
// Write marshals a single [Key] into its JSON Web Key representation.
//
// It populates the standard JWK fields ("kty", "alg", "use", "kid")
// and the algorithm-specific public key parameters (e.g., "n" and "e" for RSA).
// The output is strictly compliant with RFC 7517 and RFC 7518, ensuring that
// elliptic curve coordinates are padded to the correct fixed width.
func Write(k Key) ([]byte, error) {
r, err := toRaw(k)
if err != nil {
return nil, err
}
return json.Marshal(r)
}
// toRaw converts a [Key] object into the [raw] DTO.
func toRaw(k Key) (*raw, error) {
write, ok := writers[k.Algorithm()]
if !ok {
return nil, fmt.Errorf("unsupported algorithm %q", k.Algorithm())
}
// Populate standard metadata.
r := &raw{
Alg: k.Algorithm(),
Kid: k.KeyID(),
Use: "sig",
}
// Populate algorithm-specific fields.
if err := write(k.Public(), r); err != nil {
return nil, err
}
return r, nil
}
// raw holds the JWK parameters including the key material.
type raw struct {
Kty string `json:"kty"`
Alg string `json:"alg"`
Use string `json:"use,omitempty"`
Ops []string `json:"key_ops,omitempty"`
Kid string `json:"kid,omitempty"`
N string `json:"n,omitempty"`
E string `json:"e,omitempty"`
Crv string `json:"crv,omitempty"`
X string `json:"x,omitempty"`
Y string `json:"y,omitempty"`
Pub string `json:"pub,omitempty"`
}
// Thumbprint generates a deterministic, unique fingerprint from any standard
// public key (e.g., RSA, ECDSA, Ed25519). This fingerprint is designed to be
// used as a Key ID ("kid") for identifying keys.
//
// Note: This calculates the SHA-256 hash of the PKIX DER-encoded public key
// and returns it as a raw base64url-encoded string. It does not implement
// the JWK Thumbprint specification (RFC 7638).
func Thumbprint(pub crypto.PublicKey) (string, error) {
der, err := x509.MarshalPKIXPublicKey(pub)
if err != nil {
return "", fmt.Errorf("failed to marshal public key: %w", err)
}
hash := sha256.Sum256(der)
return base64.RawURLEncoding.EncodeToString(hash[:]), nil
}
// Generate randomly generates a new signing-capable [KeyPair] for the given
// JSON Web Algorithm. The generated private key is wrapped as a [sign.Signer],
// and the Key ID ("kid") is automatically computed as the SHA-256 [Thumbprint]
// of the corresponding public key.
//
// It returns an error if the key pair generation fails, if computing the
// thumbprint fails, or if the generated key type cannot be typed to the public
// key material type T of the specified algorithm.
func Generate[T crypto.PublicKey](alg jwa.Algorithm[T]) (KeyPair, error) {
key, err := alg.Generate()
if err != nil {
return nil, err
}
kid, err := Thumbprint(key.Public())
if err != nil {
return nil, err
}
out := NewKeyPair(alg, kid, sign.From(key))
if out == nil {
return nil, fmt.Errorf(
"key type %T does not match expected algorithm key type",
key.Public(),
)
}
return out, nil
}
// GenerateFor randomly generates a new signing-capable [KeyPair] by looking
// up the JWA algorithm by its standard name (e.g., "ES256") — the
// runtime-algorithm sibling of [Generate], for callers that read the
// algorithm from configuration or a command line rather than naming it in
// code.
//
// It returns an error if the algorithm is not supported, or if key
// generation fails.
func GenerateFor(alg string) (KeyPair, error) {
gen, ok := generators[alg]
if !ok {
return nil, fmt.Errorf("unsupported algorithm %q", alg)
}
return gen()
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package jwk
import (
"context"
"encoding/json/jsontext"
"encoding/json/v2"
"errors"
"fmt"
"iter"
"net/http"
"slices"
"strings"
"time"
"github.com/deep-rent/nexus/dat/cache"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/schedule"
)
// Set stores an immutable collection of [Key] instances, typically parsed from
// a JWKS. It extends [Resolver] with the ability to iterate over all keys in
// the set and to get the number of keys.
type Set interface {
Resolver
// Size returns the number of keys in this set.
Size() int
// Keys returns an iterator over all keys in this set.
Keys() iter.Seq[Key]
}
// newSet creates a new, empty [set] with the specified initial capacity.
func newSet(n int) *set {
return &set{
keys: make([]Key, 0, n),
kidx: make(map[string]int, n),
}
}
// set is the concrete implementation of the [Set] interface.
// It uses maps for efficient O(1) average time complexity lookups.
type set struct {
// keys is the slice of keys in the set.
keys []Key
// kidx maps key id to index in keys array.
kidx map[string]int
}
// Keys implements [Set].
func (s *set) Keys() iter.Seq[Key] { return slices.Values(s.keys) }
// Size implements [Set].
func (s *set) Size() int { return len(s.keys) }
// Find implements [Set].
func (s *set) Find(hint Hint) Key {
if hint == nil {
return nil
}
i, ok := s.kidx[hint.KeyID()]
if !ok {
return nil
}
k := s.keys[i]
if k.Algorithm() != hint.Algorithm() {
return nil
}
return k
}
// NewSet constructs a new [Set] containing the provided keys.
//
// It is primarily used to programmatically build a JSON Web Key Set from
// individual keys, for instance when preparing to expose a JWKS endpoint.
// The keys are sorted lexicographically by their Key ID to guarantee a
// deterministic output order.
//
// If multiple keys share the same Key ID, the latter keys after sorting
// will overwrite the earlier ones in the internal lookup maps.
func NewSet(keys ...Key) Set {
if len(keys) == 0 {
return empty
}
if len(keys) == 1 {
return Singleton(keys[0])
}
sorted := slices.Clone(keys)
slices.SortFunc(sorted, compare)
s := newSet(len(sorted))
for _, k := range sorted {
i := len(s.keys)
s.keys = append(s.keys, k)
s.kidx[k.KeyID()] = i
}
return s
}
// compare is a helper function used to compare two keys for sorting purposes.
func compare(a, b Key) int {
return strings.Compare(a.KeyID(), b.KeyID())
}
// emptySet represents a [Set] containing no keys.
type emptySet struct{}
// Keys implements [Set] for [emptySet].
func (emptySet) Keys() iter.Seq[Key] { return func(func(Key) bool) {} }
// Size implements [Set] for [emptySet].
func (emptySet) Size() int { return 0 }
// Find implements [Set] for [emptySet].
func (emptySet) Find(Hint) Key { return nil }
// empty is a singleton instance of an empty [Set].
var empty Set = emptySet{}
// singletonSet is an adapter that wraps a single [Key] as a [Set].
type singletonSet struct {
// key is the single key in the set.
key Key
}
// Keys implements [Set] for [singletonSet].
func (s *singletonSet) Keys() iter.Seq[Key] {
return func(f func(Key) bool) { f(s.key) }
}
// Size implements [Set] for [singletonSet].
func (*singletonSet) Size() int { return 1 }
// Find implements [Set] for [singletonSet]. It mirrors the semantics of the
// multi-key set: the hint's key id and algorithm must both match exactly.
func (s *singletonSet) Find(hint Hint) Key {
if hint == nil {
return nil
}
if s.key.KeyID() != hint.KeyID() {
return nil
}
if s.key.Algorithm() != hint.Algorithm() {
return nil
}
return s.key
}
// ParseSet parses a [Set] from a JWKS JSON input.
//
// If the top-level JSON structure is malformed, it returns an empty set and
// a fatal error. Otherwise, it iterates through the "keys" array, parsing
// each key individually. Keys that are invalid, unsupported, or occur multiple
// times, result in non-fatal errors. Ineligible keys (e.g., those meant for
// encryption) are silently skipped. If any non-fatal errors occurred, a joined
// error is returned alongside the set of successfully parsed keys.
func ParseSet(in []byte) (Set, error) {
var raw struct {
// Defer unmarshaling of individual keys to safely skip ineligible ones.
Keys []jsontext.Value `json:"keys"`
}
if err := json.Unmarshal(in, &raw); err != nil {
return empty, fmt.Errorf("invalid format: %w", err)
}
n := len(raw.Keys)
if n == 0 {
return empty, nil
}
s := newSet(n)
var errs []error
for i, v := range raw.Keys {
k, err := Parse(v)
if err != nil {
if errors.Is(err, ErrIneligibleKey) {
continue
}
err = fmt.Errorf("key at index %d: %w", i, err)
errs = append(errs, err)
continue
}
kid := k.KeyID()
if kid == "" {
errs = append(errs, fmt.Errorf(
"key at index %d: missing key id", i,
))
continue
}
// Check for duplicates before mutating the set.
if _, ok := s.kidx[kid]; ok {
errs = append(errs, fmt.Errorf(
"key at index %d: duplicate key id %q", i, kid,
))
continue
}
// Determines the index in the keys'slice where this new key will be
// stored. This is safe because we are appending linearly.
idx := len(s.keys)
// Append the key exactly once.
s.keys = append(s.keys, k)
// Update the lookup maps.
s.kidx[kid] = idx
}
return s, errors.Join(errs...)
}
// WriteSet marshals a [Set] into a JSON Web Key Set (JWKS) document.
//
// The resulting JSON corresponds to the standard JWKS structure:
//
// {
// "keys": [ ... ]
// }
//
// This function efficiently iterates over the keys in the set, converting them
// to their raw JSON representation before marshaling the entire collection.
func WriteSet(s Set) ([]byte, error) {
// We marshal into a slice of raw structs directly.
// This is more efficient than calling Write() loop, which would
// result in double-marshaling.
keys := make([]raw, 0, s.Size())
for k := range s.Keys() {
r, err := toRaw(k)
if err != nil {
return nil, fmt.Errorf("encode key %q: %w", k.KeyID(), err)
}
keys = append(keys, *r)
}
return json.Marshal(struct {
Keys []raw `json:"keys"`
}{
Keys: keys,
})
}
// Singleton creates a [Set] that contains only the provided [Key].
func Singleton(key Key) Set {
return &singletonSet{key: key}
}
// CacheSet extends the [Set] interface with [schedule.Tick], creating a
// component that can be deployed to a scheduler for automatic refreshing of a
// remote JWKS view in the background. The default implementation is backed by
// a [cache.Controller].
type CacheSet interface {
Set
schedule.Tick
// Ready returns a channel that is closed once the first successful fetch
// of the remote key set has completed. Until then, the set is empty and
// every key lookup fails; consumers can block on this channel during
// startup to ensure verification keys are available.
Ready() <-chan struct{}
}
// cacheSet is the concrete implementation of the [CacheSet] interface.
type cacheSet struct {
// ctrl manages the lifecycle and fetching of the remote JWKS.
ctrl cache.Controller[Set]
}
// get safely retrieves the current [Set] from the cache controller. If the
// cache has not been populated yet (e.g., due to an initial network failure),
// it returns a static [empty] set to ensure that delegated operations like Find
// do not panic. This makes the [Set] resilient to transient startup issues.
func (s *cacheSet) get() Set {
if set, ok := s.ctrl.Get(); ok {
return set
}
return empty
}
// Keys implements [Set].
func (s *cacheSet) Keys() iter.Seq[Key] { return s.get().Keys() }
// Size implements [Set].
func (s *cacheSet) Size() int { return s.get().Size() }
// Find implements [Set].
func (s *cacheSet) Find(hint Hint) Key { return s.get().Find(hint) }
// Run implements [schedule.Tick].
func (s *cacheSet) Run(ctx context.Context) time.Duration {
return s.ctrl.Run(ctx)
}
// Ready implements [CacheSet].
func (s *cacheSet) Ready() <-chan struct{} { return s.ctrl.Ready() }
var _ CacheSet = (*cacheSet)(nil)
// mapper adapts the [ParseSet] function to the [cache.Mapper] interface.
var mapper cache.Mapper[Set] = func(r *cache.Response) (Set, error) {
set, err := ParseSet(r.Body)
if set.Size() == 0 {
return nil, errors.New("no valid keys found")
}
if err != nil && r.Logger.Enabled(r.Ctx, log.LevelDebug) {
r.Logger.Debug(
r.Ctx,
"Some keys could not be parsed",
log.Error(err),
)
}
// Don't complain unless there are no keys available at all.
return set, nil
}
// NewCacheSet creates a new [CacheSet] that stays in sync with a remote JWKS
// endpoint. It must be deployed to a [schedule.] to begin the
// background fetching and refreshing process.
//
// The provided [cache.Option] can configure behaviors like refresh interval,
// request timeouts, and error handling; pass [cache.WithClient] to fetch with
// a custom [net/http.Client]. Parsing of retrieved key sets is
// extremely lenient: it will only fail if no valid keys are found at all.
func NewCacheSet(url string, opts ...cache.Option) CacheSet {
ctrl := cache.NewController(url, mapper, opts...)
return &cacheSet{ctrl}
}
// Handler returns a [router.HandlerFunc] that serves the provided [Set]
// as a standard JSON Web Key Set (JWKS) document.
//
// This allows other services to dynamically fetch the public keys required
// to verify signatures. If the provided Set is a dynamically updating cache
// (such as a [CacheSet]), the handler will automatically serve the latest keys.
func Handler(s Set) router.HandlerFunc {
return func(e *router.Exchange) error {
data, err := WriteSet(s)
if err != nil {
return err
}
e.SetHeader("Content-Type", MediaTypeSet)
e.Status(http.StatusOK)
_, err = e.W.Write(data)
return err
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package jwt
import (
"bytes"
"context"
"encoding/base64"
"encoding/json/jsontext"
"encoding/json/v2"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/deep-rent/nexus/sec/jose/jwk"
"github.com/deep-rent/nexus/std/ascii"
)
// Type is the media type of a JWT, as defined in RFC 7519.
const Type = "JWT"
var jsonOptions = json.JoinOptions(
json.WithMarshalers(json.MarshalFunc(func(t time.Time) ([]byte, error) {
if t.IsZero() {
return []byte("null"), nil
}
return strconv.AppendInt(nil, t.Unix(), 10), nil
})),
json.WithUnmarshalers(
json.UnmarshalFunc(func(b []byte, t *time.Time) error {
if string(b) == "null" {
*t = time.Time{}
return nil
}
i, err := strconv.ParseInt(string(b), 10, 64)
if err != nil {
return err
}
*t = time.Unix(i, 0)
return nil
}),
),
)
// Header provides access to the metadata associated with a JWT, such as the
// cryptographic algorithm used to sign the token and identifiers for the
// signing key.
//
// It is an alias for [jwk.Hint], allowing it to be passed directly to a
// [jwk.Resolver]'s Find method to locate the appropriate verification key.
type Header jwk.Hint
// header is the concrete implementation of the [Header] interface, providing
// JSON tags for standard JWS header parameters.
type header struct {
// Typ is the media type of the JWT.
Typ string `json:"typ,omitempty"`
// Alg is the JWA algorithm identifier.
Alg string `json:"alg"`
// Kid is the key identifier.
Kid string `json:"kid,omitempty"`
// X5c is the signing certificate chain, carried by issuers that
// ship the key with the token instead of publishing a key set.
// It is never written by [Sign]; see [jwk.Chained].
X5c []string `json:"x5c,omitempty"`
}
// Type returns the "typ" parameter from the header.
func (h *header) Type() string { return h.Typ }
// Chain implements [jwk.Chained].
func (h *header) Chain() []string { return h.X5c }
// Algorithm implements [jwk.Hint].
func (h *header) Algorithm() string { return h.Alg }
// KeyID implements [jwk.Hint].
func (h *header) KeyID() string { return h.Kid }
var (
_ Header = (*header)(nil)
_ jwk.Chained = (*header)(nil)
)
var (
// ErrKeyNotFound is returned when no matching key is found in the JWK set.
ErrKeyNotFound = errors.New("no matching key found")
// ErrInvalidSignature is returned when the token's signature differs from
// the computed signature.
ErrInvalidSignature = errors.New("invalid signature")
)
// Token represents a parsed, but not necessarily verified, JWT.
// The generic type T is the user-defined claims structure.
type Token[T Claims] interface {
// Header returns the token's header parameters.
Header() Header
// Claims returns the token's payload claims.
Claims() T
// Verify checks the token's signature using the provided JWK resolver.
// It returns [ErrKeyNotFound] if no matching key is found or
// [ErrInvalidSignature] if the signature is incorrect.
Verify(resolver jwk.Resolver) error
}
// token is the internal implementation of the [Token] interface.
type token[T Claims] struct {
// header contains the JWS header fields.
header Header
// claims contains the unmarshaled payload.
claims T
// msg is the raw JWS Protected Header and JWS Payload.
msg []byte
// sig is the raw JWS Signature.
sig []byte
}
// Header implements [Token].
func (t *token[T]) Header() Header { return t.header }
// Claims implements [Token].
func (t *token[T]) Claims() T { return t.claims }
// Verify implements [Token].
func (t *token[T]) Verify(resolver jwk.Resolver) error {
key := resolver.Find(t.header)
if key == nil {
return ErrKeyNotFound
}
if !key.Verify(t.msg, t.sig) {
return ErrInvalidSignature
}
return nil
}
var _ Token[Claims] = (*token[Claims])(nil)
// Audience represents the "aud" (Audience) claim of a JWT as defined in
// RFC 7519, Section 4.1.3.
//
// Because the "aud" claim can be either a single case-sensitive string or
// an array of such strings, this type implements custom JSON unmarshaling
// logic to ensure it is always handled as a slice of strings internally.
// Embed it in custom claims structs whenever the token source may use
// either encoding.
type Audience []string
// MarshalJSON writes a lone audience as a bare string and several as an
// array. RFC 7519 Section 4.1.3 permits both, but the single string is
// the form issuers document and verifiers expect -- Google's own
// service-account grant among them -- and it is what [Audience]
// already accepts on the way in.
func (a Audience) MarshalJSON() ([]byte, error) {
if len(a) == 1 {
return json.Marshal(a[0], jsonOptions)
}
// A slice conversion, so this does not call itself.
return json.Marshal([]string(a), jsonOptions)
}
// UnmarshalJSON handles the polymorphic nature of the "aud" claim.
func (a *Audience) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s, jsonOptions); err == nil {
*a = Audience{s}
return nil
}
var m []string
if err := json.Unmarshal(b, &m, jsonOptions); err == nil {
*a = Audience(m)
return nil
}
return errors.New("expected a string or an array of strings")
}
// Claims provides access to the standard JWT claims.
// It is used by [Verifier] for claim validation.
type Claims interface {
// ID returns the "jti" (JWT ID) claim, or an empty string if absent.
ID() string
// Subject returns the "sub" (Subject) claim, or an empty string if
// absent. The claim is treated as an opaque, issuer-scoped string;
// parsing it into a richer identifier type (e.g., a UUID) is left to
// the claims implementation.
Subject() string
// Issuer returns the "iss" (Issuer) claim, or an empty string if absent.
Issuer() string
// Audience returns the "aud" (Audience) claim, or nil if absent.
Audience() []string
// IssuedAt returns the "iat" (Issued At) claim, or the zero time if absent.
IssuedAt() time.Time
// ExpiresAt returns the "exp" (Expires At) claim, or the zero time if
// absent.
ExpiresAt() time.Time
// NotBefore returns the "nbf" (Not Before) claim, or the zero time if
// absent.
NotBefore() time.Time
}
// MutableClaims extends [Claims] with setters for standard JWT claims.
//
// The setter methods are not safe for concurrent use and should only be called
// during token creation.
type MutableClaims interface {
Claims
// SetID sets the "jti" (JWT ID) claim.
SetID(id string)
// SetSubject sets the "sub" (Subject) claim.
SetSubject(sub string)
// SetIssuer sets the "iss" (Issuer) claim.
SetIssuer(iss string)
// SetAudience sets the "aud" (Audience) claim.
SetAudience(aud []string)
// SetIssuedAt sets the "iat" (Issued At) claim.
SetIssuedAt(t time.Time)
// SetExpiresAt sets the "exp" (Expires At) claim.
SetExpiresAt(t time.Time)
// SetNotBefore sets the "nbf" (Not Before) claim.
SetNotBefore(t time.Time)
}
// Reserved contains the standard registered claims for a JWT. It implements
// the [Claims] interface and should be embedded in custom claims structs to
// enable standard claim handling.
type Reserved struct {
Jti string `json:"jti,omitempty"` // JWT ID
Sub string `json:"sub,omitempty"` // Subject
Iss string `json:"iss,omitempty"` // Issuer
Aud Audience `json:"aud,omitempty"` // Audience
Iat time.Time `json:"iat,omitzero"` // Issued At
Exp time.Time `json:"exp,omitzero"` // Expires At
Nbf time.Time `json:"nbf,omitzero"` // Not Before
}
// ID implements [Claims].
func (r *Reserved) ID() string { return r.Jti }
// SetID implements [MutableClaims].
func (r *Reserved) SetID(id string) { r.Jti = id }
// Subject implements [Claims].
func (r *Reserved) Subject() string { return r.Sub }
// SetSubject implements [MutableClaims].
func (r *Reserved) SetSubject(sub string) { r.Sub = sub }
// Issuer implements [Claims].
func (r *Reserved) Issuer() string { return r.Iss }
// SetIssuer implements [MutableClaims].
func (r *Reserved) SetIssuer(iss string) { r.Iss = iss }
// Audience implements [Claims].
func (r *Reserved) Audience() []string { return r.Aud }
// SetAudience implements [MutableClaims].
func (r *Reserved) SetAudience(aud []string) { r.Aud = aud }
// IssuedAt implements [Claims].
func (r *Reserved) IssuedAt() time.Time { return r.Iat }
// SetIssuedAt implements [MutableClaims].
func (r *Reserved) SetIssuedAt(t time.Time) { r.Iat = t }
// ExpiresAt implements [Claims].
func (r *Reserved) ExpiresAt() time.Time { return r.Exp }
// SetExpiresAt implements [MutableClaims].
func (r *Reserved) SetExpiresAt(t time.Time) { r.Exp = t }
// NotBefore implements [Claims].
func (r *Reserved) NotBefore() time.Time { return r.Nbf }
// SetNotBefore implements [MutableClaims].
func (r *Reserved) SetNotBefore(t time.Time) { r.Nbf = t }
var _ MutableClaims = (*Reserved)(nil)
// DynamicClaims represents a standard JWT payload extended with arbitrary
// custom claims. It embeds the standard [Reserved] claims and captures any
// unmapped JSON properties into the Other map.
//
// By applying [jsontext.Value] and the `json:",embed"` tag from the
// encoding/json/v2 package, custom claims are retained as raw JSON bytes.
// This defers parsing until the exact target type is known, avoiding the
// common pitfalls of default map[string]any unmarshaling (such as all
// numbers defaulting to float64).
type DynamicClaims struct {
// Reserved contains the standard registered JWT claims.
Reserved
// Other captures all custom claims as raw JSON.
//nolint:revive // "embed" is an encoding/json/v2 option.
Other map[string]jsontext.Value `json:",embed"`
}
// Get retrieves a specific custom claim by key from the [DynamicClaims]
// payload and unmarshals it into the requested type T.
//
// It safely handles nil pointers, missing keys, and parsing errors. If the
// receiver is nil, the custom-claims map is uninitialized, the key is not
// found, or the raw JSON cannot be successfully unmarshaled into type T,
// Get returns the zero value of T and false. Otherwise, it returns the
// parsed value and true.
func (c *DynamicClaims) Get[T any](key string) (T, bool) {
if c == nil || c.Other == nil {
var zero T
return zero, false
}
val, ok := c.Other[key]
if !ok {
var zero T
return zero, false
}
var out T
if err := json.Unmarshal(val, &out, jsonOptions); err != nil {
var zero T
return zero, false
}
return out, true
}
// dot is the byte value for the delimiting character of JWS segments.
const dot = byte('.')
// Parse decodes a JWT from its compact serialization format into a [Token]
// without verifying the signature. The type parameter T specifies the target
// struct for the token's claims. If the token is malformed or the payload does
// not unmarshal into T (using encoding/json/v2), an error is returned.
func Parse[T Claims](in []byte) (Token[T], error) {
i := bytes.IndexByte(in, dot)
j := bytes.LastIndexByte(in, dot)
if i <= 0 || i == j || j == len(in)-1 {
return nil, errors.New("expected three dot-separated segments")
}
h, err := decode(in[:i])
if err != nil {
return nil, fmt.Errorf("failed to decode header: %w", err)
}
header := new(header)
if err := json.Unmarshal(h, header, jsonOptions); err != nil {
return nil, fmt.Errorf("failed to unmarshal header: %w", err)
}
if typ := header.Typ; typ != "" && !isJWT(typ) {
return nil, fmt.Errorf("unexpected token type %q", typ)
}
c, err := decode(in[i+1 : j])
if err != nil {
return nil, fmt.Errorf("failed to decode claims: %w", err)
}
var claims T
if err := json.Unmarshal(c, &claims, jsonOptions); err != nil {
return nil, fmt.Errorf("failed to unmarshal claims: %w", err)
}
sig, err := decode(in[j+1:])
if err != nil {
return nil, fmt.Errorf("failed to decode signature: %w", err)
}
msg := in[:j]
return &token[T]{
header: header,
claims: claims,
msg: msg,
sig: sig,
}, nil
}
// isJWT checks if the token type is a JWT.
// It handles special case such as "application/jwt" and "at+jwt".
func isJWT(typ string) bool {
typ = ascii.ToLower(typ)
typ = strings.TrimPrefix(typ, "application/")
return typ == "jwt" || strings.HasSuffix(typ, "+jwt")
}
// decode is a helper for Base64URL decoding without padding.
func decode(src []byte) ([]byte, error) {
n := base64.RawURLEncoding.DecodedLen(len(src))
d := make([]byte, n)
k, err := base64.RawURLEncoding.Decode(d, src)
if err != nil {
return nil, err
}
return d[:k], nil
}
// Verify first parses a JWT and then verifies its signature against a given key
// resolver. The type parameter T specifies the target struct for the token's
// claims.
//
// This function only checks the cryptographic signature, not the content of the
// claims. For claim validation (e.g., issuer, audience, expiration), create and
// configure a [Verifier]. It is a shorthand for [Parse] followed by calling
// [Token.Verify] on the resulting [Token].
func Verify[T Claims](resolver jwk.Resolver, in []byte) (T, error) {
tok, err := Parse[T](in)
if err != nil {
var zero T
return zero, err
}
if err := tok.Verify(resolver); err != nil {
var zero T
return zero, err
}
return tok.Claims(), nil
}
// Sign creates a new signed JWT using the provided [jwk.KeyPair] and claims.
//
// The claims may be any type that serializes to a JSON object — a struct
// embedding [Reserved], a map, or a [jsontext.Value] — and are marshaled
// with encoding/json/v2 alongside a header naming the key pair's algorithm
// and key ID.
func Sign(ctx context.Context, k jwk.KeyPair, claims any) ([]byte, error) {
// Prepare and marshal the header.
header := &header{
Typ: Type,
Alg: k.Algorithm(),
Kid: k.KeyID(),
}
h, err := json.Marshal(header, jsonOptions)
if err != nil {
return nil, fmt.Errorf("failed to marshal header: %w", err)
}
h = encode(h)
// Marshal the claims.
c, err := json.Marshal(claims, jsonOptions)
if err != nil {
return nil, fmt.Errorf("failed to marshal claims: %w", err)
}
c = encode(c)
// Construct the signing input (message).
msg := make([]byte, 0, len(h)+1+len(c))
msg = append(msg, h...)
msg = append(msg, '.')
msg = append(msg, c...)
sig, err := k.Sign(ctx, msg)
if err != nil {
return nil, fmt.Errorf("failed to sign token: %w", err)
}
sig = encode(sig)
// Assemble the final token.
token := make([]byte, 0, len(msg)+1+len(sig))
token = append(token, msg...)
token = append(token, dot)
token = append(token, sig...)
return token, nil
}
// encode is a helper for Base64URL encoding without padding.
func encode(src []byte) []byte {
dst := make([]byte, base64.RawURLEncoding.EncodedLen(len(src)))
base64.RawURLEncoding.Encode(dst, src)
return dst
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package jwt
import (
"time"
"github.com/deep-rent/nexus/std/clock"
)
// VerifierOption defines a functional option for configuring a [Verifier].
type VerifierOption func(*verifierConfig)
// verifierConfig holds the configuration options for a [Verifier].
type verifierConfig struct {
issuers []string // Set of trusted issuers
audiences []string // Set of trusted audiences
leeway time.Duration // Clock skew tolerance
age time.Duration // Maximum allowed token age
now clock.Clock // Time source for temporal validation
}
// WithIssuers adds one or more trusted issuers to the verifier. If a token's
// "iss" claim is missing or does not match one of these, it will be rejected.
// This option can be used multiple times to append additional values. By
// default, no issuer validation is performed.
func WithIssuers(iss ...string) VerifierOption {
return func(c *verifierConfig) {
c.issuers = append(c.issuers, iss...)
}
}
// WithAudiences adds one or more trusted audiences to the verifier. If the
// token's "aud" claim is missing or does not contain at least one of these
// values, it will be rejected. This option can be used multiple times to append
// additional values. By default, no audience validation is performed.
func WithAudiences(aud ...string) VerifierOption {
return func(c *verifierConfig) {
c.audiences = append(c.audiences, aud...)
}
}
// WithLeeway sets a grace period to allow for clock skew in temporal
// validations of the "exp", "nbf", and "iat" claims. It is subtracted from or
// added to the current time as appropriate. The default is zero, meaning no
// leeway. Negative values will be ignored.
func WithLeeway(d time.Duration) VerifierOption {
return func(c *verifierConfig) {
if d > 0 {
c.leeway = d
}
}
}
// WithMaxAge sets the maximum age for tokens based on their "iat" claim.
// Tokens without an "iat" claim will no longer be accepted. The default is
// zero, meaning no age validation. Negative values will be ignored.
func WithMaxAge(d time.Duration) VerifierOption {
return func(c *verifierConfig) {
if d > 0 {
c.age = d
}
}
}
// WithClock sets the function used to retrieve the current time during
// validation. This is useful for deterministic testing or synchronizing with
// an external time source. The default is [clock.System].
func WithClock(now clock.Clock) VerifierOption {
return func(c *verifierConfig) {
if now != nil {
c.now = now
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package jwt
import (
"errors"
"slices"
"time"
"github.com/deep-rent/nexus/sec/jose/jwk"
"github.com/deep-rent/nexus/std/clock"
)
var (
// ErrInvalidIssuer signals that the "iss" claim did not match any of the
// expected issuers.
ErrInvalidIssuer = errors.New("invalid issuer")
// ErrInvalidAudience signals that the "aud" claim did not match any of the
// expected audiences.
ErrInvalidAudience = errors.New("invalid audience")
// ErrTokenExpired signals that the "exp" claim is in the past.
ErrTokenExpired = errors.New("token is expired")
// ErrTokenNotYetActive signals that the "nbf" claim is in the future.
ErrTokenNotYetActive = errors.New("token not yet active")
// ErrTokenTooOld signals that the "iat" claim is further in the past than
// the configured maximum age.
ErrTokenTooOld = errors.New("token is too old")
)
// Verifier defines the interface for a configured, reusable JWT verifier. The
// type parameter T is the user-defined struct for the token's claims. It must
// implement the [Claims] interface, or else verification will always fail.
type Verifier[T Claims] interface {
// Verify parses a token from its compact serialization, verifies its
// signature against the verifier's key set, and validates its claims
// according to the verifier's configuration.
Verify(in []byte) (T, error)
}
// verifier is the default implementation of the [Verifier] interface.
type verifier[T Claims] struct {
keys jwk.Resolver
issuers []string
audiences []string
leeway time.Duration
age time.Duration
now clock.Clock
}
var _ Verifier[Claims] = (*verifier[Claims])(nil)
// NewVerifier creates a new [Verifier] bound to a specific JWK resolver.
// The type parameter T is the user-defined struct for the token's claims.
func NewVerifier[T Claims](
keys jwk.Resolver,
opts ...VerifierOption,
) Verifier[T] {
cfg := verifierConfig{
now: clock.System,
}
for _, opt := range opts {
opt(&cfg)
}
return &verifier[T]{
keys: keys,
issuers: cfg.issuers,
audiences: cfg.audiences,
leeway: cfg.leeway,
age: cfg.age,
now: cfg.now,
}
}
// Verify implements the [Verifier] interface.
func (v *verifier[T]) Verify(in []byte) (T, error) {
c, err := Verify[T](v.keys, in)
if err != nil {
var zero T
return zero, err
}
now := v.now()
if len(v.issuers) > 0 && !slices.Contains(v.issuers, c.Issuer()) {
var zero T
return zero, ErrInvalidIssuer
}
if len(v.audiences) > 0 {
found := false
for _, aud := range v.audiences {
if slices.Contains(c.Audience(), aud) {
found = true
break
}
}
if !found {
var zero T
return zero, ErrInvalidAudience
}
}
if nbf := c.NotBefore(); !nbf.IsZero() {
if now.Add(v.leeway).Before(nbf) {
var zero T
return zero, ErrTokenNotYetActive
}
}
if exp := c.ExpiresAt(); !exp.IsZero() {
if now.Add(-v.leeway).After(exp) {
var zero T
return zero, ErrTokenExpired
}
}
if iat := c.IssuedAt(); v.age > 0 && !iat.IsZero() {
if iat.Add(v.age).Before(now.Add(-v.leeway)) {
var zero T
return zero, ErrTokenTooOld
}
}
return c, nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package x5c
import (
"crypto/x509"
"slices"
"github.com/deep-rent/nexus/std/clock"
)
// Option configures a [Trust].
type Option func(*Trust)
// WithClock injects the time source the chain's validity window is
// judged against, which is primarily useful for testing. A nil clock
// is ignored.
func WithClock(now clock.Clock) Option {
return func(t *Trust) {
if now != nil {
t.now = now
}
}
}
// WithAlgorithms narrows the accepted "alg" values to those named —
// defense in depth for a deployment that knows what its issuer signs
// with. The default accepts every algorithm the [jwa] package
// implements whose key type the leaf actually carries.
//
// [jwa]: github.com/deep-rent/nexus/sec/jose/jwa
func WithAlgorithms(names ...string) Option {
return func(t *Trust) {
if len(names) > 0 {
t.algorithms = slices.Clone(names)
}
}
}
// WithUsage demands the leaf carry one of the given extended key
// usages. The default constrains nothing, which is right where the
// pinned root exists only to sign tokens and wrong wherever it also
// issues certificates for something else: without this, any leaf
// under that root — a TLS server certificate, say — signs tokens this
// package accepts.
func WithUsage(usages ...x509.ExtKeyUsage) Option {
return func(t *Trust) {
if len(usages) > 0 {
t.usages = slices.Clone(usages)
}
}
}
// WithIntermediates supplies certificates the issuer is known to omit
// from the header. The chain in the token is always preferred; these
// only fill gaps. A nil pool is ignored.
func WithIntermediates(pool *x509.CertPool) Option {
return func(t *Trust) {
if pool != nil {
t.intermediates = pool
}
}
}
// WithLeaf installs a check on the verified leaf, run after the chain
// reaches a pinned root and before its key is trusted with anything.
// It answers the question pinning a root cannot — WHICH certificate
// under that root may sign — by whatever the deployment goes on:
// subject, organizational unit, DNS name, policy OID. A nil check is
// ignored; a non-nil error refuses the token as [ErrSignature].
func WithLeaf(check func(*x509.Certificate) error) Option {
return func(t *Trust) {
if check != nil {
t.leaf = check
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package x5c
import (
"bytes"
"crypto/x509"
"encoding/base64"
"encoding/json/v2"
"errors"
"fmt"
"slices"
"github.com/deep-rent/nexus/sec/jose/jwk"
"github.com/deep-rent/nexus/std/clock"
)
// MaxChain bounds how many certificates a header may carry. Real
// chains run to three or four; the cap keeps a hostile header from
// spending the verifier's time on certificate parsing.
const MaxChain = 10
// ErrSignature reports a token this package refused: a chain that does
// not reach the pinned roots, a leaf the caller's own check rejected,
// an algorithm the leaf's key cannot carry, a signature that does not
// verify, a header that will not even parse.
//
// Everything an attacker controls maps onto it, so a caller can treat
// the whole class as "forged" without inspecting further — and, just
// as importantly, a forgery never reaches a caller as some other kind
// of error it would answer with a 500 and a retry.
var ErrSignature = errors.New("invalid signature")
// Trust verifies tokens against a pinned set of root certificates,
// resolving each token's signing key from the chain the token itself
// carries. It implements [jwk.Resolver], so it stands in wherever a
// key set would:
//
// claims, err := jwt.Verify[*MyClaims](trust, token)
//
// A Trust is immutable once built and safe for concurrent use.
type Trust struct {
roots *x509.CertPool
intermediates *x509.CertPool
algorithms []string
usages []x509.ExtKeyUsage
leaf func(*x509.Certificate) error
now clock.Clock
}
// New builds a Trust anchored at the given roots, which are the whole
// trust decision: pass exactly the issuer's published roots, never a
// system pool. It panics if roots is nil, since a Trust that verifies
// against nothing would accept everything.
func New(roots *x509.CertPool, opts ...Option) *Trust {
if roots == nil {
panic("root certificates are required")
}
t := &Trust{
roots: roots,
usages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
now: clock.System,
}
for _, opt := range opts {
opt(t)
}
return t
}
// Find implements [jwk.Resolver]. It reads the certificate chain off
// the hint — which must be a [jwk.Chained], as the headers parsed by
// the jwt package are — verifies it, and returns the leaf's key bound
// to the header's algorithm.
//
// Every refusal answers nil, which reaches a caller as
// [jwt.ErrKeyNotFound] and says nothing about why. Use [Trust.Verify]
// where the reason matters.
//
// [jwt.ErrKeyNotFound]: github.com/deep-rent/nexus/sec/jose/jwt#ErrKeyNotFound
func (t *Trust) Find(hint jwk.Hint) jwk.Key {
key, _, err := t.resolve(hint)
if err != nil {
return nil
}
return key
}
// Verify checks a compact JWS whose header carries an "x5c" chain and
// returns its payload together with the certificate that signed it —
// the entry point for payloads that are not JWT claim sets, and for
// callers who need the signer's identity or the reason a token was
// refused.
//
// Where the payload IS a claim set, prefer the resolver: jwt.Verify
// over this same Trust decodes the claims and validates issuer,
// audience, and expiry in one step.
func (t *Trust) Verify(in []byte) ([]byte, *x509.Certificate, error) {
i := bytes.IndexByte(in, '.')
j := bytes.LastIndexByte(in, '.')
if i <= 0 || i == j || j == len(in)-1 {
return nil, nil, fmt.Errorf(
"%w: expected three dot-separated segments", ErrSignature,
)
}
raw, err := decode(in[:i])
if err != nil {
return nil, nil, fmt.Errorf(
"%w: undecodable header", ErrSignature,
)
}
var h header
if err := json.Unmarshal(raw, &h); err != nil {
return nil, nil, fmt.Errorf(
"%w: unparsable header", ErrSignature,
)
}
key, leaf, err := t.resolve(&h)
if err != nil {
return nil, nil, err
}
sig, err := decode(in[j+1:])
if err != nil {
return nil, nil, fmt.Errorf(
"%w: undecodable signature", ErrSignature,
)
}
if !key.Verify(in[:j], sig) {
return nil, nil, ErrSignature
}
payload, err := decode(in[i+1 : j])
if err != nil {
return nil, nil, fmt.Errorf(
"%w: undecodable payload", ErrSignature,
)
}
return payload, leaf, nil
}
// resolve is the one verification both entry points run: chain first,
// then the key the surviving leaf carries.
func (t *Trust) resolve(
hint jwk.Hint,
) (jwk.Key, *x509.Certificate, error) {
chained, ok := hint.(jwk.Chained)
if !ok {
return nil, nil, fmt.Errorf(
"%w: the token carries no certificate chain", ErrSignature,
)
}
alg := hint.Algorithm()
if len(t.algorithms) > 0 && !slices.Contains(t.algorithms, alg) {
return nil, nil, fmt.Errorf(
"%w: algorithm %q is not accepted here", ErrSignature, alg,
)
}
leaf, err := t.anchor(chained.Chain())
if err != nil {
return nil, nil, err
}
// The algorithm is named by the (unverified) header, but the KEY
// comes from the verified chain, and pairing them is itself a
// check: NewKeyFor refuses an algorithm whose key type the leaf
// does not carry, so a token cannot steer verification onto a
// family — or, for ECDSA, a curve — its certificate never spoke.
key, err := jwk.NewKeyFor(alg, hint.KeyID(), leaf.PublicKey)
if err != nil {
return nil, nil, fmt.Errorf("%w: %w", ErrSignature, err)
}
return key, leaf, nil
}
// anchor parses the header's chain and verifies it against the pinned
// roots, returning the leaf that survives.
func (t *Trust) anchor(chain []string) (*x509.Certificate, error) {
switch {
case len(chain) == 0:
return nil, fmt.Errorf(
"%w: no certificate chain", ErrSignature,
)
case len(chain) > MaxChain:
return nil, fmt.Errorf(
"%w: chain of %d exceeds the %d the header may carry",
ErrSignature, len(chain), MaxChain,
)
}
certs := make([]*x509.Certificate, len(chain))
for i, enc := range chain {
der, err := base64.StdEncoding.DecodeString(enc)
if err != nil {
return nil, fmt.Errorf(
"%w: undecodable certificate %d", ErrSignature, i,
)
}
cert, err := x509.ParseCertificate(der)
if err != nil {
return nil, fmt.Errorf(
"%w: unparsable certificate %d", ErrSignature, i,
)
}
certs[i] = cert
}
// Whatever the caller supplied stands behind whatever the header
// carried, so an issuer that omits an intermediate still chains
// while a token that carries its own needs nothing configured.
intermediates := x509.NewCertPool()
if t.intermediates != nil {
intermediates = t.intermediates.Clone()
}
for _, cert := range certs[1:] {
intermediates.AddCert(cert)
}
if _, err := certs[0].Verify(x509.VerifyOptions{
Roots: t.roots,
Intermediates: intermediates,
CurrentTime: t.now(),
KeyUsages: t.usages,
}); err != nil {
return nil, fmt.Errorf("%w: %w", ErrSignature, err)
}
if t.leaf != nil {
if err := t.leaf(certs[0]); err != nil {
return nil, fmt.Errorf("%w: %w", ErrSignature, err)
}
}
return certs[0], nil
}
// header is the slice of the JWS header this package acts on. It
// implements [jwk.Chained], so the byte-level path and the resolver
// path meet at the same verification.
type header struct {
// Alg is the JWA algorithm identifier.
Alg string `json:"alg"`
// Kid is the key identifier, which chain-carrying issuers rarely
// set; it travels only so a resolved key can name itself.
Kid string `json:"kid,omitempty"`
// X5c is the certificate chain, leaf first, each certificate in
// standard (not URL-safe) base64 DER.
X5c []string `json:"x5c"`
}
// Algorithm implements [jwk.Hint].
func (h *header) Algorithm() string { return h.Alg }
// KeyID implements [jwk.Hint].
func (h *header) KeyID() string { return h.Kid }
// Chain implements [jwk.Chained].
func (h *header) Chain() []string { return h.X5c }
var (
_ jwk.Chained = (*header)(nil)
_ jwk.Resolver = (*Trust)(nil)
)
// decode is Base64URL decoding without padding.
func decode(src []byte) ([]byte, error) {
n := base64.RawURLEncoding.DecodedLen(len(src))
d := make([]byte, n)
k, err := base64.RawURLEncoding.Decode(d, src)
if err != nil {
return nil, err
}
return d[:k], nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package nonce
import (
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"io"
)
const (
// MinAlphabetSize is the minimum number of unique characters required in a
// custom alphabet passed to [NewSampler].
MinAlphabetSize = 2
// MaxAlphabetSize is the maximum number of unique characters allowed in a
// custom alphabet passed to [NewSampler]. The bound is 256 because sampling
// draws one byte per candidate rune, and a single byte cannot address more
// than 256 symbols without bias.
MaxAlphabetSize = 256
)
// Source supplies cryptographically secure random bytes. It is the injection
// point of the package: production code uses [DefaultSource], while tests or
// specialized deployments can substitute a deterministic reader, a hardware
// module, or a remote KMS.
//
// Implementations must fill buffers completely, honor cancellation via the
// input context, and be safe for concurrent use.
type Source interface {
// Read fills the byte buffer entirely with random bytes, or returns an
// error. Note that a partial read must be reported as an error rather than
// a short fill.
Read(ctx context.Context, b []byte) error
}
// source is the default [Source], backed by [crypto/rand].
type source struct{}
func (source) Read(ctx context.Context, b []byte) error {
if err := ctx.Err(); err != nil {
return err
}
_, err := io.ReadFull(rand.Reader, b)
return err
}
// DefaultSource draws entropy from [crypto/rand], the operating system's
// cryptographically secure random number generator. It is used whenever a
// constructor receives a nil [Source].
var DefaultSource Source = source{}
// Generator produces fixed-length, high-entropy tokens by drawing raw bytes
// from a [Source]. It is safe for concurrent use.
//
// Use a Generator for opaque, machine-readable secrets such as bearer tokens,
// session identifiers, or CSRF tokens, where the full byte range is desirable.
// For human-readable output constrained to a specific alphabet, use a
// [Sampler] instead.
type Generator struct {
src Source
n int
}
// NewGenerator returns a [Generator] that draws n bytes per token from the
// the given source. If the source is nil, [DefaultSource] is used.
//
// A token of n bytes carries 8n bits of entropy; 32 bytes (256 bits) is a
// sound default for unguessable secrets. NewGenerator panics if n is not
// positive, since the size is configuration rather than runtime input.
func NewGenerator(src Source, n int) *Generator {
if n <= 0 {
panic("size must be positive")
}
if src == nil {
src = DefaultSource
}
return &Generator{src: src, n: n}
}
// Bytes returns n freshly drawn random bytes, where n is the size fixed at
// construction. It returns any error reported by the underlying [Source],
// including cancellation of ctx.
func (g *Generator) Bytes(ctx context.Context) ([]byte, error) {
b := make([]byte, g.n)
if err := g.src.Read(ctx, b); err != nil {
return nil, err
}
return b, nil
}
// Draw returns a token encoded as an unpadded base64url string. The output is
// safe for inclusion in URLs, HTTP headers, and JSON payloads; 32 bytes of
// entropy yield a 43-character string. It returns any error reported by the
// underlying [Source], including cancellation of ctx.
func (g *Generator) Draw(ctx context.Context) (string, error) {
b, err := g.Bytes(ctx)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// DefaultGenerator draws 32-byte (256-bit) tokens from [DefaultSource]. It is a
// ready-to-use replacement for one-off opaque token generation.
var DefaultGenerator = NewGenerator(nil, 32)
// Sampler produces fixed-length strings whose characters are drawn uniformly
// from a custom alphabet. It is safe for concurrent use.
//
// Sampling maps random bytes onto runes using rejection sampling, which
// discards the biased tail of the byte range so that every rune is equally
// likely. The alphabet is UTF-8 safe and may contain multi-byte runes, making
// a Sampler suitable for human-readable PINs, coupon codes, or short
// verification tokens.
type Sampler struct {
src Source
runes []rune
n int
lim int // largest byte value + 1 that maps without modulo bias
buf int // scratch buffer size, over-provisioned for rejected bytes
}
// NewSampler returns a [Sampler] that draws n-rune strings from the given
// alphabet using the provided source. If the source is nil, [DefaultSource]
// is used.
//
// The alphabet's distinct runes define the output symbols. It panics if the
// size is not positive, or if the alphabet holds fewer than [MinAlphabetSize]
// or more than [MaxAlphabetSize] runes, since these are configuration rather
// than runtime input. Duplicate runes are not collapsed and will be sampled
// more frequently; supply a de-duplicated alphabet if uniform weighting
// matters.
func NewSampler(src Source, alphabet string, n int) *Sampler {
if n <= 0 {
panic("size must be positive")
}
runes := []rune(alphabet)
size := len(runes)
switch {
case size < MinAlphabetSize:
panic(fmt.Sprintf(
"alphabet must contain %d characters or less",
MinAlphabetSize,
))
case size > MaxAlphabetSize:
panic(fmt.Sprintf(
"alphabet must contain %d characters or more",
MaxAlphabetSize,
))
}
if src == nil {
src = DefaultSource
}
// Reject byte values at or above lim so that the remaining values divide
// evenly across the alphabet, eliminating modulo bias.
lim := MaxAlphabetSize - (MaxAlphabetSize % size)
// On average 256/lim bytes are consumed per accepted rune. Size the scratch
// buffer to cover that expected cost plus a small margin, so a single read
// usually suffices. Correctness never depends on this: Draw reads again
// whenever the buffer is exhausted before n runes are filled.
buf := (n*MaxAlphabetSize)/lim + 8
return &Sampler{src: src, runes: runes, n: n, lim: lim, buf: buf}
}
// Draw returns a string of n runes drawn uniformly from the alphabet, where n
// is the size fixed at construction. It returns any error reported by the
// underlying [Source], including cancellation of the context.
func (s *Sampler) Draw(ctx context.Context) (string, error) {
size := len(s.runes)
out := make([]rune, s.n)
b := make([]byte, s.buf)
filled := 0
for filled < s.n {
// Assume that the source re-raises context errors.
// if err := ctx.Err(); err != nil {
// return "", err
// }
if err := s.src.Read(ctx, b); err != nil {
return "", err
}
for _, w := range b {
if int(w) < s.lim {
out[filled] = s.runes[int(w)%size]
filled++
if filled == s.n {
break
}
}
}
}
return string(out), nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package pass
// Option customizes a [Hasher] during construction with [New].
type Option func(*Hasher)
// WithAlgorithm registers an [Algorithm] for verification under its name.
// Records naming the algorithm verify against it; new hashes are not
// affected. Registering a second algorithm with the same name replaces the
// first.
//
// It panics if the algorithm is nil or unnamed, since both are startup
// configuration errors.
func WithAlgorithm(alg Algorithm) Option {
return func(h *Hasher) { h.register(alg) }
}
// WithDefault registers an [Algorithm] like [WithAlgorithm] and
// additionally selects it for hashing new passwords.
//
// It panics if the algorithm is nil or unnamed, since both are startup
// configuration errors.
func WithDefault(alg Algorithm) Option {
return func(h *Hasher) {
h.register(alg)
h.def = alg
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package pass
import (
"encoding/json/v2"
"errors"
"fmt"
)
var (
// ErrUnknownAlgorithm is returned when a record names an algorithm
// that has not been registered with the [Hasher].
ErrUnknownAlgorithm = errors.New("unknown hashing algorithm")
// ErrMalformedRecord is returned when a record cannot be parsed or
// misses required parameters.
ErrMalformedRecord = errors.New("malformed password record")
)
// Record is the self-describing result of hashing a password. It carries
// the algorithm name and every parameter verification needs, so that
// stored hashes remain verifiable after the server's configuration
// changes.
type Record struct {
// Algorithm names the hashing scheme that produced this record. It is
// the key under which [Hasher] resolves the [Algorithm] during
// verification.
Algorithm string `json:"alg"`
// Iterations is the work factor the digest was derived with. Its exact
// meaning is algorithm-specific; for the PBKDF2 family it is the
// iteration count.
Iterations int `json:"iter,omitzero"`
// Salt is the random per-password salt. It thwarts precomputed lookup
// tables and makes equal passwords hash differently.
Salt []byte `json:"salt,omitzero"`
// Digest is the derived key that verification compares against.
Digest []byte `json:"digest"`
}
// Algorithm defines the contract for a password hashing scheme.
//
// Implementations must be safe for concurrent use. The [Hasher] dispatches
// verification to the implementation registered under the name stored in
// the record, so implementations must accept any record they ever
// produced, including those created with older parameters.
type Algorithm interface {
// Name returns the unique identifier written into records produced by
// this algorithm.
Name() string
// Hash derives a fresh [Record] from the plaintext password using the
// algorithm's current parameters.
Hash(password string) (Record, error)
// Verify reports whether the password matches the record. A mismatch
// is (false, nil); an error is returned only if the record is
// malformed or the derivation fails.
//
// Implementations must compare digests in constant time.
Verify(record Record, password string) (bool, error)
// Outdated reports whether the record was produced with parameters
// weaker than the algorithm's current configuration and should be
// rehashed.
Outdated(record Record) bool
}
// Hasher hashes passwords with a default [Algorithm] and verifies them
// against any registered one, resolved dynamically from the stored record.
//
// Create instances with [New]. A Hasher is immutable after construction
// and safe for concurrent use.
type Hasher struct {
def Algorithm
algorithms map[string]Algorithm
}
// register adds the algorithm to the verification registry.
func (h *Hasher) register(alg Algorithm) {
if alg == nil {
panic("algorithm is required")
}
if alg.Name() == "" {
panic("algorithm name is required")
}
h.algorithms[alg.Name()] = alg
}
// New assembles a [Hasher] from the given options.
//
// By default, new passwords are hashed with PBKDF2-HMAC-SHA256, and both
// [PBKDF2SHA256] and [PBKDF2SHA512] are registered for verification with
// their default parameters. Use [WithDefault] to hash with a different
// algorithm, and [WithAlgorithm] to register additional ones for
// verification; the built-in registrations always remain available as a
// verification fallback unless replaced by name.
func New(opts ...Option) *Hasher {
h := &Hasher{algorithms: make(map[string]Algorithm)}
def := PBKDF2SHA256(0)
h.register(def)
h.register(PBKDF2SHA512(0))
h.def = def
for _, opt := range opts {
opt(h)
}
return h
}
// Hash derives a fresh hash of the password using the default algorithm
// and returns it as a JSON-encoded [Record] for storage.
func (h *Hasher) Hash(password string) ([]byte, error) {
rec, err := h.def.Hash(password)
if err != nil {
return nil, err
}
return json.Marshal(rec)
}
// Verify reports whether the password matches the stored JSON-encoded
// [Record].
//
// The hashing scheme is resolved dynamically from the record's algorithm
// name, so records produced under older configurations keep verifying. A
// mismatch is (false, nil); an error is returned only if the record is
// malformed ([ErrMalformedRecord]), names an unregistered algorithm
// ([ErrUnknownAlgorithm]), or the derivation itself fails.
func (h *Hasher) Verify(record []byte, password string) (bool, error) {
rec, err := h.parse(record)
if err != nil {
return false, err
}
alg, ok := h.algorithms[rec.Algorithm]
if !ok {
return false, fmt.Errorf(
"%w: %q",
ErrUnknownAlgorithm,
rec.Algorithm,
)
}
return alg.Verify(rec, password)
}
// Outdated reports whether the stored JSON-encoded [Record] should be
// rehashed: either it was produced by an algorithm other than the current
// default, or the default algorithm considers its parameters weaker than
// the current configuration.
//
// Call it after a successful verification, while the plaintext password is
// at hand, and store a fresh [Hasher.Hash] when it reports true. This
// keeps stored hashes converging to the strongest configuration without a
// mass reset.
func (h *Hasher) Outdated(record []byte) (bool, error) {
rec, err := h.parse(record)
if err != nil {
return false, err
}
if rec.Algorithm != h.def.Name() {
return true, nil
}
return h.def.Outdated(rec), nil
}
// parse decodes a stored record and checks the fields the [Hasher] itself
// depends on.
func (*Hasher) parse(record []byte) (Record, error) {
var rec Record
if err := json.Unmarshal(record, &rec); err != nil {
return rec, fmt.Errorf("%w: %w", ErrMalformedRecord, err)
}
if rec.Algorithm == "" {
return rec, fmt.Errorf("%w: missing algorithm", ErrMalformedRecord)
}
return rec, nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package pass
import (
"cmp"
"crypto/pbkdf2"
"crypto/rand"
"crypto/sha256"
"crypto/sha512"
"crypto/subtle"
"fmt"
"hash"
)
const (
// AlgorithmPBKDF2SHA256 is the record name of the PBKDF2-HMAC-SHA256
// algorithm.
AlgorithmPBKDF2SHA256 = "pbkdf2-sha256"
// AlgorithmPBKDF2SHA512 is the record name of the PBKDF2-HMAC-SHA512
// algorithm.
AlgorithmPBKDF2SHA512 = "pbkdf2-sha512"
)
const (
// DefaultSHA256Iterations is the default iteration count for
// PBKDF2-HMAC-SHA256, following the OWASP Password Storage Cheat Sheet
// recommendation.
DefaultSHA256Iterations = 600_000
// DefaultSHA512Iterations is the default iteration count for
// PBKDF2-HMAC-SHA512. It is lower than the SHA-256 default because a
// single SHA-512 iteration costs roughly three times as much, yielding
// comparable attack resistance per hash.
DefaultSHA512Iterations = 210_000
)
// saltLength is the size of generated salts in bytes. 128 bits is the
// minimum required by NIST SP 800-132 and enforced by the Go Cryptographic
// Module in FIPS 140-only mode.
const saltLength = 16
// minDigestLength is the smallest digest accepted during verification.
// Records with shorter digests are rejected as malformed rather than
// verified, so a truncated database value cannot degrade into an easily
// forgeable comparison. 112 bits is the FIPS 140 floor for key lengths.
const minDigestLength = 14
// pbkdf2Algorithm implements the [Algorithm] interface using PBKDF2-HMAC
// with a configurable hash function.
type pbkdf2Algorithm struct {
name string
hash func() hash.Hash
iterations int
keyLength int
}
var _ Algorithm = (*pbkdf2Algorithm)(nil)
// PBKDF2SHA256 returns the PBKDF2-HMAC-SHA256 [Algorithm], deriving
// 256-bit digests from 128-bit random salts.
//
// The iteration count applies to newly hashed passwords; verification
// always uses the count stored in the record. A count of zero selects
// [DefaultSHA256Iterations]; negative counts panic, since the work factor
// is startup configuration.
func PBKDF2SHA256(iterations int) Algorithm {
return newPBKDF2(
AlgorithmPBKDF2SHA256,
sha256.New,
sha256.Size,
iterations,
DefaultSHA256Iterations,
)
}
// PBKDF2SHA512 returns the PBKDF2-HMAC-SHA512 [Algorithm], deriving
// 512-bit digests from 128-bit random salts.
//
// The iteration count applies to newly hashed passwords; verification
// always uses the count stored in the record. A count of zero selects
// [DefaultSHA512Iterations]; negative counts panic, since the work factor
// is startup configuration.
func PBKDF2SHA512(iterations int) Algorithm {
return newPBKDF2(
AlgorithmPBKDF2SHA512,
sha512.New,
sha512.Size,
iterations,
DefaultSHA512Iterations,
)
}
// newPBKDF2 assembles a PBKDF2 variant with the given defaults.
func newPBKDF2(
name string,
hash func() hash.Hash,
keyLength int,
iterations, fallback int,
) Algorithm {
if iterations < 0 {
panic("iteration count must not be negative")
}
return &pbkdf2Algorithm{
name: name,
hash: hash,
iterations: cmp.Or(iterations, fallback),
keyLength: keyLength,
}
}
// Name implements the [Algorithm] interface.
func (a *pbkdf2Algorithm) Name() string { return a.name }
// Hash implements the [Algorithm] interface.
func (a *pbkdf2Algorithm) Hash(password string) (Record, error) {
salt := make([]byte, saltLength)
if _, err := rand.Read(salt); err != nil {
return Record{}, fmt.Errorf("failed to generate salt: %w", err)
}
digest, err := pbkdf2.Key(
a.hash,
password,
salt,
a.iterations,
a.keyLength,
)
if err != nil {
return Record{}, fmt.Errorf("failed to derive digest: %w", err)
}
return Record{
Algorithm: a.name,
Iterations: a.iterations,
Salt: salt,
Digest: digest,
}, nil
}
// Verify implements the [Algorithm] interface. It derives the digest with
// the parameters stored in the record — not the current configuration — so
// records produced under older settings keep verifying.
func (a *pbkdf2Algorithm) Verify(rec Record, password string) (bool, error) {
switch {
case rec.Iterations < 1:
return false, fmt.Errorf(
"%w: invalid iteration count",
ErrMalformedRecord,
)
case len(rec.Salt) == 0:
return false, fmt.Errorf("%w: missing salt", ErrMalformedRecord)
case len(rec.Digest) < minDigestLength:
return false, fmt.Errorf(
"%w: digest too short",
ErrMalformedRecord,
)
}
digest, err := pbkdf2.Key(
a.hash,
password,
rec.Salt,
rec.Iterations,
len(rec.Digest),
)
if err != nil {
return false, fmt.Errorf("failed to derive digest: %w", err)
}
return subtle.ConstantTimeCompare(digest, rec.Digest) == 1, nil
}
// Outdated implements the [Algorithm] interface. A record is outdated if
// it was derived with fewer iterations than currently configured, or if
// its digest length differs from the full hash size.
func (a *pbkdf2Algorithm) Outdated(rec Record) bool {
return rec.Iterations < a.iterations || len(rec.Digest) != a.keyLength
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package seal
import (
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"hash"
"io"
"strings"
)
// KeySize is the length of a sealing key. AES-256 is the only cipher this
// package offers: the values it protects are small and long-lived, so
// there is nothing to gain from a weaker one and no reason to make the
// choice configurable.
const KeySize = 32
// MaxKeyIDLength bounds a key identifier, which travels in every sealed
// value's header.
const MaxKeyIDLength = 64
// version prefixes every sealed value, so the format can change without
// leaving existing data unreadable.
const version byte = 1
// Errors reported by this package. Every failure to open a value wraps
// [ErrOpen] — a caller cannot act differently on a wrong key, a tampered
// value, or a truncated one, and telling them apart would leak which.
var (
// ErrInvalidKey reports an unusable key: wrong length, empty or oversized
// identifier, or a duplicate identifier within a keyring.
ErrInvalidKey = errors.New("invalid sealing key")
// ErrOpen reports a value that could not be opened.
ErrOpen = errors.New("failed to open the sealed value")
)
// Key is one sealing key and the identifier that names it.
type Key struct {
// ID names the key inside sealed values, so a keyring knows which to
// try. It is not secret; keep it short and stable (e.g. "2026-08").
ID string
// Material is the raw key, exactly [KeySize] bytes.
Material []byte
}
// ParseKey reads a key whose material is base64 (standard or URL
// alphabet, padded or not), the form a mounted secret carries.
func ParseKey(id, material string) (Key, error) {
decode := func(s string) ([]byte, error) {
s = strings.TrimSpace(s)
if strings.ContainsAny(s, "-_") {
return base64.RawURLEncoding.DecodeString(
strings.TrimRight(s, "="),
)
}
return base64.RawStdEncoding.DecodeString(strings.TrimRight(s, "="))
}
b, err := decode(material)
if err != nil {
return Key{}, fmt.Errorf("%w: %w", ErrInvalidKey, err)
}
return Key{ID: id, Material: b}, nil
}
// ParseKeyring builds a keyring from the form a deployment configures
// it in: the primary key's identifier and base64 material, plus any
// retired keys as "id:base64" pairs. It is the counterpart of
// [ParseKey] for the whole ring, so that reading a rotation out of the
// environment is one call rather than a loop in every service.
func ParseKeyring(
id, material string,
retired []string,
) (*Keyring, error) {
primary, err := ParseKey(id, material)
if err != nil {
return nil, err
}
keys := make([]Key, 0, len(retired))
for _, pair := range retired {
id, material, ok := strings.Cut(pair, ":")
if !ok {
return nil, fmt.Errorf(
"%w: retired key %q is not in the \"id:base64\" form",
ErrInvalidKey, pair,
)
}
key, err := ParseKey(id, material)
if err != nil {
return nil, err
}
keys = append(keys, key)
}
return NewKeyring(primary, keys...)
}
// validate reports whether the key is usable.
func (k Key) validate() error {
if k.ID == "" {
return fmt.Errorf("%w: the identifier is empty", ErrInvalidKey)
}
if len(k.ID) > MaxKeyIDLength {
return fmt.Errorf(
"%w: the identifier exceeds %d bytes",
ErrInvalidKey,
MaxKeyIDLength,
)
}
if len(k.Material) != KeySize {
return fmt.Errorf(
"%w: %q is %d bytes, want %d",
ErrInvalidKey, k.ID, len(k.Material), KeySize,
)
}
return nil
}
// GenerateKey mints a fresh key under the given identifier. A nil source
// reads from [crypto/rand].
func GenerateKey(id string, src io.Reader) (Key, error) {
if src == nil {
src = rand.Reader
}
k := Key{ID: id, Material: make([]byte, KeySize)}
if _, err := io.ReadFull(src, k.Material); err != nil {
return Key{}, fmt.Errorf("failed to draw a sealing key: %w", err)
}
return k, k.validate()
}
// Keyring seals with one key and opens with any it holds. It is safe for
// concurrent use; the keys are fixed at construction.
type Keyring struct {
primary string
ciphers map[string]cipher.AEAD
mac []byte
}
// NewKeyring builds a keyring that seals under primary and opens values
// sealed under primary or any of the retired keys. Retiring a key rather
// than dropping it is what makes rotation gradual; see the package
// documentation.
func NewKeyring(primary Key, retired ...Key) (*Keyring, error) {
r := &Keyring{
primary: primary.ID,
ciphers: make(map[string]cipher.AEAD, len(retired)+1),
}
// A MAC key derived from the primary rather than the primary
// itself, so one key never serves two algorithms. See [Keyring.MAC].
if err := primary.validate(); err == nil {
sum := hmac.New(sha256.New, primary.Material)
_, _ = sum.Write([]byte(macLabel))
r.mac = sum.Sum(nil)
}
for _, k := range append([]Key{primary}, retired...) {
if err := k.validate(); err != nil {
return nil, err
}
if _, ok := r.ciphers[k.ID]; ok {
return nil, fmt.Errorf(
"%w: duplicate identifier %q",
ErrInvalidKey,
k.ID,
)
}
block, err := aes.NewCipher(k.Material)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrInvalidKey, err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrInvalidKey, err)
}
r.ciphers[k.ID] = aead
}
return r, nil
}
// macLabel separates the derived MAC key from the sealing key it comes
// from, so the same material never serves two algorithms.
const macLabel = "nexus/seal/keyring/mac/v1"
// MAC returns a fresh keyed hash under a key derived from the ring's
// PRIMARY key.
//
// It is for committing to a value that must stay unguessable — an
// identifier used as a lookup key, say — where a plain digest would be
// a commitment anyone could test a guess against. The key never leaves
// the ring, so a stolen database holds commitments it cannot reproduce.
//
// Only the primary keys it, because a lookup wants one answer rather
// than one per retired key. So a rotation changes every digest this
// produces: values committed under the old key stop matching. Rotate
// deliberately where that matters, and treat the window as reset.
func (r *Keyring) MAC() hash.Hash {
return hmac.New(sha256.New, r.mac)
}
// Seal encrypts the value under the primary key, binding it to aad: the
// same aad must be supplied to open it. See the package documentation on
// what to bind.
//
// The result is self-describing — it names the key that produced it — so
// storage needs no companion column.
func (r *Keyring) Seal(value, aad []byte) ([]byte, error) {
aead := r.ciphers[r.primary]
if aead == nil {
return nil, fmt.Errorf(
"%w: the keyring holds no primary key",
ErrInvalidKey,
)
}
nonce := make([]byte, aead.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, fmt.Errorf("failed to draw a nonce: %w", err)
}
// version | len(id) | id | nonce | ciphertext+tag
head := 2 + len(r.primary)
out := make([]byte, head+len(nonce), head+len(nonce)+len(value)+
aead.Overhead())
out[0] = version
out[1] = byte(len(r.primary))
copy(out[2:], r.primary)
copy(out[head:], nonce)
return aead.Seal(out, nonce, value, aad), nil
}
// Open decrypts a value sealed by [Keyring.Seal] under the same aad.
//
// Every failure — an unknown key, a wrong aad, a tampered or truncated
// value — reports [ErrOpen] and nothing more specific, since a caller can
// do nothing different with the distinction and revealing it would say
// which guess was closer.
func (r *Keyring) Open(sealed, aad []byte) ([]byte, error) {
id, nonce, body, err := split(sealed, r)
if err != nil {
return nil, err
}
aead := r.ciphers[id]
if aead == nil {
return nil, fmt.Errorf("%w: no key named %q", ErrOpen, id)
}
out, err := aead.Open(nil, nonce, body, aad)
if err != nil {
return nil, fmt.Errorf("%w", ErrOpen)
}
return out, nil
}
// Stale reports whether the value was sealed under a key other than the
// current primary, so a caller can reseal it while it already holds the
// plaintext. A value it cannot parse is not stale — it is broken, and
// [Keyring.Open] is where that surfaces.
func (r *Keyring) Stale(sealed []byte) bool {
id, _, _, err := split(sealed, r)
return err == nil && id != r.primary
}
// split parses the header of a sealed value.
func split(
sealed []byte,
r *Keyring,
) (id string, nonce, body []byte, err error) {
fail := fmt.Errorf("%w: malformed", ErrOpen)
if len(sealed) < 2 || sealed[0] != version {
return "", nil, nil, fail
}
n := int(sealed[1])
if n == 0 || n > MaxKeyIDLength || len(sealed) < 2+n {
return "", nil, nil, fail
}
id = string(sealed[2 : 2+n])
// Every key in a ring shares GCM's nonce size, so any cipher answers
// for the length; the ring is never empty.
size := 0
for _, aead := range r.ciphers {
size = aead.NonceSize()
break
}
rest := sealed[2+n:]
if len(rest) < size {
return "", nil, nil, fail
}
return id, rest[:size], rest[size:], nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package sign
import (
"context"
"crypto"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"io"
)
// Signer is an interface similar to [crypto.Signer] but with context
// propagation.
type Signer interface {
// Public returns the public key corresponding to the opaque, private key.
Public() crypto.PublicKey
// Private returns the raw private key material backing the signer, or
// nil if it is not extractable — an opaque handle such as a KMS key.
// Callers persisting keys check for nil rather than special-casing
// backends.
Private() crypto.PrivateKey
// Sign creates a signature, honoring the provided context for cancellation
// and deadlines.
Sign(
ctx context.Context,
rand io.Reader,
digest []byte,
opts crypto.SignerOpts,
) (signature []byte, err error)
}
// ECDSAFormat identifies the encoding of the ECDSA signatures a [Signer]
// emits. Signers that do not implement [ECDSAFormatSigner] are assumed to
// follow the [crypto.Signer] convention, [ECDSADER].
type ECDSAFormat uint8
const (
// ECDSADER is the [crypto.Signer] convention: an ASN.1 DER-encoded SEQUENCE
// of the two signature integers.
ECDSADER ECDSAFormat = iota
// ECDSARaw is the JOSE wire form (IEEE P1363): the fixed-width big-endian
// concatenation R||S.
ECDSARaw ECDSAFormat = iota
)
// ECDSAFormatSigner is implemented by signers that declare the encoding of
// the ECDSA signatures they produce, sparing the JOSE layer a transcoding
// round trip when the backend already emits the wire form — a KMS asked
// for raw signatures, for instance.
//
// The declaration is consulted only by the ECDSA algorithms; every other
// family has a single signature encoding, so implementing this interface
// on their signers is meaningless but harmless.
type ECDSAFormatSigner interface {
Signer
// ECDSAFormat reports the encoding of produced signatures.
ECDSAFormat() ECDSAFormat
}
// From adapts a standard [crypto.Signer] into a context-aware [Signer].
//
// The wrapped signer is assumed to be extractable key material — a
// standard library private key, which is its own [crypto.Signer] — and is
// what [Signer.Private] returns. An opaque backend (a KMS, an HSM)
// implements [Signer] directly instead and returns nil from Private.
func From(s crypto.Signer) Signer {
return &ctxWrapper{signer: s}
}
type ctxWrapper struct {
signer crypto.Signer
}
// Public implements [Signer].
func (w *ctxWrapper) Public() crypto.PublicKey { return w.signer.Public() }
// Private implements [Signer]: the wrapped [crypto.Signer] is the private
// key itself.
func (w *ctxWrapper) Private() crypto.PrivateKey { return w.signer }
// Sign implements [Signer].
func (w *ctxWrapper) Sign(
ctx context.Context,
rand io.Reader,
digest []byte,
opts crypto.SignerOpts,
) (signature []byte, err error) {
if err := ctx.Err(); err != nil {
return nil, err
}
return w.signer.Sign(rand, digest, opts)
}
var _ Signer = (*ctxWrapper)(nil)
// To adapts a context-aware [Signer] into a standard [crypto.Signer].
//
// The provided context is baked into the resulting signer and will be used
// for all subsequent signature operations. This is useful when you need to pass
// a context-aware signer to a standard library function that only accepts a
// standard [crypto.Signer].
//
// If the provided [Signer] was originally created by [From], this function
// returns the original underlying [crypto.Signer] to prevent double-wrapping.
func To(ctx context.Context, s Signer) crypto.Signer {
if w, ok := s.(*ctxWrapper); ok {
return w.signer
}
return &stdWrapper{ctx: ctx, signer: s}
}
type stdWrapper struct {
ctx context.Context
signer Signer
}
// Public implements [crypto.Signer].
func (w *stdWrapper) Public() crypto.PublicKey { return w.signer.Public() }
// Sign implements [crypto.Signer].
func (w *stdWrapper) Sign(
rand io.Reader,
digest []byte,
opts crypto.SignerOpts,
) ([]byte, error) {
return w.signer.Sign(w.ctx, rand, digest, opts)
}
var _ crypto.Signer = (*stdWrapper)(nil)
// Decode decodes a PEM block and parses the contained private key into a
// [Signer]. It supports standard PKCS8 (including ML-DSA seed keys), EC, and
// PKCS1 private keys.
func Decode(data []byte) (Signer, error) {
block, _ := pem.Decode(data)
if block == nil {
return nil, errors.New("failed to decode PEM block")
}
bytes := block.Bytes
// Try standard PKCS8 first
var (
key any
err error
)
key, err = x509.ParsePKCS8PrivateKey(bytes)
if err != nil {
err1 := err
// Fallback for EC private keys
if key, err = x509.ParseECPrivateKey(bytes); err != nil {
err2 := err
// Fallback for RSA PKCS1
if key, err = x509.ParsePKCS1PrivateKey(bytes); err != nil {
return nil, fmt.Errorf(
"failed to parse private key: %w",
errors.Join(err1, err2, err),
)
}
}
}
signer, ok := key.(crypto.Signer)
if !ok {
return nil, errors.New("key is not a signer")
}
return From(signer), nil
}
// Encode encodes a cryptographic private key into a standard PKCS8 PEM
// formatted byte sequence. It accepts standard library private keys (e.g.,
// [*rsa.PrivateKey], [*ecdsa.PrivateKey], [ed25519.PrivateKey],
// [*mldsa.PrivateKey], and [*eddsa.PrivateKey]) or context-aware [Signer]
// wrappers returned by this package.
func Encode(key any) ([]byte, error) {
if w, ok := key.(*ctxWrapper); ok {
key = w.signer
}
der, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
return nil, fmt.Errorf("failed to marshal private key: %w", err)
}
block := &pem.Block{
Type: "PRIVATE KEY",
Bytes: der,
}
return pem.EncodeToMemory(block), nil
}
// DecodePublic decodes a PEM block and parses the contained public key into a
// standard [crypto.PublicKey]. It supports standard PKIX public keys.
func DecodePublic(data []byte) (crypto.PublicKey, error) {
block, _ := pem.Decode(data)
if block == nil {
return nil, errors.New("failed to decode PEM block")
}
key, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse public key: %w", err)
}
return key, nil
}
// EncodePublic encodes a cryptographic public key into a standard PKIX PEM
// formatted byte sequence. It accepts standard library public keys.
func EncodePublic(key crypto.PublicKey) ([]byte, error) {
der, err := x509.MarshalPKIXPublicKey(key)
if err != nil {
return nil, fmt.Errorf("failed to marshal public key: %w", err)
}
block := &pem.Block{
Type: "PUBLIC KEY",
Bytes: der,
}
return pem.EncodeToMemory(block), nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package oauth
import (
"context"
"fmt"
"net/http"
"net/url"
"time"
"github.com/deep-rent/nexus/sec/jose/jwk"
"github.com/deep-rent/nexus/sec/jose/jwt"
"github.com/deep-rent/nexus/sec/token"
"github.com/deep-rent/nexus/std/clock"
)
// DefaultAssertionLifetime is how long a minted assertion stays valid.
// It is the ceiling Google enforces, and the assertion is spent the
// moment it is exchanged, so nothing is gained by shortening it.
const DefaultAssertionLifetime = time.Hour
// assertion is the RFC 7523 grant this package signs: the account
// naming itself, the scope it wants, and the endpoint it may be
// redeemed at, valid for an hour. Everything but the scope is a
// registered claim, so [jwt.Reserved] carries it.
type assertion struct {
jwt.Reserved
Scope string `json:"scope"`
}
// Account declares a service account and what it is asking for: the
// identity it signs as, the key it signs with, and the endpoint that
// trades the signature for an access token.
type Account struct {
// Endpoint is the token endpoint the assertion is presented to,
// and the audience it is bound to.
Endpoint string
// Issuer is the account's own identity, the "client_email" of a
// Google service-account file.
Issuer string
// Scope is what the minted token asks for. Several are separated
// by spaces, as RFC 6749 specifies.
Scope string
// Key signs the assertion. Google issues RSA keys and accepts
// RS256; the algorithm travels with the key.
Key jwk.KeyPair
// Client dispatches the exchange. Nil uses [http.DefaultClient].
Client *http.Client
// Lifetime overrides how long an assertion stays valid; zero
// keeps [DefaultAssertionLifetime].
Lifetime time.Duration
// Clock is what the assertion and the token's expiry are measured
// against, and what the cache is told to use. Nil reads the system
// clock. It exists for tests.
Clock clock.Clock
}
// ServiceAccount returns a [token.Source] minting access tokens
// through the RFC 7523 JWT-bearer grant: the account signs a short
// assertion naming itself and the scope it wants, and the endpoint
// trades that for a bearer token.
//
// The source caches what it mints and re-mints inside its buffer
// window; pass [token.WithBufferTime] through to size it.
func ServiceAccount(acc Account, opts ...token.Option) *token.Source {
now := acc.Clock
if now == nil {
now = clock.System
}
// One clock governs the assertion, the expiry, and the cache. It
// goes first so a caller may still override it deliberately.
opts = append([]token.Option{token.WithClock(now)}, opts...)
return token.NewSource(func(
ctx context.Context,
) (string, time.Time, error) {
lifetime := acc.Lifetime
if lifetime <= 0 {
lifetime = DefaultAssertionLifetime
}
at := now()
grant, err := jwt.Sign(ctx, acc.Key, assertion{
Reserved: jwt.Reserved{
Iss: acc.Issuer,
// A lone audience travels as a bare string, which is
// the form these endpoints document.
Aud: jwt.Audience{acc.Endpoint},
Iat: at,
Exp: at.Add(lifetime),
},
Scope: acc.Scope,
})
if err != nil {
return "", time.Time{}, fmt.Errorf(
"failed to sign the assertion: %w", err,
)
}
return Exchange(ctx, acc.Client, acc.Endpoint, url.Values{
"grant_type": {GrantJWTBearer},
"assertion": {string(grant)},
}, WithClock(now))
}, opts...)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package oauth
import (
"context"
"net/http"
"net/url"
"time"
"github.com/deep-rent/nexus/sec/token"
)
// Client is a confidential OAuth client acting as itself: the
// credentials a service holds to reach a sibling with nobody signed in.
type Client struct {
// Endpoint is the authorization server's token endpoint.
Endpoint string
// ID and Secret are the client's own credentials, as registered at
// the authorization server.
ID string
Secret string
// Scope is what the minted token asks for. A machine client acts on
// its vetted scopes alone, so ask for exactly the permission the
// call needs and nothing more.
Scope string
// HTTP is the client the exchange travels on. Nil uses
// [http.DefaultClient], which is fine for a test and wrong for a
// service — pass the one carrying the deployment's User-Agent and
// connection pool.
HTTP *http.Client
}
// ClientCredentials returns a [token.Source] minting access tokens
// through the RFC 6749 Section 4.4 client-credentials grant: the client
// presents its own identity and receives a token carrying its vetted
// scopes, with no user involved.
//
// It is the grant a service uses to call a sibling from work that
// happens with nobody signed in — a notification fan-out, a nightly
// sweep, a queued job running hours after the request that caused it.
//
// The credentials travel in the Authorization header rather than the
// body, so they stay out of anything that logs a request payload. The
// source caches what it mints and re-mints inside its buffer window;
// pass [token.WithBufferTime] through to size it.
func ClientCredentials(c Client, opts ...token.Option) *token.Source {
return token.NewSource(func(
ctx context.Context,
) (string, time.Time, error) {
// A nil client is Exchange's own business; it falls back to
// http.DefaultClient rather than duplicating that choice here.
return Exchange(ctx, c.HTTP, c.Endpoint, url.Values{
"grant_type": {GrantClientCredentials},
"scope": {c.Scope},
}, WithBasicAuth(c.ID, c.Secret))
}, opts...)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package oauth
import (
"context"
"encoding/json/v2"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/deep-rent/nexus/std/clock"
)
// MaxBody caps the token response read. A token document is a handful
// of short fields; anything larger is an endpoint that is not one.
const MaxBody = 1 << 20
// ShortLifetime is how long a token lives when the endpoint names no
// lifetime of its own. Re-minting costs one request, while trusting a
// token past its end costs every request until somebody notices.
const ShortLifetime = time.Minute
// GrantJWTBearer is the RFC 7523 grant type: a signed assertion
// traded for an access token.
const GrantJWTBearer = "urn:ietf:params:oauth:grant-type:jwt-bearer"
// GrantClientCredentials is the RFC 6749 Section 4.4 grant type: a
// client acting for itself rather than for a user.
const GrantClientCredentials = "client_credentials"
// ErrNoToken reports an endpoint that answered without one.
var ErrNoToken = errors.New("the token endpoint returned no token")
// config carries what [Exchange] may be told beyond the request.
type config struct {
now clock.Clock
id string
secret string
basic bool
}
// Option adjusts one exchange.
type Option func(*config)
// WithBasicAuth sends the client credentials in the Authorization
// header rather than the form, so they stay out of anything that logs
// a request payload. RFC 6749 Section 2.3.1 prefers it.
func WithBasicAuth(id, secret string) Option {
return func(c *config) {
c.id, c.secret, c.basic = id, secret, true
}
}
// WithClock injects the clock the expiry is measured from, for tests.
// A nil clock is ignored.
func WithClock(now clock.Clock) Option {
return func(c *config) {
if now != nil {
c.now = now
}
}
}
// Exchange posts an OAuth 2.0 token request to endpoint and returns
// the access token together with the instant it expires.
//
// The expiry is absolute: the endpoint reports a lifetime, and this
// resolves it against the clock. It carries no safety margin — that is
// the buffer of the [token.Source] holding the result, which is where
// a caller can see and tune it.
//
// [token.Source]: github.com/deep-rent/nexus/sec/token#Source
func Exchange(
ctx context.Context,
client *http.Client,
endpoint string,
form url.Values,
opts ...Option,
) (string, time.Time, error) {
cfg := config{now: clock.System}
for _, opt := range opts {
opt(&cfg)
}
if client == nil {
client = http.DefaultClient
}
req, err := http.NewRequestWithContext(
ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()),
)
if err != nil {
return "", time.Time{}, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if cfg.basic {
req.SetBasicAuth(cfg.id, cfg.secret)
}
res, err := client.Do(req)
if err != nil {
return "", time.Time{}, fmt.Errorf(
"failed to reach the token endpoint: %w", err,
)
}
defer res.Body.Close()
body, err := io.ReadAll(io.LimitReader(res.Body, MaxBody))
if err != nil {
return "", time.Time{}, fmt.Errorf(
"failed to read the token response: %w", err,
)
}
if res.StatusCode != http.StatusOK {
// The body carries the RFC 6749 error, which is the whole
// difference between a wrong secret and a wrong scope.
return "", time.Time{}, fmt.Errorf(
"the token endpoint answered %d: %s",
res.StatusCode, strings.TrimSpace(string(body)),
)
}
var out struct {
AccessToken string `json:"access_token"`
ExpiresIn int64 `json:"expires_in"`
}
if err := json.Unmarshal(body, &out); err != nil {
return "", time.Time{}, fmt.Errorf(
"failed to parse the token response: %w", err,
)
}
if out.AccessToken == "" {
return "", time.Time{}, ErrNoToken
}
lifetime := time.Duration(out.ExpiresIn) * time.Second
if out.ExpiresIn <= 0 {
lifetime = ShortLifetime
}
return out.AccessToken, cfg.now().Add(lifetime), nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package token
import (
"context"
"sync"
"time"
"golang.org/x/sync/singleflight"
"github.com/deep-rent/nexus/std/clock"
)
// DefaultBufferTime is the default duration to preemptively refresh tokens.
const DefaultBufferTime = 1 * time.Minute
// Fetcher is a function that generates or retrieves a new token and its exact
// expiration time. It is called by the [Source] when a token is missing or
// expired.
//
// A single fetch is shared by all concurrent [Source.Get] calls, so the
// context passed to a Fetcher is detached from any individual caller's
// cancellation. A Fetcher that performs a network call should therefore bound
// its own duration, for example through the timeout of its HTTP client, rather
// than relying on the caller to cancel it.
type Fetcher func(ctx context.Context) (string, time.Time, error)
// config defines the configuration options for a [Source].
type config struct {
buf time.Duration
now clock.Clock
}
// Option modifies the token cache configuration.
type Option func(*config)
// WithBufferTime sets a custom buffer time for proactive token refreshing.
// If not specified or nonpositive, [DefaultBufferTime] is used.
func WithBufferTime(d time.Duration) Option {
return func(c *config) {
if d > 0 {
c.buf = d
}
}
}
// WithClock injects a custom clock function, primarily used for testing.
// If not provided, [clock.System] is used; nil values will be ignored.
func WithClock(now clock.Clock) Option {
return func(c *config) {
if now != nil {
c.now = now
}
}
}
// Source manages a lazy-loaded token cache, re-minting through its
// [Fetcher] once the cached token enters the buffer window. It owns no
// goroutine of its own; see the package documentation.
//
// It is safe for concurrent use by multiple goroutines.
type Source struct {
fetch Fetcher
buf time.Duration
now clock.Clock
mu sync.RWMutex
tok string
exp time.Time
grp singleflight.Group
}
// NewSource creates a new token cache around the given [Fetcher].
func NewSource(fetch Fetcher, opts ...Option) *Source {
cfg := config{
buf: DefaultBufferTime,
now: clock.System,
}
for _, opt := range opts {
opt(&cfg)
}
return &Source{
fetch: fetch,
buf: cfg.buf,
now: cfg.now,
}
}
// Get returns the current valid token, or fetches a new one if it is missing or
// within the expiration buffer window.
func (s *Source) Get(ctx context.Context) (string, error) {
s.mu.RLock()
// Consider the token expired if we are within the buffer window.
if s.tok != "" && s.now().Add(s.buf).Before(s.exp) {
tok := s.tok
s.mu.RUnlock()
return tok, nil
}
s.mu.RUnlock()
return s.get(ctx)
}
// get fetches the token, collapsing concurrent calls into one fetch via the
// singleflight group. The caller may still abandon the wait through its own
// context; the fetch itself continues, since its result is shared and cached.
func (s *Source) get(ctx context.Context) (string, error) {
ch := s.grp.DoChan("fetch", func() (any, error) {
// Detach from the caller's context. The fetch is shared by every
// concurrent Get, so one caller cancelling must not fail it for the
// others; only cancellation is dropped, so trace and other values on
// the winning caller's context are preserved. The fetcher is expected
// to bound its own duration.
tok, exp, err := s.fetch(context.WithoutCancel(ctx))
if err != nil {
return "", err
}
s.mu.Lock()
s.tok = tok
s.exp = exp
s.mu.Unlock()
return tok, nil
})
select {
case <-ctx.Done():
return "", ctx.Err()
case res := <-ch:
if res.Err != nil {
return "", res.Err
}
return res.Val.(string), nil
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package totp
import (
"crypto/hmac"
"crypto/rand"
// #nosec G505 -- RFC 6238 specifies HMAC-SHA1 for TOTP, and every
// authenticator app implements that.
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"crypto/subtle"
"encoding/base32"
"encoding/binary"
"errors"
"fmt"
"hash"
"io"
"net/url"
"strconv"
"strings"
"time"
)
// Algorithm names the hash backing the HMAC.
type Algorithm string
const (
// SHA1 is the RFC 6238 default and the only algorithm every
// authenticator implements. See the package documentation on why it
// remains the right default here.
SHA1 Algorithm = "SHA1"
// SHA256 backs the HMAC with SHA-256.
SHA256 Algorithm = "SHA256"
// SHA512 backs the HMAC with SHA-512.
SHA512 Algorithm = "SHA512"
)
// new returns the hash constructor for the algorithm, or nil when it
// names none.
func (a Algorithm) new() func() hash.Hash {
switch a {
case SHA1:
return sha1.New
case SHA256:
return sha256.New
case SHA512:
return sha512.New
default:
return nil
}
}
// size is the secret length recommended for the algorithm: RFC 4226
// Section 4 requires at least 128 bits and recommends 160, and RFC 6238
// Section 3 matches the secret to the hash output.
func (a Algorithm) size() int {
switch a {
case SHA256:
return 32
case SHA512:
return 64
default:
return 20
}
}
// Defaults applied to the zero [Params]. They are what mainstream
// authenticators implement; see the package documentation.
const (
// DefaultAlgorithm is the HMAC hash used when none is named.
DefaultAlgorithm = SHA1
// DefaultDigits is the length of a rendered code.
DefaultDigits = 6
// DefaultPeriod is how long one code stands.
DefaultPeriod = 30 * time.Second
// DefaultSkew is how many periods either side of the current one are
// accepted, covering clock drift between the server and the
// authenticator plus the time a user takes to type. One period is the
// near-universal choice: wider multiplies the guessing surface for no
// practical gain.
DefaultSkew = 1
)
// MaxDigits bounds a code at nine digits, the most that fits the 31-bit
// dynamic truncation of RFC 4226 without the leading digit being all but
// constant.
const MaxDigits = 9
// Params are the authenticator parameters. The zero value is valid and
// resolves to the defaults; both sides of an enrollment must agree on
// them, which is why [Secret.URI] carries them to the authenticator.
type Params struct {
// Algorithm backs the HMAC. Empty selects [DefaultAlgorithm].
Algorithm Algorithm
// Digits is the code length. Zero selects [DefaultDigits]; values
// above [MaxDigits] are refused.
Digits int
// Period is how long one code stands. Zero or negative selects
// [DefaultPeriod].
Period time.Duration
}
// resolve applies the defaults and reports whether the result is usable.
func (p Params) resolve() (Params, error) {
if p.Algorithm == "" {
p.Algorithm = DefaultAlgorithm
}
if p.Algorithm.new() == nil {
return p, fmt.Errorf("%w: unknown algorithm %q", ErrParams, p.Algorithm)
}
if p.Digits == 0 {
p.Digits = DefaultDigits
}
if p.Digits < 1 || p.Digits > MaxDigits {
return p, fmt.Errorf(
"%w: digits must be between 1 and %d, got %d",
ErrParams, MaxDigits, p.Digits,
)
}
if p.Period <= 0 {
p.Period = DefaultPeriod
}
return p, nil
}
// ErrParams reports parameters no authenticator could honor: an unknown
// algorithm, or a code length outside 1..[MaxDigits].
var ErrParams = errors.New("invalid TOTP parameters")
// ErrSecret reports a secret that is empty or not valid base32.
var ErrSecret = errors.New("invalid TOTP secret")
// encoding is RFC 4648 base32 without padding, which is what
// authenticator apps expect in an otpauth URI.
var encoding = base32.StdEncoding.WithPadding(base32.NoPadding)
// Secret is the shared key behind a user's codes.
//
// Unlike a password it cannot be stored as a digest: deriving a code
// requires the key itself, so whatever holds one must protect it at rest.
type Secret []byte
// Generate mints a fresh secret sized for the algorithm — 160 bits for
// SHA-1, matching RFC 4226 Section 4's recommendation. A nil source reads
// from [crypto/rand].
func Generate(src io.Reader, p Params) (Secret, error) {
p, err := p.resolve()
if err != nil {
return nil, err
}
if src == nil {
src = rand.Reader
}
s := make(Secret, p.Algorithm.size())
if _, err := io.ReadFull(src, s); err != nil {
return nil, fmt.Errorf("failed to draw a TOTP secret: %w", err)
}
return s, nil
}
// Parse decodes a base32 secret as produced by [Secret.String]. Casing and
// padding are tolerated, since users retyping a key from a screen supply
// neither reliably.
func Parse(s string) (Secret, error) {
s = strings.ToUpper(strings.TrimSpace(s))
s = strings.ReplaceAll(s, " ", "")
s = strings.TrimRight(s, "=")
if s == "" {
return nil, fmt.Errorf("%w: empty", ErrSecret)
}
b, err := encoding.DecodeString(s)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrSecret, err)
}
return b, nil
}
// String renders the secret as unpadded base32, the form an authenticator
// accepts when a user types the key by hand.
//
// It is the secret in the clear. Do not log it, and do not put it in an
// error.
func (s Secret) String() string { return encoding.EncodeToString(s) }
// Counter returns the RFC 6238 time step covering the instant: the number
// of whole periods since the Unix epoch. It is what a caller persists to
// make a code single-use; see the package documentation on replay.
func Counter(t time.Time, p Params) (int64, error) {
p, err := p.resolve()
if err != nil {
return 0, err
}
return t.Unix() / int64(p.Period/time.Second), nil
}
// Code renders the code standing at the given instant.
func (s Secret) Code(t time.Time, p Params) (string, error) {
counter, err := Counter(t, p)
if err != nil {
return "", err
}
return s.at(counter, p)
}
// at renders the code for a time step. Params must already be resolved.
func (s Secret) at(counter int64, p Params) (string, error) {
p, err := p.resolve()
if err != nil {
return "", err
}
if len(s) == 0 {
return "", fmt.Errorf("%w: empty", ErrSecret)
}
var msg [8]byte
binary.BigEndian.PutUint64(msg[:], uint64(counter))
mac := hmac.New(p.Algorithm.new(), s)
mac.Write(msg[:])
sum := mac.Sum(nil)
// RFC 4226 Section 5.3 dynamic truncation: the low nibble of the last
// byte picks a four-byte window, whose high bit is cleared so the
// result is a positive 31-bit integer regardless of platform.
offset := sum[len(sum)-1] & 0x0f
value := binary.BigEndian.Uint32(sum[offset:offset+4]) & 0x7fffffff
mod := uint32(1)
for range p.Digits {
mod *= 10
}
return fmt.Sprintf(
"%0*d", p.Digits, value%mod,
), nil
}
// Verify reports whether the code stands at the given instant, accepting
// skew periods either side to absorb clock drift and typing latency. A
// negative skew is treated as zero.
//
// The returned counter is the time step the code matched, which the
// caller persists to refuse a replay: a code remains valid for its whole
// window, so verification alone does not make it single-use. See the
// package documentation.
//
// A malformed secret or parameter set reports false rather than an error:
// to a caller this is a failed verification either way, and the
// distinction would only tempt a call site into treating a
// misconfiguration as a pass.
func (s Secret) Verify(
code string,
t time.Time,
p Params,
skew int,
) (counter int64, ok bool) {
resolved, err := p.resolve()
if err != nil || len(s) == 0 {
return 0, false
}
code = strings.TrimSpace(code)
if len(code) != resolved.Digits {
// Comparing lengths is not a secret-dependent branch: the code
// length is public, carried in the enrollment URI.
return 0, false
}
if skew < 0 {
skew = 0
}
now, err := Counter(t, resolved)
if err != nil {
return 0, false
}
// Every candidate in the window is compared, and the loop does not
// stop at the first match: which step matched is derivable from the
// clock anyway, and running the full window keeps the work
// independent of where the match fell.
var matched int64
var found int
for i := -skew; i <= skew; i++ {
step := now + int64(i)
want, err := s.at(step, resolved)
if err != nil {
return 0, false
}
eq := subtle.ConstantTimeCompare([]byte(want), []byte(code))
matched = int64(subtle.ConstantTimeSelect(
eq, int(step), int(matched),
))
found |= eq
}
if found == 0 {
return 0, false
}
return matched, true
}
// URI renders the otpauth URI an authenticator scans (the Key URI Format
// popularized by Google Authenticator).
//
// issuer names the deployment and account names the user within it, both
// shown in the authenticator's list; account is conventionally an email
// address or username. The parameters travel with the URI so that both
// sides agree without the user configuring anything.
//
// The URI embeds the secret in the clear. It is meant to reach exactly
// one screen, once, over a channel already authenticated — never a log,
// a mail, or a redirect.
func (s Secret) URI(issuer, account string, p Params) string {
resolved, err := p.resolve()
if err != nil {
resolved, _ = Params{}.resolve()
}
// The label repeats the issuer by convention: authenticators that
// ignore the issuer parameter read it from the label instead.
label := url.PathEscape(account)
if issuer != "" {
label = url.PathEscape(issuer) + ":" + label
}
q := url.Values{}
q.Set("secret", s.String())
if issuer != "" {
q.Set("issuer", issuer)
}
q.Set("algorithm", string(resolved.Algorithm))
q.Set("digits", strconv.Itoa(resolved.Digits))
q.Set("period", strconv.FormatInt(int64(resolved.Period/time.Second), 10))
return "otpauth://totp/" + label + "?" + q.Encode()
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package file
import (
"context"
"encoding/json/v2"
"fmt"
"os"
"github.com/deep-rent/nexus/sec/jose/jwk"
"github.com/deep-rent/nexus/sec/sign"
"github.com/deep-rent/nexus/sec/vault"
"github.com/deep-rent/nexus/std/rotor"
)
// Permissions is the file mode [Save] creates key files with. The private
// key material inside is readable by its owner alone.
const Permissions = 0o600
// Key use values for [Item.Use].
const (
// UseSign marks a key as eligible for signing new tokens. It is the
// default when [Item.Use] is empty.
UseSign = "sign"
// UseVerify marks a key as verification-only: it stays published in
// the JWKS and continues to verify existing signatures, but no longer
// signs new tokens. A newly added key holds this state until every
// JWKS cache has picked it up; a retiring key holds it until every
// signature it produced has expired.
UseVerify = "verify"
)
// Item represents a single cryptographic key configuration containing the key
// and algorithm identifiers, and PEM-encoded private key material.
type Item struct {
Kid string `json:"kid"`
Alg string `json:"alg"`
Pem string `json:"pem"`
// Use selects the key's role, [UseSign] (the default when empty) or
// [UseVerify]. Staging a fresh key and retiring an old one both pass
// through the verification-only state, which is what makes a rotation
// invisible to token holders.
Use string `json:"use,omitempty"`
}
// Items represents a collection of key configurations.
type Items []Item
// NewItem wraps a signing key as a persistable [Item], PEM-encoding the
// private key backing the pair. It requires a software key (one with
// extractable key material, as produced by [jwk.Generate]); opaque handles
// such as KMS keys cannot be persisted and yield an error.
//
// The item is marked [UseSign]. Callers staging it as verification-only
// instead set Use to [UseVerify] before persisting it.
func NewItem(pair jwk.KeyPair) (Item, error) {
private := pair.Private()
if private == nil {
return Item{}, fmt.Errorf(
"key %q is not extractable", pair.KeyID(),
)
}
pem, err := sign.Encode(private)
if err != nil {
return Item{}, fmt.Errorf("failed to encode private key: %w", err)
}
return Item{
Kid: pair.KeyID(),
Alg: pair.Algorithm(),
Pem: string(pem),
Use: UseSign,
}, nil
}
// Parse deserializes a collection of key configurations from a JSON array,
// the inverse of [Format].
func Parse(data []byte) (Items, error) {
var items Items
if err := json.Unmarshal(data, &items); err != nil {
return nil, fmt.Errorf("failed to parse key file: %w", err)
}
return items, nil
}
// Format serializes a collection of key configurations into a JSON array,
// the inverse of [Parse].
func Format(items Items) ([]byte, error) {
return json.Marshal(items)
}
// Load reads and parses the key file at path.
func Load(path string) (Items, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return Parse(data)
}
// Save serializes the key configurations and writes them to path with
// [Permissions], replacing any existing file.
func Save(path string, items Items) error {
data, err := Format(items)
if err != nil {
return err
}
return os.WriteFile(path, data, Permissions)
}
// Source is a [vault.Source] over a JSON key file in the [Items] format on
// the local filesystem, typically a mounted Kubernetes Secret.
type Source struct {
path string
}
// New returns a [Source] reading the key file at path.
func New(path string) *Source { return &Source{path: path} }
// String implements [vault.Source].
func (s *Source) String() string { return s.path }
// Fetch implements [vault.Source]. Reading a local file is synchronous, so
// the context goes unused.
func (s *Source) Fetch(context.Context) ([]byte, error) {
return os.ReadFile(s.path)
}
// Build implements [vault.Source]. Keys marked [UseVerify] are assembled
// without their private half, so they are published in the JWKS but
// cannot enter the signing rotation.
func (*Source) Build(data []byte) ([]jwk.Key, error) {
items, err := Parse(data)
if err != nil {
return nil, err
}
keys := make([]jwk.Key, 0, len(items))
for _, item := range items {
if item.Alg == "" || item.Kid == "" || item.Pem == "" {
// Deliberately not printing the item itself: it may carry
// private key material.
return nil, fmt.Errorf("incomplete key item %q", item.Kid)
}
signer, err := sign.Decode([]byte(item.Pem))
if err != nil {
return nil, fmt.Errorf(
"failed to parse PEM for key %q: %w",
item.Kid, err,
)
}
var key jwk.Key
switch item.Use {
case "", UseSign:
key, err = jwk.NewKeyPairFor(item.Alg, item.Kid, signer)
case UseVerify:
// The private half is deliberately dropped: as a plain key,
// a retired or staged entry cannot sign even though the file
// still carries its material.
key, err = jwk.NewKeyFor(item.Alg, item.Kid, signer.Public())
default:
return nil, fmt.Errorf(
"invalid use %q for key %q",
item.Use, item.Kid,
)
}
if err != nil {
return nil, fmt.Errorf(
"failed to build key %q: %w",
item.Kid, err,
)
}
keys = append(keys, key)
}
return keys, nil
}
var _ vault.Source = (*Source)(nil)
// Watch assembles a [vault.Watcher] over the key file at path — the
// file-backed sibling of [okms.Watch].
//
// The initial read is synchronous and its failure is fatal, so a service
// cannot start without keys; later failures leave the previous keys
// serving. Until the watcher is dispatched to a scheduler it simply serves
// the keys read here, which is all a caller wanting a static vault needs.
//
// [okms.Watch]: github.com/deep-rent/nexus/sec/vault/source/okms#Watch
func Watch(
path string,
strategy rotor.Strategy,
opts ...vault.WatchOption,
) (vault.Watcher, error) {
// The source ignores the context, so there is nothing meaningful for
// the caller to pass in.
return vault.NewWatcher(context.Background(), New(path), strategy, opts...)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package okms
import (
"bytes"
"context"
"encoding/base64"
"encoding/json/jsontext"
"encoding/json/v2"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/deep-rent/nexus/net/transport"
"github.com/deep-rent/nexus/sys/log"
)
// maxErrorBody caps how much of an error response body is echoed into an
// error message.
const maxErrorBody = 512
// Config configures a [Backend].
type Config struct {
// Endpoint is the base URL of the KMS REST API, for example
// "https://eu-west-rbx.okms.ovh.net". A trailing slash will be stripped.
Endpoint string
// Domain is the KMS domain identifier, the "okmsId" path segment of
// every request.
Domain string
// Client is the HTTP client all requests go through. It must present
// the mutual-TLS client certificate the KMS authenticates; build one
// with [NewClient]. A client that does not come from there is
// responsible for bounding response bodies itself, as
// [transport.DefaultClient] documents.
Client *http.Client
// Logger reports the exchanges with the KMS at debug level. Defaults
// to a discarding logger.
Logger *log.Logger
}
// Backend is a minimal OVHcloud KMS REST client, covering exactly the
// surface the vault needs: listing service keys, exporting their public
// parts, and signing digests.
type Backend struct {
endpoint string
domain string
client *http.Client
logger *log.Logger
}
// NewBackend constructs a [Backend]. It panics on an incomplete
// configuration, since that is a deployment error to fail fast on.
func NewBackend(cfg Config) *Backend {
if cfg.Endpoint == "" {
panic("endpoint must not be empty")
}
if cfg.Domain == "" {
panic("domain must not be empty")
}
if cfg.Client == nil {
panic("client must not be nil")
}
logger := cfg.Logger
if logger == nil {
logger = log.Discard()
}
return &Backend{
endpoint: strings.TrimSuffix(cfg.Endpoint, "/"),
domain: cfg.Domain,
client: cfg.Client,
logger: logger,
}
}
// NewClient builds an HTTP client presenting the given PEM-encoded
// client certificate — the mutual-TLS credential the KMS authenticates —
// over a [transport.New] round tripper, so the exchanges inherit the
// module's connection hygiene: dial and handshake timeouts, a bounded
// response body, and an overall [transport.DefaultTimeout].
//
// Additional transport options are applied first, so the client
// certificate always wins; a deployment needing to shape the TLS
// configuration further (a private root CA, say) builds its own client
// and passes it as [Config.Client].
func NewClient(
certFile, keyFile string,
opts ...transport.Option,
) (*http.Client, error) {
// The KMS presents a publicly trusted certificate, so the system
// roots verify it; only the client half is ours to supply.
cfg, err := transport.MutualTLS(certFile, keyFile, "")
if err != nil {
return nil, err
}
opts = append(opts, transport.WithTLSConfig(cfg))
return &http.Client{
Timeout: transport.DefaultTimeout,
Transport: transport.New(opts...),
}, nil
}
// item is the subset of the KMS service key representation this
// package consumes.
type item struct {
ID string `json:"id"`
Name string `json:"name"`
Operations []string `json:"operations"`
Keys []jsontext.Value `json:"keys"`
}
// listResponse is one page of the service key listing.
type listResponse struct {
Cursor string `json:"continuation_token"`
Truncated bool `json:"is_truncated"`
Items []item `json:"objects_list"`
}
// signRequest asks the KMS to sign a pre-hashed digest. The message is
// explicitly padded standard base64, matching the API's byte-array
// convention.
type signRequest struct {
Alg string `json:"alg"`
Digest bool `json:"isdigest"`
Msg string `json:"message"`
}
// signResponse carries the base64-encoded signature.
type signResponse struct {
Signature string `json:"signature"`
}
// list fetches one page of the domain's active service keys.
func (c *Backend) list(
ctx context.Context,
cursor string,
) (listResponse, error) {
query := url.Values{"state": {"active"}}
if cursor != "" {
query.Set("continuation-token", cursor)
}
var page listResponse
if err := c.do(
ctx, http.MethodGet, "/v1/servicekey", query, nil, &page,
); err != nil {
return listResponse{}, err
}
return page, nil
}
// find fetches a single service key with its public material in JWK form.
func (c *Backend) find(ctx context.Context, id string) (item, error) {
query := url.Values{"format": {"jwk"}}
var key item
if err := c.do(
ctx,
http.MethodGet,
"/v1/servicekey/"+url.PathEscape(id),
query,
nil,
&key,
); err != nil {
return item{}, err
}
return key, nil
}
// sign has the KMS sign the digest with the named key and JWS algorithm,
// returning the raw signature bytes — for ECDSA, the fixed-width R||S
// concatenation the raw format denotes.
func (c *Backend) sign(
ctx context.Context,
id string,
alg string,
digest []byte,
) ([]byte, error) {
query := url.Values{"format": {"raw"}}
req := signRequest{
Alg: alg,
Digest: true,
Msg: base64.StdEncoding.EncodeToString(digest),
}
var res signResponse
if err := c.do(
ctx,
http.MethodPost,
"/v1/servicekey/"+url.PathEscape(id)+"/sign",
query,
&req,
&res,
); err != nil {
return nil, err
}
sig, err := base64.StdEncoding.DecodeString(res.Signature)
if err != nil {
return nil, fmt.Errorf("malformed signature encoding: %w", err)
}
return sig, nil
}
// do performs one JSON exchange against the KMS API.
func (c *Backend) do(
ctx context.Context,
method string,
path string,
query url.Values,
in any,
out any,
) error {
target := c.endpoint + "/api/" + url.PathEscape(c.domain) + path
if len(query) > 0 {
target += "?" + query.Encode()
}
var body io.Reader
if in != nil {
data, err := json.Marshal(in)
if err != nil {
return fmt.Errorf("failed to encode request: %w", err)
}
body = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, method, target, body)
if err != nil {
return fmt.Errorf("failed to build request: %w", err)
}
if in != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Accept", "application/json")
start := time.Now()
res, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("%s %s: %w", method, path, err)
}
defer func() {
if err := res.Body.Close(); err != nil {
c.logger.Warn(
ctx,
"Failed to close response body",
log.Error(err),
)
}
}()
// The exchange is logged without its body: requests carry digests and
// responses carry signatures, neither of which belongs in a log.
c.logger.Debug(
ctx,
"KMS request completed",
log.String("method", method),
log.String("path", path),
log.Int("status", res.StatusCode),
log.Duration("elapsed", time.Since(start)),
)
if res.StatusCode < http.StatusOK ||
res.StatusCode >= http.StatusMultipleChoices {
detail, _ := io.ReadAll(io.LimitReader(res.Body, maxErrorBody))
return fmt.Errorf(
"%s %s: status %d: %s",
method, path, res.StatusCode, strings.TrimSpace(string(detail)),
)
}
data, err := io.ReadAll(res.Body)
if err != nil {
return fmt.Errorf("%s %s: %w", method, path, err)
}
if err := json.Unmarshal(data, out); err != nil {
return fmt.Errorf("%s %s: invalid response: %w", method, path, err)
}
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package okms
import (
"context"
"crypto"
"io"
"github.com/deep-rent/nexus/sec/sign"
)
// signer implements [sign.Signer] over the KMS sign endpoint. The private
// key never exists locally: only the digest travels to the KMS, under the
// context of the token issuance being served. The public half is captured
// at build time, so Public involves no network.
type signer struct {
backend *Backend
key string // service key ID, not necessarily the JWK kid
alg string // JWS algorithm, fixed per key
pub crypto.PublicKey
}
// Public implements [sign.Signer].
func (s *signer) Public() crypto.PublicKey { return s.pub }
// Private implements [sign.Signer]. The private key lives in the KMS and
// is never extractable.
func (*signer) Private() crypto.PrivateKey { return nil }
// Sign implements [sign.Signer]. The signer options are ignored: the JWS
// algorithm is fixed per key, and the KMS applies its hash and padding
// parameters itself.
func (s *signer) Sign(
ctx context.Context,
_ io.Reader,
digest []byte,
_ crypto.SignerOpts,
) ([]byte, error) {
return s.backend.sign(ctx, s.key, s.alg, digest)
}
// ECDSAFormat implements [sign.ECDSAFormatSigner]: asked for raw
// signatures, the KMS returns the fixed-width R||S concatenation, which
// is already the JOSE wire form.
func (*signer) ECDSAFormat() sign.ECDSAFormat { return sign.ECDSARaw }
var _ sign.ECDSAFormatSigner = (*signer)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package okms
import (
"context"
"encoding/json/jsontext"
"encoding/json/v2"
"errors"
"fmt"
"slices"
"strings"
"github.com/deep-rent/nexus/sec/jose/jwk"
"github.com/deep-rent/nexus/sec/vault"
"github.com/deep-rent/nexus/std/rotor"
)
// maxPages caps the service key listing at a size far beyond any sane
// domain, so a misbehaving endpoint that keeps returning continuation
// tokens (pagination cursors) cannot spin a refresh forever.
const maxPages = 100
// Watch assembles a [vault.Watcher] over the KMS domain's active service
// keys — the KMS-backed sibling of [file.Watch]. The context governs only
// the initial fetch.
//
// [file.Watch]: github.com/deep-rent/nexus/sec/vault/source/file#Watch
func Watch(
ctx context.Context,
b *Backend,
strategy rotor.Strategy,
opts ...vault.WatchOption,
) (vault.Watcher, error) {
return vault.NewWatcher(ctx, New(b), strategy, opts...)
}
// Source is a [vault.Source] over a KMS domain's active service keys. A
// key whose operations include "sign" joins the signing rotation; a key
// offering only "verify" is published for verification alone, which is how
// a staged or retiring key is expressed in the KMS. Keys that are neither
// EC nor RSA are ignored.
type Source struct {
backend *Backend
}
// New returns a [Source] over the service keys of the domain the backend
// addresses.
func New(b *Backend) *Source { return &Source{backend: b} }
// String implements [vault.Source].
func (s *Source) String() string {
return s.backend.endpoint + "/api/" + s.backend.domain
}
// entry is the canonical serialization of one key: the unit of the
// fingerprint the watcher hashes between refreshes, carrying only public
// material.
type entry struct {
Key string `json:"key"` // service key ID, the sign path segment
Kid string `json:"kid"`
Sign bool `json:"sign"`
JWK jsontext.Value `json:"jwk"`
}
// Fetch implements [vault.Source]. It pages through the domain's active
// service keys and serializes the eligible ones — sanitized to their
// public parameters and sorted by key ID — into a deterministic document
// for the watcher to fingerprint.
func (s *Source) Fetch(ctx context.Context) ([]byte, error) {
var entries []entry
cursor := ""
for page := 0; ; page++ {
if page == maxPages {
return nil, fmt.Errorf(
"key listing did not complete within %d pages",
maxPages,
)
}
res, err := s.backend.list(ctx, cursor)
if err != nil {
return nil, err
}
for _, key := range res.Items {
es, err := s.expand(ctx, key)
if err != nil {
return nil, err
}
entries = append(entries, es...)
}
if !res.Truncated {
break
}
cursor = res.Cursor
}
slices.SortFunc(entries, func(a, b entry) int {
return strings.Compare(a.Kid, b.Kid)
})
return json.Marshal(entries)
}
// expand turns one service key into entries, fetching its public JWKs
// when the listing did not embed them. Keys without signature operations
// or of unsupported types yield no entries.
func (s *Source) expand(
ctx context.Context,
key item,
) ([]entry, error) {
signs := slices.Contains(key.Operations, "sign")
if !signs && !slices.Contains(key.Operations, "verify") {
return nil, nil
}
jwks := key.Keys
if len(jwks) == 0 {
detail, err := s.backend.find(ctx, key.ID)
if err != nil {
return nil, err
}
jwks = detail.Keys
}
entries := make([]entry, 0, len(jwks))
for _, raw := range jwks {
norm, err := normalize(raw, key.ID)
if err != nil {
return nil, fmt.Errorf("key %q: %w", key.ID, err)
}
if norm == nil {
continue
}
// Parsing keeps only the public material — a software-protected
// key's exported "d" cannot survive the round trip — and rejects
// material the vault could not verify with later.
pub, err := jwk.Parse(norm)
if err != nil {
return nil, fmt.Errorf("key %q: %w", key.ID, err)
}
data, err := jwk.Write(pub)
if err != nil {
return nil, fmt.Errorf("key %q: %w", key.ID, err)
}
entries = append(entries, entry{
Key: key.ID,
Kid: pub.KeyID(),
Sign: signs,
JWK: data,
})
}
return entries, nil
}
// Build implements [vault.Source]. Signing keys become pairs whose public
// halves come from the fetched JWKs and whose signers delegate to the KMS
// sign endpoint; the rest stay plain verification keys, so a retired
// entry cannot sign even though the KMS itself still would if asked.
func (s *Source) Build(data []byte) ([]jwk.Key, error) {
var entries []entry
if err := json.Unmarshal(data, &entries); err != nil {
return nil, fmt.Errorf("invalid key document: %w", err)
}
keys := make([]jwk.Key, 0, len(entries))
for _, e := range entries {
pub, err := jwk.Parse(e.JWK)
if err != nil {
return nil, fmt.Errorf("key %q: %w", e.Key, err)
}
if !e.Sign {
// The parsed JWK is already a verification-only key.
keys = append(keys, pub)
continue
}
pair, err := jwk.NewKeyPairFor(pub.Algorithm(), pub.KeyID(), &signer{
backend: s.backend,
key: e.Key,
alg: pub.Algorithm(),
pub: pub.Public(),
})
if err != nil {
return nil, fmt.Errorf("key %q: %w", e.Key, err)
}
keys = append(keys, pair)
}
return keys, nil
}
var _ vault.Source = (*Source)(nil)
// curves maps an elliptic curve to the JWS algorithm it implies, for
// exports that omit the "alg" parameter.
var curves = map[string]string{
"P-256": "ES256",
"P-384": "ES384",
"P-521": "ES512",
}
// normalize prepares a KMS-exported JWK for [jwk.Parse]: it fills in the
// algorithm EC exports may omit, defaults the key ID to the service key
// ID, and marks the key for signature use. Members it does not touch pass
// through untouched, since parsing — not this step — decides what the
// vault ultimately holds.
//
// RSA keys must declare their algorithm, since the key material alone
// does not distinguish the PKCS #1 v1.5 and PSS families. A nil result
// without an error marks a key of a type the vault cannot use, which the
// caller skips.
func normalize(raw jsontext.Value, kid string) (jsontext.Value, error) {
var attrs map[string]any
if err := json.Unmarshal(raw, &attrs); err != nil {
return nil, fmt.Errorf("invalid JWK: %w", err)
}
val := func(name string) string {
s, _ := attrs[name].(string)
return s
}
switch val("kty") {
case "EC":
if val("alg") == "" {
alg, ok := curves[val("crv")]
if !ok {
return nil, fmt.Errorf("unsupported curve %q", val("crv"))
}
attrs["alg"] = alg
}
case "RSA":
if val("alg") == "" {
return nil, errors.New("RSA key does not declare an algorithm")
}
default:
return nil, nil
}
if val("kid") == "" {
attrs["kid"] = kid
}
// The KMS states a key's operations on the service key rather than on
// the exported JWK, and the caller has already read them; every key
// reaching this point is published for verification.
delete(attrs, "key_ops")
attrs["use"] = "sig"
return json.Marshal(attrs)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package vault
import (
"errors"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/sec/jose/jwk"
"github.com/deep-rent/nexus/std/rotor"
)
// Vault represents a secure retrieval mechanism for cryptographic signing keys.
// It abstracts away the underlying implementation details of external sources
// like cluster-injected keys, KMS, HSM, or HashiCorp Vault.
type Vault interface {
// Keys returns the set of all public keys for verification purposes.
Keys() jwk.Set
// Next retrieves the currently active [jwk.KeyPair] intended for signing
// new tokens.
Next() jwk.KeyPair
}
// vault is the default implementation of [Vault].
type vault struct {
pub jwk.Set
prv rotor.Rotor[jwk.KeyPair]
}
// New constructs a static [Vault] over the given keys and rotation
// strategy. Every key is published for verification; those that are
// [jwk.KeyPair]s form the signing rotation, so a caller expresses a
// verification-only key by passing a plain [jwk.Key].
//
// It returns an error if no keys are given, or if none of them can sign,
// since a vault that cannot mint tokens is a deployment error rather than
// a runtime condition. Callers wanting the key set to track a changing
// backend build a [Watcher] over a [Source] instead.
func New(keys []jwk.Key, strategy rotor.Strategy) (Vault, error) {
if len(keys) == 0 {
return nil, errors.New("empty key list")
}
signing := make([]jwk.KeyPair, 0, len(keys))
for _, k := range keys {
if pair, ok := k.(jwk.KeyPair); ok {
signing = append(signing, pair)
}
}
if len(signing) == 0 {
return nil, errors.New("no signing keys found")
}
return &vault{
pub: jwk.NewSet(keys...),
prv: rotor.New(strategy, signing),
}, nil
}
func (v *vault) Keys() jwk.Set { return v.pub }
func (v *vault) Next() jwk.KeyPair { return v.prv.Next() }
var _ Vault = (*vault)(nil)
// Handler creates a [router.Handler] that exposes the public keys of the
// [Vault] as a JSON Web Key Set (JWKS).
func Handler(v Vault) router.Handler {
return jwk.Handler(v.Keys())
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package vault
import (
"context"
"iter"
"slices"
"strings"
"sync/atomic"
"time"
"github.com/deep-rent/nexus/sec/digest"
"github.com/deep-rent/nexus/sec/jose/jwk"
"github.com/deep-rent/nexus/std/rotor"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/schedule"
)
// DefaultWatchInterval is the default period between refreshes of a
// watched key source. One minute sits in the same order as the propagation
// delay of an updated Kubernetes Secret to its mounts, so a shorter
// default would not meaningfully speed up a rotation.
const DefaultWatchInterval = time.Minute
// Watcher is a [Vault] backed by a [Source] that is refreshed on a
// schedule. Dispatch it to a [schedule.Scheduler] to activate the
// reloading; until then it serves the key set read at construction and so
// behaves like a static vault.
//
// A Watcher turns key rotation and revocation into edits of the backing
// source: update the mounted Secret (or the KMS domain), and every replica
// converges on the new key set within one watch interval — no restart, no
// API call against the service, and restarts are idempotent because the
// source remains the single point of truth.
type Watcher interface {
Vault
schedule.Tick
}
// WatchOption configures a [Watcher].
type WatchOption func(*watcher)
// WithInterval overrides [DefaultWatchInterval]. Non-positive durations are
// ignored.
func WithInterval(d time.Duration) WatchOption {
return func(w *watcher) {
if d > 0 {
w.interval = d
}
}
}
// WithLogger sets the logger refresh outcomes are reported to. A nil
// logger is ignored; the default discards.
func WithLogger(logger *log.Logger) WatchOption {
return func(w *watcher) {
if logger != nil {
w.logger = logger
}
}
}
// WithHasher sets the hasher that fingerprints fetched configurations to
// decide whether anything changed. A nil hasher is ignored. Defaults to
// [digest.DefaultHasher].
//
// The fingerprint only detects change, so the choice of algorithm is a
// question of collision resistance rather than secrecy: two distinct key
// configurations colliding would strand the watcher on the older one.
func WithHasher(hasher *digest.Hasher) WatchOption {
return func(w *watcher) {
if hasher != nil {
w.hasher = hasher
}
}
}
// watcher is the concrete implementation of [Watcher]. It keeps the most
// recently accepted key set behind an atomic pointer; readers dereference
// it per call while Run swaps it wholesale.
type watcher struct {
src Source
strategy rotor.Strategy
interval time.Duration
logger *log.Logger
hasher *digest.Hasher
// fingerprint identifies the last accepted configuration so an
// unchanged source skips rebuilding, and summary describes the key
// material that configuration yielded, so a reload that changes only
// the encoding is not reported as a rotation. Only Run touches them,
// and the scheduler never overlaps a tick with itself.
fingerprint string
summary string
inner atomic.Pointer[Vault]
}
// summarize describes a vault's key material: every key's identifier,
// algorithm, and whether it can sign, in a stable order. Two vaults with
// equal summaries are interchangeable to callers, so a configuration that
// was merely reformatted or reordered summarizes identically.
func summarize(v Vault) string {
var entries []string
for k := range v.Keys().Keys() {
use := "verify"
if _, ok := k.(jwk.KeyPair); ok {
use = "sign"
}
entries = append(entries, k.KeyID()+"/"+k.Algorithm()+"/"+use)
}
slices.Sort(entries)
return strings.Join(entries, ",")
}
// NewWatcher wraps a [Source] in a [Watcher] that refreshes it on every
// scheduler tick. The initial fetch is synchronous and its failure is
// fatal: a service must not start without keys. Later failures are not —
// see [Watcher.Run]. The context governs only the initial fetch.
func NewWatcher(
ctx context.Context,
src Source,
strategy rotor.Strategy,
opts ...WatchOption,
) (Watcher, error) {
w := &watcher{
src: src,
strategy: strategy,
interval: DefaultWatchInterval,
logger: log.Discard(),
hasher: digest.DefaultHasher,
}
for _, opt := range opts {
opt(w)
}
data, err := src.Fetch(ctx)
if err != nil {
return nil, err
}
keys, err := src.Build(data)
if err != nil {
return nil, err
}
v, err := New(keys, w.strategy)
if err != nil {
return nil, err
}
w.fingerprint = w.hasher.Bytes(data)
w.summary = summarize(v)
w.inner.Store(&v)
return w, nil
}
// Keys implements [Vault]. The returned set is a live view: every lookup
// consults the key set most recently loaded from the source, so consumers
// that capture the set once — token verifiers, the JWKS handler — observe
// reloads without re-wiring.
func (w *watcher) Keys() jwk.Set { return &liveSet{w} }
// Next implements [Vault].
func (w *watcher) Next() jwk.KeyPair {
v := *w.inner.Load()
return v.Next()
}
// Run implements [schedule.Tick]. It refreshes the source and atomically
// swaps the key set when the content changed and builds cleanly. Any
// failure — an unreachable source, malformed content, no signing keys —
// leaves the previous keys serving and logs a warning; the warning repeats
// on every tick until the source recovers, so a broken push cannot fail
// silently.
func (w *watcher) Run(ctx context.Context) time.Duration {
data, err := w.src.Fetch(ctx)
if err != nil {
w.logger.Warn(
ctx,
"Failed to fetch key source; keeping previous keys",
log.String("source", w.src.String()),
log.Error(err),
)
return w.interval
}
// Both fingerprints are derived from trusted input and neither is a
// secret an attacker can probe, so a plain comparison is enough; the
// constant-time [digest.Equal] guards secret digests instead.
sum := w.hasher.Bytes(data)
changed := sum != w.fingerprint
rotated := false
if changed {
keys, err := w.src.Build(data)
var v Vault
if err == nil {
v, err = New(keys, w.strategy)
}
if err != nil {
w.logger.Warn(
ctx,
"Failed to load key source; keeping previous keys",
log.String("source", w.src.String()),
log.Error(err),
)
return w.interval
}
next := summarize(v)
rotated = next != w.summary
w.fingerprint = sum
w.summary = next
w.inner.Store(&v)
}
// Every check reports at debug, so an operator raising the level sees
// the watcher's pulse rather than having to infer it from silence.
w.logger.Debug(
ctx,
"Checked key source",
log.String("source", w.src.String()),
log.Bool("changed", changed),
log.Bool("rotated", rotated),
)
// Info is reserved for a genuine rotation. A changed configuration
// that yields the same keys — a reformatted file, reordered entries —
// is routine bookkeeping, and reporting it would train operators to
// ignore the message that matters.
if rotated {
w.logger.Info(
ctx,
"Rotated signing keys",
log.String("source", w.src.String()),
log.Int("keys", (*w.inner.Load()).Keys().Size()),
)
}
return w.interval
}
var _ Watcher = (*watcher)(nil)
// liveSet adapts a watcher to [jwk.Set], delegating every call to the
// currently loaded key set.
type liveSet struct{ w *watcher }
// Keys implements [jwk.Set].
func (s *liveSet) Keys() iter.Seq[jwk.Key] {
v := *s.w.inner.Load()
return v.Keys().Keys()
}
// Size implements [jwk.Set].
func (s *liveSet) Size() int {
v := *s.w.inner.Load()
return v.Keys().Size()
}
// Find implements [jwk.Set].
func (s *liveSet) Find(
h jwk.Hint,
) jwk.Key {
v := *s.w.inner.Load()
return v.Keys().Find(h)
}
var _ jwk.Set = (*liveSet)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package ascii
// Named code points for the ASCII characters that lack a printable symbol,
// covering the C0 control set (0x00–0x1F), the space character (0x20), and
// the delete character (0x7F).
//
// The constants are untyped, so they can be used interchangeably as a [rune]
// or a [byte].
const (
NUL = 0x00 // '\0' Null
SOH = 0x01 // Start of Heading
STX = 0x02 // Start of Text
ETX = 0x03 // End of Text
EOT = 0x04 // End of Transmission
ENQ = 0x05 // Enquiry
ACK = 0x06 // Acknowledgement
BEL = 0x07 // '\a' Bell
BS = 0x08 // '\b' Backspace
HT = 0x09 // '\t' Horizontal Tab
LF = 0x0A // '\n' Line Feed
VT = 0x0B // '\v' Vertical Tab
FF = 0x0C // '\f' Form Feed
CR = 0x0D // '\r' Carriage Return
SO = 0x0E // Shift Out
SI = 0x0F // Shift In
DLE = 0x10 // Data Link Escape
DC1 = 0x11 // Device Control 1
DC2 = 0x12 // Device Control 2
DC3 = 0x13 // Device Control 3
DC4 = 0x14 // Device Control 4
NAK = 0x15 // Negative Acknowledgement
SYN = 0x16 // Synchronous Idle
ETB = 0x17 // End of Transmission Block
CAN = 0x18 // Cancel
EM = 0x19 // End of Medium
SUB = 0x1A // Substitute
ESC = 0x1B // '\e' Escape
FS = 0x1C // File Separator
GS = 0x1D // Group Separator
RS = 0x1E // Record Separator
US = 0x1F // Unit Separator
SP = 0x20 // Space
DEL = 0x7F // Delete
)
const (
// Uppers is the set of all uppercase ASCII letters.
Uppers = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
// Lowers is the set of all lowercase ASCII letters.
Lowers = "abcdefghijklmnopqrstuvwxyz"
// Digits is the set of all ASCII digits.
Digits = "0123456789"
)
// IsUpper reports whether the byte is an uppercase ASCII letter
// ('A' through 'Z').
func IsUpper(c byte) bool { return lookup[c]&upp != 0 }
// IsLower reports whether the byte is a lowercase ASCII letter
// ('a' through 'z').
func IsLower(c byte) bool { return lookup[c]&low != 0 }
// IsDigit reports whether the byte is an ASCII decimal digit
// ('0' through '9').
func IsDigit(c byte) bool { return lookup[c]&dig != 0 }
// IsAlpha reports whether the byte is an ASCII letter (uppercase or lowercase).
func IsAlpha(c byte) bool { return lookup[c]&alphaMask != 0 }
// IsAlphaNum reports whether the byte is an ASCII letter or decimal digit.
func IsAlphaNum(c byte) bool { return lookup[c]&alphaNumMask != 0 }
// IsHex reports whether the given byte is a hexadecimal character
// ('0' through '9', 'a' through 'f', or 'A' through 'F').
func IsHex(c byte) bool { return lookup[c]&hexMask != 0 }
// IsWord reports whether the byte is an ASCII letter, digit, or underscore
// ('_').
//
// This is commonly used for validating variable names or identifiers.
func IsWord(c byte) bool { return IsAlphaNum(c) || c == '_' }
// IsSlug reports whether the byte is an ASCII letter, digit, or hyphen ('-').
//
// This is commonly used for validating URL path components.
func IsSlug(c byte) bool { return IsAlphaNum(c) || c == '-' }
// IsSpace reports whether the byte is a space character as defined
// by ASCII's property: ' ', '\t', '\n', '\v', '\f', '\r'.
func IsSpace(c byte) bool { return lookup[c]&isp != 0 }
// IsPrint reports whether the byte is a printable ASCII character,
// defined as any character from space (0x20) to tilde (0x7E).
func IsPrint(c byte) bool { return lookup[c]&printMask != 0 }
// IsControl reports whether the byte is an ASCII control character, defined as
// any character less than space (0x20) or the delete character (0x7F).
func IsControl(c byte) bool { return lookup[c]&ctl != 0 }
// IsPunct reports whether the byte is an ASCII punctuation character, one of
// !"#%&'()*,-./:;?@[\]_{}.
func IsPunct(c byte) bool { return lookup[c]&pun != 0 }
// IsSymbol reports whether the byte is an ASCII symbol character, one of
// $+<=>^`|~.
func IsSymbol(c byte) bool { return lookup[c]&sym != 0 }
// IsGraph reports whether the byte has a visible graphic representation,
// defined as any printable ASCII character except space
// ('!' (0x21) through '~' (0x7E)).
func IsGraph(c byte) bool { return lookup[c]&graphMask != 0 }
// IsASCII reports whether the byte is a valid ASCII character.
func IsASCII(c byte) bool { return c <= 0x7F }
// Lower converts an uppercase ASCII byte to lowercase.
//
// If the byte is not an uppercase letter, it is returned unchanged.
func Lower(c byte) byte {
if c >= 'A' && c <= 'Z' {
return c | 0x20
}
return c
}
// Upper converts a lowercase ASCII byte to uppercase.
//
// If the byte is not a lowercase letter, it is returned unchanged.
func Upper(c byte) byte {
if c >= 'a' && c <= 'z' {
return c &^ 0x20
}
return c
}
// All reports whether every byte in the string satisfies the given predicate.
// If the string is empty, it returns true.
func All(s string, fn func(c byte) bool) bool {
for i := 0; i < len(s); i++ {
if !fn(s[i]) {
return false
}
}
return true
}
// EqualFold is a fast, ASCII-only case-insensitive string comparison.
// It avoids the overhead of unicode-aware casing rules found in
// [strings.EqualFold].
func EqualFold(s, t string) bool {
if len(s) != len(t) {
return false
}
for i := 0; i < len(s); i++ {
a, b := s[i], t[i]
if a == b {
continue
}
// Convert both to lowercase using bitwise OR and compare.
if a >= 'A' && a <= 'Z' {
a |= 0x20
}
if b >= 'A' && b <= 'Z' {
b |= 0x20
}
if a != b {
return false
}
}
return true
}
// HasUpper reports whether the string contains any uppercase ASCII letters.
func HasUpper(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] >= 'A' && s[i] <= 'Z' {
return true
}
}
return false
}
// HasLower reports whether the string contains any lowercase ASCII letters.
func HasLower(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] >= 'a' && s[i] <= 'z' {
return true
}
}
return false
}
// ToLower returns a copy of the string with all ASCII letters mapped to their
// lower case.
func ToLower(s string) string {
if !HasUpper(s) {
return s
}
b := make([]byte, len(s))
for i := 0; i < len(s); i++ {
c := s[i]
if c >= 'A' && c <= 'Z' {
c |= 0x20
}
b[i] = c
}
return string(b)
}
// ToUpper returns a copy of the string with all ASCII letters mapped to their
// upper case.
func ToUpper(s string) string {
if !HasLower(s) {
return s
}
b := make([]byte, len(s))
for i := 0; i < len(s); i++ {
c := s[i]
if c >= 'a' && c <= 'z' {
c &^= 0x20
}
b[i] = c
}
return string(b)
}
// Character class bits. Every printable code point belongs to exactly one of
// the exclusive classes below; the hex and whitespace bits are additive flags
// layered on top.
const (
ctl uint16 = 1 << iota // control character
spc // the space character (0x20)
dig // decimal digit
upp // uppercase letter
low // lowercase letter
pun // punctuation
sym // symbol
hex // hexadecimal digit (additive)
isp // whitespace (additive; reported by IsSpace)
)
// Class masks combining the individual character class bits.
const (
alphaMask = upp | low // IsAlpha
alphaNumMask = upp | low | dig // IsAlphaNum
hexMask = dig | hex // IsHex
graphMask = upp | low | dig | pun | sym // IsGraph
printMask = graphMask | spc // IsPrint
)
// Convenience combinations used when building the lookup table.
const (
csp = ctl | isp // control character that is also whitespace
tsp = spc | isp // the space character
uhx = upp | hex // uppercase hexadecimal digit (A–F)
lhx = low | hex // lowercase hexadecimal digit (a–f)
)
// lookup maps every byte to its set of character class bits. Entries for
// the non-ASCII bytes (0x80–0xFF) are zero, so those bytes belong to no
// class. The table is indexed by a byte, which is always in range, so the
// classification functions need neither a bounds check nor a preceding
// range test.
var lookup = [256]uint16{
/* 00-07 NUL SOH STX ETX EOT ENQ ACK BEL */
ctl, ctl, ctl, ctl, ctl, ctl, ctl, ctl,
/* 08-0F BS HT LF VT FF CR SO SI */
ctl, csp, csp, csp, csp, csp, ctl, ctl,
/* 10-17 DLE DC1 DC2 DC3 DC4 NAK SYN ETB */
ctl, ctl, ctl, ctl, ctl, ctl, ctl, ctl,
/* 18-1F CAN EM SUB ESC FS GS RS US */
ctl, ctl, ctl, ctl, ctl, ctl, ctl, ctl,
/* 20-27 SP ! " # $ % & ' */
tsp, pun, pun, pun, sym, pun, pun, pun,
/* 28-2F ( ) * + , - . / */
pun, pun, pun, sym, pun, pun, pun, pun,
/* 30-37 0 1 2 3 4 5 6 7 */
dig, dig, dig, dig, dig, dig, dig, dig,
/* 38-3F 8 9 : ; < = > ? */
dig, dig, pun, pun, sym, sym, sym, pun,
/* 40-47 @ A B C D E F G */
pun, uhx, uhx, uhx, uhx, uhx, uhx, upp,
/* 48-4F H I J K L M N O */
upp, upp, upp, upp, upp, upp, upp, upp,
/* 50-57 P Q R S T U V W */
upp, upp, upp, upp, upp, upp, upp, upp,
/* 58-5F X Y Z [ \ ] ^ _ */
upp, upp, upp, pun, pun, pun, sym, pun,
/* 60-67 ` a b c d e f g */
sym, lhx, lhx, lhx, lhx, lhx, lhx, low,
/* 68-6F h i j k l m n o */
low, low, low, low, low, low, low, low,
/* 70-77 p q r s t u v w */
low, low, low, low, low, low, low, low,
/* 78-7F x y z { | } ~ DEL */
low, low, low, pun, sym, pun, sym, ctl,
// 0x80–0xFF are non-ASCII; every entry is zero (no class).
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package atomic
import (
"math"
"sync/atomic"
)
// Float32 is an atomic float32. The zero value holds 0.
type Float32 struct {
bits atomic.Uint32
}
// Load atomically returns the value stored in f.
func (f *Float32) Load() float32 {
return math.Float32frombits(f.bits.Load())
}
// Store atomically stores v into f.
func (f *Float32) Store(v float32) {
f.bits.Store(math.Float32bits(v))
}
// Swap atomically stores v into f and returns the previous value.
func (f *Float32) Swap(v float32) (old float32) {
return math.Float32frombits(f.bits.Swap(math.Float32bits(v)))
}
// Add atomically adds delta to f and returns the new value. The addition is
// performed with a compare-and-swap loop.
func (f *Float32) Add(delta float32) (new float32) {
for {
old := f.bits.Load()
new := math.Float32frombits(old) + delta
if f.bits.CompareAndSwap(old, math.Float32bits(new)) {
return new
}
}
}
// CompareAndSwap executes the compare-and-swap operation for f. The
// comparison is on bit patterns, not floating-point equality; see the
// package documentation for the resulting NaN and signed-zero caveats.
func (f *Float32) CompareAndSwap(old, new float32) (swapped bool) {
return f.bits.CompareAndSwap(math.Float32bits(old), math.Float32bits(new))
}
// Float64 is an atomic float64. The zero value holds 0.
type Float64 struct {
bits atomic.Uint64
}
// Load atomically returns the value stored in f.
func (f *Float64) Load() float64 {
return math.Float64frombits(f.bits.Load())
}
// Store atomically stores v into f.
func (f *Float64) Store(v float64) {
f.bits.Store(math.Float64bits(v))
}
// Swap atomically stores v into f and returns the previous value.
func (f *Float64) Swap(v float64) (old float64) {
return math.Float64frombits(f.bits.Swap(math.Float64bits(v)))
}
// Add atomically adds delta to f and returns the new value. The addition is
// performed with a compare-and-swap loop.
func (f *Float64) Add(delta float64) (new float64) {
for {
old := f.bits.Load()
new := math.Float64frombits(old) + delta
if f.bits.CompareAndSwap(old, math.Float64bits(new)) {
return new
}
}
}
// CompareAndSwap executes the compare-and-swap operation for f. The
// comparison is on bit patterns, not floating-point equality; see the
// package documentation for the resulting NaN and signed-zero caveats.
func (f *Float64) CompareAndSwap(old, new float64) (swapped bool) {
return f.bits.CompareAndSwap(math.Float64bits(old), math.Float64bits(new))
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package backoff
import (
"context"
"time"
)
// Attempts is a running counter over a [Strategy], scoped to a single retried
// operation.
//
// Unlike a [Strategy], an Attempts value is stateful and therefore NOT safe
// for concurrent use. Create one per operation; sharing it between operations
// makes them inflate each other's delays and reset each other's progress.
type Attempts struct {
s Strategy // underlying strategy supplying the delays
n int // number of delays handed out so far
}
// Count returns an [Attempts] counter that draws its delays from s. It panics
// if s is nil.
func Count(s Strategy) *Attempts {
if s == nil {
panic("count requires a non-nil strategy")
}
return &Attempts{s: s}
}
// Next advances the counter and returns the delay preceding the next attempt.
func (a *Attempts) Next() time.Duration {
a.n++
return a.s.Delay(a.n)
}
// Wait advances the counter and blocks for the resulting delay, returning
// early if ctx is canceled. See [Wait] for the error semantics.
func (a *Attempts) Wait(ctx context.Context) error {
return Wait(ctx, a.Next())
}
// Count reports how many delays have been handed out since the counter was
// created or last reset.
func (a *Attempts) Count() int { return a.n }
// Reset returns the counter to its initial state, so that the next call to
// [Attempts.Next] yields the delay of the first retry again. It must be called
// before the counter is reused for another operation.
func (a *Attempts) Reset() { a.n = 0 }
// Wait blocks for the duration d, or until ctx is done, whichever happens
// first. It returns nil once the full duration has elapsed, and the result of
// [context.Context.Err] if the context was canceled first.
//
// A non-positive duration returns immediately, but the context is still
// checked, so a canceled context is reported even when there is no waiting to
// be done.
func Wait(ctx context.Context, d time.Duration) error {
if err := ctx.Err(); err != nil {
return err
}
if d <= 0 {
return nil
}
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package backoff
import (
"time"
"github.com/deep-rent/nexus/std/jitter"
)
// Strategy defines the contract for a backoff algorithm.
//
// Implementations are stateless: the delay depends only on the attempt number
// passed to [Strategy.Delay], never on how often the strategy has been called
// before. They are therefore safe to share between concurrently retried
// operations, each of which counts its own attempts.
type Strategy interface {
// Delay returns the duration to wait before attempt n, where n is 1 for
// the first retry that follows the initial, failed attempt. Values below 1
// are treated as 1. The result is bounded by [Strategy.MinDelay] and
// [Strategy.MaxDelay].
Delay(n int) time.Duration
// MinDelay returns the lower bound for the durations returned by
// [Strategy.Delay].
MinDelay() time.Duration
// MaxDelay returns the upper bound for the durations returned by
// [Strategy.Delay].
MaxDelay() time.Duration
}
// Rand is a minimal source of randomness used to compute jitter. It is
// satisfied by [math/rand/v2.Rand].
type Rand interface {
// Float64 generates a pseudo-random number in [0.0, 1.0).
Float64() float64
}
// New creates a backoff [Strategy] from the provided options.
//
// The returned strategy is exponential by default. It degrades to a linear
// strategy if the growth factor is one or less, and to a constant strategy if
// the minimum delay is not less than the maximum delay. Jitter, if any, is
// applied on top of whichever strategy is selected.
func New(opts ...Option) Strategy {
c := config{
minDelay: DefaultMinDelay,
maxDelay: DefaultMaxDelay,
growthFactor: DefaultGrowthFactor,
jitterAmount: DefaultJitterAmount,
}
for _, opt := range opts {
opt(&c)
}
var s Strategy
switch {
case c.minDelay >= c.maxDelay:
s = &constant{delay: c.maxDelay}
// Written as a negated comparison so that a growth factor of NaN, which
// compares false against everything, also selects linear backoff.
case !(c.growthFactor > 1):
s = &linear{minDelay: c.minDelay, maxDelay: c.maxDelay}
default:
s = &exponential{
minDelay: c.minDelay,
maxDelay: c.maxDelay,
growthFactor: c.growthFactor,
}
}
return Jitter(s, c.jitterAmount, c.rand)
}
// Jitter decorates a [Strategy] so that its delays are randomly shortened,
// spreading retries of concurrent clients over time. The amount is a fraction
// between 0 and 1; a jittered delay is drawn from [d*(1-amount), d]. The
// strategy is returned unchanged if the amount is zero or less. If r is nil, a
// shared, auto-seeded generator is used.
func Jitter(s Strategy, amount float64, r Rand) Strategy {
if amount <= 0 {
return s
}
return &spread{s: s, j: jitter.New(min(1, amount), r)}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package backoff
import (
"time"
)
const (
// DefaultMinDelay is the default minimum time between consecutive retries.
DefaultMinDelay = 1 * time.Second
// DefaultMaxDelay is the default maximum time between consecutive retries.
DefaultMaxDelay = 1 * time.Minute
// DefaultGrowthFactor is the default growth factor in exponential backoff.
DefaultGrowthFactor float64 = 2.0
// DefaultJitterAmount is the default amount of jitter applied.
DefaultJitterAmount float64 = 0.5
)
// config holds the parameters for building a [Strategy] via [New].
type config struct {
minDelay time.Duration // lower bound for the delay
maxDelay time.Duration // upper bound for the delay
growthFactor float64 // exponential multiplier per attempt
jitterAmount float64 // fraction of the delay subject to jitter
rand Rand // source of randomness for jitter
}
// Option customizes the behavior of a backoff [Strategy].
type Option func(*config)
// WithMinDelay sets the minimum time between consecutive retries, which is
// also the delay preceding the first retry. It is capped at zero (meaning no
// delay) if a negative duration is provided. If equal to or greater than the
// maximum delay, the backoff delays remain constant at the maximum delay. If
// not customized, [DefaultMinDelay] is used.
//
// When jitter is applied, the minimum delay is effectively reduced in
// proportion to the jitter amount. The strategy may therefore return a delay
// shorter than the configured minimum, depending on the random output.
func WithMinDelay(d time.Duration) Option {
return func(c *config) {
c.minDelay = max(0, d)
}
}
// WithMaxDelay sets the maximum time between consecutive retries. It is capped
// at zero (meaning no delay) if a negative duration is provided. If less than
// or equal to the minimum delay, the backoff delays remain constant at the
// maximum delay. If not customized, [DefaultMaxDelay] is used.
func WithMaxDelay(d time.Duration) Option {
return func(c *config) {
c.maxDelay = max(0, d)
}
}
// WithGrowthFactor determines the growth factor (multiplier) applied per
// attempt in exponential backoff. A factor of one or less selects linear
// backoff, where the minimum delay becomes the step size. A factor that is not
// a number is treated the same way. If not customized, [DefaultGrowthFactor]
// is used.
func WithGrowthFactor(f float64) Option {
return func(c *config) {
c.growthFactor = f
}
}
// WithJitterAmount specifies the amount of random jitter to apply to the
// backoff delays. It is expressed as a fraction of the delay, where 0 means no
// jitter and 1 means full jitter. The given number is capped between 0 and 1.
// If not customized, [DefaultJitterAmount] is used.
//
// Jitter scatters retry attempts in time, which mitigates the thundering herd
// problem, where many clients retry simultaneously. It is subtractive: a
// jittered delay is drawn from [d*(1-amount), d], so the maximum delay is
// never exceeded.
func WithJitterAmount(p float64) Option {
return func(c *config) {
c.jitterAmount = min(1, max(0, p))
}
}
// WithRand sets the source of randomness used to compute jitter. If not
// specified or nil, a shared, auto-seeded generator is used.
func WithRand(r Rand) Option {
return func(c *config) {
if r != nil {
c.rand = r
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package backoff
import (
"math"
"time"
"github.com/deep-rent/nexus/std/jitter"
)
// clamp converts a delay computed in floating point to a [time.Duration]
// bounded by lo and hi.
//
// The comparison against hi is written as a negation so that it also holds for
// NaN and positive infinity, which [math.Pow] readily produces once the
// attempt count grows. This matters because converting an out-of-range float
// to an integer is implementation-defined in Go: on arm64 it saturates, while
// on amd64 it yields the smallest negative integer, which would silently
// collapse the backoff back to its minimum.
func clamp(f float64, lo, hi time.Duration) time.Duration {
if !(f < float64(hi)) {
return hi
}
return max(lo, time.Duration(f))
}
// constant is a [Strategy] implementation that always returns a fixed delay.
type constant struct {
delay time.Duration // fixed duration returned for every attempt
}
// Constant produces a [Strategy] that always yields the same delay duration.
// If the provided delay is negative, it is treated as zero (meaning no delay).
func Constant(delay time.Duration) Strategy {
return &constant{delay: max(0, delay)}
}
// Delay returns the fixed delay for this [constant] strategy.
func (c *constant) Delay(int) time.Duration { return c.delay }
// MinDelay returns the fixed delay duration.
func (c *constant) MinDelay() time.Duration { return c.delay }
// MaxDelay returns the fixed delay duration.
func (c *constant) MaxDelay() time.Duration { return c.delay }
var _ Strategy = (*constant)(nil)
// linear is a [Strategy] implementation that increases the delay linearly with
// the attempt number.
type linear struct {
minDelay time.Duration // base step for the linear increment
maxDelay time.Duration // ceiling for the backoff duration
}
// Linear produces a [Strategy] whose delay grows by minDelay with every
// attempt, so that attempt n waits for n*minDelay, capped at maxDelay.
// Negative durations are treated as zero. If minDelay is not less than
// maxDelay, the result is equivalent to [Constant] at maxDelay.
func Linear(minDelay, maxDelay time.Duration) Strategy {
minDelay, maxDelay = max(0, minDelay), max(0, maxDelay)
if minDelay >= maxDelay {
return &constant{delay: maxDelay}
}
return &linear{minDelay: minDelay, maxDelay: maxDelay}
}
// Delay returns the backoff duration preceding attempt n.
func (l *linear) Delay(n int) time.Duration {
if n < 1 {
n = 1
}
// Computed in floating point so that a large attempt count cannot overflow
// the multiplication.
return clamp(float64(l.minDelay)*float64(n), l.minDelay, l.maxDelay)
}
// MinDelay returns the minimum delay configured for this [linear] strategy.
func (l *linear) MinDelay() time.Duration { return l.minDelay }
// MaxDelay returns the maximum delay configured for this [linear] strategy.
func (l *linear) MaxDelay() time.Duration { return l.maxDelay }
var _ Strategy = (*linear)(nil)
// exponential is a [Strategy] implementation that increases the delay
// exponentially with the attempt number.
type exponential struct {
minDelay time.Duration // delay preceding the first retry
maxDelay time.Duration // ceiling for the backoff duration
growthFactor float64 // multiplier applied per attempt
}
// Exponential produces a [Strategy] whose delay grows geometrically, so that
// attempt n waits for minDelay*factor^(n-1), capped at maxDelay. Negative
// durations are treated as zero. If minDelay is not less than maxDelay, or if
// factor is one or less, the result degrades to [Constant] or [Linear]
// respectively.
func Exponential(
minDelay, maxDelay time.Duration,
factor float64,
) Strategy {
minDelay, maxDelay = max(0, minDelay), max(0, maxDelay)
switch {
case minDelay >= maxDelay:
return &constant{delay: maxDelay}
case !(factor > 1): // Also selects linear backoff for NaN.
return &linear{minDelay: minDelay, maxDelay: maxDelay}
default:
return &exponential{
minDelay: minDelay,
maxDelay: maxDelay,
growthFactor: factor,
}
}
}
// Delay returns the backoff duration preceding attempt n. The first retry
// waits for the minimum delay; every further attempt multiplies it by the
// growth factor.
func (e *exponential) Delay(n int) time.Duration {
if n < 1 {
n = 1
}
f := float64(e.minDelay) * math.Pow(e.growthFactor, float64(n-1))
return clamp(f, e.minDelay, e.maxDelay)
}
// MinDelay returns the minimum delay configured for this [exponential]
// strategy.
func (e *exponential) MinDelay() time.Duration { return e.minDelay }
// MaxDelay returns the maximum delay configured for this [exponential]
// strategy.
func (e *exponential) MaxDelay() time.Duration { return e.maxDelay }
var _ Strategy = (*exponential)(nil)
// spread decorates a [Strategy] with jitter in order to scatter retry attempts
// over time.
type spread struct {
s Strategy // underlying strategy being jittered
j *jitter.Jitter // jitter implementation used to shorten durations
}
// Delay returns the delay of the underlying strategy, randomly shortened.
func (s *spread) Delay(n int) time.Duration {
return s.j.Apply(s.s.Delay(n))
}
// MinDelay returns the jittered lower bound of the underlying [Strategy],
// which is the shortest delay it can possibly return.
func (s *spread) MinDelay() time.Duration {
return s.j.Floor(s.s.MinDelay(), 1)
}
// MaxDelay returns the maximum delay of the underlying [Strategy]. Since
// jitter only ever shortens a delay, the upper bound is unaffected.
func (s *spread) MaxDelay() time.Duration {
return s.s.MaxDelay()
}
var _ Strategy = (*spread)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package cases
import (
"strings"
"github.com/deep-rent/nexus/std/ascii"
)
// Delimit converts a camelCase string into a delimited one, inserting
// sep at every word boundary and mapping each byte through toCase. It is
// the primitive behind [snake] and [kebab]; call it directly for a
// separator neither covers.
//
// Boundaries are the character-class changes described in the package
// documentation. A string already written in the target convention comes
// back unchanged, since its separators are neither letters nor digits
// and induce no transition of their own — which is what makes the
// conversion safe to apply twice.
//
// [snake]: github.com/deep-rent/nexus/std/cases/snake
// [kebab]: github.com/deep-rent/nexus/std/cases/kebab
func Delimit(s string, sep byte, toCase func(byte) byte) string {
var b strings.Builder
b.Grow(len(s) + 5)
for i := 0; i < len(s); i++ {
c := s[i]
// Insert the separator before a capital letter or digit.
if i != 0 {
q := s[i-1]
if (ascii.IsLower(q) &&
// Case 1: Lowercase to uppercase/digit transition ("myVar",
// "myVar1").
(ascii.IsUpper(c) || ascii.IsDigit(c))) ||
(ascii.IsUpper(q) &&
// Case 2: Acronym to new word transition ("MYVar").
ascii.IsUpper(c) &&
i+1 < len(s) &&
ascii.IsLower(s[i+1])) {
b.WriteByte(sep)
}
}
b.WriteByte(toCase(c))
}
return b.String()
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package kebab
import (
"github.com/deep-rent/nexus/std/ascii"
"github.com/deep-rent/nexus/std/cases"
)
// Separator joins the words of a kebab-case string.
const Separator = '-'
// ToLower converts a camelCase string to a lowercase kebab-case string.
//
// For example, "fooBar" is converted to "foo-bar", and so is "FOOBar". A
// digit opens a word of its own, so "foo1" becomes "foo-1". Only ASCII
// characters are supported. This function internally uses [cases.Delimit]
// with [ascii.Lower].
func ToLower(s string) string {
return cases.Delimit(s, Separator, ascii.Lower)
}
// ToUpper converts a camelCase string to an uppercase KEBAB-CASE string.
//
// For example, "fooBar" is converted to "FOO-BAR", and so is "FOOBar". A
// digit opens a word of its own, so "foo1" becomes "FOO-1". Only ASCII
// characters are supported. This function internally uses [cases.Delimit]
// with [ascii.Upper].
//
// Lowercase kebab-case is the convention this case is usually written
// in — HTTP headers being the notable exception, and those are spelled
// Train-Case rather than shouted.
func ToUpper(s string) string {
return cases.Delimit(s, Separator, ascii.Upper)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package snake
import (
"github.com/deep-rent/nexus/std/ascii"
"github.com/deep-rent/nexus/std/cases"
)
// Separator joins the words of a snake_case string.
const Separator = '_'
// ToUpper converts a camelCase string to an uppercase SNAKE_CASE string.
//
// For example, "fooBar" is converted to "FOO_BAR", and so is "FOOBar". A
// digit opens a word of its own, so "foo1" becomes "FOO_1". Only ASCII
// characters are supported. This function internally uses [cases.Delimit]
// with [ascii.Upper].
func ToUpper(s string) string {
return cases.Delimit(s, Separator, ascii.Upper)
}
// ToLower converts a camelCase string to a lowercase snake_case string.
//
// For example, "fooBar" is converted to "foo_bar", and so is "FOOBar". A
// digit opens a word of its own, so "foo1" becomes "foo_1". Only ASCII
// characters are supported. This function internally uses [cases.Delimit]
// with [ascii.Lower].
func ToLower(s string) string {
return cases.Delimit(s, Separator, ascii.Lower)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package clock
import "time"
// Clock reports the current time. Being a function type, it is assignable
// from [time.Now] and any other source of the same shape, which lets callers
// swap the real-time clock for a deterministic one in tests.
type Clock func() time.Time
// Now returns the current time as reported by the clock.
func (c Clock) Now() time.Time {
return c()
}
// Since returns the time elapsed since the given instant. It is shorthand for
// c.Now().Sub(t).
func (c Clock) Since(t time.Time) time.Duration {
return c().Sub(t)
}
// Until returns the duration until the given instant. It is shorthand for
// t.Sub(c.Now()).
func (c Clock) Until(t time.Time) time.Duration {
return t.Sub(c())
}
// Offset returns a clock that reports the time of c shifted by the given
// duration. A positive duration runs the clock ahead, a negative one behind,
// which is useful for simulating clock skew.
func (c Clock) Offset(d time.Duration) Clock {
return func() time.Time { return c().Add(d) }
}
// System is the real-time clock backed by [time.Now]. It is the default for
// production use.
var System Clock = time.Now
// Frozen returns a clock that always reports the given instant, regardless of
// how much wall-clock time passes.
func Frozen(at time.Time) Clock {
return func() time.Time { return at }
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package flush
import (
"bufio"
"io"
"sync"
"time"
)
// Writer is a buffered [io.Writer] that batches writes to an underlying
// destination and flushes them when the buffer fills, when the configured
// interval elapses, or on [Writer.Flush] and [Writer.Close]. It is safe
// for concurrent use.
type Writer struct {
mu sync.Mutex
dst io.Writer
buf *bufio.Writer
closed bool
done chan struct{}
wg sync.WaitGroup
once sync.Once
}
// New creates a [Writer] forwarding to dst. By default, it buffers up to
// [DefaultSize] bytes and flushes every [DefaultInterval]. These defaults
// can be overridden by passing in one or more [Option] functions. A nil
// destination discards all output.
func New(dst io.Writer, opts ...Option) *Writer {
c := config{
size: DefaultSize,
interval: DefaultInterval,
}
for _, opt := range opts {
opt(&c)
}
if dst == nil {
dst = io.Discard
}
w := &Writer{
dst: dst,
buf: bufio.NewWriterSize(dst, c.size),
done: make(chan struct{}),
}
if c.interval > 0 {
w.wg.Add(1)
go w.loop(c.interval)
}
return w
}
// loop flushes the buffer periodically until the writer is closed.
func (w *Writer) loop(interval time.Duration) {
defer w.wg.Done()
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-t.C:
// A write error has nowhere to be reported here; it sticks
// in the buffer and surfaces on the next Write or Flush.
_ = w.Flush()
case <-w.done:
return
}
}
}
// Write implements [io.Writer]. The data is buffered; any error returned
// stems from a flush of previously buffered data to the destination.
// After [Writer.Close], writes bypass the buffer and go directly to the
// destination.
func (w *Writer) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
return w.dst.Write(p)
}
return w.buf.Write(p)
}
// WriteString implements [io.StringWriter]. It forwards strings to the
// underlying writer.
func (w *Writer) WriteString(s string) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
return io.WriteString(w.dst, s)
}
return w.buf.WriteString(s)
}
// Flush forwards all buffered data to the destination.
func (w *Writer) Flush() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
return nil
}
return w.buf.Flush()
}
// Close stops the background flushing and drains the buffer. It reports
// the error of the final flush; subsequent calls return nil. The
// destination is left open, since the writer does not own it.
func (w *Writer) Close() (err error) {
w.once.Do(func() {
close(w.done)
w.wg.Wait()
w.mu.Lock()
defer w.mu.Unlock()
err = w.buf.Flush()
w.closed = true
})
return err
}
// Size returns the capacity of the underlying buffer in bytes.
func (w *Writer) Size() int {
w.mu.Lock()
defer w.mu.Unlock()
return w.buf.Size()
}
// Buffered returns the number of bytes currently stored in the buffer.
func (w *Writer) Buffered() int {
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
return 0
}
return w.buf.Buffered()
}
// Available returns how many bytes are unused in the buffer.
func (w *Writer) Available() int {
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
return 0
}
return w.buf.Available()
}
var (
_ io.Writer = (*Writer)(nil)
_ io.StringWriter = (*Writer)(nil)
)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package flush
import "time"
// Default configuration values for a new [Writer].
const (
// DefaultSize is the buffer capacity used when none is specified.
DefaultSize = 64 << 10
// DefaultInterval is the flush interval used when none is specified.
// It bounds the loss window on a crash to one second of output.
DefaultInterval = time.Second
)
// config holds the configuration settings for a [Writer].
type config struct {
// size is the buffer capacity in bytes.
size int
// interval is the cadence of background flushes.
interval time.Duration
}
// Option defines a function that modifies the [Writer] configuration.
type Option func(*config)
// WithSize sets the buffer capacity in bytes. A full buffer is flushed
// inline by the write that fills it. Sizes less than one are ignored.
func WithSize(n int) Option {
return func(c *config) {
if n > 0 {
c.size = n
}
}
}
// WithInterval sets the cadence of background flushes, bounding both the
// staleness of observable output and the loss window on a crash. A
// nonpositive interval disables background flushing entirely, leaving
// only capacity, [Writer.Flush], and [Writer.Close] to trigger writes.
func WithInterval(d time.Duration) Option {
return func(c *config) {
c.interval = d
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package graph
import (
"cmp"
"errors"
"slices"
)
// ErrCycleDetected is returned when a cyclic dependency prevents a valid
// sorting of a [Graph].
var ErrCycleDetected = errors.New("cycle detected in dependency graph")
// Graph represents a directed acyclic graph (DAG).
// It is used to determine the correct topological order for processing
// dependencies.
type Graph[T cmp.Ordered] struct {
nodes map[T]struct{}
edges map[T][]T
degree map[T]int
}
// New initializes an empty directed acyclic graph.
func New[T cmp.Ordered]() *Graph[T] {
return &Graph[T]{
nodes: make(map[T]struct{}),
edges: make(map[T][]T),
degree: make(map[T]int),
}
}
// AddNode registers a node in the graph (idempotent).
func (g *Graph[T]) AddNode(v T) {
if _, exists := g.nodes[v]; !exists {
g.nodes[v] = struct{}{}
g.degree[v] = 0
}
}
// AddEdge registers a dependency between two nodes: the first argument
// depends on the second. This guarantees that in the topologically sorted
// output, the dependency strictly precedes the dependent node. It implicitly
// adds both nodes if they do not already exist.
func (g *Graph[T]) AddEdge(child, parent T) {
g.AddNode(child)
g.AddNode(parent)
g.edges[parent] = append(g.edges[parent], child)
g.degree[child]++
}
// Sort resolves the dependency graph and returns the nodes in canonical
// topological order (Kahn sort): parents strictly precede their children, and
// nodes not constrained relative to each other appear in their natural order.
// The result is therefore a pure function of the graph's nodes and edges. It
// returns [ErrCycleDetected] if a cyclic dependency prevents a valid
// sorting.
func (g *Graph[T]) Sort() ([]T, error) {
var zero []T
deg := make(map[T]int, len(g.degree))
for v, d := range g.degree {
deg[v] = d
if d == 0 {
zero = append(zero, v)
}
}
slices.Sort(zero)
var sorted []T
for len(zero) > 0 {
// Pop the smallest node with 0 in-degree.
curr := zero[0]
zero = zero[1:]
// Append the node to the sorted list. Since the graph maps parents
// to children, roots are processed first.
sorted = append(sorted, curr)
// For each child depending on the resolved parent, reduce its
// in-degree; nodes becoming available are merged into the queue at
// their sorted position to keep the order canonical.
for _, child := range g.edges[curr] {
deg[child]--
if deg[child] == 0 {
at, _ := slices.BinarySearch(zero, child)
zero = slices.Insert(zero, at, child)
}
}
}
if len(sorted) != len(g.nodes) {
return nil, ErrCycleDetected
}
return sorted, nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package i18n
import (
"strings"
"github.com/deep-rent/nexus/std/ascii"
)
// Canonical normalizes the letter case of a BCP 47 language tag to the
// conventional form of RFC 5646 Section 2.1.1: the whole tag is lowercase,
// except that a two-letter region subtag is uppercase ("de-CH") and a
// four-letter script subtag is titlecase ("sr-Latn-RS"). Subtags following
// a singleton — extension and private-use sections — stay lowercase
// regardless of their length.
//
// The tag's structure is left untouched: Canonical neither validates the tag
// (see [valid.Lang]) nor resolves registry aliases.
//
// [valid.Lang]: github.com/deep-rent/nexus/dat/valid#Lang
func Canonical(tag string) string {
parts := strings.Split(tag, "-")
ext := false
for i, p := range parts {
switch {
case i == 0 || ext || !ascii.All(p, ascii.IsAlpha):
parts[i] = ascii.ToLower(p)
case len(p) == 2:
parts[i] = ascii.ToUpper(p)
case len(p) == 4:
parts[i] = ascii.ToUpper(p[:1]) + ascii.ToLower(p[1:])
default:
parts[i] = ascii.ToLower(p)
}
if len(p) == 1 {
ext = true
}
}
return strings.Join(parts, "-")
}
// Base returns the primary language subtag of a BCP 47 language tag,
// lowered in case: "de-CH" yields "de". An empty tag yields the empty
// string.
func Base(tag string) string {
if i := strings.IndexByte(tag, '-'); i >= 0 {
tag = tag[:i]
}
return ascii.ToLower(tag)
}
// Match picks the supported tag best matching an ordered list of preferred
// language tags, using the lookup scheme of RFC 4647 Section 3.4: each
// preference is tried in turn, falling back from the full tag to
// progressively shorter prefixes before moving on to the next preference.
// So a user preferring de-CH over fr is served de rather than fr when only
// plain de is supported.
//
// Matching compares case-insensitively; the winner is returned in the
// spelling it has in supported. The boolean reports whether any preference
// matched. Wildcard ranges ("*") are not interpreted.
func Match(preferred, supported []string) (string, bool) {
for _, p := range preferred {
for t := ascii.ToLower(p); t != ""; t = shorten(t) {
for _, s := range supported {
if ascii.ToLower(s) == t {
return s, true
}
}
}
}
return "", false
}
// shorten cuts the last subtag off a language tag, additionally dropping a
// single-character subtag left dangling at the end: a lookup fallback must
// never stop on a bare extension singleton ("de-x" is not a valid range).
// The empty string marks exhaustion.
func shorten(tag string) string {
i := strings.LastIndexByte(tag, '-')
if i < 0 {
return ""
}
tag = tag[:i]
if j := strings.LastIndexByte(tag, '-'); j >= 0 && j == len(tag)-2 {
tag = tag[:j]
}
return tag
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package jitter
import (
"math/rand/v2"
"time"
)
// Rand serves as a minimal facade over [rand.Rand] to ease mocking.
type Rand interface {
// Float64 generates a pseudo-random number in [0.0, 1.0).
Float64() float64
}
// Ensure compliance with parent interface.
var _ Rand = (*rand.Rand)(nil)
// global is a [Rand] backed by the top-level functions of [math/rand/v2].
//
// Unlike a [rand.Rand] value, which carries mutable state that a caller would
// have to guard, these are safe for concurrent use and auto-seeded by the
// runtime. That matters because a single [Jitter] is typically shared by every
// goroutine backing off against the same resource.
type global struct{}
// Float64 generates a pseudo-random number in [0.0, 1.0).
// #nosec G404 -- spreading retries needs no unpredictability, and a
// cryptographic source would cost a syscall per backoff.
func (global) Float64() float64 { return rand.Float64() }
// seeded is the [Rand] used when no source is supplied.
var seeded Rand = global{}
// Jitter applies subtractive random jitter to a duration.
type Jitter struct {
// p is the jitter percentage between 0.0 and 1.0.
p float64
// r is the random number generator source.
r Rand
}
// New creates a new [Jitter] instance with the given percentage p (0.0 to 1.0)
// and source of randomness r.
//
// If r is nil, a shared generator that is safe for concurrent use is applied.
func New(p float64, r Rand) *Jitter {
if r == nil {
r = seeded // Fallback
}
return &Jitter{
r: r,
p: p,
}
}
// Apply returns the duration d damped by a random amount based on the jitter
// percentage.
//
// The result is guaranteed to be in the range [[Jitter.Floor](d, 1.0), d].
func (j *Jitter) Apply(d time.Duration) time.Duration {
return j.Floor(d, j.r.Float64())
}
// Floor returns the minimum possible duration that [Jitter.Apply] could return
// for the given input d when provided a random factor f.
//
// While typically used internally with f as a random float, passing f = 1.0
// provides the absolute lower bound for the jittered duration.
func (j *Jitter) Floor(d time.Duration, f float64) time.Duration {
return time.Duration(float64(d) * (1 - f*j.p))
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package pointer
import "reflect"
// Alloc allocates a new value for a nil pointer and sets the pointer to it.
//
// This function causes a panic if rv is not a settable pointer. It uses
// [reflect.New] to create a zero value of the pointer's element type and
// applies it to the provided [reflect.Value].
func Alloc(rv reflect.Value) {
rv.Set(reflect.New(rv.Type().Elem()))
}
// Deref follows pointers until it reaches a non-pointer, allocating if nil.
//
// If a nil pointer is encountered along the way, [Deref] will attempt to
// allocate a new value for it using [Alloc]. If it encounters an un-settable
// nil pointer (e.g., one within an unexported struct field), it stops and
// returns that pointer to prevent a panic. The final, non-pointer value is
// returned as a [reflect.Value].
//
// Note: This function handles multi-level pointers, unlike [reflect.Indirect]
// that only strips away exactly one level.
func Deref(rv reflect.Value) reflect.Value {
// Loop through multi-level pointers to handle cases like **int.
for rv.Kind() == reflect.Pointer {
if rv.IsNil() {
// If the pointer is nil but cannot be set, we must stop
// here to avoid a panic.
if !rv.CanSet() {
break
}
Alloc(rv)
}
rv = rv.Elem()
}
return rv
}
// Value returns the value pointed to by v, or the zero value of T if v is nil.
func Value[T any](v *T) T {
if v != nil {
return *v
}
var zero T
return zero
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package quote
import "strings"
// Remove strips a single layer of surrounding single or double quotes from a
// string.
//
// If the string is not quoted or is too short to contain a matching pair, it is
// returned unchanged.
func Remove(s string) string {
if len(s) < 2 {
return s
}
// Check for a matching pair of quotes.
switch s[0] {
case '"':
if s[len(s)-1] == '"' {
return s[1 : len(s)-1]
}
case '\'':
if s[len(s)-1] == '\'' {
return s[1 : len(s)-1]
}
}
// Return the original string if no matching quotes are found.
return s
}
// RemoveAll strips all layers of surrounding quotes from a string, regardless
// of quote type mixing (e.g., "'hello'" becomes hello).
//
// It repeatedly applies [Remove] until no further changes are detected in the
// input string.
func RemoveAll(s string) string {
for {
unquoted := Remove(s)
if unquoted == s {
break
}
s = unquoted
}
return s
}
// Has returns true if the string is surrounded by a matching pair of single or
// double quotes.
func Has(s string) bool {
if len(s) < 2 {
return false
}
switch s[0] {
case '"', '\'':
return s[len(s)-1] == s[0]
}
return false
}
// Double surrounds the given string with double quotes.
//
// Note: It does not escape existing quotes inside the string. It essentially
// performs a simple concatenation of the quote and the content.
func Double(s string) string { return `"` + s + `"` }
// Single surrounds the given string with single quotes.
//
// Note: It does not escape existing quotes inside the string. It essentially
// performs a simple concatenation of the quote and the content.
func Single(s string) string { return "'" + s + "'" }
// Escape safely quotes a SQL identifier: embedded double quotes are doubled
// and the result is wrapped in double quotes using [Double].
func Escape(s string) string {
return Double(strings.ReplaceAll(s, `"`, `""`))
}
// Ident assembles a fully qualified SQL identifier by escaping each part
// with [Escape] and joining the parts with dots (e.g., "schema"."table").
// It panics if no parts are given (programmer error).
func Ident(parts ...string) string {
if len(parts) == 0 {
panic("at least one part is required")
}
escaped := make([]string, len(parts))
for i, part := range parts {
escaped[i] = Escape(part)
}
return strings.Join(escaped, ".")
}
// Literal safely quotes a SQL string literal: embedded single quotes are
// doubled and the result is wrapped in single quotes using [Single].
func Literal(s string) string {
return Single(strings.ReplaceAll(s, "'", "''"))
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package ring
import (
"math/bits"
"runtime"
"sync/atomic"
)
// Policy dictates how the buffer behaves when a producer attempts to push into
// a queue that has reached its maximum capacity (overflow).
type Policy int
const (
// Block causes the producer to yield the processor to other goroutines (via
// [runtime.Gosched]) until space becomes available.
Block Policy = iota
// DropOldest forcefully advances the read pointer, discarding the oldest
// unread item in the buffer to make room for the newly pushed item.
DropOldest
// DropNewest immediately discards the incoming item being pushed, returning
// false and leaving the existing buffer contents unchanged.
DropNewest
)
// Buffer represents a bounded, lock-free, strongly-typed concurrent queue.
type Buffer[T any] struct {
// data holds the underlying circular storage for the buffer items.
// Its length is always a power of two.
data []T
// seq holds the sequence numbers for each slot to prevent read-before-write
// race conditions in concurrent MPMC scenarios.
seq []uint64
// head is a monotonically increasing counter representing the read index.
// The actual array index is calculated as (head & mask).
head atomic.Uint64
// tail is a monotonically increasing counter representing the write index.
// The actual array index is calculated as (tail & mask).
tail atomic.Uint64
// mask is used to perform a bitwise AND operation (tail & mask) to
// wrap the counters around the buffer size efficiently.
// It is equal to (capacity - 1).
mask uint64
// policy defines the behavior of the [Buffer.Push] operation when the
// difference between tail and head reaches the buffer capacity.
policy Policy
}
// New creates a [Buffer] configured with the requested size and overflow
// [Policy].
//
// If the provided size is less than 2, it defaults to 2. The final capacity is
// always automatically rounded up to the nearest power of two to optimize
// internal index masking via the [Buffer.mask].
func New[T any](size int, policy Policy) *Buffer[T] {
if size < 2 {
size = 2
}
// Round up to the next power of two.
p := uint(1 << bits.Len(uint(size-1)))
return &Buffer[T]{
data: make([]T, p),
seq: make([]uint64, p),
mask: uint64(p - 1),
policy: policy,
}
}
// Push adds an item to the tail of the buffer using atomic operations.
//
// It returns true if the item was successfully written. If the buffer is full
// and configured with the [DropNewest] policy, it safely discards the item and
// returns false. For the [Block] policy, it will wait for space by calling
// [runtime.Gosched].
func (b *Buffer[T]) Push(item T) bool {
for {
head := b.head.Load()
tail := b.tail.Load()
capacity := b.mask + 1
// 1. Check if the buffer is full.
if tail-head >= capacity {
switch b.policy {
case DropNewest:
return false // Discard the incoming event
case DropOldest:
// Try to advance head to invalidate the oldest item.
// If CAS fails, another goroutine already changed head; loop
// and retry.
b.head.CompareAndSwap(head, head+1)
continue
case Block:
// Yield execution to the scheduler to allow consumers to
// catch up.
runtime.Gosched()
continue
}
}
// 2. Try to claim the tail slot.
if b.tail.CompareAndSwap(tail, tail+1) {
// 3. Write data to the claimed slot.
b.data[tail&b.mask] = item
// 4. Publish the write by updating the sequence number.
atomic.StoreUint64(&b.seq[tail&b.mask], tail+1)
return true
}
// CAS failed: another producer claimed the slot first; loop and retry.
}
}
// Pop retrieves and removes the oldest item from the head of the buffer.
//
// It returns the generic item and true on success. If the buffer is currently
// empty, it returns the zero-value of type T and false. This method is safe
// for concurrent use by multiple consumers.
func (b *Buffer[T]) Pop() (T, bool) {
var zero T // Used to return a zero-value on failure
for {
head := b.head.Load()
tail := b.tail.Load()
// 1. Check if the buffer is empty.
if head == tail {
return zero, false
}
// 2. Ensure the producer has finished writing to this slot.
// If the sequence doesn't match head+1, it means the producer
// claimed the tail but hasn't published the write yet, or we
// are reading a stale head.
if atomic.LoadUint64(&b.seq[head&b.mask]) != head+1 {
runtime.Gosched()
continue
}
// 3. Read the data BEFORE advancing the head pointer.
item := b.data[head&b.mask]
// 4. Try to commit the read.
if b.head.CompareAndSwap(head, head+1) {
return item, true
}
// CAS failed: another consumer popped the item first; loop and retry.
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package rotor
import (
"math/rand/v2"
"sync/atomic"
)
// Strategy represents the strategy type for selecting the next element in
// a [Rotor].
type Strategy int
const (
// Sequential strategy picks the next element in a round-robin fashion.
Sequential Strategy = iota
// Random strategy chooses the next element randomly.
Random
)
// strategy defines how the next element index is selected.
type strategy interface {
// Pick returns the next element index given the total number of elements
// available.
Pick(n int) int
}
// sequential is a strategy that picks the next index in a round-robin fashion.
type sequential struct {
idx atomic.Uint32
}
// Pick implements the Strategy interface.
func (s *sequential) Pick(n int) int {
var idx uint32
for {
idx = s.idx.Load()
// n is always positive here: New panics on an empty items slice, so
// the int-to-uint32 conversion cannot wrap.
if s.idx.CompareAndSwap(idx, (idx+1)%uint32(n)) {
break
}
}
return int(idx)
}
// random is a strategy that picks a random index.
type random struct{}
// Pick implements the Strategy interface.
func (*random) Pick(n int) int {
// math/rand/v2 is not cryptographically secure, which is fine here: the
// selection only balances load, it is not a security control.
return rand.IntN(n) // #nosec G404
}
// Rotor provides thread-safe round-robin access to a slice of items.
//
// It must be initialized with the [New] function. The interface allows for
// optimized internal implementations depending on the number of items provided.
type Rotor[E any] interface {
// Next returns the next item in the rotation.
// This method is safe for concurrent use by multiple goroutines.
Next() E
}
// singleton is a [Rotor] that contains only a single item.
type singleton[E any] struct {
// item is the solitary element in this rotation.
item E
}
// Next implements the [Rotor] interface, always returning the same item.
func (s *singleton[E]) Next() E {
return s.item
}
// rotor is a generic implementation of the [Rotor] interface for multiple
// items.
type rotor[E any] struct {
// items is the immutable slice of elements to rotate through.
items []E
// strategy determines how the next item is selected.
strategy strategy
}
// New creates a new [Rotor] with the given strategy and items to rotate
// through.
//
// It makes a defensive copy of the provided items slice to ensure immutability.
// This function panics if the items slice is empty. If the slice contains
// exactly one item, an optimized [Rotor] implementation will be created.
func New[E any](t Strategy, items []E) Rotor[E] {
if len(items) == 0 {
panic("items slice must not be empty")
}
if len(items) == 1 {
return &singleton[E]{item: items[0]}
}
c := make([]E, len(items))
copy(c, items)
var s strategy
switch t {
case Random:
s = &random{}
case Sequential:
fallthrough
default:
s = &sequential{}
}
return &rotor[E]{items: c, strategy: s}
}
// Next implements the [Rotor] interface.
//
// It uses the underlying strategy to determine the index of the next item.
func (r *rotor[E]) Next() E {
return r.items[r.strategy.Pick(len(r.items))]
}
var _ Rotor[int] = (*rotor[int])(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package bloom
import (
"encoding/binary"
"errors"
"fmt"
"math"
"math/bits"
"github.com/deep-rent/nexus/std/sketch/internal/wire"
"github.com/deep-rent/nexus/std/xxh"
)
// ErrIncompatible reports an attempt to merge filters whose parameters
// differ. Only filters constructed with identical parameters share a
// layout, so merging anything else would silently corrupt the answers;
// the error is returned instead. Test with [errors.Is].
var ErrIncompatible = errors.New("incompatible filter parameters")
// A Filter is a Bloom filter. [Filter.Has] may claim a key that was
// never added — with the probability the filter was sized for — but
// never denies one that was.
//
// The zero value is not usable; construct filters with [New] or restore
// one with [Filter.UnmarshalBinary]. A Filter is not safe for
// concurrent use.
type Filter struct {
k int // hash probes per key
m uint64 // bit width of the array
bits []uint64 // the array, m bits in ceil(m/64) words
}
// New sizes a filter for n expected distinct keys at a target false-
// positive rate fp, choosing the optimal bit width and probe count. The
// filter occupies about n·ln(1/fp)/ln²2 bits.
//
// The probe count is capped at 64, which starts to lift the achieved
// rate above the target only for fp below roughly 1e-19.
//
// New panics unless n is positive, fp lies in (0, 1), and the sized
// filter stays below 2^56 bits (eight petabytes).
func New(n int, fp float64) *Filter {
if n < 1 {
panic("capacity must be positive")
}
if !(fp > 0 && fp < 1) {
panic("false-positive rate must be in (0, 1)")
}
mf := math.Ceil(-float64(n) * math.Log(fp) / (math.Ln2 * math.Ln2))
if mf >= maxBits {
panic("sized filter is too large")
}
m := uint64(mf)
k := min(64, max(1, int(math.Round(math.Ln2*float64(m)/float64(n)))))
return &Filter{
k: k,
m: m,
bits: make([]uint64, (m+63)/64),
}
}
// maxBits bounds the bit width, keeping every downstream size
// computation far from uint64 overflow.
const maxBits = 1 << 56
// Add inserts a key. Adding a key more than once is harmless.
//
// Each of the k probes is placed independently: the hash chains
// through [xxh.Mix64] between probes, so no fixed stride exists whose
// degenerate values could collapse the probe sequence and float the
// false-positive rate above its target.
func (f *Filter) Add[T xxh.Input](key T) {
g := xxh.Sum64(key, 0)
for range f.k {
i := xxh.Reduce(g, f.m)
f.bits[i>>6] |= 1 << (i & 63)
g = xxh.Mix64(g)
}
}
// Has reports whether a key is possibly in the set. A false result is
// definitive; a true result is wrong with the probability the filter
// was sized for.
func (f *Filter) Has[T xxh.Input](key T) bool {
g := xxh.Sum64(key, 0)
for range f.k {
i := xxh.Reduce(g, f.m)
if f.bits[i>>6]&(1<<(i&63)) == 0 {
return false
}
g = xxh.Mix64(g)
}
return true
}
// Count estimates the number of distinct keys added so far from the
// filter's fill ratio. A saturated filter (every bit set) carries no
// information anymore; Count then returns [math.MaxUint64].
func (f *Filter) Count() uint64 {
var ones uint64
for _, w := range f.bits {
ones += uint64(bits.OnesCount64(w))
}
if ones == f.m {
return math.MaxUint64
}
fill := float64(ones) / float64(f.m)
est := -float64(f.m) / float64(f.k) * math.Log(1-fill)
return uint64(math.Round(est))
}
// Merge folds another filter into f, so that f afterwards reports every
// key added to either. The filters must have been constructed with the
// same parameters; anything else returns [ErrIncompatible].
func (f *Filter) Merge(o *Filter) error {
if f.m != o.m || f.k != o.k {
return fmt.Errorf("%w: %d/%d bits, %d/%d probes",
ErrIncompatible, f.m, o.m, f.k, o.k)
}
for i, w := range o.bits {
f.bits[i] |= w
}
return nil
}
// AppendBinary appends the filter's encoding to buf and returns the
// extended slice, implementing [encoding.BinaryAppender].
func (f *Filter) AppendBinary(buf []byte) ([]byte, error) {
buf = wire.AppendHeader(buf, wire.KindBloom)
buf = append(buf, byte(f.k))
buf = binary.LittleEndian.AppendUint64(buf, f.m)
for _, w := range f.bits {
buf = binary.LittleEndian.AppendUint64(buf, w)
}
return buf, nil
}
// MarshalBinary encodes the filter, implementing
// [encoding.BinaryMarshaler].
func (f *Filter) MarshalBinary() ([]byte, error) {
return f.AppendBinary(make([]byte, 0, 11+8*len(f.bits)))
}
// UnmarshalBinary restores a filter from an encoding produced by
// [Filter.MarshalBinary], implementing [encoding.BinaryUnmarshaler].
// The receiver's previous state is discarded.
func (f *Filter) UnmarshalBinary(data []byte) error {
d := wire.NewDecoder(data)
if err := d.Header(wire.KindBloom); err != nil {
return err
}
k, ok := d.U8()
if !ok {
return wire.ErrTruncated
}
m, ok := d.U64()
if !ok {
return wire.ErrTruncated
}
if k < 1 || k > 64 || m < 1 || m >= maxBits {
return fmt.Errorf("invalid encoding: %d probes over %d bits", k, m)
}
words := int((m + 63) / 64)
raw, ok := d.Bytes(8 * words)
if !ok {
return wire.ErrTruncated
}
if err := d.Done(); err != nil {
return err
}
bits := make([]uint64, words)
for i := range bits {
bits[i] = binary.LittleEndian.Uint64(raw[8*i:])
}
// Bits past m never come out of AppendBinary; admitting them
// would skew Count and leak through Merge into healthy filters.
if r := m % 64; r != 0 && bits[words-1]>>r != 0 {
return fmt.Errorf("invalid encoding: stray bits past width %d", m)
}
f.k = int(k)
f.m = m
f.bits = bits
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package countmin
import (
"encoding/binary"
"errors"
"fmt"
"math"
"github.com/deep-rent/nexus/std/sketch/internal/wire"
"github.com/deep-rent/nexus/std/xxh"
)
// ErrIncompatible reports an attempt to merge sketches whose parameters
// differ. Only sketches constructed with identical parameters share a
// layout, so merging anything else would silently corrupt the counts;
// the error is returned instead. Test with [errors.Is].
var ErrIncompatible = errors.New("incompatible sketch parameters")
// A Sketch is a count-min sketch. [Sketch.Count] never underestimates
// a key's true count, and overestimates beyond the sized error bound
// only with the sized probability.
//
// The zero value is not usable; construct sketches with [New] or
// restore one with [Sketch.UnmarshalBinary]. A Sketch is not safe for
// concurrent use.
type Sketch struct {
width uint64 // counters per row
depth int // rows
rows []uint64 // depth*width counters, row-major
total uint64 // sum of all added counts
}
// New sizes a sketch so that, writing N for the total weight added
// ([Sketch.Total]), every estimate exceeds its true count by more than
// epsilon·N with probability at most delta. The sketch holds
// ceil(e/epsilon)·ceil(ln(1/delta)) counters of eight bytes each: for
// epsilon 0.1% and delta 0.1%, about 150 KB.
//
// The row count is capped at 64, which starts to lift the achieved
// failure probability above delta only below roughly 1e-27.
//
// New panics unless epsilon and delta lie in (0, 1), or if epsilon is
// so small that the sketch would exceed 2^32 counters per row.
func New(epsilon, delta float64) *Sketch {
if !(epsilon > 0 && epsilon < 1) {
panic("epsilon must be in (0, 1)")
}
if !(delta > 0 && delta < 1) {
panic("delta must be in (0, 1)")
}
wf := math.Ceil(math.E / epsilon)
if wf >= maxWidth {
panic("epsilon is too small")
}
width := uint64(wf)
depth := min(64, max(1, int(math.Ceil(math.Log(1/delta)))))
return &Sketch{
width: width,
depth: depth,
rows: make([]uint64, uint64(depth)*width),
}
}
// maxWidth bounds the counters per row, keeping every downstream size
// computation far from overflow: 64 rows of 2^32 eight-byte counters
// stay comfortably inside uint64 arithmetic.
const maxWidth = 1 << 32
// Add records n occurrences of a key.
//
// Each row's column is placed independently: the hash chains through
// [xxh.Mix64] between rows. Row independence is what the delta
// guarantee rests on — a fixed stride would let two keys that collide
// once collide in every row.
func (s *Sketch) Add[T xxh.Input](key T, n uint64) {
g := xxh.Sum64(key, 0)
for r := range s.depth {
s.rows[uint64(r)*s.width+xxh.Reduce(g, s.width)] += n
g = xxh.Mix64(g)
}
s.total += n
}
// Inc records a single occurrence of a key.
func (s *Sketch) Inc[T xxh.Input](key T) { s.Add(key, 1) }
// Count estimates how often a key was added: the minimum of the key's
// counters. The estimate is never below the true count.
func (s *Sketch) Count[T xxh.Input](key T) uint64 {
g := xxh.Sum64(key, 0)
est := uint64(math.MaxUint64)
for r := range s.depth {
est = min(est, s.rows[uint64(r)*s.width+xxh.Reduce(g, s.width)])
g = xxh.Mix64(g)
}
return est
}
// Total returns the total weight added across all keys: the N that the
// error bound epsilon·N is relative to. A key whose estimate is a
// sizable fraction of Total is a heavy hitter.
func (s *Sketch) Total() uint64 { return s.total }
// Merge folds another sketch into s by adding counters, so that s
// afterwards estimates the concatenation of both streams. The sketches
// must have been constructed with the same parameters; anything else
// returns [ErrIncompatible].
func (s *Sketch) Merge(o *Sketch) error {
if s.width != o.width || s.depth != o.depth {
return fmt.Errorf("%w: %dx%d vs %dx%d counters",
ErrIncompatible, s.depth, s.width, o.depth, o.width)
}
for i, c := range o.rows {
s.rows[i] += c
}
s.total += o.total
return nil
}
// AppendBinary appends the sketch's encoding to buf and returns the
// extended slice, implementing [encoding.BinaryAppender].
func (s *Sketch) AppendBinary(buf []byte) ([]byte, error) {
buf = wire.AppendHeader(buf, wire.KindCountMin)
buf = append(buf, byte(s.depth))
buf = binary.LittleEndian.AppendUint64(buf, s.width)
buf = binary.LittleEndian.AppendUint64(buf, s.total)
for _, c := range s.rows {
buf = binary.LittleEndian.AppendUint64(buf, c)
}
return buf, nil
}
// MarshalBinary encodes the sketch, implementing
// [encoding.BinaryMarshaler].
func (s *Sketch) MarshalBinary() ([]byte, error) {
return s.AppendBinary(make([]byte, 0, 19+8*len(s.rows)))
}
// UnmarshalBinary restores a sketch from an encoding produced by
// [Sketch.MarshalBinary], implementing [encoding.BinaryUnmarshaler].
// The receiver's previous state is discarded.
func (s *Sketch) UnmarshalBinary(data []byte) error {
d := wire.NewDecoder(data)
if err := d.Header(wire.KindCountMin); err != nil {
return err
}
depth, ok := d.U8()
if !ok {
return wire.ErrTruncated
}
width, ok := d.U64()
if !ok {
return wire.ErrTruncated
}
total, ok := d.U64()
if !ok {
return wire.ErrTruncated
}
if depth < 1 || depth > 64 || width < 1 || width >= maxWidth {
return fmt.Errorf("invalid encoding: %d rows of %d counters",
depth, width)
}
// With depth and width bounded first, the product cannot overflow,
// and Bytes rejects any length mismatch before an allocation.
cells := uint64(depth) * width
raw, ok := d.Bytes(int(8 * cells))
if !ok {
return wire.ErrTruncated
}
if err := d.Done(); err != nil {
return err
}
s.width = width
s.depth = int(depth)
s.total = total
s.rows = make([]uint64, cells)
for i := range s.rows {
s.rows[i] = binary.LittleEndian.Uint64(raw[8*i:])
}
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package cuckoo
import (
"encoding/binary"
"fmt"
"math/bits"
"slices"
"github.com/deep-rent/nexus/std/sketch/internal/wire"
"github.com/deep-rent/nexus/std/xxh"
)
const (
// slots is the number of fingerprints per bucket. Four slots reach
// about 95% load before insertions start to fail.
slots = 4
// maxKicks bounds the eviction chain of one insertion before the
// filter is declared full.
maxKicks = 500
// seed keeps the eviction tie-breaking deterministic, so runs
// reproduce exactly.
seed = 0x5EED5EED5EED5EED
)
// A Filter is a cuckoo filter. [Filter.Has] may claim a key that was
// never added — at roughly an 0.01% rate with the 16-bit fingerprints
// used here — but never denies one that was, as long as deletions
// honor the contract in the package documentation.
//
// The zero value is not usable; construct filters with [New] or
// restore one with [Filter.UnmarshalBinary]. A Filter is not safe for
// concurrent use.
type Filter struct {
fps []uint16 // fingerprints, slots per bucket; 0 marks a free slot
mask uint64 // bucket count minus one (the count is a power of two)
n uint64 // fingerprints currently stored
rng prng // eviction tie-breaking
path []step // eviction scratch, reused across insertions
}
// A step remembers one eviction of an insertion chain, so a dead end
// can be unwound.
type step struct {
cell uint64
old uint16
}
// New sizes a filter for n expected keys, rounding the bucket count up
// to a power of two so that about 5% headroom remains at capacity. The
// filter occupies two bytes per slot: for a million keys, about 2 MB.
//
// New panics unless n is positive.
func New(n int) *Filter {
if n < 1 {
panic("capacity must be positive")
}
// Slots to hold n at 95% load, in power-of-two bucket counts.
buckets := uint64(1)
for buckets*slots*95/100 < uint64(n) {
buckets *= 2
}
buckets = max(buckets, 2) // Two candidate buckets must differ.
return &Filter{
fps: make([]uint16, buckets*slots),
mask: buckets - 1,
rng: seed,
}
}
// place summarizes where a key may live: its fingerprint and the two
// candidate buckets, either of which XORs into the other.
func (f *Filter) place(h uint64) (fp uint16, i1, i2 uint64) {
fp = uint16(h >> 48)
if fp == 0 {
fp = 1 // 0 marks a free slot.
}
i1 = h & f.mask
i2 = f.alt(i1, fp)
return fp, i1, i2
}
// alt maps one candidate bucket onto the other. The mapping is an
// involution: alt(alt(i)) == i, which is what lets an eviction move a
// fingerprint without knowing which of its buckets it came from. The
// XOR delta is forced nonzero so the two candidates always differ —
// otherwise the fingerprints whose mixed hash lands on zero would
// silently live with half the promised capacity.
func (f *Filter) alt(i uint64, fp uint16) uint64 {
d := xxh.Mix64(uint64(fp)) & f.mask
if d == 0 {
d = 1
}
return i ^ d
}
// Add inserts a key and reports whether it fit. A false return means
// the filter is at capacity; the filter is left exactly as it was, so
// the caller can fall back to the authority the filter fronts (or
// grow a replacement). Adding a key repeatedly stores that many
// copies; more than eight copies cannot fit.
func (f *Filter) Add[T xxh.Input](key T) bool {
fp, i1, i2 := f.place(xxh.Sum64(key, 0))
if f.insert(i1, fp) || f.insert(i2, fp) {
f.n++
return true
}
// Both buckets are full: evict cuckoo-style, remembering the
// chain so a dead end restores the filter instead of dropping a
// neighbor.
f.path = f.path[:0]
i := i1
if f.rng.next()&1 == 0 {
i = i2
}
curr := fp
for range maxKicks {
cell := i*slots + f.rng.next()%slots
f.path = append(f.path, step{cell, f.fps[cell]})
curr, f.fps[cell] = f.fps[cell], curr
i = f.alt(i, curr)
if f.insert(i, curr) {
f.n++
return true
}
}
for _, v := range slices.Backward(f.path) {
f.fps[v.cell] = v.old
}
return false
}
// insert places a fingerprint into a free slot of bucket i, reporting
// whether one was free.
func (f *Filter) insert(i uint64, fp uint16) bool {
b := f.fps[i*slots:][:slots]
for s := range b {
if b[s] == 0 {
b[s] = fp
return true
}
}
return false
}
// Has reports whether a key is possibly in the set. A false result is
// definitive; a true result is wrong at roughly the fingerprint
// collision rate of 0.01%.
func (f *Filter) Has[T xxh.Input](key T) bool {
fp, i1, i2 := f.place(xxh.Sum64(key, 0))
return f.scan(i1, fp) >= 0 || f.scan(i2, fp) >= 0
}
// Delete removes one copy of a key, reporting whether one was found.
// Deleting keys that were never added violates the filter's no-false-
// negatives promise for colliding keys; see the package documentation.
func (f *Filter) Delete[T xxh.Input](key T) bool {
fp, i1, i2 := f.place(xxh.Sum64(key, 0))
s := f.scan(i1, fp)
if s < 0 {
s = f.scan(i2, fp)
}
if s < 0 {
return false
}
f.fps[s] = 0
f.n--
return true
}
// scan returns the slot of fp within bucket i, or -1.
func (f *Filter) scan(i uint64, fp uint16) int {
b := f.fps[i*slots:][:slots]
for s := range b {
if b[s] == fp {
return int(i*slots) + s
}
}
return -1
}
// Count returns the number of fingerprints currently stored: additions
// minus deletions.
func (f *Filter) Count() uint64 { return f.n }
// Load returns the fraction of slots in use, between 0 and 1. Past
// about 0.95, insertions start to fail.
func (f *Filter) Load() float64 {
return float64(f.n) / float64(len(f.fps))
}
// AppendBinary appends the filter's encoding to buf and returns the
// extended slice, implementing [encoding.BinaryAppender]. The layout
// carries the filter's geometry — slots per bucket and fingerprint
// width — even though both are compile-time constants today, so that
// tuning either later is a decoded field rather than a format break.
func (f *Filter) AppendBinary(buf []byte) ([]byte, error) {
buf = wire.AppendHeader(buf, wire.KindCuckoo)
buf = append(buf, slots, 16)
buf = binary.LittleEndian.AppendUint64(buf, f.mask+1)
for _, fp := range f.fps {
buf = binary.LittleEndian.AppendUint16(buf, fp)
}
return buf, nil
}
// MarshalBinary encodes the filter, implementing
// [encoding.BinaryMarshaler].
func (f *Filter) MarshalBinary() ([]byte, error) {
return f.AppendBinary(make([]byte, 0, 12+2*len(f.fps)))
}
// UnmarshalBinary restores a filter from an encoding produced by
// [Filter.MarshalBinary], implementing [encoding.BinaryUnmarshaler].
// The receiver's previous state is discarded.
func (f *Filter) UnmarshalBinary(data []byte) error {
d := wire.NewDecoder(data)
if err := d.Header(wire.KindCuckoo); err != nil {
return err
}
perBucket, ok1 := d.U8()
fpBits, ok2 := d.U8()
if !ok1 || !ok2 {
return wire.ErrTruncated
}
if perBucket != slots || fpBits != 16 {
return fmt.Errorf("unsupported filter geometry: %d slots of "+
"%d bits", perBucket, fpBits)
}
buckets, ok := d.U64()
if !ok {
return wire.ErrTruncated
}
if buckets < 2 || bits.OnesCount64(buckets) != 1 {
return fmt.Errorf("invalid encoding: %d buckets", buckets)
}
if buckets > uint64(len(data))/(2*slots) {
// Cheaper than letting Bytes fail on gigantic counts, and it
// keeps the multiplication below overflow-safe.
return wire.ErrTruncated
}
raw, ok := d.Bytes(int(2 * slots * buckets))
if !ok {
return wire.ErrTruncated
}
if err := d.Done(); err != nil {
return err
}
f.fps = make([]uint16, slots*buckets)
f.mask = buckets - 1
f.n = 0
f.rng = seed
for i := range f.fps {
f.fps[i] = binary.LittleEndian.Uint16(raw[2*i:])
if f.fps[i] != 0 {
f.n++
}
}
return nil
}
// A prng is a SplitMix64 sequence: a tiny deterministic source of
// randomness for eviction tie-breaking that must not pull in global
// state.
type prng uint64
// next advances the sequence and returns its next value.
func (p *prng) next() uint64 {
*p += 0x9E3779B97F4A7C15
return xxh.Mix64(uint64(*p))
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package hll
import (
"errors"
"fmt"
"math"
"math/bits"
"github.com/deep-rent/nexus/std/sketch/internal/wire"
"github.com/deep-rent/nexus/std/xxh"
)
// ErrIncompatible reports an attempt to merge sketches of different
// precision. Registers of different widths do not correspond, so
// merging them would silently corrupt the estimate; the error is
// returned instead. Test with [errors.Is].
var ErrIncompatible = errors.New("incompatible sketch precision")
// MinPrecision is the minimum precision that can be used for a [Sketch].
const MinPrecision = 4
// MaxPrecision is the maximum precision that can be used for a [Sketch].
const MaxPrecision = 18
// A Sketch is a HyperLogLog counter. Adding a key that was added
// before never changes the state, so [Sketch.Count] estimates distinct
// keys regardless of how often each recurred.
//
// The zero value is not usable; construct sketches with [New] or
// restore one with [Sketch.UnmarshalBinary]. A Sketch is not safe for
// concurrent use.
type Sketch struct {
p int // precision: 2^p registers
reg []uint8 // per-register maximum rank
}
// New returns a sketch with 2^precision one-byte registers. The
// relative standard error of the estimate is about 1.04/sqrt(2^p):
// precision 14 costs 16 KB and estimates within about 0.8%; each step
// down halves the memory and widens the error by about 1.4x.
//
// New panics unless precision lies in [MinPrecision, MaxPrecision].
func New(precision int) *Sketch {
if precision < MinPrecision || precision > MaxPrecision {
panic(fmt.Errorf(
"precision must be in [%d, %d]",
MinPrecision, MaxPrecision,
))
}
return &Sketch{
p: precision,
reg: make([]uint8, 1<<precision),
}
}
// Add records a key. Adding the same key again has no effect.
func (s *Sketch) Add[T xxh.Input](key T) {
h := xxh.Sum64(key, 0)
// The top p bits pick the register; the rank of the remainder is
// the position of its highest set bit, counted from the top. A
// stop bit keeps the rank in range when the remainder is zero.
i := h >> (64 - s.p)
w := h<<s.p | 1<<(s.p-1)
r := uint8(bits.LeadingZeros64(w) + 1)
if r > s.reg[i] {
s.reg[i] = r
}
}
// Count estimates the number of distinct keys added so far.
func (s *Sketch) Count() uint64 {
m := len(s.reg)
q := 64 - s.p // highest attainable rank is q+1
// Multiplicity histogram: how many registers sit at each rank.
// The array is sized for the widest case (precision 4, ranks
// 0..61) so Count never allocates.
var counts [62]int
hist := counts[: q+2 : q+2]
for _, r := range s.reg {
hist[r]++
}
// Ertl's improved raw estimator (arXiv:1702.01284, section 4):
// corrected register moments replace the classic bias tables and
// linear-counting handover.
z := float64(m) * tau(float64(m-hist[q+1])/float64(m))
for k := q; k >= 1; k-- {
z = 0.5 * (z + float64(hist[k]))
}
z += float64(m) * sigma(float64(hist[0])/float64(m))
const alpha = 1 / (2 * math.Ln2)
est := alpha * float64(m) * float64(m) / z
if est >= math.MaxUint64 {
// Only reachable through crafted encodings whose registers
// all sit at or near the maximum rank; no addable key stream
// gets here. Saturate rather than overflow the conversion.
return math.MaxUint64
}
return uint64(math.Round(est))
}
// sigma computes x + x²+ x⁴ + ... weighted per Ertl's estimator; it
// diverges to +Inf as x approaches 1 (an empty sketch).
func sigma(x float64) float64 {
if x == 1 {
return math.Inf(1)
}
y := 1.0
z := x
for {
x *= x
prev := z
z += x * y
y += y
if z == prev {
return z
}
}
}
// tau computes the complementary correction for saturated registers.
func tau(x float64) float64 {
if x == 0 || x == 1 {
return 0
}
y := 1.0
z := 1 - x
for {
x = math.Sqrt(x)
prev := z
y /= 2
d := 1 - x
z -= d * d * y
if z == prev {
return z / 3
}
}
}
// Merge folds another sketch into s, so that s afterwards estimates
// the union of both key sets. Merging is idempotent: keys counted by
// both sides are not counted twice. The sketches must share their
// precision; anything else returns [ErrIncompatible].
func (s *Sketch) Merge(o *Sketch) error {
if s.p != o.p {
return fmt.Errorf("%w: %d vs %d", ErrIncompatible, s.p, o.p)
}
for i, r := range o.reg {
if r > s.reg[i] {
s.reg[i] = r
}
}
return nil
}
// AppendBinary appends the sketch's encoding to buf and returns the
// extended slice, implementing [encoding.BinaryAppender].
func (s *Sketch) AppendBinary(buf []byte) ([]byte, error) {
buf = wire.AppendHeader(buf, wire.KindHyperLogLog)
buf = append(buf, byte(s.p))
return append(buf, s.reg...), nil
}
// MarshalBinary encodes the sketch, implementing
// [encoding.BinaryMarshaler].
func (s *Sketch) MarshalBinary() ([]byte, error) {
return s.AppendBinary(make([]byte, 0, 3+len(s.reg)))
}
// UnmarshalBinary restores a sketch from an encoding produced by
// [Sketch.MarshalBinary], implementing [encoding.BinaryUnmarshaler].
// The receiver's previous state is discarded.
func (s *Sketch) UnmarshalBinary(data []byte) error {
d := wire.NewDecoder(data)
if err := d.Header(wire.KindHyperLogLog); err != nil {
return err
}
p, ok := d.U8()
if !ok {
return wire.ErrTruncated
}
if p < 4 || p > 18 {
return fmt.Errorf("invalid encoding: precision %d", p)
}
raw, ok := d.Bytes(1 << p)
if !ok {
return wire.ErrTruncated
}
if err := d.Done(); err != nil {
return err
}
limit := uint8(64 - p + 1)
for _, r := range raw {
if r > limit {
return fmt.Errorf("invalid encoding: rank %d exceeds %d",
r, limit)
}
}
s.p = int(p)
s.reg = append([]uint8(nil), raw...)
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package wire
import (
"encoding/binary"
"errors"
"fmt"
"math"
)
// The kind bytes leading every encoding, one per sketch. The values are
// wire constants: they must never be renumbered.
const (
KindBloom byte = iota + 1
KindCountMin
KindHyperLogLog
KindQuantile
KindCuckoo
KindTopK
)
// Version is stamped into every encoding after the kind byte. It is
// bumped when a layout changes shape.
const Version byte = 1
// ErrTruncated reports an encoding shorter than its layout demands.
var ErrTruncated = errors.New("truncated encoding")
// AppendHeader appends the two-byte encoding header.
func AppendHeader(b []byte, kind byte) []byte {
return append(b, kind, Version)
}
// AppendF64 appends a float64 in little-endian IEEE 754 bit order.
func AppendF64(b []byte, v float64) []byte {
return binary.LittleEndian.AppendUint64(b, math.Float64bits(v))
}
// A Decoder consumes an encoding front to back. Each read reports ok as
// false once the buffer runs short, so a caller checks results where
// convenient and turns the first failure into [ErrTruncated] rather
// than validating lengths up front.
type Decoder struct{ buf []byte }
// NewDecoder returns a Decoder over data. The Decoder aliases data and
// never mutates it.
func NewDecoder(data []byte) Decoder {
return Decoder{buf: data}
}
// Header consumes and checks the kind and version bytes.
func (d *Decoder) Header(kind byte) error {
k, ok := d.U8()
if !ok {
return ErrTruncated
}
if k != kind {
return fmt.Errorf("unexpected encoding kind %#x", k)
}
v, ok := d.U8()
if !ok {
return ErrTruncated
}
if v != Version {
return fmt.Errorf("unsupported encoding version %d", v)
}
return nil
}
// U8 consumes a single byte.
func (d *Decoder) U8() (byte, bool) {
if len(d.buf) < 1 {
return 0, false
}
v := d.buf[0]
d.buf = d.buf[1:]
return v, true
}
// U32 consumes a little-endian uint32.
func (d *Decoder) U32() (uint32, bool) {
if len(d.buf) < 4 {
return 0, false
}
v := binary.LittleEndian.Uint32(d.buf)
d.buf = d.buf[4:]
return v, true
}
// U64 consumes a little-endian uint64.
func (d *Decoder) U64() (uint64, bool) {
if len(d.buf) < 8 {
return 0, false
}
v := binary.LittleEndian.Uint64(d.buf)
d.buf = d.buf[8:]
return v, true
}
// F64 consumes a float64 in little-endian IEEE 754 bit order.
func (d *Decoder) F64() (float64, bool) {
v, ok := d.U64()
return math.Float64frombits(v), ok
}
// Bytes consumes exactly n bytes. The returned slice aliases the
// encoding; callers copy out of it rather than retaining it. The
// length check precedes any allocation a caller might size off n, so
// a corrupt length cannot balloon memory.
func (d *Decoder) Bytes(n int) ([]byte, bool) {
if n < 0 || len(d.buf) < n {
return nil, false
}
v := d.buf[:n]
d.buf = d.buf[n:]
return v, true
}
// Done reports whether the encoding was consumed exactly, rejecting
// trailing bytes that a length-driven layout would silently ignore.
func (d *Decoder) Done() error {
if len(d.buf) != 0 {
return errors.New("trailing bytes after encoding")
}
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package quantile
// DefaultBuckets is the bucket cap applied by [New] unless overridden
// via [WithBuckets]. At one-percent accuracy, the default spans
// roughly seventeen orders of magnitude before any folding occurs —
// far beyond what a latency or size distribution occupies.
const DefaultBuckets = 2048
// An Option adjusts a [Sketch] under construction.
type Option func(*Sketch)
// WithBuckets caps the number of buckets the sketch keeps. Whenever
// the cap would be exceeded, the two lowest buckets fold into one,
// trading accuracy at the bottom of the range for bounded memory. The
// cap divides the representable spread: a sketch of accuracy alpha
// covers about n·log10((1+alpha)/(1-alpha)) orders of magnitude before
// folding.
//
// WithBuckets panics unless n is at least 2, the minimum a fold needs.
func WithBuckets(n int) Option {
if n < 2 {
panic("bucket cap must be at least 2")
}
return func(s *Sketch) {
s.limit = n
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package quantile
import (
"encoding/binary"
"errors"
"fmt"
"maps"
"math"
"slices"
"github.com/deep-rent/nexus/std/sketch/internal/wire"
)
// ErrIncompatible reports an attempt to merge sketches of different
// accuracy. Their buckets describe different intervals, so merging
// them would silently corrupt the estimates; the error is returned
// instead. Test with [errors.Is].
var ErrIncompatible = errors.New("incompatible sketch accuracy")
// A Sketch is a DDSketch estimator over a nonnegative measure.
// [Sketch.Quantile] answers within the relative accuracy the sketch
// was built with; exact minimum, maximum, sum, and count ride along
// for free.
//
// The zero value is not usable; construct sketches with [New] or
// restore one with [Sketch.UnmarshalBinary]. A Sketch is not safe for
// concurrent use.
type Sketch struct {
alpha float64 // relative accuracy
gamma float64 // bucket growth factor (1+α)/(1−α)
lg float64 // log(gamma), cached for Add
limit int // bucket cap; exceeding it folds
buckets map[int]uint64 // count per logarithmic index
zeros uint64 // values equal to zero
count uint64 // all values, zeros included
sum float64 // exact running sum
min float64 // exact minimum, NaN while empty
max float64 // exact maximum, NaN while empty
}
// New returns a sketch of the given relative accuracy: an estimated
// quantile q̂ relates to the true quantile q as |q̂−q| ≤ alpha·q.
// Accuracy 0.01 keeps estimates within one percent using at most a few
// thousand buckets of sixteen bytes.
//
// New panics unless alpha lies in (0, 1) wide enough to distinguish
// buckets (roughly above 1e-9).
func New(alpha float64, opts ...Option) *Sketch {
if !(alpha > 0 && alpha < 1) {
panic("accuracy must be in (0, 1)")
}
s := &Sketch{
limit: DefaultBuckets,
buckets: make(map[int]uint64),
min: math.NaN(),
max: math.NaN(),
}
if !s.setAccuracy(alpha) {
panic("accuracy must be in (0, 1)")
}
for _, opt := range opts {
opt(s)
}
return s
}
// setAccuracy derives the interlocked accuracy fields — the single
// place alpha turns into gamma and its cached logarithm, so a
// constructed sketch and a restored one can never disagree. It reports
// whether alpha describes a usable accuracy.
func (s *Sketch) setAccuracy(alpha float64) bool {
gamma := (1 + alpha) / (1 - alpha)
lg := math.Log(gamma)
if !(lg > 0) || math.IsInf(gamma, 0) {
return false
}
s.alpha = alpha
s.gamma = gamma
s.lg = lg
return true
}
// Add records a value. Add panics if v is negative, NaN, or infinite:
// the sketch describes nonnegative finite measures, and admitting
// anything else would silently corrupt every later estimate.
func (s *Sketch) Add(v float64) {
if !(v >= 0) || math.IsInf(v, 1) {
panic("value must be finite and nonnegative")
}
if s.count == 0 || v < s.min {
s.min = v
}
if s.count == 0 || v > s.max {
s.max = v
}
s.count++
s.sum += v
if v == 0 {
s.zeros++
return
}
s.buckets[s.index(v)]++
if len(s.buckets) > s.limit {
s.fold()
}
}
// index maps a positive value onto its logarithmic bucket: bucket i
// covers (gamma^(i-1), gamma^i].
func (s *Sketch) index(v float64) int {
return int(math.Ceil(math.Log(v) / s.lg))
}
// value returns the representative value of bucket i — the point whose
// worst-case relative error against anything in the bucket is alpha.
func (s *Sketch) value(i int) float64 {
return 2 * math.Pow(s.gamma, float64(i)) / (1 + s.gamma)
}
// fold merges the two lowest buckets, keeping memory bounded at the
// price of accuracy at the bottom of the range. It requires at least
// two buckets, which the callers' limit (at least 2) guarantees.
func (s *Sketch) fold() {
lo, next := math.MaxInt, math.MaxInt
for i := range s.buckets {
if i < lo {
lo, next = i, lo
} else if i < next {
next = i
}
}
s.buckets[next] += s.buckets[lo]
delete(s.buckets, lo)
}
// Quantile returns the estimated value at rank q in [0, 1]: 0.5 is the
// median, 0.99 the 99th percentile. Estimates are clamped into the
// exact observed range, so Quantile(0) is [Sketch.Min] and Quantile(1)
// is [Sketch.Max]. An empty sketch returns NaN.
//
// Quantile panics if q is NaN or outside [0, 1].
func (s *Sketch) Quantile(q float64) float64 {
if !(q >= 0 && q <= 1) {
panic("quantile must be in [0, 1]")
}
if s.count == 0 {
return math.NaN()
}
rank := uint64(math.Round(q * float64(s.count-1)))
// The extreme ranks are order statistics the sketch tracks
// exactly; skip the bucket walk and its rounding.
if rank == 0 {
return s.min
}
if rank == s.count-1 {
return s.max
}
if rank < s.zeros {
return 0
}
cum := s.zeros
for _, i := range slices.Sorted(maps.Keys(s.buckets)) {
cum += s.buckets[i]
if cum > rank {
return min(max(s.value(i), s.min), s.max)
}
}
return s.max // Unreachable while counts are consistent.
}
// Count returns how many values were added, zeros included.
func (s *Sketch) Count() uint64 { return s.count }
// Sum returns the exact sum of all added values; Sum/Count is the
// exact mean.
func (s *Sketch) Sum() float64 { return s.sum }
// Min returns the exact smallest value added, or NaN while empty.
func (s *Sketch) Min() float64 { return s.min }
// Max returns the exact largest value added, or NaN while empty.
func (s *Sketch) Max() float64 { return s.max }
// Merge folds another sketch into s, so that s afterwards summarizes
// the concatenation of both streams. The sketches must share their
// accuracy; anything else returns [ErrIncompatible]. Bucket caps may
// differ: the receiver's cap wins.
func (s *Sketch) Merge(o *Sketch) error {
if s.alpha != o.alpha {
return fmt.Errorf("%w: %g vs %g", ErrIncompatible,
s.alpha, o.alpha)
}
if o.count == 0 {
return nil
}
if s.count == 0 || o.min < s.min {
s.min = o.min
}
if s.count == 0 || o.max > s.max {
s.max = o.max
}
s.count += o.count
s.sum += o.sum
s.zeros += o.zeros
for i, c := range o.buckets {
s.buckets[i] += c
}
for len(s.buckets) > s.limit {
s.fold()
}
return nil
}
// AppendBinary appends the sketch's encoding to buf and returns the
// extended slice, implementing [encoding.BinaryAppender]. Buckets are
// encoded in ascending index order, so equal sketches encode equally.
func (s *Sketch) AppendBinary(buf []byte) ([]byte, error) {
buf = wire.AppendHeader(buf, wire.KindQuantile)
buf = wire.AppendF64(buf, s.alpha)
buf = binary.LittleEndian.AppendUint64(buf, s.zeros)
buf = binary.LittleEndian.AppendUint64(buf, s.count)
buf = wire.AppendF64(buf, s.sum)
buf = wire.AppendF64(buf, s.min)
buf = wire.AppendF64(buf, s.max)
buf = binary.LittleEndian.AppendUint32(buf, uint32(len(s.buckets)))
for _, i := range slices.Sorted(maps.Keys(s.buckets)) {
buf = binary.LittleEndian.AppendUint64(buf, uint64(int64(i)))
buf = binary.LittleEndian.AppendUint64(buf, s.buckets[i])
}
return buf, nil
}
// MarshalBinary encodes the sketch, implementing
// [encoding.BinaryMarshaler].
func (s *Sketch) MarshalBinary() ([]byte, error) {
// 2 header + 5 eight-byte fields + zeros + the bucket count.
return s.AppendBinary(make([]byte, 0, 54+16*len(s.buckets)))
}
// UnmarshalBinary restores a sketch from an encoding produced by
// [Sketch.MarshalBinary], implementing [encoding.BinaryUnmarshaler].
// The bucket cap is not part of the encoding; the receiver keeps its
// own, or the default. The receiver's previous state is discarded.
func (s *Sketch) UnmarshalBinary(data []byte) error {
d := wire.NewDecoder(data)
if err := d.Header(wire.KindQuantile); err != nil {
return err
}
alpha, ok := d.F64()
if !ok {
return wire.ErrTruncated
}
if !(alpha > 0 && alpha < 1) {
return fmt.Errorf("invalid encoding: accuracy %g", alpha)
}
zeros, ok1 := d.U64()
count, ok2 := d.U64()
sum, ok3 := d.F64()
mn, ok4 := d.F64()
mx, ok5 := d.F64()
n, ok6 := d.U32()
if !ok1 || !ok2 || !ok3 || !ok4 || !ok5 || !ok6 {
return wire.ErrTruncated
}
// Each bucket occupies 16 bytes, so a count the buffer cannot
// possibly hold is rejected before the map is sized from it.
if uint64(n)*16 > uint64(len(data)) {
return wire.ErrTruncated
}
buckets := make(map[int]uint64, n)
var total uint64
for range n {
i, ok1 := d.U64()
c, ok2 := d.U64()
if !ok1 || !ok2 {
return wire.ErrTruncated
}
// Zero counts and overflow of the running total are both
// states AppendBinary cannot produce, and a wrapped total
// would defeat the count identity checked below.
if c == 0 || c > math.MaxUint64-total {
return fmt.Errorf("invalid encoding: bucket %d", int64(i))
}
buckets[int(int64(i))] = c
total += c
}
if err := d.Done(); err != nil {
return err
}
if len(buckets) != int(n) {
return errors.New("invalid encoding: duplicate buckets")
}
if zeros > math.MaxUint64-total || total+zeros != count {
return fmt.Errorf("invalid encoding: %d values for count %d",
total+zeros, count)
}
if count > 0 {
// The invariants Add maintains: a nonnegative, finite,
// ordered range; a zero minimum whenever zeros were counted;
// and a sum no better-behaved than NaN-free.
switch {
case !(mn >= 0 && mn <= mx) || math.IsInf(mx, 0):
return fmt.Errorf("invalid encoding: range [%g, %g]", mn, mx)
case zeros > 0 && mn != 0:
return fmt.Errorf("invalid encoding: %d zeros with minimum %g",
zeros, mn)
case math.IsNaN(sum) || sum < 0:
return fmt.Errorf("invalid encoding: sum %g", sum)
}
}
if s.limit == 0 {
s.limit = DefaultBuckets
}
if !s.setAccuracy(alpha) {
return fmt.Errorf("invalid encoding: accuracy %g", alpha)
}
s.buckets = buckets
s.zeros = zeros
s.count = count
s.sum = sum
s.min = mn
s.max = mx
for len(s.buckets) > s.limit {
s.fold()
}
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package topk
import (
"encoding/binary"
"fmt"
"slices"
"strings"
"github.com/deep-rent/nexus/std/sketch/internal/wire"
"github.com/deep-rent/nexus/std/xxh"
)
// An Entry is one tracked key of the board.
type Entry struct {
// Key is the tracked key, exactly as added.
Key string
// Count estimates the total weight added for Key. It never
// undercounts: the true weight lies in [Count−Err, Count].
Count uint64
// Err bounds the overcount: the weight Count may have inherited
// from keys evicted before this one took its seat.
Err uint64
}
// A Sketch is a SpaceSaving top-k board. See the package documentation
// for the guarantees it maintains.
//
// The zero value is not usable; construct sketches with [New] or
// restore one with [Sketch.UnmarshalBinary]. A Sketch is not safe for
// concurrent use.
type Sketch struct {
cap int
total uint64
byKey map[string]*entry
heap []*entry // min-heap on count; the root is first to go
}
// entry is the mutable state behind an [Entry], threaded through both
// the key index and the eviction heap.
type entry struct {
key string
count uint64
err uint64
pos int // index in the heap
}
// New returns a board tracking the k heaviest keys. Memory is k small
// entries plus the tracked key strings, however many keys the stream
// carries.
//
// New panics unless k is positive.
func New(k int) *Sketch {
if k < 1 {
panic("capacity must be positive")
}
return &Sketch{
cap: k,
byKey: make(map[string]*entry, k),
}
}
// Add records weight n for a key. A zero weight is a no-op.
func (s *Sketch) Add[T xxh.Input](key T, n uint64) {
if n == 0 {
return
}
s.total += n
if e, ok := s.byKey[string(key)]; ok {
e.count += n
s.sink(e.pos)
return
}
if len(s.heap) < s.cap {
e := &entry{key: string(key), count: n, pos: len(s.heap)}
s.byKey[e.key] = e
s.heap = append(s.heap, e)
s.rise(e.pos)
return
}
// The board is full: the newcomer takes the seat of the lightest
// incumbent, inheriting its count as the error bound — the
// newcomer's true weight cannot exceed what the seat had seen.
min := s.heap[0]
delete(s.byKey, min.key)
e := &entry{key: string(key), count: min.count + n, err: min.count}
s.heap[0] = e
s.byKey[e.key] = e
s.sink(0)
}
// Count returns the estimated weight of a key and whether the key is
// on the board. Keys off the board report zero: they may have been
// added, but their weight is bounded by the lightest tracked count.
func (s *Sketch) Count[T xxh.Input](key T) (uint64, bool) {
e, ok := s.byKey[string(key)]
if !ok {
return 0, false
}
return e.count, true
}
// Total returns the total weight added across all keys, evicted ones
// included: the N that the package guarantees are relative to.
func (s *Sketch) Total() uint64 { return s.total }
// List returns the board ordered by weight, heaviest first, ties by
// key. The slice is the caller's to keep.
func (s *Sketch) List() []Entry {
out := make([]Entry, len(s.heap))
for i, e := range s.heap {
out[i] = Entry{Key: e.key, Count: e.count, Err: e.err}
}
slices.SortFunc(out, func(a, b Entry) int {
switch {
case a.Count > b.Count:
return -1
case a.Count < b.Count:
return 1
default:
return strings.Compare(a.Key, b.Key)
}
})
return out
}
// rise restores the heap upward from i after an insertion.
func (s *Sketch) rise(i int) {
for i > 0 {
p := (i - 1) / 2
if s.heap[p].count <= s.heap[i].count {
return
}
s.swap(i, p)
i = p
}
}
// sink restores the heap downward from i after a count grew.
func (s *Sketch) sink(i int) {
for {
l, r, m := 2*i+1, 2*i+2, i
if l < len(s.heap) && s.heap[l].count < s.heap[m].count {
m = l
}
if r < len(s.heap) && s.heap[r].count < s.heap[m].count {
m = r
}
if m == i {
return
}
s.swap(i, m)
i = m
}
}
// swap exchanges two heap slots, keeping positions current.
func (s *Sketch) swap(i, j int) {
s.heap[i], s.heap[j] = s.heap[j], s.heap[i]
s.heap[i].pos, s.heap[j].pos = i, j
}
// AppendBinary appends the board's encoding to buf and returns the
// extended slice, implementing [encoding.BinaryAppender]. Entries are
// encoded in [Sketch.List] order, so equal boards encode equally.
func (s *Sketch) AppendBinary(buf []byte) ([]byte, error) {
buf = wire.AppendHeader(buf, wire.KindTopK)
buf = binary.LittleEndian.AppendUint32(buf, uint32(s.cap))
buf = binary.LittleEndian.AppendUint64(buf, s.total)
buf = binary.LittleEndian.AppendUint32(buf, uint32(len(s.heap)))
for _, e := range s.List() {
buf = binary.LittleEndian.AppendUint32(buf, uint32(len(e.Key)))
buf = append(buf, e.Key...)
buf = binary.LittleEndian.AppendUint64(buf, e.Count)
buf = binary.LittleEndian.AppendUint64(buf, e.Err)
}
return buf, nil
}
// MarshalBinary encodes the board, implementing
// [encoding.BinaryMarshaler].
func (s *Sketch) MarshalBinary() ([]byte, error) {
size := 18
for _, e := range s.heap {
size += 20 + len(e.key)
}
return s.AppendBinary(make([]byte, 0, size))
}
// UnmarshalBinary restores a board from an encoding produced by
// [Sketch.MarshalBinary], implementing [encoding.BinaryUnmarshaler].
// The receiver's previous state is discarded.
func (s *Sketch) UnmarshalBinary(data []byte) error {
d := wire.NewDecoder(data)
if err := d.Header(wire.KindTopK); err != nil {
return err
}
k, ok1 := d.U32()
total, ok2 := d.U64()
n, ok3 := d.U32()
if !ok1 || !ok2 || !ok3 {
return wire.ErrTruncated
}
if k < 1 || n > k {
return fmt.Errorf("invalid encoding: %d of %d entries", n, k)
}
// Each entry occupies at least 20 bytes, so a count the buffer
// cannot possibly hold is rejected before anything is sized off
// it.
if uint64(n)*20 > uint64(len(data)) {
return wire.ErrTruncated
}
byKey := make(map[string]*entry, n)
heap := make([]*entry, 0, n)
var tracked uint64
for range n {
klen, ok := d.U32()
if !ok {
return wire.ErrTruncated
}
raw, ok := d.Bytes(int(klen))
if !ok {
return wire.ErrTruncated
}
count, ok1 := d.U64()
err, ok2 := d.U64()
if !ok1 || !ok2 {
return wire.ErrTruncated
}
// States Add cannot produce: a seat holds strictly more than
// it inherited, attributed weight cannot wrap, and a key
// holds one seat at most.
if count <= err || count-err > total-tracked {
return fmt.Errorf("invalid encoding: entry holds %d over %d",
count, err)
}
tracked += count - err
e := &entry{key: string(raw), count: count, err: err}
if _, dup := byKey[e.key]; dup {
return fmt.Errorf("invalid encoding: duplicate key %q", e.key)
}
byKey[e.key] = e
heap = append(heap, e)
}
if err := d.Done(); err != nil {
return err
}
s.cap = int(k)
s.total = total
s.byKey = byKey
s.heap = heap
for i := range s.heap {
s.heap[i].pos = i
}
for i := len(s.heap)/2 - 1; i >= 0; i-- {
s.sink(i)
}
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package stat
import (
"math"
"slices"
)
// consistency rescales a median absolute deviation to estimate a
// standard deviation under normality — the constant that makes
// [Deviation.Sigma] read in the familiar sigma units.
const consistency = 1.4826
// A Deviation is a robust yardstick over a sliding window: it keeps
// the last window values and measures spread as the median absolute
// deviation rather than the standard deviation. The distinction is
// the point — a spike inflates a standard deviation and thereby
// excuses itself, while the median barely moves, so the yardstick
// keeps calling the spike what it is.
//
// The zero value is not usable; construct with [NewDeviation]. A
// Deviation is not safe for concurrent use.
type Deviation struct {
ring []float64 // the window, overwritten in place
scratch []float64 // sort buffer, reused across queries
n int // values seen, capped at the window
head int // next write position
dirty bool // window changed since the last query
median float64 // cached center
sigma float64 // cached scale
}
// NewDeviation returns a yardstick over the last window values.
// Larger windows steady the estimate; smaller ones track change
// faster.
//
// NewDeviation panics if window is less than 4.
func NewDeviation(window int) *Deviation {
if window < 4 {
panic("window must be at least 4")
}
return &Deviation{
ring: make([]float64, window),
scratch: make([]float64, 0, window),
median: math.NaN(),
sigma: math.NaN(),
}
}
// Observe records a value, displacing the oldest once the window is
// full. Observe panics if v is not finite.
func (d *Deviation) Observe(v float64) {
if math.IsNaN(v) || math.IsInf(v, 0) {
panic("value must be finite")
}
d.ring[d.head] = v
d.head = (d.head + 1) % len(d.ring)
d.n = min(d.n+1, len(d.ring))
d.dirty = true
}
// Median returns the median of the window, or NaN while empty.
func (d *Deviation) Median() float64 {
d.refresh()
return d.median
}
// Sigma returns the window's robust scale: the median absolute
// deviation rescaled to sigma units, so thresholds read like standard
// deviations. It returns NaN while empty and 0 when every value in
// the window is identical.
func (d *Deviation) Sigma() float64 {
d.refresh()
return d.sigma
}
// Score returns how many sigmas v sits from the window's median,
// signed. A window of identical values has no scale: any departure
// from it scores infinite, and matching it scores zero. An empty
// window scores NaN.
func (d *Deviation) Score(v float64) float64 {
d.refresh()
if d.n == 0 {
return math.NaN()
}
delta := v - d.median
if delta == 0 {
return 0
}
if d.sigma == 0 {
return math.Inf(1) * delta / math.Abs(delta)
}
return delta / d.sigma
}
// Ready reports whether the window holds enough values for its
// estimates to be trusted: at least half its size.
func (d *Deviation) Ready() bool {
return d.n >= len(d.ring)/2
}
// Len returns how many values the window currently holds.
func (d *Deviation) Len() int { return d.n }
// refresh recomputes the cached center and scale after changes.
func (d *Deviation) refresh() {
if !d.dirty {
return
}
d.dirty = false
if d.n == 0 {
d.median = math.NaN()
d.sigma = math.NaN()
return
}
d.scratch = append(d.scratch[:0], d.ring[:d.n]...)
slices.Sort(d.scratch)
d.median = middle(d.scratch)
for i, v := range d.scratch {
d.scratch[i] = math.Abs(v - d.median)
}
slices.Sort(d.scratch)
d.sigma = consistency * middle(d.scratch)
}
// middle returns the median of a sorted, non-empty slice.
func middle(sorted []float64) float64 {
mid := len(sorted) / 2
if len(sorted)%2 == 1 {
return sorted[mid]
}
return (sorted[mid-1] + sorted[mid]) / 2
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package stat
import "math"
// A Drift is a two-sided CUSUM detector: it accumulates standardized
// deviations beyond a slack allowance, separately for each direction,
// and raises once either sum crosses the decision threshold. Where a
// spike test asks "is this sample absurd?", a Drift asks "have the
// last many samples leaned the same small way?" — the question that
// catches a regression of one sigma, which no single sample betrays.
//
// The zero value is not usable; construct with [NewDrift]. A Drift is
// not safe for concurrent use.
type Drift struct {
slack float64 // per-sample allowance, in sigma units
decision float64 // accumulated evidence that raises the alarm
pos float64
neg float64
}
// NewDrift returns a detector with the given slack and decision
// threshold, both in the sigma units of the scores fed to it. The
// classic operating point pairs a slack of 0.5 with a decision of 4
// to 5: sensitive to sustained shifts of about one sigma while
// staying quiet on symmetric noise.
//
// NewDrift panics unless the slack is nonnegative and the decision
// threshold positive.
func NewDrift(slack, decision float64) *Drift {
if !(slack >= 0) {
panic("slack must be nonnegative")
}
if !(decision > 0) {
panic("decision threshold must be positive")
}
return &Drift{slack: slack, decision: decision}
}
// Observe feeds the next standardized deviation and reports whether
// the accumulated evidence just crossed the decision threshold. On an
// alarm both accumulators reset, so evidence is spent by the alarm it
// raised rather than ringing forever. Infinite scores alarm
// immediately; Observe panics on NaN.
func (d *Drift) Observe(z float64) bool {
if math.IsNaN(z) {
panic("score must not be NaN")
}
d.pos = max(0, d.pos+z-d.slack)
d.neg = max(0, d.neg-z-d.slack)
if d.pos > d.decision || d.neg > d.decision {
d.pos, d.neg = 0, 0
return true
}
return false
}
// Levels returns the two accumulators — evidence of an upward and a
// downward shift — chiefly for observability and tests.
func (d *Drift) Levels() (up, down float64) {
return d.pos, d.neg
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package stat
import "math"
// A Smoother is an additive Holt–Winters model: it maintains a level,
// a trend, and — when constructed with [NewSeasonalSmoother] — a
// repeating seasonal profile, each updated by exponential smoothing as
// values arrive. Its forecast is what the series is expected to do
// next; the residual against it is what the deviation and drift
// primitives feed on.
//
// The zero value is not usable; construct smoothers with [NewSmoother]
// or [NewSeasonalSmoother]. A Smoother is not safe for concurrent use.
type Smoother struct {
alpha float64 // level gain
beta float64 // trend gain
gamma float64 // season gain
season []float64 // additive seasonal profile; nil without season
cursor int // slot the next observation lands on
level float64
trend float64
seen int // observations so far, for readiness
}
// NewSmoother returns a season-less smoother tracking level and trend.
// The gains weigh how fast each component chases the series: alpha is
// the level gain in (0, 1], beta the trend gain in [0, 1] (zero
// freezes the trend at flat).
//
// NewSmoother panics if a gain falls outside its range.
func NewSmoother(alpha, beta float64) *Smoother {
if !(alpha > 0 && alpha <= 1) {
panic("level gain must be in (0, 1]")
}
if !(beta >= 0 && beta <= 1) {
panic("trend gain must be in [0, 1]")
}
return &Smoother{alpha: alpha, beta: beta}
}
// NewSeasonalSmoother returns a smoother that additionally learns an
// additive seasonal profile of the given period: observations cycle
// through period slots, and each slot remembers how far the series
// habitually sits above or below its level there. The gamma gain in
// [0, 1] weighs how fast the profile adapts.
//
// NewSeasonalSmoother panics like [NewSmoother], if gamma falls
// outside [0, 1], or if the period is less than 2.
func NewSeasonalSmoother(alpha, beta, gamma float64, period int) *Smoother {
s := NewSmoother(alpha, beta)
if !(gamma >= 0 && gamma <= 1) {
panic("season gain must be in [0, 1]")
}
if period < 2 {
panic("period must be at least 2")
}
s.gamma = gamma
s.season = make([]float64, period)
return s
}
// Observe feeds the next value of the series. Observe panics if v is
// not finite: a NaN or infinity would silently poison every later
// forecast.
func (s *Smoother) Observe(v float64) {
if math.IsNaN(v) || math.IsInf(v, 0) {
panic("value must be finite")
}
if s.seen == 0 {
s.level = v
} else {
sea := 0.0
if s.season != nil {
sea = s.season[s.cursor]
}
prev := s.level
s.level = s.alpha*(v-sea) + (1-s.alpha)*(s.level+s.trend)
s.trend = s.beta*(s.level-prev) + (1-s.beta)*s.trend
if s.season != nil {
s.season[s.cursor] = s.gamma*(v-s.level) + (1-s.gamma)*sea
}
}
s.advance()
s.seen++
}
// Skip advances the model across n slots that carried no observation —
// a gap in the series. The level marches on with the trend and the
// seasonal phase stays aligned with the clock, so the forecast after a
// gap is what the model would have predicted for that slot all along.
// Non-positive n does nothing.
func (s *Smoother) Skip(n int) {
for range n {
s.level += s.trend
s.advance()
}
}
// advance moves the seasonal cursor to the next slot.
func (s *Smoother) advance() {
if s.season != nil {
s.cursor = (s.cursor + 1) % len(s.season)
}
}
// Forecast returns the expected next value: the one the upcoming
// [Smoother.Observe] will be compared against.
func (s *Smoother) Forecast() float64 {
return s.ForecastAt(1)
}
// ForecastAt returns the expected value h steps ahead: the level
// carried forward by the trend, plus the seasonal profile of the slot
// the step lands on. ForecastAt panics unless h is positive.
func (s *Smoother) ForecastAt(h int) float64 {
if h < 1 {
panic("horizon must be positive")
}
f := s.level + float64(h)*s.trend
if s.season != nil {
f += s.season[(s.cursor+h-1)%len(s.season)]
}
return f
}
// Level returns the current smoothed level of the series.
func (s *Smoother) Level() float64 { return s.level }
// Trend returns the current smoothed per-step change of the series.
func (s *Smoother) Trend() float64 { return s.trend }
// Ready reports whether the model has seen enough to be trusted: two
// observations without a season, two full periods with one. Consumers
// gate their verdicts on it rather than alerting off a cold model.
func (s *Smoother) Ready() bool {
if s.season == nil {
return s.seen >= 2
}
return s.seen >= 2*len(s.season)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package text
import (
"unicode/utf8"
"github.com/deep-rent/nexus/std/ascii"
)
// MaxSuggestDistance is the furthest a candidate may sit from the input
// and still be offered.
//
// Three edits is enough for the mistakes people actually make — a
// doubled letter, a dropped one, a wrong one — and short of the
// distance at which a suggestion becomes a guess. It is a ceiling
// rather than the whole rule; see [Nearest].
const MaxSuggestDistance = 3
// Nearest picks the candidate closest to the input, reporting whether
// one was close enough to offer.
//
// It is for the "did you mean" beside a refusal, over a CLOSED
// vocabulary a program already holds: the commands of a CLI, a
// deployment's tag list, the categories of a notification catalog.
// Those are dozens of strings in memory, and the question is which one
// the person meant to type.
//
// It is deliberately not a search. There is no ranking, no relevance,
// no tokenizing, and no index — pointing this at a table would be a
// mistake the name is chosen to discourage. Text a database holds is
// searched by the database; see the help desk's ticket search.
//
// # What counts as close
//
// Distance is measured over lower-cased input, and the allowance
// scales with the shorter word: a third of its length, capped at
// [MaxSuggestDistance] and never more than one edit for a word of three
// characters or fewer. Without that scaling, "on" and "off" sit one
// edit apart and every short word suggests every other.
//
// Candidates are considered in order, and the first of an equal-scoring
// tie wins, so a caller that passes its vocabulary sorted gets a stable
// answer.
func Nearest(input string, candidates []string) (string, bool) {
if input == "" {
return "", false
}
want := ascii.ToLower(input)
// Counted in runes, because that is the unit Distance charges in;
// byte lengths would hand a multi-byte word an allowance its length
// has not earned.
n := utf8.RuneCountInString(want)
best, score := "", -1
for _, candidate := range candidates {
if candidate == "" {
continue
}
d := Distance(want, ascii.ToLower(candidate))
if d > allowance(n, utf8.RuneCountInString(candidate)) {
continue
}
if score < 0 || d < score {
best, score = candidate, d
}
}
return best, score >= 0
}
// allowance is how far apart two words of these lengths may sit and
// still be the same word mistyped.
func allowance(a, b int) int {
n := min(a, b)
switch {
case n <= 3:
return 1
case n/3 < MaxSuggestDistance:
return n / 3
}
return MaxSuggestDistance
}
// Distance is the edit distance between two strings: the fewest
// single-character insertions, deletions, substitutions, or
// transpositions of two adjacent characters that turn one into the
// other.
//
// The transposition is why this is Damerau-Levenshtein (in its optimal
// string alignment form) rather than plain Levenshtein. Swapping two
// letters is among the most common ways to mistype a word, and plain
// Levenshtein charges two edits for it — enough to put "hepl" out of
// reach of "help" under any allowance short enough to be useful.
//
// It compares by RUNE, so an edit to a multi-byte character counts once
// rather than by its encoded width. Comparison is exact; [Nearest]
// lower-cases before calling it.
func Distance(a, b string) int {
x, y := []rune(a), []rune(b)
if len(x) == 0 {
return len(y)
}
if len(y) == 0 {
return len(x)
}
// The shorter string drives the row width, so the allocation is
// bounded by the smaller of the two. The distance is symmetric, so
// the swap costs nothing but memory.
if len(x) > len(y) {
x, y = y, x
}
// Only the two previous rows are ever read, so three rolling rows
// replace the full matrix. Two are needed rather than one because a
// transposition reaches back diagonally by two.
prev2 := make([]int, len(x)+1)
prev := make([]int, len(x)+1)
cur := make([]int, len(x)+1)
for i := range prev {
prev[i] = i
}
for j := 1; j <= len(y); j++ {
cur[0] = j
for i := 1; i <= len(x); i++ {
cost := 1
if x[i-1] == y[j-1] {
cost = 0
}
cur[i] = min(
prev[i]+1, // deletion
cur[i-1]+1, // insertion
prev[i-1]+cost, // substitution
)
// The two characters are each other's neighbours, swapped.
if i > 1 && j > 1 &&
x[i-1] == y[j-2] && x[i-2] == y[j-1] {
cur[i] = min(cur[i], prev2[i-2]+1)
}
}
prev2, prev, cur = prev, cur, prev2
}
return prev[len(x)]
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package text
import (
"strings"
"unicode"
"unicode/utf8"
)
// Ellipsis marks a string that [Truncate] shortened. It is one rune of
// three bytes, and counts against the bound like any other content.
const Ellipsis = "…"
// Truncate shortens s to at most n bytes, marking the cut with
// [Ellipsis]. A string that already fits is returned unchanged, and a
// bound too tight to hold the marker cuts without it.
//
// The result is always valid UTF-8: the cut lands on a rune boundary,
// so a multi-byte character is dropped whole rather than left half
// written. A bound of zero or less yields the empty string.
func Truncate(s string, n int) string {
if n <= 0 {
return ""
}
if len(s) <= n {
return s
}
if n < len(Ellipsis) {
return Cut(s, n)
}
return Cut(s, n-len(Ellipsis)) + Ellipsis
}
// Fit shortens s to at most n CHARACTERS, which is the bound a database
// column declared VARCHAR(n) enforces and the one a person reading the
// result would count. Surrounding white space goes first, so a value
// padded by whoever typed it is not stored padded, and any space the cut
// leaves behind goes with it.
//
// Fit marks nothing, unlike [Truncate]: the result is meant to be stored
// and shown as though it had always been that length. Use it for values
// arriving from a source that cannot be asked to correct them — an
// identity provider's display name, a search term off a query string —
// and refuse the input outright wherever the caller CAN be asked.
//
// A bound of zero or less yields the empty string.
func Fit(s string, n int) string {
if n <= 0 {
return ""
}
s = strings.TrimSpace(s)
// Ranging a string yields the byte offset of each rune's first byte,
// so the offset reached after n runes is exactly where to cut — no
// counting pass, and no []rune copy of the whole string.
count := 0
for i := range s {
if count == n {
return strings.TrimRightFunc(s[:i], unicode.IsSpace)
}
count++
}
return s
}
// Cut shortens s to at most n bytes, backing up to the start of a rune
// so the result never ends mid-character. Unlike [Truncate] it marks
// nothing: the caller wants the prefix, not the news that there was
// more.
func Cut(s string, n int) string {
if n <= 0 {
return ""
}
if len(s) <= n {
return s
}
// The bytes of a multi-byte rune after the first are continuation
// bytes; walking back off them lands on the rune's own first byte,
// which is the first byte NOT to keep.
for n > 0 && !utf8.RuneStart(s[n]) {
n--
}
return s[:n]
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package xxh
import "math/bits"
// Input constrains hashable inputs to the two shapes keys arrive in:
// raw bytes and strings. The hash reads bytes in place either way, so
// passing a string never copies it into a throwaway slice.
type Input interface{ ~[]byte | ~string }
// The XXH64 prime constants.
const (
prime1 uint64 = 0x9E3779B185EBCA87
prime2 uint64 = 0xC2B2AE3D27D4EB4F
prime3 uint64 = 0x165667B19E3779F9
prime4 uint64 = 0x85EBCA77C2B2AE63
prime5 uint64 = 0x27D4EB2F165667C5
)
// Sum64 returns the XXH64 hash of v under the given seed. Pass seed 0
// unless distinct hash families are needed; deriving further values
// from one hash with [Mix64] is cheaper than a second pass over v.
//
// The output is frozen — see the package documentation.
func Sum64[T Input](v T, seed uint64) uint64 {
// Consuming v by reslicing (rather than walking an index) lets
// the compiler prove every load in bounds and drop the checks
// from the hot loops.
n := len(v)
var h uint64
if len(v) >= 32 {
v1 := seed + prime1 + prime2
v2 := seed + prime2
v3 := seed
v4 := seed - prime1
for len(v) >= 32 {
v1 = round(v1, u64(v, 0))
v2 = round(v2, u64(v, 8))
v3 = round(v3, u64(v, 16))
v4 = round(v4, u64(v, 24))
v = v[32:]
}
h = rotl(v1, 1) + rotl(v2, 7) + rotl(v3, 12) + rotl(v4, 18)
h = fold(h, v1)
h = fold(h, v2)
h = fold(h, v3)
h = fold(h, v4)
} else {
h = seed + prime5
}
h += uint64(n)
for len(v) >= 8 {
h ^= round(0, u64(v, 0))
h = rotl(h, 27)*prime1 + prime4
v = v[8:]
}
if len(v) >= 4 {
h ^= uint64(u32(v, 0)) * prime1
h = rotl(h, 23)*prime2 + prime3
v = v[4:]
}
for i := range len(v) {
h ^= uint64(v[i]) * prime5
h = rotl(h, 11) * prime1
}
h ^= h >> 33
h *= prime2
h ^= h >> 29
h *= prime3
h ^= h >> 32
return h
}
// Reduce maps a hash uniformly onto [0, n) — the multiply-shift
// reduction, which does the work of a modulo without the division.
// Like [Sum64], the mapping is frozen: consumers persist placements
// computed from it.
//
// The reduction is fair only for well-mixed inputs such as hashes;
// feeding it small integers directly concentrates the low buckets.
func Reduce(h, n uint64) uint64 {
hi, _ := bits.Mul64(h, n)
return hi
}
// Mix64 disperses the bits of x with the SplitMix64 avalanche, so that
// a second, independent-looking value can be derived from a hash
// without another pass over the input. Like [Sum64], the output is
// frozen.
//
// Mix64 is a bijection: distinct inputs stay distinct.
func Mix64(x uint64) uint64 {
x ^= x >> 30
x *= 0xBF58476D1CE4E5B9
x ^= x >> 27
x *= 0x94D049BB133111EB
x ^= x >> 31
return x
}
// round mixes one lane of input into an accumulator.
func round(acc, in uint64) uint64 {
acc += in * prime2
acc = rotl(acc, 31)
return acc * prime1
}
// fold merges a lane accumulator into the running hash.
func fold(h, v uint64) uint64 {
h ^= round(0, v)
return h*prime1 + prime4
}
// rotl is a shorthand for [bits.RotateLeft64].
func rotl(x uint64, k int) uint64 { return bits.RotateLeft64(x, k) }
// u64 reads a little-endian uint64 at offset i. The byte-wise form
// works on strings as well as slices, and the compiler recognizes it as
// a single load on little-endian architectures.
func u64[T Input](v T, i int) uint64 {
return uint64(v[i]) | uint64(v[i+1])<<8 | uint64(v[i+2])<<16 |
uint64(v[i+3])<<24 | uint64(v[i+4])<<32 | uint64(v[i+5])<<40 |
uint64(v[i+6])<<48 | uint64(v[i+7])<<56
}
// u32 reads a little-endian uint32 at offset i.
func u32[T Input](v T, i int) uint32 {
return uint32(v[i]) | uint32(v[i+1])<<8 | uint32(v[i+2])<<16 |
uint32(v[i+3])<<24
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package app
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"slices"
"strconv"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/deep-rent/nexus/sys/log"
)
const (
// DefaultTimeout is the default duration to wait for components to return
// after a shutdown has been triggered.
DefaultTimeout = 10 * time.Second
// DefaultStartTimeout is the default duration to wait for a [Stage] to
// signal readiness before the next stage is started.
DefaultStartTimeout = 30 * time.Second
)
// Stage is a group of [Component] functions that are started concurrently. See
// [RunStages] for how stages are ordered.
type Stage []Component
// Run provides a managed execution environment for a single [Component]. It
// launches the component in a separate goroutine and blocks until it returns,
// an OS signal is caught, or the parent context is canceled. For running
// multiple components concurrently, see [RunAll].
func Run(component Component, opts ...Option) error {
return RunAll([]Component{component}, opts...)
}
// RunAll provides a managed execution environment for multiple [Component]
// functions running concurrently. It blocks until a shutdown is triggered as
// described in the package documentation, then cancels the components and
// waits for them to return.
//
// The returned error joins the errors of all components that failed. It is nil
// if every component returned nil or an error wrapping [context.Canceled]. Use
// [errors.Is] and [errors.As] to inspect it; in particular, a component that
// panicked yields a [PanicError], and a component wrapped in [Named] yields a
// [ComponentError].
//
// Use [RunStages] if the components must be started in a particular order.
func RunAll(components []Component, opts ...Option) error {
return RunStages([]Stage{Stage(components)}, opts...)
}
// RunStages provides a managed execution environment for ordered stages of
// [Component] functions. The components of a stage are started concurrently; a
// stage is started only after every component of the preceding stage has
// signalled readiness via [Ready] or has returned. If a stage does not become
// ready within the startup timeout, startup is aborted and the already running
// stages are shut down. See [WithStartTimeout].
//
// On shutdown, stages are canceled in reverse order, and each stage is fully
// drained before the preceding one is canceled. This lets infrastructure
// components such as database pools outlive the components that depend on
// them. The shutdown timeout set by [WithTimeout] applies to the entire
// sequence, not to each stage individually.
//
// Error handling matches [RunAll], which is the single-stage form of this
// function.
func RunStages(stages []Stage, opts ...Option) error {
cfg := config{
logger: log.New(),
timeout: DefaultTimeout,
start: DefaultStartTimeout,
signals: []os.Signal{syscall.SIGTERM, syscall.SIGINT},
ctx: context.Background(),
}
for _, opt := range opts {
opt(&cfg)
}
total := 0
for _, stage := range stages {
for _, c := range stage {
if c == nil {
return ErrNilComponent
}
total++
}
}
if total == 0 {
return ErrNoComponents
}
r := &runner{
cfg: cfg,
trigger: make(chan struct{}),
started: make([]*stage, 0, len(stages)),
}
r.remaining.Store(int64(total))
return r.run(stages)
}
// stage holds the runtime state of a single [Stage].
type stage struct {
cancel context.CancelFunc // stops the components of this stage
running sync.WaitGroup // components that have not returned yet
ready sync.WaitGroup // components that are not yet ready
// mu guards errs, which may still be written to by components that
// outlive the shutdown timeout.
mu sync.Mutex
errs []error
}
// runner executes stages and coordinates their shutdown.
type runner struct {
cfg config
trigger chan struct{} // closed once shutdown must begin
shutdown sync.Once // guards the closing of trigger
started []*stage // stages launched so far, in order
// remaining counts components that have not returned yet, across all
// stages, including those that have not been started.
remaining atomic.Int64
}
// fire triggers the shutdown. It is safe to call concurrently and repeatedly.
func (r *runner) fire() {
r.shutdown.Do(func() { close(r.trigger) })
}
func (r *runner) run(stages []Stage) error {
cfg := r.cfg
// Signals and parent cancellation act as shutdown triggers only. In
// particular, the signal context is not used as the parent of the
// component contexts: stopping signal delivery cancels that context, which
// would defeat the reverse-order shutdown below.
sigCtx := cfg.ctx
stopSignals := func() {}
if len(cfg.signals) > 0 {
sigCtx, stopSignals = signal.NotifyContext(cfg.ctx, cfg.signals...)
}
defer stopSignals()
go func() {
select {
case <-sigCtx.Done():
r.fire()
case <-r.trigger:
}
}()
base := context.WithValue(cfg.ctx, loggerKey{}, cfg.logger)
base = context.WithValue(base, timeoutKey{}, cfg.timeout)
cfg.logger.Info(
base,
"Application starting",
log.Int("stages", len(stages)),
log.Int("components", int(r.remaining.Load())),
)
startErr := r.start(base, stages)
<-r.trigger
// Determine the cause before stopping signal delivery, which cancels
// sigCtx as a side effect.
var reason string
switch {
case cfg.ctx.Err() != nil:
reason = "parent context canceled"
case sigCtx.Err() != nil:
reason = "signal received"
case startErr != nil:
reason = "startup failed"
default:
reason = "component exited"
}
// Restore the default signal disposition, so that a second interrupt
// terminates a process whose components refuse to stop.
stopSignals()
cfg.logger.Info(base, "Shutting down", log.String("reason", reason))
timedOut := r.stop(base)
return r.result(base, startErr, timedOut)
}
// start launches the stages in order, waiting for each one to become ready
// before starting the next. It returns a non-nil error if startup was aborted
// because a stage did not become ready in time.
func (r *runner) start(base context.Context, stages []Stage) error {
last := len(stages) - 1
for i, components := range stages {
ctx, cancel := context.WithCancel(base)
s := &stage{cancel: cancel, errs: make([]error, len(components))}
r.started = append(r.started, s)
for j, c := range components {
r.launch(ctx, s, j, c)
}
r.cfg.logger.Info(
ctx,
"Stage started",
log.Int("stage", i),
log.Int("components", len(components)),
)
// The last stage has no dependents, so nothing waits on it.
if i == last {
return nil
}
ready := make(chan struct{})
go func() {
s.ready.Wait()
close(ready)
}()
timer := time.NewTimer(r.cfg.start)
select {
case <-ready:
timer.Stop()
r.cfg.logger.Info(base, "Stage ready", log.Int("stage", i))
case <-r.trigger:
timer.Stop()
return nil
case <-timer.C:
r.fire()
return &stageError{stage: i, timeout: r.cfg.start}
}
}
return nil
}
// launch runs a single component in its own goroutine, recording its result in
// the stage and triggering a shutdown if it fails or if it is the last
// component to return.
func (r *runner) launch(
ctx context.Context,
s *stage,
index int,
c Component,
) {
s.running.Add(1)
s.ready.Add(1)
go func() {
defer s.running.Done()
// Returning implies readiness, so that one-shot components do not hold
// up the stages that follow.
signal := once(s.ready.Done)
defer signal()
err := invoke(context.WithValue(ctx, readyKey{}, signal), c)
s.mu.Lock()
s.errs[index] = err
s.mu.Unlock()
if err != nil && !errors.Is(err, context.Canceled) {
r.report(ctx, err)
r.fire()
}
if r.remaining.Add(-1) == 0 {
r.fire()
}
}()
}
// report logs a component failure as it happens, so that the cause of a
// cascading shutdown is visible before the runner returns.
func (r *runner) report(ctx context.Context, err error) {
if panicErr, ok := errors.AsType[*PanicError](err); ok {
r.cfg.logger.Error(
ctx,
"Component panicked",
log.String("panic", fmt.Sprint(panicErr.Value)),
log.String("stack", string(panicErr.Stack)),
)
return
}
r.cfg.logger.Error(ctx, "Component failed", log.Error(err))
}
// stop cancels the started stages in reverse order, draining each one before
// moving on. It reports whether the shutdown timeout elapsed.
func (r *runner) stop(ctx context.Context) bool {
timer := time.NewTimer(r.cfg.timeout)
defer timer.Stop()
for i, s := range slices.Backward(r.started) {
s.cancel()
drained := make(chan struct{})
go func() {
s.running.Wait()
close(drained)
}()
select {
case <-drained:
r.cfg.logger.Info(ctx, "Stage stopped", log.Int("stage", i))
case <-timer.C:
r.cfg.logger.Error(
ctx,
"Shutdown timed out",
log.Int("stage", i),
log.Duration("timeout", r.cfg.timeout),
)
return true
}
}
return false
}
// result joins the errors collected from all components with any startup or
// shutdown failure.
func (r *runner) result(
ctx context.Context,
startErr error,
timedOut bool,
) error {
errs := make([]error, 0, len(r.started)+2)
if timedOut {
errs = append(errs, ErrShutdownTimeout)
}
if startErr != nil {
errs = append(errs, startErr)
}
for _, s := range r.started {
s.mu.Lock()
for _, err := range s.errs {
// Cancellation is the expected outcome of a shutdown, not a
// failure worth reporting.
if err != nil && !errors.Is(err, context.Canceled) {
errs = append(errs, err)
}
}
s.mu.Unlock()
}
if err := errors.Join(errs...); err != nil {
return err
}
r.cfg.logger.Info(ctx, "Shutdown complete")
return nil
}
// stageError reports a stage that did not become ready in time.
type stageError struct {
stage int
timeout time.Duration
}
// Error implements the [error] interface.
func (e *stageError) Error() string {
return ErrStartTimeout.Error() +
" after " + e.timeout.String() +
": stage " + strconv.Itoa(e.stage)
}
// Unwrap allows matching against [ErrStartTimeout].
func (*stageError) Unwrap() error { return ErrStartTimeout }
var _ error = (*stageError)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package app
import (
"context"
"errors"
"runtime/debug"
"sync"
"time"
"github.com/deep-rent/nexus/sys/log"
)
// Component defines a function that can be executed by the application runner.
// It receives a [context.Context] that is canceled once the application starts
// to shut down. The function is expected to return promptly after its context
// is done.
//
// A component that returns nil is considered to have completed its work. It
// does not cause the application to shut down; the remaining components keep
// running. A component that returns a non-nil error, or panics, triggers a
// graceful shutdown of the entire application.
type Component func(ctx context.Context) error
// Context keys for component-scoped values.
type (
loggerKey struct{}
timeoutKey struct{}
readyKey struct{}
)
// Logger returns the [log.Logger] configured on the runner via [WithLogger].
// It returns [log.Discard] if ctx does not originate from a runner, so
// components stay silent outside a managed run. Prefer this over a
// package-level logger so that component output is correlated with the
// application lifecycle. See also [Named].
func Logger(ctx context.Context) *log.Logger {
if logger, ok := ctx.Value(loggerKey{}).(*log.Logger); ok && logger != nil {
return logger
}
return log.Discard()
}
// ShutdownTimeout returns the graceful shutdown budget configured on the
// runner via [WithTimeout]. It returns [DefaultTimeout] if ctx does not
// originate from a runner.
//
// Components that need to perform blocking cleanup after their context is
// canceled should bound it by this duration, typically by deriving a fresh
// context via [context.WithoutCancel]. See [Graceful], which does this for
// you.
func ShutdownTimeout(ctx context.Context) time.Duration {
if d, ok := ctx.Value(timeoutKey{}).(time.Duration); ok && d > 0 {
return d
}
return DefaultTimeout
}
// Ready signals that the [Component] associated with ctx has finished its
// startup work and that dependent components may now be started. It is safe to
// call Ready multiple times, from multiple goroutines, or on a context that
// does not originate from a runner, in which case it does nothing.
//
// Ready is only meaningful for components running in a non-final [Stage]; see
// [RunStages]. Every component in such a stage must eventually call Ready or
// return, otherwise startup fails with [ErrStartTimeout]. Returning implies
// readiness, so one-shot components such as schema migrations need not call
// Ready explicitly.
func Ready(ctx context.Context) {
if signal, ok := ctx.Value(readyKey{}).(func()); ok {
signal()
}
}
// Named returns a [Component] that behaves like c, but attributes any error or
// panic it produces to name via [ComponentError], and scopes the logger
// returned by [Logger] with a "component" attribute. Naming components is
// recommended: without it, an error surfacing from a multi-component
// application carries no indication of its origin.
func Named(name string, c Component) Component {
if c == nil {
panic("Named requires a non-nil component")
}
return func(ctx context.Context) error {
logger := Logger(ctx).With(log.String("component", name))
ctx = context.WithValue(ctx, loggerKey{}, logger)
if err := invoke(ctx, c); err != nil {
return &ComponentError{Name: name, Err: err}
}
return nil
}
}
// Graceful returns a [Component] that runs a startup callback and, once the
// component's context is canceled, invokes a shutdown callback to release
// resources.
//
// Unlike the context handed to the startup callback, the context handed to
// the shutdown callback is not canceled; it carries a fresh deadline derived
// from [ShutdownTimeout]. This makes Graceful the natural adapter for
// resources whose teardown is itself context-aware, such as
// [net/http.Server.Shutdown] or a database pool drain.
//
// If the startup callback returns before the context is canceled, the
// shutdown callback is not invoked. Server implementations that report their
// own closure as an error should normalize it, for example:
//
// app.Graceful(
// func(ctx context.Context) error {
// app.Ready(ctx)
// err := srv.ListenAndServe()
// if errors.Is(err, http.ErrServerClosed) {
// return nil
// }
// return err
// },
// srv.Shutdown,
// )
//
// Graceful panics if either callback is nil.
func Graceful(start Component, stop func(ctx context.Context) error) Component {
if start == nil || stop == nil {
panic("start and stop functions must be provided")
}
return func(ctx context.Context) error {
errCh := make(chan error, 1)
go func() { errCh <- invoke(ctx, start) }()
select {
case err := <-errCh:
// The component finished on its own; there is nothing to stop.
return err
case <-ctx.Done():
}
// Detach from the canceled context so that stop can still perform
// blocking work, but bound it by the shutdown budget.
stopCtx, cancel := context.WithTimeout(
context.WithoutCancel(ctx),
ShutdownTimeout(ctx),
)
defer cancel()
stopErr := invoke(stopCtx, stop)
var startErr error
select {
case startErr = <-errCh:
case <-stopCtx.Done():
startErr = errors.Join(
ErrShutdownTimeout,
errors.New("start did not return after stop"),
)
}
return errors.Join(startErr, stopErr)
}
}
// Sequence returns a [Component] that invokes the given components one after
// another, stopping at the first error. It is intended for ordered one-shot
// work, such as running migrations before seeding data. Long-running
// components should be ordered with [RunStages] instead, since they never
// return on their own.
func Sequence(components ...Component) Component {
return func(ctx context.Context) error {
for _, c := range components {
if c == nil {
return ErrNilComponent
}
if err := invoke(ctx, c); err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
}
return nil
}
}
// invoke runs the given component, converting a panic into a [PanicError].
// Nested calls are harmless: the innermost recovery wins, which keeps the
// stack trace close to the origin of the panic.
func invoke(ctx context.Context, c Component) (err error) {
defer func() {
if r := recover(); r != nil {
err = &PanicError{Value: r, Stack: debug.Stack()}
}
}()
return c(ctx)
}
// once returns a function that runs the given function at most once. It is
// used to build the idempotent readiness signal handed to each component.
func once(fn func()) func() {
var o sync.Once
return func() { o.Do(fn) }
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package app
import (
"errors"
"fmt"
"strconv"
)
var (
// ErrShutdownTimeout indicates that one or more [Component] functions did
// not return within the configured shutdown timeout. See [WithTimeout].
ErrShutdownTimeout = errors.New("shutdown timed out")
// ErrStartTimeout indicates that a [Stage] did not signal readiness within
// the configured startup timeout. See [Ready] and [WithStartTimeout].
ErrStartTimeout = errors.New("startup timed out")
// ErrNoComponents indicates that no [Component] was passed to the runner.
ErrNoComponents = errors.New("no components to run")
// ErrNilComponent indicates that a nil [Component] was passed to the
// runner.
ErrNilComponent = errors.New("nil component")
)
// PanicError wraps a value recovered from a panicking [Component]. The runner
// converts panics into errors so that a single misbehaving component cannot
// bring down the process without the remaining components being shut down
// gracefully.
type PanicError struct {
// Value is the value passed to panic.
Value any
// Stack is the stack trace captured at the point of recovery.
Stack []byte
}
// Error implements the [error] interface.
func (e *PanicError) Error() string {
return fmt.Sprintf("panic: %v", e.Value)
}
// Unwrap returns the recovered value if it is itself an error, allowing
// [errors.Is] and [errors.As] to inspect the original cause. It returns nil
// otherwise.
func (e *PanicError) Unwrap() error {
err, _ := e.Value.(error)
return err
}
// ComponentError attributes an error to a named [Component]. It is produced by
// components wrapped in [Named].
type ComponentError struct {
// Name is the name given to the component via [Named].
Name string
// Err is the error returned by the component.
Err error
}
// Error implements the [error] interface.
func (e *ComponentError) Error() string {
return "component " + strconv.Quote(e.Name) + ": " + e.Err.Error()
}
// Unwrap returns the underlying error.
func (e *ComponentError) Unwrap() error { return e.Err }
var _ error = (*ComponentError)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package app
import (
"context"
"os"
"slices"
"time"
"github.com/deep-rent/nexus/sys/log"
)
// config holds the internal settings for the application runner, including
// logging, timeouts, signal handling, and parent context.
type config struct {
logger *log.Logger
timeout time.Duration
start time.Duration
signals []os.Signal
ctx context.Context
}
// Option is a function that configures the application runner [config].
type Option func(*config)
// WithLogger provides a custom [log.Logger] for the application runner. It is
// also made available to components via [Logger]. If not set, the runner
// defaults to a logger created by [log.New]. A nil value will be ignored.
func WithLogger(logger *log.Logger) Option {
return func(c *config) {
if logger != nil {
c.logger = logger
}
}
}
// WithTimeout sets the total duration granted to the shutdown process. If the
// components take longer than this to return, the runner gives up waiting and
// returns an error wrapping [ErrShutdownTimeout]. The same duration is
// reported to components by [ShutdownTimeout]. A negative or zero duration
// will be ignored, and [DefaultTimeout] is used instead.
//
// Note that the runner cannot forcibly terminate a component that ignores its
// context. On timeout it returns while those goroutines are still running,
// under the assumption that the caller exits the process shortly after.
func WithTimeout(d time.Duration) Option {
return func(c *config) {
if d > 0 {
c.timeout = d
}
}
}
// WithStartTimeout sets the duration to wait for a [Stage] to signal readiness
// before the next stage is started. If the stage does not become ready in
// time, startup is aborted with an error wrapping [ErrStartTimeout]. A
// negative or zero duration will be ignored, and [DefaultStartTimeout] is used
// instead. This setting has no effect unless [RunStages] is used with more
// than one stage.
func WithStartTimeout(d time.Duration) Option {
return func(c *config) {
if d > 0 {
c.start = d
}
}
}
// WithSignals selects the [os.Signal] values that trigger a shutdown,
// replacing the default of [syscall.SIGTERM] and [syscall.SIGINT]. Passing no
// signals disables signal handling entirely, which is useful when the runner
// is embedded in a process that traps signals itself.
func WithSignals(signals ...os.Signal) Option {
return func(c *config) {
c.signals = slices.Clone(signals)
}
}
// WithContext sets a parent [context.Context] for the runner. Cancelling it
// triggers a graceful shutdown. If not set, [context.Background] is used as
// the default parent. A nil value will be ignored.
//
// Because component contexts derive from this parent, cancelling it cancels
// every component at once. Ordered, reverse-stage shutdown as described in
// [RunStages] therefore only applies to shutdowns triggered by a signal or by
// a component.
func WithContext(ctx context.Context) Option {
return func(c *config) {
if ctx != nil {
c.ctx = ctx
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package boot
import (
"database/sql"
"net/http"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/deep-rent/nexus/net/header"
"github.com/deep-rent/nexus/net/notify/hook"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/sec/auth"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/queue"
)
// Name returns the service name declared by [Spec.Name].
func (r *Runtime) Name() string { return r.name }
// Logger returns the service's root logger. Child loggers name the
// subsystem they belong to:
//
// store := ledger.New(rt.Pool(), ledger.WithLogger(
// rt.Logger().Child("store"),
// ))
func (r *Runtime) Logger() *log.Logger { return r.logger }
// Client returns the shared outbound HTTP client. Every integration
// should use it rather than one of its own, so they share a connection
// pool, bounded response reads, and the User-Agent naming this
// deployment to its peers.
//
// The one thing that should stay apart is a client authenticating with
// a credential of its own, such as a mutual-TLS scrape identity: that
// one must not share transport state with anything else.
func (r *Runtime) Client() *http.Client { return r.client }
// Agent returns the User-Agent header the shared client carries. A
// client that must stay apart — one presenting a mutual-TLS credential
// of its own — builds its transport around this, so every request the
// service makes still names the same deployment:
//
// transport.New(
// transport.WithHeader(rt.Agent()),
// transport.WithTLSConfig(cfg),
// )
func (r *Runtime) Agent() header.Header { return r.agent }
// Pool returns the native connection pool, or nil when the service
// declared no database. A service with an in-memory fallback reads the
// absence back here; one that declared a database always gets a pool,
// since [New] refuses an empty URL.
func (r *Runtime) Pool() *pgxpool.Pool { return r.pool }
// DB returns the [database/sql] view of the same pool, for the
// consumers that speak that interface — schema migrators and the
// readiness probe. It is nil exactly when [Runtime.Pool] is.
func (r *Runtime) DB() *sql.DB { return r.db }
// Guard returns the token guard verifying the identity provider's
// access tokens, or nil when the service declared no [Auth] section.
// Handlers mount behind it:
//
// api.Mount(rt.Router(), rt.Guard().Secure(rule))
func (r *Runtime) Guard() *auth.Guard { return r.guard }
// Router returns the public router, with the middleware baseline and
// the body cap already installed. It is what the service mounts its
// own API onto.
func (r *Runtime) Router() *router.Router { return r.router }
// Ops returns the operational router carrying the health probes and
// the metrics endpoint, served on a listener the ingress never routes.
// A service with an operational surface of its own — a live summary, a
// debug dump — mounts it here rather than on the public router.
func (r *Runtime) Ops() *router.Router { return r.ops }
// Handler returns the public router as an [http.Handler], for
// embedding the API into a server of the caller's own.
func (r *Runtime) Handler() http.Handler { return r.router }
// Jobs returns the durable job queue, or nil when the service declared
// no database. Work pushed onto it survives a restart and is run by
// the fleet [Runtime.Run] starts.
//
// Register the kind with [Runtime.Handle] before pushing it. The
// queue's schema is applied only for a service that handles jobs or
// sends webhooks, since one that does neither would carry two tables it
// never reads; pushing without a handler therefore fails on the missing
// table, which is the same defect either way — nothing would have run
// the job.
func (r *Runtime) Jobs() *queue.Queue[pgx.Tx] { return r.jobs }
// Hooks returns the outbound webhook engine, or nil when the service
// declared no [Sender] section, disabled it, or runs without a
// database. Domain engines publish through it, and the management API
// mounts over it:
//
// if h := rt.Hooks(); h != nil {
// opts = append(opts, desk.WithPublisher(h))
// }
func (r *Runtime) Hooks() *hook.Engine[pgx.Tx] { return r.hooks }
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package boot
import (
"context"
"fmt"
"github.com/deep-rent/nexus/dat/migrate"
"github.com/deep-rent/nexus/sys/cli"
)
// Opener connects a schema migrator to a database URL. Every module's
// Open function in this repository already has this shape, so wiring
// the command group is naming it; see [Migrations].
type Opener func(url string, opts ...migrate.Option) (
m *migrate.Migrator,
close func() error,
err error,
)
// Migrations builds the "migrate" command group a service binary hangs
// under its root command:
//
// Commands: []*cli.Command{
// {Name: "serve", Short: "serve the ledger", Run: serve},
// boot.Migrations(config.Prefix, ledger.Open),
// cli.ShowVersion(cli.Version(version)),
// },
//
// The connection is read from the [Database] section under prefix and
// opened per invocation, so printing help touches no database and the
// rest of the service's configuration need not be present to migrate.
// An unset URL is reported by the variable's own name, which is what
// an operator migrating out of band needs to know.
func Migrations(prefix string, open Opener) *cli.Command {
return migrate.Command(func(_ context.Context) (
*migrate.Migrator, func() error, error,
) {
cfg, err := Load[Database](prefix + "DATABASE_")
if err != nil {
return nil, nil, fmt.Errorf("configuration issue: %w", err)
}
if !cfg.Enabled() {
return nil, nil, fmt.Errorf(
"%sDATABASE_URL is not set", prefix,
)
}
return open(cfg.URL)
})
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package boot
import (
"strings"
"time"
"github.com/deep-rent/nexus/sec/seal"
"github.com/deep-rent/nexus/sys/env"
"github.com/deep-rent/nexus/sys/log"
)
// Core is the configuration every service carries, whatever it does:
// where it listens, how loudly it logs, and how long a connection may
// occupy it. Embed it into a service configuration to inherit the
// variable names every deployment already knows:
//
// type Config struct {
// boot.Core `env:",inline"`
// Database boot.Database `env:",prefix:DATABASE_"`
// // ... the service's own sections
// }
//
// The inline tag flattens the section into the enclosing namespace, so
// the listen address stays SVC_ADDR rather than moving under a section
// of its own.
type Core struct {
// Addr is the listen address of the HTTP server carrying the
// service's API.
Addr string `env:",default:':8080'"`
// MonitorAddr is the listen address of the operational server,
// which carries the health probes and the service's own metrics.
//
// They sit on a listener of their own so that a deployment can keep
// them off the public one entirely: route only [Core.Addr] through
// the ingress and let the platform scrape this port directly. The
// endpoints expose no credentials, but they do expose a live view
// of the service, which is not something to publish.
MonitorAddr string `env:",default:':9090'"`
// AllowedOrigins enables CORS for the given origins, for frontends
// served from a different origin than the service. Empty mounts no
// CORS middleware at all.
AllowedOrigins []string
// LogLevel is the minimum severity written to the log. Debug
// additionally emits one access log line per HTTP request.
LogLevel log.Level `env:",default:info"`
// Timeouts bound how long a connection may occupy the server.
Timeouts Timeouts `env:",prefix:TIMEOUT_"`
}
// Timeouts bounds how long a single connection may occupy the HTTP
// server. Every phase of a connection's life is bounded, so that no
// client — slow, stalled, or hostile — can hold one open indefinitely.
// Zero disables a timeout; never do that on a public listener.
type Timeouts struct {
// ReadHeader bounds reading the request headers, from the moment
// the connection is accepted. It is the timeout that matters most
// on a public listener: without it, a client dribbling headers
// holds a connection open indefinitely (the Slowloris attack).
ReadHeader time.Duration `env:",default:5s"`
// Read bounds reading the entire request, headers and body. It is
// the body's counterpart to ReadHeader — that one having elapsed
// says nothing about a client that then dribbles the body.
Read time.Duration `env:",default:30s"`
// Write bounds writing the response, and with it how long a
// handler may take. It also sets the shutdown budget; see
// [ShutdownMargin].
Write time.Duration `env:",default:30s"`
// Idle bounds how long a kept-alive connection may sit quiet
// between requests before the server closes it.
Idle time.Duration `env:",default:2m"`
}
// Database configures the PostgreSQL connection a service runs on.
//
// The URL carries no required tag: whether a deployment may run
// without a database is the service's judgment, not the section's. A
// service that cannot passes the section to [Spec] unconditionally and
// lets [New] refuse an empty URL; one with an in-memory fallback
// passes nil instead. See [Spec.Database].
type Database struct {
// URL is the connection string, including any pool_* parameters.
URL string
}
// Enabled reports whether a database is configured.
func (c Database) Enabled() bool { return c.URL != "" }
// Auth declares the identity provider whose access tokens a service
// verifies. Services that issue no tokens of their own — every service
// but the identity provider itself — bind this section and hand it to
// [Spec.Auth], which is what builds their guard.
//
// A service needing more than this (a role name, a scope vocabulary)
// embeds the section and adds its own fields:
//
// type Auth struct {
// boot.Auth `env:",inline"`
// StaffRole string `env:",default:support"`
// }
type Auth struct {
// Issuer is the identity provider's issuer URL, as stamped into
// its tokens.
Issuer string `env:",required"`
// JWKSURL is the provider's signing key endpoint (bound from
// AUTH_JWKS_URL). Empty derives "<issuer>/jwks.json" — the
// location the sibling IAM service serves its key set at and
// advertises in its discovery document.
JWKSURL string `env:"JWKS_URL"`
// Audience optionally restricts accepted tokens to those naming
// one of the given audiences.
Audience []string
}
// Keys returns the JWKS endpoint, deriving the sibling IAM service's
// location from the issuer when none is configured.
func (c Auth) Keys() string {
if c.JWKSURL != "" {
return c.JWKSURL
}
return strings.TrimSuffix(c.Issuer, "/") + "/jwks.json"
}
// Keys is the sealing keys a section encrypts its secrets at rest
// under: one key that seals, and any number of retired ones that still
// open, so a rotation can overlap.
//
// It is embedded rather than repeated. Four sections in this repository
// declared these three fields with the same tags and the same Keyring
// body, and a fifth spelled the same idea as a flat list of "id:base64"
// pairs — so an operator deploying two services met two different
// shapes for one concept:
//
// type Tokens struct {
// boot.Keys `env:",inline"`
// }
//
// The inline tag flattens the fields into the enclosing section, so a
// deployment sets NDS_TOKEN_KEY rather than moving them under a
// namespace of their own.
//
// # Rotation
//
// Mint a new key, make it primary, and keep the displaced one in
// RetiredKeys until everything sealed under it has been rewritten. A
// sealed value names the key that produced it, so nothing needs a
// migration to know which to try; see [seal.Keyring].
type Keys struct {
// Key seals the secrets at rest, base64-encoded. Empty stores them
// as they are, which is fine for a test rig and wrong for a
// deployment — what a leak then costs is the section's to say.
Key string
// KeyID names the key inside sealed values, so a rotation can tell
// them apart.
KeyID string `env:",default:primary"`
// RetiredKeys are previously used sealing keys, as "id:base64"
// pairs, kept so values sealed under them still open.
RetiredKeys []string
}
// Sealed reports whether the secrets are encrypted at rest.
func (c Keys) Sealed() bool { return c.Key != "" }
// Keyring builds the keyring sealing the secrets. It is only meaningful
// while [Keys.Sealed] holds.
func (c Keys) Keyring() (*seal.Keyring, error) {
return seal.ParseKeyring(c.KeyID, c.Key, c.RetiredKeys)
}
// Sender configures the outbound webhook engine: the machinery that
// carries a service's own events to the endpoints subscribed to them.
// It is the counterpart of [Intake], which accepts the deliveries
// somebody else sent. The two directions bind under prefixes of their
// own — conventionally HOOK_ for what a service sends and INTAKE_ for
// what it hears — so a service doing both keeps them apart.
//
// Endpoints are registered through the management API rather than
// here, since they come and go with the services that subscribe.
type Sender struct {
// Disabled switches the webhook system off entirely: no deliveries
// are queued, dispatched, or retained. A deployment nobody
// subscribes to wants this rather than a queue that fills with
// fan-out to no one.
Disabled bool
// Retention is how long published events and settled delivery jobs
// are kept before pruning. Events are read back at every delivery
// attempt, so the window must outlast the retry schedule; values
// below [RetentionFloor] are floored rather than stranding live
// retries. Zero keeps the engine's default of thirty days.
Retention time.Duration
// SuspendAfter is the unbroken failure streak after which an
// endpoint is suspended. Zero keeps the engine's default.
SuspendAfter time.Duration
// Retries is the retry budget beyond the initial attempt. Negative
// keeps the engine's default; zero means one attempt and no retry.
Retries int `env:",default:-1"`
// Insecure admits plain-http endpoint URLs and disables the
// private-address dial guard. It is for test rigs delivering to a
// local listener, and for nothing else: with it set, an endpoint
// can be pointed at anything the service can reach.
Insecure bool
// InternalHosts limits which hosts an endpoint registered as
// internal may name. Such an endpoint bypasses the address guard,
// so an empty list makes the permission to register one the
// permission to reach anything the service can — name the
// in-cluster subscribers instead.
InternalHosts []string
// EndpointConcurrency bounds how many deliveries one replica has
// in flight to a single endpoint, so a subscriber working through
// a backlog does not crowd out the rest. Zero keeps the engine's
// default.
EndpointConcurrency int
// Keys seal the signing secrets at rest. Leaving them empty stores
// the secrets as they are, which is wrong for a deployment: a
// database backup would then carry the means to forge deliveries
// to every subscriber.
Keys `env:",inline"`
}
// Enabled reports whether the webhook engine should run.
func (c Sender) Enabled() bool { return !c.Disabled }
// Intake configures the inbound webhook receiver: which senders this
// service accepts deliveries from. It is the counterpart of [Sender],
// and [Runtime.Receiver] turns it into the machinery.
type Intake struct {
// Secrets are the accepted signing secrets ("whsec_..."), each
// validated at startup. Several may be listed so a rotation can
// overlap: rotate at the SENDER first — deliveries then carry
// signatures from both the fresh and the displaced secret through
// its grace window, so the old one here keeps verifying — then add
// the newly shown secret beside it and deploy within that window,
// and drop the old entry at leisure. Empty leaves the receiver
// unmounted, and the service hears nothing.
Secrets []string
// Tolerance overrides how far a delivery's timestamp may lie from
// now before it is refused as a replay. Zero applies the webhook
// system's default of five minutes.
Tolerance time.Duration
}
// Enabled reports whether the receiver is configured.
func (c Intake) Enabled() bool { return len(c.Secrets) > 0 }
// Load binds a configuration of any shape from the environment under
// prefix, reporting every binding problem at once. It is the one-line
// body of a service's own Load function:
//
// func Load(opts ...env.Option) (Config, error) {
// return boot.Load[Config](Prefix, opts...)
// }
//
// The same function binds a single section, for commands that touch
// one subsystem without the rest of the service being configured:
//
// db, err := boot.Load[boot.Database](Prefix + "DATABASE_")
func Load[T any](prefix string, opts ...env.Option) (T, error) {
var cfg T
err := env.Unmarshal(
&cfg,
append([]env.Option{env.WithPrefix(prefix)}, opts...)...,
)
return cfg, err
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package boot
import (
"time"
)
// The defaults the runtime applies when an option leaves a knob alone.
const (
// DefaultMaxBodySize caps a request body at 64 KiB. It suits an API
// whose largest legitimate body is a JSON document of a few short
// fields; services accepting anything bulkier raise it with
// [WithMaxBody] and say in the constant's comment what makes their
// bodies big.
DefaultMaxBodySize = 64 << 10
// MaxHeaderBytes caps the request head at 64 KiB, well below the
// standard library's 1 MiB default. The largest header a client
// sends is an Authorization bearer token; 64 KiB accommodates that
// many times over while bounding the memory an idle connection can
// hold. It is not configurable: no deployment has a reason to
// differ.
MaxHeaderBytes = 64 << 10
// DefaultWorkers is how many queued jobs one replica runs at once.
DefaultWorkers = 4
// DefaultKeyWait bounds the wait for the identity provider's
// signing keys at startup. Exceeding it degrades rather than fails:
// the service starts and the scheduled refresh keeps trying.
DefaultKeyWait = 15 * time.Second
// ShutdownMargin is what the shutdown budget adds on top of
// [Timeouts.Write], covering the stages that drain behind the
// server once it has stopped serving.
//
// The budget is derived from the write timeout rather than fixed: a
// drain shorter than the longest response the server permits would
// sever requests the write timeout still considers valid, and
// deriving it keeps the two from diverging when a deployment raises
// the timeout.
ShutdownMargin = 10 * time.Second
// RetentionFloor is the least webhook event retention the runtime
// will run with. The default delivery retry schedule spans roughly
// a day, and an event pruned mid-schedule strands the deliveries
// still reading it back, so a configured window below this is
// floored rather than obeyed.
RetentionFloor = 24 * time.Hour
)
// Intervals the runtime schedules its own upkeep at. They are
// deliberately not configuration: each is a property of the machinery
// rather than of the deployment around it.
const (
// DatabaseProbeInterval is how often the readiness probe pings the
// database. It is frequent enough that a lost pool surfaces within
// one probe cycle, and cheap enough to be irrelevant to load.
DatabaseProbeInterval = 5 * time.Second
// PoolSampleInterval is how often the connection pool's statistics
// are sampled into the metrics registry. The registry is pull-based
// with no scrape-time hook, so gauges hold the last sample; fifteen
// seconds keeps them fresher than any sane scrape interval at
// negligible cost.
PoolSampleInterval = 15 * time.Second
// BacklogInterval is how often the job queue's depth gauges are
// sampled. A backlog that climbs and stays up means the workers are
// not keeping pace.
BacklogInterval = 30 * time.Second
// RetentionInterval is how often settled deliveries, spent events,
// and finished jobs are pruned.
RetentionInterval = time.Hour
// StartJitter spreads the first run of each scheduled job over a
// tenth of its interval, so several replicas starting together do
// not sweep in lockstep.
StartJitter = 0.1
)
// The log keys redacted in every service. The list covers the places a
// credential travels by convention rather than by accident; a service
// carrying more adds them with [WithRedact].
var redacted = []string{"authorization", "cookie", "token"}
// Option adjusts the assembly beyond what the environment declares.
// Options exist only where the audited services genuinely differ: a
// knob nobody turns is a knob that drifts.
type Option func(*options)
type options struct {
maxBody int64
credentials bool
workers int
keyWait time.Duration
redact []string
}
func defaults() options {
return options{
maxBody: DefaultMaxBodySize,
workers: DefaultWorkers,
keyWait: DefaultKeyWait,
redact: redacted,
}
}
// WithMaxBody caps a request body on the public router, replacing
// [DefaultMaxBodySize]. Raise it only for a service whose payloads are
// genuinely larger — a sync push, a provider notification carrying a
// certificate chain — and pin the reason to a named constant. A
// negative or zero size is ignored.
func WithMaxBody(bytes int64) Option {
return func(o *options) {
if bytes > 0 {
o.maxBody = bytes
}
}
}
// WithCredentialedCORS lets cross-origin requests carry cookies. It is
// for the one service whose browser clients hold a session: everywhere
// else authentication travels in the Authorization header, which
// cross-origin JavaScript attaches itself, so the wider browser trust
// grant would buy nothing and widen the blast radius of a permissive
// origin list.
func WithCredentialedCORS() Option {
return func(o *options) { o.credentials = true }
}
// WithWorkers sets how many queued jobs one replica runs at once,
// replacing [DefaultWorkers]. Raise it for a service whose jobs are
// slow IO rather than work — mail, deliveries to somebody else's
// server. A negative or zero count is ignored.
func WithWorkers(n int) Option {
return func(o *options) {
if n > 0 {
o.workers = n
}
}
}
// WithKeyWait bounds the wait for the identity provider's signing keys
// at startup, replacing [DefaultKeyWait]. Zero skips the wait: a rig
// running against a placeholder issuer would otherwise stall for the
// whole timeout on every start, and the scheduled refresh keeps trying
// either way. A negative duration is ignored.
func WithKeyWait(d time.Duration) Option {
return func(o *options) {
if d >= 0 {
o.keyWait = d
}
}
}
// WithRedact adds log keys whose values are masked, beyond the
// authorization header, cookies, and tokens every service redacts. A
// service handling credentials of its own — passwords, one-time codes,
// signing secrets — names them here.
func WithRedact(keys ...string) Option {
return func(o *options) {
o.redact = append(append([]string{}, o.redact...), keys...)
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package boot
import (
"context"
"time"
"github.com/deep-rent/nexus/net/notify/hook"
"github.com/deep-rent/nexus/sys/app"
"github.com/deep-rent/nexus/sys/health"
"github.com/deep-rent/nexus/sys/queue"
"github.com/deep-rent/nexus/sys/schedule"
)
// Migrate registers schema streams applied at startup, before anything
// else runs, in the order given. Every module's Migrator function has
// the shape [Migrator] asks for, so registering a stream is naming it:
//
// rt.Migrate(ledger.Migrator)
//
// The runtime appends the streams of the subsystems it owns itself —
// the job queue and the webhook registry — after these, so a service
// never mounts them by hand. Registering anything is pointless without
// a database; the streams are ignored when there is no pool.
func (r *Runtime) Migrate(streams ...Migrator) {
for _, m := range streams {
if m != nil {
r.migrators = append(r.migrators, m)
}
}
}
// Probe attaches a health check run every interval. The database, when
// there is one, is probed already; this is for the dependencies a
// service adds.
//
// Reach for it only when the verdict should change what the platform
// does: a readiness check drains traffic, a liveness check restarts the
// process. A dependency the service degrades gracefully without — an
// object store behind an optional feature — belongs in a gauge and the
// log instead, which is what [Runtime.Every] is for.
func (r *Runtime) Probe(
name string,
interval time.Duration,
fn health.CheckFunc,
opts ...health.Option,
) {
r.monitor.Attach(name, interval, fn, opts...)
}
// Every registers a task run on the given cadence for as long as the
// service serves. The name is scoped to the service, so "sweep" is
// reported and logged as "pes.sweep".
//
// The first run is jittered by [StartJitter], so replicas starting
// together do not sweep in lockstep. The runtime schedules its own
// upkeep the same way — pool statistics, queue depth, retention — so a
// service registers only what belongs to its domain.
func (r *Runtime) Every(
name string,
interval time.Duration,
task schedule.Task,
) {
r.Tick(name, schedule.Every(interval, task))
}
// Tick registers a job that decides its own next run, for the cadences
// a fixed interval cannot express — a key cache refreshing when its
// documents expire, a backoff that widens after a failure. The name is
// scoped like [Runtime.Every]'s.
func (r *Runtime) Tick(name string, tick schedule.Tick) {
if tick != nil {
r.ticks = append(r.ticks, schedule.Named(r.name+"."+name, tick))
}
}
// Await holds startup until the given channel closes, bounded by the
// key wait ([WithKeyWait]); exceeding it warns rather than failing.
//
// It is for the caches whose first fetch decides whether early requests
// succeed — a social provider's signing keys above all. The identity
// provider's own key set is waited on already, when the service
// declares an [Auth] section.
func (r *Runtime) Await(name string, ready <-chan struct{}) {
if ready != nil {
r.waits = append(r.waits, wait{name: name, ready: ready})
}
}
// Once registers one-shot startup work, run after the schema is
// migrated and before anything else starts. Nothing the service
// registers elsewhere — no scheduled job, no queue handler, no
// listener — runs until every one-shot has returned, so this is where
// work belongs that the rest depends on: provisioning an operator to
// act through, materializing the partitions the first sweep writes
// into, warming an engine before its input arrives.
//
// An error aborts startup. One-shots registered together run
// concurrently; register a single function if they must be ordered
// among themselves.
func (r *Runtime) Once(name string, fn func(ctx context.Context) error) {
if fn != nil {
r.oneshots = append(r.oneshots, app.Named(name, fn))
}
}
// Background registers a component running for the lifetime of the
// service, alongside the runtime's own scheduler. Use it for the long-lived
// machinery a service owns that is neither a scheduled job nor a queue
// handler — an event broker's consumers, a subscription held open.
//
// The component must signal readiness with [app.Ready] once it is up,
// and return promptly once its context is canceled. Returning nil
// means done, not fatal; returning an error shuts the service down.
func (r *Runtime) Background(name string, c app.Component) {
if c != nil {
r.components = append(r.components, app.Named(name, c))
}
}
// Receiver builds a webhook intake over the accepted secrets, carrying
// the runtime's logger. The service subscribes to its topics and mounts
// it on a path of its own:
//
// rcv, err := rt.Receiver(cfg.Intake)
// if err != nil {
// return nil, err
// }
// rcv.On(TopicUserDeleted, s.forget).Mount(rt.Router(), "/hooks/iam")
//
// A malformed secret fails here rather than at runtime, where it would
// be indistinguishable from a forged signature. A deployment that
// accepts none should skip the call rather than mount a receiver that
// refuses everything; see [Intake.Enabled].
func (r *Runtime) Receiver(cfg Intake) (*hook.Receiver, error) {
return hook.NewReceiver(hook.ReceiverConfig{
Secrets: cfg.Secrets,
Tolerance: cfg.Tolerance,
Logger: r.logger.Child("hook"),
})
}
// Handle registers a handler for one kind of queued job, run by the
// fleet the runtime starts. The webhook engine registers its own
// deliveries; this is for the work a service queues itself:
//
// rt.Handle(desk.NotifyKind, s.mailer.Handle,
// queue.HandlerTimeout(NotifyTimeout),
// queue.HandlerRetries(NotifyRetries),
// )
//
// The fleet's size is shared across kinds; see [WithWorkers]. A
// service without a database queues nothing, and registrations are
// ignored.
func (r *Runtime) Handle(
kind string,
fn queue.Handler,
opts ...queue.HandlerOption,
) {
if fn != nil {
r.handlers = append(r.handlers, handler{
kind: kind,
fn: fn,
opts: opts,
})
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package boot
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"strings"
"time"
"github.com/deep-rent/nexus/dat/migrate"
hookpg "github.com/deep-rent/nexus/net/notify/hook/driver/postgres"
"github.com/deep-rent/nexus/sys/app"
"github.com/deep-rent/nexus/sys/log"
metricspg "github.com/deep-rent/nexus/sys/metrics/source/postgres"
"github.com/deep-rent/nexus/sys/queue"
queuepg "github.com/deep-rent/nexus/sys/queue/driver/postgres"
"github.com/deep-rent/nexus/sys/schedule"
)
// Run serves the service until ctx is canceled or a termination signal
// arrives. It returns once every component has stopped, or once the
// shutdown budget derived from [Timeouts.Write] and [ShutdownMargin]
// has elapsed.
//
// The components run in ordered stages, so that shutdown drains in
// reverse and infrastructure outlives what depends on it:
//
// - storage: reach the database, apply the schema streams, and hold
// the pool open. Absent without a database.
// - provisioning: the one-shot work of [Runtime.Once], which the
// stages after it may depend on. Absent when none is registered.
// - startup: the scheduled jobs, the readiness waits, and the
// components of [Runtime.Background].
// - serving: the two HTTP listeners and the job fleet.
//
// A failure in any of them shuts the whole service down; see [app] for
// what counts as one.
func (r *Runtime) Run(ctx context.Context) error {
timeouts := r.spec.Core.Timeouts
web := r.logger.Child("http")
// One line answering the question a misconfigured deployment raises
// first: which of the optional halves of this service came up, and
// how much the assembly asked the runtime to keep running.
r.logger.Info(ctx, "Starting "+strings.ToUpper(r.name),
log.String("version", r.spec.Version),
log.Bool("database", r.pool != nil),
log.Bool("webhooks", r.hooks != nil),
log.Int("streams", len(r.migrators)),
log.Int("jobs", len(r.ticks)),
log.Int("handlers", len(r.handlers)),
log.Int("origins", len(r.spec.Core.AllowedOrigins)),
)
srv := &http.Server{
Addr: r.spec.Core.Addr,
Handler: r.router,
// Every phase of a connection's life is bounded, so that no
// client — slow, stalled, or hostile — can hold one open
// indefinitely. See [Timeouts] for what each phase covers.
ReadHeaderTimeout: timeouts.ReadHeader,
ReadTimeout: timeouts.Read,
WriteTimeout: timeouts.Write,
IdleTimeout: timeouts.Idle,
MaxHeaderBytes: MaxHeaderBytes,
// Connection-level failures, such as a TLS handshake error or a
// malformed request line, are reported by the server itself
// rather than by a handler. Routed through the bridge they stay
// in the JSON stream instead of reaching the standard library's
// default logger. They are warnings: a broken connection is
// routine on a public listener and says nothing about the
// service's health.
ErrorLog: web.Std(ctx, log.LevelWarn),
// Handlers receive the run context, so a request in flight
// observes shutdown through the context it already carries.
BaseContext: func(net.Listener) context.Context { return ctx },
}
// The operational server shares the connection timeouts but none of
// the public listener's caps: a scraper is trusted infrastructure,
// not an anonymous client.
ops := &http.Server{
Addr: r.spec.Core.MonitorAddr,
Handler: r.ops,
ReadHeaderTimeout: timeouts.ReadHeader,
ReadTimeout: timeouts.Read,
WriteTimeout: timeouts.Write,
IdleTimeout: timeouts.Idle,
ErrorLog: web.Child("ops").Std(ctx, log.LevelWarn),
BaseContext: func(net.Listener) context.Context { return ctx },
}
var stages []app.Stage
if r.pool != nil {
stages = append(stages, app.Stage{
app.Named("storage", r.storage),
})
}
if len(r.oneshots) > 0 {
stages = append(stages, app.Stage(r.oneshots))
}
stages = append(stages, append(
app.Stage{app.Named("background", r.background)},
r.components...,
))
serving := app.Stage{
app.Named("server", r.listen(
srv, strings.ToUpper(r.name)+" server", r.spec.Core.Addr,
)),
// The probes ride on this listener, so it comes up alongside the
// API rather than after it: a readiness probe that cannot
// connect reads as an unready pod.
app.Named("ops", r.listen(
ops, "Operational server", r.spec.Core.MonitorAddr,
)),
}
if r.working() {
serving = append(serving, app.Named("work", r.work))
}
stages = append(stages, serving)
if err := app.RunStages(
stages,
app.WithLogger(r.logger.Child("app")),
app.WithContext(ctx),
// See [ShutdownMargin]: the drain outlasts the longest response
// the write timeout permits, so shutdown does not sever a
// request the server would otherwise have finished.
app.WithTimeout(timeouts.Write+ShutdownMargin),
); err != nil {
// The failure is logged here, where the JSON logger lives; the
// caller decides the exit code but need not know the log format.
r.logger.Error(ctx, "Service failed", log.Error(err))
return err
}
return nil
}
// listen wraps one HTTP server as a gracefully draining component.
func (r *Runtime) listen(
srv *http.Server,
what string,
addr string,
) app.Component {
return app.Graceful(
func(ctx context.Context) error {
r.logger.Info(ctx, what+" listening", log.String("addr", addr))
app.Ready(ctx)
err := srv.ListenAndServe()
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
},
srv.Shutdown,
)
}
// storage verifies connectivity, applies the pending schema streams,
// and keeps the pool open until shutdown.
func (r *Runtime) storage(ctx context.Context) error {
// Closing the database/sql view does not close the pool beneath it,
// so both close here, the view first.
defer r.pool.Close()
defer func() {
if err := r.db.Close(); err != nil {
r.logger.Warn(ctx,
"Failed to close database connection", log.Error(err))
}
}()
if err := r.pool.Ping(ctx); err != nil {
return fmt.Errorf("failed to reach database: %w", err)
}
if err := r.migrate(ctx); err != nil {
return err
}
app.Ready(ctx)
<-ctx.Done()
return nil
}
// migrate applies the registered streams, then those of the subsystems
// the runtime owns. Order matters: the webhook schema gates on the
// queue's, since a delivery is a job.
func (r *Runtime) migrate(ctx context.Context) error {
opt := migrate.WithLogger(r.logger.Child("migrate"))
streams := r.migrators
if r.working() {
// The queue and the webhook registry are streams of their own,
// namespaced by their own modules, so each versions
// independently of the service's tables.
streams = append(
append([]Migrator{}, streams...), queuepg.Migrator,
)
if r.hooks != nil {
streams = append(streams, hookpg.Migrator)
}
}
for _, stream := range streams {
if err := stream(r.db, opt).Up(ctx); err != nil {
return fmt.Errorf("failed to apply migrations: %w", err)
}
}
return nil
}
// working reports whether the job fleet has anything to run.
func (r *Runtime) working() bool {
return r.jobs != nil && (r.hooks != nil || len(r.handlers) > 0)
}
// work runs the job fleet, blocking until shutdown and then draining,
// so a job in flight is finished rather than repeated — which is what a
// webhook receiver would otherwise see as a duplicate delivery.
func (r *Runtime) work(ctx context.Context) error {
w := r.jobs.Worker(queue.WithConcurrency(r.opt.workers))
if r.hooks != nil {
r.hooks.Handle(w)
}
for _, h := range r.handlers {
w.Handle(h.kind, h.fn, h.opts...)
}
app.Ready(ctx)
return w.Run(ctx)
}
// background drives the scheduler and holds startup until the caches
// early requests depend on have been filled.
func (r *Runtime) background(ctx context.Context) error {
sched := schedule.New(
ctx,
schedule.WithLogger(r.logger.Child("schedule")),
schedule.WithStartJitter(StartJitter),
)
defer sched.Shutdown()
if r.keys != nil {
sched.Dispatch(schedule.Named(r.name+".keys", r.keys))
}
if r.pool != nil {
sched.Dispatch(schedule.Named(
r.name+".pool",
schedule.Every(PoolSampleInterval, metricspg.Pool(
func() metricspg.Stats { return r.pool.Stat() },
metricspg.WithPrefix(r.name+"_db"),
)),
))
}
if r.working() {
// The jobs themselves run on the fleet; these keep the table
// behind it healthy and visible.
sched.Dispatch(schedule.Named(
r.name+".queue.backlog",
schedule.Every(
BacklogInterval, schedule.TaskFn(r.jobs.Backlog),
),
))
sched.Dispatch(schedule.Named(
r.name+".queue.retention",
schedule.Every(
RetentionInterval, schedule.TaskFn(r.jobs.Retention),
),
))
}
if r.hooks != nil {
sched.Dispatch(schedule.Named(
r.name+".hook.retention",
schedule.Every(
RetentionInterval, schedule.TaskFn(r.hooks.Retention),
),
))
}
for _, tick := range r.ticks {
sched.Dispatch(tick)
}
// Every fetch is already in flight by now, so several slow ones
// delay startup once rather than one after another.
if r.keys != nil {
r.await(ctx, "identity provider", r.keys.Ready())
}
for _, w := range r.waits {
r.await(ctx, w.name, w.ready)
}
app.Ready(ctx)
<-ctx.Done()
return nil
}
// await blocks until a key cache reports its first successful fetch.
// Requests fail verification until then, so waiting briefly keeps
// startup from answering a burst of 401s. Exceeding the budget degrades
// to a warning rather than failing the service: a slow provider must
// not keep the whole service from serving everything else.
func (r *Runtime) await(
ctx context.Context,
what string,
ready <-chan struct{},
) {
if r.opt.keyWait <= 0 {
return
}
timer := time.NewTimer(r.opt.keyWait)
defer timer.Stop()
select {
case <-ready:
case <-timer.C:
r.logger.Warn(ctx,
"Signing keys not ready; requests may fail until fetched",
log.String("source", what),
)
case <-ctx.Done():
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package boot
import (
"context"
"database/sql"
"errors"
"fmt"
"net/http"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5/stdlib"
"github.com/deep-rent/nexus/dat/cache"
"github.com/deep-rent/nexus/dat/migrate"
"github.com/deep-rent/nexus/dat/pg"
"github.com/deep-rent/nexus/net/header"
"github.com/deep-rent/nexus/net/middleware"
"github.com/deep-rent/nexus/net/middleware/cors"
"github.com/deep-rent/nexus/net/middleware/measure"
"github.com/deep-rent/nexus/net/middleware/secure"
"github.com/deep-rent/nexus/net/middleware/shed"
"github.com/deep-rent/nexus/net/notify/hook"
hookpg "github.com/deep-rent/nexus/net/notify/hook/driver/postgres"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/net/transport"
"github.com/deep-rent/nexus/sec/auth"
"github.com/deep-rent/nexus/sec/jose/jwk"
"github.com/deep-rent/nexus/sec/jose/jwt"
"github.com/deep-rent/nexus/sys/app"
"github.com/deep-rent/nexus/sys/health"
"github.com/deep-rent/nexus/sys/health/check"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/metrics"
"github.com/deep-rent/nexus/sys/queue"
queuepg "github.com/deep-rent/nexus/sys/queue/driver/postgres"
"github.com/deep-rent/nexus/sys/schedule"
)
// CSP locks every response down as inert data. The services this
// package serves speak JSON and mailed links exclusively, so none has a
// reason to differ; it is a constant rather than an option for exactly
// that reason.
const CSP = "default-src 'none'; frame-ancestors 'none'"
// Errors reported by [New] for a specification it cannot honour.
var (
// ErrNoName reports a [Spec] without a name.
ErrNoName = errors.New("the service needs a name")
// ErrNoDatabase reports a [Spec] declaring a database section whose
// URL is empty. A service with an in-memory fallback leaves the
// section nil instead; see [Spec.Database].
ErrNoDatabase = errors.New("the database URL is required")
)
// Migrator builds a schema migrator over a database handle. Every
// module's Migrator function in this repository already has this shape,
// so registering a stream is naming it:
//
// rt.Migrate(ledger.Migrator)
type Migrator func(db *sql.DB, opts ...migrate.Option) *migrate.Migrator
// Spec declares what a service is made of. The sections are the ones
// [Core], [Database], [Auth], and [Sender] bind from the environment;
// a nil section leaves the subsystem behind it unbuilt, which is how a
// deployment turns one off.
type Spec struct {
// Name identifies the service. It is the product token of the
// outbound User-Agent ("nexus.<name>"), the prefix of the scheduled
// jobs ("<name>.retention"), and the prefix of the pool metrics
// ("<name>_db_..."). Required, and conventionally the binary's own
// short name.
Name string
// Version is the build stamped into the outbound User-Agent and
// into webhook deliveries; resolve it via [cli.Version].
//
// [cli.Version]: github.com/deep-rent/nexus/sys/cli#Version
Version string
// Contact is the comment of the outbound User-Agent, naming this
// deployment to the peers it calls. Empty falls back to the issuer
// declared by Auth, which is what a service verifying somebody
// else's tokens should present.
Contact string
// Core is the listen addresses, log level, CORS origins, and
// connection timeouts. It is the one section every service has.
Core Core
// Database configures the connection pool. Non-nil demands a
// usable URL: [New] refuses an empty one rather than starting a
// service whose storage silently is not there. A service with an
// in-memory fallback passes nil for that mode instead, and reads
// the absence back from [Runtime.Pool].
Database *Database
// Auth declares the identity provider whose tokens the service
// verifies. Non-nil builds the signing key cache, the verifier,
// and the guard, schedules the key refresh, and waits briefly for
// the first fetch at startup. Nil leaves [Runtime.Guard] nil — for
// the identity provider itself, which verifies its own tokens with
// machinery of its own.
Auth *Auth
// Sender configures the outbound webhook engine. Non-nil and
// enabled builds the job queue and the engine over the pool, mounts
// their schema streams, and runs the delivery fleet. Nil leaves
// [Runtime.Hooks] nil; the job queue is still built whenever a pool
// exists, since jobs are not only deliveries.
Sender *Sender
}
// Runtime is a service's assembled infrastructure and its lifecycle.
//
// [New] builds the parts every service has — the logger, the HTTP
// client, the connection pool, the guard, the routers, the job queue,
// the webhook engine — and hands them out through the accessors. The
// service wires its own domain onto them, registers what has to run
// alongside serving, and calls [Runtime.Run].
//
// Registration is not inversion of control: nothing calls back into the
// service to ask what to do. The service does its assembly in the plain
// order it reads in, and the runtime only remembers what to start.
type Runtime struct {
name string
opt options
spec Spec
logger *log.Logger
agent header.Header
client *http.Client
monitor *health.Monitor
router *router.Router
ops *router.Router
pool *pgxpool.Pool
db *sql.DB
keys jwk.CacheSet
guard *auth.Guard
jobs *queue.Queue[pgx.Tx]
hooks *hook.Engine[pgx.Tx]
// What the service registered, replayed at Run.
migrators []Migrator
ticks []schedule.Tick
waits []wait
handlers []handler
oneshots []app.Component
components []app.Component
}
// wait is one readiness channel held up at startup.
type wait struct {
name string
ready <-chan struct{}
}
// handler is one queue kind and the limits it runs under.
type handler struct {
kind string
fn queue.Handler
opts []queue.HandlerOption
}
// New assembles the infrastructure declared by spec. It returns an
// error for unusable external inputs — an unreachable database, an
// unreadable sealing key — and for a specification it cannot honour.
//
// Nothing it builds is running yet: connections are opened, but the
// pool is not pinged, no migration is applied, and no listener is
// bound until [Runtime.Run].
//
// The pool it opens is released by the storage stage on shutdown. A
// service whose own assembly fails afterwards should return the error
// and let the process exit rather than reach for the pool: an assembly
// that cannot finish has nothing to serve, and the runtime deliberately
// offers no way to release it twice.
func New(ctx context.Context, spec Spec, opts ...Option) (*Runtime, error) {
if spec.Name == "" {
return nil, ErrNoName
}
opt := defaults()
for _, o := range opts {
o(&opt)
}
r := &Runtime{name: spec.Name, opt: opt, spec: spec}
r.logger = log.New(
log.WithLevel(spec.Core.LogLevel),
log.WithRedact(opt.redact...),
log.WithAmbient(func(
ctx context.Context,
args []log.Arg,
) []log.Arg {
if id := middleware.GetRequestID(ctx); id != "" {
args = append(args, log.String("request_id", id))
}
return args
}),
)
// One tuned HTTP client serves every outbound integration, so they
// share a connection pool, bounded response reads, and a User-Agent
// that names this deployment to its peers.
contact := spec.Contact
if contact == "" && spec.Auth != nil {
contact = spec.Auth.Issuer
}
r.agent = header.UserAgent(
"nexus."+spec.Name, spec.Version, contact,
)
r.client = &http.Client{
Timeout: transport.DefaultTimeout,
Transport: transport.New(transport.WithHeader(r.agent)),
}
if err := r.connect(ctx); err != nil {
return nil, err
}
r.authenticate()
if err := r.publish(ctx); err != nil {
// The pool was opened a moment ago and nothing has been staged
// yet, so release it here rather than leaving it to a process
// that may not exit.
r.close()
return nil, err
}
r.mount()
return r, nil
}
// close releases what [New] opened, for the paths where assembly fails
// after the pool exists. A successful assembly hands the pool to the
// storage stage, which closes it on shutdown instead.
func (r *Runtime) close() {
if r.db != nil {
_ = r.db.Close()
}
if r.pool != nil {
r.pool.Close()
}
}
// connect opens the connection pool and its database/sql view.
func (r *Runtime) connect(ctx context.Context) error {
if r.spec.Database == nil {
return nil
}
if !r.spec.Database.Enabled() {
return ErrNoDatabase
}
pool, err := pg.Connect(ctx, r.spec.Database.URL)
if err != nil {
return err
}
r.pool = pool
r.db = stdlib.OpenDBFromPool(pool)
return nil
}
// authenticate builds the guard over the identity provider's key set.
func (r *Runtime) authenticate() {
if r.spec.Auth == nil {
return
}
r.keys = jwk.NewCacheSet(
r.spec.Auth.Keys(), cache.WithClient(r.client),
)
r.guard = auth.NewGuard(jwt.NewVerifier[*auth.Claims](
r.keys,
jwt.WithIssuers(r.spec.Auth.Issuer),
jwt.WithAudiences(r.spec.Auth.Audience...),
))
}
// publish builds the job queue and, where one is configured, the
// outbound webhook engine over it. Both are durable, so neither exists
// without a pool.
func (r *Runtime) publish(ctx context.Context) error {
if r.pool == nil {
return nil
}
cfg := r.spec.Sender
var retention time.Duration
if cfg != nil && cfg.Enabled() {
// The event window must outlast the delivery retry schedule: an
// event is read back at every attempt, so pruning one whose
// deliveries are still retrying strands them.
retention = cfg.Retention
if retention > 0 && retention < RetentionFloor {
r.logger.Warn(ctx,
"Webhook retention floored: the window must outlast the "+
"delivery retry schedule",
log.Duration("configured", retention),
log.Duration("floor", RetentionFloor),
)
retention = RetentionFloor
}
}
// The queue exists wherever a pool does: jobs are not only webhook
// deliveries. Its own retention follows the webhook window, so a
// settled delivery and the event behind it age out together.
r.jobs = queue.New(
queuepg.New(r.pool),
queue.WithLogger(r.logger.Child("queue")),
queue.WithRetention(retention),
)
if cfg == nil || !cfg.Enabled() {
return nil
}
opts := []hook.Option{
hook.WithLogger(r.logger.Child("hook")),
hook.WithVersion(r.spec.Version),
hook.WithRetention(retention),
hook.WithSuspendAfter(cfg.SuspendAfter),
hook.WithRetries(cfg.Retries),
hook.WithInsecureHTTP(cfg.Insecure),
hook.WithInternalHosts(cfg.InternalHosts...),
hook.WithEndpointConcurrency(cfg.EndpointConcurrency),
}
if cfg.Sealed() {
ring, err := cfg.Keyring()
if err != nil {
return fmt.Errorf(
"failed to load the webhook sealing keys: %w", err,
)
}
opts = append(opts, hook.WithSealer(ring))
} else {
r.logger.Warn(ctx,
"Webhook signing secrets are stored unsealed; set the "+
"sealing key so a database backup does not carry the "+
"means to forge deliveries",
)
}
if cfg.Insecure {
r.logger.Warn(ctx,
"Webhook endpoints may use plain http and resolve to "+
"private addresses; this is a test-rig setting",
)
}
r.hooks = hook.New(hookpg.New(r.pool), r.jobs, opts...)
return nil
}
// mount builds the public and operational routers and the health
// monitor behind them.
func (r *Runtime) mount() {
r.monitor = health.NewMonitor()
if r.db != nil {
r.monitor.Attach(
"database",
DatabaseProbeInterval,
check.Ping(r.db),
health.WithKind(health.KindReadiness),
)
}
// The router, its middleware, and the server's own connection-level
// errors share one name, since they all report on the same layer.
web := r.logger.Child("http")
mws := []router.Middleware{
router.Recover(web),
measure.New(),
router.RequestID(),
// Access logging enables itself with the debug level and
// collapses away otherwise.
router.Log(web),
}
if len(r.spec.Core.AllowedOrigins) > 0 {
// CORS sits outside the tail middlewares, so even shed
// rejections carry the headers a browser needs to read them.
mws = append(mws, router.CORS(
cors.WithAllowedOrigins(r.spec.Core.AllowedOrigins...),
cors.WithAllowCredentials(r.opt.credentials),
))
}
mws = append(mws,
router.Secure(
secure.WithCSP(CSP),
secure.WithHSTSPreload(),
),
shed.New(),
)
r.router = router.New(
router.WithLogger(web),
router.WithMaxBodySize(r.opt.maxBody),
router.WithMiddleware(mws...),
)
// The operational endpoints live on their own router, served on a
// separate listener. Keeping them off the public one is what
// protects them: an endpoint the ingress never routes needs no
// authentication to misconfigure.
//
// The middleware is deliberately thin. A scraper is not a browser,
// so the security headers, CORS, and body caps the public router
// carries buy nothing here; panics still must not take the process
// down.
ops := web.Child("ops")
r.ops = router.New(
router.WithLogger(ops),
router.WithMiddleware(router.Recover(ops)),
)
r.monitor.Mount(r.ops)
r.ops.Handle(
http.MethodGet, "/metrics", metrics.DefaultRegistry.Handler(),
)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package cli
import (
"context"
"flag"
"io"
"github.com/deep-rent/nexus/std/text"
)
// Command is one node of a command tree: a runnable action, a group of
// subcommands, or both at once. Commands are plain values, so reusable
// groups are built by constructor functions that return a *Command.
type Command struct {
// Name is the word that invokes the command, such as "migrate". It must
// be non-empty and unique among its siblings; the literal word "help"
// is claimed by the runner and should be avoided.
Name string
// Usage describes the arguments the command accepts, for example
// "<up|down|status>" or "[flags] <file>". It is printed after the
// command path in help output. When empty, a placeholder is derived
// from the declared flags and subcommands.
Usage string
// Short is the one-line description shown in command listings.
Short string
// Long is the description shown in the command's help output. When
// empty, it falls back to Short.
Long string
// Hidden excludes the command from help listings and autocompletion.
// The command remains invocable by name.
Hidden bool
// Flags declares the command's flags. It is called with a fresh, empty
// flag set every time the command is dispatched, so variables bound
// through it are reset to their defaults on each invocation.
Flags func(fs *flag.FlagSet)
// Run executes the command. It receives the positional arguments left
// over after flag parsing; when the command also declares subcommands,
// it only runs if the first positional argument matches none of them.
//
// A nil Run turns the command into a pure group, which has no action of
// its own: invoking it without a subcommand is a [UsageError] naming the
// omission, so a caller that meant to name one is told rather than left
// to read a help page it did not ask for.
Run func(ctx context.Context, args []string) error
// Commands lists the subcommands in the order they appear in help
// output.
Commands []*Command
}
// lookup returns the subcommand invoked by name, or nil if there is none.
func (c *Command) lookup(name string) *Command {
for _, sub := range c.Commands {
if sub.Name == name {
return sub
}
}
return nil
}
// nearest names the subcommand closest to what was typed, reporting
// whether one is close enough to offer. The built-in help command
// counts, since it is one somebody may well mistype.
func (c *Command) nearest(name string) (string, bool) {
names := make([]string, 0, len(c.Commands)+1)
for _, sub := range c.Commands {
names = append(names, sub.Name)
}
if c.lookup(helpName) == nil {
names = append(names, helpName)
}
return text.Nearest(name, names)
}
// flagSet builds the flag set used to parse arguments addressed to the
// command. The set continues on errors and stays silent; the runner reports
// parse errors and renders usage itself.
func (c *Command) flagSet(name string) *flag.FlagSet {
fs := flag.NewFlagSet(name, flag.ContinueOnError)
fs.SetOutput(io.Discard)
fs.Usage = func() {}
if c.Flags != nil {
c.Flags(fs)
}
return fs
}
// visible returns the subcommands that are not hidden.
func (c *Command) visible() []*Command {
var subs []*Command
for _, sub := range c.Commands {
if !sub.Hidden {
subs = append(subs, sub)
}
}
return subs
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package cli
import (
"context"
"io"
"os"
)
// Declares a unique context key.
type (
stdoutKey struct{}
stderrKey struct{}
)
// Stdout returns the standard output stream of the invocation carried by
// ctx. Commands write their regular output through it, so that [WithStdout]
// can capture the output in tests. It falls back to [os.Stdout] for contexts
// that did not originate from this package.
func Stdout(ctx context.Context) io.Writer {
if w, ok := ctx.Value(stdoutKey{}).(io.Writer); ok {
return w
}
return os.Stdout
}
// Stderr returns the error output stream of the invocation carried by ctx.
// It falls back to [os.Stderr] for contexts that did not originate from this
// package.
func Stderr(ctx context.Context) io.Writer {
if w, ok := ctx.Value(stderrKey{}).(io.Writer); ok {
return w
}
return os.Stderr
}
// withOutput attaches the configured output streams to ctx.
func withOutput(ctx context.Context, c *config) context.Context {
ctx = context.WithValue(ctx, stdoutKey{}, c.stdout)
ctx = context.WithValue(ctx, stderrKey{}, c.stderr)
return ctx
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package cli
import "fmt"
// UsageError reports command line input that could not be interpreted, such
// as an unknown command or a malformed flag. [Run] prints it together with a
// usage hint and maps it to process exit code 2, distinguishing operator
// mistakes from failures of the command itself.
type UsageError struct {
// Path is the invocation path of the command that rejected the input,
// starting at the root command, for example ["svc", "migrate"]. The
// runner fills it in for errors returned by [Command.Run].
Path []string
// Err describes the rejected input.
Err error
}
// Error returns the description of the rejected input.
func (e *UsageError) Error() string { return e.Err.Error() }
// Unwrap returns the underlying error.
func (e *UsageError) Unwrap() error { return e.Err }
// Usagef returns a [UsageError] with the given format and arguments.
// [Command.Run] implementations use it to reject invalid positional
// arguments so that the process exits with code 2.
func Usagef(format string, args ...any) error {
return &UsageError{Err: fmt.Errorf(format, args...)}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package cli
import (
"flag"
"fmt"
"io"
"strings"
)
// writeHelp renders the help message of the given command, invoked under the
// given path, to the given writer. The message consists of a usage
// synopsis, the long description, a listing of the visible subcommands, the
// declared flags, and a closing hint pointing at the help command.
func writeHelp(w io.Writer, cmd *Command, path []string) {
name := strings.Join(path, " ")
fs := cmd.flagSet(name)
flags := 0
fs.VisitAll(func(*flag.Flag) { flags++ })
subs := cmd.visible()
fmt.Fprintf(w, "Usage:\n %s\n", synopsis(cmd, name, flags, subs))
long := strings.TrimSpace(cmd.Long)
if long == "" {
long = strings.TrimSpace(cmd.Short)
}
if long != "" {
fmt.Fprintf(w, "\n%s\n", long)
}
if len(subs) > 0 {
fmt.Fprint(w, "\nCommands:\n")
writeCommands(w, subs)
}
if flags > 0 {
fmt.Fprint(w, "\nFlags:\n")
fs.SetOutput(w)
fs.PrintDefaults()
}
if len(subs) > 0 {
fmt.Fprintf(
w,
"\nRun '%s %s <command>' for details on a command.\n",
name,
helpName,
)
}
}
// writeCommands renders one line per command, with the descriptions
// aligned in a column after the longest name.
func writeCommands(w io.Writer, cmds []*Command) {
width := 0
for _, cmd := range cmds {
width = max(width, len(cmd.Name))
}
for _, cmd := range cmds {
line := fmt.Sprintf(" %-*s %s", width, cmd.Name, cmd.Short)
fmt.Fprintf(w, "%s\n", strings.TrimRight(line, " "))
}
}
// synopsis composes the one-line invocation synopsis of the given command,
// invoked under the given name and offering the given flags and
// subcommands. The declared [Command.Usage] wins; otherwise a placeholder
// is derived.
func synopsis(cmd *Command, name string, flags int, subs []*Command) string {
if cmd.Usage != "" {
return name + " " + cmd.Usage
}
if flags > 0 {
name += " [flags]"
}
if len(subs) > 0 {
name += " <command>"
}
return name
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package cli
import (
"io"
"os"
)
// Option is a functional option for configuring [Run] and [Execute].
type Option func(*config)
// WithStdout redirects the standard output of the runner and its commands.
// It defaults to [os.Stdout]. Commands observe the redirection through
// [Stdout].
func WithStdout(w io.Writer) Option {
return func(c *config) {
if w != nil {
c.stdout = w
}
}
}
// WithStderr redirects the error output of the runner and its commands.
// It defaults to [os.Stderr]. Commands observe the redirection through
// [Stderr].
func WithStderr(w io.Writer) Option {
return func(c *config) {
if w != nil {
c.stderr = w
}
}
}
// config holds the configured output streams.
type config struct {
stdout io.Writer
stderr io.Writer
}
// newConfig applies opts on top of the defaults.
func newConfig(opts []Option) *config {
c := &config{
stdout: os.Stdout,
stderr: os.Stderr,
}
for _, opt := range opts {
opt(c)
}
return c
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package cli
import (
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"slices"
"strings"
)
// Main runs the given command tree against [os.Args] and returns the
// intended process exit code, making
//
// func main() { os.Exit(cli.Main(root)) }
//
// a complete main function. Main does not trap OS signals; long-running
// commands are expected to manage their own lifecycle, for example through
// the app package.
func Main(root *Command) int {
return Run(context.Background(), root, os.Args[1:])
}
// Run executes the given command tree against the given arguments, which
// must not include the program name. Errors are printed to the configured
// error output, and the outcome is translated into a process exit code: 0
// on success, 2 for a [UsageError], and 1 for any other error.
func Run(
ctx context.Context,
root *Command,
args []string,
opts ...Option,
) int {
cfg := newConfig(opts)
err := execute(ctx, root, args, cfg)
if err == nil {
return 0
}
if usage, ok := errors.AsType[*UsageError](err); ok {
if len(usage.Path) == 0 {
usage.Path = []string{root.Name}
}
path := strings.Join(usage.Path, " ")
fmt.Fprintf(cfg.stderr, "%s: %s\n", path, err)
fmt.Fprintf(cfg.stderr, "Run '%s help' for usage.\n", path)
return 2
}
fmt.Fprintln(cfg.stderr, err)
return 1
}
// Execute is like [Run], but returns the outcome as an error instead of
// printing it: nil on success, a [UsageError] for input that could not be
// interpreted, or the error returned by the dispatched [Command.Run].
// Requests for help are served on the configured standard output and
// report success.
func Execute(
ctx context.Context,
root *Command,
args []string,
opts ...Option,
) error {
return execute(ctx, root, args, newConfig(opts))
}
// execute prepares the invocation context and dispatches the given
// arguments.
func execute(
ctx context.Context,
root *Command,
args []string,
cfg *config,
) error {
ctx = withOutput(ctx, cfg)
return exec(ctx, cfg.stdout, root, []string{root.Name}, args)
}
// helpName is the word that requests the help of the commands after it.
const helpName = "help"
// exec parses the flags of the given command from the given arguments and
// either descends into a subcommand, serves a help request, or runs the
// command itself. Help is rendered to stdout; the given path is the chain
// of command names from the root up to and including the given command.
func exec(
ctx context.Context,
stdout io.Writer,
cmd *Command,
path []string,
args []string,
) error {
fs := cmd.flagSet(strings.Join(path, " "))
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
writeHelp(stdout, cmd, path)
return nil
}
return &UsageError{Path: path, Err: err}
}
rest := fs.Args()
if len(rest) > 0 {
name := rest[0]
if name == helpName && cmd.lookup(helpName) == nil {
return help(stdout, cmd, path, rest[1:])
}
if sub := cmd.lookup(name); sub != nil {
return exec(
ctx,
stdout,
sub,
append(slices.Clip(path), name),
rest[1:],
)
}
if cmd.Run == nil {
// A mistyped command is the usual reason to land here, so
// the refusal names the closest one rather than leaving the
// reader to compare against the help.
if did, ok := cmd.nearest(name); ok {
return &UsageError{
Path: path,
Err: fmt.Errorf(
"unknown command %q; did you mean %q?",
name, did,
),
}
}
return &UsageError{
Path: path,
Err: fmt.Errorf("unknown command %q", name),
}
}
}
if cmd.Run == nil {
return &UsageError{
Path: path,
Err: errors.New("a subcommand is required"),
}
}
err := cmd.Run(ctx, rest)
var usage *UsageError
if errors.As(err, &usage) && usage.Path == nil {
usage.Path = path
}
return err
}
// help serves "... help [topic ...]" by resolving the topic path relative
// to the given command and rendering the help of the target command to the
// given writer.
func help(w io.Writer, cmd *Command, path, topics []string) error {
for _, name := range topics {
sub := cmd.lookup(name)
if sub == nil {
return &UsageError{
Path: path,
Err: fmt.Errorf("unknown help topic %q", name),
}
}
cmd = sub
path = append(slices.Clip(path), name)
}
writeHelp(w, cmd, path)
return nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package cli
import (
"context"
"fmt"
"runtime/debug"
)
// Version resolves a binary's version: the given build-stamped string when
// non-empty, or failing that, the module version or VCS revision recorded
// in the build information. It returns "devel" when nothing usable was
// recorded, so the result is always printable.
//
// The stamp variable belongs to each command's own main package, so a
// build injects it there:
//
// var version string
//
// go build -ldflags "-X main.version=v1.2.3"
//
// Resolve it once at startup and hand the result to whatever needs it —
// [ShowVersion], outbound User-Agent headers, startup logs.
func Version(stamped string) string {
if stamped != "" {
return stamped
}
info, ok := debug.ReadBuildInfo()
if !ok {
return "devel"
}
if v := info.Main.Version; v != "" && v != "(devel)" {
return v
}
var revision string
var modified bool
for _, s := range info.Settings {
switch s.Key {
case "vcs.revision":
revision = s.Value
case "vcs.modified":
modified = s.Value == "true"
}
}
if revision == "" {
return "devel"
}
if len(revision) > 12 {
revision = revision[:12]
}
if modified {
revision += "-dirty"
}
return revision
}
// ShowVersion builds the customary "version" command, which prints the
// given version on a line of its own. Pass a resolved version, typically
// via [Version].
func ShowVersion(version string) *Command {
return &Command{
Name: "version",
Short: "print the version of this binary",
Run: func(ctx context.Context, args []string) error {
if len(args) > 0 {
return Usagef("unexpected argument %q", args[0])
}
fmt.Fprintln(Stdout(ctx), version)
return nil
},
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package env
import (
"bytes"
"errors"
"fmt"
"os"
"github.com/deep-rent/nexus/dat/bind"
"github.com/deep-rent/nexus/std/ascii"
"github.com/deep-rent/nexus/std/cases/snake"
)
// Lookup is a function that retrieves the value of an environment variable.
// It follows the signature of [os.LookupEnv], returning the value and a boolean
// indicating whether the variable was present. This type allows for custom
// lookup mechanisms, such as reading from sources other than the actual
// environment, which is especially useful for testing.
type Lookup func(key string) (string, bool)
// binder is shared by every call to [Unmarshal]. Caching the reflection
// metadata is safe because a type's tags cannot change, and it keeps a
// process that unmarshals repeatedly from re-walking the same structs.
var binder = bind.New(
"env",
bind.WithTransformer(snake.ToUpper),
bind.WithCache(true),
)
type source struct {
lookup Lookup
}
func (s source) Lookup(key string) ([]string, bool) {
if val, ok := s.lookup(key); ok {
return []string{val}, true
}
return nil, false
}
var _ bind.Source = (*source)(nil)
// Unmarshal populates the fields of a struct with values from environment
// variables. The given value must be a non-nil pointer to a struct.
//
// By default, [Unmarshal] processes all exported fields. A field's environment
// variable name is derived from its name, converted to uppercase SNAKE_CASE.
// To ignore a field, tag it with `env:"-"`. Unexported fields are always
// excluded. If a variable is not set, the field remains unchanged unless a
// default value is specified in the struct tag, or it is marked as required.
//
// Every problem found is reported together, so a misconfigured environment
// can be corrected in one pass rather than one variable per attempt. Use
// [errors.Join] semantics to inspect the result.
func Unmarshal[T any](v *T, opts ...Option) error {
cfg := config{
Lookup: os.LookupEnv,
}
for _, opt := range opts {
opt(&cfg)
}
return binder.Bind(v, cfg.Prefix, source{cfg.Lookup})
}
// Expand substitutes environment variables in a string.
//
// It replaces references to environment variables in the formats ${KEY} or $KEY
// with their corresponding values. A literal dollar sign can be escaped with $$
// (double dollar sign). If a referenced variable is not found in the
// environment, the function returns an error. Its behavior can be adjusted
// through functional options.
func Expand(s string, opts ...Option) (string, error) {
cfg := config{
Lookup: os.LookupEnv,
}
for _, opt := range opts {
opt(&cfg)
}
var b bytes.Buffer
b.Grow(len(s))
i := 0
for i < len(s) {
// Find the next dollar sign.
j := i
for j < len(s) && s[j] != '$' {
j++
}
// Append the literal part before the dollar sign.
b.WriteString(s[i:j])
i = j
// If there is no dollar sign left, we are done.
if i >= len(s) {
break
}
// Look at the character after the dollar sign.
if i+1 < len(s) && s[i+1] == '$' { //nolint:gocritic
// Handle the `$$` escape sequence.
b.WriteByte('$')
i += 2
} else if i+1 < len(s) && s[i+1] == '{' {
// Handle the `${VAR}` syntax.
// Find the closing brace.
end := 2
for i+end < len(s) && s[i+end] != '}' {
end++
}
if i+end == len(s) {
return "", errors.New("variable bracket not closed")
}
// Extract the bracketed variable name.
key := cfg.Prefix + s[i+2:i+end]
val, ok := cfg.Lookup(key)
if !ok {
return "", fmt.Errorf("variable %q is not set", key)
}
b.WriteString(val)
// Move the index past the processed variable `${KEY}`.
i += end + 1
} else {
// Handle the `$VAR` syntax.
// Find the end of the variable name. The first character of a
// variable must be a letter or underscore; subsequent characters
// an include digits.
n := 0
for j := i + 1; j < len(s); j++ {
c := s[j]
if !ascii.IsAlpha(c) && c != '_' &&
(n == 0 || !ascii.IsDigit(c)) {
break
}
n++
}
if n == 0 {
// No valid identifier characters found (e.g., "$5", "$!").
// Treat as a literal dollar sign.
b.WriteByte('$')
i++
} else {
// Extract the unbracketed variable name.
key := cfg.Prefix + s[i+1:i+1+n]
val, ok := cfg.Lookup(key)
if !ok {
return "", fmt.Errorf("variable %q is not set", key)
}
b.WriteString(val)
// Move the index past the processed variable `$KEY`.
i += 1 + n
}
}
}
return b.String(), nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package env
// Option is a functional option for configuring the [Unmarshal] behavior.
type Option func(*config)
// WithPrefix sets a prefix that will be prepended to all environment variable
// keys before looking them up. If not provided, no prefix is used.
func WithPrefix(prefix string) Option {
return func(c *config) {
c.Prefix = prefix
}
}
// WithLookup overrides the default mechanism for retrieving environment
// variables. By default, [Unmarshal] uses [os.LookupEnv]. This option is
// particularly useful for unit tests, allowing you to inject a mock environment
// or an alternative configuration source.
func WithLookup(lookup Lookup) Option {
return func(c *config) {
if lookup != nil {
c.Lookup = lookup
}
}
}
// config holds configuration options for environment variable processing.
type config struct {
// Prefix is a common prefix for all environment variable keys.
Prefix string
// Lookup is the injectable callback for variable lookup.
Lookup Lookup
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package event
import (
"fmt"
"slices"
"sync"
)
// closer is an internal interface that allows the [Broker] to shut down buses
// without knowing their generic type payloads.
type closer interface {
// Close signals the resource to shut down.
Close()
}
// Broker manages a collection of typed event buses segregated by topic strings.
type Broker struct {
// mu protects the buses map and the closed flag.
mu sync.RWMutex
// buses maps topic names to their underlying typed [Bus] instances.
buses map[string]closer
// closed indicates whether the broker has been shut down.
closed bool
// opts are the default options applied to all buses created by this broker.
opts []Option
}
// NewBroker initializes an empty event [Broker] with options applied to all
// subsequently created buses.
func NewBroker(opts ...Option) *Broker {
return &Broker{
buses: make(map[string]closer),
opts: opts,
}
}
// Topic retrieves an existing [Bus] for the given topic or creates a new one
// using the broker's configured options. It panics if the topic already exists
// but is registered to a different event type.
//
// Once the broker has been closed it owns no buses, so Topic hands back an
// already closed [Bus] that accepts no events. This keeps a shutdown racing
// with a late caller from silently starting a processor that nothing will ever
// stop.
func Topic[T any](b *Broker, name string) *Bus[T] {
// Fast path: Invoke the read-only lock.
b.mu.RLock()
existing, exists := b.buses[name]
closed := b.closed
b.mu.RUnlock()
if exists {
return cast[T](existing, name)
}
if closed {
return closedBus[T]()
}
// Slow path: Invoke the write lock to initialize.
b.mu.Lock()
defer b.mu.Unlock()
// Double-check locking in case another goroutine initialized it, or closed
// the broker, while we were waiting to acquire the write lock.
if existing, exists = b.buses[name]; exists {
return cast[T](existing, name)
}
if b.closed {
return closedBus[T]()
}
// Create and store the new typed bus, named after its topic so that the
// recorded metrics tell the broker's buses apart.
bus := NewBus[T](append(slices.Clone(b.opts), WithName(name))...)
b.buses[name] = bus
return bus
}
// cast converts a registered bus back to its requested generic type, panicking
// if the topic was registered for a different event type.
func cast[T any](existing closer, name string) *Bus[T] {
bus, ok := existing.(*Bus[T])
if !ok {
panic(fmt.Sprintf(
"topic %q exists but expects a different event type",
name,
))
}
return bus
}
// closedBus returns a [Bus] that is already shut down, so that it holds no
// running goroutine and rejects every publish.
func closedBus[T any]() *Bus[T] {
bus := NewBus[T]()
bus.Close()
return bus
}
// Close gracefully shuts down all buses managed by the broker. It blocks until
// every bus has drained, and is safe to call more than once.
//
// After Close returns, [Topic] no longer creates buses; see its documentation.
func (b *Broker) Close() {
b.mu.Lock()
// 1. Capture the existing buses.
buses := b.buses
// 2. Clear the map to release references and block new retrievals.
b.buses = make(map[string]closer)
b.closed = true
b.mu.Unlock() // Release the lock before calling Close on all the buses
// 3. Close all buses concurrently so that their drains overlap.
var wg sync.WaitGroup
for _, bus := range buses {
wg.Go(bus.Close)
}
wg.Wait()
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package event
import (
"context"
"fmt"
"runtime/debug"
"sync"
"github.com/deep-rent/nexus/sys/log"
)
// handler pairs a subscriber callback with the identifier used during
// dispatching. The identifier allows for constant-time unsubscription without
// relying on function pointers.
type handler[T any] struct {
// id is a unique identifier for the subscriber.
id uint64
// fn is the callback function to be executed.
fn Subscriber[T]
}
// dispatcher defines the internal strategy for delivering events to
// subscribers.
type dispatcher[T any] interface {
// dispatch delivers the event to the provided list of handlers.
dispatch(event T, handlers []handler[T])
// wait blocks until every delivery this dispatcher started has finished.
// It is called once the processor has stopped, so no further dispatch can
// race with it.
wait()
}
// deliver invokes a single subscriber, isolating the call so that a panic in
// one subscriber neither reaches the background processor nor keeps the
// remaining subscribers from being notified. Completed deliveries and panics
// are counted separately.
func deliver[T any](
logger *log.Logger,
stats *counters,
fn Subscriber[T],
event T,
) {
defer func() {
if r := recover(); r != nil {
// Subscribers are context-free, so there is no context to pass
// along here.
logger.Error(
context.Background(),
"Subscriber panicked",
log.String("panic", fmt.Sprint(r)),
log.String("stack", string(debug.Stack())),
)
stats.panics.Inc()
return
}
stats.delivered.Inc()
}()
fn(event)
}
// basicDispatcher delivers events sequentially on the background worker's
// goroutine.
type basicDispatcher[T any] struct {
// logger records any panics triggered by a subscriber function.
logger *log.Logger
// stats counts deliveries and panics.
stats *counters
}
// dispatch iterates through all handlers and executes them sequentially.
func (d *basicDispatcher[T]) dispatch(event T, handlers []handler[T]) {
for _, h := range handlers {
deliver(d.logger, d.stats, h.fn, event)
}
}
// wait returns immediately: delivery already finished on the caller's
// goroutine.
func (*basicDispatcher[T]) wait() {}
// asyncDispatcher delivers events concurrently by spawning a goroutine per
// subscriber.
type asyncDispatcher[T any] struct {
// logger records any panics triggered by a subscriber function.
logger *log.Logger
// stats counts deliveries and panics.
stats *counters
// wg tracks deliveries that have been started but not yet completed.
wg sync.WaitGroup
}
// dispatch executes all handlers in parallel.
func (d *asyncDispatcher[T]) dispatch(event T, handlers []handler[T]) {
for _, h := range handlers {
d.wg.Add(1)
go func(f Subscriber[T]) {
defer d.wg.Done()
deliver(d.logger, d.stats, f, event)
}(h.fn)
}
}
// wait blocks until every spawned delivery has returned.
func (d *asyncDispatcher[T]) wait() { d.wg.Wait() }
var (
_ dispatcher[any] = &basicDispatcher[any]{}
_ dispatcher[any] = &asyncDispatcher[any]{}
)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package event
import (
"runtime"
"sync"
"sync/atomic"
"github.com/deep-rent/nexus/std/ring"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/metrics"
)
// OverflowMode determines how the bus behaves when the internal buffer is
// full. An unrecognized value is treated as [Block].
type OverflowMode ring.Policy
const (
// Block waits until space is available in the buffer.
Block = OverflowMode(ring.Block)
// DropOldest removes the oldest unread event to make room for the new one.
DropOldest = OverflowMode(ring.DropOldest)
// DropNewest discards the incoming event if the buffer is full.
DropNewest = OverflowMode(ring.DropNewest)
)
const (
// DefaultSize is the default capacity of the internal ring buffer.
// It is automatically rounded up to the nearest power of 2.
DefaultSize = 1024
// DefaultOverflowMode is the default overflow mode ([Block]).
DefaultOverflowMode = Block
)
// Subscriber is a callback function that handles events of type T.
type Subscriber[T any] func(T)
// Emitter returns a callback that publishes to the bus, discarding the
// admission result.
//
// It adapts a [Bus] onto the plain func(T) seam a producer exposes when it
// must stay unaware of the event system, which is how a producer stays
// usable with no bus behind it at all. Emission is advisory there: a
// producer that cannot afford to lose an event has to call [Bus.Publish]
// itself and act on the false it may return.
func Emitter[T any](b *Bus[T]) func(T) {
return func(event T) { b.Publish(event) }
}
// Bus carries events of one type from publishers to the subscribers
// that registered for them, over a lock-free ring buffer drained by a
// processor goroutine of its own.
type Bus[T any] struct {
// evts is the underlying lock-free ring buffer.
evts *ring.Buffer[T]
// disp is the configured strategy for calling subscriber functions.
disp dispatcher[T]
// wait dictates how the processor idles when the buffer is empty.
wait WaitStrategy
// subs is a copy-on-write pointer holding the active list of subscribers.
subs atomic.Pointer[[]handler[T]]
// closed indicates whether the bus has been shut down.
closed atomic.Bool
// pubs counts publishers that passed the closed check but have not yet
// finished pushing.
pubs atomic.Int64
// mu protects write operations to the active subscriber list.
mu sync.Mutex
// id is an incrementing counter providing unique keys for new subscribers.
id uint64
// wg tracks the lifecycle of the background processor goroutine.
wg sync.WaitGroup
// stats counts published and dropped events.
stats *counters
}
// NewBus initializes a [Bus] with the provided options.
func NewBus[T any](opts ...Option) *Bus[T] {
cfg := config{
size: DefaultSize,
mode: DefaultOverflowMode,
sync: false,
wait: func() WaitStrategy { return adaptiveWait{} },
}
for _, opt := range opts {
opt(&cfg)
}
if cfg.logger == nil {
cfg.logger = log.Discard()
}
if cfg.registry == nil {
cfg.registry = metrics.DefaultRegistry
}
stats := newCounters(cfg.registry, cfg.name)
// Every bus idles on its own strategy, so that a stateful one cannot be
// shared by the buses a broker creates.
wait := cfg.wait()
if wait == nil {
wait = adaptiveWait{}
}
var disp dispatcher[T]
if cfg.sync {
disp = &basicDispatcher[T]{
logger: cfg.logger,
stats: stats,
}
} else {
disp = &asyncDispatcher[T]{
logger: cfg.logger,
stats: stats,
}
}
bus := &Bus[T]{
evts: ring.New[T](cfg.size, ring.Policy(cfg.mode)),
disp: disp,
wait: wait,
stats: stats,
}
// Seed the atomic pointer with an empty slice to avoid nil pointer panics
// on first load.
empty := make([]handler[T], 0)
bus.subs.Store(&empty)
// Spin up the background processor.
bus.wg.Add(1)
go bus.process()
return bus
}
// Subscribe adds a callback to the bus. It returns an unsubscribe function that
// removes the callback when invoked.
func (b *Bus[T]) Subscribe(fn Subscriber[T]) (unsubscribe func()) {
b.mu.Lock()
defer b.mu.Unlock()
b.id++
id := b.id
// Copy-on-write: Load current state, clone into a larger slice, and append.
curr := *b.subs.Load()
next := make([]handler[T], len(curr), len(curr)+1)
copy(next, curr)
next = append(next, handler[T]{id: id, fn: fn})
// Atomically swap the new slice into place for the background processor to
// read lock-free.
b.subs.Store(&next)
// Guarantee the teardown logic only runs exactly once.
var once sync.Once
return func() {
once.Do(func() {
b.detach(id)
})
}
}
// detach filters out the subscriber matching the given ID.
func (b *Bus[T]) detach(id uint64) {
b.mu.Lock()
defer b.mu.Unlock()
curr := *b.subs.Load()
// Pre-allocate the new slice. By creating a new backing array, we ensure
// the old array (and its function pointers) can be garbage collected.
next := make([]handler[T], 0, len(curr))
for _, h := range curr {
if h.id != id {
next = append(next, h)
}
}
b.subs.Store(&next)
}
// Publish pushes an event to the bus. It returns false if the buffer is full
// (and [DropNewest] policy is active) or if the bus is already closed.
//
// An event for which Publish reports true is guaranteed to reach the
// subscribers, even if [Bus.Close] is called concurrently.
func (b *Bus[T]) Publish(event T) bool {
// Register as an in-flight publisher before consulting the flag, so that
// Close cannot conclude the buffer is quiescent while this push is still
// on its way in.
b.pubs.Add(1)
defer b.pubs.Add(-1)
// Guard against publishing to a stopped bus.
if b.closed.Load() {
b.stats.dropped.Inc()
return false
}
// Attempt to push to the lock-free ring buffer.
if b.evts.Push(event) {
// Awaken the processor if it happens to be snoozing.
b.wait.Signal()
b.stats.published.Inc()
return true
}
b.stats.dropped.Inc()
return false
}
// Close signals the background processor to drain remaining events and stop.
// Further calls to [Bus.Publish] will immediately return false.
//
// Close blocks until every buffered event has been handed to the subscribers
// and, under the default asynchronous dispatch, until those deliveries have
// returned. It is safe to call more than once and safe to call concurrently
// with [Bus.Publish]: a publisher that has already been admitted completes
// before the drain begins.
func (b *Bus[T]) Close() {
// Atomically swap to closed. If it was already closed, do nothing.
if b.closed.Swap(true) {
return
}
// Publishers admitted before the flag was set may still be on their way
// into the ring buffer. Waiting for them here is what keeps the final
// drain below from missing an event.
for b.pubs.Load() > 0 {
runtime.Gosched()
}
// Wake up the processor if it is blocking on a semaphore so it can
// perform its final drain and exit.
b.wait.Signal()
// Wait for the processor goroutine to finish...
b.wg.Wait()
// ...and then for the deliveries it started.
b.disp.wait()
}
// process continuously polls the ring buffer for new events.
func (b *Bus[T]) process() {
defer b.wg.Done()
idle := 0
for {
// Fast path: attempt to pop an event off the lock-free queue.
if evt, ok := b.evts.Pop(); ok {
idle = 0 // Reset the backoff counter on success
// Load a read-only snapshot of the subscribers.
if handlers := *b.subs.Load(); len(handlers) > 0 {
b.disp.dispatch(evt, handlers)
}
} else {
// Slow path: queue is empty.
if b.closed.Load() {
// The bus was closed. Perform one final exhaustive drain check
// in case events were published just before the close signal.
for {
final, ok := b.evts.Pop()
if !ok {
// Queue is truly empty and bus is closed; exit.
return
}
if handlers := *b.subs.Load(); len(handlers) > 0 {
b.disp.dispatch(final, handlers)
}
}
}
// Backoff and yield to prevent spinning the CPU at 100% capacity.
b.wait.Snooze(idle)
idle++
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package event
import (
"github.com/deep-rent/nexus/sys/metrics"
)
// Names of the counters recorded by a [Bus], all tagged with the bus name
// set via [WithName].
const (
// BusPublished counts events accepted by the bus.
BusPublished = "event_published_total"
// BusDropped counts events rejected because the bus was full or closed.
// Note that under [DropOldest], the ring buffer evicts the displaced
// event silently, so such evictions do not show up here; only rejected
// publishes do.
BusDropped = "event_dropped_total"
// BusDelivered counts completed subscriber deliveries.
BusDelivered = "event_delivered_total"
// BusPanics counts subscriber deliveries that panicked.
BusPanics = "event_panics_total"
)
// counters bundles the bus counters, resolved once at construction so the
// publish and delivery hot paths touch nothing but atomics.
type counters struct {
published *metrics.Counter
dropped *metrics.Counter
delivered *metrics.Counter
panics *metrics.Counter
}
// newCounters resolves the bus counters from the given registry.
func newCounters(reg *metrics.Registry, name string) *counters {
tag := metrics.T("bus", name)
return &counters{
published: reg.Counter(BusPublished, tag),
dropped: reg.Counter(BusDropped, tag),
delivered: reg.Counter(BusDelivered, tag),
panics: reg.Counter(BusPanics, tag),
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package event
import (
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/metrics"
)
// Option configures a [Bus] at construction.
type Option func(*config)
// config aggregates all user-defined settings for the [Bus].
type config struct {
// size is the internal buffer capacity.
size int
// mode is the behavior on buffer overflow.
mode OverflowMode
// sync determines if dispatching is sequential.
sync bool
// wait constructs the idling strategy for a bus's background worker. It is
// a constructor rather than an instance so that every bus gets its own.
wait func() WaitStrategy
// logger is used for reporting errors and panics.
logger *log.Logger
// registry records the bus counters.
registry *metrics.Registry
// name distinguishes bus instances in the recorded metrics.
name string
}
// WithSize sets the buffer capacity (rounded up to the nearest power of 2).
// Defaults to [DefaultSize]. Non-positive values will be ignored.
func WithSize(size int) Option {
return func(o *config) {
if size > 0 {
o.size = size
}
}
}
// WithOverflowMode defines how the bus deals with backpressure on buffer
// exhaustion. Defaults to [DefaultOverflowMode].
func WithOverflowMode(mode OverflowMode) Option {
return func(o *config) {
o.mode = mode
}
}
// WithSyncDispatch forces sequential event delivery. If omitted, the bus
// defaults to asynchronous parallel delivery.
func WithSyncDispatch() Option {
return func(o *config) {
o.sync = true
}
}
// WithAdaptiveWait uses a low-latency spin-yield-sleep strategy. This is the
// default.
func WithAdaptiveWait() Option {
return func(o *config) {
o.wait = func() WaitStrategy { return adaptiveWait{} }
}
}
// WithBlockingWait uses a semaphore to park the CPU when idle. Ideal for
// multi-tenant setups.
func WithBlockingWait() Option {
return func(o *config) {
o.wait = func() WaitStrategy {
return &blockingWait{sem: make(chan struct{}, 1)}
}
}
}
// WithWaitStrategy injects a user-defined idling strategy.
//
// It takes a constructor rather than a strategy, because a [Broker] applies
// its options to every bus it creates and a strategy that carries state must
// not be shared between them. A single semaphore backing several buses, for
// instance, lets one bus consume the wakeup meant for another, leaving the
// other parked with an event already in its buffer.
//
// The constructor is invoked once per [Bus]. A nil constructor, or one that
// returns nil, is ignored.
func WithWaitStrategy(strategy func() WaitStrategy) Option {
return func(o *config) {
if strategy != nil {
o.wait = strategy
}
}
}
// WithLogger sets the structured logger for recording subscriber panics. If
// not provided, the bus stays silent, as if [log.Discard] had been given.
func WithLogger(logger *log.Logger) Option {
return func(o *config) {
if logger != nil {
o.logger = logger
}
}
}
// WithRegistry sets the registry receiving the bus counters [BusPublished],
// [BusDropped], [BusDelivered], and [BusPanics]. It defaults to
// [metrics.DefaultRegistry]. A nil value is ignored.
func WithRegistry(reg *metrics.Registry) Option {
return func(o *config) {
if reg != nil {
o.registry = reg
}
}
}
// WithName sets the value of the "bus" attribute on the recorded counters,
// keeping multiple buses apart in a telemetry backend. A [Broker] names every
// bus it creates after its topic, overriding this option.
func WithName(name string) Option {
return func(o *config) {
o.name = name
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package event
import (
"runtime"
"time"
)
// WaitStrategy defines the idling behavior of the background processor when the
// ring buffer is empty.
type WaitStrategy interface {
// Snooze is called when the buffer is empty, with the number of
// consecutive empty polls so far.
Snooze(idle int)
// Signal awakens the processor from a Snooze when a new event arrives.
Signal()
}
// adaptiveWait employs a spin-yield-sleep sequence to minimize latency while
// preventing constant CPU burn during idle periods.
type adaptiveWait struct{}
// Snooze scales the waiting mechanism based on how long the bus has been idle.
func (adaptiveWait) Snooze(idle int) {
const (
phase1 = 1000 // Spin-yield limit
phase2 = 5000 // Sleep limit
)
switch {
case idle < phase1:
// Low latency mode: Yield the processor but stay actively scheduled.
runtime.Gosched()
case idle < phase2:
// Cooldown mode: Drop CPU usage significantly while maintaining fast
// response.
time.Sleep(time.Microsecond)
default:
// Deep idle mode: Near 0% CPU consumption.
time.Sleep(time.Millisecond)
}
}
// Signal is a no-op because the loop actively wakes itself up.
func (adaptiveWait) Signal() {}
// blockingWait uses a semaphore channel to park the goroutine entirely when
// idle, saving CPU cycles at the cost of a slight wakeup latency.
type blockingWait struct {
// sem is a buffered channel acting as a non-blocking signaling mechanism.
sem chan struct{}
}
// Snooze parks the goroutine until a value is received on the semaphore
// channel.
func (w *blockingWait) Snooze(_ int) { <-w.sem }
// Signal attempts to send a wakeup token. If the channel already has a token,
// it drops the send to avoid blocking the publisher.
func (w *blockingWait) Signal() {
select {
case w.sem <- struct{}{}:
default:
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package check
import (
"context"
"fmt"
"io"
"net"
"net/http"
"time"
"github.com/deep-rent/nexus/net/transport"
"github.com/deep-rent/nexus/sys/health"
)
// TCP returns a health check that attempts to establish a TCP connection
// to the specified address.
//
// It returns [health.StatusSick] if the connection cannot be established within
// the provided timeout.
func TCP(addr string, timeout time.Duration) health.CheckFunc {
return func(ctx context.Context) (health.Status, error) {
d := net.Dialer{Timeout: timeout}
conn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return health.StatusSick, fmt.Errorf("tcp dial %s: %w", addr, err)
}
_ = conn.Close()
return health.StatusHealthy, nil
}
}
// HTTP returns a health check that performs a GET request to the specified URL.
//
// The check logic includes:
//
// 1. Fallback Timeout: If neither the client nor the request context has a
// deadline, a 10-second timeout is applied.
// 2. Connection Hygiene: The response body is fully drained and closed
// to ensure the underlying TCP connection can be reused.
// 3. Status Codes: Any status code in the 2xx or 3xx range is considered
// healthy.
//
// Requests are dispatched through [transport.DefaultClient] unless
// [WithClient] provides another one.
func HTTP(url string, opts ...Option) health.CheckFunc {
const defaultTimeout = 10 * time.Second
cfg := config{client: transport.DefaultClient}
for _, opt := range opts {
opt(&cfg)
}
client := cfg.client
return func(ctx context.Context) (health.Status, error) {
child := ctx
// If the client has no timeout set, we enforce a fallback timeout
// specifically for this check execution using the context.
// We only do this if the incoming context doesn't already have a
// deadline.
if _, deadline := ctx.Deadline(); !deadline && client.Timeout == 0 {
var cancel context.CancelFunc
child, cancel = context.WithTimeout(ctx, defaultTimeout)
defer cancel()
}
req, err := http.NewRequestWithContext(child, http.MethodGet, url, nil)
if err != nil {
return health.StatusSick, fmt.Errorf(
"http request %s: %w",
url,
err,
)
}
res, err := client.Do(req)
if err != nil {
return health.StatusSick, fmt.Errorf("http get %s: %w", url, err)
}
// Ensure the body is drained so the connection can be reused.
defer func() {
_, _ = io.Copy(io.Discard, res.Body)
_ = res.Body.Close()
}()
code := res.StatusCode
if code >= http.StatusOK && code < http.StatusBadRequest {
return health.StatusHealthy, nil
}
return health.StatusSick, fmt.Errorf(
"http get %s: unexpected status code: %d",
url, code,
)
}
}
// Pinger is an interface for types that support context-aware connectivity
// checks.
//
// This is most commonly satisfied by [*database/sql.DB] from the standard
// library.
type Pinger interface {
// PingContext verifies a connection to the target system is still alive.
PingContext(ctx context.Context) error
}
// Ping returns a health check that calls [Pinger.PingContext] on the
// provided [Pinger].
//
// It is ideal for monitoring the health of SQL database connections.
func Ping(p Pinger) health.CheckFunc {
return func(ctx context.Context) (health.Status, error) {
if err := p.PingContext(ctx); err != nil {
return health.StatusSick, err
}
return health.StatusHealthy, nil
}
}
// DNS returns a health check that verifies the provided host resolves
// to at least one IP address using the default system resolver.
func DNS(host string) health.CheckFunc {
return func(ctx context.Context) (health.Status, error) {
_, err := net.DefaultResolver.LookupHost(ctx, host)
if err != nil {
return health.StatusSick, fmt.Errorf("dns lookup %s: %w", host, err)
}
return health.StatusHealthy, nil
}
}
// Wrap converts a simple function that returns an error into a health check
// callback.
//
// The resulting check is not context-aware and will ignore the context passed
// during execution.
func Wrap(fn func() error) health.CheckFunc {
return func(_ context.Context) (health.Status, error) {
if err := fn(); err != nil {
return health.StatusSick, err
}
return health.StatusHealthy, nil
}
}
// WrapContext converts a context-aware function into a health check callback.
//
// This is used for custom checks that need to respect timeouts or cancellation
// signals provided by the [health.Monitor].
func WrapContext(fn func(context.Context) error) health.CheckFunc {
return func(ctx context.Context) (health.Status, error) {
if err := fn(ctx); err != nil {
return health.StatusSick, err
}
return health.StatusHealthy, nil
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package check
import (
"net/http"
)
// config holds the optional configuration for the [HTTP] check.
type config struct {
// client is the HTTP client used to perform the check request.
client *http.Client
}
// Option defines the functional option pattern for configuring the [HTTP]
// check.
type Option func(*config)
// WithClient sets the [http.Client] used to perform the check request.
// Defaults to [transport.DefaultClient]. Nil values are ignored.
func WithClient(client *http.Client) Option {
return func(c *config) {
if client != nil {
c.client = client
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package health
import (
"context"
"encoding/json/v2"
"fmt"
"net/http"
"sync"
"time"
"golang.org/x/sync/singleflight"
"github.com/deep-rent/nexus/net/router"
)
// Status enumerates the operational states of a dependency.
//
// Note that statuses are ranked by severity: [StatusHealthy] > [StatusDegraded]
// > [StatusSick]. This allows for direct comparison using standard operators.
type Status int
const (
// StatusSick indicates the dependency is non-functional.
StatusSick Status = iota
// StatusDegraded indicates the dependency is functioning but with
// issues (e.g., high latency).
StatusDegraded
// StatusHealthy indicates the dependency is functioning normally.
StatusHealthy
)
// String returns the human-readable representation of the [Status].
func (s Status) String() string {
switch s {
case StatusHealthy:
return "healthy"
case StatusDegraded:
return "degraded"
case StatusSick:
return "sick"
default:
return "unknown"
}
}
// MarshalJSON implements the [json.Marshaler] interface, ensuring that the
// status is represented by its string name in JSON output rather than its
// underlying integer value.
func (s Status) MarshalJSON() ([]byte, error) {
return json.Marshal(s.String())
}
// UnmarshalJSON implements the [json.Unmarshaler] interface. It converts a
// JSON string back into the corresponding [Status] integer constant. It returns
// an error if the string is not a recognized status.
func (s *Status) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return err
}
switch v {
case "healthy":
*s = StatusHealthy
case "degraded":
*s = StatusDegraded
case "sick":
*s = StatusSick
default:
return fmt.Errorf("invalid status: %s", v)
}
return nil
}
// Result holds the outcome of a health check execution.
type Result struct {
// Status is the state of the check.
Status Status `json:"status"`
// Error contains a descriptive error message if the check failed.
Error string `json:"error,omitempty"`
// Timestamp records when this check was actually executed.
Timestamp time.Time `json:"timestamp"`
}
// Report represents the aggregated outcome of all registered health checks.
type Report struct {
// Status is the overall health state of the application.
Status Status `json:"status"`
// Checks maps the name of each registered check to its specific [Result].
Checks map[string]Result `json:"checks"`
}
// CheckFunc defines the signature for a pluggable health check. It receives
// the request context to allow for cancellation and should return the
// perceived [Status] and an error if applicable.
type CheckFunc func(ctx context.Context) (Status, error)
// Kind is a bitmask used to categorize health checks for different probes.
type Kind int
const (
// KindReadiness indicates the check should be evaluated by the readiness
// probe.
KindReadiness Kind = 1 << iota
// KindLiveness indicates the check should be evaluated by the liveness
// probe.
KindLiveness
// KindAll is a convenience mask that includes all kinds of checks.
KindAll = KindReadiness | KindLiveness
)
// check wraps a registered check with its caching state and mutex.
type check struct {
name string // specific identifier for the health check
fn CheckFunc // health check callback to execute
ttl time.Duration // time for which the result is considered fresh
timeout time.Duration // maximum execution time for the check
kind Kind // bitmask of probes this check applies to
sf singleflight.Group // deduplicates concurrent executions
mu sync.RWMutex // protects access to the cached last result
last Result // most recently recorded result
}
// run executes the check or returns the cached result if the TTL hasn't
// expired. It protects against panics in the callback and deduplicates
// concurrent executions using singleflight.
func (c *check) run(ctx context.Context) Result {
c.mu.RLock()
if !c.last.Timestamp.IsZero() && time.Since(c.last.Timestamp) < c.ttl {
res := c.last
c.mu.RUnlock()
return res
}
c.mu.RUnlock()
ch := c.sf.DoChan("run", func() (any, error) {
// Detach context to prevent client disconnects from poisoning the
// cache.
bgCtx := context.WithoutCancel(ctx)
if c.timeout > 0 {
var cancel context.CancelFunc
bgCtx, cancel = context.WithTimeout(bgCtx, c.timeout)
defer cancel()
}
var res Result
func() {
defer func() {
if r := recover(); r != nil {
res = Result{
Status: StatusSick,
Error: fmt.Sprintf("health check panicked: %v", r),
Timestamp: time.Now(),
}
}
}()
status, err := c.fn(bgCtx)
msg := ""
if err != nil {
msg = err.Error()
// Default to sick if an error occurs but the status wasn't
// explicitly set to degraded.
if status != StatusDegraded {
status = StatusSick
}
}
res = Result{
Status: status,
Error: msg,
Timestamp: time.Now(),
}
}()
c.mu.Lock()
c.last = res
c.mu.Unlock()
return res, nil
})
select {
case <-ctx.Done():
// Client disconnected or request timed out. Return a stale result if
// available so we don't return an error while the check is successfully
// updating in the background.
c.mu.RLock()
res := c.last
c.mu.RUnlock()
if res.Timestamp.IsZero() {
return Result{
Status: StatusSick,
Error: ctx.Err().Error(),
Timestamp: time.Now(),
}
}
return res
case res := <-ch:
return res.Val.(Result)
}
}
// Monitor manages the registry of health checks and provides the
// [router]-compatible handlers. It is safe for concurrent use.
type Monitor struct {
// mu protects access to the internal map of checks.
mu sync.RWMutex
// checks stores registered health checks indexed by name.
checks map[string]*check
}
// NewMonitor creates a fresh [Monitor] instance.
func NewMonitor() *Monitor {
return &Monitor{
checks: make(map[string]*check),
}
}
// Attach registers a new health check under the given name. If a check with
// the same name already exists, it is replaced.
//
// The name should be formatted in snake_case (e.g., "redis_primary").
// The TTL (Time-To-Live) parameter defines the minimum duration between
// consecutive executions of the [CheckFunc]; subsequent calls within this
// window return the cached [Result] to prevent overloading the dependency.
func (m *Monitor) Attach(
name string,
ttl time.Duration,
fn CheckFunc,
opts ...Option,
) {
m.mu.Lock()
defer m.mu.Unlock()
c := &check{
name: name,
fn: fn,
ttl: ttl,
kind: KindAll,
}
for _, opt := range opts {
opt(c)
}
m.checks[name] = c
}
// Detach unregisters a health check by name. If the check does not exist, this
// is a no-op.
func (m *Monitor) Detach(name string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.checks, name)
}
// run runs all registered checks matching the given kind concurrently and
// compiles the overall [Status] from the gathered results.
func (m *Monitor) run(
ctx context.Context, kind Kind,
) (Status, map[string]Result) {
m.mu.RLock()
checks := make([]*check, 0, len(m.checks))
for _, c := range m.checks {
if c.kind&kind != 0 {
checks = append(checks, c)
}
}
m.mu.RUnlock()
results := make(map[string]Result, len(checks))
overall := StatusHealthy
var wg sync.WaitGroup
var mu sync.Mutex
for _, current := range checks {
wg.Go(func() {
res := current.run(ctx)
mu.Lock()
results[current.name] = res
if res.Status < overall {
overall = res.Status
}
mu.Unlock()
})
}
wg.Wait()
return overall, results
}
// Live returns a handler that evaluates liveness checks.
// It returns HTTP 503 (Service Unavailable) if any check results in
// [StatusSick]. Otherwise, it returns HTTP 200.
func (m *Monitor) Live() router.Handler {
return router.HandlerFunc(func(e *router.Exchange) error {
overall, results := m.run(e.Context(), KindLiveness)
code := http.StatusOK
if overall == StatusSick {
code = http.StatusServiceUnavailable
}
return e.JSON(code, Report{
Status: overall,
Checks: results,
})
})
}
// Ready returns a handler that evaluates readiness checks.
// It returns HTTP 503 (Service Unavailable) if any check results in
// [StatusSick]. Otherwise, it returns HTTP 200.
func (m *Monitor) Ready() router.Handler {
return router.HandlerFunc(func(e *router.Exchange) error {
overall, results := m.run(e.Context(), KindReadiness)
code := http.StatusOK
if overall == StatusSick {
code = http.StatusServiceUnavailable
}
return e.JSON(code, Report{
Status: overall,
Checks: results,
})
})
}
// Handler is an alias for [Monitor.Ready]. It provides a detailed JSON
// breakdown of all checks, suitable for monitoring scrapers and dashboards.
func (m *Monitor) Handler() router.Handler {
return m.Ready()
}
// Mount registers the standard health check routes on the provided
// [router.Router].
//
// It exposes:
// - GET /health: Detailed summary of all checks.
// - GET /health/live: Shallow liveness probe.
// - GET /health/ready: Deep readiness probe.
func (m *Monitor) Mount(r router.Registrar) {
r.Handle(http.MethodGet, "/health", m.Handler())
r.Handle(http.MethodGet, "/health/live", m.Live())
r.Handle(http.MethodGet, "/health/ready", m.Ready())
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package health
import "time"
// Option configures a health check.
type Option func(*check)
// WithTimeout sets a maximum execution duration for the check. If the check
// takes longer than this timeout, it is canceled and reports as sick.
func WithTimeout(t time.Duration) Option {
return func(c *check) {
c.timeout = t
}
}
// WithKind categorizes the check, restricting it to specific probes like
// liveness or readiness. By default, checks are evaluated for all probes.
func WithKind(k Kind) Option {
return func(c *check) {
c.kind = k
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package log
import (
"math"
"time"
"unsafe"
"uuid"
)
// Kind identifies the type of value carried by an [Arg].
type Kind uint8
const (
// KindString marks a string value.
KindString Kind = iota
// KindInt64 marks a signed integer value.
KindInt64
// KindUint64 marks an unsigned integer value.
KindUint64
// KindFloat64 marks a 64-bit floating-point value.
KindFloat64
// KindFloat32 marks a 32-bit floating-point value. It is distinct
// from [KindFloat64] so that values keep their 32-bit precision on
// output instead of picking up conversion noise.
KindFloat32
// KindBool marks a boolean value.
KindBool
// KindDuration marks a [time.Duration] value.
KindDuration
// KindTime marks a [time.Time] value.
KindTime
// KindError marks an error value.
KindError
)
// Arg is a typed key/value pair attached to a log record. Args are plain
// values constructed with functions like [String], [Int], or [Error];
// building one does not allocate for any kind except [Time] values that
// fall outside the range representable as Unix nanoseconds.
//
// The set of kinds is fixed. There is deliberately no constructor for
// arbitrary values: every value passes through a typed constructor, which
// keeps sinks free of reflection. Callers with a complex value serialize
// it themselves and log the result as a [String].
//
// Args are not comparable with ==, since string values are packed as
// pointer and length; compare keys, kinds, and [Arg.Value] instead.
type Arg struct {
_ [0]func() // disallow ==
// Key names the argument in the log record.
Key string
kind Kind
num uint64
any any
}
// stringptr marks the packed data pointer of a string value held in
// Arg.any. Being pointer-shaped, it enters the interface without
// allocating.
type stringptr *byte
// str unpacks the string carried by a [KindString] argument.
func (a Arg) str() string {
if p, ok := a.any.(stringptr); ok {
// The pointer and the length were taken from one string in
// String below, and an Arg is immutable once built.
return unsafe.String(p, int(a.num)) // #nosec G103
}
return ""
}
// Kind returns the kind of the value carried by the argument.
func (a Arg) Kind() Kind {
return a.kind
}
// Value returns the carried value boxed as any: a string, int64, uint64,
// float64, float32, bool, [time.Duration], [time.Time], or error,
// depending on the kind. Value is meant for tests and custom sinks; the
// built-in JSON sink never boxes.
func (a Arg) Value() any {
switch a.kind {
case KindString:
return a.str()
case KindInt64:
return int64(a.num)
case KindUint64:
return a.num
case KindFloat64:
return math.Float64frombits(a.num)
case KindFloat32:
return math.Float32frombits(uint32(a.num))
case KindBool:
return a.num != 0
case KindDuration:
return time.Duration(a.num)
case KindTime:
return a.time()
case KindError:
if a.any == nil {
return nil
}
return a.any
default:
return nil
}
}
// String returns an [Arg] carrying a string value. The value is packed
// as data pointer and length, so no allocation or copy takes place.
func String(key, val string) Arg {
return Arg{
Key: key,
kind: KindString,
num: uint64(len(val)),
// #nosec G103 -- the header is kept whole; see str.
any: stringptr(unsafe.StringData(val)),
}
}
// Int returns an [Arg] carrying an int value as an int64.
func Int(key string, val int) Arg {
return Int64(key, int64(val))
}
// Int8 returns an [Arg] carrying an int8 value as an int64.
func Int8(key string, val int8) Arg {
return Int64(key, int64(val))
}
// Int16 returns an [Arg] carrying an int16 value as an int64.
func Int16(key string, val int16) Arg {
return Int64(key, int64(val))
}
// Int32 returns an [Arg] carrying an int32 value as an int64.
func Int32(key string, val int32) Arg {
return Int64(key, int64(val))
}
// Int64 returns an [Arg] carrying an int64 value.
func Int64(key string, val int64) Arg {
return Arg{Key: key, kind: KindInt64, num: uint64(val)}
}
// Uint returns an [Arg] carrying a uint value as a uint64.
func Uint(key string, val uint) Arg {
return Uint64(key, uint64(val))
}
// Uint8 returns an [Arg] carrying a uint8 value as a uint64.
func Uint8(key string, val uint8) Arg {
return Uint64(key, uint64(val))
}
// Uint16 returns an [Arg] carrying a uint16 value as a uint64.
func Uint16(key string, val uint16) Arg {
return Uint64(key, uint64(val))
}
// Uint32 returns an [Arg] carrying a uint32 value as a uint64.
func Uint32(key string, val uint32) Arg {
return Uint64(key, uint64(val))
}
// Uint64 returns an [Arg] carrying a uint64 value.
func Uint64(key string, val uint64) Arg {
return Arg{Key: key, kind: KindUint64, num: val}
}
// Float32 returns an [Arg] carrying a float32 value. Unlike a conversion
// through [Float64], the value is encoded at 32-bit precision, so its
// shortest decimal representation is preserved.
func Float32(key string, val float32) Arg {
return Arg{Key: key, kind: KindFloat32, num: uint64(math.Float32bits(val))}
}
// Float64 returns an [Arg] carrying a float64 value.
func Float64(key string, val float64) Arg {
return Arg{Key: key, kind: KindFloat64, num: math.Float64bits(val)}
}
// Bool returns an [Arg] carrying a bool value.
func Bool(key string, val bool) Arg {
var n uint64
if val {
n = 1
}
return Arg{Key: key, kind: KindBool, num: n}
}
// Duration returns an [Arg] carrying a [time.Duration] value. The JSON
// sink encodes durations as seconds, matching the convention used for
// metrics throughout this codebase.
func Duration(key string, val time.Duration) Arg {
return Arg{Key: key, kind: KindDuration, num: uint64(val)}
}
// Time returns an [Arg] carrying a [time.Time] value. The JSON sink
// encodes times in UTC using [time.RFC3339Nano]; the location of the given
// value does not survive, only the instant.
func Time(key string, val time.Time) Arg {
// The instant is stored as Unix nanoseconds to avoid boxing; times
// outside the representable range fall back to the interface field.
if y := val.Year(); y >= 1679 && y <= 2261 {
return Arg{Key: key, kind: KindTime, num: uint64(val.UnixNano())}
}
return Arg{Key: key, kind: KindTime, any: val}
}
// time reconstructs the instant carried by a [KindTime] argument.
func (a Arg) time() time.Time {
if t, ok := a.any.(time.Time); ok {
return t
}
return time.Unix(0, int64(a.num)).UTC()
}
// UUID returns an [Arg] carrying the canonical textual form of a
// [uuid.UUID] as a string value.
func UUID(key string, id uuid.UUID) Arg {
return String(key, id.String())
}
// ErrorKey is the key under which [Error] records an error. It is exported
// so that sinks and log processors can find errors by a stable name.
const ErrorKey = "error"
// Error returns an [Arg] carrying err under the [ErrorKey]. It is the
// canonical way to log an error in this codebase, so that every error is
// recorded under the same key and enriching that record later is a change
// in one place rather than at every call site:
//
// logger.Error(ctx, "Failed to fetch resource", log.Error(err))
//
// A nil error is encoded as null; callers should log an error argument
// only when there is an error to report.
func Error(err error) Arg {
return Arg{Key: ErrorKey, kind: KindError, any: err}
}
// ErrorIDKey is the key under which an error's occurrence identifier is
// recorded, correlating a client-side report with the server-side log. It
// is exported so that the error boundaries attaching one, as well as sinks
// and log processors reading it, agree on a stable name.
const ErrorIDKey = "error_id"
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package log
import (
"bytes"
"strings"
"sync"
)
// Buffer is a concurrency-safe [io.Writer] that captures log output for
// inspection, primarily in tests. Unlike [bytes.Buffer], it may be read
// while other goroutines are still logging. The zero value is ready for
// use.
type Buffer struct {
mu sync.Mutex
buf bytes.Buffer
}
// Capture couples a new [Logger] to a fresh [Buffer] capturing its
// output. The buffer overrides any [WithWriter] option.
func Capture(opts ...Option) (*Logger, *Buffer) {
b := new(Buffer)
return New(append(opts, WithWriter(b))...), b
}
// Write implements [io.Writer].
func (b *Buffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
// String returns a snapshot of the captured output.
func (b *Buffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
// Bytes returns a copy of the captured output.
func (b *Buffer) Bytes() []byte {
b.mu.Lock()
defer b.mu.Unlock()
return bytes.Clone(b.buf.Bytes())
}
// Lines splits the captured output into individual records, dropping the
// trailing newline. Since each record is a single JSON line, tests can
// assert on counts and unmarshal single elements.
func (b *Buffer) Lines() []string {
lines := strings.Split(b.String(), "\n")
if n := len(lines); n > 0 && lines[n-1] == "" {
lines = lines[:n-1]
}
return lines
}
// Reset discards the captured output.
func (b *Buffer) Reset() {
b.mu.Lock()
defer b.mu.Unlock()
b.buf.Reset()
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package log
import "context"
// levelKey is the context key under which a level override is stored.
type levelKey struct{}
// SetLevel returns a context carrying a level override. For records
// logged under the returned context, the JSON sink replaces its
// configured threshold with the given level, in either direction: the
// override can force debug output for a single request as well as quiet a
// known-noisy code path.
//
// ctx = log.SetLevel(ctx, log.LevelDebug)
func SetLevel(ctx context.Context, level Level) context.Context {
return context.WithValue(ctx, levelKey{}, level)
}
// GetLevel returns the level override carried by ctx, if any. It is
// exported for use by custom [Sink] implementations, which should honor
// the override in their [Sink.Enabled] method.
func GetLevel(ctx context.Context) (Level, bool) {
level, ok := ctx.Value(levelKey{}).(Level)
return level, ok
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package log
import (
"context"
"io"
"math"
"os"
"slices"
"strconv"
"sync"
"time"
"unicode/utf8"
"github.com/deep-rent/nexus/std/ascii"
)
// NewSink creates and configures the JSON [Sink]. By default, it logs at
// [DefaultLevel] to [os.Stdout]. These defaults can be overridden by
// passing in one or more [Option] functions.
//
// The sink appends one JSON object per record, terminated by a newline,
// in a single write. Timestamps are normalized to UTC and formatted as
// [time.RFC3339Nano]; there are no other formats. Encoding is
// reflection-free: each record is assembled in a pooled buffer, and
// arguments bound via [Sink.With] are pre-encoded once.
func NewSink(opts ...Option) Sink {
c := config{
level: DefaultLevel,
writer: os.Stdout,
}
for _, opt := range opts {
opt(&c)
}
cutoff := c.cutoff
if cutoff == nil {
cutoff = NewCutoff(c.level)
}
var redact map[string]struct{}
if len(c.redact) > 0 {
redact = make(map[string]struct{}, len(c.redact))
for _, k := range c.redact {
redact[ascii.ToLower(k)] = struct{}{}
}
}
return &sink{
out: c.writer,
mu: new(sync.Mutex),
cutoff: cutoff,
redact: redact,
derive: c.derive,
}
}
// The keys under which the JSON sink records the fixed fields present on
// every record. They are exported so that log processors can find those
// fields by a stable name.
const (
// TimeKey is the key of the record timestamp, normalized to UTC and
// formatted as [time.RFC3339Nano].
TimeKey = "time"
// LevelKey is the key of the record level, in lower case.
LevelKey = "level"
// NameKey is the key of the dotted logger path, such as
// "http.server". It is omitted for the root logger.
NameKey = "name"
// MessageKey is the key of the log message.
MessageKey = "message"
)
// sink implements the JSON [Sink] returned by [NewSink].
type sink struct {
// out is the output destination.
out io.Writer
// mu serializes writes to out. It is a pointer so that all sinks
// derived via [Sink.With] share it.
mu *sync.Mutex
// cutoff is the level threshold, possibly shared.
cutoff *Cutoff
// redact holds lower-cased keys whose values are masked.
redact map[string]struct{}
// derive derives ambient arguments from the record context.
derive func(ctx context.Context, args []Arg) []Arg
// prefix holds the arguments bound via [Sink.With], pre-encoded as a raw
// JSON fragment of the form `,"key":value,...`.
prefix []byte
}
// Enabled implements [Sink.Enabled]. It reports whether the sink will
// accept records at the given level. Sinks forward level decisions to a
// shared [Cutoff] and respects any per-call [Level] override set in the
// context via [WithLevel].
func (s *sink) Enabled(ctx context.Context, level Level) bool {
if level == LevelSilent || level > LevelDebug {
return false
}
cut := s.cutoff.Level()
if override, ok := GetLevel(ctx); ok {
cut = override
}
return level <= cut
}
// Receive implements [Sink.Receive]. It formats the record as a single
// JSON object on a line, then writes it to the underlying io.Writer. The
// method synchronizes concurrent calls, so multiple receivers may safely
// share a single sink.
func (s *sink) Receive(ctx context.Context, r Record) {
bp := pool.Get().(*[]byte)
b := (*bp)[:0]
b = append(b, '{', '"')
b = append(b, TimeKey...)
b = append(b, `":"`...)
b = r.Time.UTC().AppendFormat(b, time.RFC3339Nano)
b = append(b, `","`...)
b = append(b, LevelKey...)
b = append(b, `":"`...)
b = append(b, r.Level.String()...)
b = append(b, '"')
if r.Logger != "" {
b = append(b, `,"`...)
b = append(b, NameKey...)
b = append(b, `":`...)
b = addString(b, r.Logger)
}
b = append(b, `,"`...)
b = append(b, MessageKey...)
b = append(b, `":`...)
b = addString(b, r.Msg)
b = append(b, s.prefix...)
if s.derive != nil {
var scratch [4]Arg
for _, a := range s.derive(ctx, scratch[:0]) {
b = s.add(b, a)
}
}
for _, a := range r.Args {
b = s.add(b, a)
}
b = append(b, '}', '\n')
s.mu.Lock()
_, _ = s.out.Write(b)
s.mu.Unlock()
// Return the buffer to the pool unless a huge record grew it; holding
// on to such a buffer would pin its memory indefinitely.
if cap(b) <= maxPooled {
*bp = b
pool.Put(bp)
}
}
// With implements [Sink.With]. It returns a new sink that appends
// the given arguments to every record it later receives, after existing
// ambient and explicitly-provided arguments. Sinks returned by this method
// share the same [Cutoff] and redactor configuration as the receiver.
func (s *sink) With(args []Arg) Sink {
if len(args) == 0 {
return s
}
c := *s
// Clip forces the first append to reallocate, so that sibling sinks
// derived from the same parent never share backing memory.
prefix := slices.Clip(s.prefix)
for _, a := range args {
prefix = c.add(prefix, a)
}
c.prefix = prefix
return &c
}
// redacted is the marker substituted for masked argument values.
const redacted = `"[REDACTED]"`
// add appends an argument to the byte buffer as a `,"key":value` fragment.
func (s *sink) add(b []byte, a Arg) []byte {
b = append(b, ',')
b = addString(b, a.Key)
b = append(b, ':')
if s.redact != nil {
if _, ok := s.redact[ascii.ToLower(a.Key)]; ok {
return append(b, redacted...)
}
}
switch a.kind {
case KindString:
return addString(b, a.str())
case KindInt64:
return strconv.AppendInt(b, int64(a.num), 10)
case KindUint64:
return strconv.AppendUint(b, a.num, 10)
case KindFloat64:
return addNumber(b, math.Float64frombits(a.num), 64)
case KindFloat32:
f := math.Float32frombits(uint32(a.num))
return addNumber(b, float64(f), 32)
case KindBool:
return strconv.AppendBool(b, a.num != 0)
case KindDuration:
return addNumber(b, time.Duration(a.num).Seconds(), 64)
case KindTime:
b = append(b, '"')
b = a.time().UTC().AppendFormat(b, time.RFC3339Nano)
return append(b, '"')
case KindError:
if a.any == nil {
return append(b, "null"...)
}
return addString(b, a.any.(error).Error())
default:
return append(b, "null"...)
}
}
// addNumber appends f as a JSON number using the shortest decimal
// representation for the given bit size. NaN and the infinities have no
// JSON representation and are encoded as strings instead.
func addNumber(b []byte, f float64, bits int) []byte {
switch {
case math.IsNaN(f):
return append(b, `"NaN"`...)
case math.IsInf(f, +1):
return append(b, `"+Inf"`...)
case math.IsInf(f, -1):
return append(b, `"-Inf"`...)
default:
return strconv.AppendFloat(b, f, 'g', -1, bits)
}
}
// hexDigits is used to encode control characters as \u00XX escapes.
const hexDigits = "0123456789abcdef"
// addString appends s to b as a JSON string literal. Runs of safe
// bytes are copied in bulk; control characters are escaped, and invalid
// UTF-8 is replaced by the Unicode replacement character, matching
// [encoding/json].
func addString(b []byte, s string) []byte {
b = append(b, '"')
start := 0
for i := 0; i < len(s); {
if c := s[i]; c < utf8.RuneSelf {
if c >= 0x20 && c != '"' && c != '\\' {
i++
continue
}
b = append(b, s[start:i]...)
switch c {
case '"':
b = append(b, '\\', '"')
case '\\':
b = append(b, '\\', '\\')
case '\n':
b = append(b, '\\', 'n')
case '\r':
b = append(b, '\\', 'r')
case '\t':
b = append(b, '\\', 't')
default:
b = append(b,
'\\', 'u', '0', '0',
hexDigits[c>>4],
hexDigits[c&0xF],
)
}
i++
start = i
continue
}
r, size := utf8.DecodeRuneInString(s[i:])
if r == utf8.RuneError && size == 1 {
b = append(b, s[start:i]...)
b = append(b, `�`...)
i += size
start = i
continue
}
i += size
}
b = append(b, s[start:]...)
return append(b, '"')
}
// maxPooled caps the capacity of buffers returned to the pool.
const maxPooled = 16 << 10
// pool recycles record buffers across [Sink.Receive] calls.
var pool = sync.Pool{
New: func() any {
b := make([]byte, 0, 1<<10)
return &b
},
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package log
import (
"fmt"
"sync/atomic"
"github.com/deep-rent/nexus/std/ascii"
)
// Level indicates the severity of a log record. Severity decreases with
// increasing numeric value: [LevelError] is the most severe, [LevelDebug]
// the least. The zero value is [LevelSilent], which disables logging
// entirely, so an unconfigured threshold stays quiet.
//
// The level set is fixed. There is deliberately no fatal or panic level;
// see the package documentation.
type Level byte
const (
// LevelSilent disables all output. It is a threshold, not a loggable
// level: it may be used to configure a sink, but records logged at this
// level are discarded.
LevelSilent Level = iota
// LevelError marks failures that require attention.
LevelError
// LevelWarn marks anomalies that do not prevent normal operation.
LevelWarn
// LevelInfo marks noteworthy state changes during normal operation.
LevelInfo
// LevelDebug marks details useful only for troubleshooting.
LevelDebug
)
// String returns the lower-case name of the level. Values outside the
// defined range are reported as "silent".
func (l Level) String() string {
switch l {
case LevelError:
return "error"
case LevelWarn:
return "warn"
case LevelInfo:
return "info"
case LevelDebug:
return "debug"
default:
return "silent"
}
}
// AppendText implements [encoding.TextAppender]. It appends the name of
// the level to b without allocating.
func (l Level) AppendText(b []byte) ([]byte, error) {
if l > LevelDebug {
return b, fmt.Errorf("invalid log level %d", byte(l))
}
return append(b, l.String()...), nil
}
// MarshalText implements [encoding.TextMarshaler].
func (l Level) MarshalText() ([]byte, error) {
return l.AppendText(nil)
}
// UnmarshalText implements [encoding.TextUnmarshaler]. Level names are
// matched case-insensitively.
func (l *Level) UnmarshalText(text []byte) error {
switch ascii.ToLower(string(text)) {
case "silent":
*l = LevelSilent
case "error":
*l = LevelError
case "warn":
*l = LevelWarn
case "info":
*l = LevelInfo
case "debug":
*l = LevelDebug
default:
return fmt.Errorf("invalid log level %q", text)
}
return nil
}
// ParseLevel converts a case-insensitive string into a [Level]. Valid
// inputs are "silent", "error", "warn", "info", and "debug". It returns an
// error for any other value.
func ParseLevel(s string) (level Level, err error) {
err = level.UnmarshalText([]byte(s))
return level, err
}
// Cutoff is an atomically adjustable level threshold. Sharing one Cutoff
// between several sinks lets a single control point, such as a SIGHUP
// handler or an admin endpoint, retune all of them at runtime.
//
// The zero value cuts off everything ([LevelSilent]). A Cutoff must not be
// copied after first use.
type Cutoff struct{ v atomic.Uint32 }
// NewCutoff creates a [Cutoff] starting at the given level.
func NewCutoff(level Level) *Cutoff {
c := new(Cutoff)
c.Set(level)
return c
}
// Level returns the current threshold.
func (c *Cutoff) Level() Level {
return Level(c.v.Load())
}
// Set atomically replaces the threshold. Values beyond [LevelDebug] are
// clamped to it.
func (c *Cutoff) Set(level Level) {
c.v.Store(uint32(min(level, LevelDebug)))
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package log
import (
"context"
"time"
)
// Logger is the front-end of the logging facility. It is a concrete type
// on purpose: the policy it enforces, such as leveled, context-aware calls
// and typed arguments, is fixed, while extension happens behind the [Sink]
// interface. Loggers are immutable and safe for concurrent use; methods
// like [Logger.Child] and [Logger.With] derive new loggers rather than
// modifying the receiver.
//
// Methods must not be called on a nil Logger; use [Discard] for a logger
// that is deliberately inert. The context passed to them must not be nil
// either; pass [context.Background] where no context is at hand.
type Logger struct {
sink Sink
name string
path string
parent *Logger
}
// New creates a root [Logger] backed by the JSON sink returned by
// [NewSink] with the same options. By default, it logs at [LevelInfo] to
// [os.Stdout].
func New(opts ...Option) *Logger {
return Wrap(NewSink(opts...))
}
// Wrap creates a root [Logger] on top of an arbitrary [Sink]. A nil sink
// yields a logger that discards everything.
func Wrap(sink Sink) *Logger {
if sink == nil {
sink = discard{}
}
return &Logger{sink: sink}
}
// discardLogger backs [Discard]. It is shared; loggers are immutable.
var discardLogger = &Logger{sink: discard{}}
// Discard returns a logger that reports every level as disabled and drops
// all records. It is the explicit value for optional logger fields; unlike
// a logger pointed at a discarding writer, callers guarding expensive work
// with [Logger.Enabled] skip it entirely.
func Discard() *Logger {
return discardLogger
}
// Sink returns the sink backing the logger.
func (l *Logger) Sink() Sink {
return l.sink
}
// Name returns the last segment of the logger's path, such as "server"
// for the logger "http.server". It is empty for the root logger.
func (l *Logger) Name() string {
return l.name
}
// Path returns the dotted path of the logger, such as "http.server". It
// is empty for the root logger.
func (l *Logger) Path() string {
return l.path
}
// Parent returns the logger this logger was derived from by
// [Logger.Child], or nil for a root logger.
func (l *Logger) Parent() *Logger {
return l.parent
}
// Child derives a named sub-logger. The child shares the parent's sink,
// and its dotted path, recorded under the [NameKey] of every record,
// extends the parent's path by the given name. An empty name returns the
// receiver unchanged.
func (l *Logger) Child(name string) *Logger {
if name == "" {
return l
}
path := name
if l.path != "" {
path = l.path + "." + name
}
return &Logger{sink: l.sink, name: name, path: path, parent: l}
}
// With returns a logger that includes the given arguments in every
// record, ahead of the arguments passed at the call site. The bound
// arguments are pre-encoded by the JSON sink, so they are paid for once
// rather than on every call. Without arguments, the receiver is returned
// unchanged.
func (l *Logger) With(args ...Arg) *Logger {
if len(args) == 0 {
return l
}
c := *l
c.sink = l.sink.With(args)
return &c
}
// Enabled reports whether a record at the given level would be emitted.
// Use it to guard work that is expensive even before its result is passed
// to the logger:
//
// if logger.Enabled(ctx, log.LevelDebug) {
// logger.Debug(ctx, "State dump", log.String("state", dump()))
// }
func (l *Logger) Enabled(ctx context.Context, level Level) bool {
if level == LevelSilent || level > LevelDebug {
return false
}
return l.sink.Enabled(ctx, level)
}
// Log emits a record at the given level. Records at [LevelSilent] or at
// levels outside the defined range are discarded.
//
// The message is a short, self-contained phrase that starts with a capital
// letter and carries no trailing period, such as "Server started" or
// "Failed to open database". It is a constant: everything variable belongs
// in the arguments, so that records of the same kind share one message and
// remain groupable.
func (l *Logger) Log(
ctx context.Context,
level Level,
msg string,
args ...Arg,
) {
l.log(ctx, level, msg, args)
}
// Error emits a record at [LevelError].
func (l *Logger) Error(ctx context.Context, msg string, args ...Arg) {
l.log(ctx, LevelError, msg, args)
}
// Warn emits a record at [LevelWarn].
func (l *Logger) Warn(ctx context.Context, msg string, args ...Arg) {
l.log(ctx, LevelWarn, msg, args)
}
// Info emits a record at [LevelInfo].
func (l *Logger) Info(ctx context.Context, msg string, args ...Arg) {
l.log(ctx, LevelInfo, msg, args)
}
// Debug emits a record at [LevelDebug].
func (l *Logger) Debug(ctx context.Context, msg string, args ...Arg) {
l.log(ctx, LevelDebug, msg, args)
}
// log implements the emission path shared by all logging methods. The
// timestamp is taken only after the level check, so disabled calls cost
// little more than a branch.
func (l *Logger) log(ctx context.Context, level Level, msg string, args []Arg) {
if level == LevelSilent || level > LevelDebug {
return
}
if !l.sink.Enabled(ctx, level) {
return
}
l.sink.Receive(ctx, Record{
Time: time.Now(),
Level: level,
Logger: l.path,
Msg: msg,
Args: args,
})
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package log
import (
"context"
"io"
)
// DefaultLevel is the threshold used when none is specified.
const DefaultLevel = LevelInfo
// config holds the configuration settings for the JSON sink.
type config struct {
// level is the initial threshold; ignored if cutoff is set.
level Level
// cutoff is a shared, externally adjustable threshold.
cutoff *Cutoff
// writer is the output destination.
writer io.Writer
// redact lists keys whose values are masked on output.
redact []string
// derive derives ambient arguments from the context of each record.
derive func(ctx context.Context, args []Arg) []Arg
}
// Option defines a function that modifies the JSON sink configuration.
type Option func(*config)
// WithLevel sets the minimum log level. The sink owns a private [Cutoff]
// initialized to this level; use [WithCutoff] instead to share an
// adjustable threshold, in which case this option is ignored.
func WithLevel(level Level) Option {
return func(c *config) {
c.level = level
}
}
// WithCutoff shares an externally adjustable threshold with the sink,
// overriding [WithLevel]. A nil cutoff is ignored.
func WithCutoff(cutoff *Cutoff) Option {
return func(c *config) {
if cutoff != nil {
c.cutoff = cutoff
}
}
}
// WithWriter sets the output destination for the logs. The sink
// serializes its writes, so the writer need not be safe for concurrent
// use. A nil writer is ignored.
//
// The sink issues one write per record. High-volume deployments where
// the resulting system calls show up in profiles can wrap the
// destination in a [flush.Writer] to batch them, trading a bounded loss
// window on a crash.
//
// [flush.Writer]: github.com/deep-rent/nexus/std/flush#Writer
func WithWriter(w io.Writer) Option {
return func(c *config) {
if w != nil {
c.writer = w
}
}
}
// WithRedact masks the value of any argument whose key matches one of the
// given names with a fixed marker. Key comparison is case-insensitive,
// since header- and field-derived keys vary in casing. Repeated use adds
// to the set.
//
// log.New(log.WithRedact("authorization", "password", "set-cookie"))
//
// Redaction applies to call-site arguments and to arguments bound with
// [Logger.With] alike. It guards accidental leaks; it is not a substitute
// for not logging secrets in the first place.
func WithRedact(keys ...string) Option {
return func(c *config) {
c.redact = append(c.redact, keys...)
}
}
// WithAmbient sets a function that derives ambient arguments, such as a
// trace or request ID, from the context of each record. The function
// appends to args and returns the result; it runs after the level check,
// once per emitted record. A nil function is ignored.
//
// log.New(log.WithAmbient(
// func(ctx context.Context, args []log.Arg) []log.Arg {
// if id, ok := trace.FromContext(ctx); ok {
// args = append(args, log.String("trace_id", id))
// }
// return args
// },
// ))
func WithAmbient(fn func(ctx context.Context, args []Arg) []Arg) Option {
return func(c *config) {
if fn != nil {
c.derive = fn
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package log
import (
"context"
"slices"
"sync"
)
// Recorder is a [Sink] that captures records in memory, letting tests
// assert on what was logged without coupling to the JSON wire format. It
// enables every level, and arguments bound via [Recorder.With] are
// materialized into each captured record, ahead of the call-site arguments.
type Recorder struct {
state *recorderState
bound []Arg
}
// recorderState is the capture storage shared between a [Recorder] and
// all sinks derived from it via [Recorder.With].
type recorderState struct {
mu sync.Mutex
records []Record
}
// NewRecorder creates an empty [Recorder].
func NewRecorder() *Recorder {
return &Recorder{state: new(recorderState)}
}
// Enabled implements [Sink]. All defined levels are enabled.
func (*Recorder) Enabled(_ context.Context, level Level) bool {
return level != LevelSilent && level <= LevelDebug
}
// Receive implements [Sink]. The record's arguments are copied, so the
// captured records remain valid indefinitely.
func (r *Recorder) Receive(_ context.Context, rec Record) {
args := make([]Arg, 0, len(r.bound)+len(rec.Args))
args = append(args, r.bound...)
args = append(args, rec.Args...)
rec.Args = args
r.state.mu.Lock()
defer r.state.mu.Unlock()
r.state.records = append(r.state.records, rec)
}
// With implements [Sink]. The derived sink records into the same
// [Recorder], so records logged through it are visible to
// [Recorder.Records].
func (r *Recorder) With(args []Arg) Sink {
if len(args) == 0 {
return r
}
return &Recorder{
state: r.state,
bound: append(slices.Clip(r.bound), args...),
}
}
// Records returns a copy of the captured records in order of arrival.
func (r *Recorder) Records() []Record {
r.state.mu.Lock()
defer r.state.mu.Unlock()
return slices.Clone(r.state.records)
}
// Reset discards the captured records.
func (r *Recorder) Reset() {
r.state.mu.Lock()
defer r.state.mu.Unlock()
r.state.records = nil
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package log
import (
"context"
"slices"
)
// Sink is the back-end of a [Logger]: it decides which records to accept
// and encodes those it receives. Implementations must be safe for
// concurrent use.
type Sink interface {
// Enabled reports whether the sink accepts records at the given level.
// Loggers consult it before constructing a [Record], so expensive work
// is skipped entirely when the level is cut off.
Enabled(ctx context.Context, level Level) bool
// Receive handles a single record. Callers must invoke it only after
// [Sink.Enabled] has reported true for the record's level. The record's
// Args slice is only valid for the duration of the call; a sink that
// retains arguments must copy them.
Receive(ctx context.Context, r Record)
// With returns a sink that includes the given arguments in every record
// it receives, ahead of the record's own arguments. The receiver is not
// modified. Implementations are encouraged to pre-encode the bound
// arguments so that repeated records pay for them only once.
With(args []Arg) Sink
}
// Multi returns a [Sink] that fans records out to all given sinks. It
// reports a level as enabled if any sink enables it, and forwards a record
// only to those sinks that do. Without arguments, it returns a sink that
// discards everything; with a single argument, it returns that sink
// unchanged.
func Multi(sinks ...Sink) Sink {
switch len(sinks) {
case 0:
return discard{}
case 1:
return sinks[0]
default:
return multi{sinks: slices.Clone(sinks)}
}
}
// multi implements the fan-out [Sink] returned by [Multi].
type multi struct {
sinks []Sink
}
func (m multi) Enabled(ctx context.Context, level Level) bool {
for _, s := range m.sinks {
if s.Enabled(ctx, level) {
return true
}
}
return false
}
func (m multi) Receive(ctx context.Context, r Record) {
for _, s := range m.sinks {
if s.Enabled(ctx, r.Level) {
s.Receive(ctx, r)
}
}
}
func (m multi) With(args []Arg) Sink {
sinks := make([]Sink, len(m.sinks))
for i, s := range m.sinks {
sinks[i] = s.With(args)
}
return multi{sinks: sinks}
}
// discard is a [Sink] that reports every level as disabled and drops any
// record it receives.
type discard struct{}
func (discard) Enabled(context.Context, Level) bool { return false }
func (discard) Receive(context.Context, Record) {}
func (discard) With([]Arg) Sink { return discard{} }
var (
_ Sink = multi{}
_ Sink = discard{}
)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package log
import (
"context"
"io"
stdlog "log"
"strings"
)
// Writer adapts the logger to an [io.Writer], logging each write as one
// record at the given level with the written text as its message. Trailing
// newlines are stripped and blank writes are dropped, so a line-oriented
// producer yields one record per line.
//
// It exists for the standard library's own interfaces, which predate
// structured logging and accept an [io.Writer] or a [log.Logger] — see
// [Logger.Std]. Nothing in this repository should log through it
// directly: the text arrives as an opaque message, so none of it can be
// searched as a field.
//
// The context is captured, since the writing code has none to pass. Use
// the context of the surrounding component, so that per-request overrides
// such as [SetLevel] apply as they would elsewhere.
func (l *Logger) Writer(ctx context.Context, level Level) io.Writer {
return &writer{logger: l, ctx: ctx, level: level}
}
// writer is the [io.Writer] returned by [Logger.Writer].
type writer struct {
logger *Logger
ctx context.Context
level Level
}
// Write implements [io.Writer]. It always reports the full slice as
// written: a dropped record is not a write failure, and reporting a short
// write would make the standard library's logger report an error of its
// own.
func (w *writer) Write(p []byte) (int, error) {
if msg := strings.TrimRight(string(p), "\n"); msg != "" {
w.logger.Log(w.ctx, w.level, msg)
}
return len(p), nil
}
// Std wraps the logger in a [log.Logger] from the standard library, for
// the APIs that insist on one — [net/http.Server.ErrorLog] above all.
// Records arrive at the given level with the formatted line as their
// message.
//
// The returned logger carries no prefix and no flags, since the sink
// stamps the timestamp and the level itself:
//
// srv := &http.Server{
// ErrorLog: logger.Child("http").Std(ctx, log.LevelWarn),
// }
//
// Without this, such APIs write to the standard library's default logger,
// and their output bypasses the sink entirely — landing in the stream as
// unstructured text among the JSON lines.
func (l *Logger) Std(ctx context.Context, level Level) *stdlog.Logger {
return stdlog.New(l.Writer(ctx, level), "", 0)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package metrics
import (
"fmt"
"slices"
"strings"
"sync"
"time"
)
// Tag is a key/value pair qualifying a metric, comparable to a Prometheus
// label. Instruments with the same name but different tags are independent.
type Tag struct {
Key string
Val string
}
// T builds a [Tag]. It reads better than a keyed struct literal at call
// sites, which tend to stack several tags:
//
// reg.Counter("requests_total", metrics.T("route", "/users"))
func T(key, val string) Tag {
return Tag{Key: key, Val: val}
}
// Kind identifies the type of an instrument.
type Kind uint8
const (
// KindCounter identifies a [Counter].
KindCounter Kind = iota
// KindGauge identifies a [Gauge].
KindGauge
// KindHistogram identifies a [Histogram].
KindHistogram
// KindMeter identifies a [Meter].
KindMeter
// KindTimer identifies a [Timer].
KindTimer
// KindSummary identifies a [Summary].
KindSummary
)
// String returns the lower-case name of the kind.
func (k Kind) String() string {
switch k {
case KindCounter:
return "counter"
case KindGauge:
return "gauge"
case KindHistogram:
return "histogram"
case KindMeter:
return "meter"
case KindTimer:
return "timer"
case KindSummary:
return "summary"
default:
return "unknown"
}
}
// MarshalText implements [encoding.TextMarshaler].
func (k Kind) MarshalText() ([]byte, error) {
return []byte(k.String()), nil
}
// UnmarshalText implements [encoding.TextUnmarshaler].
func (k *Kind) UnmarshalText(text []byte) error {
switch string(text) {
case "counter":
*k = KindCounter
case "gauge":
*k = KindGauge
case "histogram":
*k = KindHistogram
case "meter":
*k = KindMeter
case "timer":
*k = KindTimer
case "summary":
*k = KindSummary
default:
return fmt.Errorf("invalid metric kind %q", text)
}
return nil
}
// instrument is the interface shared by all primitives, used internally by
// the registry to take snapshots.
type instrument interface {
kind() Kind
// sample fills the kind-specific fields of a snapshot sample.
sample(s *Sample)
}
// entry pairs an instrument with its identity for snapshotting.
type entry struct {
name string
tags []Tag // sorted by key
inst instrument
}
// Registry is a collection of named, tagged instruments. The zero value is
// not usable; create one with [NewRegistry] or use [DefaultRegistry].
//
// Lookup methods return the existing instrument when the same name and tags
// are requested again, and panic if the name and tags are already registered
// under a different kind — mixing kinds under one identity is a programming
// error that would silently corrupt the exposition otherwise.
type Registry struct {
mu sync.RWMutex
entries map[string]entry
}
// NewRegistry creates an empty [Registry].
func NewRegistry() *Registry {
return &Registry{entries: make(map[string]entry)}
}
// DefaultRegistry is the registry used by instrumented packages in this
// module unless overridden. Applications that only ever need one registry
// can use it exclusively.
var DefaultRegistry = NewRegistry()
// identity renders the canonical key of an instrument: the name followed by
// its tags sorted by key. The tags slice is sorted in place.
func identity(name string, tags []Tag) string {
if len(tags) == 0 {
return name
}
slices.SortFunc(tags, func(a, b Tag) int {
return strings.Compare(a.Key, b.Key)
})
var b strings.Builder
b.Grow(len(name) + 16*len(tags))
b.WriteString(name)
b.WriteByte('{')
for i, t := range tags {
if i > 0 {
b.WriteByte(',')
}
b.WriteString(t.Key)
b.WriteByte('=')
b.WriteString(t.Val)
}
b.WriteByte('}')
return b.String()
}
// lookup returns the instrument registered under name and tags, creating it
// with make if absent. It panics on a kind mismatch.
func (r *Registry) lookup(
name string,
tags []Tag,
kind Kind,
make func() instrument,
) instrument {
tags = slices.Clone(tags)
id := identity(name, tags)
r.mu.RLock()
e, ok := r.entries[id]
r.mu.RUnlock()
if !ok {
r.mu.Lock()
if e, ok = r.entries[id]; !ok {
e = entry{name: name, tags: tags, inst: make()}
r.entries[id] = e
}
r.mu.Unlock()
}
if got := e.inst.kind(); got != kind {
panic(fmt.Sprintf(
"%s already registered as %s, requested as %s",
id, got, kind,
))
}
return e.inst
}
// Counter returns the counter registered under the given name and tags,
// creating it on first use.
func (r *Registry) Counter(name string, tags ...Tag) *Counter {
return r.lookup(name, tags, KindCounter, func() instrument {
return &Counter{}
}).(*Counter)
}
// Gauge returns the gauge registered under the given name and tags, creating
// it on first use.
func (r *Registry) Gauge(name string, tags ...Tag) *Gauge {
return r.lookup(name, tags, KindGauge, func() instrument {
return &Gauge{}
}).(*Gauge)
}
// Histogram returns the histogram registered under the given name and tags,
// creating it on first use with the given bucket upper bounds. The bounds of
// an existing histogram are left untouched, so callers must agree on them.
// Passing no bounds uses [DefaultDurationBuckets].
func (r *Registry) Histogram(
name string,
bounds []float64,
tags ...Tag,
) *Histogram {
return r.lookup(name, tags, KindHistogram, func() instrument {
return newHistogram(bounds)
}).(*Histogram)
}
// Meter returns the meter registered under the given name and tags, creating
// it on first use.
func (r *Registry) Meter(name string, tags ...Tag) *Meter {
return r.lookup(name, tags, KindMeter, func() instrument {
return newMeter()
}).(*Meter)
}
// Timer returns the timer registered under the given name and tags, creating
// it on first use. Durations are observed in seconds using
// [DefaultDurationBuckets].
func (r *Registry) Timer(name string, tags ...Tag) *Timer {
return r.lookup(name, tags, KindTimer, func() instrument {
return &Timer{
hist: newHistogram(nil),
meter: newMeter(),
}
}).(*Timer)
}
// Summary returns the summary registered under the given name and tags,
// creating it on first use with the given quantile objectives and sliding
// window. The objectives and window of an existing summary are left
// untouched, so callers must agree on them. Passing no objectives uses
// [DefaultObjectives]; a zero window uses [DefaultWindow].
func (r *Registry) Summary(
name string,
objectives []float64,
window time.Duration,
tags ...Tag,
) *Summary {
return r.lookup(name, tags, KindSummary, func() instrument {
return newSummary(objectives, window)
}).(*Summary)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package metrics
import (
"math"
"slices"
"sort"
"sync/atomic"
"time"
xatomic "github.com/deep-rent/nexus/std/atomic"
"github.com/deep-rent/nexus/std/clock"
)
// Counter is a monotonically increasing count. The zero value is ready for
// use, but counters should be obtained from a [Registry] so they appear in
// snapshots.
type Counter struct {
count atomic.Uint64
}
// Inc increments the counter by one.
func (c *Counter) Inc() {
c.count.Add(1)
}
// Add increments the counter by n.
func (c *Counter) Add(n uint64) {
c.count.Add(n)
}
// Value returns the current count.
func (c *Counter) Value() uint64 {
return c.count.Load()
}
func (*Counter) kind() Kind { return KindCounter }
func (c *Counter) sample(s *Sample) {
s.Value = float64(c.count.Load())
}
// Gauge is a value that can go up and down, such as a queue depth or a pool
// size. The zero value is ready for use, but gauges should be obtained from
// a [Registry] so they appear in snapshots.
type Gauge struct {
value xatomic.Float64
}
// Set replaces the current value.
func (g *Gauge) Set(v float64) {
g.value.Store(v)
}
// Add adds delta to the current value; a negative delta subtracts.
func (g *Gauge) Add(delta float64) {
g.value.Add(delta)
}
// Inc increments the gauge by one.
func (g *Gauge) Inc() {
g.value.Add(1)
}
// Dec decrements the gauge by one.
func (g *Gauge) Dec() {
g.value.Add(-1)
}
// Value returns the current value.
func (g *Gauge) Value() float64 {
return g.value.Load()
}
func (*Gauge) kind() Kind { return KindGauge }
func (g *Gauge) sample(s *Sample) {
s.Value = g.value.Load()
}
// DefaultDurationBuckets are histogram bucket upper bounds suited to request
// and task latencies, in seconds.
var DefaultDurationBuckets = []float64{
0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10,
}
// Histogram records the distribution of observations across fixed buckets,
// along with their count and sum. Observations above the highest bound fall
// into an implicit overflow bucket, so quantile estimates are bounded by the
// chosen bucket layout.
type Histogram struct {
bounds []float64 // sorted upper bounds
counts []atomic.Uint64
count atomic.Uint64
sum xatomic.Float64
}
// newHistogram builds a histogram with the given sorted upper bounds,
// falling back to [DefaultDurationBuckets] when none are given.
func newHistogram(bounds []float64) *Histogram {
if len(bounds) == 0 {
bounds = DefaultDurationBuckets
}
bounds = slices.Clone(bounds)
slices.Sort(bounds)
return &Histogram{
bounds: bounds,
counts: make([]atomic.Uint64, len(bounds)+1),
}
}
// Observe records a single observation.
func (h *Histogram) Observe(v float64) {
// The last cell is the overflow bucket for values above every bound.
h.counts[sort.SearchFloat64s(h.bounds, v)].Add(1)
h.count.Add(1)
h.sum.Add(v)
}
// Count returns the number of observations recorded.
func (h *Histogram) Count() uint64 {
return h.count.Load()
}
// Sum returns the sum of all recorded observations.
func (h *Histogram) Sum() float64 {
return h.sum.Load()
}
func (*Histogram) kind() Kind { return KindHistogram }
func (h *Histogram) sample(s *Sample) {
s.Count = h.count.Load()
s.Sum = h.sum.Load()
s.Buckets = h.buckets()
}
// buckets renders the cumulative bucket counts, Prometheus-style: each
// bucket counts observations less than or equal to its bound. The overflow
// cell has no bucket of its own — its share is the difference between the
// total count and the last bucket.
func (h *Histogram) buckets() []Bucket {
buckets := make([]Bucket, len(h.bounds))
var cum uint64
for i := range h.bounds {
cum += h.counts[i].Load()
buckets[i] = Bucket{Bound: h.bounds[i], Count: cum}
}
return buckets
}
// Meter measures the rate of events: a total count together with 1-, 5-, and
// 15-minute exponentially weighted moving averages, in events per second.
//
// Rates advance lazily: the decay owed since the last advance is applied
// when the meter is marked or read, so an idle meter costs nothing.
type Meter struct {
count atomic.Uint64 // total marks
uncounted atomic.Uint64 // marks since the last tick
started time.Time // creation time, for the mean rate
tick atomic.Int64 // unix nanos of the last rate advance
r01 xatomic.Float64
r05 xatomic.Float64
r15 xatomic.Float64
warm atomic.Bool // whether the rates have been seeded
now clock.Clock // clock, replaced in tests
}
// tickInterval is the resolution at which meter rates advance.
const tickInterval = 5 * time.Second
// Decay factors per tick for the moving averages: 1 - e^(-interval/window).
var (
alpha01 = 1 - math.Exp(-tickInterval.Seconds()/(1*60))
alpha05 = 1 - math.Exp(-tickInterval.Seconds()/(5*60))
alpha15 = 1 - math.Exp(-tickInterval.Seconds()/(15*60))
)
// newMeter builds a meter starting now.
func newMeter() *Meter {
m := &Meter{started: time.Now(), now: clock.System}
m.tick.Store(m.started.UnixNano())
return m
}
// Mark records the occurrence of n events.
func (m *Meter) Mark(n uint64) {
m.advance()
m.count.Add(n)
m.uncounted.Add(n)
}
// Count returns the total number of events recorded.
func (m *Meter) Count() uint64 {
return m.count.Load()
}
// Rates returns the moving average rates in events per second, along with
// the mean rate since the meter was created.
func (m *Meter) Rates() Rates {
m.advance()
mean := 0.0
if elapsed := m.now().Sub(m.started).Seconds(); elapsed > 0 {
mean = float64(m.count.Load()) / elapsed
}
return Rates{
M01: m.r01.Load(),
M05: m.r05.Load(),
M15: m.r15.Load(),
Mean: mean,
}
}
// advance applies any decay owed since the last tick. The CAS on the tick
// timestamp elects a single writer per interval; losers simply proceed, at
// worst leaving their marks for the next tick.
func (m *Meter) advance() {
now := m.now().UnixNano()
last := m.tick.Load()
elapsed := now - last
if elapsed < int64(tickInterval) {
return
}
ticks := elapsed / int64(tickInterval)
if !m.tick.CompareAndSwap(last, last+ticks*int64(tickInterval)) {
return // Another goroutine is advancing.
}
// The marks accumulated since the last tick all count toward the first
// elapsed interval; the remaining intervals were idle.
instant := float64(m.uncounted.Swap(0)) / tickInterval.Seconds()
if !m.warm.Swap(true) {
// The first tick seeds the averages with the observed rate.
m.r01.Store(instant)
m.r05.Store(instant)
m.r15.Store(instant)
ticks--
instant = 0
}
for range ticks {
m.r01.Store(ewma(m.r01.Load(), instant, alpha01))
m.r05.Store(ewma(m.r05.Load(), instant, alpha05))
m.r15.Store(ewma(m.r15.Load(), instant, alpha15))
instant = 0 // Only the first interval carries the marks.
}
}
// ewma folds an instant rate into a moving average with the given decay.
func ewma(avg, instant, alpha float64) float64 {
return avg + alpha*(instant-avg)
}
func (*Meter) kind() Kind { return KindMeter }
func (m *Meter) sample(s *Sample) {
rates := m.Rates()
s.Count = m.count.Load()
s.Rates = &rates
}
// Timer measures durations: a [Histogram] of seconds combined with a [Meter]
// tracking the event rate.
type Timer struct {
hist *Histogram
meter *Meter
}
// Observe records a completed duration.
func (t *Timer) Observe(d time.Duration) {
t.hist.Observe(d.Seconds())
t.meter.Mark(1)
}
// Start begins timing and returns a function that records the elapsed
// duration when called:
//
// defer timer.Start()()
func (t *Timer) Start() func() {
start := time.Now()
return func() {
t.Observe(time.Since(start))
}
}
// Count returns the number of durations recorded.
func (t *Timer) Count() uint64 {
return t.hist.Count()
}
// Sum returns the total of all recorded durations in seconds.
func (t *Timer) Sum() float64 {
return t.hist.Sum()
}
// Rates returns the moving average rates; see [Meter.Rates].
func (t *Timer) Rates() Rates {
return t.meter.Rates()
}
func (*Timer) kind() Kind { return KindTimer }
func (t *Timer) sample(s *Sample) {
t.hist.sample(s)
rates := t.meter.Rates()
s.Rates = &rates
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package scrape
import (
"net/http"
"time"
"github.com/deep-rent/nexus/sys/log"
)
// DefaultTimeout bounds a single target fetch unless the context imposes a
// tighter deadline.
const DefaultTimeout = 10 * time.Second
// config holds the collector settings.
type config struct {
client *http.Client
logger *log.Logger
timeout time.Duration
sink Sink
}
// Option configures a [Collector].
type Option func(*config)
// WithClient sets the HTTP client used to fetch snapshots. It defaults to
// [transport.DefaultClient]. A nil value is ignored.
func WithClient(client *http.Client) Option {
return func(c *config) {
if client != nil {
c.client = client
}
}
}
// WithLogger sets the logger receiving scrape failures. If not provided,
// the collector stays silent, as if [log.Discard] had been given. A nil
// value is ignored.
func WithLogger(logger *log.Logger) Option {
return func(c *config) {
if logger != nil {
c.logger = logger
}
}
}
// WithTimeout bounds each target fetch. Values of zero or less are ignored,
// keeping [DefaultTimeout].
func WithTimeout(d time.Duration) Option {
return func(c *config) {
if d > 0 {
c.timeout = d
}
}
}
// WithSink registers a hook invoked after every scrape attempt, so a
// consumer can record history as it happens rather than polling
// [Collector.Summary]. A nil sink is ignored.
func WithSink(sink Sink) Option {
return func(c *config) {
if sink != nil {
c.sink = sink
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package scrape
import (
"context"
"encoding/json/v2"
"fmt"
"io"
"net/http"
"sync"
"time"
"github.com/deep-rent/nexus/net/transport"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/metrics"
"github.com/deep-rent/nexus/sys/schedule"
)
// maxBody caps how many bytes of a snapshot response are read, so that a
// misbehaving target cannot exhaust the collector.
const maxBody = 8 << 20 // 8 MB
// target is one registered collection endpoint together with its latest
// scrape result.
type target struct {
name string
url string
mu sync.Mutex
snapshot *metrics.Snapshot // nil until the first successful scrape
scraped time.Time // when the last attempt finished
took time.Duration // duration of the last attempt
err error // outcome of the last attempt
}
// Result is the outcome of one scrape attempt, handed to a [Sink]
// right after the attempt finished.
type Result struct {
// Target is the instance name given to [Collector.Add].
Target string
// Snapshot is the decoded snapshot; nil when the attempt failed.
Snapshot *metrics.Snapshot
// Took is how long the attempt lasted.
Took time.Duration
// Err carries the failure, if any.
Err error
}
// Sink receives the outcome of every scrape attempt. It runs on the
// sweep's scrape goroutine, so it must stay reasonably cheap; sinks
// needing heavy work should hand the result off. Attempts against one
// target never overlap, but sinks are invoked concurrently across
// targets. The context is the attempt's own — bounded by the scrape
// timeout — so sinks outliving it detach with [context.WithoutCancel].
type Sink func(ctx context.Context, res Result)
// Collector scrapes a set of collection endpoints; see the package
// documentation.
type Collector struct {
client *http.Client
logger *log.Logger
timeout time.Duration
sink Sink
mu sync.RWMutex
targets []*target
seen map[string]struct{}
}
// New creates a [Collector] with no targets.
func New(opts ...Option) *Collector {
cfg := config{
client: transport.DefaultClient,
logger: log.Discard(),
timeout: DefaultTimeout,
}
for _, opt := range opts {
opt(&cfg)
}
return &Collector{
client: cfg.client,
logger: cfg.logger,
timeout: cfg.timeout,
sink: cfg.sink,
seen: make(map[string]struct{}),
}
}
// Add registers a collection endpoint under an instance name, which tags the
// target's samples in the summary. Adding a name twice panics: two targets
// with one name would silently shadow each other in the summary.
func (c *Collector) Add(name, url string) {
c.mu.Lock()
defer c.mu.Unlock()
if _, ok := c.seen[name]; ok {
panic(fmt.Sprintf("scrape: target %q already registered", name))
}
c.seen[name] = struct{}{}
c.targets = append(c.targets, &target{name: name, url: url})
}
// Run performs one concurrent sweep over all targets, retaining the latest
// snapshot per target. It implements [schedule.Task], so a scheduler can
// drive it periodically; see the package documentation.
func (c *Collector) Run(ctx context.Context) {
c.mu.RLock()
targets := c.targets
c.mu.RUnlock()
var wg sync.WaitGroup
for _, t := range targets {
wg.Go(func() {
c.scrape(ctx, t)
})
}
wg.Wait()
}
// scrape fetches one target and records the outcome.
func (c *Collector) scrape(ctx context.Context, t *target) {
ctx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel()
start := time.Now()
snapshot, err := c.fetch(ctx, t.url)
took := time.Since(start)
if err != nil {
c.logger.Warn(ctx,
"Scrape failed",
log.String("target", t.name),
log.String("url", t.url),
log.Error(err),
)
}
t.mu.Lock()
t.scraped = time.Now()
t.took = took
t.err = err
if err == nil {
t.snapshot = snapshot
}
t.mu.Unlock()
if c.sink != nil {
if err != nil {
snapshot = nil
}
c.sink(ctx, Result{
Target: t.name,
Snapshot: snapshot,
Took: took,
Err: err,
})
}
}
// fetch retrieves and decodes a snapshot.
func (c *Collector) fetch(
ctx context.Context,
url string,
) (*metrics.Snapshot, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
res, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer func() {
if err := res.Body.Close(); err != nil {
c.logger.Warn(
ctx,
"Failed to close response body",
log.Error(err),
)
}
}()
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status %d", res.StatusCode)
}
var snapshot metrics.Snapshot
body := io.LimitReader(res.Body, maxBody)
if err := json.UnmarshalRead(body, &snapshot); err != nil {
return nil, fmt.Errorf("decoding snapshot: %w", err)
}
return &snapshot, nil
}
var _ schedule.Task = (*Collector)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package scrape
import (
"encoding/json/v2"
"maps"
"net/http"
"slices"
"strings"
"time"
"github.com/deep-rent/nexus/net/router"
"github.com/deep-rent/nexus/sys/metrics"
)
// InstanceTag is the tag key under which merged samples carry the name of
// the target they came from.
const InstanceTag = "instance"
// Summary is a merged view over the latest snapshots of every target.
type Summary struct {
// Time is when the summary was assembled.
Time time.Time `json:"time"`
// Targets reports the scrape status of every registered endpoint.
Targets []Target `json:"targets"`
// Metrics is the union of all target samples, each tagged with its
// instance name under [InstanceTag].
Metrics []metrics.Sample `json:"metrics"`
// Totals aggregates counter-like samples (counters, histograms, meters,
// timers) across instances by name and tags. Gauges are omitted: sums
// of point-in-time levels from different instances rarely mean
// anything.
Totals []metrics.Sample `json:"totals"`
}
// Target is the scrape status of one registered endpoint.
type Target struct {
// Name is the instance name given to [Collector.Add].
Name string `json:"name"`
// URL is the scraped collection endpoint.
URL string `json:"url"`
// Up reports whether the most recent scrape succeeded.
Up bool `json:"up"`
// Error carries the failure of the most recent scrape, if any.
Error string `json:"error,omitempty"`
// Scraped is when the most recent scrape finished; zero if the target
// has never been scraped.
Scraped time.Time `json:"scraped,omitzero"`
// Duration is how long the most recent scrape took, in seconds.
Duration float64 `json:"duration,omitempty"`
// Snapshot is when the retained snapshot was taken by the target
// itself; zero if none has been obtained yet.
Snapshot time.Time `json:"snapshot,omitzero"`
}
// Summary assembles the merged view from the latest retained snapshots. A
// target that has never been scraped successfully contributes only its
// status.
func (c *Collector) Summary() Summary {
c.mu.RLock()
targets := c.targets
c.mu.RUnlock()
summary := Summary{
Time: time.Now().UTC(),
Targets: make([]Target, 0, len(targets)),
}
for _, t := range targets {
t.mu.Lock()
status := Target{
Name: t.name,
URL: t.url,
Up: !t.scraped.IsZero() && t.err == nil,
Scraped: t.scraped,
Duration: t.took.Seconds(),
}
if t.err != nil {
status.Error = t.err.Error()
}
var snapshot *metrics.Snapshot
if t.snapshot != nil {
snapshot = t.snapshot
status.Snapshot = t.snapshot.Time
}
t.mu.Unlock()
summary.Targets = append(summary.Targets, status)
if snapshot == nil {
continue
}
for _, s := range snapshot.Metrics {
tags := make(map[string]string, len(s.Tags)+1)
maps.Copy(tags, s.Tags)
tags[InstanceTag] = t.name
s.Tags = tags
summary.Metrics = append(summary.Metrics, s)
}
}
summary.Totals = aggregate(summary.Metrics)
return summary
}
// aggregate folds instance-tagged samples into per-family totals: counters,
// histogram counts and sums, and meter counts are added across instances;
// histogram buckets merge bucket-wise when the layouts agree, and are
// dropped for a family with mismatched layouts. Rates and gauges do not
// aggregate meaningfully and are omitted, and neither do the quantiles of a
// summary — its counts and sums add, its quantiles are dropped.
func aggregate(samples []metrics.Sample) []metrics.Sample {
families := make(map[string]*metrics.Sample)
order := make([]string, 0, len(samples))
for _, s := range samples {
if s.Kind == metrics.KindGauge {
continue
}
tags := make(map[string]string, len(s.Tags))
maps.Copy(tags, s.Tags)
delete(tags, InstanceTag)
id := familyID(s.Name, tags)
total, ok := families[id]
if !ok {
families[id] = &metrics.Sample{
Name: s.Name,
Kind: s.Kind,
Tags: tags,
Value: s.Value,
Count: s.Count,
Sum: s.Sum,
Buckets: slices.Clone(s.Buckets),
}
order = append(order, id)
continue
}
total.Value += s.Value
total.Count += s.Count
total.Sum += s.Sum
total.Buckets = mergeBuckets(total.Buckets, s.Buckets)
}
slices.Sort(order)
totals := make([]metrics.Sample, len(order))
for i, id := range order {
totals[i] = *families[id]
}
return totals
}
// familyID renders the identity of a family: the metric name plus its
// sorted tags.
func familyID(name string, tags map[string]string) string {
var b strings.Builder
b.WriteString(name)
for _, k := range slices.Sorted(maps.Keys(tags)) {
b.WriteByte(',')
b.WriteString(k)
b.WriteByte('=')
b.WriteString(tags[k])
}
return b.String()
}
// mergeBuckets adds counts bucket-wise. Mismatched layouts yield nil, since
// adding counts across different bounds would fabricate a distribution.
func mergeBuckets(a, b []metrics.Bucket) []metrics.Bucket {
if len(a) != len(b) {
return nil
}
for i := range a {
if a[i].Bound != b[i].Bound {
return nil
}
a[i].Count += b[i].Count
}
return a
}
// Handler returns a [router.Handler] serving the current [Summary] as JSON.
func (c *Collector) Handler() router.Handler {
return router.HandlerFunc(func(e *router.Exchange) error {
e.SetHeader("Content-Type", "application/json")
e.SetHeader("Cache-Control", "no-store")
e.Status(http.StatusOK)
return json.MarshalWrite(e.W, c.Summary())
})
}
// Mount registers the summary endpoint on the router under "GET /metrics",
// mirroring how a single instance exposes its own registry.
func (c *Collector) Mount(r *router.Router) {
r.Handle(http.MethodGet, "/metrics", c.Handler())
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package metrics
import (
"encoding/json/v2"
"net/http"
"slices"
"strings"
"time"
"github.com/deep-rent/nexus/net/router"
)
// Snapshot is a point-in-time view of every instrument in a [Registry],
// ordered deterministically by name and tags.
type Snapshot struct {
// Time is when the snapshot was taken.
Time time.Time `json:"time"`
// Metrics holds one sample per registered instrument.
Metrics []Sample `json:"metrics"`
}
// Sample is the state of a single instrument. Which fields are populated
// depends on the kind:
//
// - counter, gauge: Value
// - histogram: Count, Sum, Buckets
// - meter: Count, Rates
// - timer: Count, Sum, Buckets, Rates
// - summary: Count, Sum, Quantiles
type Sample struct {
// Name is the metric name.
Name string `json:"name"`
// Kind identifies the instrument type.
Kind Kind `json:"kind"`
// Tags qualify the sample; see [Tag].
Tags map[string]string `json:"tags,omitempty"`
// Value is the current count or gauge level.
Value float64 `json:"value,omitempty"`
// Count is the number of recorded observations or events.
Count uint64 `json:"count,omitempty"`
// Sum is the sum of all recorded observations.
Sum float64 `json:"sum,omitempty"`
// Buckets are cumulative bucket counts; see [Bucket].
Buckets []Bucket `json:"buckets,omitempty"`
// Rates are moving average event rates; see [Rates].
Rates *Rates `json:"rates,omitempty"`
// Quantiles are estimated quantiles over a summary's sliding
// window, ordered by rank; see [Quantile]. An idle window reports
// none.
Quantiles []Quantile `json:"quantiles,omitempty"`
}
// Quantile is one estimated quantile of a summary sample.
type Quantile struct {
// Q is the rank, between 0 and 1: 0.99 is the 99th percentile.
Q float64 `json:"q"`
// V is the estimated value at that rank, within the summary's
// relative accuracy.
V float64 `json:"v"`
}
// Bucket is one cumulative histogram bucket: the number of observations
// less than or equal to its upper bound. Only the finite bounds are listed;
// observations above the highest bound are the difference between the
// sample's total Count and the last bucket's Count.
type Bucket struct {
// Bound is the inclusive upper bound.
Bound float64 `json:"le"`
// Count is the cumulative number of observations up to the bound.
Count uint64 `json:"count"`
}
// Rates are exponentially weighted moving average rates in events per
// second, over 1-, 5-, and 15-minute windows, plus the lifetime mean.
type Rates struct {
M01 float64 `json:"m01"`
M05 float64 `json:"m05"`
M15 float64 `json:"m15"`
Mean float64 `json:"mean"`
}
// Snapshot captures the current state of every registered instrument.
// Instruments keep recording while the snapshot is taken; each sample is
// individually consistent, the set as a whole is approximate — the usual
// contract of a scrape.
func (r *Registry) Snapshot() Snapshot {
r.mu.RLock()
entries := make([]entry, 0, len(r.entries))
for _, e := range r.entries {
entries = append(entries, e)
}
r.mu.RUnlock()
slices.SortFunc(entries, func(a, b entry) int {
if c := strings.Compare(a.name, b.name); c != 0 {
return c
}
return slices.CompareFunc(a.tags, b.tags, func(x, y Tag) int {
if c := strings.Compare(x.Key, y.Key); c != 0 {
return c
}
return strings.Compare(x.Val, y.Val)
})
})
samples := make([]Sample, len(entries))
for i, e := range entries {
s := Sample{Name: e.name, Kind: e.inst.kind()}
if len(e.tags) > 0 {
s.Tags = make(map[string]string, len(e.tags))
for _, t := range e.tags {
s.Tags[t.Key] = t.Val
}
}
e.inst.sample(&s)
samples[i] = s
}
return Snapshot{Time: time.Now().UTC(), Metrics: samples}
}
// Handler returns a [router.Handler] serving the registry's current
// [Snapshot] as JSON — the collection endpoint a scraper polls. Register it
// on a router under the conventional path:
//
// r.Handle(http.MethodGet, "/metrics", metrics.DefaultRegistry.Handler())
func (r *Registry) Handler() router.Handler {
return router.HandlerFunc(func(e *router.Exchange) error {
e.SetHeader("Content-Type", "application/json")
e.SetHeader("Cache-Control", "no-store")
e.Status(http.StatusOK)
return json.MarshalWrite(e.W, r.Snapshot())
})
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package postgres
import "github.com/deep-rent/nexus/sys/metrics"
// config holds the configuration for the sampler.
type config struct {
registry *metrics.Registry
prefix string
}
// Option configures the sampler.
type Option func(*config)
// WithRegistry sets the destination registry. It defaults to
// [metrics.DefaultRegistry]. A nil value is ignored.
func WithRegistry(reg *metrics.Registry) Option {
return func(c *config) {
if reg != nil {
c.registry = reg
}
}
}
// WithPrefix sets the metric name prefix, which defaults to
// [DefaultPrefix]. An empty value is ignored.
func WithPrefix(prefix string) Option {
return func(c *config) {
if prefix != "" {
c.prefix = prefix
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package postgres
import (
"context"
"github.com/deep-rent/nexus/sys/metrics"
"github.com/deep-rent/nexus/sys/schedule"
)
// DefaultPrefix is the metric name prefix used when [WithPrefix] is not
// given. A deployment running more than one pool should set a distinct
// prefix per pool, since the instruments are keyed by name.
const DefaultPrefix = "db"
// Stats is the pool statistics this package samples, a subset of what
// *pgxpool.Stat reports. Readings are cumulative where the corresponding
// instrument is a counter.
type Stats interface {
// AcquiredConns is the number of connections currently in use.
AcquiredConns() int32
// IdleConns is the number of connections sitting idle in the pool.
IdleConns() int32
// ConstructingConns is the number of connections being established.
ConstructingConns() int32
// MaxConns is the pool's ceiling.
MaxConns() int32
// AcquireCount is the cumulative number of successful acquires.
AcquireCount() int64
// EmptyAcquireCount is the cumulative number of acquires that had to
// wait because the pool was empty.
EmptyAcquireCount() int64
// CanceledAcquireCount is the cumulative number of acquires canceled
// through their context.
CanceledAcquireCount() int64
}
// Pool returns a task that samples the pool statistics returned by the given
// callback: the connection states and the ceiling as gauges, and the
// cumulative acquire counts as counters, advanced by the delta since the
// previous sample. Under [DefaultPrefix] it records
//
// - db_pool_conns, tagged by state ("acquired", "idle", "constructing")
// - db_pool_conns_max
// - db_pool_acquires_total
// - db_pool_acquire_waits_total
// - db_pool_acquires_canceled_total
//
// An acquire that had to wait for a connection is the saturation signal:
// db_pool_acquire_waits_total climbing means the pool is the bottleneck,
// not the queries.
//
// The registry is pull-based with no scrape-time hook, so the gauges hold
// the last sample; an interval well under the scrape interval keeps them
// fresh at negligible cost. The returned task keeps the previous reading
// between runs, and the scheduler runs a task's ticks sequentially, so it
// needs no locking of its own.
func Pool(stats func() Stats, opts ...Option) schedule.TaskFn {
cfg := config{registry: metrics.DefaultRegistry, prefix: DefaultPrefix}
for _, opt := range opts {
opt(&cfg)
}
var acquires, waits, cancels uint64
return func(context.Context) {
stat := stats()
if stat == nil {
return
}
reg := cfg.registry
conns := cfg.prefix + "_pool_conns"
reg.Gauge(conns, metrics.T("state", "acquired")).
Set(float64(stat.AcquiredConns()))
reg.Gauge(conns, metrics.T("state", "idle")).
Set(float64(stat.IdleConns()))
reg.Gauge(conns, metrics.T("state", "constructing")).
Set(float64(stat.ConstructingConns()))
reg.Gauge(conns + "_max").Set(float64(stat.MaxConns()))
count(reg, cfg.prefix+"_pool_acquires_total",
uint64(stat.AcquireCount()), &acquires)
count(reg, cfg.prefix+"_pool_acquire_waits_total",
uint64(stat.EmptyAcquireCount()), &waits)
count(reg, cfg.prefix+"_pool_acquires_canceled_total",
uint64(stat.CanceledAcquireCount()), &cancels)
}
}
// count advances a registry counter to a cumulative reading taken from an
// external source, remembering the previous reading for the next call.
func count(r *metrics.Registry, name string, reading uint64, prev *uint64) {
if reading > *prev {
r.Counter(name).Add(reading - *prev)
*prev = reading
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package metrics
import (
"math"
"slices"
"sync"
"time"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/std/sketch/quantile"
)
// DefaultObjectives are the quantile objectives applied by
// [Registry.Summary] when none are given: the median, the 90th, and
// the 99th percentile.
var DefaultObjectives = []float64{0.5, 0.9, 0.99}
// DefaultWindow is the sliding window applied by [Registry.Summary]
// when none is given.
const DefaultWindow = 10 * time.Minute
const (
// summarySlices is the number of age slices a summary's window is
// divided into: observations expire in window/summarySlices steps
// rather than all at once.
summarySlices = 5
// summaryAccuracy is the relative accuracy of the underlying
// quantile sketches: estimates land within one percent of the
// true quantile.
summaryAccuracy = 0.01
)
// A Summary estimates quantiles of a nonnegative measure — latencies,
// sizes, durations — over a sliding time window. Unlike a [Histogram],
// it needs no bucket bounds chosen up front: values land in
// logarithmic buckets (a DDSketch per age slice, see [quantile]), so
// an estimate is within one percent of the true quantile whether the
// measure lives in microseconds or minutes.
//
// Reported quantiles cover roughly the most recent window; the
// lifetime [Summary.Count] and [Summary.Sum] never reset, so a scraper
// can still derive rates and means from deltas. An idle window reports
// no quantiles at all rather than stale ones.
//
// A Summary is safe for concurrent use. Unlike the other instruments
// it takes a short lock per observation — the price of the sliding
// window. Where a lock-free hot path matters more than configuration-
// free accuracy, use a [Histogram] or [Timer].
type Summary struct {
mu sync.Mutex
qs []float64 // objectives, ascending
slice time.Duration // window / summarySlices
ring [summarySlices]*quantile.Sketch // age slices, head current
head int // slice receiving observations
turned time.Time // when head began
count uint64 // lifetime observations
sum float64 // lifetime sum
now clock.Clock // clock, replaced in tests
}
// newSummary builds a summary for the given objectives and window,
// applying the package defaults for zero values. It panics if an
// objective falls outside [0, 1] or the window is negative or too
// short to slice.
func newSummary(objectives []float64, window time.Duration) *Summary {
if len(objectives) == 0 {
objectives = DefaultObjectives
}
qs := slices.Clone(objectives)
slices.Sort(qs)
for _, q := range qs {
if !(q >= 0 && q <= 1) {
panic("objectives must be in [0, 1]")
}
}
if window == 0 {
window = DefaultWindow
}
if window < time.Duration(summarySlices) {
panic("window is too short")
}
s := &Summary{
qs: qs,
slice: window / summarySlices,
now: clock.System,
}
for i := range s.ring {
s.ring[i] = quantile.New(summaryAccuracy)
}
s.turned = s.now()
return s
}
// Observe records a value. Negative values count as zero — a measure
// cannot be negative, but clock adjustments can manufacture one — and
// non-finite values are dropped entirely.
func (s *Summary) Observe(v float64) {
if math.IsNaN(v) || math.IsInf(v, 0) {
return
}
v = max(v, 0)
s.mu.Lock()
defer s.mu.Unlock()
s.advance(s.now())
s.ring[s.head].Add(v)
s.count++
s.sum += v
}
// Start begins timing an operation and returns a function that records
// the elapsed duration in seconds when called:
//
// defer summary.Start()()
func (s *Summary) Start() func() {
start := time.Now()
return func() {
s.Observe(time.Since(start).Seconds())
}
}
// Count returns the number of observations recorded over the
// summary's lifetime, expired ones included.
func (s *Summary) Count() uint64 {
s.mu.Lock()
defer s.mu.Unlock()
return s.count
}
// Sum returns the sum of all observations recorded over the summary's
// lifetime, expired ones included.
func (s *Summary) Sum() float64 {
s.mu.Lock()
defer s.mu.Unlock()
return s.sum
}
// Quantile returns the estimated value at rank q in [0, 1] over the
// sliding window, or NaN while the window is idle. The estimate is
// within one percent of the true quantile, relatively. Quantile
// panics if q is NaN or outside [0, 1].
func (s *Summary) Quantile(q float64) float64 {
s.mu.Lock()
defer s.mu.Unlock()
s.advance(s.now())
return s.merged().Quantile(q)
}
// advance expires age slices the clock has moved past. The caller
// must hold the mutex.
func (s *Summary) advance(t time.Time) {
if t.Sub(s.turned) >= time.Duration(summarySlices)*s.slice {
// The whole window has expired; start fresh rather than
// stepping through the gap.
for i := range s.ring {
s.ring[i] = quantile.New(summaryAccuracy)
}
s.turned = t
return
}
for t.Sub(s.turned) >= s.slice {
s.head = (s.head + 1) % summarySlices
s.ring[s.head] = quantile.New(summaryAccuracy)
s.turned = s.turned.Add(s.slice)
}
}
// merged folds the age slices into one sketch covering the window.
// The caller must hold the mutex.
func (s *Summary) merged() *quantile.Sketch {
m := quantile.New(summaryAccuracy)
for _, sk := range s.ring {
// Merging cannot fail: every slice shares the accuracy.
_ = m.Merge(sk)
}
return m
}
func (*Summary) kind() Kind { return KindSummary }
func (s *Summary) sample(out *Sample) {
s.mu.Lock()
defer s.mu.Unlock()
s.advance(s.now())
out.Count = s.count
out.Sum = s.sum
m := s.merged()
if m.Count() == 0 {
return // An idle window has no quantiles to report.
}
out.Quantiles = make([]Quantile, len(s.qs))
for i, q := range s.qs {
out.Quantiles[i] = Quantile{Q: q, V: m.Quantile(q)}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
// Package mock implements the job store in memory: every job is lost on
// restart, and transactions are nominal — operations apply immediately.
// It backs tests and the mock-driver assemblies of host services; the
// semantics that need real transactional machinery (outbox atomicity,
// concurrent claim exclusion under load) are the PostgreSQL driver's
// tests to prove.
package mock
import (
"bytes"
"context"
"slices"
"strings"
"sync"
"time"
"uuid"
"github.com/deep-rent/nexus/sys/queue"
)
// Tx is the nominal transaction type of the in-memory store.
type Tx struct{}
// Store implements [queue.Store] in memory. It is safe for concurrent
// use.
type Store struct {
mu sync.Mutex
jobs map[uuid.UUID]queue.Job
}
// New creates an empty in-memory store.
func New() *Store {
return &Store{jobs: make(map[uuid.UUID]queue.Job)}
}
// Exec implements the [queue.Store] interface. Transactions are
// nominal: fn's effects apply as they happen.
func (*Store) Exec(
ctx context.Context,
fn func(ctx context.Context, tx *Tx) error,
) error {
return fn(ctx, &Tx{})
}
// clone copies a job, detaching its payload so a caller cannot reach
// into the store through it.
func clone(j queue.Job) queue.Job {
j.Payload = bytes.Clone(j.Payload)
return j
}
// Insert implements the [queue.Store] interface.
func (s *Store) Insert(
_ context.Context, _ *Tx, j queue.Job,
) (queue.Job, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
if j.Key != "" {
for _, held := range s.jobs {
if held.State == queue.StatePending &&
held.Kind == j.Kind && held.Key == j.Key {
return clone(held), false, nil
}
}
}
s.jobs[j.ID] = clone(j)
return j, true, nil
}
// InsertBatch implements the [queue.Store] interface.
func (s *Store) InsertBatch(
ctx context.Context, tx *Tx, js []queue.Job,
) (int, error) {
var n int
for _, j := range js {
if _, ok, err := s.Insert(ctx, tx, j); err != nil {
return n, err
} else if ok {
n++
}
}
return n, nil
}
// Jobs implements the [queue.Store] interface.
func (s *Store) Jobs(
_ context.Context, _ *Tx, f queue.Filter,
) ([]queue.Job, error) {
s.mu.Lock()
defer s.mu.Unlock()
var out []queue.Job
for _, j := range s.jobs {
if len(f.Kinds) > 0 && !slices.Contains(f.Kinds, j.Kind) {
continue
}
if len(f.States) > 0 && !slices.Contains(f.States, j.State) {
continue
}
if f.Cursor != uuid.Nil() && j.ID.Compare(f.Cursor) >= 0 {
continue
}
out = append(out, clone(j))
}
// Newest first, which for UUIDv7 is by identifier descending.
slices.SortFunc(out, func(a, b queue.Job) int {
return b.ID.Compare(a.ID)
})
if len(out) > f.Limit {
out = out[:f.Limit]
}
return out, nil
}
// Job implements the [queue.Store] interface.
func (s *Store) Job(
_ context.Context, _ *Tx, id uuid.UUID,
) (queue.Job, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
j, ok := s.jobs[id]
if !ok {
return queue.Job{}, false, nil
}
return clone(j), true, nil
}
// Cancel implements the [queue.Store] interface.
func (s *Store) Cancel(
_ context.Context, _ *Tx, id uuid.UUID, at time.Time,
) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
j, ok := s.jobs[id]
if !ok || j.State.Terminal() {
return false, nil
}
j.State = queue.StateCanceled
j.SettledAt = at
s.jobs[id] = j
return true, nil
}
// Requeue implements the [queue.Store] interface.
func (s *Store) Requeue(
_ context.Context, _ *Tx, id uuid.UUID, at time.Time,
) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
j, ok := s.jobs[id]
if !ok || j.State != queue.StateDead {
return false, nil
}
j.State = queue.StatePending
j.Attempts = 0
j.RunAt = at
j.Note = ""
j.SettledAt = time.Time{}
s.jobs[id] = j
return true, nil
}
// Claim implements the [queue.Store] interface.
func (s *Store) Claim(
_ context.Context, now time.Time, lease time.Duration, limit int,
kinds []string,
) ([]queue.Job, error) {
if len(kinds) == 0 || limit <= 0 {
return nil, nil
}
s.mu.Lock()
defer s.mu.Unlock()
var due []queue.Job
for _, j := range s.jobs {
if j.State == queue.StatePending && !j.RunAt.After(now) &&
slices.Contains(kinds, j.Kind) {
due = append(due, j)
}
}
slices.SortFunc(due, func(a, b queue.Job) int {
if c := b.Priority - a.Priority; c != 0 {
return c
}
if c := a.RunAt.Compare(b.RunAt); c != 0 {
return c
}
return a.ID.Compare(b.ID)
})
if len(due) > limit {
due = due[:limit]
}
out := make([]queue.Job, 0, len(due))
for _, j := range due {
j.RunAt = now.Add(lease)
s.jobs[j.ID] = j
out = append(out, clone(j))
}
return out, nil
}
// Extend implements the [queue.Store] interface.
func (s *Store) Extend(
_ context.Context, ids []uuid.UUID, until time.Time,
) error {
s.mu.Lock()
defer s.mu.Unlock()
for _, id := range ids {
j, ok := s.jobs[id]
if !ok || j.State != queue.StatePending {
continue
}
j.RunAt = until
s.jobs[id] = j
}
return nil
}
// settle applies a terminal or retry transition to a pending job.
func (s *Store) settle(id uuid.UUID, fn func(*queue.Job)) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
j, ok := s.jobs[id]
if !ok || j.State != queue.StatePending {
return false, nil
}
j.Attempts++
fn(&j)
s.jobs[id] = j
return true, nil
}
// Succeed implements the [queue.Store] interface.
func (s *Store) Succeed(
_ context.Context, _ *Tx, id uuid.UUID, at time.Time,
) (bool, error) {
return s.settle(id, func(j *queue.Job) {
j.State = queue.StateSucceeded
j.SettledAt = at
j.Note = ""
})
}
// Retry implements the [queue.Store] interface.
func (s *Store) Retry(
_ context.Context, _ *Tx, id uuid.UUID, next time.Time, note string,
) (bool, error) {
return s.settle(id, func(j *queue.Job) {
j.RunAt = next
j.Note = note
})
}
// Defer implements the [queue.Store] interface.
func (s *Store) Defer(
_ context.Context, _ *Tx, id uuid.UUID, next time.Time, note string,
) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
j, ok := s.jobs[id]
if !ok || j.State != queue.StatePending {
return false, nil
}
// Deliberately no attempt increment: the handler declined to run.
j.RunAt = next
j.Note = note
s.jobs[id] = j
return true, nil
}
// Bury implements the [queue.Store] interface.
func (s *Store) Bury(
_ context.Context, _ *Tx, id uuid.UUID, at time.Time, note string,
) (bool, error) {
return s.settle(id, func(j *queue.Job) {
j.State = queue.StateDead
j.SettledAt = at
j.Note = note
})
}
// Depths implements the [queue.Store] interface.
func (s *Store) Depths(
_ context.Context, now time.Time,
) ([]queue.Depth, error) {
s.mu.Lock()
defer s.mu.Unlock()
byKind := make(map[string]*queue.Depth)
for _, j := range s.jobs {
if j.State != queue.StatePending && j.State != queue.StateDead {
continue
}
d, ok := byKind[j.Kind]
if !ok {
d = &queue.Depth{Kind: j.Kind}
byKind[j.Kind] = d
}
switch {
case j.State == queue.StateDead:
d.Dead++
case j.RunAt.After(now):
d.Delayed++
default:
d.Pending++
if d.Oldest.IsZero() || j.RunAt.Before(d.Oldest) {
d.Oldest = j.RunAt
}
}
}
out := make([]queue.Depth, 0, len(byKind))
for _, d := range byKind {
out = append(out, *d)
}
slices.SortFunc(out, func(a, b queue.Depth) int {
return strings.Compare(a.Kind, b.Kind)
})
return out, nil
}
// Prune implements the [queue.Store] interface.
func (s *Store) Prune(
_ context.Context, _ *Tx, before time.Time,
) (int64, error) {
s.mu.Lock()
defer s.mu.Unlock()
var n int64
for id, j := range s.jobs {
if j.State.Terminal() && j.SettledAt.Before(before) {
delete(s.jobs, id)
n++
}
}
return n, nil
}
var _ queue.Store[*Tx] = (*Store)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
// Package postgres implements the job store on PostgreSQL — the
// reference driver. Claims lease through FOR UPDATE SKIP LOCKED, so any
// number of workers share the queue without coordination, and the
// idempotency key is held by a partial unique index, so concurrent
// pushes of one key cannot both win.
package postgres
import (
"context"
"database/sql"
"embed"
"encoding/json/jsontext"
"errors"
"fmt"
"io/fs"
"time"
"uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/deep-rent/nexus/dat/migrate"
driver "github.com/deep-rent/nexus/dat/migrate/driver/postgres"
source "github.com/deep-rent/nexus/dat/migrate/source/file"
"github.com/deep-rent/nexus/sys/queue"
)
//go:embed migrations
var migrations embed.FS
// Module is the migration stream the queue schema lives in. Host
// services applying their own streams gate on it via
// "-- requires: queue@1".
const Module = "queue"
// pruneBatch bounds how many settled jobs one retention statement
// removes, so a long-neglected queue drains over several small
// transactions instead of one that locks the table for minutes.
const pruneBatch = 10_000
// Migrations exposes the embedded schema migrations for [migrate]
// paired with its PostgreSQL driver.
func Migrations() fs.FS {
sub, err := fs.Sub(migrations, "migrations")
if err != nil {
// The subdirectory is embedded at compile time; failing to open
// it is a build defect, not a runtime condition.
panic(err)
}
return sub
}
// Migrator wires a [migrate.Migrator] for the queue schema over an
// existing database handle. The module, source, and driver are this
// schema's to declare; opts carry what the caller legitimately varies,
// such as a logger.
func Migrator(db *sql.DB, opts ...migrate.Option) *migrate.Migrator {
return migrate.New(append([]migrate.Option{
migrate.WithModule(Module),
migrate.WithSource(source.New(Migrations())),
migrate.WithDriver(driver.New(db)),
}, opts...)...)
}
// Open connects a migrator for the queue schema to the database at url,
// for commands that only run migrations. The returned close function
// releases the connection.
func Open(
url string,
opts ...migrate.Option,
) (*migrate.Migrator, func() error, error) {
return driver.Open(url, append([]migrate.Option{
migrate.WithModule(Module),
migrate.WithSource(source.New(Migrations())),
}, opts...)...)
}
// Store implements [queue.Store] on PostgreSQL. It is safe for
// concurrent use.
type Store struct {
pool *pgxpool.Pool
}
// New creates a [Store] over the given connection pool. It panics on a
// nil pool (programmer error).
func New(pool *pgxpool.Pool) *Store {
if pool == nil {
panic("pool is required")
}
return &Store{pool: pool}
}
// Exec implements the [queue.Store] interface.
func (s *Store) Exec(
ctx context.Context,
fn func(ctx context.Context, tx pgx.Tx) error,
) error {
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
return fn(ctx, tx)
})
}
// unstamp reads a nullable time back into the zero-value convention.
func unstamp(t *time.Time) time.Time {
if t == nil {
return time.Time{}
}
return *t
}
// document renders a payload for JSONB: an absent one is NULL, not the
// four bytes "null".
func document(v jsontext.Value) []byte {
if len(v) == 0 {
return nil
}
return []byte(v)
}
// jobColumns is the canonical select list of scanJob.
const jobColumns = `id, kind, payload, key, priority, state, attempts,
run_at, note, created_at, settled_at`
// scanJob reads one job row.
func scanJob(row pgx.Row) (queue.Job, error) {
var (
j queue.Job
state string
payload []byte
settled *time.Time
)
err := row.Scan(
&j.ID, &j.Kind, &payload, &j.Key, &j.Priority, &state,
&j.Attempts, &j.RunAt, &j.Note, &j.CreatedAt, &settled,
)
if err != nil {
return queue.Job{}, err
}
j.State = queue.State(state)
j.Payload = payload
j.SettledAt = unstamp(settled)
return j, nil
}
// Insert implements the [queue.Store] interface. The partial unique
// index on (kind, key) settles a race between two pushes of one key:
// the loser's insert does nothing and it reads back the winner.
func (*Store) Insert(
ctx context.Context, tx pgx.Tx, j queue.Job,
) (queue.Job, bool, error) {
// A key freed between the failed insert and the read leaves neither
// a job to return nor one inserted; going round again is the whole
// of the fix, and twice is already more than the race needs.
for range 3 {
tag, err := tx.Exec(ctx, insertJob,
j.ID, j.Kind, document(j.Payload), j.Key, j.Priority,
j.RunAt, j.CreatedAt,
)
if err != nil {
return queue.Job{}, false, fmt.Errorf(
"failed to insert job: %w", err,
)
}
if tag.RowsAffected() > 0 {
return j, true, nil
}
held, err := scanJob(tx.QueryRow(ctx, `
SELECT `+jobColumns+` FROM queue_jobs
WHERE kind = $1 AND key = $2 AND state = 'pending'`,
j.Kind, j.Key,
))
if errors.Is(err, pgx.ErrNoRows) {
continue
}
if err != nil {
return queue.Job{}, false, fmt.Errorf(
"failed to read the job holding the key: %w", err,
)
}
return held, false, nil
}
return queue.Job{}, false, fmt.Errorf(
"failed to insert job: the key %q kept changing hands", j.Key,
)
}
// insertJob is the statement behind [Store.Insert] and
// [Store.InsertBatch].
const insertJob = `
INSERT INTO queue_jobs
(id, kind, payload, key, priority, state, attempts,
run_at, note, created_at)
VALUES ($1, $2, $3, $4, $5, 'pending', 0, $6, '', $7)
ON CONFLICT (kind, key) WHERE state = 'pending' AND key <> ''
DO NOTHING`
// InsertBatch implements the [queue.Store] interface. The rows are
// pipelined rather than copied: COPY cannot skip a conflict, and the
// idempotency key has to keep holding while a fan-out writes.
func (*Store) InsertBatch(
ctx context.Context, tx pgx.Tx, js []queue.Job,
) (int, error) {
if len(js) == 0 {
return 0, nil
}
batch := &pgx.Batch{}
for _, j := range js {
batch.Queue(insertJob,
j.ID, j.Kind, document(j.Payload), j.Key, j.Priority,
j.RunAt, j.CreatedAt,
)
}
res := tx.SendBatch(ctx, batch)
var n int
for range js {
tag, err := res.Exec()
if err != nil {
// Close discards the results not yet read; the insert error
// is the one worth reporting.
_ = res.Close()
return 0, fmt.Errorf("failed to insert jobs: %w", err)
}
n += int(tag.RowsAffected())
}
if err := res.Close(); err != nil {
return 0, fmt.Errorf("failed to insert jobs: %w", err)
}
return n, nil
}
// Jobs implements the [queue.Store] interface. The listing walks
// identifiers backwards, which orders by creation for free: they are
// UUIDv7.
func (*Store) Jobs(
ctx context.Context, tx pgx.Tx, f queue.Filter,
) ([]queue.Job, error) {
// Both filters are passed as arrays that are empty rather than
// absent: a nil slice reaches PostgreSQL as NULL, and every
// comparison against NULL is NULL — which would quietly match no
// rows at all instead of every row.
kinds := make([]string, 0, len(f.Kinds))
kinds = append(kinds, f.Kinds...)
states := make([]string, 0, len(f.States))
for _, state := range f.States {
states = append(states, string(state))
}
rows, err := tx.Query(ctx, `
SELECT `+jobColumns+` FROM queue_jobs
WHERE (cardinality($1::text[]) = 0 OR kind = ANY($1))
AND (cardinality($2::text[]) = 0 OR state = ANY($2))
AND ($3::uuid IS NULL OR id < $3)
ORDER BY id DESC
LIMIT $4`,
kinds, states, cursor(f.Cursor), f.Limit,
)
if err != nil {
return nil, fmt.Errorf("failed to list jobs: %w", err)
}
defer rows.Close()
var out []queue.Job
for rows.Next() {
j, err := scanJob(rows)
if err != nil {
return nil, fmt.Errorf("failed to list jobs: %w", err)
}
out = append(out, j)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to list jobs: %w", err)
}
return out, nil
}
// cursor renders a paging cursor: the zero identifier starts at the
// newest job rather than matching one.
func cursor(id uuid.UUID) *uuid.UUID {
if id == uuid.Nil() {
return nil
}
return &id
}
// Job implements the [queue.Store] interface.
func (*Store) Job(
ctx context.Context, tx pgx.Tx, id uuid.UUID,
) (queue.Job, bool, error) {
j, err := scanJob(tx.QueryRow(ctx, `
SELECT `+jobColumns+` FROM queue_jobs WHERE id = $1`, id,
))
if errors.Is(err, pgx.ErrNoRows) {
return queue.Job{}, false, nil
}
if err != nil {
return queue.Job{}, false, fmt.Errorf("failed to read job: %w", err)
}
return j, true, nil
}
// Cancel implements the [queue.Store] interface.
func (*Store) Cancel(
ctx context.Context, tx pgx.Tx, id uuid.UUID, at time.Time,
) (bool, error) {
res, err := tx.Exec(ctx, `
UPDATE queue_jobs SET state = 'canceled', settled_at = $2
WHERE id = $1 AND state = 'pending'`, id, at,
)
if err != nil {
return false, fmt.Errorf("failed to cancel job: %w", err)
}
return res.RowsAffected() > 0, nil
}
// Requeue implements the [queue.Store] interface. It fails when the
// job's idempotency key has since been taken by a pending job — the
// conflict is real: that job is already doing this work.
func (*Store) Requeue(
ctx context.Context, tx pgx.Tx, id uuid.UUID, at time.Time,
) (bool, error) {
res, err := tx.Exec(ctx, `
UPDATE queue_jobs SET
state = 'pending',
attempts = 0,
run_at = $2,
note = '',
settled_at = NULL
WHERE id = $1 AND state = 'dead'`, id, at,
)
if err != nil {
return false, fmt.Errorf("failed to requeue job: %w", err)
}
return res.RowsAffected() > 0, nil
}
// Claim implements the [queue.Store] interface: due pending rows lease
// through FOR UPDATE SKIP LOCKED, so concurrent workers never take the
// same job, and a worker that dies lets its leases lapse.
func (s *Store) Claim(
ctx context.Context, now time.Time, lease time.Duration, limit int,
kinds []string,
) ([]queue.Job, error) {
if len(kinds) == 0 || limit <= 0 {
return nil, nil
}
var out []queue.Job
err := s.Exec(ctx, func(ctx context.Context, tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
WITH due AS (
SELECT id FROM queue_jobs
WHERE state = 'pending'
AND run_at <= $1
AND kind = ANY($4)
ORDER BY priority DESC, run_at
LIMIT $3
FOR UPDATE SKIP LOCKED
)
UPDATE queue_jobs j SET run_at = $2
FROM due WHERE j.id = due.id
RETURNING j.id, j.kind, j.payload, j.key, j.priority, j.state,
j.attempts, j.run_at, j.note, j.created_at, j.settled_at`,
now, now.Add(lease), limit, kinds,
)
if err != nil {
return fmt.Errorf("failed to claim jobs: %w", err)
}
defer rows.Close()
for rows.Next() {
j, err := scanJob(rows)
if err != nil {
return fmt.Errorf("failed to claim jobs: %w", err)
}
out = append(out, j)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("failed to claim jobs: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
return out, nil
}
// Extend implements the [queue.Store] interface.
func (s *Store) Extend(
ctx context.Context, ids []uuid.UUID, until time.Time,
) error {
if len(ids) == 0 {
return nil
}
_, err := s.pool.Exec(ctx, `
UPDATE queue_jobs SET run_at = $2
WHERE id = ANY($1) AND state = 'pending'`, ids, until,
)
if err != nil {
return fmt.Errorf("failed to extend leases: %w", err)
}
return nil
}
// Succeed implements the [queue.Store] interface.
func (*Store) Succeed(
ctx context.Context, tx pgx.Tx, id uuid.UUID, at time.Time,
) (bool, error) {
res, err := tx.Exec(ctx, `
UPDATE queue_jobs SET
state = 'succeeded',
attempts = attempts + 1,
note = '',
settled_at = $2
WHERE id = $1 AND state = 'pending'`, id, at,
)
if err != nil {
return false, fmt.Errorf("failed to record a finished job: %w", err)
}
return res.RowsAffected() > 0, nil
}
// Retry implements the [queue.Store] interface.
func (*Store) Retry(
ctx context.Context, tx pgx.Tx, id uuid.UUID, next time.Time,
note string,
) (bool, error) {
res, err := tx.Exec(ctx, `
UPDATE queue_jobs SET
attempts = attempts + 1,
run_at = $2,
note = $3
WHERE id = $1 AND state = 'pending'`, id, next, note,
)
if err != nil {
return false, fmt.Errorf("failed to schedule a retry: %w", err)
}
return res.RowsAffected() > 0, nil
}
// Defer implements the [queue.Store] interface: the attempt count is
// left exactly where it was.
func (*Store) Defer(
ctx context.Context, tx pgx.Tx, id uuid.UUID, next time.Time,
note string,
) (bool, error) {
res, err := tx.Exec(ctx, `
UPDATE queue_jobs SET
run_at = $2,
note = $3
WHERE id = $1 AND state = 'pending'`, id, next, note,
)
if err != nil {
return false, fmt.Errorf("failed to defer job: %w", err)
}
return res.RowsAffected() > 0, nil
}
// Bury implements the [queue.Store] interface.
func (*Store) Bury(
ctx context.Context, tx pgx.Tx, id uuid.UUID, at time.Time,
note string,
) (bool, error) {
res, err := tx.Exec(ctx, `
UPDATE queue_jobs SET
state = 'dead',
attempts = attempts + 1,
note = $3,
settled_at = $2
WHERE id = $1 AND state = 'pending'`, id, at, note,
)
if err != nil {
return false, fmt.Errorf("failed to bury job: %w", err)
}
return res.RowsAffected() > 0, nil
}
// Depths implements the [queue.Store] interface. The partial index on
// the open rows keeps the report off the settled bulk.
func (s *Store) Depths(
ctx context.Context, now time.Time,
) ([]queue.Depth, error) {
rows, err := s.pool.Query(ctx, `
SELECT kind,
count(*) FILTER (WHERE state = 'pending' AND run_at <= $1),
count(*) FILTER (WHERE state = 'pending' AND run_at > $1),
count(*) FILTER (WHERE state = 'dead'),
min(run_at) FILTER (WHERE state = 'pending' AND run_at <= $1)
FROM queue_jobs
WHERE state IN ('pending', 'dead')
GROUP BY kind
ORDER BY kind`, now,
)
if err != nil {
return nil, fmt.Errorf("failed to report queue depth: %w", err)
}
defer rows.Close()
var out []queue.Depth
for rows.Next() {
var (
d queue.Depth
oldest *time.Time
)
if err := rows.Scan(
&d.Kind, &d.Pending, &d.Delayed, &d.Dead, &oldest,
); err != nil {
return nil, fmt.Errorf("failed to report queue depth: %w", err)
}
d.Oldest = unstamp(oldest)
out = append(out, d)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to report queue depth: %w", err)
}
return out, nil
}
// Prune implements the [queue.Store] interface. One call removes at
// most [pruneBatch] jobs; the queue's retention pass repeats until the
// window is clear, which keeps each transaction short.
func (*Store) Prune(
ctx context.Context, tx pgx.Tx, before time.Time,
) (int64, error) {
res, err := tx.Exec(ctx, `
WITH aged AS (
SELECT id FROM queue_jobs
WHERE settled_at IS NOT NULL AND settled_at < $1
LIMIT $2
)
DELETE FROM queue_jobs j USING aged WHERE j.id = aged.id`,
before, pruneBatch,
)
if err != nil {
return 0, fmt.Errorf("failed to prune settled jobs: %w", err)
}
return res.RowsAffected(), nil
}
var _ queue.Store[pgx.Tx] = (*Store)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package queue
import (
"context"
"errors"
"fmt"
"sync"
"uuid"
"github.com/deep-rent/nexus/std/ascii"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/metrics"
)
// maxPrunePasses bounds one retention run, so a queue that produces
// settled jobs faster than they can be pruned still yields the
// scheduler its slot back.
const maxPrunePasses = 1000
// Queue is the producer side of the job queue: pushing work, tracking
// it, and the housekeeping that keeps the table healthy. Workers are
// created from it with [Queue.Worker]. It is safe for concurrent use.
type Queue[Tx any] struct {
store Store[Tx]
cfg config
// mu guards seen, the set of kinds the backlog sampler has published
// gauges for. A kind that drains away stops appearing in the
// driver's report, and its gauges would otherwise sit at their last
// value forever.
mu sync.Mutex
seen map[string]struct{}
}
// New creates a [Queue] over the given store. It panics on a nil store
// (programmer error).
func New[Tx any](store Store[Tx], opts ...Option) *Queue[Tx] {
if store == nil {
panic("store is required")
}
cfg := defaults()
for _, opt := range opts {
opt(&cfg)
}
return &Queue[Tx]{
store: store,
cfg: cfg,
seen: make(map[string]struct{}),
}
}
// build validates a request and stamps it into a fresh pending job.
func (q *Queue[Tx]) build(r Request) (Job, error) {
if !ValidKind(r.Kind) {
return Job{}, fmt.Errorf("invalid kind %q", r.Kind)
}
if len(r.Key) > MaxKeyLength {
return Job{}, fmt.Errorf(
"idempotency key must be at most %d bytes", MaxKeyLength,
)
}
// The key reaches a text column and an index; a control character
// in it is either a mistake or an attempt to smuggle one past a log.
if !ascii.All(r.Key, ascii.IsPrint) {
return Job{}, errors.New("idempotency key must be printable ASCII")
}
if r.Priority < MinPriority || r.Priority > MaxPriority {
return Job{}, fmt.Errorf(
"priority must be %d to %d", MinPriority, MaxPriority,
)
}
if len(r.Payload) > MaxPayloadSize {
return Job{}, fmt.Errorf(
"payload exceeds %d bytes — job payloads are thin by "+
"design; push identifiers, not resources",
MaxPayloadSize,
)
}
// Reject a malformed payload here, where the producer can react,
// rather than letting it surface at the first attempt — where it
// would dead-letter a job nobody is watching.
if len(r.Payload) > 0 && !r.Payload.IsValid() {
return Job{}, errors.New("payload is not valid JSON")
}
now := q.cfg.clock().UTC()
run := r.RunAt
if run.IsZero() {
run = now
}
return Job{
ID: uuid.NewV7(),
Kind: r.Kind,
Payload: r.Payload,
Key: r.Key,
Priority: r.Priority,
State: StatePending,
RunAt: run.UTC(),
CreatedAt: now,
}, nil
}
// Push enqueues one job INSIDE the producer's own transaction — the
// transactional outbox: if the business write commits, the job exists;
// if it rolls back, no orphan work is left behind to run against state
// that was never persisted.
//
// It reports whether the job was actually enqueued. A request carrying
// an idempotency key already held by a pending job of the same kind
// enqueues nothing and returns that job with pushed == false, which is
// success, not failure: the work the caller asked for is already on its
// way.
func (q *Queue[Tx]) Push(
ctx context.Context,
tx Tx,
r Request,
) (job Job, pushed bool, err error) {
job, err = q.build(r)
if err != nil {
return Job{}, false, err
}
return q.store.Insert(ctx, tx, job)
}
// PushBatch enqueues several jobs in the producer's transaction, in one
// round trip, and reports how many were enqueued — a request whose
// idempotency key is already held by a pending job enqueues nothing and
// is counted out, exactly as in [Queue.Push].
//
// It is the fan-out primitive: one event bound for two hundred
// recipients is two hundred jobs, and a round trip each would hold the
// producer's transaction open for all of them. Every request is
// validated before any is written, so a malformed one refuses the batch
// rather than enqueueing half of it.
func (q *Queue[Tx]) PushBatch(
ctx context.Context,
tx Tx,
rs []Request,
) (int, error) {
if len(rs) == 0 {
return 0, nil
}
jobs := make([]Job, len(rs))
for i, r := range rs {
job, err := q.build(r)
if err != nil {
return 0, fmt.Errorf("request %d: %w", i, err)
}
jobs[i] = job
}
return q.store.InsertBatch(ctx, tx, jobs)
}
// List returns the jobs matching the filter, newest first — the
// operator's window onto the queue, and the way a dead job's identifier
// is found before [Queue.Requeue] is given it.
//
// Page through by passing the last job's identifier as the next
// filter's [Filter.Cursor].
func (q *Queue[Tx]) List(ctx context.Context, f Filter) ([]Job, error) {
var out []Job
err := q.store.Exec(ctx, func(ctx context.Context, tx Tx) error {
var err error
out, err = q.store.Jobs(ctx, tx, f.normalize())
return err
})
return out, err
}
// Submit is [Queue.Push] in a transaction of its own, for producers
// with no business write to join. Prefer Push wherever a transaction is
// already open: a job pushed beside an uncommitted write can run before
// — or without — the state it depends on.
func (q *Queue[Tx]) Submit(
ctx context.Context,
r Request,
) (job Job, pushed bool, err error) {
err = q.store.Exec(ctx, func(ctx context.Context, tx Tx) error {
var err error
job, pushed, err = q.Push(ctx, tx, r)
return err
})
if err != nil {
return Job{}, false, err
}
return job, pushed, nil
}
// Get returns one job, reporting [ErrNotFound] when none carries the
// identifier. It is the read behind a "how is my export doing?"
// endpoint — though what the job PRODUCED belongs in the host's own
// tables: the queue tracks execution, and retention eventually prunes
// what it knows.
func (q *Queue[Tx]) Get(ctx context.Context, id uuid.UUID) (Job, error) {
var out Job
err := q.store.Exec(ctx, func(ctx context.Context, tx Tx) error {
job, ok, err := q.store.Job(ctx, tx, id)
if err != nil {
return err
}
if !ok {
return ErrNotFound
}
out = job
return nil
})
return out, err
}
// Cancel withdraws a job that has not settled. A job a worker is
// running right now cancels too: the worker's verdict is dropped when
// it lands, though the attempt itself runs to its end — cancellation
// stops future attempts, it does not reach into a running one.
//
// It reports [ErrNotFound] when no job carries the identifier, and
// [ErrConflict] when the job has already settled.
func (q *Queue[Tx]) Cancel(ctx context.Context, id uuid.UUID) error {
return q.store.Exec(ctx, func(ctx context.Context, tx Tx) error {
job, ok, err := q.store.Job(ctx, tx, id)
if err != nil {
return err
}
if !ok {
return ErrNotFound
}
if job.State.Terminal() {
return fmt.Errorf("%w: %s", ErrConflict, job.State)
}
_, err = q.store.Cancel(ctx, tx, id, q.cfg.clock().UTC())
return err
})
}
// Requeue returns a dead-lettered job to the queue with a fresh attempt
// budget — the operator's second chance, once whatever broke has been
// fixed. It reports [ErrNotFound] when no job carries the identifier,
// and [ErrConflict] when the job is not dead.
func (q *Queue[Tx]) Requeue(ctx context.Context, id uuid.UUID) error {
return q.store.Exec(ctx, func(ctx context.Context, tx Tx) error {
job, ok, err := q.store.Job(ctx, tx, id)
if err != nil {
return err
}
if !ok {
return ErrNotFound
}
if job.State != StateDead {
return fmt.Errorf("%w: %s", ErrConflict, job.State)
}
_, err = q.store.Requeue(ctx, tx, id, q.cfg.clock().UTC())
return err
})
}
// Backlog samples the queue's standing into the depth gauges, per job
// kind. It satisfies [schedule.TaskFn]; run it on a modest cadence from
// one process — every replica sampling the same table only multiplies
// the query.
//
// [MetricOldest] is the one to alert on: a backlog of ten that never
// ages is a fleet keeping pace, while a backlog of one stuck for an
// hour is a job nobody can run.
//
// [schedule.TaskFn]: github.com/deep-rent/nexus/sys/schedule#TaskFn
func (q *Queue[Tx]) Backlog(ctx context.Context) {
now := q.cfg.clock().UTC()
depths, err := q.store.Depths(ctx, now)
if err != nil {
q.cfg.logger.Error(ctx, "Failed to sample queue depth",
log.Error(err))
return
}
q.mu.Lock()
defer q.mu.Unlock()
fresh := make(map[string]struct{}, len(depths))
for _, d := range depths {
fresh[d.Kind] = struct{}{}
var age float64
if !d.Oldest.IsZero() {
age = max(0, now.Sub(d.Oldest).Seconds())
}
q.gauge(MetricPending, d.Kind).Set(float64(d.Pending))
q.gauge(MetricDelayed, d.Kind).Set(float64(d.Delayed))
q.gauge(MetricDead, d.Kind).Set(float64(d.Dead))
q.gauge(MetricOldest, d.Kind).Set(age)
}
// A kind that drained away reports nothing at all; zero its gauges
// once so a dashboard shows an empty queue rather than the last
// depth it ever had.
for kind := range q.seen {
if _, ok := fresh[kind]; ok {
continue
}
q.gauge(MetricPending, kind).Set(0)
q.gauge(MetricDelayed, kind).Set(0)
q.gauge(MetricDead, kind).Set(0)
q.gauge(MetricOldest, kind).Set(0)
}
q.seen = fresh
}
// gauge resolves one depth gauge of a kind.
func (q *Queue[Tx]) gauge(name, kind string) *metrics.Gauge {
return q.cfg.reg.Gauge(name, metrics.T("kind", kind))
}
// Retention prunes jobs that settled longer ago than the retention
// window — succeeded, dead, and canceled alike. It satisfies
// [schedule.TaskFn] like [Queue.Backlog], and wants the same single
// runner.
//
// Dead jobs are pruned too: the window is how long a failure stays
// available for inspection and [Queue.Requeue], so a queue whose dead
// letters matter wants a window long enough to notice them in.
//
// [schedule.TaskFn]: github.com/deep-rent/nexus/sys/schedule#TaskFn
func (q *Queue[Tx]) Retention(ctx context.Context) {
cutoff := q.cfg.clock().UTC().Add(-q.cfg.retention)
var pruned int64
// Drivers prune in bounded batches, so that one neglected window
// does not turn into one transaction that locks the table for
// minutes. Keep asking until the window is clear.
for range maxPrunePasses {
var n int64
err := q.store.Exec(ctx, func(ctx context.Context, tx Tx) error {
var err error
n, err = q.store.Prune(ctx, tx, cutoff)
return err
})
if err != nil {
q.cfg.logger.Error(ctx, "Retention pass failed", log.Error(err))
return
}
pruned += n
if n == 0 || ctx.Err() != nil {
break
}
}
if pruned > 0 {
q.cfg.logger.Info(ctx, "Pruned settled jobs",
log.Int64("jobs", pruned),
log.Time("before", cutoff),
)
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package queue
import (
"time"
"github.com/deep-rent/nexus/std/backoff"
"github.com/deep-rent/nexus/std/clock"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/metrics"
)
// Defaults of the knobs left unset by the options.
const (
// DefaultRetention is the age after which settled jobs are pruned.
DefaultRetention = 7 * 24 * time.Hour
// DefaultConcurrency is how many jobs one worker runs at once.
DefaultConcurrency = 8
// DefaultBatch bounds one claim; the worker never asks for more than
// its free capacity anyway.
DefaultBatch = 32
// DefaultLease is how long a claimed job stays off the queue. A
// running job's lease is extended by the heartbeat, so this bounds
// how long a job stays stranded after its worker DIES, not how long
// a job may run.
DefaultLease = time.Minute
// DefaultPoll is how long a worker waits before looking for work
// again after finding none. It is the queue's idle latency floor.
DefaultPoll = time.Second
// DefaultDrain is how long [Worker.Run] lets running jobs finish
// after its context is canceled, before canceling them too.
DefaultDrain = 30 * time.Second
// DefaultTimeout bounds one attempt of a job whose handler names no
// timeout of its own.
DefaultTimeout = time.Minute
// DefaultRetries is the retry budget beyond the initial attempt.
DefaultRetries = 5
// MinLease is the floor under [WithLease]; below it a heartbeat
// could not plausibly keep up.
MinLease = 5 * time.Second
)
// DefaultStrategy builds the default retry pacing: exponential from ten
// seconds toward an hourly ceiling, with the backoff package's default
// jitter spreading concurrent retries apart.
func DefaultStrategy() backoff.Strategy {
return backoff.New(
backoff.WithMinDelay(10*time.Second),
backoff.WithMaxDelay(time.Hour),
)
}
// config holds the queue settings.
type config struct {
logger *log.Logger
clock clock.Clock
reg *metrics.Registry
retention time.Duration
}
// defaults returns the baseline queue configuration.
func defaults() config {
return config{
logger: log.Discard(),
clock: clock.System,
reg: metrics.DefaultRegistry,
retention: DefaultRetention,
}
}
// Option configures a [Queue]. A worker created with [Queue.Worker]
// inherits the queue's logger, clock, and registry.
type Option func(*config)
// WithLogger sets the logger receiving queue diagnostics. If not
// provided, the queue stays silent. A nil logger is ignored.
func WithLogger(logger *log.Logger) Option {
return func(c *config) {
if logger != nil {
c.logger = logger
}
}
}
// WithClock injects the time source, which is primarily useful for
// testing. A nil clock is ignored.
func WithClock(now clock.Clock) Option {
return func(c *config) {
if now != nil {
c.clock = now
}
}
}
// WithRegistry registers the queue's instruments with reg instead of
// [metrics.DefaultRegistry]. A nil registry is ignored.
func WithRegistry(reg *metrics.Registry) Option {
return func(c *config) {
if reg != nil {
c.reg = reg
}
}
}
// WithRetention overrides how long settled jobs are kept; see
// [Queue.Retention]. Values of zero or less are ignored.
func WithRetention(d time.Duration) Option {
return func(c *config) {
if d > 0 {
c.retention = d
}
}
}
// limits are the per-attempt bounds a handler runs under. A worker
// carries the fleet-wide set; each registration may override it.
type limits struct {
strategy backoff.Strategy
retries int
timeout time.Duration
}
// worker holds the worker settings.
type worker struct {
limits
concurrency int
batch int
lease time.Duration
poll time.Duration
drain time.Duration
}
// workerDefaults returns the baseline worker configuration.
func workerDefaults() worker {
return worker{
strategy: DefaultStrategy(),
retries: DefaultRetries,
timeout: DefaultTimeout,
concurrency: DefaultConcurrency,
batch: DefaultBatch,
lease: DefaultLease,
poll: DefaultPoll,
drain: DefaultDrain,
}
}
// WorkerOption configures a [Worker].
type WorkerOption func(*worker)
// WithConcurrency sets how many jobs the worker runs at once. Values of
// zero or less are ignored.
func WithConcurrency(n int) WorkerOption {
return func(w *worker) {
if n > 0 {
w.concurrency = n
}
}
}
// WithBatch bounds one claim. Values of zero or less are ignored.
func WithBatch(n int) WorkerOption {
return func(w *worker) {
if n > 0 {
w.batch = n
}
}
}
// WithLease overrides how long a claim holds a job before the heartbeat
// has to renew it. Values below [MinLease] are ignored.
func WithLease(d time.Duration) WorkerOption {
return func(w *worker) {
if d >= MinLease {
w.lease = d
}
}
}
// WithPoll overrides the idle poll interval — how long the worker waits
// after an empty claim before asking again. Values of zero or less are
// ignored.
func WithPoll(d time.Duration) WorkerOption {
return func(w *worker) {
if d > 0 {
w.poll = d
}
}
}
// WithDrain overrides the shutdown grace: how long [Worker.Run] lets
// running jobs finish after its context is canceled. Negative values
// are ignored; zero cancels running jobs at once.
func WithDrain(d time.Duration) WorkerOption {
return func(w *worker) {
if d >= 0 {
w.drain = d
}
}
}
// WithBackoff replaces the fleet-wide retry pacing; see [backoff.New]
// for the strategy vocabulary. A nil strategy is ignored.
func WithBackoff(s backoff.Strategy) WorkerOption {
return func(w *worker) {
if s != nil {
w.strategy = s
}
}
}
// WithRetries replaces the fleet-wide retry budget beyond the initial
// attempt. Negative values are ignored; zero means one attempt, no
// retries.
func WithRetries(n int) WorkerOption {
return func(w *worker) {
if n >= 0 {
w.retries = n
}
}
}
// WithTimeout bounds one attempt fleet-wide. Values of zero or less are
// ignored.
func WithTimeout(d time.Duration) WorkerOption {
return func(w *worker) {
if d > 0 {
w.timeout = d
}
}
}
// HandlerOption overrides a worker's limits for one kind of job — the
// knobs that differ between a ten-minute render and a ten-second
// webhook delivery sharing a fleet.
type HandlerOption func(*limits)
// HandlerTimeout bounds one attempt of this kind. Values of zero or
// less are ignored.
func HandlerTimeout(d time.Duration) HandlerOption {
return func(l *limits) {
if d > 0 {
l.timeout = d
}
}
}
// HandlerRetries replaces the retry budget of this kind. Negative
// values are ignored; zero means one attempt, no retries.
func HandlerRetries(n int) HandlerOption {
return func(l *limits) {
if n >= 0 {
l.retries = n
}
}
}
// HandlerBackoff replaces the retry pacing of this kind. A nil strategy
// is ignored.
func HandlerBackoff(s backoff.Strategy) HandlerOption {
return func(l *limits) {
if s != nil {
l.strategy = s
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package queue
import (
"encoding/json/jsontext"
"errors"
"fmt"
"strings"
"time"
"uuid"
"github.com/deep-rent/nexus/dat/valid"
"github.com/deep-rent/nexus/std/text"
)
// Bounds on the pushed inputs.
const (
// MaxKindLength bounds a job kind.
MaxKindLength = 64
// MaxKeyLength bounds an idempotency key.
MaxKeyLength = 128
// MaxPriority and MinPriority bound [Job.Priority]. The band is
// narrow on purpose: priority decides which of two waiting jobs runs
// first, and a wide scale invites callers to invent a total order
// the queue never promised.
MaxPriority = 1000
MinPriority = -1000
// MaxPayloadSize bounds a job payload. Payloads are THIN by
// convention — identifiers, not resources — so the cap is
// deliberately tight; see the package documentation.
MaxPayloadSize = 64 << 10
// MaxNoteLength bounds the stored failure note of an attempt.
MaxNoteLength = 256
)
// Names of the metrics the queue records, exported so dashboards,
// alert rules, and tests reference the registration spelling. All
// carry a "kind" tag.
const (
// MetricJobs counts settled attempts, tagged by result as well; see
// [ResultSucceeded] and its siblings.
MetricJobs = "queue_jobs_total"
// MetricPanics counts attempts whose handler panicked. They are
// counted as failures too; this one exists to be alerted on.
MetricPanics = "queue_job_panics_total"
// MetricDuration is the summary of attempt durations in seconds.
MetricDuration = "queue_job_seconds"
// MetricRunning gauges the jobs one worker is running right now.
MetricRunning = "queue_jobs_running"
// MetricPending gauges the jobs due for attempt; see [Depth].
MetricPending = "queue_jobs_pending"
// MetricDelayed gauges the jobs waiting or in flight; see [Depth].
MetricDelayed = "queue_jobs_delayed"
// MetricDead gauges the dead-lettered jobs still retained.
MetricDead = "queue_jobs_dead"
// MetricOldest gauges how long the longest-waiting due job has been
// waiting, in seconds — the queue's latency.
MetricOldest = "queue_oldest_pending_seconds"
)
// The attempt outcome vocabulary of [MetricJobs].
const (
// ResultSucceeded counts attempts whose handler returned nil.
ResultSucceeded = "succeeded"
// ResultRetried counts failed attempts with retry budget left.
ResultRetried = "retried"
// ResultDead counts attempts that dead-lettered the job, by
// exhausting the budget or by [Abort].
ResultDead = "dead"
// ResultDeferred counts jobs a handler declined to start; see
// [Defer]. They cost no attempt, so a rate that climbs means work
// waiting on capacity rather than work going wrong.
ResultDeferred = "deferred"
)
// State is the lifecycle state of a job.
type State string
// The job lifecycle vocabulary.
const (
// StatePending awaits its next attempt. A job leased by a worker
// stays pending with its RunAt pushed out to the lease expiry, so a
// worker that dies simply lets the job fall due again.
StatePending State = "pending"
// StateSucceeded ran to completion: a handler returned nil.
// Terminal.
StateSucceeded State = "succeeded"
// StateDead exhausted its retry budget, or failed permanently via
// [Abort]. Terminal, and the one state that wants an alert.
StateDead State = "dead"
// StateCanceled was withdrawn before it settled. Terminal.
StateCanceled State = "canceled"
)
// Terminal reports whether the state is final — no worker will touch
// the job again.
func (s State) Terminal() bool {
return s == StateSucceeded || s == StateDead || s == StateCanceled
}
// Job is one unit of durable, at-least-once work.
type Job struct {
// ID identifies the job (UUIDv7).
ID uuid.UUID
// Kind selects the handler, lowercase and dot-separated:
// "pdf.render", "hook.deliver".
Kind string
// Payload is the handler's input. Keep it thin: identifiers, not
// resources — a handler re-reads the current state of the world at
// attempt time, which is what makes a retry days later still
// correct. May be nil.
Payload jsontext.Value
// Key is the optional idempotency key: printable ASCII, opaque to
// the queue. While a job of the same kind and key is pending, a push
// carrying it enqueues nothing and returns the job already in
// flight.
Key string
// Priority orders the queue: higher runs first, ties broken by due
// time. Zero is the ordinary band.
Priority int
// State is the lifecycle state.
State State
// Attempts counts the attempts made so far.
Attempts int
// RunAt is when the job is next due. It carries both meanings the
// queue needs: a delayed job is not yet due, a failed job is due
// after its backoff, and a leased job is due when its lease lapses.
RunAt time.Time
// Note describes the most recent failure, if any.
Note string
// CreatedAt is when the job was pushed.
CreatedAt time.Time
// SettledAt is when the job reached a terminal state; zero while
// pending.
SettledAt time.Time
}
// Request is the input of [Queue.Push] and [Queue.Submit].
type Request struct {
// Kind selects the handler; see [Job.Kind].
Kind string
// Payload is the handler's input; see [Job.Payload].
Payload jsontext.Value
// Key is the optional idempotency key; see [Job.Key].
Key string
// Priority orders the queue; see [Job.Priority].
Priority int
// RunAt withholds the job until the given instant. The zero value
// makes it due immediately.
RunAt time.Time
}
// Filter selects jobs for [Queue.List]. The zero filter matches
// everything, newest first.
type Filter struct {
// Kinds narrows the listing to these kinds; empty matches every
// kind.
Kinds []string
// States narrows the listing to these states; empty matches every
// state. Listing [StateDead] is what an operator does before
// deciding what to requeue.
States []State
// Cursor continues a listing after the job it names, for paging
// through more than one page's worth. The zero value starts at the
// newest job.
//
// Identifiers are UUIDv7 and therefore ordered by creation, so the
// cursor is both the page marker and the sort key — a listing cannot
// skip or repeat a job because one settled while the operator read.
Cursor uuid.UUID
// Limit bounds the page. Values of zero or less apply
// [DefaultListLimit], and anything above [MaxListLimit] is clamped
// to it.
Limit int
}
// Bounds on a listing page.
const (
// DefaultListLimit is the page size of a listing that names none.
DefaultListLimit = 50
// MaxListLimit is the largest page a listing may ask for. An
// operator paging through a dead-letter pile is reading, not
// exporting.
MaxListLimit = 500
)
// normalize clamps the filter's page bounds.
func (f Filter) normalize() Filter {
switch {
case f.Limit <= 0:
f.Limit = DefaultListLimit
case f.Limit > MaxListLimit:
f.Limit = MaxListLimit
}
return f
}
// Depth is the queue's standing at one instant, per job kind — the
// numbers an operator watches.
type Depth struct {
// Kind is the job kind these counts belong to.
Kind string
// Pending counts jobs due for attempt now: the work a fleet still
// has to get through. A number that climbs and stays up means the
// fleet is not keeping pace.
Pending int64
// Delayed counts pending jobs not yet due — scheduled for later, or
// leased by a worker right now. Waiting, not backed up.
Delayed int64
// Dead counts dead-lettered jobs still held by the retention
// window. It should sit near zero.
Dead int64
// Oldest is the due time of the longest-waiting due job; zero when
// none is due. Its age is the queue's latency, and the signal worth
// alerting on: a backlog of ten that never ages is healthy, a
// backlog of one stuck for an hour is not.
Oldest time.Time
}
// ErrNotFound reports an operation on a job that does not exist.
var ErrNotFound = errors.New("job not found")
// ErrConflict reports an operation the job's current state does not
// allow: canceling one that has already settled, requeueing one that is
// not dead. Hosts mapping the queue onto HTTP answer 409.
var ErrConflict = errors.New("job state does not allow the operation")
// ErrAbort marks a failure as permanent; see [Abort].
var ErrAbort = errors.New("permanent failure")
// Abort wraps err to tell the worker that no retry can help: the job
// dead-letters at once, whatever budget remains. Use it for failures
// rooted in the job itself rather than in the world around it — an
// unparseable payload, a document that no longer exists, a receiver
// that answered 410 Gone.
//
// if errors.Is(err, dse.ErrNoSuchDocument) {
// return queue.Abort(err)
// }
//
// The wrapper is transparent: errors.Is and errors.As still see err.
func Abort(err error) error {
if err == nil {
return ErrAbort
}
return fmt.Errorf("%w: %w", ErrAbort, err)
}
// RetryAfter wraps err to override the backoff strategy for the next
// attempt only — for failures that name their own delay, such as a
// Retry-After header or a rate limiter's reset time. The retry budget
// still applies: once it is spent the job dead-letters like any other.
//
// A delay of zero or less falls back to the strategy.
func RetryAfter(d time.Duration, err error) error {
if err == nil {
err = errors.New("retry requested")
}
return &deferral{delay: d, err: err}
}
// Defer tells the worker that the job could not be STARTED and should
// come back later, without counting an attempt against it. Use it when
// the obstacle is not the job's own doing — a per-recipient concurrency
// cap already full, a dependency not yet ready, a maintenance window —
// so that waiting cannot dead-letter work that has never once failed.
//
// A delay of zero or less falls back to the strategy's minimum. The
// reason is stored on the job like a failure note, so an operator
// looking at a queue that is not moving can see why.
//
// if !limiter.Allow(endpoint) {
// return queue.Defer(time.Second, "endpoint at capacity")
// }
//
// Compare [RetryAfter], which is for a job that DID run and failed, and
// which spends an attempt.
func Defer(d time.Duration, reason string) error {
if reason == "" {
reason = "deferred"
}
return &deferral{delay: d, err: errors.New(reason), hold: true}
}
// deferral carries the delay of [RetryAfter] and [Defer].
type deferral struct {
delay time.Duration
err error
// hold marks a deferral that must not count as an attempt.
hold bool
}
func (d *deferral) Error() string { return d.err.Error() }
func (d *deferral) Unwrap() error { return d.err }
// after reports the delay a failure asked for, if any.
func after(err error) (time.Duration, bool) {
var d *deferral
if errors.As(err, &d) && d.delay > 0 {
return d.delay, true
}
return 0, false
}
// held reports whether the error is a [Defer]: the job did not run, so
// the worker must reschedule it without spending an attempt.
func held(err error) bool {
var d *deferral
return errors.As(err, &d) && d.hold
}
// ValidKind reports whether the kind is well-formed: a [valid.Topic]
// within the length bound.
func ValidKind(kind string) bool {
return len(kind) <= MaxKindLength && valid.Topic(kind)
}
// truncate bounds a stored failure note and makes it storable. A note
// is whatever a handler's error said, and an error carrying a byte from
// some wire is not necessarily valid UTF-8 — which PostgreSQL refuses,
// failing the very write that records the failure and leaving the job to
// be attempted again with nothing learned.
func truncate(note string) string {
return text.Truncate(strings.ToValidUTF8(note, "\uFFFD"), MaxNoteLength)
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package queue
import (
"context"
"errors"
"fmt"
"maps"
"runtime/debug"
"slices"
"strings"
"sync"
"time"
"uuid"
"github.com/deep-rent/nexus/std/jitter"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/metrics"
)
// Handler runs one job. Returning nil settles it as succeeded; any
// error costs one attempt from the budget and schedules a retry, until
// the budget runs out and the job dead-letters. [Abort] gives up at
// once, and [RetryAfter] names the delay before the next attempt.
//
// A handler MUST be idempotent. Delivery is at-least-once: a worker
// that dies after finishing the work but before recording it leaves the
// job to be claimed again, and no amount of care in the queue can
// change that.
//
// The context carries the attempt's timeout and is canceled when the
// worker shuts down; a handler doing long work should honour it and
// return, rather than being killed mid-write. A handler that ignores it
// keeps its slot until it returns, and its job falls due again once the
// attempt's deadline passes — the queue stops renewing the lease of an
// attempt that is already over.
//
// The returned error is stored on the job and logged. Keep secrets out
// of it: an error carrying a token or a connection string ends up in
// the queue table and every log sink downstream.
type Handler func(ctx context.Context, job Job) error
// registration is one kind's handler with the limits it runs under.
type registration struct {
handler Handler
limits limits
}
// settleTimeout bounds the write that records an attempt's verdict. It
// runs on a context detached from the worker's, so a job that finished
// just as the process began shutting down still records its outcome
// instead of coming back for another run.
const settleTimeout = 5 * time.Second
// pollJitter scatters the idle poll, so replicas that started together
// do not query the queue in lockstep.
const pollJitter = 0.25
// errorCeiling caps the backoff applied to a queue that cannot be
// reached; long enough not to hammer a struggling database, short
// enough to recover promptly once it returns.
const errorCeiling = 30 * time.Second
// Worker is the consumer side of the job queue: it claims due jobs of
// the kinds it has handlers for, runs them under their limits, and
// records what happened. Create one with [Queue.Worker], register
// handlers, then call [Worker.Run].
//
// Any number of workers may run against one queue, in one process or
// across a fleet: claims never overlap, and a worker that dies lets its
// leases lapse back into the queue.
type Worker[Tx any] struct {
store Store[Tx]
cfg config
opt worker
// mu guards the registry and the started flag together, so a handler
// registered as Run reads the kinds is either claimed from the start
// or refused outright.
mu sync.RWMutex
handlers map[string]registration
started bool
// leased holds the jobs this worker is running, each with the
// deadline of its attempt, for the heartbeat to renew.
leased sync.Map // uuid.UUID -> time.Time
}
// Worker creates a worker over the queue's store, inheriting its
// logger, clock, and metrics registry.
func (q *Queue[Tx]) Worker(opts ...WorkerOption) *Worker[Tx] {
opt := workerDefaults()
for _, o := range opts {
o(&opt)
}
return &Worker[Tx]{
store: q.store,
cfg: q.cfg,
opt: opt,
handlers: make(map[string]registration),
}
}
// Handle registers the handler for one kind of job, optionally under
// limits of its own — the ten-minute render and the ten-second webhook
// delivery of one fleet want different timeouts and different retry
// budgets.
//
// A worker claims ONLY the kinds it has handlers for, which is what
// lets specialised fleets share one queue. Jobs of an unregistered kind
// are left untouched for whoever does handle them.
//
// It panics on an invalid kind, a nil handler, a kind registered twice,
// or a registration after [Worker.Run] has started (all programmer
// errors).
func (w *Worker[Tx]) Handle(
kind string,
h Handler,
opts ...HandlerOption,
) {
if !ValidKind(kind) {
panic(fmt.Sprintf("invalid kind %q", kind))
}
if h == nil {
panic("handler is required")
}
lim := w.opt.limits
for _, o := range opts {
o(&lim)
}
w.mu.Lock()
defer w.mu.Unlock()
if w.started {
panic("handlers must be registered before Run")
}
if _, dup := w.handlers[kind]; dup {
panic(fmt.Sprintf("duplicate handler for kind %q", kind))
}
w.handlers[kind] = registration{handler: h, limits: lim}
}
// lookup resolves a kind's registration.
func (w *Worker[Tx]) lookup(kind string) (registration, bool) {
w.mu.RLock()
defer w.mu.RUnlock()
reg, ok := w.handlers[kind]
return reg, ok
}
// Run claims and runs jobs until ctx is canceled, then drains: it stops
// claiming, lets the running jobs finish within the drain grace, and
// cancels whatever is still running when the grace expires. It returns
// nil on that ordinary shutdown, and an error only when the worker
// cannot start at all.
//
// Run blocks, which makes it the main stage of a worker service:
//
// w := q.Worker(queue.WithConcurrency(4))
// w.Handle("pdf.render", renderer.Render,
// queue.HandlerTimeout(10*time.Minute))
// return w.Run(ctx)
//
// It may be called once per worker.
func (w *Worker[Tx]) Run(ctx context.Context) error {
w.mu.Lock()
if w.started {
w.mu.Unlock()
return errors.New("worker is already running")
}
w.started = true
kinds := slices.Sorted(maps.Keys(w.handlers))
w.mu.Unlock()
if len(kinds) == 0 {
return errors.New("no handlers registered")
}
// Jobs run under a context detached from ctx, so a shutdown can
// offer them the drain grace before pulling the rug; stop() is what
// finally cancels them, and the heartbeat with them.
jobs, stop := context.WithCancel(context.WithoutCancel(ctx))
defer stop()
var (
wg sync.WaitGroup // running jobs
beat sync.WaitGroup // the lease heartbeat
slots = make(chan struct{}, w.opt.concurrency)
scat = jitter.New(pollJitter, nil)
fails int
)
beat.Go(func() { w.heartbeat(jobs) })
w.cfg.logger.Info(ctx, "Worker started",
log.String("kinds", strings.Join(kinds, ",")),
log.Int("concurrency", w.opt.concurrency),
log.Duration("lease", w.opt.lease),
)
claiming:
for {
// Hold a slot before claiming: a claim is a lease, and leasing
// work this worker has no capacity to start would only strand it
// until the lease lapsed.
select {
case <-ctx.Done():
break claiming
case slots <- struct{}{}:
}
// Only this goroutine fills slots, so the free capacity measured
// here can only grow before it is used — the remaining tokens are
// there to take.
free := w.opt.concurrency - len(slots) + 1
claimed, err := w.claim(ctx, min(w.opt.batch, free), kinds)
if err != nil || len(claimed) == 0 {
<-slots
delay := scat.Apply(w.opt.poll)
if err != nil {
fails++
if ctx.Err() == nil {
w.cfg.logger.Error(ctx, "Failed to claim jobs",
log.Error(err), log.Int("failures", fails))
}
// Back off a queue that cannot be reached, rather than
// polling a struggling database at full rate. The shift
// is bounded before it can overflow the duration.
delay = min(w.opt.poll<<min(fails, 20), errorCeiling)
}
select {
case <-ctx.Done():
break claiming
case <-time.After(delay):
}
continue
}
fails = 0
for i, job := range claimed {
if i > 0 {
slots <- struct{}{}
}
wg.Go(func() {
defer func() { <-slots }()
w.run(jobs, job)
})
}
}
w.drain(ctx, &wg, stop)
beat.Wait()
w.cfg.logger.Info(ctx, "Worker stopped")
return nil
}
// claim leases the next batch of due jobs.
func (w *Worker[Tx]) claim(
ctx context.Context,
limit int,
kinds []string,
) ([]Job, error) {
now := w.cfg.clock().UTC()
return w.store.Claim(ctx, now, w.opt.lease, limit, kinds)
}
// drain waits for the running jobs, cancelling them once the grace
// expires.
func (w *Worker[Tx]) drain(
ctx context.Context,
wg *sync.WaitGroup,
stop context.CancelFunc,
) {
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
// A worker with nothing running has already drained; do not let a
// zero grace race the wait and report a timeout that never was.
select {
case <-done:
stop()
return
default:
}
timer := time.NewTimer(w.opt.drain)
defer timer.Stop()
select {
case <-done:
case <-timer.C:
w.cfg.logger.Warn(ctx,
"Drain grace expired; canceling jobs still running",
log.Duration("drain", w.opt.drain))
stop()
<-done
}
stop()
}
// heartbeat renews the leases of the jobs this worker is running, so a
// job that outlives its lease is not claimed a second time while it is
// still going. It runs until the job context is canceled — that is,
// until the drain is over.
//
// An attempt whose deadline has passed is no longer renewed: a handler
// that ignores its context would otherwise hold its job off the queue
// forever, invisible to everyone. Letting that lease lapse hands the
// job to a worker that can still finish it.
func (w *Worker[Tx]) heartbeat(ctx context.Context) {
every := max(time.Second, w.opt.lease/3)
ticker := time.NewTicker(every)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
}
var ids []uuid.UUID
live := time.Now()
w.leased.Range(func(key, val any) bool {
if deadline, ok := val.(time.Time); ok && deadline.After(live) {
ids = append(ids, key.(uuid.UUID))
}
return true
})
if len(ids) == 0 {
continue
}
until := w.cfg.clock().UTC().Add(w.opt.lease)
if err := w.store.Extend(ctx, ids, until); err != nil {
if ctx.Err() != nil {
return
}
// The jobs stay leased until their current lease lapses;
// another worker picking one up early is exactly the
// at-least-once case handlers already have to tolerate.
w.cfg.logger.Error(ctx, "Failed to extend leases",
log.Int("jobs", len(ids)), log.Error(err))
}
}
}
// run performs one attempt and records its outcome.
func (w *Worker[Tx]) run(ctx context.Context, job Job) {
reg, ok := w.lookup(job.Kind)
if !ok {
// The claim filters on registered kinds, so this cannot happen;
// leaving the lease to lapse is the harmless way to be wrong.
w.cfg.logger.Error(ctx, "Claimed a job of an unhandled kind",
log.String("kind", job.Kind), log.UUID("job", job.ID))
return
}
running := w.gauge(MetricRunning, job.Kind)
running.Inc()
defer running.Dec()
attempt, cancel := context.WithTimeout(ctx, reg.limits.timeout)
defer cancel()
// The heartbeat renews this job's lease for as long as the attempt
// has left to run, and no longer; the deadline is wall-clock, as
// context deadlines are.
w.leased.Store(job.ID, time.Now().Add(reg.limits.timeout))
defer w.leased.Delete(job.ID)
start := time.Now()
err := w.invoke(attempt, reg.handler, job)
w.cfg.reg.Summary(
MetricDuration, nil, 0, metrics.T("kind", job.Kind),
).Observe(time.Since(start).Seconds())
// The verdict is written on a context of its own: an attempt that
// finished must be recorded even if the worker is shutting down or
// the attempt's own deadline has just passed.
settle, done := context.WithTimeout(
context.WithoutCancel(ctx), settleTimeout,
)
defer done()
if err == nil {
w.succeed(settle, job)
return
}
if held(err) {
// The handler declined to start rather than tried and failed, so
// the job goes back on the queue with its budget intact.
w.hold(settle, job, err, reg.limits)
return
}
if attempt.Err() != nil && !errors.Is(err, ErrAbort) {
// A handler that returned because its deadline passed reports
// whatever it was doing at the time; name the real cause.
err = fmt.Errorf("attempt timed out after %s: %w",
reg.limits.timeout, err)
}
w.fail(settle, job, err, reg.limits)
}
// invoke runs a handler, converting a panic into an ordinary failed
// attempt. One malformed job must not take down a worker that is
// running unrelated work; the retry budget bounds a panic that repeats,
// and [MetricPanics] makes it visible meanwhile.
func (w *Worker[Tx]) invoke(
ctx context.Context,
h Handler,
job Job,
) (err error) {
defer func() {
if r := recover(); r != nil {
w.cfg.logger.Error(ctx, "Job panicked",
log.UUID("job", job.ID),
log.String("kind", job.Kind),
log.String("panic", fmt.Sprint(r)),
log.String("stack", string(debug.Stack())),
)
w.cfg.reg.Counter(
MetricPanics, metrics.T("kind", job.Kind),
).Inc()
err = fmt.Errorf("handler panicked: %v", r)
}
}()
return h(ctx, job)
}
// succeed settles a finished job.
func (w *Worker[Tx]) succeed(ctx context.Context, job Job) {
var ok bool
err := w.store.Exec(ctx, func(ctx context.Context, tx Tx) error {
var err error
ok, err = w.store.Succeed(ctx, tx, job.ID, w.cfg.clock().UTC())
return err
})
if err != nil {
// The lease lapses and the job runs again — the at-least-once
// case, and the reason handlers must be idempotent.
w.cfg.logger.Error(ctx, "Failed to record a finished job",
log.UUID("job", job.ID), log.Error(err))
return
}
if !ok {
w.dropped(ctx, job)
return
}
w.count(job.Kind, ResultSucceeded)
}
// hold reschedules a job the handler declined to start, leaving the
// attempt count and therefore the retry budget alone.
func (w *Worker[Tx]) hold(
ctx context.Context,
job Job,
cause error,
lim limits,
) {
delay, named := after(cause)
if !named {
delay = lim.strategy.MinDelay()
}
next := w.cfg.clock().UTC().Add(delay)
note := truncate(cause.Error())
var ok bool
err := w.store.Exec(ctx, func(ctx context.Context, tx Tx) error {
var err error
ok, err = w.store.Defer(ctx, tx, job.ID, next, note)
return err
})
if err != nil {
w.cfg.logger.Error(ctx, "Failed to defer a job",
log.UUID("job", job.ID), log.Error(err))
return
}
if !ok {
w.dropped(ctx, job)
return
}
w.count(job.Kind, ResultDeferred)
w.cfg.logger.Debug(ctx, "Job deferred",
log.UUID("job", job.ID),
log.String("kind", job.Kind),
log.Time("next", next),
log.String("note", note),
)
}
// fail records a failed attempt: a retry while budget remains, a dead
// letter beyond it or on [Abort].
func (w *Worker[Tx]) fail(
ctx context.Context,
job Job,
cause error,
lim limits,
) {
now := w.cfg.clock().UTC()
attempt := job.Attempts + 1 // this one
note := truncate(cause.Error())
dead := errors.Is(cause, ErrAbort) || attempt > lim.retries
var (
ok bool
next time.Time
)
err := w.store.Exec(ctx, func(ctx context.Context, tx Tx) error {
var err error
if dead {
ok, err = w.store.Bury(ctx, tx, job.ID, now, note)
return err
}
// The strategy counts retries from 1 and jitters its own delays;
// see backoff.New.
delay, named := after(cause)
if !named {
delay = lim.strategy.Delay(attempt)
}
next = now.Add(delay)
ok, err = w.store.Retry(ctx, tx, job.ID, next, note)
return err
})
if err != nil {
w.cfg.logger.Error(ctx, "Failed to record a failed job",
log.UUID("job", job.ID), log.Error(err))
return
}
if !ok {
w.dropped(ctx, job)
return
}
if dead {
w.count(job.Kind, ResultDead)
w.cfg.logger.Warn(ctx, "Job dead-lettered",
log.UUID("job", job.ID),
log.String("kind", job.Kind),
log.Int("attempts", attempt),
log.String("note", note),
)
return
}
w.count(job.Kind, ResultRetried)
w.cfg.logger.Info(ctx, "Job failed; retry scheduled",
log.UUID("job", job.ID),
log.String("kind", job.Kind),
log.Int("attempts", attempt),
log.Time("next", next),
log.String("note", note),
)
}
// dropped reports a verdict that no longer applies, because the job
// settled while the attempt was running — cancellation, or a second
// worker that finished first after a lapsed lease.
func (w *Worker[Tx]) dropped(ctx context.Context, job Job) {
w.cfg.logger.Info(ctx, "Job settled elsewhere; verdict dropped",
log.UUID("job", job.ID), log.String("kind", job.Kind))
}
// count records one attempt outcome.
func (w *Worker[Tx]) count(kind, result string) {
w.cfg.reg.Counter(
MetricJobs, metrics.T("kind", kind), metrics.T("result", result),
).Inc()
}
// gauge resolves one per-kind gauge.
func (w *Worker[Tx]) gauge(name, kind string) *metrics.Gauge {
return w.cfg.reg.Gauge(name, metrics.T("kind", kind))
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package schedule
import (
"time"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/metrics"
)
// DefaultRecoveryDelay is the default duration to wait before running a [Tick]
// again after it panicked. It keeps a job that fails immediately on every run
// from spinning.
const DefaultRecoveryDelay = 1 * time.Minute
// config holds the internal settings for the scheduler.
type config struct {
logger *log.Logger // destination for internal logs
recovery time.Duration // delay applied after a tick panicked
start time.Duration // delay before the first run of a tick
jitter float64 // fraction of the start delay subject to jitter
minimum time.Duration // floor for the interval a tick asks for
registry *metrics.Registry // records tick durations and panics
}
// Option is a function that configures the [Scheduler].
type Option func(*config)
// WithLogger provides a custom [log.Logger] for the scheduler. It receives
// the report when a [Tick] panics. If not provided, the scheduler stays
// silent, as if [log.Discard] had been given. A nil value is ignored.
func WithLogger(logger *log.Logger) Option {
return func(c *config) {
if logger != nil {
c.logger = logger
}
}
}
// WithRegistry sets the registry receiving tick durations and panic counts.
// It defaults to [metrics.DefaultRegistry]. A nil value is ignored.
func WithRegistry(reg *metrics.Registry) Option {
return func(c *config) {
if reg != nil {
c.registry = reg
}
}
}
// WithRecoveryDelay sets how long the scheduler waits before running a [Tick]
// again after it panicked. Without a delay, a tick that panics on every run
// would be retried in a tight loop.
//
// Values of zero or less are ignored, and [DefaultRecoveryDelay] is used
// instead.
func WithRecoveryDelay(d time.Duration) Option {
return func(c *config) {
if d > 0 {
c.recovery = d
}
}
}
// WithStartDelay postpones the first run of every dispatched [Tick] by d.
// Without it, a tick runs as soon as it is dispatched. Subsequent runs are
// unaffected, since a tick sets its own cadence.
//
// Values of zero or less are ignored, and ticks start immediately.
func WithStartDelay(d time.Duration) Option {
return func(c *config) {
if d > 0 {
c.start = d
}
}
}
// WithStartJitter scatters the start delay by a random fraction between 0 and
// 1, where 0 means no jitter and 1 means the first run may land anywhere
// between dispatch and the full delay. The given number is capped to that
// range. If not customized, no jitter is applied.
//
// This matters for a fleet of instances that restart together: without a
// stagger they align on the same schedule and hit their dependencies in
// lockstep. Since jitter only ever shortens a delay, it has no effect unless
// [WithStartDelay] is set.
func WithStartJitter(p float64) Option {
return func(c *config) {
c.jitter = min(1, max(0, p))
}
}
// WithMinInterval sets a floor for the interval a [Tick] asks for. A tick that
// returns a shorter duration, including zero, is rescheduled after this
// duration instead.
//
// Rescheduling without delay is a supported and occasionally useful pattern,
// for instance to drain a queue until it is empty. It is also the way to peg a
// core by accident: a tick that always returns zero is re-run as fast as the
// scheduler can call it. Set a floor on schedulers whose ticks are not trusted
// to converge.
//
// Values of zero or less are ignored, and ticks are rescheduled exactly as
// they ask.
func WithMinInterval(d time.Duration) Option {
return func(c *config) {
if d > 0 {
c.minimum = d
}
}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package schedule
import (
"context"
"fmt"
"runtime/debug"
"sync"
"time"
"github.com/deep-rent/nexus/std/jitter"
"github.com/deep-rent/nexus/sys/log"
"github.com/deep-rent/nexus/sys/metrics"
)
// Names of the metrics recorded by a [Scheduler], both tagged with the tick
// name given via [Named].
const (
// TickDuration is the summary of tick run durations in seconds.
TickDuration = "schedule_tick_duration_seconds"
// TickPanics counts tick runs that panicked.
TickPanics = "schedule_tick_panics_total"
)
// Tick represents a unit of work that can be scheduled to run repeatedly.
type Tick interface {
// Run executes the job and returns the duration to wait before the next
// execution. It accepts a context that is cancelled when the scheduler
// is shut down.
//
// If the returned duration is zero or negative, the next run is scheduled
// immediately.
Run(ctx context.Context) time.Duration
}
// TickFn is an adapter to allow the use of ordinary functions as [Tick]s.
type TickFn func(ctx context.Context) time.Duration
// Run implements [Tick].
func (f TickFn) Run(ctx context.Context) time.Duration { return f(ctx) }
// Task represents a unit of work to be executed in a scheduler loop.
//
// Helpers like [After] and [Every] adapt a [Task] into a [Tick].
type Task interface {
// Run executes the job. It accepts a context for cancellation and
// timeout control.
Run(ctx context.Context)
}
// TaskFn is an adapter to allow the use of ordinary functions as [Task]s.
type TaskFn func(ctx context.Context)
// Run implements [Task].
func (f TaskFn) Run(ctx context.Context) { f(ctx) }
// After creates a drifting [Tick] that runs after a fixed delay.
//
// The scheduler waits for the full delay after the task has completed, so
// the effective cadence will vary based on the task's execution time.
func After(d time.Duration, task Task) Tick {
return TickFn(func(ctx context.Context) time.Duration {
task.Run(ctx)
return d
})
}
// Every creates a drift-free [Tick] that runs at a fixed interval.
//
// The wrapper measures the [Task] execution time and subtracts it from the
// specified interval, ensuring the task starts at a consistent cadence. If a
// task's execution time exceeds the interval, the next run starts immediately.
func Every(d time.Duration, task Task) Tick {
return TickFn(func(ctx context.Context) time.Duration {
start := time.Now()
task.Run(ctx)
elapsed := time.Since(start)
return max(0, d-elapsed)
})
}
// Named attaches a name to a [Tick]. The scheduler uses it as the metric
// tag when recording the tick's runs, so unrelated jobs on one scheduler
// stay distinguishable:
//
// scheduler.Dispatch(schedule.Named(
// "cache-refresh",
// schedule.Every(time.Minute, task),
// ))
//
// Unnamed ticks are recorded as "schedule.tick". Panics if tick is nil.
func Named(name string, tick Tick) Tick {
if tick == nil {
panic("tick must not be nil")
}
return &namedTick{name: name, tick: tick}
}
// namedTick decorates a [Tick] with a name for telemetry.
type namedTick struct {
name string
tick Tick
}
// Run implements [Tick].
func (t *namedTick) Run(ctx context.Context) time.Duration {
return t.tick.Run(ctx)
}
// Name returns the name given to [Named].
func (t *namedTick) Name() string { return t.name }
// label resolves the telemetry name of a tick: the value provided via
// [Named] — or any tick carrying its own Name method — with a generic
// fallback.
func label(tick Tick) string {
if n, ok := tick.(interface{ Name() string }); ok {
return n.Name()
}
return "schedule.tick"
}
// Scheduler manages the non-blocking execution of [Tick]s at their intervals.
type Scheduler interface {
// Context returns the scheduler's context. This context is cancelled when
// [Scheduler.Shutdown] is called. Users can select on this context's Done
// channel to coordinate with the scheduler's termination.
Context() context.Context
// Dispatch executes the given tick in a separate goroutine. The tick will
// run immediately and then repeat according to the duration it returns
// until the scheduler is shut down. Multiple ticks can be dispatched
// concurrently without blocking each other. Dispatching after
// [Scheduler.Shutdown] has been called does nothing.
//
// It returns a function that stops this tick alone, leaving the rest of
// the scheduler running. The function may be called more than once, and
// unlike [Scheduler.Shutdown] it does not wait for a run already in
// progress to finish. Callers that only stop ticks by shutting the whole
// scheduler down may discard it.
Dispatch(tick Tick) context.CancelFunc
// Shutdown gracefully stops the scheduler. It cancels the scheduler's
// context and waits for all its pending tasks to complete. Shutdown blocks
// until all dispatched goroutines have finished. Once it has been called,
// no further tick is started, though a tick already in progress runs to
// completion. It is safe to call Shutdown more than once.
Shutdown()
}
// New creates a new [Scheduler] tied to the provided parent context.
//
// Cancelling this context will also cause the scheduler to shut down.
func New(ctx context.Context, opts ...Option) Scheduler {
cfg := config{
logger: log.Discard(),
recovery: DefaultRecoveryDelay,
registry: metrics.DefaultRegistry,
}
for _, opt := range opts {
opt(&cfg)
}
ctx, cancel := context.WithCancel(ctx)
return &scheduler{
ctx: ctx,
cancel: cancel,
logger: cfg.logger,
recovery: cfg.recovery,
minimum: cfg.minimum,
start: cfg.start,
jitter: jitter.New(cfg.jitter, nil),
registry: cfg.registry,
}
}
// scheduler is the concrete implementation of the [Scheduler] interface.
type scheduler struct {
ctx context.Context // internal lifecycle context
cancel context.CancelFunc // stops all dispatched goroutines
logger *log.Logger // destination for internal logs
recovery time.Duration // delay applied after a tick panicked
minimum time.Duration // floor for the interval a tick asks for
start time.Duration // delay before the first run of a tick
jitter *jitter.Jitter // scatters the start delay
registry *metrics.Registry // records tick durations and panics
wg sync.WaitGroup // tracks active task goroutines
mu sync.Mutex // guards closed against a concurrent Dispatch
closed bool // whether [Scheduler.Shutdown] has been called
}
// Context implements [Scheduler].
func (s *scheduler) Context() context.Context {
return s.ctx
}
// Dispatch implements [Scheduler].
func (s *scheduler) Dispatch(tick Tick) context.CancelFunc {
s.mu.Lock()
defer s.mu.Unlock()
// Starting new work once [Scheduler.Shutdown] has begun would both
// outlive the scheduler and add to a WaitGroup that is already being
// waited on.
if s.closed {
return func() {}
}
// Each tick gets its own context, so that it can be stopped on its own
// while the scheduler keeps running.
ctx, cancel := context.WithCancel(s.ctx)
s.wg.Go(func() {
// Releases the context from its parent once the loop is done, so that
// short-lived ticks do not pile up on a long-lived scheduler.
defer cancel()
timer := time.NewTimer(s.delay())
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case <-timer.C:
// A ready timer and a canceled context are chosen between at
// random, so the context is checked explicitly before
// committing to another run.
if ctx.Err() != nil {
return
}
timer.Reset(max(s.minimum, s.run(ctx, tick)))
}
}
})
return cancel
}
// delay returns how long to wait before the first run of a tick, scattered by
// the configured jitter so that instances starting together do not align.
func (s *scheduler) delay() time.Duration {
return max(0, s.jitter.Apply(s.start))
}
// run executes a single iteration of tick, converting a panic into a log
// record. A scheduler shared by unrelated jobs must not let one of them take
// down the process, so a panicking tick is reported and rescheduled after the
// recovery delay rather than being propagated.
//
// Each run lands in the [TickDuration] summary, and a panic additionally
// increments [TickPanics]; both carry the tick's name as a tag.
func (s *scheduler) run(
ctx context.Context,
tick Tick,
) (d time.Duration) {
name := label(tick)
start := time.Now()
defer func() {
if r := recover(); r != nil {
s.logger.Error(ctx,
"Tick panicked",
log.String("tick", name),
log.String("panic", fmt.Sprint(r)),
log.String("stack", string(debug.Stack())),
)
d = s.recovery
s.registry.Counter(TickPanics, metrics.T("tick", name)).Inc()
}
s.registry.Summary(TickDuration, nil, 0, metrics.T("tick", name)).
Observe(time.Since(start).Seconds())
}()
return tick.Run(ctx)
}
// Shutdown implements [Scheduler].
func (s *scheduler) Shutdown() {
s.mu.Lock()
s.closed = true
s.cancel()
s.mu.Unlock()
// Waited on without the lock, so that a concurrent Dispatch returns
// promptly instead of blocking until every tick has drained.
s.wg.Wait()
}
var _ Scheduler = (*scheduler)(nil)
// Once creates a synchronous [Scheduler] that runs each [Tick] exactly once.
//
// Its [Scheduler.Dispatch] method is blocking and runs the [Tick] in the
// calling goroutine. This implementation is useful for testing or executing a
// task without true background scheduling.
//
// Unlike the scheduler returned by [New], it does not recover panics: the tick
// runs on the caller's stack, where the caller is better placed to handle
// them, and a swallowed panic would hide failures in tests.
func Once(ctx context.Context) Scheduler {
return &once{ctx: ctx}
}
// once is a [Scheduler] implementation for synchronous, single execution.
type once struct {
// ctx is the context passed to executed ticks.
ctx context.Context
}
// Context implements [Scheduler].
func (o *once) Context() context.Context { return o.ctx }
// Dispatch implements [Scheduler]. The returned function does nothing, since
// the tick has already run by the time Dispatch returns.
func (o *once) Dispatch(tick Tick) context.CancelFunc {
tick.Run(o.ctx)
return func() {}
}
// Shutdown implements [Scheduler].
func (*once) Shutdown() {}
var _ Scheduler = (*once)(nil)
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package pgtest
import (
"context"
"database/sql"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5/stdlib"
"github.com/testcontainers/testcontainers-go"
testpg "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
"github.com/deep-rent/nexus/dat/migrate"
"github.com/deep-rent/nexus/dat/pg"
)
// The disposable instance every test gets. They are constants rather
// than options because a test has no reason to care: the database is
// thrown away, and pinning one image keeps a version bump to one edit.
const (
// Image is the PostgreSQL the tests run against.
Image = "postgres:18-alpine"
// Database, User, and Password are the throwaway credentials.
Database = "testdb"
User = "user"
Password = "pass"
// Startup bounds how long the container may take to accept
// connections before the test fails rather than hangs.
Startup = 30 * time.Second
)
// DB is a disposable PostgreSQL and the handles onto it.
type DB struct {
// URL is the connection string, for the code paths that take one —
// a migrator opened out of band, a service configuration.
URL string
// Pool is the connection pool, ready to use.
Pool *pgxpool.Pool
}
// SQL returns the database/sql view over the pool, which is what a
// [migrate.Migrator] takes. The handle is closed with the pool.
func (d *DB) SQL() *sql.DB { return stdlib.OpenDBFromPool(d.Pool) }
// Migrate applies the given schema streams, failing the test on the
// first that will not apply. Every module's Migrator function in this
// repository has the shape this asks for, so migrating is naming them:
//
// db.Migrate(t, store.Migrator)
func (d *DB) Migrate(
t *testing.T,
streams ...func(*sql.DB, ...migrate.Option) *migrate.Migrator,
) {
t.Helper()
handle := d.SQL()
for _, stream := range streams {
if err := stream(handle).Up(t.Context()); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
}
}
// Start boots a disposable PostgreSQL, waits for it to accept
// connections, and registers its teardown with the test.
//
// It skips when -short is set: an integration test needs a working
// Docker daemon, which a quick unit pass does not assume.
func Start(t *testing.T) *DB {
t.Helper()
if testing.Short() {
t.Skip("skipping integration test: database setup required")
}
// The container outlives the test body: Terminate runs from
// cleanup, after t.Context() would already be canceled.
ctx := context.Background()
container, err := testpg.Run(ctx,
Image,
testpg.WithDatabase(Database),
testpg.WithUsername(User),
testpg.WithPassword(Password),
testcontainers.WithWaitStrategy(
// Twice, because the entrypoint starts the server once to
// initialize the cluster and again to serve it; the first
// announcement is not one a client may connect on.
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(Startup),
),
)
if err != nil {
t.Fatalf("failed to start postgres container: %v", err)
}
t.Cleanup(func() {
if err := container.Terminate(ctx); err != nil {
t.Errorf("failed to terminate container: %v", err)
}
})
url, err := container.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatalf("failed to get connection string: %v", err)
}
pool, err := pg.Connect(ctx, url)
if err != nil {
t.Fatalf("failed to open database pool: %v", err)
}
t.Cleanup(pool.Close)
return &DB{URL: url, Pool: pool}
}
// Copyright (c) 2026 deep.rent GmbH. All rights reserved.
//
// PROPRIETARY AND CONFIDENTIAL
//
// This source code, and any derivative works or binaries compiled therefrom,
// contain trade secrets and confidential information. Unauthorized copying,
// distribution, modification, public display, or disclosure via any medium
// is strictly prohibited.
//
// For internal company use only.
package ports
import (
"context"
"net"
"strconv"
"testing"
"time"
)
const (
// timeout caps how long [Wait] polls before failing the test. It guards
// against tests hanging until the test binary times out when a server
// never comes up and the test context carries no earlier deadline.
timeout = 10 * time.Second
// interval is the delay between successive dial attempts in [Wait].
interval = 100 * time.Millisecond
)
// Free asks the kernel for a TCP port that is free on the given host and
// returns its number. An empty host allocates a port that is free on all
// interfaces. If no port can be allocated, the test fails immediately.
//
// The temporary listener backing the allocation is closed before Free
// returns; see the package documentation for the implications.
func Free(t testing.TB, host string) int {
t.Helper()
l, err := net.Listen("tcp", net.JoinHostPort(host, "0"))
if err != nil {
t.Fatalf("failed to allocate free port on %q: %v", host, err)
}
addr, ok := l.Addr().(*net.TCPAddr)
if !ok {
t.Fatalf("failed to cast address of type %T", l.Addr())
}
if err := l.Close(); err != nil {
t.Logf("failed to release port %d on %q: %v", addr.Port, host, err)
}
return addr.Port
}
// Address asks the kernel for a free TCP port on the given host and
// returns the two joined as a listen address — what a service
// configuration takes.
//
// It is [Free] for the common case: every caller that configures a
// server rather than dialing one wants "host:port" rather than the
// number, and six test suites had each written the join by hand.
//
// cfg.Addr = ports.Address(t, "127.0.0.1")
//
// The caveats of [Free] apply: the port is free when it is returned and
// nothing reserves it until the server binds.
func Address(t testing.TB, host string) string {
t.Helper()
return net.JoinHostPort(host, strconv.Itoa(Free(t, host)))
}
// Wait blocks until a TCP server accepts connections on the given host and
// port.
//
// It dials the address every 100ms until a connection succeeds. If the test
// context is canceled or its deadline is exceeded first, or no dial succeeds
// within 10 seconds, the test fails immediately.
func Wait(t testing.TB, host string, port int) {
t.Helper()
addr := net.JoinHostPort(host, strconv.Itoa(port))
// Bound the polling even if the test context has no earlier deadline.
ctx, cancel := context.WithTimeout(t.Context(), timeout)
defer cancel()
ticker := time.NewTicker(interval)
defer ticker.Stop()
var d net.Dialer
for {
// Respect the context for the dial operation itself.
conn, err := d.DialContext(ctx, "tcp", addr)
if err == nil {
if err := conn.Close(); err != nil {
t.Logf("failed to close connection to %s: %v", addr, err)
}
return
}
select {
case <-ctx.Done():
// The context was canceled or its deadline exceeded.
t.Fatalf("failed waiting for port %d on %q: %v", port, host, err)
case <-ticker.C:
// Wait for the next tick before trying again.
}
}
}