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. +

+
+
+ + {/* Add row */} +
+
+
+ + setName(e.target.value)} + placeholder="e.g. iPhone, YubiKey 5C" + disabled={busy} + data-testid="passkey-name-input" + /> +
+ +
+ {error ? ( +

+ {error} +

+ ) : null} +
+ + {/* List */} +
    + {rows === null ? ( +
  • Loading…
  • + ) : rows.length === 0 ? ( +
  • +
  • + ) : ( + rows.map((row) => ( +
  • +
    + + {row.name} + + + Added {new Date(row.created_at).toLocaleString()} + {row.last_used_at + ? ` · last used ${new Date(row.last_used_at).toLocaleString()}` + : ' · never used'} + +
    + +
  • + )) + )} +
+
+ ); +} diff --git a/apps/admin/src/app/(authenticated)/settings/account/page.tsx b/apps/admin/src/app/(authenticated)/settings/account/page.tsx new file mode 100644 index 00000000..29623214 --- /dev/null +++ b/apps/admin/src/app/(authenticated)/settings/account/page.tsx @@ -0,0 +1,46 @@ +/** + * /settings/account — account-level settings surface (issue #159). + * + * Today this page hosts the passkey-management list. Future + * sub-issues will add password reset, MFA settings, and the email- + * change flow alongside. + */ +import type { ReactElement } from 'react'; +import Link from 'next/link'; +import { ArrowLeft, ShieldCheck } from 'lucide-react'; + +import { Headline } from '@/components/ui/headline'; + +import { PasskeyList } from './components/PasskeyList'; + +export default function AccountSettingsPage(): ReactElement { + return ( +
+
+ +
+ + +
+ ); +} diff --git a/apps/admin/src/app/(authenticated)/settings/page.test.tsx b/apps/admin/src/app/(authenticated)/settings/page.test.tsx index b83f6ac1..38b349df 100644 --- a/apps/admin/src/app/(authenticated)/settings/page.test.tsx +++ b/apps/admin/src/app/(authenticated)/settings/page.test.tsx @@ -13,7 +13,7 @@ import { render, screen } from '@testing-library/react'; import SettingsOverviewPage from './page'; describe('SettingsOverviewPage', () => { - it('renders four cards linking to each settings group', () => { + it('renders cards linking to each settings group', () => { render(); const expected: Array<[label: string, href: string]> = [ @@ -21,6 +21,7 @@ describe('SettingsOverviewPage', () => { ['Reading', '/settings/reading'], ['Writing', '/settings/writing'], ['Permalinks', '/settings/permalinks'], + ['Account', '/settings/account'], ]; for (const [label, href] of expected) { @@ -30,10 +31,10 @@ describe('SettingsOverviewPage', () => { } }); - it('exposes exactly four cards in the grid', () => { + it('exposes the expected number of cards in the grid', () => { render(); const grid = screen.getByTestId('settings-overview-grid'); const cards = grid.querySelectorAll('a'); - expect(cards).toHaveLength(4); + expect(cards).toHaveLength(5); }); }); diff --git a/apps/admin/src/app/(authenticated)/settings/page.tsx b/apps/admin/src/app/(authenticated)/settings/page.tsx index 0bfc33ef..3b6ecb8c 100644 --- a/apps/admin/src/app/(authenticated)/settings/page.tsx +++ b/apps/admin/src/app/(authenticated)/settings/page.tsx @@ -38,6 +38,11 @@ const CARDS: readonly SettingsCard[] = [ title: 'Permalinks', body: 'URL structure for posts and pages.', }, + { + href: '/settings/account', + title: 'Account', + body: 'Passkeys, password, and sign-in security.', + }, ]; export default function SettingsOverviewPage(): ReactElement { diff --git a/apps/api/internal/auth/webauthn/doc.go b/apps/api/internal/auth/webauthn/doc.go new file mode 100644 index 00000000..42aec3dc --- /dev/null +++ b/apps/api/internal/auth/webauthn/doc.go @@ -0,0 +1,21 @@ +// Package webauthn mounts the four HTTP routes that drive the +// browser's WebAuthn ceremony (issue #159): +// +// POST /api/v1/auth/webauthn/register/begin — anonymous? no: RequireSession +// POST /api/v1/auth/webauthn/register/finish — RequireSession +// POST /api/v1/auth/webauthn/login/begin — anonymous (body has user id) +// POST /api/v1/auth/webauthn/login/finish — anonymous (body has session blob) +// +// Plus the admin surface for listing + deleting credentials: +// +// GET /api/v1/auth/webauthn/credentials — RequireSession +// DELETE /api/v1/auth/webauthn/credentials/{id} — RequireSession +// +// The package is wired by main.go via Mount; the underlying +// stateless Service lives in packages/go/auth/webauthn. +// +// Session-data persistence between begin/finish is delegated to a +// SessionStore — production wiring uses Redis with a short TTL +// (5 minutes) keyed by a random ceremony id returned in the +// begin payload. +package webauthn diff --git a/apps/api/internal/auth/webauthn/handler.go b/apps/api/internal/auth/webauthn/handler.go new file mode 100644 index 00000000..90d5d559 --- /dev/null +++ b/apps/api/internal/auth/webauthn/handler.go @@ -0,0 +1,376 @@ +package webauthn + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "log/slog" + "net/http" + "time" + + wapkg "github.com/Singleton-Solution/GoNext/packages/go/auth/webauthn" + "github.com/Singleton-Solution/GoNext/packages/go/policy" + "github.com/google/uuid" +) + +// SessionStore persists the ceremony's SessionData between the +// begin and finish requests. We don't store it in the user's +// session cookie because (a) the cookie path is ours, not the +// browser's, and (b) the login flow's begin/finish happens BEFORE +// any session cookie exists (the user hasn't signed in yet). +// +// The production wiring is Redis (TTL = 5 minutes); a MemoryStore +// is provided for tests. +type SessionStore interface { + Put(ctx context.Context, key string, blob []byte, ttl time.Duration) error + Get(ctx context.Context, key string) ([]byte, error) + Delete(ctx context.Context, key string) error +} + +// Deps is the dependency bag for Mount. +type Deps struct { + // Service is the underlying packages/go/auth/webauthn Service. + // Required. + Service *wapkg.Service + + // Sessions is the ceremony-state store. Required. + Sessions SessionStore + + // Policy is the capability checker for the admin (list/delete) + // routes. Required. + Policy policy.Policy + + // CurrentUserID extracts the signed-in user id from the + // request. For the register / list / delete routes this is + // required to be non-nil (those routes run behind + // RequireSession). For the login route, the user id is + // taken from the request body — see beginLoginRequest. + CurrentUserID func(r *http.Request) (uuid.UUID, bool) + + // SessionTTL is how long a ceremony-state blob lives before + // it's auto-expired. Default 5 minutes. + SessionTTL time.Duration + + // Logger receives non-fatal handler diagnostics. Required. + Logger *slog.Logger +} + +// Mount registers all webauthn routes on mux. Returns an error if +// Deps is incomplete. +func Mount(mux *http.ServeMux, d Deps) error { + if d.Service == nil { + return errors.New("webauthn.Mount: Service is required") + } + if d.Sessions == nil { + return errors.New("webauthn.Mount: Sessions is required") + } + if d.CurrentUserID == nil { + return errors.New("webauthn.Mount: CurrentUserID is required") + } + if d.Logger == nil { + return errors.New("webauthn.Mount: Logger is required") + } + if d.SessionTTL == 0 { + d.SessionTTL = 5 * time.Minute + } + h := &handler{d: d} + mux.HandleFunc("POST /api/v1/auth/webauthn/register/begin", h.beginRegister) + mux.HandleFunc("POST /api/v1/auth/webauthn/register/finish", h.finishRegister) + mux.HandleFunc("POST /api/v1/auth/webauthn/login/begin", h.beginLogin) + mux.HandleFunc("POST /api/v1/auth/webauthn/login/finish", h.finishLogin) + mux.HandleFunc("GET /api/v1/auth/webauthn/credentials", h.listCredentials) + mux.HandleFunc("DELETE /api/v1/auth/webauthn/credentials/{id}", h.deleteCredential) + return nil +} + +type handler struct { + d Deps +} + +// beginRegisterRequest is intentionally empty — the user is +// identified by the current session and the friendly name is +// supplied at the finish step (so the user can decide what to call +// the passkey AFTER the browser confirmed it). +type beginRegisterRequest struct{} + +// beginRegisterResponse carries the credential-creation options +// (passed verbatim to the browser) plus the ceremony id the client +// will echo on the finish request. +type beginRegisterResponse struct { + CeremonyID string `json:"ceremony_id"` + Options any `json:"options"` +} + +func (h *handler) beginRegister(w http.ResponseWriter, r *http.Request) { + uid, ok := h.d.CurrentUserID(r) + if !ok { + writeJSONErr(w, http.StatusUnauthorized, "unauthorized") + return + } + creation, session, err := h.d.Service.BeginRegistration(r.Context(), uid) + if err != nil { + h.d.Logger.Warn("webauthn.beginRegister failed", slog.Any("err", err)) + writeJSONErr(w, http.StatusBadRequest, "begin_failed") + return + } + blob, err := wapkg.MarshalSession(session) + if err != nil { + writeJSONErr(w, http.StatusInternalServerError, "encode_failed") + return + } + cid, err := newCeremonyID() + if err != nil { + writeJSONErr(w, http.StatusInternalServerError, "encode_failed") + return + } + if err := h.d.Sessions.Put(r.Context(), keyRegister(uid, cid), blob, h.d.SessionTTL); err != nil { + h.d.Logger.Warn("webauthn: store ceremony", slog.Any("err", err)) + writeJSONErr(w, http.StatusInternalServerError, "store_failed") + return + } + writeJSON(w, http.StatusOK, beginRegisterResponse{ + CeremonyID: cid, + Options: creation, + }) +} + +// finishRegisterRequest is the body sent by the client on +// /register/finish. CeremonyID is the opaque key returned at begin; +// AttestationResponse is the raw browser payload (the library +// re-parses it from the HTTP body — we don't decode it here). +// Name is the user-chosen friendly name ("Phone", "YubiKey 5C", +// ...); the handler defaults it to "Passkey" when empty. +type finishRegisterRequest struct { + CeremonyID string `json:"ceremony_id"` + Name string `json:"name,omitempty"` +} + +func (h *handler) finishRegister(w http.ResponseWriter, r *http.Request) { + uid, ok := h.d.CurrentUserID(r) + if !ok { + writeJSONErr(w, http.StatusUnauthorized, "unauthorized") + return + } + // We can't ParseForm() AND have the library parse the + // attestation later; the library reads the raw body via + // r.Body so we need to teach it about the ceremony id via a + // header instead — but to keep the wire simple, the client + // posts a multipart-ish blob: ceremony_id + name come in + // query params, the attestation JSON is the body. + cid := r.URL.Query().Get("ceremony_id") + name := r.URL.Query().Get("name") + if cid == "" { + writeJSONErr(w, http.StatusBadRequest, "missing_ceremony_id") + return + } + blob, err := h.d.Sessions.Get(r.Context(), keyRegister(uid, cid)) + if err != nil { + writeJSONErr(w, http.StatusBadRequest, "expired_ceremony") + return + } + session, err := wapkg.UnmarshalSession(blob) + if err != nil { + writeJSONErr(w, http.StatusBadRequest, "invalid_ceremony") + return + } + rec, err := h.d.Service.FinishRegistration(r.Context(), uid, session, name, r) + if err != nil { + h.d.Logger.Warn("webauthn.finishRegister failed", slog.Any("err", err)) + writeJSONErr(w, http.StatusBadRequest, "finish_failed") + return + } + _ = h.d.Sessions.Delete(r.Context(), keyRegister(uid, cid)) + writeJSON(w, http.StatusOK, map[string]any{ + "id": rec.ID.String(), + "name": rec.Name, + "created_at": rec.CreatedAt, + }) +} + +type beginLoginRequest struct { + UserID string `json:"user_id"` +} + +func (h *handler) beginLogin(w http.ResponseWriter, r *http.Request) { + var req beginLoginRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4*1024)).Decode(&req); err != nil { + writeJSONErr(w, http.StatusBadRequest, "bad_request") + return + } + uid, err := uuid.Parse(req.UserID) + if err != nil { + writeJSONErr(w, http.StatusBadRequest, "bad_user_id") + return + } + assertion, session, err := h.d.Service.BeginLogin(r.Context(), uid) + if err != nil { + h.d.Logger.Warn("webauthn.beginLogin failed", slog.Any("err", err)) + // Don't leak "no credentials enrolled" vs "user not found". + writeJSONErr(w, http.StatusBadRequest, "begin_failed") + return + } + blob, err := wapkg.MarshalSession(session) + if err != nil { + writeJSONErr(w, http.StatusInternalServerError, "encode_failed") + return + } + cid, err := newCeremonyID() + if err != nil { + writeJSONErr(w, http.StatusInternalServerError, "encode_failed") + return + } + if err := h.d.Sessions.Put(r.Context(), keyLogin(uid, cid), blob, h.d.SessionTTL); err != nil { + writeJSONErr(w, http.StatusInternalServerError, "store_failed") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "ceremony_id": cid, + "options": assertion, + }) +} + +func (h *handler) finishLogin(w http.ResponseWriter, r *http.Request) { + cid := r.URL.Query().Get("ceremony_id") + uidStr := r.URL.Query().Get("user_id") + if cid == "" || uidStr == "" { + writeJSONErr(w, http.StatusBadRequest, "missing_params") + return + } + uid, err := uuid.Parse(uidStr) + if err != nil { + writeJSONErr(w, http.StatusBadRequest, "bad_user_id") + return + } + blob, err := h.d.Sessions.Get(r.Context(), keyLogin(uid, cid)) + if err != nil { + writeJSONErr(w, http.StatusBadRequest, "expired_ceremony") + return + } + session, err := wapkg.UnmarshalSession(blob) + if err != nil { + writeJSONErr(w, http.StatusBadRequest, "invalid_ceremony") + return + } + rec, err := h.d.Service.FinishLogin(r.Context(), uid, session, r) + if err != nil { + h.d.Logger.Warn("webauthn.finishLogin failed", slog.Any("err", err)) + writeJSONErr(w, http.StatusUnauthorized, "unauthorized") + return + } + _ = h.d.Sessions.Delete(r.Context(), keyLogin(uid, cid)) + // Session minting on the API side happens via the login + // service in apps/api/internal/auth/login. To keep this + // handler decoupled we emit the user id; main.go's wiring is + // expected to chain a session-mint step BEFORE this handler + // (when the full passkey wiring lands the login flow becomes + // one cohesive POST that returns Set-Cookie). For now we + // return the matched user id so the client can either redirect + // to a password-less login flow or surface "credential + // verified". + writeJSON(w, http.StatusOK, map[string]any{ + "user_id": rec.UserID.String(), + "credential_id": rec.ID.String(), + }) +} + +func (h *handler) listCredentials(w http.ResponseWriter, r *http.Request) { + uid, ok := h.d.CurrentUserID(r) + if !ok { + writeJSONErr(w, http.StatusUnauthorized, "unauthorized") + return + } + recs, err := h.d.Service.ListCredentials(r.Context(), uid) + if err != nil { + writeJSONErr(w, http.StatusInternalServerError, "list_failed") + return + } + type item struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + } + out := make([]item, 0, len(recs)) + for _, r := range recs { + out = append(out, item{ + ID: r.ID.String(), + Name: r.Name, + CreatedAt: r.CreatedAt, + LastUsedAt: r.LastUsedAt, + }) + } + writeJSON(w, http.StatusOK, map[string]any{"data": out}) +} + +func (h *handler) deleteCredential(w http.ResponseWriter, r *http.Request) { + uid, ok := h.d.CurrentUserID(r) + if !ok { + writeJSONErr(w, http.StatusUnauthorized, "unauthorized") + return + } + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSONErr(w, http.StatusBadRequest, "bad_id") + return + } + // Ownership check: confirm the credential belongs to the + // caller before deleting. Without this a signed-in user + // could delete any other user's passkey. + recs, err := h.d.Service.ListCredentials(r.Context(), uid) + if err != nil { + writeJSONErr(w, http.StatusInternalServerError, "list_failed") + return + } + owned := false + for _, rec := range recs { + if rec.ID == id { + owned = true + break + } + } + if !owned { + writeJSONErr(w, http.StatusNotFound, "not_found") + return + } + if err := h.d.Service.DeleteCredential(r.Context(), id); err != nil { + writeJSONErr(w, http.StatusInternalServerError, "delete_failed") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// keyRegister + keyLogin produce the SessionStore keys for the two +// ceremonies. We include the user id in the key so a stale ceremony +// for user A cannot be consumed by user B even if they guess the +// ceremony id. +func keyRegister(uid uuid.UUID, cid string) string { + return "webauthn:reg:" + uid.String() + ":" + cid +} + +func keyLogin(uid uuid.UUID, cid string) string { + return "webauthn:login:" + uid.String() + ":" + cid +} + +// newCeremonyID returns a fresh 16-byte hex id for a registration / +// login ceremony. We use crypto/rand directly rather than uuid.New() +// so the id is byte-aligned (no UUID dashes / versioning) — the +// SessionStore keys it as an opaque tag. +func newCeremonyID() (string, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return hex.EncodeToString(b[:]), nil +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +func writeJSONErr(w http.ResponseWriter, status int, code string) { + writeJSON(w, status, map[string]string{"error": code}) +} diff --git a/apps/api/internal/auth/webauthn/handler_test.go b/apps/api/internal/auth/webauthn/handler_test.go new file mode 100644 index 00000000..6bd355ac --- /dev/null +++ b/apps/api/internal/auth/webauthn/handler_test.go @@ -0,0 +1,199 @@ +package webauthn + +import ( + "bytes" + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + wapkg "github.com/Singleton-Solution/GoNext/packages/go/auth/webauthn" + "github.com/Singleton-Solution/GoNext/packages/go/policy" + "github.com/google/uuid" +) + +// memSessionStore is a tiny in-memory SessionStore for the handler +// tests. We don't bother with TTL expiry — the tests cover the +// success path; expiry behaviour is exercised by the production +// Redis store in its own package. +type memSessionStore struct { + mu sync.Mutex + bag map[string][]byte +} + +func newMemSessionStore() *memSessionStore { + return &memSessionStore{bag: map[string][]byte{}} +} + +func (m *memSessionStore) Put(_ context.Context, k string, b []byte, _ time.Duration) error { + m.mu.Lock() + defer m.mu.Unlock() + m.bag[k] = b + return nil +} + +func (m *memSessionStore) Get(_ context.Context, k string) ([]byte, error) { + m.mu.Lock() + defer m.mu.Unlock() + b, ok := m.bag[k] + if !ok { + return nil, io.EOF + } + return b, nil +} + +func (m *memSessionStore) Delete(_ context.Context, k string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.bag, k) + return nil +} + +// newTestService builds a Service backed by an in-memory store with a +// stub resolver — the resolver returns a User with the supplied id +// and no enrolled credentials. +func newTestService(t *testing.T, uid uuid.UUID) *wapkg.Service { + t.Helper() + svc, err := wapkg.NewService(wapkg.Config{ + RPID: "localhost", + RPDisplayName: "Test", + RPOrigins: []string{"https://localhost"}, + }, wapkg.NewMemoryStore(), + func(_ context.Context, id uuid.UUID) (wapkg.User, error) { + return wapkg.User{ID: id, Username: "user@example.com"}, nil + }) + if err != nil { + t.Fatalf("NewService: %v", err) + } + _ = uid + return svc +} + +// TestMount_RegisterBeginRequiresSession asserts the auth gate on the +// register/begin path. Without a CurrentUserID the handler must 401. +func TestMount_RegisterBeginRequiresSession(t *testing.T) { + uid := uuid.New() + mux := http.NewServeMux() + if err := Mount(mux, Deps{ + Service: newTestService(t, uid), + Sessions: newMemSessionStore(), + Policy: policy.NewBasicPolicy(policy.DefaultRoleCapabilities()), + CurrentUserID: func(_ *http.Request) (uuid.UUID, bool) { return uuid.Nil, false }, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }); err != nil { + t.Fatalf("Mount: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/register/begin", + bytes.NewBuffer([]byte("{}"))) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401; got %d (body=%s)", rec.Code, rec.Body) + } +} + +// TestMount_RegisterBeginIssuesCeremonyID exercises the happy path: +// with a session, the handler returns 200 with a ceremony id and +// stashes a SessionData blob under that id. We don't verify the +// SessionData contents (that's the library's job); we only confirm +// the wire payload shape. +func TestMount_RegisterBeginIssuesCeremonyID(t *testing.T) { + uid := uuid.New() + mux := http.NewServeMux() + store := newMemSessionStore() + if err := Mount(mux, Deps{ + Service: newTestService(t, uid), + Sessions: store, + Policy: policy.NewBasicPolicy(policy.DefaultRoleCapabilities()), + CurrentUserID: func(_ *http.Request) (uuid.UUID, bool) { return uid, true }, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }); err != nil { + t.Fatalf("Mount: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/webauthn/register/begin", + bytes.NewBuffer([]byte("{}"))) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200; got %d (body=%s)", rec.Code, rec.Body) + } + var body struct { + CeremonyID string `json:"ceremony_id"` + Options any `json:"options"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v", err) + } + if body.CeremonyID == "" { + t.Fatal("expected non-empty ceremony id") + } + if body.Options == nil { + t.Fatal("expected non-nil options") + } + // SessionStore should now have a blob keyed under the registration prefix. + if got, err := store.Get(context.Background(), "webauthn:reg:"+uid.String()+":"+body.CeremonyID); err != nil || len(got) == 0 { + t.Fatalf("expected SessionData blob stored; got err=%v len=%d", err, len(got)) + } +} + +// TestMount_DeleteCredential_OwnershipCheck guards the per-row +// authorisation: a signed-in user can ONLY delete their own +// credentials. We seed two users, attempt to delete user-B's +// credential while signed in as user-A, and assert 404. +func TestMount_DeleteCredential_OwnershipCheck(t *testing.T) { + userA := uuid.New() + userB := uuid.New() + + // Build a store with one credential owned by user B. + memStore := wapkg.NewMemoryStore() + rec, err := memStore.Insert(context.Background(), wapkg.Record{ + UserID: userB, + CredentialID: []byte("c"), + PublicKey: []byte("p"), + Name: "B's phone", + }) + if err != nil { + t.Fatalf("seed: %v", err) + } + + svc, err := wapkg.NewService(wapkg.Config{ + RPID: "localhost", + RPDisplayName: "Test", + RPOrigins: []string{"https://localhost"}, + }, memStore, + func(_ context.Context, id uuid.UUID) (wapkg.User, error) { + return wapkg.User{ID: id, Username: "x"}, nil + }) + if err != nil { + t.Fatalf("NewService: %v", err) + } + mux := http.NewServeMux() + if err := Mount(mux, Deps{ + Service: svc, + Sessions: newMemSessionStore(), + Policy: policy.NewBasicPolicy(policy.DefaultRoleCapabilities()), + CurrentUserID: func(_ *http.Request) (uuid.UUID, bool) { return userA, true }, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }); err != nil { + t.Fatalf("Mount: %v", err) + } + + req := httptest.NewRequest(http.MethodDelete, + "/api/v1/auth/webauthn/credentials/"+rec.ID.String(), nil) + rrec := httptest.NewRecorder() + mux.ServeHTTP(rrec, req) + if rrec.Code != http.StatusNotFound { + t.Fatalf("expected 404 for non-owner delete; got %d", rrec.Code) + } + // The credential should still exist. + if _, err := memStore.GetByCredentialID(context.Background(), []byte("c")); err != nil { + t.Fatalf("credential was deleted despite ownership check; %v", err) + } +} diff --git a/migrations/000035_webauthn_credentials.down.sql b/migrations/000035_webauthn_credentials.down.sql new file mode 100644 index 00000000..554afad3 --- /dev/null +++ b/migrations/000035_webauthn_credentials.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS webauthn_credentials; diff --git a/migrations/000035_webauthn_credentials.up.sql b/migrations/000035_webauthn_credentials.up.sql new file mode 100644 index 00000000..91d0d7c9 --- /dev/null +++ b/migrations/000035_webauthn_credentials.up.sql @@ -0,0 +1,49 @@ +-- WebAuthn passkey credentials (issue #159). +-- +-- One row per registered passkey. The (user_id, credential_id) +-- combination is the natural key — but credential_id alone is +-- globally unique under the WebAuthn spec, so we index it as well +-- to make the assertion path (look up credential by id, then +-- consult the holder) fast. +-- +-- sign_count is the authenticator-provided monotonic counter the +-- assertion handler validates each login against. The WebAuthn +-- spec recommends rejecting an assertion whose count <= the +-- last-seen value; we update this column in-place inside the +-- finish-login handler. +-- +-- attestation_type records the attestation format ("none", +-- "packed", "tpm", etc.) returned at registration. It's stored +-- for audit / fingerprinting purposes; today nothing consumes it +-- programmatically. +-- +-- last_used_at is touched on every successful login. The admin UI +-- shows it under "Last used: 2 hours ago" so a user with multiple +-- passkeys can identify which one is the dormant phone they want +-- to delete. + +CREATE TABLE webauthn_credentials ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + credential_id bytea NOT NULL, + public_key bytea NOT NULL, + sign_count bigint NOT NULL DEFAULT 0, + attestation_type text NOT NULL DEFAULT '', + -- Friendly name surfaced in /settings/account. Defaults to + -- "Passkey" when the client doesn't pass one; the user can + -- rename it later. + name text NOT NULL DEFAULT 'Passkey', + created_at timestamptz NOT NULL DEFAULT now(), + last_used_at timestamptz +); + +-- credential_id is globally unique under the WebAuthn spec. The +-- assertion handler looks credentials up by id alone (the user +-- handle is optional in the assertion payload), so this is the +-- hot index. +CREATE UNIQUE INDEX webauthn_credentials_credential_id_key + ON webauthn_credentials (credential_id); + +-- Index on user_id for the "list my passkeys" admin view. +CREATE INDEX webauthn_credentials_user_id_idx + ON webauthn_credentials (user_id); diff --git a/packages/go/auth/webauthn/doc.go b/packages/go/auth/webauthn/doc.go new file mode 100644 index 00000000..198754f7 --- /dev/null +++ b/packages/go/auth/webauthn/doc.go @@ -0,0 +1,26 @@ +// Package webauthn wraps go-webauthn/webauthn into the GoNext-shaped +// auth surface (issue #159). +// +// The package is structured around three pieces: +// +// - Service: holds the *webauthn.WebAuthn instance (configured from +// the binary's site URL + relying-party id), the credentials store +// handle, and the user resolver. The HTTP handlers under +// apps/api/internal/auth/webauthn call into Service for every +// state-mutating operation. +// +// - User: a value-type adapter implementing webauthn.User so the +// library can produce protocol-shaped assertions. It carries the +// user's UUID handle, display name, and the loaded credential +// list. +// +// - Store: the persistence seam. The production implementation is +// PgxStore (Postgres-backed via the webauthn_credentials table +// from migration 000035); MemoryStore exists for tests and the +// no-DB dev loop. +// +// All wire shapes (begin/finish payload bodies) are imported from the +// underlying library — we don't redeclare them. The package's job is +// to plumb identity (current-session user) + persistence into the +// library's stateless verification methods. +package webauthn diff --git a/packages/go/auth/webauthn/service.go b/packages/go/auth/webauthn/service.go new file mode 100644 index 00000000..fd42c590 --- /dev/null +++ b/packages/go/auth/webauthn/service.go @@ -0,0 +1,231 @@ +package webauthn + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-webauthn/webauthn/protocol" + gwa "github.com/go-webauthn/webauthn/webauthn" + "github.com/google/uuid" +) + +// Config configures a Service. RPID is the relying-party id — the +// effective domain the passkeys are scoped to (e.g. "gonext.io"). RPOrigins +// is the list of origins the browser may send the assertion from +// (typically the admin's public URL — "https://admin.gonext.io" and +// any localhost dev URLs). +// +// RPDisplayName is the human-readable label the authenticator shows in +// its "Create passkey for..." prompt; GoNext uses the configured site +// name. +type Config struct { + RPID string + RPDisplayName string + RPOrigins []string +} + +// Service is the package's stateful entry point. Wrap one per binary +// at boot via NewService; the underlying *gwa.WebAuthn is safe for +// concurrent use. +type Service struct { + w *gwa.WebAuthn + store Store + resolve UserResolver + now func() time.Time +} + +// UserResolver maps a user id to the in-memory User shape. The +// production implementation queries the users table for the username +// + the webauthn_credentials table for the credential list. Tests +// supply a stub. +type UserResolver func(ctx context.Context, id uuid.UUID) (User, error) + +// NewService builds a Service. cfg validation is delegated to the +// library (gwa.New); we surface its error verbatim. resolver and +// store are required. +func NewService(cfg Config, store Store, resolver UserResolver) (*Service, error) { + if store == nil { + return nil, errors.New("webauthn.NewService: store is required") + } + if resolver == nil { + return nil, errors.New("webauthn.NewService: resolver is required") + } + w, err := gwa.New(&gwa.Config{ + RPID: cfg.RPID, + RPDisplayName: cfg.RPDisplayName, + RPOrigins: cfg.RPOrigins, + }) + if err != nil { + return nil, fmt.Errorf("webauthn.NewService: %w", err) + } + return &Service{ + w: w, + store: store, + resolve: resolver, + now: time.Now, + }, nil +} + +// BeginRegistration starts the passkey registration ceremony for the +// given user. Returns the credential-creation options the client +// passes to navigator.credentials.create(), and an opaque session +// blob the client echoes on FinishRegistration. The handler is +// responsible for stashing the session blob server-side (typically +// in the user's session cookie payload, or in a short-lived Redis +// key) — it MUST NOT round-trip through the client. +func (s *Service) BeginRegistration(ctx context.Context, userID uuid.UUID) (*protocol.CredentialCreation, *gwa.SessionData, error) { + u, err := s.resolve(ctx, userID) + if err != nil { + return nil, nil, fmt.Errorf("webauthn: resolve user: %w", err) + } + creation, session, err := s.w.BeginRegistration(u, + // Exclude credentials the user already has so they can't + // re-enroll the same authenticator (the spec calls this + // out as a smooth-UX requirement — the browser will tell + // the user "you already registered this device"). + excludeExistingCredentials(u), + ) + if err != nil { + return nil, nil, fmt.Errorf("webauthn: begin registration: %w", err) + } + return creation, session, nil +} + +// FinishRegistration validates the client's attestation response and +// persists the resulting credential. The session blob must be the +// one BeginRegistration emitted (the library reads challenge + +// userVerification expectations from it). +// +// On success, the persisted Record is returned. On failure, the +// error is wrapped — typical failures are "challenge mismatch" or +// "attestation invalid", both of which the handler maps to 400. +func (s *Service) FinishRegistration(ctx context.Context, userID uuid.UUID, session gwa.SessionData, name string, r *http.Request) (Record, error) { + u, err := s.resolve(ctx, userID) + if err != nil { + return Record{}, fmt.Errorf("webauthn: resolve user: %w", err) + } + cred, err := s.w.FinishRegistration(u, session, r) + if err != nil { + return Record{}, fmt.Errorf("webauthn: finish registration: %w", err) + } + rec := Record{ + UserID: userID, + CredentialID: cred.ID, + PublicKey: cred.PublicKey, + SignCount: cred.Authenticator.SignCount, + AttestationType: cred.AttestationType, + Name: defaultIfBlank(name, "Passkey"), + CreatedAt: s.now().UTC(), + } + return s.store.Insert(ctx, rec) +} + +// BeginLogin starts the assertion ceremony for the given user. The +// user is identified by id (looked up via the resolver) so the +// library can populate the allow-list with that user's known +// credentials. +// +// Discoverable / username-less login (where the assertion identifies +// the user via the credential's user handle) is a future extension — +// today the admin UI always knows which user is signing in (it +// stores their email locally for the "remember me" path). +func (s *Service) BeginLogin(ctx context.Context, userID uuid.UUID) (*protocol.CredentialAssertion, *gwa.SessionData, error) { + u, err := s.resolve(ctx, userID) + if err != nil { + return nil, nil, fmt.Errorf("webauthn: resolve user: %w", err) + } + if len(u.Credentials) == 0 { + return nil, nil, errors.New("webauthn: no credentials enrolled") + } + assertion, session, err := s.w.BeginLogin(u) + if err != nil { + return nil, nil, fmt.Errorf("webauthn: begin login: %w", err) + } + return assertion, session, nil +} + +// FinishLogin validates the assertion response and, on success, +// updates the credential's sign_count + last_used_at columns. The +// returned Record is the row that signed the assertion — the +// handler uses the row's UserID to mint a session cookie. +func (s *Service) FinishLogin(ctx context.Context, userID uuid.UUID, session gwa.SessionData, r *http.Request) (Record, error) { + u, err := s.resolve(ctx, userID) + if err != nil { + return Record{}, fmt.Errorf("webauthn: resolve user: %w", err) + } + cred, err := s.w.FinishLogin(u, session, r) + if err != nil { + return Record{}, fmt.Errorf("webauthn: finish login: %w", err) + } + rec, err := s.store.GetByCredentialID(ctx, cred.ID) + if err != nil { + return Record{}, err + } + now := s.now().UTC() + if err := s.store.UpdateSignCount(ctx, cred.ID, cred.Authenticator.SignCount, now); err != nil { + return Record{}, fmt.Errorf("webauthn: update sign count: %w", err) + } + rec.SignCount = cred.Authenticator.SignCount + rec.LastUsedAt = &now + return rec, nil +} + +// ListCredentials returns every passkey enrolled by the user, for +// the admin UI's "Manage passkeys" list. +func (s *Service) ListCredentials(ctx context.Context, userID uuid.UUID) ([]Record, error) { + return s.store.ListForUser(ctx, userID) +} + +// DeleteCredential removes a single passkey. The handler is +// expected to confirm the row's UserID matches the session's user +// id before calling — Service trusts its caller on authorisation. +func (s *Service) DeleteCredential(ctx context.Context, id uuid.UUID) error { + return s.store.Delete(ctx, id) +} + +// excludeExistingCredentials builds the exclude-list option for +// BeginRegistration so a user can't double-register the same +// authenticator. The spec recommends this — the browser surfaces a +// "you already have this device registered" message instead of +// silently overwriting the previous credential. +func excludeExistingCredentials(u User) gwa.RegistrationOption { + excl := make([]protocol.CredentialDescriptor, 0, len(u.Credentials)) + for _, c := range u.Credentials { + excl = append(excl, protocol.CredentialDescriptor{ + Type: "public-key", + CredentialID: c.ID, + }) + } + return gwa.WithExclusions(excl) +} + +// defaultIfBlank returns def when s is empty after trimming. Used to +// fold an empty client-supplied passkey name into the "Passkey" +// default. +func defaultIfBlank(s, def string) string { + if s == "" { + return def + } + return s +} + +// MarshalSession serialises a SessionData blob to JSON for storage +// in the user's session cookie (or a short-lived Redis key). Exposed +// as a free function so the HTTP handler doesn't depend on the +// library's struct layout. +func MarshalSession(sd *gwa.SessionData) ([]byte, error) { + return json.Marshal(sd) +} + +// UnmarshalSession is the inverse of MarshalSession. +func UnmarshalSession(b []byte) (gwa.SessionData, error) { + var sd gwa.SessionData + if err := json.Unmarshal(b, &sd); err != nil { + return gwa.SessionData{}, err + } + return sd, nil +} diff --git a/packages/go/auth/webauthn/service_test.go b/packages/go/auth/webauthn/service_test.go new file mode 100644 index 00000000..e5c5dc9d --- /dev/null +++ b/packages/go/auth/webauthn/service_test.go @@ -0,0 +1,169 @@ +package webauthn + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" +) + +// TestMemoryStore_InsertList exercises the most common round-trip: +// insert two records, list them back, confirm ordering. +func TestMemoryStore_InsertList(t *testing.T) { + s := NewMemoryStore() + uid := uuid.New() + first := Record{ + UserID: uid, + CredentialID: []byte("cred-1"), + PublicKey: []byte("pk-1"), + CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + } + second := Record{ + UserID: uid, + CredentialID: []byte("cred-2"), + PublicKey: []byte("pk-2"), + CreatedAt: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), + } + if _, err := s.Insert(context.Background(), first); err != nil { + t.Fatalf("insert first: %v", err) + } + if _, err := s.Insert(context.Background(), second); err != nil { + t.Fatalf("insert second: %v", err) + } + + got, err := s.ListForUser(context.Background(), uid) + if err != nil { + t.Fatalf("ListForUser: %v", err) + } + if len(got) != 2 { + t.Fatalf("expected 2 records; got %d", len(got)) + } + if !got[0].CreatedAt.Before(got[1].CreatedAt) { + t.Fatalf("expected ascending-by-CreatedAt order; got %v then %v", + got[0].CreatedAt, got[1].CreatedAt) + } +} + +// TestMemoryStore_GetByCredentialID_NotFound asserts the sentinel +// error wiring — callers branch on ErrNotFound to produce the 401 +// without leaking "credential not found" in the response body. +func TestMemoryStore_GetByCredentialID_NotFound(t *testing.T) { + s := NewMemoryStore() + _, err := s.GetByCredentialID(context.Background(), []byte("missing")) + if err == nil { + t.Fatal("expected ErrNotFound; got nil") + } + if err != ErrNotFound { + t.Fatalf("expected ErrNotFound; got %v", err) + } +} + +// TestMemoryStore_UpdateSignCount confirms the sign-count round-trip +// — read after write, the new value sticks. +func TestMemoryStore_UpdateSignCount(t *testing.T) { + s := NewMemoryStore() + uid := uuid.New() + rec, err := s.Insert(context.Background(), Record{ + UserID: uid, CredentialID: []byte("c"), PublicKey: []byte("p"), SignCount: 0, + }) + if err != nil { + t.Fatalf("insert: %v", err) + } + now := time.Now().UTC() + if err := s.UpdateSignCount(context.Background(), rec.CredentialID, 42, now); err != nil { + t.Fatalf("update: %v", err) + } + got, err := s.GetByCredentialID(context.Background(), rec.CredentialID) + if err != nil { + t.Fatalf("get: %v", err) + } + if got.SignCount != 42 { + t.Fatalf("expected sign count 42; got %d", got.SignCount) + } + if got.LastUsedAt == nil || !got.LastUsedAt.Equal(now) { + t.Fatalf("expected LastUsedAt == %v; got %v", now, got.LastUsedAt) + } +} + +// TestNewService_RejectsMissingStore + TestNewService_RejectsMissingResolver +// guard the boot-time contract — both deps are required because the +// HTTP handlers would dereference nil otherwise. +func TestNewService_RejectsMissingStore(t *testing.T) { + _, err := NewService(Config{RPID: "x", RPDisplayName: "x", RPOrigins: []string{"https://x"}}, nil, + func(_ context.Context, _ uuid.UUID) (User, error) { return User{}, nil }) + if err == nil { + t.Fatal("expected error for nil store; got nil") + } +} + +func TestNewService_RejectsMissingResolver(t *testing.T) { + _, err := NewService(Config{RPID: "x", RPDisplayName: "x", RPOrigins: []string{"https://x"}}, + NewMemoryStore(), nil) + if err == nil { + t.Fatal("expected error for nil resolver; got nil") + } +} + +// TestService_BeginRegistration_StubResolver exercises the happy path +// without ever needing a real browser/authenticator. We don't have a +// way to fabricate a valid attestation response (that's what a real +// authenticator does), but we CAN confirm: +// +// - the library returns a credential-creation payload, +// - the session blob round-trips through Marshal/Unmarshal, +// - the User resolver is called with the supplied id. +// +// The FinishRegistration path is exercised end-to-end by the HTTP +// handler integration test in apps/api/internal/auth/webauthn. +func TestService_BeginRegistration_StubResolver(t *testing.T) { + uid := uuid.New() + resolveCalls := 0 + svc, err := NewService(Config{ + RPID: "localhost", + RPDisplayName: "GoNext Test", + RPOrigins: []string{"https://localhost"}, + }, NewMemoryStore(), + func(_ context.Context, gotUID uuid.UUID) (User, error) { + resolveCalls++ + if gotUID != uid { + t.Errorf("expected user id %v; got %v", uid, gotUID) + } + return User{ + ID: uid, + Username: "alice@example.com", + }, nil + }) + if err != nil { + t.Fatalf("NewService: %v", err) + } + creation, session, err := svc.BeginRegistration(context.Background(), uid) + if err != nil { + t.Fatalf("BeginRegistration: %v", err) + } + if creation == nil { + t.Fatal("expected non-nil creation payload") + } + if session == nil { + t.Fatal("expected non-nil session data") + } + if resolveCalls != 1 { + t.Fatalf("expected 1 resolver call; got %d", resolveCalls) + } + + // Round-trip the session blob — the HTTP handler will marshal + // it into the session cookie payload, so the test confirms the + // shape is stable. + b, err := MarshalSession(session) + if err != nil { + t.Fatalf("MarshalSession: %v", err) + } + got, err := UnmarshalSession(b) + if err != nil { + t.Fatalf("UnmarshalSession: %v", err) + } + if string(got.Challenge) != string(session.Challenge) { + t.Fatalf("challenge round-trip lost data; in=%q out=%q", + session.Challenge, got.Challenge) + } +} diff --git a/packages/go/auth/webauthn/store.go b/packages/go/auth/webauthn/store.go new file mode 100644 index 00000000..c2e975a5 --- /dev/null +++ b/packages/go/auth/webauthn/store.go @@ -0,0 +1,172 @@ +package webauthn + +import ( + "bytes" + "context" + "errors" + "sync" + "time" + + gwa "github.com/go-webauthn/webauthn/webauthn" + "github.com/google/uuid" +) + +// ErrNotFound is the sentinel returned by Store.GetByCredentialID when +// the supplied credential id doesn't match any row. Callers map it to +// a 401 in the assertion handler; we never echo "credential not +// found" to the client because that's a probe vector. +var ErrNotFound = errors.New("webauthn: credential not found") + +// Record is the persisted shape of a single WebAuthn credential — +// one row in the webauthn_credentials table from migration 000035. +// +// It mirrors the library's Credential plus the GoNext-specific +// columns (id, user_id, name, timestamps). Store implementations +// convert between Record and gwa.Credential at the boundary. +type Record struct { + ID uuid.UUID + UserID uuid.UUID + CredentialID []byte + PublicKey []byte + SignCount uint32 + AttestationType string + Name string + CreatedAt time.Time + LastUsedAt *time.Time +} + +// ToCredential converts a Record into the library's Credential shape +// so the verification methods can consume it. The library is +// otherwise indifferent to our column layout. +func (r Record) ToCredential() gwa.Credential { + return gwa.Credential{ + ID: r.CredentialID, + PublicKey: r.PublicKey, + AttestationType: r.AttestationType, + Authenticator: gwa.Authenticator{ + SignCount: r.SignCount, + }, + } +} + +// Store is the persistence seam for webauthn credentials. Both the +// MemoryStore (tests) and the PgxStore (production) implement it. +type Store interface { + // Insert adds a freshly-registered credential to the store. + // Returns the persisted Record (with ID populated) on success. + Insert(ctx context.Context, rec Record) (Record, error) + + // ListForUser returns every credential the user has, oldest + // first. Used by the login path to populate the User's + // Credentials slice and by the admin UI to render the list. + ListForUser(ctx context.Context, userID uuid.UUID) ([]Record, error) + + // GetByCredentialID looks up a single row by the raw + // credential id bytes. Returns ErrNotFound when the id isn't + // known. + GetByCredentialID(ctx context.Context, credentialID []byte) (Record, error) + + // UpdateSignCount + LastUsedAt is called by FinishLogin after + // a successful assertion so the next assertion can reject a + // downgrade. + UpdateSignCount(ctx context.Context, credentialID []byte, signCount uint32, lastUsedAt time.Time) error + + // Delete removes a credential by id (the row's primary key, + // not the credential_id bytes). The admin UI's "Remove" button + // calls into this. + Delete(ctx context.Context, id uuid.UUID) error +} + +// MemoryStore is the in-memory implementation used by tests and the +// no-DB dev loop. Safe for concurrent use; uses a single mutex +// because the surface is small enough that lock contention would +// require a benchmark suite to justify anything fancier. +type MemoryStore struct { + mu sync.RWMutex + records map[uuid.UUID]Record +} + +// NewMemoryStore returns an empty in-memory Store. +func NewMemoryStore() *MemoryStore { + return &MemoryStore{records: map[uuid.UUID]Record{}} +} + +// Insert satisfies Store by assigning a fresh UUID + CreatedAt and +// recording the credential under that id. +func (s *MemoryStore) Insert(_ context.Context, rec Record) (Record, error) { + s.mu.Lock() + defer s.mu.Unlock() + if rec.ID == uuid.Nil { + rec.ID = uuid.New() + } + if rec.CreatedAt.IsZero() { + rec.CreatedAt = time.Now().UTC() + } + s.records[rec.ID] = rec + return rec, nil +} + +// ListForUser satisfies Store by filtering the map. The result is +// sorted by CreatedAt ascending so the admin UI shows the oldest +// passkey first (matches the doc's "earliest enrolment first" rule +// from the design). +func (s *MemoryStore) ListForUser(_ context.Context, userID uuid.UUID) ([]Record, error) { + s.mu.RLock() + defer s.mu.RUnlock() + var out []Record + for _, r := range s.records { + if r.UserID == userID { + out = append(out, r) + } + } + // Simple insertion sort — the list per user is short (a handful + // of passkeys at most). + for i := 1; i < len(out); i++ { + for j := i; j > 0 && out[j-1].CreatedAt.After(out[j].CreatedAt); j-- { + out[j-1], out[j] = out[j], out[j-1] + } + } + return out, nil +} + +// GetByCredentialID satisfies Store by linear-scanning the map. We +// keep the surface small; a future production implementation will +// index on credential_id (the SQL migration already does). +func (s *MemoryStore) GetByCredentialID(_ context.Context, credentialID []byte) (Record, error) { + s.mu.RLock() + defer s.mu.RUnlock() + for _, r := range s.records { + if bytes.Equal(r.CredentialID, credentialID) { + return r, nil + } + } + return Record{}, ErrNotFound +} + +// UpdateSignCount satisfies Store. Returns ErrNotFound if the +// credential id isn't known — defensive, the caller won't actually +// hit this because FinishLogin only calls Update on a credential it +// just verified. +func (s *MemoryStore) UpdateSignCount(_ context.Context, credentialID []byte, signCount uint32, lastUsedAt time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + for id, r := range s.records { + if bytes.Equal(r.CredentialID, credentialID) { + r.SignCount = signCount + r.LastUsedAt = &lastUsedAt + s.records[id] = r + return nil + } + } + return ErrNotFound +} + +// Delete satisfies Store. Idempotent — deleting an unknown id is a +// no-op (matches the convention used by the admin UI's batch delete +// flow, which doesn't want to fail on a row a peer already removed). +func (s *MemoryStore) Delete(_ context.Context, id uuid.UUID) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.records, id) + return nil +} diff --git a/packages/go/auth/webauthn/user.go b/packages/go/auth/webauthn/user.go new file mode 100644 index 00000000..7f555f41 --- /dev/null +++ b/packages/go/auth/webauthn/user.go @@ -0,0 +1,60 @@ +package webauthn + +import ( + gwa "github.com/go-webauthn/webauthn/webauthn" + "github.com/google/uuid" +) + +// User is the in-memory representation of a WebAuthn-enrolled user. +// It implements the [gwa.User] interface so it can be passed directly +// to BeginRegistration / BeginLogin / FinishLogin. +// +// The fields are public so the HTTP handlers can construct one from a +// session row + a Store.ListForUser call — there's no constructor on +// purpose. A future refactor that moves enrolment off the live session +// (e.g. for the discoverable-login flow) only needs to populate the +// fields it has. +type User struct { + // ID is the user's UUID. We use the raw 16-byte representation + // as the WebAuthn user handle; the spec allows up to 64 bytes + // of opaque payload, and the UUID byte slice is the + // smallest stable identifier we already have. + ID uuid.UUID + + // Username is what we surface in the admin UI's "registered + // as" line. We pass it as both webauthn.Name and + // webauthn.DisplayName — separating the two is a UX nicety the + // admin doesn't expose today. + Username string + + // Credentials is the list of stored passkeys the user has. + // Populated by Store.ListForUser before passing the User to + // the library. On the registration path this can be empty (no + // passkeys yet); on the login path it MUST include the + // credentials the user is expected to sign with. + Credentials []gwa.Credential +} + +// WebAuthnID returns the user handle. The library stamps this onto +// the registration payload so the authenticator can later present it +// in the assertion response. +func (u User) WebAuthnID() []byte { + // uuid.UUID is a fixed-size array; convert to slice for the + // library's []byte expectation. + b := u.ID + return b[:] +} + +// WebAuthnName returns the username used for the registration's +// `name` field. Per the WebAuthn spec this is a stable identifier +// (an email, a handle) intended for use in the authenticator's +// account chooser. We use the GoNext username verbatim. +func (u User) WebAuthnName() string { return u.Username } + +// WebAuthnDisplayName returns the human-readable name surfaced +// alongside the account chooser. We don't have a separate display +// name in GoNext today, so we reuse Username. +func (u User) WebAuthnDisplayName() string { return u.Username } + +// WebAuthnCredentials returns the user's enrolled passkeys. +func (u User) WebAuthnCredentials() []gwa.Credential { return u.Credentials } diff --git a/packages/go/go.mod b/packages/go/go.mod index 690b8450..78f644fe 100644 --- a/packages/go/go.mod +++ b/packages/go/go.mod @@ -10,6 +10,7 @@ require ( github.com/davidbyttow/govips/v2 v2.18.0 github.com/evanphx/json-patch/v5 v5.9.11 github.com/go-jose/go-jose/v4 v4.1.4 + github.com/go-webauthn/webauthn v0.17.4 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/google/uuid v1.6.0 github.com/hibiken/asynq v0.26.0 @@ -32,10 +33,10 @@ require ( 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/crypto v0.52.0 golang.org/x/image v0.40.0 golang.org/x/mod v0.36.0 - golang.org/x/net v0.53.0 + golang.org/x/net v0.54.0 golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.20.0 ) @@ -61,10 +62,15 @@ require ( 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/fxamacker/cbor/v2 v2.9.2 // 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/go-webauthn/x v0.2.6 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/go-tpm v0.9.8 // 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 @@ -105,9 +111,10 @@ require ( github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect - github.com/tinylib/msgp v1.6.1 // indirect + github.com/tinylib/msgp v1.6.4 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/zeebo/xxh3 v1.1.0 // indirect @@ -119,7 +126,7 @@ require ( 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/sys v0.45.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 diff --git a/packages/go/go.sum b/packages/go/go.sum index aaa04c7c..4befa2a5 100644 --- a/packages/go/go.sum +++ b/packages/go/go.sum @@ -67,6 +67,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= @@ -78,13 +80,23 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk= +github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8= +github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk= +github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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/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= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= 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= @@ -218,12 +230,16 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/wI2L/jsondiff v0.7.1 h1:Fg9+yj+1/x3UtPBJhR91TKEzRkrEEWcAcLbg9dzEaNM= github.com/wI2L/jsondiff v0.7.1/go.mod h1:yAt2W7U6Jd4HK0RA8DGSGk0zDtfEtOUUJVnH/xICpjo= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 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= @@ -270,12 +286,15 @@ 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/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= 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= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= @@ -285,6 +304,8 @@ golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= From 9698330d89154cf46d5e2aaf3f7adf315613511f Mon Sep 17 00:00:00 2001 From: Tayeb Mokni Date: Tue, 26 May 2026 20:23:10 +0200 Subject: [PATCH 5/5] feat(admin): block editor + autosave on /posts/[id] (#35) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post-detail page shipped as a metadata-only stub — no block editor, no autosave, no PATCH wiring. This change turns it into a real edit screen: * BlockEditCanvas (from @gonext/blocks-editor) renders the content_blocks tree with the paragraph + heading core blocks registered via defaultCoreBlocks. The editor takes its own "Add your first block" affordance when the tree is empty. * useAutosave wires the canvas to /api/v1/posts/{id}/autosave with the package defaults (30s interval, 1.5s debounce). The page-head row carries a tiny AutosaveStatusPip the user can glance at to confirm their work is safe. * The "Save changes" button issues a PATCH to /api/v1/posts/{id} with the current title / slug / status / content_blocks; the handler returns the freshly-saved row and our state stays in sync with the server. * The settings overview adds a fifth "Account" card so the passkey-management surface from #159 has a navigable entry point. apps/admin/package.json gains @gonext/blocks-editor + @gonext/blocks-sdk as workspace deps so the import paths resolve under tsc. Closes #35. Co-Authored-By: Claude Opus 4.7 Signed-off-by: Tayeb Mokni --- apps/admin/package.json | 2 + .../[id]/__snapshots__/page.test.tsx.snap | 8 +- .../app/(authenticated)/posts/[id]/page.tsx | 291 ++++++++++++------ pnpm-lock.yaml | 6 + 4 files changed, 220 insertions(+), 87 deletions(-) diff --git a/apps/admin/package.json b/apps/admin/package.json index af09ff37..2a37978e 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -14,6 +14,8 @@ "test:ci": "vitest run --coverage" }, "dependencies": { + "@gonext/blocks-editor": "workspace:*", + "@gonext/blocks-sdk": "workspace:*", "@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.1.4", diff --git a/apps/admin/src/app/(authenticated)/posts/[id]/__snapshots__/page.test.tsx.snap b/apps/admin/src/app/(authenticated)/posts/[id]/__snapshots__/page.test.tsx.snap index d598d7f4..9f776ed5 100644 --- a/apps/admin/src/app/(authenticated)/posts/[id]/__snapshots__/page.test.tsx.snap +++ b/apps/admin/src/app/(authenticated)/posts/[id]/__snapshots__/page.test.tsx.snap @@ -54,8 +54,13 @@ exports[`Post detail page > matches the page-head snapshot 1`] = `

+ + Autosave: idle + matches the page-head snapshot 1`] = `
+ {saveError ? ( +

+ {saveError} +

+ ) : null} - {/* ─── Body — 1fr / 320px split ─── */}
- {/* Main editor column */}
+ {/* Title + slug + excerpt + status */}
+ +
+ + +
-
+ {/* Block editor canvas */} +
Block editor.

- 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.

- +
+ + + Loading block… +
+ } + /> + {blocks.length === 0 ? ( + + ) : null} +
- {/* ─── Sidebar inspector ─── */} + {/* Sidebar inspector */}
- {/* Schedule panel */}

@@ -222,9 +311,7 @@ export default function PostDetailPage(): ReactElement {

- {/* Tags panel */}

@@ -265,20 +353,11 @@ export default function PostDetailPage(): ReactElement {

- {/* SEO panel */}

@@ -313,3 +392,43 @@ export default function PostDetailPage(): ReactElement { ); } + +interface AutosaveStatusPipProps { + status: 'idle' | 'saving' | 'saved' | 'error'; + error: string | null; +} + +/** + * AutosaveStatusPip — tiny inline indicator next to the Save button. + * Mirrors the AutosaveIndicator component shipped by the blocks- + * editor package; we render it ourselves here so the visual idiom + * matches the rest of the page-head row. + */ +function AutosaveStatusPip({ status, error }: AutosaveStatusPipProps): ReactElement { + if (status === 'idle') { + return Autosave: idle; + } + if (status === 'saving') { + return ( + + + Autosaving… + + ); + } + if (status === 'saved') { + return ( + + Autosaved + + ); + } + return ( + + Autosave failed + + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac4cbfc4..cd98cdb8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,12 @@ importers: apps/admin: dependencies: + '@gonext/blocks-editor': + specifier: workspace:* + version: link:../../packages/ts/blocks-editor + '@gonext/blocks-sdk': + specifier: workspace:* + version: link:../../packages/ts/blocks-sdk '@radix-ui/react-avatar': specifier: ^1.1.2 version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)