245
AGENTS.md
245
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)
|
||||
|
||||
@@ -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
|
||||
@@ -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`
|
||||
@@ -1,4 +1,4 @@
|
||||
# pkg/agent
|
||||
# Agent (`pkg/agent`)
|
||||
|
||||
LLM agent orchestration framework.
|
||||
|
||||
12
contrib/claude/api-surface.md
Normal file
12
contrib/claude/api-surface.md
Normal 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/`.
|
||||
@@ -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()`.
|
||||
|
||||
@@ -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
84
contrib/claude/gid.md
Normal 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 |
|
||||
|-------|------|---------|
|
||||
| 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`
|
||||
@@ -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),
|
||||
)
|
||||
```
|
||||
|
||||
@@ -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
81
contrib/claude/make.md
Normal 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
221
contrib/claude/n8n.md
Normal 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
|
||||
186
e2e/CLAUDE.md
186
e2e/CLAUDE.md
@@ -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`).
|
||||
@@ -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)
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
Reference in New Issue
Block a user