245
AGENTS.md
245
AGENTS.md
@@ -1,237 +1,28 @@
|
|||||||
# AGENTS.md
|
# 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/`:
|
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/make.md`](contrib/claude/make.md) — GNUmakefile targets, codegen, overridable variables
|
||||||
- [`contrib/claude/cli.md`](contrib/claude/cli.md) — CLI command patterns (cobra, huh prompts, pagination, output formatting)
|
- [`contrib/claude/api-surface.md`](contrib/claude/api-surface.md) — GraphQL / MCP / CLI / n8n sync rules
|
||||||
- [`contrib/claude/commit.md`](contrib/claude/commit.md) — Commit message conventions
|
- [`contrib/claude/go-style.md`](contrib/claude/go-style.md) — Go project deps, style (declarations, calls, imports, errors, naming, logging)
|
||||||
- [`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/go-testing.md`](contrib/claude/go-testing.md) — Go test conventions (parallel, require vs assert, naming)
|
- [`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/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/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/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/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/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/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)
|
- [`contrib/claude/n8n.md`](contrib/claude/n8n.md) — n8n community node (resources, operations, GraphQL helpers)
|
||||||
|
|
||||||
## 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),
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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
|
## 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>/<resource>.go # Group command, wires verbs
|
||||||
pkg/cmd/<resource>/list/list.go # List verb
|
pkg/cmd/<resource>/list/list.go # List verb
|
||||||
pkg/cmd/<resource>/create/create.go # Create 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/cmdutil/ # Factory, flags, output helpers
|
||||||
pkg/cmd/iostreams/ # Terminal I/O abstraction
|
pkg/cmd/iostreams/ # Terminal I/O abstraction
|
||||||
pkg/cli/api/ # GraphQL client, pagination
|
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()`.
|
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.
|
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
|
## 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`.
|
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
|
# 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
|
## 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.
|
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
|
# Go Testing
|
||||||
|
|
||||||
|
Test library: `github.com/stretchr/testify` (`require` for fatal, `assert` for non-fatal).
|
||||||
|
|
||||||
## Package naming
|
## Package naming
|
||||||
|
|
||||||
Black-box test packages (`package foo_test`). White-box (`package foo`) only when testing unexported functions.
|
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