diff --git a/AGENTS.md b/AGENTS.md index 845a0dac2..fd7505dea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,237 +1,28 @@ # AGENTS.md -## Build & Development - -| Command | Purpose | -|---|---| -| `make build` | Build `bin/probod` (includes frontend apps and codegen) | -| `SKIP_APPS=1 make build` | Build `bin/probod` without frontend apps (faster for backend-only work) | -| `make test` | Run tests with race detection and coverage | -| `make test MODULE=./pkg/foo` | Run tests for a single module | -| `make test-verbose` | Tests with verbose output | -| `make lint` | Vet + Go lint + npm lint | -| `make fmt` | Format Go code | -| `make test-e2e` | Run console end-to-end tests (requires `bin/probod`) | -| `make deadcode` | Detect dead code — run after removing or renaming exported functions | -| `make stack-up` / `make stack-down` | Start / stop Docker compose infra | -| `make psql` | Open psql shell to dev database | - -GraphQL and MCP codegen is triggered by `go generate`: -- `go generate ./pkg/server/api/console/v1` -- `go generate ./pkg/server/api/connect/v1` -- `go generate ./pkg/server/api/trust/v1` -- `go generate ./pkg/server/api/mcp/v1` - -## Reference Documentation - Detailed guides for specific subsystems live in `contrib/claude/`: -- [`contrib/claude/app-arborescence.md`](contrib/claude/app-arborescence.md) — Frontend app folder layout (pages, routes, loaders, skeletons, `_components`) -- [`contrib/claude/authorization.md`](contrib/claude/authorization.md) — IAM policy-based authorization (policies, conditions, roles, AuthorizationAttributer) -- [`contrib/claude/cli.md`](contrib/claude/cli.md) — CLI command patterns (cobra, huh prompts, pagination, output formatting) -- [`contrib/claude/commit.md`](contrib/claude/commit.md) — Commit message conventions -- [`contrib/claude/coredata.md`](contrib/claude/coredata.md) — Data access layer (Scoper, SQL patterns, filters, order fields, migrations) -- [`contrib/claude/e2e.md`](contrib/claude/e2e.md) — End-to-end testing (factory builders, RBAC tests, tenant isolation, assertions) -- [`contrib/claude/go-service.md`](contrib/claude/go-service.md) — Go service orchestration (Run, graceful shutdown, crash propagation) -- [`contrib/claude/go-style.md`](contrib/claude/go-style.md) — Call expressions, multiline argument lists, layout conventions + +- [`contrib/claude/make.md`](contrib/claude/make.md) — GNUmakefile targets, codegen, overridable variables +- [`contrib/claude/api-surface.md`](contrib/claude/api-surface.md) — GraphQL / MCP / CLI / n8n sync rules +- [`contrib/claude/go-style.md`](contrib/claude/go-style.md) — Go project deps, style (declarations, calls, imports, errors, naming, logging) - [`contrib/claude/go-testing.md`](contrib/claude/go-testing.md) — Go test conventions (parallel, require vs assert, naming) +- [`contrib/claude/go-service.md`](contrib/claude/go-service.md) — Go service orchestration (Run, graceful shutdown, crash propagation) - [`contrib/claude/go-worker.md`](contrib/claude/go-worker.md) — Go worker pattern (poll-based, bounded concurrency, FOR UPDATE SKIP LOCKED) +- [`contrib/claude/gid.md`](contrib/claude/gid.md) — Global identifiers (GID layout, TenantID, entity type registry) +- [`contrib/claude/coredata.md`](contrib/claude/coredata.md) — Data access layer (Scoper, SQL patterns, filters, order fields, migrations) - [`contrib/claude/graphql.md`](contrib/claude/graphql.md) — Go GraphQL backend (gqlgen, @goModel, connection types, cursor pagination) -- [`contrib/claude/license.md`](contrib/claude/license.md) — ISC license header (all file types) - [`contrib/claude/mcp.md`](contrib/claude/mcp.md) — MCP API patterns (specification.yaml, mcpgen, resolvers, type helpers) -- [`contrib/claude/react-components.md`](contrib/claude/react-components.md) — React component shape (file/export, props, configure vs data via hooks) +- [`contrib/claude/cli.md`](contrib/claude/cli.md) — CLI command patterns (cobra, huh prompts, pagination, output formatting) +- [`contrib/claude/authorization.md`](contrib/claude/authorization.md) — IAM policy-based authorization (policies, conditions, roles, AuthorizationAttributer) +- [`contrib/claude/validation.md`](contrib/claude/validation.md) — Validation framework (fluent API, validators, error codes, propagation) +- [`contrib/claude/e2e.md`](contrib/claude/e2e.md) — End-to-end testing (factory builders, RBAC tests, tenant isolation, assertions) +- [`contrib/claude/agent.md`](contrib/claude/agent.md) — Agent orchestration framework (tools, handoffs, execution) +- [`contrib/claude/app-arborescence.md`](contrib/claude/app-arborescence.md) — Frontend app folder layout (pages, routes, loaders, skeletons, _components) - [`contrib/claude/relay.md`](contrib/claude/relay.md) — Frontend Relay client (queries, fragments, mutations, pagination) -- [`contrib/claude/ui.md`](contrib/claude/ui.md) — `@probo/ui`, Tailwind, tailwind-variants, folders, skeletons, compound components +- [`contrib/claude/react-components.md`](contrib/claude/react-components.md) — React component shape (file/export, props, configure vs data via hooks) +- [`contrib/claude/ui.md`](contrib/claude/ui.md) — @probo/ui, Tailwind, tailwind-variants, folders, skeletons, compound components +- [`contrib/claude/commit.md`](contrib/claude/commit.md) — Commit message conventions +- [`contrib/claude/license.md`](contrib/claude/license.md) — ISC license header (all file types) - [`contrib/claude/release.md`](contrib/claude/release.md) — Release process (version bump, changelog, tag, push) - [`contrib/claude/sandbox.md`](contrib/claude/sandbox.md) — Lima sandbox environments (create, manage, access services) -- [`contrib/claude/validation.md`](contrib/claude/validation.md) — Validation framework (fluent API, validators, error codes, propagation) - -## API Surface Rules - -Every feature must be exposed through **all three interfaces**: GraphQL, MCP, and CLI. When adding a new endpoint or editing an existing type, keep all three in sync: - -- **GraphQL** — `pkg/server/api/console/v1/graphql/*.graphql` (+ codegen) -- **MCP** — `pkg/server/api/mcp/v1/` (+ codegen) -- **CLI** — `cmd/` - -If you add a mutation in GraphQL, add the corresponding MCP tool and CLI command. If you rename or change a type, update it everywhere. - -Every new Go API endpoint must have end-to-end tests in `e2e/`. - -## Project - -- Module: `go.probo.inc/probo` -- Router: `github.com/go-chi/chi/v5` -- Database: `go.gearno.de/kit/pg` — all raw SQL lives in `pkg/coredata`, never elsewhere -- HTTP server: `go.gearno.de/kit/httpserver` -- HTTP client: `go.gearno.de/kit/httpclient` -- Logging: `go.gearno.de/kit/log` -- Tracing: OpenTelemetry (`go.opentelemetry.io/otel`) -- UUID: `go.gearno.de/crypto/uuid` (never use `github.com/google/uuid`) -- Pointers: `go.gearno.de/x/ref` for pointer helpers (`ref.UnrefOrZero`, etc.) -- Tests: `github.com/stretchr/testify` (`require` for fatal, `assert` for non-fatal) -- Go version: 1.26 — use `new(expr)` to create pointers to values (e.g. `new(1)`, `new("foo")`, `new(time.Now())`) instead of helper functions or temporary variables - -## Go Style - -### Grouped declarations - -Use `type ()`, `const ()`, and `var ()` blocks to group related declarations. Use explicit typed values for string enums, not `iota`. - -```go -type ( - CreateFooRequest struct { - Name string - Active bool - } - - UpdateFooRequest struct { - ID gid.GID - Name *string - Active *bool - } -) - -const ( - NameMaxLength = 100 - ContentMaxLength = 5000 -) - -var ( - _ Reader = (*FileReader)(nil) - _ Writer = (*FileWriter)(nil) -) -``` - -### One argument per line - -A function call is either entirely on one line or fully expanded with one argument per line. Never mix the two styles. - -```go -// Good — short enough to fit on one line -id := gid.New(tenantID, "Foo") - -// Good — multiple arguments, one per line -svc, err := foo.NewService( - ctx, - db, - logger, - foo.Config{ - Interval: 10 * time.Second, - MaxRetry: 3, - }, -) - -// Bad — mixed inline and multiline -svc, err := foo.NewService(ctx, db, logger, foo.Config{ - Interval: 10 * time.Second, -}) -``` - -### Import ordering - -Two groups separated by a blank line: stdlib, then everything else (third-party and internal sorted together alphabetically). - -```go -import ( - "errors" - "net/http" - "strings" - - "github.com/go-chi/chi/v5" - "go.gearno.de/kit/httpserver" - "go.gearno.de/kit/log" - "go.probo.inc/probo/pkg/iam" - "go.probo.inc/probo/pkg/probo" - "go.probo.inc/probo/pkg/trust" -) -``` - -### Receiver names - -Short receivers: usually single-letter matching the type (`s` for Service, `c` for Client, `p` for Provider). - -### Error handling - -Wrap errors with `fmt.Errorf` using lowercase messages starting with `cannot`: - -```go -return nil, fmt.Errorf("cannot load trust center: %w", err) -return nil, fmt.Errorf("cannot create SAML service: %w", err) -``` - -Sentinel errors in grouped `var ()` blocks. Custom error types implement `Unwrap() error`. Use `errors.Is` for sentinel checks. Use `errors.AsType[T](err)` (generic form) instead of `errors.As(err, &ptr)` for type assertions: - -```go -// Good -if e, ok := errors.AsType[*ValidationError](err); ok { - // use e -} - -// Bad — avoid the two-argument form -var ve *ValidationError -if errors.As(err, &ve) { - // use ve -} -``` - -### Naming - -- Constructors: `New*` (e.g. `NewService`, `NewServer`, `NewBridge`) -- Config structs: `*Config` suffix (e.g. `APIConfig`, `PgConfig`, `TrustCenterConfig`) -- Request structs: `*Request` suffix (e.g. `UpdateTrustCenterRequest`) -- Unexported types for internal data: lowercase (e.g. `vendorInfo`, `ctxKey`) - -### Functional options and Config structs - -Use `Config` structs when a constructor has many required parameters. Use functional options (`With*` functions) for optional configuration. - -```go -type Option func(*Bridge) - -func WithDryRun(dryRun bool) Option { - return func(s *Bridge) { - s.dryRun = dryRun - } -} - -func NewBridge(provider provider.Provider, client *scimclient.Client, opts ...Option) *Bridge { - s := &Bridge{provider: provider, scimClient: client} - for _, opt := range opts { - opt(s) - } - return s -} -``` - -### Interfaces - -Define interfaces in the consumer package. Keep them small. Verify satisfaction at compile time: - -```go -var ( - _ unit.Configurable = (*Implm)(nil) - _ unit.Runnable = (*Implm)(nil) -) -``` - -### Context - -Always first parameter. Private struct keys for context values: - -```go -type ctxKey struct{ name string } -var trustCenterIDKey = &ctxKey{name: "trust_center_id"} -``` - -### Logging - -Named, context-aware structured logging with typed fields. **Never log PII, PHI, or other sensitive data** (e.g. emails, names, passwords, tokens, health records). Log opaque identifiers (IDs, request IDs) instead. - -```go -l.InfoCtx(ctx, "HTTP request to trust center custom domain, redirecting to HTTPS", - log.String("domain", domain), - log.String("path", r.URL.Path), - log.String("to", httpsURL), -) -``` - +- [`contrib/claude/n8n.md`](contrib/claude/n8n.md) — n8n community node (resources, operations, GraphQL helpers) diff --git a/apps/console/CLAUDE.md b/apps/console/CLAUDE.md deleted file mode 100644 index a6b426dc5..000000000 --- a/apps/console/CLAUDE.md +++ /dev/null @@ -1,57 +0,0 @@ -# apps/console - -React 19 + Vite + TypeScript + Relay + TailwindCSS. Port 5173. - -## Commands - -| Command | Purpose | -|---------|---------| -| `npm run dev` | Start dev server (port 5173) | -| `npm run build` | Production build | -| `make relay` | Merge split schemas and regenerate Relay artifacts | - -## Routes - -Defined in `src/routes.tsx` with feature-specific route files (e.g. `src/routes/assetRoutes.ts`). - -- Lazy-loaded via `lazy()` from `@probo/react-lazy` -- Data loading: dedicated `*PageLoader` components with `useQueryLoader` (Relay) -- Type: all routes `satisfies AppRoute[]` -- Fallback: `PageSkeleton` or `Fallback` components during loading -- Error boundaries per route group - -## Relay - -Queries, fragments, and mutations are **colocated** in the component that uses them — never in separate `hooks/graph/` files. - -Always use **fragments** to define data requirements. Never create custom TypeScript types for API data — let Relay generate types from fragments. - -### Mutations - -Use `@appendEdge` / `@deleteEdge` directives for Relay store updates: - -```typescript -const createAssetMutation = graphql` - mutation AssetCreateMutation($input: CreateAssetInput!, $connections: [ID!]!) { - createAsset(input: $input) { - assetEdge @appendEdge(connections: $connections) { - node { id name } - } - } - } -`; -``` - -## Permissions - -Inline permission queries in Relay fragments: - -```graphql -canCreate: permission(action: "core:asset:create") -``` - -## Components - -- Form fields: `src/components/form/` -- Dialogs: modal components with mutation handling -- Shared UI: `@probo/ui` package diff --git a/apps/trust/CLAUDE.md b/apps/trust/CLAUDE.md deleted file mode 100644 index 8d036dbad..000000000 --- a/apps/trust/CLAUDE.md +++ /dev/null @@ -1,15 +0,0 @@ -# apps/trust - -React 19 + Vite + TypeScript + Relay + TailwindCSS. Port 5174. - -Same frontend stack as `apps/console/` — see its CLAUDE.md for Relay query patterns, mutation hooks, and component conventions. - -## Trust-specific differences - -- Public-facing trust center app (not an internal dashboard) -- Path-prefix routing: `/trust/{slug}` for Probo-hosted, `/` for custom domains -- Routes: `/overview`, `/documents`, `/subprocessors`, `/updates` -- Auth flow: `/connect`, `/verify-magic-link`, `/full-name` -- Content routes (`/overview`, `/documents`, `/subprocessors`, `/updates`) wrapped in `MainLayout` -- Auth routes (`/connect`, `/verify-magic-link`, `/full-name`) wrapped in `AuthLayout` -- All route groups use `RootErrorBoundary` diff --git a/pkg/agent/CLAUDE.md b/contrib/claude/agent.md similarity index 98% rename from pkg/agent/CLAUDE.md rename to contrib/claude/agent.md index 94f7cc979..72208892f 100644 --- a/pkg/agent/CLAUDE.md +++ b/contrib/claude/agent.md @@ -1,4 +1,4 @@ -# pkg/agent +# Agent (`pkg/agent`) LLM agent orchestration framework. diff --git a/contrib/claude/api-surface.md b/contrib/claude/api-surface.md new file mode 100644 index 000000000..563a362a5 --- /dev/null +++ b/contrib/claude/api-surface.md @@ -0,0 +1,12 @@ +# API Surface Rules + +Every feature must be exposed through **all four interfaces**: GraphQL, MCP, CLI, and n8n. When adding a new endpoint or editing an existing type, keep all four in sync: + +- **GraphQL** — `pkg/server/api/console/v1/graphql/*.graphql` (+ codegen) — see [`contrib/claude/graphql.md`](graphql.md) +- **MCP** — `pkg/server/api/mcp/v1/` (+ codegen) — see [`contrib/claude/mcp.md`](mcp.md) +- **CLI** — `pkg/cmd/` — see [`contrib/claude/cli.md`](cli.md) +- **n8n** — `packages/n8n-node/` — see [`contrib/claude/n8n.md`](n8n.md) + +If you add a mutation in GraphQL, add the corresponding MCP tool, CLI command, and n8n node. If you rename or change a type, update it everywhere. + +Every new Go API endpoint must have end-to-end tests in `e2e/`. diff --git a/contrib/claude/cli.md b/contrib/claude/cli.md index cb0bbee79..2e651ee37 100644 --- a/contrib/claude/cli.md +++ b/contrib/claude/cli.md @@ -5,6 +5,8 @@ CLI commands use [cobra](https://github.com/spf13/cobra) with `pkg/cmd/cmdutil.F ## Directory structure ``` +cmd/prb/main.go # Binary entry point +pkg/cmd/root/ # Root command, registers all subcommands pkg/cmd//.go # Group command, wires verbs pkg/cmd//list/list.go # List verb pkg/cmd//create/create.go # Create verb @@ -14,6 +16,7 @@ pkg/cmd//delete/delete.go # Delete verb pkg/cmd/cmdutil/ # Factory, flags, output helpers pkg/cmd/iostreams/ # Terminal I/O abstraction pkg/cli/api/ # GraphQL client, pagination +pkg/cli/config/ # Config file management (hosts, tokens, default org) ``` Register group commands in `pkg/cmd/root/root.go` with `cmd.AddCommand()`. diff --git a/contrib/claude/coredata.md b/contrib/claude/coredata.md index c58a66fe4..2b9f2ac70 100644 --- a/contrib/claude/coredata.md +++ b/contrib/claude/coredata.md @@ -2,6 +2,9 @@ All raw SQL lives in `pkg/coredata` — never in service, handler, or resolver packages. One file per entity, with companion `*_filter.go` and `*_order_field.go` files when needed. +- Database: `go.gearno.de/kit/pg` +- UUID: `go.gearno.de/crypto/uuid` (never use `github.com/google/uuid`) + ## Entity struct pattern Every entity uses `gid.GID` for its ID, `db` tags for pgx mapping, and `CreatedAt`/`UpdatedAt` timestamps. The `tenant_id` column exists in the database but is **never** stored on the Go struct — it is injected at query time via `Scoper`. diff --git a/contrib/claude/gid.md b/contrib/claude/gid.md new file mode 100644 index 000000000..da07e74ea --- /dev/null +++ b/contrib/claude/gid.md @@ -0,0 +1,84 @@ +# GID — Global Identifiers (`pkg/gid`) + +Every entity ID in the system is a 24-byte tenant-scoped GID, serialized as base64url in the database, JSON, and API surfaces. + +## GID layout (24 bytes / 192 bits) + +| Bytes | Size | Content | +|-------|------|---------| +| 0–7 | 8 bytes | Tenant ID | +| 8–9 | 2 bytes | Entity type (`uint16`) | +| 10–17 | 8 bytes | Timestamp (milliseconds since epoch) | +| 18–23 | 6 bytes | Random data | + +## Creating a GID + +GIDs are created in the **service layer** (e.g. `pkg/probo/*_service.go`), not in coredata `Insert` methods. The entity type constant comes from `pkg/coredata/entity_type_reg.go`: + +```go +assetID := gid.New(s.svc.scope.GetTenantID(), coredata.AssetEntityType) + +asset := &coredata.Asset{ + ID: assetID, + OrganizationID: req.OrganizationID, + Name: req.Name, + CreatedAt: now, + UpdatedAt: now, +} + +err := asset.Insert(ctx, conn, s.svc.scope) +``` + +`gid.New` panics on random source failure (should never happen). Use `gid.NewGID` if you need the error. + +## Extracting fields + +```go +id.TenantID() // TenantID (first 8 bytes) +id.EntityType() // uint16 (bytes 8–9) +id.Timestamp() // time.Time (bytes 10–17) +``` + +## Parsing and serialization + +- `gid.ParseGID(encoded)` — base64url string to GID +- `gid.String()` — GID to base64url string +- Implements `sql.Scanner`, `driver.Valuer`, `MarshalText`, `UnmarshalText` +- `gid.Nil` — zero-value GID + +## TenantID + +`TenantID` is an 8-byte type with its own layout: + +| Bytes | Size | Content | +|-------|------|---------| +| 0–2 | 3 bytes | Machine ID (random per process) | +| 3–5 | 3 bytes | Timestamp (truncated Unix seconds) | +| 6–7 | 2 bytes | Atomic counter | + +Create with `gid.NewTenantID()`. Check with `tenantID.IsValid()` (non-nil). Same serialization interfaces as GID (base64url, SQL scanner/valuer). + +## Entity type registry + +All entity type constants live in `pkg/coredata/entity_type_reg.go` as sequential `uint16` values: + +```go +const ( + OrganizationEntityType uint16 = 0 + FrameworkEntityType uint16 = 1 + MeasureEntityType uint16 = 2 + // ... + _ uint16 = 8 // PeopleEntityType - removed + // ... +) +``` + +**Never reuse removed type numbers.** Use `_` placeholders with a comment noting what was removed. New types get the next available number. + +`NewEntityFromID(id gid.GID) (any, bool)` switches on `id.EntityType()` and returns a pointer to the concrete coredata struct with `ID: id` set, or `nil, false` for unknown types. Add a case here when registering a new entity type. + +## New entity checklist (GID-related steps) + +1. Add `FooEntityType uint16 = N` in the `const` block in `entity_type_reg.go` (next sequential number) +2. Add a `case FooEntityType` in `NewEntityFromID` returning `&Foo{ID: id}, true` +3. In the service `Create` method, call `gid.New(scope.GetTenantID(), coredata.FooEntityType)` to generate the ID before `Insert` diff --git a/contrib/claude/go-style.md b/contrib/claude/go-style.md index 49452b958..d14d53122 100644 --- a/contrib/claude/go-style.md +++ b/contrib/claude/go-style.md @@ -1,6 +1,40 @@ # Go Style -Layout and readability rules for Go source. (Error handling, naming, and imports are covered in `AGENTS.md` / other guides.) +## Project and dependencies + +- HTTP server: `go.gearno.de/kit/httpserver` +- HTTP client: `go.gearno.de/kit/httpclient` +- Tracing: OpenTelemetry (`go.opentelemetry.io/otel`) +- Pointers: Go 1.26 — use `new(expr)` to create pointers to values (e.g. `new(1)`, `new("foo")`, `new(time.Now())`). Use `go.gearno.de/x/ref` only for dereference helpers (`ref.UnrefOrZero`, etc.) + +## Grouped declarations + +Use `type ()`, `const ()`, and `var ()` blocks to group related declarations. Use explicit typed values for string enums, not `iota`. + +```go +type ( + CreateFooRequest struct { + Name string + Active bool + } + + UpdateFooRequest struct { + ID gid.GID + Name *string + Active *bool + } +) + +const ( + NameMaxLength = 100 + ContentMaxLength = 5000 +) + +var ( + _ Reader = (*FileReader)(nil) + _ Writer = (*FileWriter)(nil) +) +``` ## Call expressions and argument lists @@ -44,3 +78,113 @@ svc, err := foo.NewService(ctx, db, logger, foo.Config{ ``` The same rule applies to **method calls** `x.M(a1, …)` — the receiver is already bound; the rule applies to the **argument list** after the method name. + +## Import ordering + +Two groups separated by a blank line: stdlib, then everything else (third-party and internal sorted together alphabetically). + +```go +import ( + "errors" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + "go.gearno.de/kit/httpserver" + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/iam" + "go.probo.inc/probo/pkg/probo" + "go.probo.inc/probo/pkg/trust" +) +``` + +## Receiver names + +Short receivers: usually single-letter matching the type (`s` for Service, `c` for Client, `p` for Provider). + +## Error handling + +Wrap errors with `fmt.Errorf` using lowercase messages starting with `cannot`: + +```go +return nil, fmt.Errorf("cannot load trust center: %w", err) +return nil, fmt.Errorf("cannot create SAML service: %w", err) +``` + +Sentinel errors in grouped `var ()` blocks. Custom error types implement `Unwrap() error`. Use `errors.Is` for sentinel checks. Use `errors.AsType[T](err)` (generic form) instead of `errors.As(err, &ptr)` for type assertions: + +```go +// Good +if e, ok := errors.AsType[*ValidationError](err); ok { + // use e +} + +// Bad — avoid the two-argument form +var ve *ValidationError +if errors.As(err, &ve) { + // use ve +} +``` + +## Naming + +- Constructors: `New*` (e.g. `NewService`, `NewServer`, `NewBridge`) +- Config structs: `*Config` suffix (e.g. `APIConfig`, `PgConfig`, `TrustCenterConfig`) +- Request structs: `*Request` suffix (e.g. `UpdateTrustCenterRequest`) +- Unexported types for internal data: lowercase (e.g. `vendorInfo`, `ctxKey`) + +## Functional options and Config structs + +Use `Config` structs when a constructor has many required parameters. Use functional options (`With*` functions) for optional configuration. + +```go +type Option func(*Bridge) + +func WithDryRun(dryRun bool) Option { + return func(s *Bridge) { + s.dryRun = dryRun + } +} + +func NewBridge(provider provider.Provider, client *scimclient.Client, opts ...Option) *Bridge { + s := &Bridge{provider: provider, scimClient: client} + for _, opt := range opts { + opt(s) + } + return s +} +``` + +## Interfaces + +Define interfaces in the consumer package. Keep them small. Verify satisfaction at compile time: + +```go +var ( + _ unit.Configurable = (*Implm)(nil) + _ unit.Runnable = (*Implm)(nil) +) +``` + +## Context + +Always first parameter. Private struct keys for context values: + +```go +type ctxKey struct{ name string } +var trustCenterIDKey = &ctxKey{name: "trust_center_id"} +``` + +## Logging + +`go.gearno.de/kit/log` — named, context-aware structured logging with typed fields. **Never log PII, PHI, or other sensitive data** (e.g. emails, names, passwords, tokens, health records). Log opaque identifiers (IDs, request IDs) instead. + +```go +l.InfoCtx( + ctx, + "HTTP request to trust center custom domain, redirecting to HTTPS", + log.String("domain", domain), + log.String("path", r.URL.Path), + log.String("to", httpsURL), +) +``` diff --git a/contrib/claude/go-testing.md b/contrib/claude/go-testing.md index ffdeb9909..531ec1902 100644 --- a/contrib/claude/go-testing.md +++ b/contrib/claude/go-testing.md @@ -1,5 +1,7 @@ # Go Testing +Test library: `github.com/stretchr/testify` (`require` for fatal, `assert` for non-fatal). + ## Package naming Black-box test packages (`package foo_test`). White-box (`package foo`) only when testing unexported functions. diff --git a/contrib/claude/make.md b/contrib/claude/make.md new file mode 100644 index 000000000..461deb04a --- /dev/null +++ b/contrib/claude/make.md @@ -0,0 +1,81 @@ +# GNUmakefile + +The project uses a `GNUmakefile` at the root. Builds run with `--jobs=$(nproc)` by default. + +## Everyday targets + +| Target | Purpose | +|---|---| +| `make build` | Build `bin/probod`, `bin/prb`, and `bin/probod-bootstrap` (includes frontend apps, codegen, and Relay) | +| `SKIP_APPS=1 make build` | Build without frontend apps (faster for backend-only work) | +| `make test` | Run tests with race detection and coverage | +| `make test MODULE=./pkg/foo` | Run tests for a single module | +| `make test-verbose` | Tests with verbose output | +| `make test-short` | Short tests only | +| `make test-bench` | Run benchmarks | +| `make test-e2e` | Run console end-to-end tests (requires `bin/probod`) | +| `make lint` | Run all linters: `vet` + `go-fmt` + `go-fix` + `go-lint` + `npm-lint` | +| `make fmt` | Format Go code (`go fmt ./...`) | +| `make clean` | Remove all build artifacts, `node_modules`, generated files, and coverage | +| `make help` | List targets with `##` doc comments | + +## Infrastructure + +| Target | Purpose | +|---|---| +| `make stack-up` | Start Docker Compose infra (Postgres, Pebble, Keycloak, etc.) | +| `make stack-down` | Stop Docker Compose infra | +| `make stack-ps` | List running containers | +| `make psql` | Open a `psql` shell to the dev Postgres database | + +## Codegen + +`make generate` runs all code generation (GraphQL + MCP + Relay). Individual codegen is driven by `go generate`: + +- `go generate ./pkg/server/api/console/v1` — Console GraphQL (gqlgen) +- `go generate ./pkg/server/api/connect/v1` — Connect GraphQL (gqlgen) +- `go generate ./pkg/server/api/trust/v1` — Trust GraphQL (gqlgen) +- `go generate ./pkg/server/api/mcp/v1` — MCP (mcpgen) +- `go generate ./pkg/llm` — LLM model registry from OpenRouter (`make genmodels`) + +`make relay` merges split `.graphql` schema files and runs `relay-compiler`. + +## Coverage + +| Target | Purpose | +|---|---| +| `make coverage-report` | Unit test HTML coverage report (`coverage.html`) | +| `make test-e2e-coverage` | E2E coverage report (`coverage-e2e.html`) | +| `make coverage-combined` | Combined unit + e2e report (`coverage-combined.html`) | + +## Docker + +| Target | Purpose | +|---|---| +| `make docker-build` | Build the Docker image (`ghcr.io/getprobo/probo`) | +| `make sbom` | Source SBOM (CycloneDX) | +| `make sbom-docker` | Docker image SBOM | +| `make scan` | Vulnerability scan (Grype) on source + Docker | +| `make scan-license` | License compliance scan (Trivy) | + +## Sandbox (Lima) + +| Target | Purpose | +|---|---| +| `make sandbox-create` | Create a Lima sandbox VM for this worktree | +| `make sandbox-start` | Start the VM | +| `make sandbox-stop` | Stop (hibernate) the VM | +| `make sandbox-delete` | Delete the VM | +| `make sandbox-ssh` | Open a shell in the VM | +| `make sandbox-status` | Show VM status and IP | + +## Overridable variables + +| Variable | Default | Purpose | +|---|---|---| +| `SKIP_APPS` | (unset) | Set to `1` to skip frontend app builds | +| `CGO_ENABLED` | `0` | Enable/disable CGO | +| `GOOS` | (host) | Cross-compile target OS | +| `TEST_FLAGS` | `-race -cover -coverprofile=coverage.out` | Extra flags passed to `go test` | +| `DOCKER_BUILD_FLAGS` | (empty) | Extra flags for `docker build` | +| `E2E_CONFIG` | `e2e/console/testdata/config.yaml` | E2E test config path | diff --git a/contrib/claude/n8n.md b/contrib/claude/n8n.md new file mode 100644 index 000000000..b148bff3a --- /dev/null +++ b/contrib/claude/n8n.md @@ -0,0 +1,221 @@ +# n8n Node (`packages/n8n-node`) + +Community node package `@probo/n8n-nodes-probo` exposing the Probo API as n8n operations. One `Probo` node with many resources; each resource maps to a set of GraphQL operations against the Console or Connect API. + +## Directory structure + +``` +packages/n8n-node/ + credentials/ProboApi.credentials.ts # API key credential (Bearer token) + nodes/Probo/ + Probo.node.ts # Node class — resource picker, dispatch + Probo.node.json # n8n codex metadata + GenericFunctions.ts # GraphQL request helpers, pagination + actions/ + index.ts # Resource registry, dispatch, field aggregators + / + index.ts # Operation dropdown + spread descriptions + re-exports + create.operation.ts # One file per operation + get.operation.ts + getAll.operation.ts + update.operation.ts + delete.operation.ts + ... +``` + +## Resource registration + +Two places must be updated when adding a resource: + +**1. `actions/index.ts`** — import the module and add it to the `resources` map: + +```typescript +import * as myresource from './myresource'; + +export const resources: Record = { + // ... existing resources ... + myresource: myresource as ResourceModule, +}; +``` + +**2. `Probo.node.ts`** — add a Resource dropdown entry in the `properties` array: + +```typescript +{ + name: 'My Resource', + value: 'myresource', + description: 'Manage my resources', +}, +``` + +The `value` must match the key in `resources` and the `displayOptions.show.resource` in every operation file. + +## Per-resource file pattern + +### `/index.ts` + +1. Import each `*.operation.ts` as a namespace. +2. Export `description` — the operation dropdown (gated with `displayOptions.show.resource`) plus all spread operation descriptions. +3. Re-export each operation module with a name matching its `operation` value. + +```typescript +import * as createOp from './create.operation'; +import * as getOp from './get.operation'; +import * as getAllOp from './getAll.operation'; + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['myresource'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a new resource', + action: 'Create a resource', + }, + // ... more operations ... + ], + default: 'create', + }, + ...createOp.description, + ...getOp.description, + ...getAllOp.description, +]; + +export { + createOp as create, + getOp as get, + getAllOp as getAll, +}; +``` + +Export names (`create`, `get`, `getAll`, etc.) **must match** the operation `value` strings — `getExecuteFunction` uses them as keys. + +### `/.operation.ts` + +Each file exports `description` (field definitions) and `execute` (the handler): + +```typescript +export const description: INodeProperties[] = [ + { + displayName: 'Organization ID', + name: 'organizationId', + type: 'string', + displayOptions: { + show: { + resource: ['myresource'], + operation: ['create'], + }, + }, + default: '', + required: true, + }, + // ... more fields ... + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: ['myresource'], + operation: ['create'], + }, + }, + options: [ + // optional field definitions + ], + }, +]; + +export async function execute( + this: IExecuteFunctions, + itemIndex: number, +): Promise { + const organizationId = this.getNodeParameter('organizationId', itemIndex) as string; + const name = this.getNodeParameter('name', itemIndex) as string; + + const query = ` + mutation CreateMyResource($input: CreateMyResourceInput!) { + createMyResource(input: $input) { + myResourceEdge { + node { + id + name + } + } + } + } + `; + + const responseData = await proboApiRequest.call(this, query, { + input: { organizationId, name }, + }); + + return { + json: responseData, + pairedItem: { item: itemIndex }, + }; +} +``` + +## GraphQL helpers + +All helpers live in `GenericFunctions.ts`. + +| Helper | API endpoint | Use case | +|--------|-------------|----------| +| `proboApiRequest` | `/api/console/v1/graphql` | Single mutations and queries | +| `proboConnectApiRequest` | `/api/connect/v1/graphql` | Organization/user operations (IAM) | +| `proboApiRequestAllItems` | Console API | Cursor-paginated list queries | +| `proboConnectApiRequestAllItems` | Connect API | Cursor-paginated list queries (IAM) | +| `proboApiMultipartRequest` | Console API | File upload mutations (multipart/form-data) | + +### Pagination (`proboApiRequestAllItems`) + +Caller supplies a `getConnection` function that navigates from the raw GraphQL response to the Relay connection object (must have `edges` and `pageInfo`): + +```typescript +const items = await proboApiRequestAllItems.call( + this, + query, + { organizationId }, + (response) => { + const data = response?.data as IDataObject | undefined; + const node = data?.node as IDataObject | undefined; + return node?.myResources as IDataObject | undefined; + }, + returnAll, + limit, +); +``` + +Internal page size is 100. When `returnAll` is false, stops at `limit`. + +### Update pattern + +For nullable fields, empty string means "clear the value": + +```typescript +if (additionalFields.description !== undefined) { + input.description = additionalFields.description === '' ? null : additionalFields.description; +} +``` + +## Adding a new resource — checklist + +1. **Directory** — create `nodes/Probo/actions//` with `index.ts` and one `*.operation.ts` per operation +2. **Operations** — each file exports `description` (fields gated with `displayOptions`) and `execute` (reads params, calls GraphQL, returns `{ json, pairedItem }`) +3. **Index** — `/index.ts` defines the operation dropdown, spreads all descriptions, re-exports ops with matching value names +4. **Register** — import and add to `resources` map in `actions/index.ts` +5. **Node** — add Resource dropdown entry in `Probo.node.ts` properties +6. **Verify** — `npx n8n-node lint` must pass diff --git a/e2e/CLAUDE.md b/e2e/CLAUDE.md deleted file mode 100644 index 3e7577a2b..000000000 --- a/e2e/CLAUDE.md +++ /dev/null @@ -1,186 +0,0 @@ -# e2e - -End-to-end tests against a running `bin/probod` instance. - -## Prerequisites - -Build the binary first: `make build` (or `SKIP_APPS=1 make build` for backend-only). - -## Running - -``` -make test-e2e -``` - -## Test setup - -`testutil.Setup()` starts `bin/probod` as a subprocess (once per test run via `sync.Once`) and waits for the GraphQL endpoint to be healthy. No explicit teardown is needed — each test gets its own organization/user, so tests never interfere with each other. - -## Client - -```go -owner := testutil.NewClient(t, testutil.RoleOwner) -admin := testutil.NewClientInOrg(t, testutil.RoleAdmin, owner) -viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner) -``` - -`NewClient` creates a standalone user with their own organization. `NewClientInOrg` adds a user to an existing owner's organization with a downgraded role. - -The client provides: -- `c.Execute(query, variables, &result)` — Console API (authenticated) -- `c.ExecuteConnect(query, variables, &result)` — Connect API (sign-up, sign-in) -- `c.ExecuteShouldFail(query, variables, &result)` — expects an error -- `c.GetOrganizationID()` — current org - -## Factory pattern - -Test data created via `factory.Create*` or the builder pattern: - -```go -// Simple — returns ID string -vendorID := factory.CreateVendor(c, factory.Attrs{"name": "Acme"}) - -// Builder — chainable for optional fields -vendorID := factory.NewVendor(owner). - WithName("Test"). - WithDescription("Desc"). - Create() -``` - -- `factory.SafeName(prefix)` — random unique names -- `factory.SafeEmail()` — random unique emails -- `factory.Attrs` map for overriding defaults - -## Writing a test - -Every test follows this structure: - -```go -func TestVendor_Create(t *testing.T) { - t.Parallel() - - owner := testutil.NewClient(t, testutil.RoleOwner) - - t.Run( - "create a vendor", - func(t *testing.T) { - t.Parallel() - - const query = ` - mutation CreateVendor($input: CreateVendorInput!) { - createVendor(input: $input) { - vendorEdge { - node { - id - name - description - } - } - } - } - ` - - var result struct { - CreateVendor struct { - VendorEdge struct { - Node struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - } `json:"node"` - } `json:"vendorEdge"` - } `json:"createVendor"` - } - - name := factory.SafeName("vendor") - - err := owner.Execute( - query, - map[string]any{ - "input": map[string]any{ - "organizationId": owner.GetOrganizationID(), - "name": name, - "description": "A test vendor", - }, - }, - &result, - ) - - require.NoError(t, err) - assert.NotEmpty(t, result.CreateVendor.VendorEdge.Node.ID) - assert.Equal(t, name, result.CreateVendor.VendorEdge.Node.Name) - }, - ) -} -``` - -Key rules: -- Always `t.Parallel()` at both test function and subtest level -- Inline GraphQL queries as string constants -- Typed result structs with `json` tags per query -- Variables as `map[string]any` -- `require.NoError` for GraphQL call errors, `assert.Equal` for value checks - -## Authorization testing - -Test that roles are properly enforced and tenants are isolated: - -```go -t.Run( - "viewer cannot create vendor", - func(t *testing.T) { - t.Parallel() - - viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner) - err := viewer.Execute(query, variables, &result) - testutil.RequireForbiddenError(t, err) - }, -) - -t.Run( - "other org cannot access vendor", - func(t *testing.T) { - t.Parallel() - - otherOwner := testutil.NewClient(t, testutil.RoleOwner) - err := otherOwner.Execute(query, variables, &result) - require.Error(t, err) - }, -) -``` - -## Assertion helpers - -| Helper | Purpose | -|--------|---------| -| `RequireForbiddenError(t, err)` | Verifies FORBIDDEN error code | -| `RequireErrorCode(t, err, code)` | Checks specific GraphQL error code | -| `AssertTimestampsOnCreate(t, created, updated)` | `createdAt == updatedAt` | -| `AssertTimestampsOnUpdate(t, created, updated)` | `createdAt` unchanged, `updatedAt` advances | -| `AssertFirstPage(t, pageInfo)` | First page of a paginated result | -| `AssertLastPage(t, pageInfo)` | Last page of a paginated result | -| `AssertOrderedAscending(t, items)` | Items in ascending order | -| `AssertOrderedDescending(t, items)` | Items in descending order | -| `AssertNodeNotAccessible(t, client, id)` | Tenant isolation check | - -## File organization - -``` -e2e/ -├── console/ # Test files (package console_test) -│ ├── vendor_test.go -│ ├── framework_test.go -│ ├── audit_test.go -│ └── ... -└── internal/ - ├── factory/ - │ └── factory.go # Test data builders - └── testutil/ - ├── testutil.go # Server setup/teardown - ├── client.go # Client and auth - ├── graphql.go # GraphQL request/response - ├── assert.go # Assertion helpers - └── mailpit.go # Email service integration -``` - -One test file per entity (e.g. `vendor_test.go`). Test function names follow `TestEntity_Operation` (e.g. `TestVendor_Create`, `TestVendor_Update`). diff --git a/pkg/cmd/AGENTS.md b/pkg/cmd/AGENTS.md deleted file mode 100644 index 173118417..000000000 --- a/pkg/cmd/AGENTS.md +++ /dev/null @@ -1,82 +0,0 @@ -# AGENTS.md — prb CLI - -## Overview - -`prb` is the Probo CLI built with [cobra](https://github.com/spf13/cobra). Entry point: `cmd/prb/main.go`. - -## Package layout - -| Package | Purpose | -|---|---| -| `cmd/prb` | Binary entry point — creates `Factory`, root command, and runs it | -| `pkg/cmd/root` | Root command — registers all top-level subcommands | -| `pkg/cmd/` | Command group (e.g. `risk`, `framework`, `webhook`) — wires subcommands | -| `pkg/cmd//` | Leaf command (e.g. `risk/create`, `risk/list`) — owns the `RunE` | -| `pkg/cmd/cmdutil` | Shared helpers: `Factory`, flag validators, table/JSON output, time formatting | -| `pkg/cmd/iostreams` | Terminal I/O abstraction (stdout, stderr, color, interactivity) | -| `pkg/cli/api` | GraphQL client (`Client`) and generic pagination (`Paginate[T]`) | -| `pkg/cli/config` | Config file management (hosts, tokens, default org) | - -## Adding a new resource command - -1. Create `pkg/cmd//.go` with a `NewCmd(f *cmdutil.Factory) *cobra.Command` that groups the subcommands. -2. Create a subpackage per verb (`list`, `create`, `view`, `update`, `delete`) each exporting `NewCmd(f *cmdutil.Factory) *cobra.Command`. -3. Register the group command in `pkg/cmd/root/root.go`. - -## Command structure pattern - -Every leaf command follows this pattern: - -```go -package verb - -func NewCmd(f *cmdutil.Factory) *cobra.Command { - var ( - flagOrg string - flagFoo string - // ... - ) - - cmd := &cobra.Command{ - Use: "", - Short: "One-line description", - Aliases: []string{"..."}, // optional, e.g. "ls" for list - Example: ` prb ...`, - RunE: func(cmd *cobra.Command, args []string) error { - // 1. Validate output flags (for list commands) - // 2. Load config, get host + token - // 3. Create api.Client - // 4. Resolve --org (flag → config default) - // 5. Interactive prompts if IOStreams.IsInteractive() and flags are missing - // 6. Call API via client.Do(query, variables) - // 7. Output: JSON via cmdutil.PrintJSON or table via cmdutil.NewTable - }, - } - - cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID") - // ... more flags ... - - return cmd -} -``` - -## Key conventions - -- **GraphQL queries/mutations** are `const` strings declared at package level in the leaf command file. -- **Response types** are unexported structs in the leaf command file, shaped to match the GraphQL response. -- **Organization resolution**: every command that needs an org checks `--org` flag first, then falls back to `hc.Organization` from config. If both are empty, return an error telling the user to pass `--org` or run `prb auth login`. -- **Interactive prompts** use `github.com/charmbracelet/huh`. Gate them behind `f.IOStreams.IsInteractive()`. Always support full non-interactive use via flags. -- **Output format**: list commands support `--output json|table` via `cmdutil.AddOutputFlag` / `cmdutil.ValidateOutputFlag`. Default is table. -- **Pagination**: list commands use `api.Paginate[T]` with a `--limit` / `-L` flag (default 30). Show "Showing X of Y" on stderr when results are truncated. -- **Table output**: use `cmdutil.NewTable("COL", ...).Rows(rows...)`. -- **View commands** print detailed formatted output with lipgloss-styled labels and sections. They support `--output json|table` like list commands. Use `lipgloss.NewStyle()` for bold titles and dimmed labels. -- **Create/update/delete** commands print a single confirmation line to stdout (e.g. `"Created risk %s (%s)\n"`). -- **Delete commands** prompt for confirmation interactively; skip the prompt when `--yes` / `-y` is passed. -- **Flag naming**: use kebab-case (`--order-by`, `--inherent-likelihood`). Use `StringVar` / `IntVar` (not positional args) for all inputs. - -## Dependencies - -- CLI framework: `github.com/spf13/cobra` -- Interactive prompts: `github.com/charmbracelet/huh` -- Styled terminal output: `github.com/charmbracelet/lipgloss` -- All other dependencies follow the root AGENTS.md (same module, same style rules) diff --git a/pkg/coredata/CLAUDE.md b/pkg/coredata/CLAUDE.md deleted file mode 100644 index 48a85dde6..000000000 --- a/pkg/coredata/CLAUDE.md +++ /dev/null @@ -1,164 +0,0 @@ -# pkg/coredata - -All raw SQL lives here — never in service or handler packages. - -## Entity files - -One file per entity (`asset.go`, `vendor.go`, etc.), plus optional companion `_filter.go` and `_order_field.go` files when needed. No codegen — everything is hand-written. - -Entity structs do **not** have a `TenantID` field — the `tenant_id` column is provided by the `Scoper` (via `scope.GetTenantID()`) at query time, not stored on the Go struct. - -## SQL query pattern - -Every query uses raw SQL with `pgx.StrictNamedArgs` and scope injection via `fmt.Sprintf`: - -```go -q := ` -SELECT id, name, created_at, updated_at -FROM assets -WHERE - %s - AND id = @asset_id -LIMIT 1; -` - -q = fmt.Sprintf(q, scope.SQLFragment()) - -args := pgx.StrictNamedArgs{"asset_id": assetID} -maps.Copy(args, scope.SQLArguments()) -``` - -## Scoper interface - -Every Load/Insert/Update/Delete method takes a `Scoper` parameter for tenant isolation: -- `Scope` — adds `tenant_id = @tenant_id` WHERE clause -- `NoScope` — returns `TRUE` (for cross-tenant operations) - -Insert uses `scope.GetTenantID()` for the tenant_id value. - -## Method patterns - -| Method | Receiver | Returns | Notes | -|--------|----------|---------|-------| -| `LoadByID` | `*Entity` | `error` | Assigns into receiver via `*e = entity` | -| `LoadAllBy*` | `*Entities` (slice type) | `error` | Paginated with `page.Cursor[OrderField]` | -| `CountBy*` | `*Entities` | `(int, error)` | Uses `COUNT(id)` | -| `Insert` | `*Entity` | `error` | Uses `scope.GetTenantID()` for tenant_id | -| `Update` | `*Entity` | `error` | Uses `RETURNING` to reassign receiver | -| `Delete` | `*Entity` | `error` | — | -| `CursorKey` | `*Entity` | `page.CursorKey` | Switch on OrderField, panic on unknown | -| `AuthorizationAttributes` | `*Entity` | `(map[string]string, error)` | Returns org/tenant IDs for authz | - -## Row collection - -- Single row: `pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[T])` -- Multiple rows: `pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[T])` -- Check `pgx.ErrNoRows` → return `ErrResourceNotFound` - -## Sentinel errors - -Defined in `errors.go`: -- `ErrResourceNotFound` — row not found (`pgx.ErrNoRows`) -- `ErrResourceAlreadyExists` — unique constraint violation -- `ErrResourceInUse` — foreign key constraint prevents deletion - -## Filter pattern - -Double pointer fields: `nil` = no filter, `*nil` = IS NULL, `*val` = equals. - -`SQLFragment()` must return a **static** SQL string (no conditional string building) so the prepared statement is always the same. Use `CASE WHEN` in SQL to handle optional filters. - -`SQLArguments()` returns `pgx.StrictNamedArgs` — **every key referenced in the SQL must be set in every code path** (use `nil` for inactive filters). `StrictNamedArgs` rejects missing keys at runtime. - -```go -func (f *VendorFilter) SQLArguments() pgx.StrictNamedArgs { - args := pgx.StrictNamedArgs{ - "show_on_trust_center": nil, - "has_snapshot_filter": false, - "filter_snapshot_id": nil, - } - - if f.showOnTrustCenter != nil { - args["show_on_trust_center"] = *f.showOnTrustCenter - } - - if f.snapshotID != nil { - args["has_snapshot_filter"] = true - if *f.snapshotID != nil { - args["filter_snapshot_id"] = **f.snapshotID - } - } - - return args -} - -func (f *VendorFilter) SQLFragment() string { - return ` -( - CASE - WHEN @show_on_trust_center::boolean IS NOT NULL THEN - show_on_trust_center = @show_on_trust_center::boolean - ELSE TRUE - END - AND - CASE - WHEN @has_snapshot_filter::boolean = false THEN TRUE - WHEN @has_snapshot_filter::boolean = true AND @filter_snapshot_id::text IS NOT NULL THEN - snapshot_id = @filter_snapshot_id::text - WHEN @has_snapshot_filter::boolean = true AND @filter_snapshot_id::text IS NULL THEN - snapshot_id IS NULL - ELSE TRUE - END -)` -} -``` - -## OrderField pattern - -OrderField types must validate their value via `IsValid()` and implement text marshalling: - -```go -type InvitationOrderField string - -const ( - InvitationOrderFieldCreatedAt InvitationOrderField = "CREATED_AT" -) - -func (p InvitationOrderField) Column() string { - switch p { - case InvitationOrderFieldCreatedAt: - return "created_at" - } - panic(fmt.Sprintf("unsupported order by: %s", p)) -} - -func (e InvitationOrderField) IsValid() bool { - switch e { - case InvitationOrderFieldCreatedAt: - return true - } - return false -} - -func (e InvitationOrderField) String() string { return string(e) } - -func (e *InvitationOrderField) UnmarshalText(text []byte) error { - *e = InvitationOrderField(text) - if !e.IsValid() { - return fmt.Errorf("%s is not a valid InvitationOrderField", string(text)) - } - return nil -} - -func (e InvitationOrderField) MarshalText() ([]byte, error) { - return []byte(e.String()), nil -} -``` - -## Argument merging - -Always use `maps.Copy` to combine args from scope, filter, and cursor. Never manually merge. - -## Migrations - -Pure SQL files in `pkg/coredata/migrations/` with timestamp names: `YYYYMMDDTHHMMSSZ.sql`. diff --git a/pkg/server/api/console/v1/CLAUDE.md b/pkg/server/api/console/v1/CLAUDE.md deleted file mode 100644 index 0a370dc2c..000000000 --- a/pkg/server/api/console/v1/CLAUDE.md +++ /dev/null @@ -1,55 +0,0 @@ -# pkg/server/api/console/v1 - -GraphQL API using `gqlgen`. Schema-first approach. - -## Generated vs hand-written - -| File | Type | Notes | -|------|------|-------| -| `graphql/*.graphql` | Hand-written | GraphQL schema split by entity (one file per coredata model) | -| `gqlgen.yaml` | Hand-written | Codegen config | -| `resolver.go` | Hand-written | Root `Resolver` struct and `NewMux` | -| `graphql_handler.go` | Hand-written | Handler setup | -| `*_resolvers.go` | Generated stubs | Per-entity resolver files (edit the bodies) | -| `schema/schema.go` | **Generated — DO NOT EDIT** | Executable schema | -| `types/types.go` | **Generated — DO NOT EDIT** | Type definitions | - -## Schema file organization - -Schema files live in `graphql/` and are split by coredata model: -- `base.graphql` — directives, scalars, Node, PageInfo, root Query/Mutation/Organization/Viewer types -- Entity files (e.g., `vendor.graphql`, `control.graphql`) — use `extend type Organization`, `extend type Mutation`, etc. to add fields - -When adding a new entity, create a new `.graphql` file in `graphql/`. Types that get extended across files (Organization, Mutation, Viewer) must be defined in `base.graphql`. - -## Important rules - -- **Never edit generated files** (`schema/schema.go`, `types/types.go`). Only edit `graphql/*.graphql` and resolver bodies. -- **After any change to `graphql/*.graphql`**, always run codegen: - -``` -go generate ./pkg/server/api/console/v1 -``` - -## Resolver pattern - -Every resolver method follows this sequence: - -1. **Authorize** — `r.authorize(ctx, obj.ID, probo.ActionXxxGet)` -2. **Get service** — `prb := r.ProboService(ctx, tenantID)` -3. **Call service** — `result, err := prb.Foo.Bar(ctx, ...)` -4. **Handle error** — wrap or panic on unexpected errors - -## Pagination - -Relay cursor pattern: -- `page.Cursor[OrderField]` for cursor handling -- Connection types (`*Connection`) with `ParentID`, `Resolver`, `Filter` fields - -## Custom scalars - -`ID`, `Datetime`, `CursorKey`, `Duration`, `BigInt`, `EmailAddr` — mapped in `gqlgen.yaml`. - -## Authentication middleware - -`NewMux()` chains: session → API key → identity presence middlewares. diff --git a/pkg/server/api/mcp/v1/CLAUDE.md b/pkg/server/api/mcp/v1/CLAUDE.md deleted file mode 100644 index 019ef163b..000000000 --- a/pkg/server/api/mcp/v1/CLAUDE.md +++ /dev/null @@ -1,80 +0,0 @@ -# pkg/server/api/mcp/v1 - -MCP (Model Context Protocol) API. Schema-first approach using `mcpgen`. - -## Generated vs hand-written - -| File | Type | Notes | -|------|------|-------| -| `specification.yaml` | Hand-written | Tool definitions, input/output schemas | -| `mcpgen.yaml` | Hand-written | Codegen config | -| `resolver.go` | Hand-written | Resolver struct, `MustAuthorize()`, helpers | -| `v1_handler.go` | Hand-written | `NewMux()`, MCP server setup | -| `middleware.go` | Hand-written | API key authentication | -| `helpers.go` | Hand-written | Pagination helpers | -| `schema.resolvers.go` | **Generated (preserved)** | Tool implementations — edit the bodies | -| `server/server.go` | **Generated — DO NOT EDIT** | Tool registration, `ResolverInterface` | -| `types/types.go` | **Generated — DO NOT EDIT** | Type definitions and JSON schemas | -| `types/*.go` (other) | Hand-written | Type conversion helpers (`NewVendor`, etc.) | - -## Important rules - -- **Never edit generated files** (`server/server.go`, `types/types.go`). Only edit `specification.yaml`, resolver bodies, and hand-written helpers. -- **After any change to `specification.yaml`**, always run codegen: - -``` -go generate ./pkg/server/api/mcp/v1 -``` - -Reads `specification.yaml` and generates server, types, and resolver stubs. - -## Adding a new tool - -1. Define the tool in `specification.yaml` under `tools:` with name, description, hints, inputSchema, outputSchema -2. Define input/output schemas under `components/schemas/` -3. Run `go generate ./pkg/server/api/mcp/v1` -4. Implement the tool body in `schema.resolvers.go` -5. Add type conversion helpers in `types/` if needed - -## Tool definition format - -```yaml -tools: - - name: listVendors - description: List all vendors for the organization - hints: - readonly: true - idempotent: true - inputSchema: - $ref: "#/components/schemas/ListVendorsInput" - outputSchema: - $ref: "#/components/schemas/ListVendorsOutput" -``` - -## Resolver pattern - -```go -func (r *Resolver) ListVendorsTool(ctx context.Context, input types.ListVendorsInput) (*types.ListVendorsOutput, error) { - r.MustAuthorize(ctx, input.OrganizationID, probo.ActionVendorList) - prb := r.ProboService(ctx, input.OrganizationID.TenantID()) - // ... service call, type conversion -} -``` - -- `MustAuthorize()` panics on auth failure — caught by MCP recovery middleware -- Type conversion via `types.New*()` helpers - -## Custom type mappings - -In `specification.yaml`, map Go types with `go.probo.inc/mcpgen/type`: - -```yaml -OrderDirection: - type: string - enum: [ASC, DESC] - go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/page.OrderDirection -``` - -## Authentication - -API key auth via `authn.NewAPIKeyMiddleware`. Mounted at `/mcp/v1`.