Clean agent rules

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-04-19 11:42:07 +02:00
parent aee77184df
commit ab52dc0a34
17 changed files with 570 additions and 868 deletions

245
AGENTS.md
View File

@@ -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)

View File

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

View File

@@ -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`

View File

@@ -1,4 +1,4 @@
# pkg/agent
# Agent (`pkg/agent`)
LLM agent orchestration framework.

View File

@@ -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/`.

View File

@@ -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/<resource>/<resource>.go # Group command, wires verbs
pkg/cmd/<resource>/list/list.go # List verb
pkg/cmd/<resource>/create/create.go # Create verb
@@ -14,6 +16,7 @@ pkg/cmd/<resource>/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()`.

View File

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

84
contrib/claude/gid.md Normal file
View File

@@ -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 |
|-------|------|---------|
| 07 | 8 bytes | Tenant ID |
| 89 | 2 bytes | Entity type (`uint16`) |
| 1017 | 8 bytes | Timestamp (milliseconds since epoch) |
| 1823 | 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 89)
id.Timestamp() // time.Time (bytes 1017)
```
## 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 |
|-------|------|---------|
| 02 | 3 bytes | Machine ID (random per process) |
| 35 | 3 bytes | Timestamp (truncated Unix seconds) |
| 67 | 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`

View File

@@ -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),
)
```

View File

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

81
contrib/claude/make.md Normal file
View File

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

221
contrib/claude/n8n.md Normal file
View File

@@ -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
<resource>/
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<string, ResourceModule> = {
// ... 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
### `<resource>/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.
### `<resource>/<verb>.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<INodeExecutionData> {
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/<resource>/` 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**`<resource>/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

View File

@@ -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`).

View File

@@ -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/<resource>` | Command group (e.g. `risk`, `framework`, `webhook`) — wires subcommands |
| `pkg/cmd/<resource>/<verb>` | 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/<resource>/<resource>.go` with a `NewCmd<Resource>(f *cmdutil.Factory) *cobra.Command` that groups the subcommands.
2. Create a subpackage per verb (`list`, `create`, `view`, `update`, `delete`) each exporting `NewCmd<Verb>(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<Verb>(f *cmdutil.Factory) *cobra.Command {
var (
flagOrg string
flagFoo string
// ...
)
cmd := &cobra.Command{
Use: "<verb>",
Short: "One-line description",
Aliases: []string{"..."}, // optional, e.g. "ls" for list
Example: ` prb <resource> <verb> ...`,
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)

View File

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

View File

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

View File

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