Fix logging format string bugs and add missing contributor guides

Replace three instances of leftover %T format verbs in logger.ErrorCtx() calls with proper structured logging fields. Add alphabetically-sorted reference documentation for six new contrib/claude/ guides and reorder the existing list.

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-03-18 15:03:18 +01:00
parent 2f6574ee49
commit 7895dd32b0
8 changed files with 1155 additions and 8 deletions

View File

@@ -26,16 +26,21 @@ GraphQL and MCP codegen is triggered by `go generate`:
## Reference Documentation
Detailed guides for specific subsystems live in `contrib/claude/`:
- [`contrib/claude/relay.md`](contrib/claude/relay.md) — Frontend Relay client (queries, fragments, mutations, pagination)
- [`contrib/claude/graphql.md`](contrib/claude/graphql.md) — Go GraphQL backend (gqlgen, @goModel, connection types, cursor pagination)
- [`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/license.md`](contrib/claude/license.md) — ISC license header (all file types)
- [`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-testing.md`](contrib/claude/go-testing.md) — Go test conventions (parallel, require vs assert, naming)
- [`contrib/claude/go-worker.md`](contrib/claude/go-worker.md) — Go worker pattern (poll-based, bounded concurrency, FOR UPDATE SKIP LOCKED)
- [`contrib/claude/go-service.md`](contrib/claude/go-service.md) — Go service orchestration (Run, graceful shutdown, crash propagation)
- [`contrib/claude/sandbox.md`](contrib/claude/sandbox.md) — Lima sandbox environments (create, manage, access services)
- [`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/relay.md`](contrib/claude/relay.md) — Frontend Relay client (queries, fragments, mutations, pagination)
- [`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

View File

@@ -0,0 +1,167 @@
# Authorization — IAM & Policy
Policy-based authorization in `pkg/iam/` using an evaluation model similar to AWS IAM. Explicit deny > explicit allow > implicit deny.
## Core concepts
**Policy** — a named collection of statements:
```go
policy.NewPolicy("vendor-crud", "Vendor CRUD",
policy.Allow(ActionVendorGet, ActionVendorList).WithSID("read-vendors"),
policy.Deny(ActionVendorDelete).WithSID("deny-vendor-delete"),
).WithDescription("Standard vendor access")
```
**Statement** — a single permission rule with effect (allow/deny), actions, optional resources, and optional conditions.
**Action format**`SERVICE:RESOURCE:OPERATION` with wildcard support:
```
core:vendor:create # specific action
core:vendor:* # all vendor actions
core:* # all core actions
* # everything
```
## Policy evaluation
The evaluator processes all statements against a request:
1. If any statement explicitly denies → `DecisionDeny`
2. If any statement explicitly allows → `DecisionAllow`
3. No match → `DecisionNoMatch` (implicit deny)
## Authorizer flow
`Authorizer` is the main orchestrator in `pkg/iam/authorizer.go`:
```go
err := iamService.Authorizer.Authorize(ctx, iam.AuthorizeParams{
Principal: identityID, // who
Resource: vendorID, // what
Action: probo.ActionVendorGet, // which action
ResourceAttributes: map[string]string{}, // optional extra attributes
})
```
The flow:
1. Load organization membership for the resource's organization
2. Load principal attributes (identity + membership role)
3. Load resource attributes via `AuthorizationAttributes()` on the entity
4. Build policies: identity-scoped + role-specific
5. Evaluate all policies
6. Return `ErrInsufficientPermissions` if no allow match
## PolicySet
Policies are organized into identity-scoped (applied to all authenticated users) and role-based:
```go
ps := iam.NewPolicySet().
AddRolePolicy("OWNER", OwnerPolicy).
AddRolePolicy("ADMIN", AdminPolicy).
AddRolePolicy("VIEWER", ViewerPolicy).
AddIdentityScopedPolicy(SelfManagePolicy)
```
Register during service initialization:
```go
iamService.Authorizer.RegisterPolicySet(ProboPolicySet())
```
## Conditions (attribute-based access control)
Conditions constrain when a statement applies. All conditions must be satisfied.
```go
// Users can only access resources in their organization
organizationCondition := policy.Equals("principal.organization_id", "resource.organization_id")
policy.Allow(ActionVendorGet).
WithSID("view-vendor").
When(organizationCondition)
```
| Operator | Purpose |
|----------|---------|
| `policy.Equals(key, value)` | Key equals value |
| `policy.NotEquals(key, value)` | Key does not equal value |
| `policy.In(key, value)` | Key in list (supports comma-separated DB fields) |
| `policy.NotIn(key, value)` | Key not in list |
Key paths use `principal.ATTR` or `resource.ATTR` (e.g., `principal.organization_id`, `resource.source`).
## AuthorizationAttributer interface
Resources that support authorization must implement this interface in `pkg/coredata/`:
```go
func (v *Vendor) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[string]string, error) {
q := `SELECT organization_id FROM vendors WHERE id = $1 LIMIT 1;`
var organizationID gid.GID
if err := conn.QueryRow(ctx, q, v.ID).Scan(&organizationID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrResourceNotFound
}
return nil, fmt.Errorf("cannot query vendor authorization attributes: %w", err)
}
return map[string]string{"organization_id": organizationID.String()}, nil
}
```
The returned map provides attributes for condition evaluation (e.g., `resource.organization_id`).
## Error types
```go
var (
ErrInsufficientPermissions // access denied
ErrAssumptionRequired // session assumption needed
ErrUnsupportedPrincipalType // principal is not an Identity
)
```
## Integration in resolvers
**GraphQL resolvers** use `AuthorizeFunc` from `pkg/server/api/authz/`:
```go
if err := authorize(ctx, vendorID, probo.ActionVendorGet); err != nil {
return nil, err
}
```
**MCP resolvers** use `MustAuthorize` which panics (caught by middleware):
```go
r.MustAuthorize(ctx, input.ID, probo.ActionVendorGet)
```
## Action constants
IAM actions live in `pkg/iam/iam_actions.go`, probo actions in `pkg/probo/actions.go`. Follow the naming pattern:
```go
const (
ActionVendorGet = "core:vendor:get"
ActionVendorList = "core:vendor:list"
ActionVendorCreate = "core:vendor:create"
ActionVendorUpdate = "core:vendor:update"
ActionVendorDelete = "core:vendor:delete"
)
```
## Built-in role policies
| Role | Access level |
|------|-------------|
| `OWNER` | Full access to all features including org management |
| `ADMIN` | Full access to core features, restricted org management |
| `VIEWER` | Read-only access to most entities |
| `AUDITOR` | Read-only, excludes internal/employee content |
| `EMPLOYEE` | Can sign documents and view internal content |
## Key patterns
- **Always use `organization_id` condition** — most policies scope access to the principal's organization
- **SID every statement** — `.WithSID("description")` for debugging
- **Explicit denies for restrictions** — even if allow would match, deny takes precedence
- **Identity-scoped for self-management** — cross-org permissions like managing own profile
- **Role-based for org features** — CRUD operations on domain entities

231
contrib/claude/cli.md Normal file
View File

@@ -0,0 +1,231 @@
# CLI Command Patterns
CLI commands use [cobra](https://github.com/spf13/cobra) with `pkg/cmd/cmdutil.Factory` for shared dependencies. Each resource gets a group command with verb subcommands (`list`, `create`, `view`, `update`, `delete`).
## Directory structure
```
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
pkg/cmd/<resource>/view/view.go # View verb
pkg/cmd/<resource>/update/update.go # Update verb
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
```
Register group commands in `pkg/cmd/root/root.go` with `cmd.AddCommand()`.
## Leaf command pattern
Every leaf command follows this structure:
```go
package list
const listQuery = `query($id: ID!, $first: Int, $after: CursorKey) { ... }`
type listResponse struct { ... } // unexported, shaped to match GraphQL response
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
var (
flagOrg string
flagLimit int
flagOutput *string
)
cmd := &cobra.Command{
Use: "list",
Short: "List resources",
Aliases: []string{"ls"},
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
// 1. Validate output flag
// 2. Load config, get host + token
// 3. Create api.Client
// 4. Resolve --org (flag or config default)
// 5. Call API
// 6. Output results
},
}
cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID")
cmd.Flags().IntVarP(&flagLimit, "limit", "L", 30, "Maximum number of items")
flagOutput = cmdutil.AddOutputFlag(cmd)
return cmd
}
```
## Pagination with `api.Paginate[T]`
```go
resources, totalCount, err := api.Paginate(
client,
listQuery,
variables,
flagLimit,
func(data json.RawMessage) (*api.Connection[resource], error) {
var resp struct {
Node *struct {
Typename string `json:"__typename"`
Resources api.Connection[resource] `json:"resources"`
} `json:"node"`
}
if err := json.Unmarshal(data, &resp); err != nil {
return nil, err
}
if resp.Node == nil {
return nil, fmt.Errorf("organization %s not found", flagOrg)
}
return &resp.Node.Resources, nil
},
)
```
## Output formatting
**JSON output:**
```go
if *flagOutput == cmdutil.OutputJSON {
return cmdutil.PrintJSON(f.IOStreams.Out, resources)
}
```
**Table output:**
```go
rows := make([][]string, 0, len(resources))
for _, r := range resources {
rows = append(rows, []string{r.ID, r.Name})
}
t := cmdutil.NewTable("ID", "NAME").Rows(rows...)
_, _ = fmt.Fprintln(f.IOStreams.Out, t)
```
**View detail output with lipgloss:**
```go
bold := lipgloss.NewStyle().Bold(true)
label := lipgloss.NewStyle().Foreground(lipgloss.Color("242")).Width(22)
_, _ = fmt.Fprintf(out, "%s\n\n", bold.Render(r.Name))
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("ID:"), r.ID)
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Created:"), cmdutil.FormatTime(r.CreatedAt))
```
**Truncation message** (stderr, not stdout):
```go
if totalCount > len(resources) {
_, _ = fmt.Fprintf(f.IOStreams.ErrOut, "\nShowing %d of %d resources\n", len(resources), totalCount)
}
```
## Interactive prompts with `charmbracelet/huh`
Gate all prompts behind interactivity check, then validate:
```go
if f.IOStreams.IsInteractive() {
if flagName == "" {
err := huh.NewInput().Title("Resource name").Value(&flagName).Run()
if err != nil {
return err
}
}
if flagCategory == "" {
err := huh.NewSelect[string]().
Title("Category").
Options(
huh.NewOption("Cloud Provider", "CLOUD_PROVIDER"),
huh.NewOption("SaaS", "SAAS"),
).
Value(&flagCategory).Run()
if err != nil {
return err
}
}
}
if flagName == "" {
return fmt.Errorf("name is required; pass --name or run interactively")
}
```
Available prompt types: `huh.NewInput()` (text), `huh.NewText()` (multiline), `huh.NewSelect[T]()` (dropdown), `huh.NewConfirm()` (yes/no).
## Update commands
Only include fields that were explicitly changed:
```go
input := map[string]any{"id": args[0]}
if cmd.Flags().Changed("name") {
input["name"] = flagName
}
if cmd.Flags().Changed("description") {
input["description"] = flagDescription
}
if len(input) == 1 {
return fmt.Errorf("at least one field must be specified for update")
}
```
## Delete commands
Require confirmation via `--yes` flag or interactive prompt:
```go
if !flagYes {
if !f.IOStreams.IsInteractive() {
return fmt.Errorf("cannot delete resource: confirmation required, use --yes to confirm")
}
var confirmed bool
err := huh.NewConfirm().Title(fmt.Sprintf("Delete %s?", args[0])).Value(&confirmed).Run()
if err != nil {
return err
}
if !confirmed {
return nil
}
}
```
## Organization resolution
```go
if flagOrg == "" {
flagOrg = hc.Organization
}
if flagOrg == "" {
return fmt.Errorf("organization is required; pass --org or set a default with 'prb auth login'")
}
```
## Flag conventions
- Use kebab-case: `--order-by`, `--inherent-likelihood`
- Short flags for common options: `-L` (limit), `-o` (output), `-q` (query), `-y` (yes)
- `StringVar`/`IntVar`/`BoolVar` for flags, positional args only for IDs in view/update/delete
- Use `cmd.MarkFlagRequired()` for mandatory flags
## IOStreams
```go
f.IOStreams.Out // stdout — primary output
f.IOStreams.ErrOut // stderr — status messages, truncation info
f.IOStreams.IsInteractive() // true if TTY and not forced non-interactive
```
Environment variables: `PROBO_NO_INTERACTIVE=1`, `CI=true`, `TERM=dumb` (non-interactive), `NO_COLOR` (disable color).
## New resource command checklist
1. **Group command**`pkg/cmd/<resource>/<resource>.go` with `NewCmd<Resource>(f)`, wiring all verb subcommands
2. **Leaf commands** — one file per verb in `pkg/cmd/<resource>/<verb>/<verb>.go`
3. **Each leaf file** — ISC license header, GraphQL const, unexported response struct, `NewCmd<Verb>(f)` function
4. **Register in root** — import and `cmd.AddCommand()` in `pkg/cmd/root/root.go`
5. **API surface** — update GraphQL schema, MCP tools, and e2e tests

169
contrib/claude/coredata.md Normal file
View File

@@ -0,0 +1,169 @@
# Coredata — Data Access Layer
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.
## 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`.
```go
type (
Asset struct {
ID gid.GID `db:"id"`
SnapshotID *gid.GID `db:"snapshot_id"`
Name string `db:"name"`
OrganizationID gid.GID `db:"organization_id"`
AssetType AssetType `db:"asset_type"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
Assets []*Asset
)
```
Use pointer types (`*T`) for nullable database columns.
## Scoper interface
`Scoper` provides tenant isolation. Two implementations:
| Type | Constructor | `SQLFragment()` | `GetTenantID()` | Use case |
|------|-------------|-----------------|-----------------|----------|
| `Scope` | `NewScope(tenantID)` or `NewScopeFromObjectID(gid)` | `"tenant_id = @tenant_id"` | Returns tenant ID | Multi-tenant queries (default) |
| `NoScope` | `NewNoScope()` | `"TRUE"` | **Panics** — never call | Cross-tenant / administrative queries |
Always inject `tenant_id` at INSERT time using `scope.GetTenantID()`, never from the struct.
## SQL query composition
All queries use `fmt.Sprintf` to inject scope/filter/cursor fragments, then `pgx.StrictNamedArgs` for parameters. Merge args with `maps.Copy`.
```go
q := `
SELECT id, name, created_at, updated_at
FROM assets
WHERE
%s
AND organization_id = @organization_id
AND %s
AND %s
LIMIT %d;
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment(), cursor.Limit())
args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
```
**All SQL must be static** after `fmt.Sprintf()` injection — no conditional string building. Use `CASE WHEN` in SQL for optional filter logic.
## Standard method signatures
| Method | Receiver | Returns | Purpose |
|--------|----------|---------|---------|
| `LoadByID(ctx, conn, scope, id)` | `*Entity` | `error` | Single entity by ID |
| `LoadBy*(ctx, conn, scope, key)` | `*Entity` | `error` | Single entity by unique key |
| `LoadAllBy*(ctx, conn, scope, parentID, cursor, filter)` | `*Entities` | `error` | Paginated list |
| `CountBy*(ctx, conn, scope, parentID, filter)` | `*Entities` | `(int, error)` | Count matching rows |
| `Insert(ctx, conn, scope)` | `*Entity` | `error` | Insert, uses `scope.GetTenantID()` |
| `Update(ctx, conn, scope)` | `*Entity` | `error` | Update with `RETURNING` |
| `Delete(ctx, conn, scope)` | `*Entity` | `error` | Delete entity |
| `CursorKey(orderField)` | `*Entity` | `page.CursorKey` | Cursor for pagination |
| `AuthorizationAttributes(ctx, conn)` | `*Entity` | `(map[string]string, error)` | Attributes for IAM policy evaluation |
## Row collection
```go
// Single row
rows, err := conn.Query(ctx, q, args)
asset, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Asset])
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
*a = asset
// Multiple rows
rows, err := conn.Query(ctx, q, args)
assets, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Asset])
*a = assets
```
## Sentinel errors
```go
var (
ErrResourceNotFound = errors.New("resource not found")
ErrResourceAlreadyExists = errors.New("resource already exists")
ErrResourceInUse = errors.New("resource is in use")
)
```
Map `pgx.ErrNoRows` to `ErrResourceNotFound`. Check unique constraint violations for `ErrResourceAlreadyExists`, foreign key violations for `ErrResourceInUse`.
## Filters
Filters implement `SQLFragment() string` and `SQLArguments() pgx.NamedArgs`. Use double pointers for three-state filtering: `nil` = no filter, `*nil` = IS NULL, `*val` = equals.
```go
type AssetFilter struct {
snapshotID **gid.GID
}
func NewAssetFilter(snapshotID **gid.GID) *AssetFilter {
return &AssetFilter{snapshotID: snapshotID}
}
func (f *AssetFilter) SQLFragment() string {
if f.snapshotID == nil {
return "TRUE"
}
if *f.snapshotID == nil {
return "snapshot_id IS NULL"
}
return "snapshot_id = @filter_snapshot_id"
}
func (f *AssetFilter) SQLArguments() pgx.NamedArgs {
if f.snapshotID == nil || *f.snapshotID == nil {
return pgx.NamedArgs{}
}
return pgx.NamedArgs{"filter_snapshot_id": **f.snapshotID}
}
```
For complex multi-field filters, use `CASE WHEN` in SQL and always declare all argument keys in every code path (use `nil` for inactive ones).
## Order fields
String-based enums with `Column()`, `IsValid()`, `String()`, and `MarshalText`/`UnmarshalText`:
```go
type AssetOrderField string
const (
AssetOrderFieldCreatedAt AssetOrderField = "CREATED_AT"
AssetOrderFieldName AssetOrderField = "NAME"
)
```
Each entity implements `CursorKey(field)` returning `page.NewCursorKey(entity.ID, sortValue)`, with a `panic` on unknown fields.
## Entity type registry
Each entity gets a unique `uint16` constant in `entity_type_reg.go`. **Never reuse** removed type numbers — use `_` placeholder. Register new entities in the `NewEntityFromID` switch statement.
## Migrations
Files in `pkg/coredata/migrations/` use timestamp naming: `YYYYMMDDTHHMMSSZ.sql` (UTC). One logical change per file. Always create indexes for frequently queried columns.
## New entity checklist
1. **Entity file** (`entity.go`) — struct with `db` tags, slice type alias, `LoadByID`, `Insert`, `Update`, `Delete`, `CursorKey`, `AuthorizationAttributes`
2. **Filter file** (`entity_filter.go`) — filter struct, `NewEntityFilter`, `SQLFragment`, `SQLArguments`
3. **Order field file** (`entity_order_field.go`) — order field type, constants, `Column`, `IsValid`, marshaling
4. **Entity type constant** — add to `entity_type_reg.go` and `NewEntityFromID`
5. **Migration**`YYYYMMDDTHHMMSSZ.sql` with CREATE TABLE

