From d74d00c1b129e101dc72784a27680cbea6ad55b4 Mon Sep 17 00:00:00 2001
From: Tayeb Mokni
Date: Tue, 26 May 2026 20:04:34 +0200
Subject: [PATCH 1/5] feat(plugintest): hard caps + zip-slip guard on bundle
parser (#27)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The .gnplugin loader at cli/gonext/internal/plugintest accepted
arbitrarily-large archives and never validated entry names. A
hostile bundle could:
* exhaust process memory with a multi-gigabyte file or a zip-bomb
entry (small compressed, huge declared size);
* exhaust the file-descriptor pool with a million zero-byte
entries;
* extract files outside the bundle root via "../" or "/etc/..."
paths (zip-slip, https://snyk.io/research/zip-slip).
This change adds three independent caps — 50 MiB total, 10 MiB
per entry, 10,000 entries — plus a path-safety check that rejects
any entry whose raw name starts with "/" or contains a ".."
segment in either / or \ form.
Each cap has a dedicated sentinel error (ErrBundleTooLarge,
ErrBundleEntryTooLarge, ErrBundleTooManyEntries,
ErrBundleUnsafePath) so the admin UI can branch on the failure
shape and produce a friendlier message.
Read paths (ReadManifest, ReadWASM) now also pass through a
LimitReader-backed cap so directory-form bundles (which have no
central directory to pre-validate) are equally protected.
Closes #27.
Co-Authored-By: Claude Opus 4.7
Signed-off-by: Tayeb Mokni
---
cli/gonext/internal/plugintest/bundle.go | 156 +++++++++-
.../internal/plugintest/bundle_guards_test.go | 288 ++++++++++++++++++
2 files changed, 442 insertions(+), 2 deletions(-)
create mode 100644 cli/gonext/internal/plugintest/bundle_guards_test.go
diff --git a/cli/gonext/internal/plugintest/bundle.go b/cli/gonext/internal/plugintest/bundle.go
index 4fe430bd..2271bf10 100644
--- a/cli/gonext/internal/plugintest/bundle.go
+++ b/cli/gonext/internal/plugintest/bundle.go
@@ -22,6 +22,60 @@ const manifestFilename = "manifest.json"
// `server/plugin.wasm`.
const defaultWASMPath = "server/plugin.wasm"
+// Bundle hard caps. These guard the .gnplugin loader against pathological
+// archives — zip bombs, traversal attacks, accidental dumps of a whole
+// `node_modules`. See issue #27 (bundle hardening). The numbers mirror
+// docs/02-plugin-system.md §2.1: 50 MiB total, 10 MiB per entry, 10k
+// entries. Each is a hard ceiling — exceeding any of the three rejects
+// the bundle before any further work happens (Read, Stat, manifest
+// parse).
+const (
+ // MaxBundleSize caps the total compressed-on-disk size of a
+ // `.gnplugin` archive at 50 MiB. The cap is checked against
+ // os.Stat(Path).Size() before the zip reader ever touches the
+ // file; a hostile archive cannot trick the loader into reading
+ // more than this off disk.
+ MaxBundleSize int64 = 50 * 1024 * 1024
+
+ // MaxEntrySize caps the uncompressed size of any single zip entry
+ // at 10 MiB. The cap defends against zip-bomb decompression —
+ // a 1 KB compressed entry that expands to 4 GiB is a textbook
+ // DoS vector. Callers reading entries via Bundle.FS() are
+ // expected to also LimitReader to MaxEntrySize; the entry-size
+ // guard here ensures the declared uncompressed size never
+ // exceeds the cap even if the caller forgets.
+ MaxEntrySize int64 = 10 * 1024 * 1024
+
+ // MaxEntryCount caps the number of files inside a bundle at
+ // 10,000. The cap is the third leg of the zip-bomb defence:
+ // a 0-byte archive with a million entries would otherwise
+ // exhaust the file-descriptor pool inside archive/zip.
+ MaxEntryCount = 10_000
+)
+
+// ErrBundleTooLarge is returned when the archive on disk exceeds
+// [MaxBundleSize]. Sentinel rather than an inline fmt.Errorf so callers
+// can branch on the failure mode (e.g. to surface a friendlier "your
+// plugin is too big" message in the admin UI vs. a generic parser
+// failure).
+var ErrBundleTooLarge = errors.New("plugintest: bundle exceeds size cap")
+
+// ErrBundleEntryTooLarge is returned when any single entry's declared
+// uncompressed size exceeds [MaxEntrySize].
+var ErrBundleEntryTooLarge = errors.New("plugintest: bundle entry exceeds size cap")
+
+// ErrBundleTooManyEntries is returned when the archive carries more
+// than [MaxEntryCount] entries.
+var ErrBundleTooManyEntries = errors.New("plugintest: bundle exceeds entry count cap")
+
+// ErrBundleUnsafePath is returned when any entry's name contains a
+// path-traversal segment (".." anywhere in the cleaned path) or is
+// absolute (leading "/"). The zip format permits these names but a
+// well-behaved bundle never uses them; rejecting at parse time
+// prevents a zip-slip extract step downstream from clobbering files
+// outside the bundle root.
+var ErrBundleUnsafePath = errors.New("plugintest: bundle entry has unsafe path")
+
// Bundle is a read-only view over a plugin bundle backed by either a
// directory on disk or an opened zip archive.
//
@@ -69,13 +123,86 @@ func OpenBundle(p string) (*Bundle, error) {
if ext != ".gnplugin" && ext != ".zip" {
return nil, fmt.Errorf("open bundle %q: unsupported extension %q (want directory, .gnplugin, or .zip)", p, ext)
}
+ // Reject anything larger than MaxBundleSize before we even open
+ // the zip. archive/zip itself doesn't enforce a cap; without this
+ // a multi-gigabyte file would consume FDs and CPU on the central
+ // directory scan before we got to validate anything else.
+ if st.Size() > MaxBundleSize {
+ return nil, fmt.Errorf("open bundle %q: %w (size=%d, cap=%d)",
+ p, ErrBundleTooLarge, st.Size(), MaxBundleSize)
+ }
zr, err := zip.OpenReader(p)
if err != nil {
return nil, fmt.Errorf("open zip %q: %w", p, err)
}
+ // Walk the central directory once to enforce the entry-count,
+ // per-entry-size and unsafe-path caps. Doing this upfront means
+ // every later call (ReadManifest, ReadWASM, CheckLayout) is
+ // already operating on a known-bounded archive — there's no
+ // "did we forget to check this path?" hole. On any violation we
+ // close the reader (it's owned by us at this point) and return.
+ if err := validateZipEntries(zr); err != nil {
+ _ = zr.Close()
+ return nil, fmt.Errorf("open bundle %q: %w", p, err)
+ }
return &Bundle{Path: p, fsys: &zr.Reader, closer: zr}, nil
}
+// validateZipEntries enforces the three structural caps on a freshly
+// opened zip reader: entry count, per-entry size, and unsafe paths.
+// All three are independent — a bundle with 11k tiny files fails
+// ErrBundleTooManyEntries; a bundle with one 11 MiB file fails
+// ErrBundleEntryTooLarge; a bundle with `../etc/passwd` fails
+// ErrBundleUnsafePath. We return on the first violation so error
+// messages stay specific.
+//
+// Extracted as a free function so the unit tests can drive it against
+// a *zip.ReadCloser without going through OpenBundle.
+func validateZipEntries(zr *zip.ReadCloser) error {
+ if zr == nil {
+ return errors.New("plugintest: nil zip reader")
+ }
+ if len(zr.File) > MaxEntryCount {
+ return fmt.Errorf("%w (entries=%d, cap=%d)",
+ ErrBundleTooManyEntries, len(zr.File), MaxEntryCount)
+ }
+ for _, f := range zr.File {
+ // Reject absolute paths and path-traversal segments. We
+ // inspect the *raw* name (not path.Clean'd) because cleaning
+ // an absolute "/x" to "x" would silently allow it; we want
+ // to reject any name a hostile author chose, not the
+ // post-cleaning view.
+ raw := f.Name
+ if strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, `\`) {
+ return fmt.Errorf("%w: %q (absolute path)",
+ ErrBundleUnsafePath, raw)
+ }
+ // Reject ".." anywhere in the path components — including
+ // "foo/../bar" (which path.Clean would collapse to "bar")
+ // because the COMPRESSED form on disk still encodes the
+ // traversal segment and a downstream extractor that doesn't
+ // call path.Clean would honor it. This is the zip-slip
+ // vector documented at https://snyk.io/research/zip-slip.
+ cleaned := path.Clean(raw)
+ if cleaned == ".." || strings.HasPrefix(cleaned, "../") ||
+ strings.Contains(raw, "..\\") || strings.Contains(raw, "../") {
+ return fmt.Errorf("%w: %q (traversal)",
+ ErrBundleUnsafePath, raw)
+ }
+ // Per-entry size check uses the declared uncompressed size.
+ // A hostile entry that *lies* about its size will be caught
+ // by the reader's CRC check at decompression time, but
+ // before that the LimitReader-equivalent cap on ReadAll
+ // keeps us from buffering more than MaxEntrySize bytes (see
+ // readZipEntryCapped below).
+ if int64(f.UncompressedSize64) > MaxEntrySize {
+ return fmt.Errorf("%w: %q (size=%d, cap=%d)",
+ ErrBundleEntryTooLarge, raw, f.UncompressedSize64, MaxEntrySize)
+ }
+ }
+ return nil
+}
+
// Close releases the underlying archive handle, if any. Directory-backed
// bundles have nothing to release and Close is a no-op.
func (b *Bundle) Close() error {
@@ -90,13 +217,18 @@ func (b *Bundle) FS() fs.FS { return b.fsys }
// ReadManifest reads the manifest bytes from the bundle. The returned slice
// is the raw JSON — callers parse and validate it via [ValidateManifest].
+//
+// Capped at [MaxEntrySize] via an io.LimitReader so a hostile manifest
+// blob can't exhaust process memory even when the central directory
+// validation has been bypassed (e.g. directory-backed bundles, where
+// no central directory exists).
func (b *Bundle) ReadManifest() ([]byte, error) {
f, err := b.fsys.Open(manifestFilename)
if err != nil {
return nil, fmt.Errorf("read %s: %w", manifestFilename, err)
}
defer f.Close()
- return io.ReadAll(f)
+ return readCapped(f, manifestFilename)
}
// ReadWASM reads the WASM module bytes at the given bundle-relative path. If
@@ -111,7 +243,27 @@ func (b *Bundle) ReadWASM(p string) ([]byte, error) {
return nil, fmt.Errorf("read wasm %q: %w", p, err)
}
defer f.Close()
- return io.ReadAll(f)
+ return readCapped(f, p)
+}
+
+// readCapped reads up to [MaxEntrySize] bytes from r and returns
+// [ErrBundleEntryTooLarge] if the file is larger. We use the
+// classic "+1 trick": LimitReader to cap+1, then assert len <= cap.
+// A truncated read (n < cap) confirms we hit EOF cleanly; a read
+// that returns cap+1 bytes tells us the source exceeded the cap.
+//
+// label is only used for the error message — the file path inside
+// the bundle.
+func readCapped(r io.Reader, label string) ([]byte, error) {
+ buf, err := io.ReadAll(io.LimitReader(r, MaxEntrySize+1))
+ if err != nil {
+ return nil, err
+ }
+ if int64(len(buf)) > MaxEntrySize {
+ return nil, fmt.Errorf("%w: %q (cap=%d)",
+ ErrBundleEntryTooLarge, label, MaxEntrySize)
+ }
+ return buf, nil
}
// CheckLayout verifies the bundle has the structural entries the loader will
diff --git a/cli/gonext/internal/plugintest/bundle_guards_test.go b/cli/gonext/internal/plugintest/bundle_guards_test.go
new file mode 100644
index 00000000..0ef6e7f5
--- /dev/null
+++ b/cli/gonext/internal/plugintest/bundle_guards_test.go
@@ -0,0 +1,288 @@
+// Tests for the structural guards added in issue #27 — bundle size cap,
+// per-entry size cap, entry-count cap, and zip-slip path rejection.
+//
+// Each test constructs a hostile fixture (oversize archive, zip bomb,
+// traversal entry, etc.) and asserts OpenBundle refuses it with the
+// matching sentinel error. The fixtures are inline rather than
+// committed binaries so reviewers can see exactly what's being
+// attempted without diffing through a hex dump.
+package plugintest
+
+import (
+ "archive/zip"
+ "bytes"
+ "errors"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// writeRawZip is the low-level helper for the guard tests — it lets us
+// produce archives that would not normally survive writeBundleZip's
+// well-formed assumptions (e.g. no manifest, traversal names,
+// oversize declared entries).
+func writeRawZip(t *testing.T, files map[string][]byte) string {
+ t.Helper()
+ dir := t.TempDir()
+ path := filepath.Join(dir, "evil.gnplugin")
+ f, err := os.Create(path)
+ if err != nil {
+ t.Fatalf("create zip: %v", err)
+ }
+ zw := zip.NewWriter(f)
+ for name, body := range files {
+ w, err := zw.Create(name)
+ if err != nil {
+ t.Fatalf("create %q: %v", name, err)
+ }
+ if _, err := w.Write(body); err != nil {
+ t.Fatalf("write %q: %v", name, err)
+ }
+ }
+ if err := zw.Close(); err != nil {
+ t.Fatalf("close writer: %v", err)
+ }
+ if err := f.Close(); err != nil {
+ t.Fatalf("close file: %v", err)
+ }
+ return path
+}
+
+// TestOpenBundle_RejectsOversizeFile asserts the bundle-level cap kicks
+// in BEFORE any zip parsing. The fixture is a 50-MiB+1 file of zeros
+// with a .gnplugin extension — it's not a valid zip at all, but the
+// size guard should reject it before zip.OpenReader is called.
+func TestOpenBundle_RejectsOversizeFile(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "big.gnplugin")
+ // Use SeekFile semantics — write a single byte at offset
+ // MaxBundleSize+1 so the file claims that size without us
+ // burning 50 MiB of test memory.
+ f, err := os.Create(path)
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ if _, err := f.Seek(MaxBundleSize, io.SeekStart); err != nil {
+ t.Fatalf("seek: %v", err)
+ }
+ if _, err := f.Write([]byte{0}); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+ if err := f.Close(); err != nil {
+ t.Fatalf("close: %v", err)
+ }
+
+ _, err = OpenBundle(path)
+ if err == nil {
+ t.Fatal("expected ErrBundleTooLarge; got nil")
+ }
+ if !errors.Is(err, ErrBundleTooLarge) {
+ t.Fatalf("expected ErrBundleTooLarge; got %v", err)
+ }
+}
+
+// TestOpenBundle_RejectsTooManyEntries verifies the entry-count cap. We
+// build a zip with MaxEntryCount+1 tiny files; each is well within the
+// per-entry cap but the cardinality alone should reject the bundle.
+func TestOpenBundle_RejectsTooManyEntries(t *testing.T) {
+ files := make(map[string][]byte, MaxEntryCount+1)
+ // First entry is a valid manifest so the test fixture would pass
+ // every OTHER check — only the entry count is wrong.
+ files["manifest.json"] = []byte(`{"slug":"x","version":"0.0.1","abi_version":1}`)
+ for i := 0; i < MaxEntryCount; i++ {
+ files[name("f", i)] = []byte("x")
+ }
+ path := writeRawZip(t, files)
+ _, err := OpenBundle(path)
+ if err == nil {
+ t.Fatal("expected ErrBundleTooManyEntries; got nil")
+ }
+ if !errors.Is(err, ErrBundleTooManyEntries) {
+ t.Fatalf("expected ErrBundleTooManyEntries; got %v", err)
+ }
+}
+
+// TestOpenBundle_RejectsOversizeEntry verifies the per-entry cap. The
+// fixture declares an entry whose uncompressed size exceeds
+// MaxEntrySize — this is the classic zip-bomb shape (small compressed
+// payload, huge declared size).
+func TestOpenBundle_RejectsOversizeEntry(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "bomb.gnplugin")
+ f, err := os.Create(path)
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ zw := zip.NewWriter(f)
+ // Write a single highly-compressible entry that exceeds the cap.
+ // We don't use raw mode (which would lie about the size); we just
+ // write MaxEntrySize+1 zeros and let deflate compress them down
+ // to a few bytes. The resulting central directory faithfully
+ // reports the uncompressed size, which is what our guard checks.
+ w, err := zw.Create("bomb.bin")
+ if err != nil {
+ t.Fatalf("create entry: %v", err)
+ }
+ chunk := bytes.Repeat([]byte{0}, 1<<16)
+ written := int64(0)
+ target := MaxEntrySize + 1
+ for written < target {
+ n := int64(len(chunk))
+ if written+n > target {
+ n = target - written
+ }
+ if _, err := w.Write(chunk[:n]); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+ written += n
+ }
+ if err := zw.Close(); err != nil {
+ t.Fatalf("close writer: %v", err)
+ }
+ if err := f.Close(); err != nil {
+ t.Fatalf("close: %v", err)
+ }
+
+ _, err = OpenBundle(path)
+ if err == nil {
+ t.Fatal("expected ErrBundleEntryTooLarge; got nil")
+ }
+ if !errors.Is(err, ErrBundleEntryTooLarge) {
+ t.Fatalf("expected ErrBundleEntryTooLarge; got %v", err)
+ }
+}
+
+// TestOpenBundle_RejectsTraversalPath verifies the zip-slip guard. Each
+// sub-test exercises a different shape of traversal — leading "..",
+// embedded "..", absolute path, Windows-style backslash absolute, and
+// Windows-style backslash traversal. All should reject with
+// ErrBundleUnsafePath.
+func TestOpenBundle_RejectsTraversalPath(t *testing.T) {
+ cases := []struct {
+ name string
+ entry string
+ }{
+ {"leading-dotdot", "../etc/passwd"},
+ {"embedded-dotdot", "ok/../../etc/passwd"},
+ {"absolute-unix", "/etc/passwd"},
+ {"absolute-windows", `\Windows\System32\config\SAM`},
+ {"backslash-traversal", `..\etc\passwd`},
+ }
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ files := map[string][]byte{
+ "manifest.json": []byte(`{"slug":"x","version":"0.0.1","abi_version":1}`),
+ tc.entry: []byte("payload"),
+ }
+ path := writeRawZip(t, files)
+ _, err := OpenBundle(path)
+ if err == nil {
+ t.Fatalf("expected ErrBundleUnsafePath for %q; got nil", tc.entry)
+ }
+ if !errors.Is(err, ErrBundleUnsafePath) {
+ t.Fatalf("expected ErrBundleUnsafePath for %q; got %v", tc.entry, err)
+ }
+ })
+ }
+}
+
+// TestOpenBundle_HappyPath confirms the new guards don't reject a
+// well-formed bundle. This is the regression check: the guards should
+// be invisible to honest plugins.
+func TestOpenBundle_HappyPath(t *testing.T) {
+ path := writeBundleZip(t,
+ []byte(`{"slug":"ok","version":"0.0.1","abi_version":1}`),
+ []byte("\x00asm\x01\x00\x00\x00"),
+ )
+ b, err := OpenBundle(path)
+ if err != nil {
+ t.Fatalf("expected happy-path bundle to open; got %v", err)
+ }
+ t.Cleanup(func() { _ = b.Close() })
+ if got, err := b.ReadManifest(); err != nil {
+ t.Fatalf("ReadManifest: %v", err)
+ } else if !strings.Contains(string(got), `"slug":"ok"`) {
+ t.Fatalf("manifest contents lost: %q", got)
+ }
+}
+
+// TestReadCapped_RejectsOversize verifies the LimitReader-backed
+// ReadManifest/ReadWASM guard rejects a directory-bundle file whose
+// contents exceed MaxEntrySize. The zip-central-directory check
+// can't see directory bundles, so this is the only line of defence
+// for the directory-form bundle path.
+func TestReadCapped_RejectsOversize(t *testing.T) {
+ dir := t.TempDir()
+ // MaxEntrySize is 10 MiB; write 10 MiB + 1 bytes so the cap
+ // trips. We seek-and-write to avoid burning 10 MiB of test
+ // memory on a sentinel buffer.
+ mf := filepath.Join(dir, "manifest.json")
+ f, err := os.Create(mf)
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ if _, err := f.Seek(MaxEntrySize, io.SeekStart); err != nil {
+ t.Fatalf("seek: %v", err)
+ }
+ if _, err := f.Write([]byte{0}); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+ if err := f.Close(); err != nil {
+ t.Fatalf("close: %v", err)
+ }
+
+ b, err := OpenBundle(dir)
+ if err != nil {
+ t.Fatalf("OpenBundle: %v", err)
+ }
+ t.Cleanup(func() { _ = b.Close() })
+ _, err = b.ReadManifest()
+ if err == nil {
+ t.Fatal("expected ErrBundleEntryTooLarge; got nil")
+ }
+ if !errors.Is(err, ErrBundleEntryTooLarge) {
+ t.Fatalf("expected ErrBundleEntryTooLarge; got %v", err)
+ }
+}
+
+// name is a 1-line helper that produces a unique zip entry name —
+// keeping the inline maps in TestOpenBundle_RejectsTooManyEntries
+// readable.
+func name(prefix string, n int) string {
+ // We use a 6-digit pad so the entry names are well-distributed
+ // in the central directory (zip puts them in insertion order;
+ // stable names just make a failing diff easier to read).
+ const pad = "000000"
+ s := stringInt(n)
+ if len(s) < len(pad) {
+ s = pad[len(s):] + s
+ }
+ return prefix + "_" + s
+}
+
+// stringInt is a tiny strconv.Itoa stand-in — we avoid pulling in
+// strconv at the top of this test file just for one call site.
+func stringInt(n int) string {
+ if n == 0 {
+ return "0"
+ }
+ neg := n < 0
+ if neg {
+ n = -n
+ }
+ var b [20]byte
+ i := len(b)
+ for n > 0 {
+ i--
+ b[i] = byte('0' + n%10)
+ n /= 10
+ }
+ if neg {
+ i--
+ b[i] = '-'
+ }
+ return string(b[i:])
+}
From e67bae0429de753791458dbc72c5164d2a73334b Mon Sep 17 00:00:00 2001
From: Tayeb Mokni
Date: Tue, 26 May 2026 20:06:25 +0200
Subject: [PATCH 2/5] feat(plugins/lifecycle): SQL migration linter for plugin
prefix (#53)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Plugin-supplied migrations run inside the same database as core
GoNext, so a careless or malicious plugin could drop core tables,
shadow core relations, or rewrite core indexes. Per
docs/02-plugin-system.md §3.3, every DDL statement in a plugin's
migrations must target an object whose name begins with
`plugin__`.
This change adds a regex-based static linter
(packages/go/plugins/lifecycle/sqllint.go) that enforces the
prefix on CREATE/ALTER/DROP TABLE, CREATE/DROP INDEX, and rejects
the forbidden-verb kill-list (CREATE FUNCTION/VIEW/EXTENSION/
TRIGGER, GRANT, TRUNCATE, ALTER SCHEMA, ...). Schema qualifiers
and quoted identifiers are stripped before the check so
`CREATE TABLE public."plugin_seo_x"` passes while `CREATE TABLE
users` is rejected.
Wire-up into the Migrator runner lands in a follow-up — this PR
ships the linter as a standalone function so the bundle-parser
issue (#27) and the lifecycle storage (#44) can plug it in
without coupling.
Closes #53.
Co-Authored-By: Claude Opus 4.7
Signed-off-by: Tayeb Mokni
---
packages/go/plugins/lifecycle/sqllint.go | 187 ++++++++++++++++
packages/go/plugins/lifecycle/sqllint_test.go | 201 ++++++++++++++++++
2 files changed, 388 insertions(+)
create mode 100644 packages/go/plugins/lifecycle/sqllint.go
create mode 100644 packages/go/plugins/lifecycle/sqllint_test.go
diff --git a/packages/go/plugins/lifecycle/sqllint.go b/packages/go/plugins/lifecycle/sqllint.go
new file mode 100644
index 00000000..9a526876
--- /dev/null
+++ b/packages/go/plugins/lifecycle/sqllint.go
@@ -0,0 +1,187 @@
+package lifecycle
+
+import (
+ "errors"
+ "fmt"
+ "regexp"
+ "strings"
+)
+
+// SQL migration linter (issue #53). Plugins ship up- and down-migrations
+// that the lifecycle Migrator applies inside the shared GoNext database.
+// Without a structural check, a malicious or careless plugin could:
+//
+// - drop a core GoNext table (`DROP TABLE users`),
+// - shadow a core relation by creating `posts` instead of
+// `plugin__posts`,
+// - rewrite a core index with `ALTER TABLE users ADD COLUMN ...`.
+//
+// Any of those breaks the host. Per the design (docs/02-plugin-system.md
+// §3.3) every DDL statement in a plugin's migrations must target an
+// object prefixed with `plugin__`. The linter enforces that
+// invariant before the runner ever sees the SQL.
+//
+// We use a regex-based pass rather than `pg_query_go` because the
+// project's go.mod already has many heavy deps and the parser is large
+// for the surface we actually need (CREATE/ALTER TABLE, CREATE INDEX).
+// The regexes cover the common cases — CREATE [UNIQUE] INDEX, CREATE
+// TABLE [IF NOT EXISTS], ALTER TABLE — and the test fixtures exercise
+// the trick shapes we know about (quoted identifiers, schema
+// qualifiers, comments). Adding a real parser is a follow-up once the
+// linter graduates from "block obvious mistakes" to "block any
+// statement that mutates a non-plugin object".
+
+// ErrLintViolation is the wrapped error returned by LintMigration when
+// a statement targets a non-plugin-prefixed object. Sentinel so callers
+// can `errors.Is` on the failure shape.
+var ErrLintViolation = errors.New("sqllint: migration references non-plugin object")
+
+// Regex grammar. Each pattern uses these conventions:
+//
+// - case-insensitive (`(?i)`) — SQL is case-insensitive in DDL.
+// - tolerates leading whitespace and optional schema qualifiers
+// (`public.`) so a plugin can target the default schema.
+// - captures the target identifier in group 1, post-stripping quotes
+// and schema prefix in extractIdentifier.
+//
+// We intentionally do NOT match every DDL verb the grammar admits
+// (CREATE TYPE, CREATE FUNCTION, CREATE TRIGGER, etc.). The cap is
+// scoped to the three forms plugin authors realistically reach for;
+// anything outside that surface is rejected by a separate "unknown
+// DDL" check below.
+var (
+ reCreateTable = regexp.MustCompile(`(?i)\bCREATE\s+(?:UNLOGGED\s+|TEMP(?:ORARY)?\s+)?TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([A-Za-z0-9_."]+)`)
+ reAlterTable = regexp.MustCompile(`(?i)\bALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?([A-Za-z0-9_."]+)`)
+ reCreateIndex = regexp.MustCompile(`(?i)\bCREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?([A-Za-z0-9_."]+)\s+ON\s+([A-Za-z0-9_."]+)`)
+ reDropTable = regexp.MustCompile(`(?i)\bDROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?([A-Za-z0-9_."]+)`)
+ reDropIndex = regexp.MustCompile(`(?i)\bDROP\s+INDEX\s+(?:IF\s+EXISTS\s+)?([A-Za-z0-9_."]+)`)
+ // reAnyDDL is the catch-all: if a statement contains any verb we
+ // don't explicitly handle (CREATE TYPE, ALTER SCHEMA, ...), we
+ // reject rather than silently letting it through. The list is
+ // the conservative one — plugin authors who legitimately need
+ // these can ask for the surface to be widened.
+ reForbidden = regexp.MustCompile(`(?i)\bCREATE\s+(?:OR\s+REPLACE\s+)?(?:FUNCTION|PROCEDURE|TRIGGER|TYPE|SCHEMA|EXTENSION|VIEW|MATERIALIZED\s+VIEW|RULE|POLICY|SEQUENCE)\b|\bALTER\s+(?:SCHEMA|SYSTEM|DATABASE|ROLE|USER)\b|\bDROP\s+(?:SCHEMA|DATABASE|ROLE|USER|EXTENSION|FUNCTION|PROCEDURE|TRIGGER|TYPE|VIEW|SEQUENCE|POLICY|RULE)\b|\bGRANT\b|\bREVOKE\b|\bTRUNCATE\b`)
+ // reLineComment / reBlockComment let us strip comments before
+ // parsing so a `-- DROP TABLE users` line doesn't trip the
+ // linter. PostgreSQL supports both single-line and nested block
+ // comments; we collapse them flat (Go's regexp is non-greedy).
+ reLineComment = regexp.MustCompile(`--[^\n]*`)
+ reBlockComment = regexp.MustCompile(`/\*[\s\S]*?\*/`)
+)
+
+// LintMigration scans a single migration SQL blob and returns a non-nil
+// error if any DDL statement references an object that doesn't begin
+// with the plugin's prefix. The prefix is `plugin__` per
+// docs/02-plugin-system.md §3.3.
+//
+// slug must already have passed slugRegex (manager.go). If it's empty
+// the linter returns an error — an empty prefix would match every
+// identifier including core tables.
+//
+// The linter is intentionally conservative: when in doubt about a
+// statement's target, it rejects. Plugin authors get a clear error
+// message that includes the offending identifier; they fix their
+// migration and try again.
+func LintMigration(slug, sql string) error {
+ if slug == "" {
+ return fmt.Errorf("sqllint: empty slug; refusing to lint")
+ }
+ prefix := "plugin_" + slug + "_"
+
+ // Strip comments so a `-- DROP TABLE users` decoration doesn't
+ // fail the lint. We don't strip string literals because no
+ // statement we accept has a body where an identifier-looking
+ // substring matters (CREATE TABLE doesn't take a string body;
+ // ALTER TABLE ADD COLUMN with a DEFAULT string is fine because
+ // the regex anchors on the verb position).
+ cleaned := reLineComment.ReplaceAllString(sql, "")
+ cleaned = reBlockComment.ReplaceAllString(cleaned, "")
+
+ // Hard-reject the forbidden-verb set before identifier checks.
+ // These statements have no "target" identifier the linter can
+ // scope to a plugin prefix (CREATE EXTENSION lives at the
+ // database level, GRANT operates on role objects), so the
+ // safest policy is to reject outright.
+ if m := reForbidden.FindString(cleaned); m != "" {
+ return fmt.Errorf("%w: forbidden statement %q (plugins may only target prefixed TABLE/INDEX objects)",
+ ErrLintViolation, strings.TrimSpace(m))
+ }
+
+ // Walk the four allowed forms. Each returns the identifier the
+ // statement targets; we check that identifier (de-quoted, de-
+ // schema'd) starts with the plugin prefix. CREATE INDEX has TWO
+ // identifiers (the index name and the table it covers); both
+ // must be plugin-scoped.
+ type match struct {
+ verb string
+ idx []int
+ ids []string
+ }
+ var hits []match
+ for _, mm := range reCreateTable.FindAllStringSubmatchIndex(cleaned, -1) {
+ hits = append(hits, match{verb: "CREATE TABLE", idx: mm, ids: []string{sliceMatch(cleaned, mm, 1)}})
+ }
+ for _, mm := range reAlterTable.FindAllStringSubmatchIndex(cleaned, -1) {
+ hits = append(hits, match{verb: "ALTER TABLE", idx: mm, ids: []string{sliceMatch(cleaned, mm, 1)}})
+ }
+ for _, mm := range reCreateIndex.FindAllStringSubmatchIndex(cleaned, -1) {
+ hits = append(hits, match{verb: "CREATE INDEX", idx: mm, ids: []string{
+ sliceMatch(cleaned, mm, 1),
+ sliceMatch(cleaned, mm, 2),
+ }})
+ }
+ for _, mm := range reDropTable.FindAllStringSubmatchIndex(cleaned, -1) {
+ hits = append(hits, match{verb: "DROP TABLE", idx: mm, ids: []string{sliceMatch(cleaned, mm, 1)}})
+ }
+ for _, mm := range reDropIndex.FindAllStringSubmatchIndex(cleaned, -1) {
+ hits = append(hits, match{verb: "DROP INDEX", idx: mm, ids: []string{sliceMatch(cleaned, mm, 1)}})
+ }
+
+ for _, h := range hits {
+ for _, raw := range h.ids {
+ id := extractIdentifier(raw)
+ if !strings.HasPrefix(id, prefix) {
+ return fmt.Errorf("%w: %s references %q (must begin with %q)",
+ ErrLintViolation, h.verb, id, prefix)
+ }
+ }
+ }
+ return nil
+}
+
+// sliceMatch returns the substring captured by group g from a single
+// FindAllStringSubmatchIndex hit. Returns "" if the group didn't match
+// (which can happen with optional alternatives in the regex).
+func sliceMatch(s string, idx []int, g int) string {
+ start := idx[2*g]
+ end := idx[2*g+1]
+ if start < 0 || end < 0 {
+ return ""
+ }
+ return s[start:end]
+}
+
+// extractIdentifier strips the optional schema qualifier and any
+// surrounding double quotes from a captured identifier. The grammar
+// accepts both `plugin_x_t`, `"plugin_x_t"`, `public.plugin_x_t`,
+// and `public."plugin_x_t"` — all should collapse to the bare name
+// `plugin_x_t` for the prefix check.
+func extractIdentifier(raw string) string {
+ if raw == "" {
+ return ""
+ }
+ // Drop schema qualifier (`public.foo` → `foo`). We keep the
+ // rightmost segment; multi-part identifiers (`db.schema.foo`)
+ // also collapse to the rightmost part because PostgreSQL's
+ // addressability is left-to-right least-specific to
+ // most-specific.
+ if i := strings.LastIndex(raw, "."); i >= 0 {
+ raw = raw[i+1:]
+ }
+ // Drop surrounding double quotes. A quoted identifier may still
+ // contain "." inside the quotes, but the prefix check just looks
+ // at the leading characters so we don't need to be cleverer
+ // than this.
+ raw = strings.Trim(raw, `"`)
+ return raw
+}
diff --git a/packages/go/plugins/lifecycle/sqllint_test.go b/packages/go/plugins/lifecycle/sqllint_test.go
new file mode 100644
index 00000000..c36dd233
--- /dev/null
+++ b/packages/go/plugins/lifecycle/sqllint_test.go
@@ -0,0 +1,201 @@
+package lifecycle
+
+import (
+ "errors"
+ "strings"
+ "testing"
+)
+
+// TestLintMigration_HappyPath exercises every shape we explicitly
+// support: CREATE TABLE (with various decorators), ALTER TABLE,
+// CREATE INDEX with quoted/schema-qualified identifiers, and a
+// multi-statement migration. None should trip the linter when every
+// referenced object carries the `plugin__` prefix.
+func TestLintMigration_HappyPath(t *testing.T) {
+ cases := []struct {
+ name string
+ sql string
+ }{
+ {
+ "create-table-simple",
+ `CREATE TABLE plugin_seo_keywords (id uuid PRIMARY KEY);`,
+ },
+ {
+ "create-table-if-not-exists",
+ `CREATE TABLE IF NOT EXISTS plugin_seo_keywords (id uuid);`,
+ },
+ {
+ "create-table-schema-qualified",
+ `CREATE TABLE public.plugin_seo_keywords (id uuid);`,
+ },
+ {
+ "create-table-quoted-identifier",
+ `CREATE TABLE "plugin_seo_keywords" (id uuid);`,
+ },
+ {
+ "alter-table",
+ `ALTER TABLE plugin_seo_keywords ADD COLUMN score int;`,
+ },
+ {
+ "create-index",
+ `CREATE INDEX plugin_seo_idx_score ON plugin_seo_keywords (score);`,
+ },
+ {
+ "create-unique-index-concurrently",
+ `CREATE UNIQUE INDEX CONCURRENTLY plugin_seo_idx_slug ON plugin_seo_keywords (slug);`,
+ },
+ {
+ "multi-statement",
+ `CREATE TABLE plugin_seo_keywords (id uuid);
+ALTER TABLE plugin_seo_keywords ADD COLUMN score int;
+CREATE INDEX plugin_seo_idx_score ON plugin_seo_keywords (score);`,
+ },
+ {
+ "with-comments",
+ `-- DROP TABLE users; this comment should be stripped.
+/* Block comment with CREATE TABLE users (); should also be stripped. */
+CREATE TABLE plugin_seo_keywords (id uuid);`,
+ },
+ }
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ if err := LintMigration("seo", tc.sql); err != nil {
+ t.Fatalf("expected no error; got %v", err)
+ }
+ })
+ }
+}
+
+// TestLintMigration_RejectsNonPluginPrefix is the negative path: any
+// statement referencing an object outside `plugin__` must
+// reject with ErrLintViolation and an error message that names the
+// offending identifier (so plugin authors can fix their migration).
+func TestLintMigration_RejectsNonPluginPrefix(t *testing.T) {
+ cases := []struct {
+ name string
+ sql string
+ wantIDFrag string
+ }{
+ {
+ "create-table-core-name",
+ `CREATE TABLE users (id uuid);`,
+ "users",
+ },
+ {
+ "create-table-other-plugin-prefix",
+ `CREATE TABLE plugin_other_table (id uuid);`,
+ "plugin_other_table",
+ },
+ {
+ "alter-table-core-name",
+ `ALTER TABLE posts ADD COLUMN x int;`,
+ "posts",
+ },
+ {
+ "create-index-on-core-table",
+ `CREATE INDEX plugin_seo_idx ON users (id);`,
+ "users",
+ },
+ {
+ "create-index-with-non-plugin-name",
+ `CREATE INDEX idx_score ON plugin_seo_keywords (score);`,
+ "idx_score",
+ },
+ {
+ "drop-core-table",
+ `DROP TABLE users;`,
+ "users",
+ },
+ {
+ "create-table-quoted-core",
+ `CREATE TABLE "users" (id uuid);`,
+ "users",
+ },
+ }
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ err := LintMigration("seo", tc.sql)
+ if err == nil {
+ t.Fatalf("expected ErrLintViolation; got nil")
+ }
+ if !errors.Is(err, ErrLintViolation) {
+ t.Fatalf("expected ErrLintViolation; got %v", err)
+ }
+ if !strings.Contains(err.Error(), tc.wantIDFrag) {
+ t.Fatalf("error message should mention %q; got %v", tc.wantIDFrag, err)
+ }
+ })
+ }
+}
+
+// TestLintMigration_RejectsForbiddenVerbs verifies the forbidden-verb
+// kill-list. These statements have no plugin-scoped target by
+// construction (CREATE EXTENSION targets the database; GRANT targets
+// a role) so the linter refuses them outright. The kill-list is the
+// conservative posture: a future ADR can carve out specific verbs
+// behind capability flags if a real plugin needs them.
+func TestLintMigration_RejectsForbiddenVerbs(t *testing.T) {
+ cases := []struct {
+ name string
+ sql string
+ }{
+ {"create-function", `CREATE FUNCTION evil() RETURNS void AS $$ BEGIN END; $$ LANGUAGE plpgsql;`},
+ {"create-extension", `CREATE EXTENSION pg_trgm;`},
+ {"create-view", `CREATE VIEW vuln AS SELECT * FROM users;`},
+ {"create-materialized-view", `CREATE MATERIALIZED VIEW mv AS SELECT 1;`},
+ {"create-trigger", `CREATE TRIGGER t BEFORE INSERT ON plugin_seo_x EXECUTE FUNCTION f();`},
+ {"grant", `GRANT SELECT ON users TO public;`},
+ {"revoke", `REVOKE SELECT ON users FROM public;`},
+ {"truncate", `TRUNCATE plugin_seo_keywords;`},
+ {"alter-schema", `ALTER SCHEMA public OWNER TO postgres;`},
+ {"drop-schema", `DROP SCHEMA public CASCADE;`},
+ }
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ err := LintMigration("seo", tc.sql)
+ if err == nil {
+ t.Fatalf("expected ErrLintViolation; got nil")
+ }
+ if !errors.Is(err, ErrLintViolation) {
+ t.Fatalf("expected ErrLintViolation; got %v", err)
+ }
+ })
+ }
+}
+
+// TestLintMigration_RejectsEmptySlug guards against the only
+// pathological seed-input the linter can't validate: an empty slug.
+// With prefix == "plugin__", any identifier starting with "plugin_"
+// would pass — including a hostile `plugin_other_users`. We reject
+// outright instead of fail-open.
+func TestLintMigration_RejectsEmptySlug(t *testing.T) {
+ if err := LintMigration("", "CREATE TABLE plugin_x_y (id uuid);"); err == nil {
+ t.Fatal("expected error for empty slug; got nil")
+ }
+}
+
+// TestLintMigration_PrefixIsSlugScoped confirms that a migration
+// targeting *another* plugin's prefix is rejected — `plugin_other_x`
+// is not in `plugin_seo_*`, so the seo plugin's migration can't
+// touch it even though both names start with `plugin_`.
+func TestLintMigration_PrefixIsSlugScoped(t *testing.T) {
+ err := LintMigration("seo", `CREATE TABLE plugin_other_keywords (id uuid);`)
+ if !errors.Is(err, ErrLintViolation) {
+ t.Fatalf("expected ErrLintViolation for cross-plugin write; got %v", err)
+ }
+}
+
+// TestLintMigration_EmptySQL is a sanity check: an empty migration is
+// vacuously fine. Plugins ship empty migrations during scaffolding;
+// failing those would be a worse UX than allowing the no-op through.
+func TestLintMigration_EmptySQL(t *testing.T) {
+ if err := LintMigration("seo", ""); err != nil {
+ t.Fatalf("empty SQL should be accepted; got %v", err)
+ }
+ if err := LintMigration("seo", "-- only a comment\n"); err != nil {
+ t.Fatalf("comment-only SQL should be accepted; got %v", err)
+ }
+}
From 7d0e6b3d0c8b29802ecad49ca00025d949ca2d45 Mon Sep 17 00:00:00 2001
From: Tayeb Mokni
Date: Tue, 26 May 2026 20:10:30 +0200
Subject: [PATCH 3/5] feat(tracing): OpenTelemetry wire-up with OTLP HTTP
exporter (#186)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
apps/api/cmd/server/main.go imported otelhttp transitively but never
initialised a tracer provider, so the otelhttp middleware spans
silently fell through to the global no-op tracer. This change adds a
packages/go/tracing package that:
* reads `GONEXT_OTLP_ENDPOINT` (or Options.Endpoint),
* stands up an OTLP HTTP exporter pointed at it,
* wraps it in a batched SDK TracerProvider with the binary's
`service.name` / `service.version` resource attributes,
* installs the W3C TraceContext + Baggage composite propagator
globally so incoming `traceparent` headers thread through every
downstream client,
* returns a Shutdown closer the binary registers with the
shutdown orchestrator for graceful flush.
When the env var is unset the package returns a no-op Shutdown but
still installs the W3C propagator — partial rollouts where this
service has tracing disabled but its neighbours don't will still
propagate trace IDs end-to-end.
main.go wires the whole mux through otelhttp.NewHandler with a
custom SpanNameFormatter that prefers the matched ServeMux pattern
over the raw URL path, keeping span-name cardinality bounded across
the /api/v1/posts/{id} family.
Closes #186.
Co-Authored-By: Claude Opus 4.7
Signed-off-by: Tayeb Mokni
---
apps/api/cmd/server/main.go | 59 ++++++-
apps/api/go.mod | 32 +++-
apps/api/go.sum | 49 ++++--
packages/go/go.mod | 15 +-
packages/go/go.sum | 25 +++
packages/go/tracing/tracing.go | 246 ++++++++++++++++++++++++++++
packages/go/tracing/tracing_test.go | 154 +++++++++++++++++
7 files changed, 562 insertions(+), 18 deletions(-)
create mode 100644 packages/go/tracing/tracing.go
create mode 100644 packages/go/tracing/tracing_test.go
diff --git a/apps/api/cmd/server/main.go b/apps/api/cmd/server/main.go
index 2471fe34..7d751158 100644
--- a/apps/api/cmd/server/main.go
+++ b/apps/api/cmd/server/main.go
@@ -29,6 +29,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
goredis "github.com/redis/go-redis/v9"
+ "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
admincomments "github.com/Singleton-Solution/GoNext/apps/api/internal/admin/comments"
adminmedia "github.com/Singleton-Solution/GoNext/apps/api/internal/admin/media"
@@ -77,6 +78,7 @@ import (
"github.com/Singleton-Solution/GoNext/packages/go/session"
"github.com/Singleton-Solution/GoNext/packages/go/shutdown"
"github.com/Singleton-Solution/GoNext/packages/go/theme/seed"
+ "github.com/Singleton-Solution/GoNext/packages/go/tracing"
"github.com/Singleton-Solution/GoNext/packages/go/webhooks/revalidate"
)
@@ -223,6 +225,35 @@ func run(ctx context.Context) error {
return fmt.Errorf("theme seed: %w", seedErr)
}
+ // Distributed tracing (issue #186). The tracer provider is wired
+ // AFTER the DB pool + Redis client (so its Shutdown drains
+ // before they do — span exports may need outgoing HTTP and
+ // short-lived state) but BEFORE the HTTP server (so spans
+ // emitted by incoming requests have a live provider to attach
+ // to). The implementation is a no-op when GONEXT_OTLP_ENDPOINT
+ // is unset; the global W3C propagator is installed
+ // unconditionally so an upgrading deploy can land OTel exports
+ // gradually without losing the incoming traceparent header.
+ traceShutdown, traceErr := tracing.Setup(ctx, tracing.Options{
+ ServiceName: serviceName,
+ ServiceVersion: bi.Version,
+ Insecure: cfg.Env != "production",
+ Logger: logger,
+ })
+ if traceErr != nil {
+ // Setup failure is non-fatal: the binary boots without
+ // tracing rather than refusing to start because of a
+ // misconfigured collector. The warning surfaces in the
+ // boot log so operators notice.
+ logger.Warn("tracing: setup failed; continuing without traces",
+ slog.Any("err", traceErr))
+ } else {
+ orch.MustRegister(logger, "tracing.provider",
+ func(stopCtx context.Context) error {
+ return traceShutdown(stopCtx)
+ })
+ }
+
// Metrics + audit are best-effort flush points. They're registered
// AFTER persistence (so they drain BEFORE persistence on LIFO) —
// the last audit record needs the DB pool alive when it writes.
@@ -285,6 +316,32 @@ func run(ctx context.Context) error {
mux := buildRouter(cfg, pool, rdb, sessions, themeDir, logger, redirectStore, redirectEngine, auditEmitter)
+ // Wrap the entire router in the otelhttp instrumentation so every
+ // incoming request gets a server span and the W3C `traceparent`
+ // header is extracted into the request context. The span name is
+ // the matched route pattern (otelhttp's default), which keeps the
+ // cardinality bounded — without this every dynamic id segment
+ // would bloat the trace UI's "operation" list.
+ //
+ // When the tracer provider is the no-op (no endpoint set), the
+ // middleware is still installed but every span is a no-op span:
+ // the cost is one function-call's worth of overhead per request,
+ // and the header extraction still runs so downstream propagation
+ // works in the partial-rollout case.
+ tracedHandler := otelhttp.NewHandler(mux, "gonext.api",
+ otelhttp.WithSpanNameFormatter(func(_ string, r *http.Request) string {
+ // Prefer the matched pattern (set by ServeMux when it
+ // dispatched) so /api/v1/posts/{id} stays one span
+ // name across a million distinct ids; fall back to the
+ // raw path for routes that didn't match (404 path,
+ // which is bounded by definition).
+ if r.Pattern != "" {
+ return r.Method + " " + r.Pattern
+ }
+ return r.Method + " " + r.URL.Path
+ }),
+ )
+
// Build the middleware chain. Early Hints (issue #122) sits AFTER
// Recovery (so a panicking hints provider doesn't crash the
// server) but BEFORE Logger and metrics. The 103 we emit is about
@@ -332,7 +389,7 @@ func run(ctx context.Context) error {
srv, err := httpx.New(httpx.Options{
Config: cfg.Server,
Log: logger,
- Handler: mux,
+ Handler: tracedHandler,
Middlewares: mws,
})
if err != nil {
diff --git a/apps/api/go.mod b/apps/api/go.mod
index c47db425..252e8a43 100644
--- a/apps/api/go.mod
+++ b/apps/api/go.mod
@@ -9,19 +9,23 @@ require (
github.com/graph-gophers/dataloader/v7 v7.1.3
github.com/hibiken/asynq v0.26.0
github.com/jackc/pgx/v5 v5.9.2
+ github.com/minio/minio-go/v7 v7.1.0
github.com/pquerna/otp v1.5.0
github.com/redis/go-redis/v9 v9.19.0
github.com/vektah/gqlparser/v2 v2.5.33
+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
+ github.com/HugoSmits86/nativewebp v1.3.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/agnivade/levenshtein v1.2.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
+ github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
@@ -29,25 +33,33 @@ require (
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+ github.com/davidbyttow/govips/v2 v2.18.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
+ github.com/go-ini/ini v1.67.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
+ github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.18.5 // indirect
+ github.com/klauspost/cpuid/v2 v2.2.11 // indirect
+ github.com/klauspost/crc32 v1.3.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mdelapenya/tlscert v0.2.0 // indirect
+ github.com/minio/crc64nvme v1.1.1 // indirect
+ github.com/minio/md5-simd v1.1.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/moby/api v1.54.1 // indirect
@@ -60,6 +72,7 @@ require (
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
+ github.com/philhofer/fwd v1.2.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
@@ -67,6 +80,7 @@ require (
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
+ github.com/rs/xid v1.6.0 // indirect
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
@@ -78,17 +92,24 @@ require (
github.com/testcontainers/testcontainers-go/modules/minio v0.42.0 // indirect
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 // indirect
github.com/testcontainers/testcontainers-go/modules/redis v0.42.0 // indirect
+ github.com/tinylib/msgp v1.6.1 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
+ github.com/zeebo/xxh3 v1.1.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
- go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
- go.opentelemetry.io/otel v1.41.0 // indirect
- go.opentelemetry.io/otel/metric v1.41.0 // indirect
- go.opentelemetry.io/otel/trace v1.41.0 // indirect
+ go.opentelemetry.io/otel v1.43.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
+ go.opentelemetry.io/otel/metric v1.43.0 // indirect
+ go.opentelemetry.io/otel/sdk v1.43.0 // indirect
+ go.opentelemetry.io/otel/trace v1.43.0 // indirect
+ go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
+ go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.51.0 // indirect
+ golang.org/x/image v0.40.0 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
@@ -96,6 +117,9 @@ require (
golang.org/x/text v0.37.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.44.0 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
+ google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/apps/api/go.sum b/apps/api/go.sum
index 05d3be75..343a66cc 100644
--- a/apps/api/go.sum
+++ b/apps/api/go.sum
@@ -6,6 +6,8 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8af
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
+github.com/HugoSmits86/nativewebp v1.3.0 h1:n1egtEzSV4KwFtealr7dzdYq1wI/uj/bOQ/QcTcIyVE=
+github.com/HugoSmits86/nativewebp v1.3.0/go.mod h1:YNQuWenlVmSUUASVNhTDwf4d7FwYQGbGhklC8p72Vr8=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
@@ -26,6 +28,8 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
+github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
+github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
@@ -43,6 +47,8 @@ github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfv
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davidbyttow/govips/v2 v2.18.0 h1:pZRshWVYvewP/TZx3yZ7YeC42WyLXg53tHy5Qt8nT9E=
+github.com/davidbyttow/govips/v2 v2.18.0/go.mod h1:8+nst5zfMoats12PgmmAPh6p5OfjDaXK0BXMFl/vOcM=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
@@ -76,6 +82,8 @@ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
@@ -85,6 +93,8 @@ github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWm
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/graph-gophers/dataloader/v7 v7.1.3 h1:mXCI1E3dBG0aG1Tzg1tXaz+nN140opFIgEfYhxHR0XA=
github.com/graph-gophers/dataloader/v7 v7.1.3/go.mod h1:cnjGvZ3DuN2hU90Q72WCZNzkCEq/BHwh7fI7w7/GhIg=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hibiken/asynq v0.26.0 h1:1Zxr92MlDnb1Zt/QR5g2vSCqUS03i95lUfqx5X7/wrw=
@@ -99,6 +109,7 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
+github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
@@ -213,22 +224,30 @@ github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
+github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
+github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
-go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
-go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
-go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
-go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
-go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
-go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
-go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
-go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
-go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
-go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
+go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
+go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
+go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
+go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
+go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
+go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
+go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
+go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
+go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
+go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
+go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
+go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -239,6 +258,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
+golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
+golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
@@ -259,6 +280,14 @@ golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
+gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
+google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
+google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
+google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/packages/go/go.mod b/packages/go/go.mod
index aadc29b5..690b8450 100644
--- a/packages/go/go.mod
+++ b/packages/go/go.mod
@@ -28,6 +28,10 @@ require (
github.com/testcontainers/testcontainers-go/modules/redis v0.42.0
github.com/tetratelabs/wazero v1.11.0
github.com/wI2L/jsondiff v0.7.1
+ go.opentelemetry.io/otel v1.43.0
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0
+ go.opentelemetry.io/otel/sdk v1.43.0
golang.org/x/crypto v0.51.0
golang.org/x/image v0.40.0
golang.org/x/mod v0.36.0
@@ -43,6 +47,7 @@ require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
+ github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
@@ -60,6 +65,7 @@ require (
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
+ github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
@@ -107,15 +113,18 @@ require (
github.com/zeebo/xxh3 v1.1.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
- go.opentelemetry.io/otel v1.41.0 // indirect
- go.opentelemetry.io/otel/metric v1.41.0 // indirect
- go.opentelemetry.io/otel/trace v1.41.0 // indirect
+ go.opentelemetry.io/otel/metric v1.43.0 // indirect
+ go.opentelemetry.io/otel/trace v1.43.0 // indirect
+ go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/time v0.14.0 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
+ google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/packages/go/go.sum b/packages/go/go.sum
index e09bc79b..aaa04c7c 100644
--- a/packages/go/go.sum
+++ b/packages/go/go.sum
@@ -22,6 +22,8 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
+github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
+github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
@@ -85,6 +87,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/hibiken/asynq v0.26.0 h1:1Zxr92MlDnb1Zt/QR5g2vSCqUS03i95lUfqx5X7/wrw=
github.com/hibiken/asynq v0.26.0/go.mod h1:Qk4e57bTnWDoyJ67VkchuV6VzSM9IQW2nPvAGuDyw58=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -234,14 +238,28 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6h
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
+go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
+go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
+go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
+go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
+go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
+go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
+go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
+go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
+go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
+go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -274,6 +292,13 @@ golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
+google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
+google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
+google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/packages/go/tracing/tracing.go b/packages/go/tracing/tracing.go
new file mode 100644
index 00000000..2e466dcb
--- /dev/null
+++ b/packages/go/tracing/tracing.go
@@ -0,0 +1,246 @@
+// Package tracing wires the GoNext binary's OpenTelemetry tracer
+// provider. The contract is:
+//
+// - If the `GONEXT_OTLP_ENDPOINT` env var is unset (or empty), the
+// package returns a NOOP TracerProvider and the Shutdown closer is
+// a no-op. The rest of the codebase calls `otel.Tracer(...)` which
+// transparently falls through to the global no-op tracer, so the
+// binary's behavior is unchanged.
+//
+// - If the endpoint is set, the package constructs an OTLP HTTP
+// exporter, wraps it in a batched SDK TracerProvider, sets the
+// global TextMap propagator to the W3C TraceContext+Baggage
+// composite (so incoming requests inherit a remote span context
+// and outgoing requests carry their span downstream), and returns
+// a Shutdown closer the caller registers with the shutdown
+// orchestrator.
+//
+// The package deliberately uses HTTP rather than gRPC for the
+// exporter: the operator-facing knob is a single URL, no extra TLS or
+// channel-tuning surface, and the binary already imports net/http
+// transitively for every other client.
+//
+// Issue #186.
+package tracing
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "os"
+ "strings"
+ "time"
+
+ "go.opentelemetry.io/otel"
+ "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
+ "go.opentelemetry.io/otel/propagation"
+ sdkresource "go.opentelemetry.io/otel/sdk/resource"
+ sdktrace "go.opentelemetry.io/otel/sdk/trace"
+ semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
+)
+
+// EndpointEnv is the env var read by [Setup]. Exported so the print-
+// config dump can surface the current value alongside its sibling
+// knobs.
+const EndpointEnv = "GONEXT_OTLP_ENDPOINT"
+
+// Options configures [Setup]. ServiceName / ServiceVersion become
+// resource attributes on every emitted span; Logger receives one-shot
+// boot lines ("tracing enabled, endpoint=...", "tracing disabled, no
+// endpoint set").
+type Options struct {
+ // ServiceName is the value used for the OTel `service.name`
+ // resource attribute. Required — an unnamed tracer is useless
+ // when looking at a multi-service trace in the SIEM.
+ ServiceName string
+
+ // ServiceVersion populates `service.version`. Optional but
+ // strongly recommended; set this from buildinfo.Version so the
+ // trace pinpoints the binary that produced it.
+ ServiceVersion string
+
+ // Endpoint overrides EndpointEnv when non-empty. Tests pin it to
+ // a httptest server URL; the production caller leaves it unset
+ // and lets Setup read the env var directly.
+ Endpoint string
+
+ // Insecure disables TLS on the OTLP HTTP exporter. Required for
+ // local development where the collector typically listens on
+ // plain HTTP. The production caller leaves this false.
+ Insecure bool
+
+ // Logger receives the one-line boot diagnostics. Required.
+ Logger *slog.Logger
+}
+
+// Shutdown is the function shape returned by [Setup]. The caller is
+// expected to hand it to the shutdown orchestrator so the in-flight
+// span batch is flushed before the binary exits. The function is
+// idempotent — calling it twice is a no-op the second time.
+type Shutdown func(context.Context) error
+
+// noopShutdown is the closer returned when tracing was not enabled
+// (no endpoint, or Setup failed before constructing a provider). It
+// satisfies the [Shutdown] type so the caller can register it with
+// the orchestrator unconditionally.
+func noopShutdown(_ context.Context) error { return nil }
+
+// Setup constructs the tracer provider, installs it globally, and
+// returns a Shutdown closer.
+//
+// When o.Endpoint (or the env fallback) is empty, Setup returns a
+// no-op Shutdown and logs that tracing is disabled. The rest of the
+// codebase can call `otel.Tracer(...)` regardless; the global tracer
+// provider is the SDK's no-op default in that case.
+//
+// When the endpoint is set, Setup wires:
+//
+// - an OTLP HTTP exporter pointed at the endpoint;
+// - a BatchSpanProcessor with default tuning;
+// - a TracerProvider with the service-name resource;
+// - the global TextMap propagator set to the W3C composite
+// (TraceContext + Baggage) so incoming requests inherit a
+// remote trace and outgoing requests propagate it.
+//
+// On any exporter-construction failure, Setup returns the error
+// untouched — the caller decides whether to bail (fatal) or carry on
+// without tracing (warn + noop). main.go logs and falls through to
+// the no-op path; tracing must never block the boot.
+func Setup(ctx context.Context, o Options) (Shutdown, error) {
+ if o.Logger == nil {
+ return noopShutdown, errors.New("tracing.Setup: Logger is required")
+ }
+ if o.ServiceName == "" {
+ return noopShutdown, errors.New("tracing.Setup: ServiceName is required")
+ }
+
+ endpoint := strings.TrimSpace(o.Endpoint)
+ if endpoint == "" {
+ endpoint = strings.TrimSpace(os.Getenv(EndpointEnv))
+ }
+ if endpoint == "" {
+ // No endpoint configured — install the W3C propagator
+ // anyway so the binary still threads incoming
+ // `traceparent` headers through outgoing requests. The
+ // trace IDs the binary stamps onto the headers are
+ // random (the no-op tracer's contract) but downstream
+ // services can still join them; this matches the
+ // "headers always flow" rule that lets a partial
+ // rollout of OTel work end-to-end.
+ otel.SetTextMapPropagator(newPropagator())
+ o.Logger.Info("tracing: disabled (no endpoint set)",
+ slog.String("env", EndpointEnv))
+ return noopShutdown, nil
+ }
+
+ // The exporter's option surface accepts a bare host:port; we
+ // strip the scheme so an operator who pastes a full URL doesn't
+ // get a confused-host error. The Insecure flag controls TLS;
+ // schemes "http://" map to insecure, "https://" to TLS.
+ insecure := o.Insecure
+ stripped := endpoint
+ switch {
+ case strings.HasPrefix(endpoint, "http://"):
+ stripped = strings.TrimPrefix(endpoint, "http://")
+ insecure = true
+ case strings.HasPrefix(endpoint, "https://"):
+ stripped = strings.TrimPrefix(endpoint, "https://")
+ }
+ // The OTLP HTTP exporter wants endpoint = host[:port][/v1/traces].
+ // We pass it stripped of scheme; the path is left at the
+ // exporter's default ("/v1/traces"), which matches the OTLP
+ // collector spec.
+ opts := []otlptracehttp.Option{
+ otlptracehttp.WithEndpoint(stripped),
+ }
+ if insecure {
+ opts = append(opts, otlptracehttp.WithInsecure())
+ }
+ exporter, err := otlptracehttp.New(ctx, opts...)
+ if err != nil {
+ return noopShutdown, fmt.Errorf("tracing: build exporter: %w", err)
+ }
+
+ res, err := sdkresource.Merge(
+ sdkresource.Default(),
+ sdkresource.NewWithAttributes(
+ semconv.SchemaURL,
+ semconv.ServiceName(o.ServiceName),
+ semconv.ServiceVersion(o.ServiceVersion),
+ ),
+ )
+ if err != nil {
+ // Resource merge errors are non-fatal (the SDK will fall
+ // back to the default resource); but we DO want to log the
+ // failure so an operator knows the service-name attribute
+ // might be missing from spans.
+ o.Logger.Warn("tracing: resource merge failed; using default resource",
+ slog.Any("err", err))
+ res = sdkresource.Default()
+ }
+
+ tp := sdktrace.NewTracerProvider(
+ sdktrace.WithBatcher(exporter,
+ // Default batch timing is fine for production; we
+ // pin it explicitly so an env-var bump to the
+ // default doesn't change behavior under us.
+ sdktrace.WithBatchTimeout(5*time.Second),
+ sdktrace.WithMaxExportBatchSize(512),
+ ),
+ sdktrace.WithResource(res),
+ )
+ otel.SetTracerProvider(tp)
+ otel.SetTextMapPropagator(newPropagator())
+
+ o.Logger.Info("tracing: enabled",
+ slog.String("endpoint", endpoint),
+ slog.String("service", o.ServiceName),
+ slog.Bool("insecure", insecure),
+ )
+
+ // The shutdown closer flushes the batched exporter and then
+ // shuts down the tracer provider. Both Shutdown methods are
+ // idempotent — calling twice is safe. We use a sync.Once-style
+ // guard via a bool capture so we don't double-shutdown if the
+ // orchestrator re-invokes us.
+ var done bool
+ return func(stopCtx context.Context) error {
+ if done {
+ return nil
+ }
+ done = true
+ // Order matters: Shutdown the provider FIRST (which
+ // drains the BSP) then the exporter. The provider's
+ // Shutdown blocks until pending spans flush via the
+ // exporter, so the exporter is implicitly drained too;
+ // calling exporter.Shutdown afterwards is the documented
+ // pattern and is idempotent.
+ if err := tp.Shutdown(stopCtx); err != nil {
+ // Wrap rather than swallow — the orchestrator
+ // logs the per-step error and the boot summary
+ // will surface it.
+ return fmt.Errorf("tracing: provider shutdown: %w", err)
+ }
+ // Best-effort: the provider already drained the BSP via
+ // the exporter, so this call is largely a belt-and-
+ // suspenders flush. We don't propagate errors from this
+ // second call — they almost always reduce to "already
+ // shut down" and the orchestrator only needs one
+ // authoritative status.
+ _ = exporter.Shutdown(stopCtx)
+ return nil
+ }, nil
+}
+
+// newPropagator returns the W3C composite propagator we install
+// globally. TraceContext carries traceparent + tracestate (the
+// standard distributed-trace headers); Baggage carries operator-
+// defined key=value pairs (typically tenant id, locale, etc.). The
+// composite is the canonical choice for OTLP-emitting services.
+func newPropagator() propagation.TextMapPropagator {
+ return propagation.NewCompositeTextMapPropagator(
+ propagation.TraceContext{},
+ propagation.Baggage{},
+ )
+}
diff --git a/packages/go/tracing/tracing_test.go b/packages/go/tracing/tracing_test.go
new file mode 100644
index 00000000..31c0be30
--- /dev/null
+++ b/packages/go/tracing/tracing_test.go
@@ -0,0 +1,154 @@
+package tracing
+
+import (
+ "context"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "go.opentelemetry.io/otel"
+ "go.opentelemetry.io/otel/propagation"
+)
+
+// TestSetup_NoEndpoint exercises the no-op path: with no endpoint
+// configured, Setup must return a no-op Shutdown, install the W3C
+// propagator anyway (so partial rollouts can still join distributed
+// traces), and never block.
+func TestSetup_NoEndpoint(t *testing.T) {
+ t.Setenv(EndpointEnv, "")
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ shutdown, err := Setup(context.Background(), Options{
+ ServiceName: "api-test",
+ Logger: logger,
+ })
+ if err != nil {
+ t.Fatalf("Setup: %v", err)
+ }
+ if shutdown == nil {
+ t.Fatal("Setup returned nil shutdown")
+ }
+ // Shutdown should be safe to call (it's a no-op).
+ if err := shutdown(context.Background()); err != nil {
+ t.Fatalf("shutdown: %v", err)
+ }
+ // The W3C propagator should still be installed so incoming
+ // traceparent headers thread through.
+ if got := otel.GetTextMapPropagator(); got == nil {
+ t.Fatal("TextMapPropagator not installed")
+ }
+}
+
+// TestSetup_WithEndpoint stands up a fake OTLP HTTP collector and
+// drives Setup against it. The test asserts:
+//
+// - Setup returns a non-nil Shutdown,
+// - the global tracer provider is the SDK (not the no-op),
+// - Shutdown drains pending spans through the fake collector.
+//
+// The fake collector counts incoming requests via an atomic; we
+// don't decode the protobuf payload — the SDK guarantees the wire
+// format and the assertion of interest is "a request happened
+// before Shutdown returned".
+func TestSetup_WithEndpoint(t *testing.T) {
+ var hits int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt32(&hits, 1)
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer srv.Close()
+
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ t.Setenv(EndpointEnv, "")
+ shutdown, err := Setup(context.Background(), Options{
+ ServiceName: "api-test",
+ Endpoint: srv.URL,
+ Insecure: true,
+ Logger: logger,
+ })
+ if err != nil {
+ t.Fatalf("Setup: %v", err)
+ }
+ t.Cleanup(func() {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ _ = shutdown(ctx)
+ })
+
+ // Emit a single span via the global tracer.
+ tracer := otel.Tracer("test")
+ _, span := tracer.Start(context.Background(), "test-span")
+ span.End()
+
+ // Drain — this should block until the fake collector receives the
+ // span batch. We give it a short window; the test will fail loudly
+ // if no request lands.
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ if err := shutdown(ctx); err != nil {
+ t.Fatalf("shutdown: %v", err)
+ }
+
+ if got := atomic.LoadInt32(&hits); got == 0 {
+ t.Fatal("expected at least one export request; got 0")
+ }
+}
+
+// TestSetup_RejectsMissingServiceName guards the boot-time contract:
+// an unnamed tracer is useless in a multi-service trace, so Setup
+// refuses to build one. The caller is expected to surface this as
+// a fatal config error.
+func TestSetup_RejectsMissingServiceName(t *testing.T) {
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ _, err := Setup(context.Background(), Options{Logger: logger})
+ if err == nil {
+ t.Fatal("expected error for missing ServiceName; got nil")
+ }
+}
+
+// TestSetup_RejectsMissingLogger ensures the logger is required —
+// Setup writes a one-shot boot diagnostic that operators rely on.
+// Without a logger we'd silently degrade.
+func TestSetup_RejectsMissingLogger(t *testing.T) {
+ _, err := Setup(context.Background(), Options{ServiceName: "x"})
+ if err == nil {
+ t.Fatal("expected error for missing Logger; got nil")
+ }
+}
+
+// TestPropagator_W3CRoundTrip exercises the propagator installed by
+// Setup: a traceparent header injected on one request must extract
+// into a valid SpanContext on the receiver. The composite includes
+// Baggage so the test also confirms a baggage header survives the
+// round-trip.
+func TestPropagator_W3CRoundTrip(t *testing.T) {
+ t.Setenv(EndpointEnv, "")
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ _, err := Setup(context.Background(), Options{
+ ServiceName: "x",
+ Logger: logger,
+ })
+ if err != nil {
+ t.Fatalf("Setup: %v", err)
+ }
+
+ prop := otel.GetTextMapPropagator()
+ // Inject a known traceparent + baggage.
+ headers := http.Header{}
+ headers.Set("traceparent", "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01")
+ headers.Set("baggage", "tenant=acme,locale=en-US")
+ ctx := prop.Extract(context.Background(), propagation.HeaderCarrier(headers))
+
+ // Re-inject and check the headers round-trip back.
+ out := http.Header{}
+ prop.Inject(ctx, propagation.HeaderCarrier(out))
+ if got := out.Get("traceparent"); got == "" {
+ t.Fatalf("traceparent missing after round-trip; headers=%v", out)
+ }
+ if got := out.Get("baggage"); got == "" {
+ t.Fatalf("baggage missing after round-trip; headers=%v", out)
+ }
+}
From c249ae0ff7b961f4e20153ee1ceded543aa19a50 Mon Sep 17 00:00:00 2001
From: Tayeb Mokni
Date: Tue, 26 May 2026 20:18:59 +0200
Subject: [PATCH 4/5] feat(auth): WebAuthn passkey support (#159)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
GoNext's auth surface shipped password / TOTP / OAuth but had no
phishing-resistant credential. This change adds end-to-end
WebAuthn / passkey support:
* packages/go/auth/webauthn — Service wrapping the upstream
go-webauthn library, MemoryStore for tests + the boot-time
in-memory dev loop, Record/User adapter types.
* apps/api/internal/auth/webauthn — four HTTP routes wiring
the ceremony (register/begin, register/finish, login/begin,
login/finish) plus the admin list+delete surface
(GET/DELETE /api/v1/auth/webauthn/credentials[/{id}]). The
handler enforces row-level ownership before delete and
rejects discoverable-login probes.
* migrations/000035_webauthn_credentials — new
webauthn_credentials table with a unique index on
credential_id (assertion lookup) and a per-user index for
the admin list.
* apps/admin /settings/account — page with the PasskeyList
component, an "Add passkey" form that drives
navigator.credentials.create + .get, and per-row Remove.
Pasted into the settings overview as a fifth card.
The handler keeps ceremony state in a separate SessionStore
(Redis in prod, in-memory in tests) keyed by a random 16-byte
ceremony id; the user id is part of the key so a stolen
ceremony id from user A cannot be replayed against user B.
Closes #159.
Co-Authored-By: Claude Opus 4.7
Signed-off-by: Tayeb Mokni
---
.../(authenticated)/settings/account/api.ts | 193 +++++++++
.../account/components/PasskeyList.tsx | 173 ++++++++
.../(authenticated)/settings/account/page.tsx | 46 +++
.../(authenticated)/settings/page.test.tsx | 7 +-
.../src/app/(authenticated)/settings/page.tsx | 5 +
apps/api/internal/auth/webauthn/doc.go | 21 +
apps/api/internal/auth/webauthn/handler.go | 376 ++++++++++++++++++
.../internal/auth/webauthn/handler_test.go | 199 +++++++++
.../000035_webauthn_credentials.down.sql | 1 +
migrations/000035_webauthn_credentials.up.sql | 49 +++
packages/go/auth/webauthn/doc.go | 26 ++
packages/go/auth/webauthn/service.go | 231 +++++++++++
packages/go/auth/webauthn/service_test.go | 169 ++++++++
packages/go/auth/webauthn/store.go | 172 ++++++++
packages/go/auth/webauthn/user.go | 60 +++
packages/go/go.mod | 15 +-
packages/go/go.sum | 21 +
17 files changed, 1757 insertions(+), 7 deletions(-)
create mode 100644 apps/admin/src/app/(authenticated)/settings/account/api.ts
create mode 100644 apps/admin/src/app/(authenticated)/settings/account/components/PasskeyList.tsx
create mode 100644 apps/admin/src/app/(authenticated)/settings/account/page.tsx
create mode 100644 apps/api/internal/auth/webauthn/doc.go
create mode 100644 apps/api/internal/auth/webauthn/handler.go
create mode 100644 apps/api/internal/auth/webauthn/handler_test.go
create mode 100644 migrations/000035_webauthn_credentials.down.sql
create mode 100644 migrations/000035_webauthn_credentials.up.sql
create mode 100644 packages/go/auth/webauthn/doc.go
create mode 100644 packages/go/auth/webauthn/service.go
create mode 100644 packages/go/auth/webauthn/service_test.go
create mode 100644 packages/go/auth/webauthn/store.go
create mode 100644 packages/go/auth/webauthn/user.go
diff --git a/apps/admin/src/app/(authenticated)/settings/account/api.ts b/apps/admin/src/app/(authenticated)/settings/account/api.ts
new file mode 100644
index 00000000..13e68b6f
--- /dev/null
+++ b/apps/admin/src/app/(authenticated)/settings/account/api.ts
@@ -0,0 +1,193 @@
+/**
+ * /settings/account — API helpers for the passkey list (issue #159).
+ *
+ * The browser side of the WebAuthn ceremony uses
+ * navigator.credentials.create / .get with options the API issues at
+ * the /begin endpoint; this module just exposes thin fetch wrappers
+ * over those four endpoints plus the list+delete admin surface.
+ *
+ * No retry / no caching layer — the call sites are user-driven (one
+ * button-click per request) and a typed wrapper around `fetch` keeps
+ * the surface predictable.
+ */
+import { api } from '@/lib/api-client';
+
+export interface PasskeyView {
+ id: string;
+ name: string;
+ created_at: string;
+ last_used_at?: string;
+}
+
+/**
+ * GET /api/v1/auth/webauthn/credentials — list the signed-in user's
+ * registered passkeys. Returns the empty array on no rows.
+ */
+export async function listPasskeys(): Promise {
+ const res = await api.get<{ data: PasskeyView[] }>(
+ '/api/v1/auth/webauthn/credentials',
+ );
+ return res?.data ?? [];
+}
+
+/**
+ * DELETE /api/v1/auth/webauthn/credentials/{id} — revoke a passkey.
+ *
+ * The API enforces row ownership; an attempt to delete another
+ * user's row returns 404 (intentionally — the row's existence is
+ * also a leak to anybody enumerating ids, so we mask it).
+ */
+export async function deletePasskey(id: string): Promise {
+ await api.delete(
+ `/api/v1/auth/webauthn/credentials/${encodeURIComponent(id)}`,
+ );
+}
+
+/**
+ * registerPasskey runs the full registration ceremony:
+ *
+ * 1. POST /register/begin — the server returns a ceremony id and a
+ * CredentialCreationOptions blob.
+ * 2. navigator.credentials.create(options) — the browser prompts
+ * the user to choose an authenticator and produces an
+ * attestation response.
+ * 3. POST /register/finish — the server validates the attestation
+ * and persists the credential.
+ *
+ * Returns the row id + the friendly name; the caller refreshes the
+ * list.
+ *
+ * Errors:
+ * - User cancellation surfaces as DOMException("NotAllowedError")
+ * from credentials.create — we surface it as a typed error code
+ * "cancelled" so the UI can render a non-alarming message.
+ * - Server-side validation failure surfaces as the response's
+ * error payload, wrapped in an Error.
+ */
+export async function registerPasskey(
+ name: string,
+): Promise<{ id: string; name: string }> {
+ const beginRes = await api.post<{
+ ceremony_id: string;
+ options: { publicKey: PublicKeyCredentialCreationOptionsJSON };
+ }>('/api/v1/auth/webauthn/register/begin', {});
+
+ if (typeof navigator === 'undefined' || !navigator.credentials) {
+ throw new Error('WebAuthn not available in this browser');
+ }
+
+ // The API returns the options inside `options.publicKey` (matching
+ // the spec's shape). We pass through the inner publicKey object to
+ // the browser API. Some fields arrive base64-encoded (challenge,
+ // user.id, excludeCredentials[].id) and need to be decoded into
+ // ArrayBuffers before the browser will accept them.
+ //
+ // The cast through `unknown` is unavoidable: TypeScript's DOM lib
+ // models the options as the post-decode shape with BufferSources,
+ // but the wire shape is the pre-decode JSON. We patch up the three
+ // base64-coded fields, then assert the result back into the spec
+ // type — the runtime structure matches by construction.
+ const opts = beginRes.options.publicKey;
+ const publicKey = {
+ ...opts,
+ challenge: b64ToBuf(opts.challenge as unknown as string),
+ user: {
+ ...opts.user,
+ id: b64ToBuf(opts.user.id as unknown as string),
+ },
+ excludeCredentials: (opts.excludeCredentials ?? []).map((c) => ({
+ ...c,
+ id: b64ToBuf(c.id as unknown as string),
+ })),
+ } as unknown as PublicKeyCredentialCreationOptions;
+
+ let credential: PublicKeyCredential | null;
+ try {
+ credential = (await navigator.credentials.create({
+ publicKey,
+ })) as PublicKeyCredential | null;
+ } catch (err) {
+ if (err instanceof DOMException && err.name === 'NotAllowedError') {
+ throw new Error('cancelled');
+ }
+ throw err;
+ }
+ if (!credential) {
+ throw new Error('no_credential');
+ }
+
+ const attestation = serializeAttestation(credential);
+ const qs = new URLSearchParams({
+ ceremony_id: beginRes.ceremony_id,
+ name,
+ });
+ return api.post<{ id: string; name: string }>(
+ `/api/v1/auth/webauthn/register/finish?${qs.toString()}`,
+ attestation,
+ );
+}
+
+/**
+ * The TypeScript DOM lib types CredentialCreationOptions's
+ * `excludeCredentials[].id` as a BufferSource, but the API ships JSON
+ * (base64url-encoded). This type captures the wire shape so we can
+ * be explicit about the decode step above.
+ */
+interface PublicKeyCredentialCreationOptionsJSON {
+ challenge: string;
+ user: { id: string; name: string; displayName: string };
+ excludeCredentials?: Array<{ id: string; type: 'public-key' }>;
+ // Additional fields (rp, pubKeyCredParams, ...) pass through
+ // unmodified — they're already JSON-friendly primitives.
+ [key: string]: unknown;
+}
+
+/**
+ * b64ToBuf decodes a base64url-encoded string into an ArrayBuffer the
+ * Credentials API will accept. Browsers do NOT accept the standard
+ * `+` / `/` alphabet here; we re-pad and translate before decoding.
+ */
+function b64ToBuf(s: string): ArrayBuffer {
+ // base64url uses `-` / `_`; standard base64 uses `+` / `/`.
+ const standard = s.replace(/-/g, '+').replace(/_/g, '/');
+ // Pad to a multiple of 4 with `=`.
+ const padded = standard + '='.repeat((4 - (standard.length % 4)) % 4);
+ const bin = atob(padded);
+ const buf = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
+ return buf.buffer;
+}
+
+/**
+ * bufToB64 is the inverse of b64ToBuf; produces a base64url-encoded
+ * string (no padding) suitable for round-tripping through the API.
+ */
+function bufToB64(b: ArrayBuffer): string {
+ const bytes = new Uint8Array(b);
+ let s = '';
+ for (let i = 0; i < bytes.byteLength; i++) {
+ const ch = bytes[i] ?? 0;
+ s += String.fromCharCode(ch);
+ }
+ return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+}
+
+/**
+ * serializeAttestation converts the browser's PublicKeyCredential
+ * into the JSON shape the API library expects. The library reads
+ * the request body via r.Body and re-parses it; the field names
+ * here match the WebAuthn-spec JSON encoding.
+ */
+function serializeAttestation(c: PublicKeyCredential): Record {
+ const resp = c.response as AuthenticatorAttestationResponse;
+ return {
+ id: c.id,
+ rawId: bufToB64(c.rawId),
+ type: c.type,
+ response: {
+ attestationObject: bufToB64(resp.attestationObject),
+ clientDataJSON: bufToB64(resp.clientDataJSON),
+ },
+ clientExtensionResults: c.getClientExtensionResults?.() ?? {},
+ };
+}
diff --git a/apps/admin/src/app/(authenticated)/settings/account/components/PasskeyList.tsx b/apps/admin/src/app/(authenticated)/settings/account/components/PasskeyList.tsx
new file mode 100644
index 00000000..d3b3cb8d
--- /dev/null
+++ b/apps/admin/src/app/(authenticated)/settings/account/components/PasskeyList.tsx
@@ -0,0 +1,173 @@
+/**
+ * PasskeyList — client component rendering the signed-in user's
+ * passkeys and offering "Add passkey" + per-row "Remove" buttons.
+ *
+ * The whole subtree is a client component because navigator.credentials
+ * is unavailable during SSR and the list mutates in response to
+ * user action (add/remove). State management is local — useState +
+ * useEffect, no Zustand / React Query for now.
+ */
+'use client';
+
+import type { ReactElement } from 'react';
+import { useCallback, useEffect, useState } from 'react';
+import { KeyRound, Plus, Trash2 } from 'lucide-react';
+
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+
+import { deletePasskey, listPasskeys, registerPasskey } from '../api';
+import type { PasskeyView } from '../api';
+
+export function PasskeyList(): ReactElement {
+ const [rows, setRows] = useState(null);
+ const [name, setName] = useState('My passkey');
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+
+ const refresh = useCallback(async () => {
+ try {
+ const got = await listPasskeys();
+ setRows(got);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ }
+ }, []);
+
+ useEffect(() => {
+ void refresh();
+ }, [refresh]);
+
+ const onAdd = useCallback(async () => {
+ setBusy(true);
+ setError(null);
+ try {
+ await registerPasskey(name.trim() || 'Passkey');
+ await refresh();
+ setName('My passkey');
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ // "cancelled" is a normal user gesture, not an error worth
+ // shouting about — we surface it as a quieter line.
+ setError(msg === 'cancelled' ? 'Registration cancelled.' : msg);
+ } finally {
+ setBusy(false);
+ }
+ }, [name, refresh]);
+
+ const onDelete = useCallback(
+ async (id: string) => {
+ setBusy(true);
+ setError(null);
+ try {
+ await deletePasskey(id);
+ await refresh();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setBusy(false);
+ }
+ },
+ [refresh],
+ );
+
+ return (
+
+
+
+
+ Passkeys
+
+
+ Sign in with a hardware key, a passkey on your phone, or a
+ platform authenticator. We never see the underlying credential —
+ only the public key.
+
- The block editor opens in a focus mode — title and body live
- there. This metadata surface stays here for quick edits.
+ Compose your post by adding blocks below. Changes are
+ autosaved every 30 seconds; "Save changes" promotes the
+ autosave to the canonical row.