- 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.
@@ -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/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.
+
+
+ No passkeys yet. Add one above to sign in without a password.
+
+ ) : (
+ 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 (
+
+
+
+
+ Back to settings
+
+
+
+
+ Security
+
+
+ Your account.
+
+
+ Manage how you sign in. Passkeys are the recommended way —
+ they replace passwords with a hardware-bound key the site
+ never sees.
+
+
+
+
+
+
+ );
+}
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/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/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/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:])
+}
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 aadc29b5..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
@@ -28,10 +29,14 @@ 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
- golang.org/x/crypto v0.51.0
+ 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.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
)
@@ -43,6 +48,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
@@ -56,10 +62,16 @@ 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
github.com/jackc/puddle/v2 v2.2.2 // indirect
@@ -99,23 +111,27 @@ 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
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/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
+ 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..4befa2a5 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=
@@ -65,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=
@@ -76,15 +80,27 @@ 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=
+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=
@@ -214,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=
@@ -234,14 +254,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=
@@ -252,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=
@@ -267,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=
@@ -274,6 +313,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/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)
+ }
+}
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)
+ }
+}
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)