237
contrib/claude/e2e.md Normal file
View File

@@ -0,0 +1,237 @@
# End-to-End Testing
E2E tests live in `e2e/console/` (package `console_test`) and run against a live `bin/probod` instance. The test infrastructure handles server lifecycle, authentication, and test data creation.
## Running tests
```bash
SKIP_APPS=1 make build # Build the binary (backend only)
make test-e2e # Run all e2e tests
```
## Client setup
**Standalone user (new organization):**
```go
owner := testutil.NewClient(t, testutil.RoleOwner)
```
**User in existing organization:**
```go
admin := testutil.NewClientInOrg(t, testutil.RoleAdmin, owner)
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
```
Each call creates a unique identity with a fresh email. Available roles: `RoleOwner`, `RoleAdmin`, `RoleViewer`.
## Client methods
| Method | API | Purpose |
|--------|-----|---------|
| `c.Execute(query, vars, &result)` | Console | Execute and unmarshal into result |
| `c.MustExecute(query, vars, &result)` | Console | Execute, fail test on error |
| `c.ExecuteShouldFail(query, vars)` | Console | Expect error, fail if succeeds |
| `c.Do(query, vars)` | Console | Low-level, returns raw response |
| `c.ExecuteConnect(query, vars, &result)` | Connect | For auth operations |
| `c.ExecuteWithFile(query, vars, path, file, &result)` | Console | Single file upload |
| `c.GetOrganizationID()` | — | Current org GID |
| `c.GetUserID()` | — | Current user GID |
## Test data factories
Two patterns in `e2e/internal/factory/`:
**Builder pattern (preferred):**
```go
vendorID := factory.NewVendor(owner).
WithName("Stripe").
WithCategory("CLOUD_PROVIDER").
Create()
frameworkID := factory.NewFramework(owner).
WithName("SOC 2").
Create()
controlID := factory.NewControl(owner, frameworkID).
WithName("Access Control").
Create()
```
**Simple factory functions:**
```go
vendorID := factory.CreateVendor(c, factory.Attrs{"name": "Acme"})
taskID := factory.CreateTask(c, &measureID, factory.Attrs{"name": "Task 1"})
```
Use `factory.SafeName("prefix")` for unique names and `factory.SafeEmail()` for unique emails.
## Test structure
Every test and subtest **must** call `t.Parallel()`. One test file per entity in `e2e/console/`. Function naming: `TestEntity_Operation`.
```go
func TestVendor_Create(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
t.Run("with required fields", func(t *testing.T) {
t.Parallel()
const query = `
mutation CreateVendor($input: CreateVendorInput!) {
createVendor(input: $input) {
vendorEdge {
node { id name }
}
}
}
`
var result struct {
CreateVendor struct {
VendorEdge struct {
Node struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"node"`
} `json:"vendorEdge"`
} `json:"createVendor"`
}
err := owner.Execute(query, map[string]any{
"input": map[string]any{
"organizationId": owner.GetOrganizationID().String(),
"name": factory.SafeName("Vendor"),
},
}, &result)
require.NoError(t, err)
assert.NotEmpty(t, result.CreateVendor.VendorEdge.Node.ID)
})
}
```
## Authorization (RBAC) testing
Test each role's access to each operation:
```go
t.Run("viewer cannot create", func(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner)
_, err := viewer.Do(createQuery, map[string]any{
"input": map[string]any{
"organizationId": viewer.GetOrganizationID().String(),
"name": "Test",
},
})
testutil.RequireForbiddenError(t, err, "viewer cannot create")
})
```
## Tenant isolation testing
```go
t.Run("other org cannot access", func(t *testing.T) {
t.Parallel()
owner1 := testutil.NewClient(t, testutil.RoleOwner)
owner2 := testutil.NewClient(t, testutil.RoleOwner)
vendorID := factory.NewVendor(owner1).WithName("Vendor").Create()
var result struct {
Node *struct{ ID string } `json:"node"`
}
err := owner2.Execute(nodeQuery, map[string]any{"id": vendorID}, &result)
testutil.AssertNodeNotAccessible(t, err, result.Node == nil, "Vendor")
})
```
## Assertion helpers
**Pagination:**
```go
testutil.AssertFirstPage(t, edgeCount, pageInfo, expectedCount, expectMore)
testutil.AssertMiddlePage(t, edgeCount, pageInfo, expectedCount)
testutil.AssertLastPage(t, edgeCount, pageInfo, expectedCount, expectPrevious)
```
**Timestamps:**
```go
testutil.AssertTimestampsOnCreate(t, createdAt, updatedAt, beforeCreate)
testutil.AssertTimestampsOnUpdate(t, createdAt, updatedAt, origCreatedAt, origUpdatedAt)
```
**Ordering:**
```go
testutil.AssertOrderedAscending[T](t, values, "fieldName")
testutil.AssertOrderedDescending[T](t, values, "fieldName")
testutil.AssertTimesOrderedDescending(t, times, "createdAt")
```
**Authorization:**
```go
testutil.RequireForbiddenError(t, err, "message")
testutil.RequireErrorCode(t, err, "CODE_NAME", "message")
```
**Optional fields:**
```go
testutil.AssertOptionalStringEqual(t, expected, actual, "fieldName")
```
## Validation testing
Use table-driven tests for validation scenarios:
```go
tests := []struct {
name string
input map[string]any
wantErrorContains string
}{
{name: "missing name", input: map[string]any{}, wantErrorContains: "name"},
{name: "HTML injection", input: map[string]any{"name": "<script>xss</script>"}, wantErrorContains: "HTML"},
{name: "control char", input: map[string]any{"name": "Test\x00"}, wantErrorContains: "control"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
input := map[string]any{"organizationId": owner.GetOrganizationID().String()}
maps.Copy(input, tt.input)
_, err := owner.Do(query, map[string]any{"input": input})
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErrorContains)
})
}
```
## File uploads
```go
err := owner.ExecuteWithFile(
uploadQuery,
map[string]any{"input": map[string]any{"vendorId": vendorID, "file": nil}},
"input.file",
testutil.UploadFile{
Filename: "report.pdf",
ContentType: "application/pdf",
Content: pdfBytes,
},
&result,
)
```
## New entity e2e test checklist
1. **File**`e2e/console/<entity>_test.go`, package `console_test`
2. **CRUD** — create (required fields, all fields), update, delete, get by ID, list
3. **Validation** — required fields, empty strings, HTML injection, control chars, max length, invalid enums
4. **RBAC** — owner/admin/viewer access for create, update, delete, read
5. **Tenant isolation** — cross-org user cannot access resource
6. **Timestamps**`createdAt == updatedAt` on create, `updatedAt` advances on update
7. **Sub-resolvers** — parent references, child collections
8. **Parallel**`t.Parallel()` on every test and subtest

194
contrib/claude/mcp.md Normal file
View File

@@ -0,0 +1,194 @@
# MCP API Patterns
MCP tools are defined in `pkg/server/api/mcp/v1/specification.yaml` and generated with `mcpgen`. The schema is hand-written; Go types, server registration, and resolver stubs are generated.
## File organization
**Hand-written** (edit these):
- `specification.yaml` — tool definitions, input/output schemas, component schemas
- `resolver.go``Resolver` struct, `MustAuthorize`, service accessors
- `helpers.go` — pagination helpers, `UnwrapOmittable`
- `types/*.go` (except `types/types.go`) — type conversion helpers (`NewVendor()`, `NewListVendorsOutput()`, etc.)
- `schema.resolvers.go` — tool implementation bodies (stubs generated, you edit the bodies)
**Generated** (do not edit):
- `server/server.go` — tool registration, `ResolverInterface`
- `types/types.go` — type definitions and JSON schemas
After modifying `specification.yaml`, run:
```bash
go generate ./pkg/server/api/mcp/v1
```
## Tool definition in specification.yaml
```yaml
tools:
- name: listVendors
description: List all vendors for the organization
hints:
readonly: true
idempotent: true
destructive: false
inputSchema:
$ref: "#/components/schemas/ListVendorsInput"
outputSchema:
$ref: "#/components/schemas/ListVendorsOutput"
```
Input/output schemas reference `components/schemas`. Map custom Go types with the `go.probo.inc/mcpgen/type` extension:
```yaml
components:
schemas:
GID:
type: string
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/gid.GID
```
## Resolver signature
Generated stubs follow this pattern:
```go
func (r *Resolver) ListVendorsTool(
ctx context.Context,
req *mcp.CallToolRequest,
input *types.ListVendorsInput,
) (*mcp.CallToolResult, types.ListVendorsOutput, error)
```
First return is always `nil`. Errors are either returned (for recoverable) or panicked (for authorization and unexpected failures).
## Authorization
Use `MustAuthorize` which panics on failure (caught by middleware):
```go
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionVendorList)
```
## Common resolver patterns
**List with pagination:**
```go
func (r *Resolver) ListVendorsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListVendorsInput) (*mcp.CallToolResult, types.ListVendorsOutput, error) {
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionVendorList)
prb := r.ProboService(ctx, input.OrganizationID)
pageOrderBy := page.OrderBy[coredata.VendorOrderField]{
Field: coredata.VendorOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
if input.OrderBy != nil {
pageOrderBy = page.OrderBy[coredata.VendorOrderField]{
Field: input.OrderBy.Field,
Direction: input.OrderBy.Direction,
}
}
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
page, err := prb.Vendors.ListForOrganizationID(ctx, input.OrganizationID, cursor, coredata.NewVendorFilter(nil, nil))
if err != nil {
panic(fmt.Errorf("cannot list vendors: %w", err))
}
return nil, types.NewListVendorsOutput(page), nil
}
```
**Get single resource:**
```go
func (r *Resolver) GetRiskTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetRiskInput) (*mcp.CallToolResult, types.GetRiskOutput, error) {
r.MustAuthorize(ctx, input.ID, probo.ActionRiskGet)
prb := r.ProboService(ctx, input.ID)
risk, err := prb.Risks.Get(ctx, input.ID)
if err != nil {
return nil, types.GetRiskOutput{}, fmt.Errorf("failed to get risk: %w", err)
}
return nil, types.GetRiskOutput{Risk: types.NewRisk(risk)}, nil
}
```
**Create:**
```go
func (r *Resolver) AddRiskTool(ctx context.Context, req *mcp.CallToolRequest, input *types.AddRiskInput) (*mcp.CallToolResult, types.AddRiskOutput, error) {
r.MustAuthorize(ctx, input.OrganizationID, probo.ActionRiskCreate)
svc := r.ProboService(ctx, input.OrganizationID)
risk, err := svc.Risks.Create(ctx, probo.CreateRiskRequest{
OrganizationID: input.OrganizationID,
Name: input.Name,
Description: input.Description,
})
if err != nil {
return nil, types.AddRiskOutput{}, fmt.Errorf("failed to create risk: %w", err)
}
return nil, types.AddRiskOutput{Risk: types.NewRisk(risk)}, nil
}
```
## Optional fields with Omittable
For nullable update fields, use `go.probo.inc/mcpgen/omittable: true` in the schema:
```yaml
description:
type:
- string
- "null"
go.probo.inc/mcpgen/omittable: true
```
In resolvers, unwrap with `UnwrapOmittable`:
```go
Description: UnwrapOmittable(input.Description),
```
## Type conversion helpers
Live in `types/*.go` (not the generated `types/types.go`). One file per entity:
```go
func NewVendor(v *coredata.Vendor) *Vendor {
return &Vendor{
ID: v.ID,
OrganizationID: v.OrganizationID,
Name: v.Name,
CreatedAt: v.CreatedAt,
UpdatedAt: v.UpdatedAt,
}
}
func NewListVendorsOutput(vendorPage *page.Page[*coredata.Vendor, coredata.VendorOrderField]) ListVendorsOutput {
vendors := make([]*Vendor, 0, len(vendorPage.Data))
for _, v := range vendorPage.Data {
vendors = append(vendors, NewVendor(v))
}
var nextCursor *page.CursorKey
if len(vendorPage.Data) > 0 {
cursorKey := vendorPage.Data[len(vendorPage.Data)-1].CursorKey(vendorPage.Cursor.OrderBy.Field)
nextCursor = &cursorKey
}
return ListVendorsOutput{
NextCursor: nextCursor,
Vendors: vendors,
}
}
```
## Adding a new MCP tool — checklist
1. **Schema** — add input/output schemas and tool definition in `specification.yaml`
2. **Codegen**`go generate ./pkg/server/api/mcp/v1`
3. **Resolver** — implement the tool body in `schema.resolvers.go` (authorize, call service, convert types)
4. **Type helpers** — add `New<Entity>()` and `New<Output>()` in `types/<entity>.go`
5. **Verify** — tool is automatically registered via generated `server/server.go`

View File

@@ -0,0 +1,144 @@
# Validation Framework
Custom fluent validation API in `pkg/validator/`. Used in every service method to validate request structs before processing.
## Basic pattern
Create a validator, chain `Check()` calls for each field, then call `Error()` to get accumulated errors:
```go
func (req *CreateVendorRequest) Validate() error {
v := validator.New()
v.Check(req.OrganizationID, "organization_id",
validator.Required(),
validator.GID(coredata.OrganizationEntityType),
)
v.Check(req.Name, "name",
validator.Required(),
validator.SafeTextNoNewLine(TitleMaxLength),
)
v.Check(req.Category, "category",
validator.OneOfSlice(coredata.VendorCategories()),
)
return v.Error()
}
```
`Check(value, fieldName, validators...)` runs validators sequentially on a value. Multiple `Check()` calls accumulate all errors. `Error()` returns `nil` if clean, or `ValidationErrors` (which implements `error`).
## Available validators
### Common
- `Required()` — value must not be nil, empty string, or empty slice
- `NotEmpty()` — value cannot be empty/nil (only checks content, not presence)
### String
- `MinLen(n)` — at least n characters
- `MaxLen(n)` — at most n characters
- `OneOfSlice[T](allowed)` — value must be in allowed list
### Numeric
- `Min(n)` — value >= n
- `Max(n)` — value <= n
### Format
- `URL()` — valid HTTP/HTTPS URL with host
- `HTTPSUrl()` — HTTPS-only URL
- `Domain()` — valid RFC-compliant domain name
- `GID(entityTypes...)` — valid GID, optionally restricted to specific entity types
### Security
- `NoHTML()` — rejects HTML tags
- `PrintableText()` — rejects invisible/harmful Unicode (control chars, bidi overrides, zero-width)
- `NoNewLine()` — rejects `\n` and `\r`
- `SafeText(maxLen)` — combines NotEmpty + MaxLen + NoHTML + PrintableText (allows newlines)
- `SafeTextNoNewLine(maxLen)` — same as SafeText but also rejects newlines (for single-line fields)
### Time
- `After(refTime)` — time must be after reference
- `Before(refTime)` — time must be before reference
- `RangeDuration(min, max)` — duration between min and max inclusive
## Pointer handling
The framework automatically dereferences pointers at any level. Nil pointers pass all non-`Required` validators:
```go
v.Check(stringValue, "field", validator.Required()) // Direct value
v.Check(&stringValue, "field", validator.Required()) // Pointer — auto-dereferenced
v.Check(nilPointer, "field", validator.MinLen(5)) // Nil passes (not Required)
v.Check(nilPointer, "field", validator.Required()) // Nil fails Required
```
## Collection validation
Use `CheckEach` to validate each item in a slice:
```go
v.CheckEach(ids, "ids", func(index int, item any) {
gidValue := item.(gid.GID)
v.Check(gidValue, fmt.Sprintf("ids[%d]", index),
validator.Required(),
validator.GID(coredata.VendorEntityType),
)
})
```
Nil or empty slices are silently skipped.
## Error types
```go
type ValidationError struct {
Field string // e.g. "email"
Code ErrorCode // e.g. ErrorCodeRequired
Message string // human-readable
Value any // the problematic value
}
```
Error codes:
| Code | Meaning |
|------|---------|
| `REQUIRED` | Field is missing or empty |
| `INVALID_FORMAT` | Value does not match expected format |
| `OUT_OF_RANGE` | Numeric value outside bounds |
| `TOO_SHORT` | String below minimum length |
| `TOO_LONG` | String above maximum length |
| `INVALID_EMAIL` | Invalid email address |
| `INVALID_URL` | Invalid URL |
| `INVALID_ENUM` | Value not in allowed set |
| `INVALID_GID` | Invalid GID or wrong entity type |
| `UNSAFE_CONTENT` | HTML, control chars, or harmful Unicode |
| `CUSTOM` | Custom validation error |
`ValidationErrors` is a slice with query methods:
```go
errs := err.(validator.ValidationErrors)
errs.HasErrors() // bool
errs.Fields() // unique field names
errs.ByField("name") // filter by field
errs.ByCode(ErrorCodeRequired) // filter by code
errs.First() // first error
```
## Error propagation
Validation errors flow naturally through Go's error interface:
1. Request struct's `Validate()` returns `ValidationErrors` or `nil`
2. Service method checks error before processing
3. GraphQL/HTTP handlers convert `ValidationErrors` to appropriate response format
```go
func (s *Service) CreateVendor(ctx context.Context, req CreateVendorRequest) (*coredata.Vendor, error) {
if err := req.Validate(); err != nil {
return nil, err
}
// proceed with business logic
}
```

View File

@@ -86,7 +86,7 @@ func (r *applicabilityStatementConnectionResolver) TotalCount(ctx context.Contex
return count, nil
}
r.logger.ErrorCtx(ctx, "not implemented: TotalCount for parent type %T")
r.logger.ErrorCtx(ctx, "unsupported resolver for applicability statement connection", log.String("resolver", fmt.Sprintf("%T", obj.Resolver)))
return 0, gqlutils.Internal(ctx)
}
@@ -1771,7 +1771,7 @@ func (r *mailingListSubscriberConnectionResolver) TotalCount(ctx context.Context
return count, nil
}
r.logger.ErrorCtx(ctx, "not implemented: TotalCount for parent type %T")
r.logger.ErrorCtx(ctx, "unsupported resolver for mailing list subscriber connection", log.String("resolver", fmt.Sprintf("%T", obj.Resolver)))
return 0, gqlutils.Internal(ctx)
}
@@ -7278,7 +7278,7 @@ func (r *profileConnectionResolver) TotalCount(ctx context.Context, obj *types.P
return count, nil
}
r.logger.ErrorCtx(ctx, "not implemented: TotalCount for parent type %T")
r.logger.ErrorCtx(ctx, "unsupported resolver for profile connection", log.String("resolver", fmt.Sprintf("%T", obj.Resolver)))
return 0, gqlutils.Internal(ctx)
}