Skip to content

API Token Scopes + API Key as Agent Registration Token

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Add permission scopes to API tokens so they can grant restricted access, and allow API tokens with nodes.create scope to be used as agent registration tokens (reusable, fixed, good for IaC/automation).

Architecture: Add a scopes TEXT[] column to api_tokens. Empty scopes = full user access (backwards compatible). Non-empty scopes = intersection with user's RBAC permissions. In Register, detect shardlyn_api_ prefix → validate as API token, require nodes.create scope, create node directly into the requested org.

Tech Stack: Go, PostgreSQL (TEXT[]), SQLite (JSON string via types.StringArray), golang-migrate, chi router.


Task 1: PostgreSQL migration — add scopes to api_tokens

Files:

  • Create: migrations/postgres/000072_api_token_scopes.up.sql
  • Create: migrations/postgres/000072_api_token_scopes.down.sql

Step 1: Write up migration

migrations/postgres/000072_api_token_scopes.up.sql:

sql
ALTER TABLE api_tokens ADD COLUMN scopes TEXT[] NOT NULL DEFAULT '{}';

migrations/postgres/000072_api_token_scopes.down.sql:

sql
ALTER TABLE api_tokens DROP COLUMN scopes;

Step 2: Verify files exist

bash
ls migrations/postgres/000072_*

Expected: both files listed.

Step 3: Commit

bash
git add migrations/postgres/000072_api_token_scopes.up.sql migrations/postgres/000072_api_token_scopes.down.sql
git commit -m "feat(migrations): add scopes column to api_tokens (postgres)"

Task 2: SQLite migration — add scopes to api_tokens

Files:

  • Create: migrations/sqlite/000073_api_token_scopes.up.sql
  • Create: migrations/sqlite/000073_api_token_scopes.down.sql

SQLite doesn't support DROP COLUMN before version 3.35; the down migration is a no-op for SQLite (or uses recreate pattern). Follow the project's existing pattern — check migrations/sqlite/000009_credential_expiry.down.sql for how nullifying is done; here a simple no-op is fine since SQLite 3.35+ supports DROP COLUMN.

Step 1: Write migrations

migrations/sqlite/000073_api_token_scopes.up.sql:

sql
ALTER TABLE api_tokens ADD COLUMN scopes TEXT NOT NULL DEFAULT '[]';

migrations/sqlite/000073_api_token_scopes.down.sql:

sql
ALTER TABLE api_tokens DROP COLUMN scopes;

Step 2: Verify

bash
ls migrations/sqlite/000073_*

Step 3: Commit

bash
git add migrations/sqlite/000073_api_token_scopes.up.sql migrations/sqlite/000073_api_token_scopes.down.sql
git commit -m "feat(migrations): add scopes column to api_tokens (sqlite)"

Task 3: Update APIToken model

Files:

  • Modify: internal/controlplane/store/models.go:31-39

Step 1: Add Scopes field to APIToken struct

Current (models.go:31-39):

go
type APIToken struct {
	ID         uuid.UUID  `json:"id"`
	UserID     uuid.UUID  `json:"user_id"`
	Name       string     `json:"name"`
	TokenHash  string     `json:"-"`
	LastUsedAt *time.Time `json:"last_used_at,omitempty"`
	ExpiresAt  *time.Time `json:"expires_at,omitempty"`
	CreatedAt  time.Time  `json:"created_at"`
}

Change to:

go
type APIToken struct {
	ID         uuid.UUID         `json:"id"`
	UserID     uuid.UUID         `json:"user_id"`
	Name       string            `json:"name"`
	TokenHash  string            `json:"-"`
	Scopes     types.StringArray `json:"scopes,omitempty"`
	LastUsedAt *time.Time        `json:"last_used_at,omitempty"`
	ExpiresAt  *time.Time        `json:"expires_at,omitempty"`
	CreatedAt  time.Time         `json:"created_at"`
}

Step 2: Verify import of types package is present

The file already imports "github.com/caiolombello/shardlyn/internal/controlplane/store/types" (used by other models). If not, add it.

Step 3: Build check

bash
go build ./internal/controlplane/store/...

Expected: no errors.

Step 4: Commit

bash
git add internal/controlplane/store/models.go
git commit -m "feat(store): add Scopes field to APIToken model"

Task 4: Update APITokenRepo queries to persist and read scopes

Files:

  • Modify: internal/controlplane/store/api_token_repo.go

All three query functions need updating: CreateAPIToken, GetAPITokenByHash, ListAPITokensByUser.

Step 1: Update CreateAPIToken

Change the INSERT query from:

go
query := `
    INSERT INTO api_tokens (id, user_id, name, token_hash, expires_at, created_at)
    VALUES ($1, $2, $3, $4, $5, $6)
`

_, err := r.db.Exec(ctx, query,
    token.ID,
    token.UserID,
    token.Name,
    token.TokenHash,
    token.ExpiresAt,
    token.CreatedAt,
)

To:

go
query := `
    INSERT INTO api_tokens (id, user_id, name, token_hash, scopes, expires_at, created_at)
    VALUES ($1, $2, $3, $4, $5, $6, $7)
`

_, err := r.db.Exec(ctx, query,
    token.ID,
    token.UserID,
    token.Name,
    token.TokenHash,
    token.Scopes,
    token.ExpiresAt,
    token.CreatedAt,
)

Step 2: Update GetAPITokenByHash

Change the SELECT query from:

go
query := `
    SELECT id, user_id, name, token_hash, last_used_at, expires_at, created_at
    FROM api_tokens WHERE token_hash = $1
`

token := &APIToken{}
err := r.db.QueryRow(ctx, query, hash).Scan(
    &token.ID,
    &token.UserID,
    &token.Name,
    &token.TokenHash,
    &token.LastUsedAt,
    &token.ExpiresAt,
    &token.CreatedAt,
)

To:

go
query := `
    SELECT id, user_id, name, token_hash, scopes, last_used_at, expires_at, created_at
    FROM api_tokens WHERE token_hash = $1
`

token := &APIToken{}
err := r.db.QueryRow(ctx, query, hash).Scan(
    &token.ID,
    &token.UserID,
    &token.Name,
    &token.TokenHash,
    &token.Scopes,
    &token.LastUsedAt,
    &token.ExpiresAt,
    &token.CreatedAt,
)

Step 3: Update ListAPITokensByUser

Change the SELECT query from:

go
query := `
    SELECT id, user_id, name, token_hash, last_used_at, expires_at, created_at
    FROM api_tokens WHERE user_id = $1 ORDER BY created_at DESC
`
...
if err := rows.Scan(
    &t.ID,
    &t.UserID,
    &t.Name,
    &t.TokenHash,
    &t.LastUsedAt,
    &t.ExpiresAt,
    &t.CreatedAt,
); err != nil {

To:

go
query := `
    SELECT id, user_id, name, token_hash, scopes, last_used_at, expires_at, created_at
    FROM api_tokens WHERE user_id = $1 ORDER BY created_at DESC
`
...
if err := rows.Scan(
    &t.ID,
    &t.UserID,
    &t.Name,
    &t.TokenHash,
    &t.Scopes,
    &t.LastUsedAt,
    &t.ExpiresAt,
    &t.CreatedAt,
); err != nil {

Step 4: Build check

bash
go build ./internal/controlplane/store/...

Step 5: Commit

bash
git add internal/controlplane/store/api_token_repo.go
git commit -m "feat(store): persist and read scopes in api_token_repo"

Task 5: Add APITokenScopes to UserContext and scope enforcement

Files:

  • Modify: internal/controlplane/auth/middleware.go:21-32
  • Modify: internal/controlplane/auth/api_token_validator.go:51-56
  • Modify: internal/controlplane/auth/permission.go:38-55

5a: Add field to UserContext

Current UserContext struct (middleware.go:21-32):

go
type UserContext struct {
	UserID uuid.UUID
	Email  string
	Role   string // Global role (admin/user)

	// Organization context (populated by RequireOrganization middleware)
	OrganizationID *uuid.UUID
	OrgRole        string // Role in the organization (owner/admin/member)

	// Permissions (resolved by PermissionChecker, not persisted)
	Permissions map[string]bool `json:"-"`
}

Add APITokenScopes field:

go
type UserContext struct {
	UserID uuid.UUID
	Email  string
	Role   string // Global role (admin/user)

	// Organization context (populated by RequireOrganization middleware)
	OrganizationID *uuid.UUID
	OrgRole        string // Role in the organization (owner/admin/member)

	// Permissions (resolved by PermissionChecker, not persisted)
	Permissions map[string]bool `json:"-"`

	// APITokenScopes restricts permissions when non-empty.
	// A non-empty slice means only listed scopes are allowed (intersection with RBAC).
	// An empty slice means full user access (no restriction).
	APITokenScopes []string `json:"-"`
}

Also add a helper method HasScope after the HasPermission method:

go
// HasAPITokenScope returns true if the context has no scope restriction,
// or if the specified scope is in the allowed list.
func (u *UserContext) HasAPITokenScope(scope string) bool {
	if len(u.APITokenScopes) == 0 {
		return true // no restriction
	}
	for _, s := range u.APITokenScopes {
		if s == scope {
			return true
		}
	}
	return false
}

5b: Populate scopes in APITokenValidator

Current return in api_token_validator.go:51-56:

go
return &UserContext{
    UserID: user.ID,
    Email:  user.Email,
    Role:   user.Role,
}, nil

Change to:

go
return &UserContext{
    UserID:         user.ID,
    Email:          user.Email,
    Role:           user.Role,
    APITokenScopes: apiToken.Scopes,
}, nil

5c: Enforce scopes in PermissionChecker.HasPermission

Current function (permission.go:38-55):

go
func (pc *PermissionChecker) HasPermission(ctx context.Context, user *UserContext, permission string) (bool, error) {
	// Global admin bypass
	if user.Role == "admin" {
		return true, nil
	}

	// Need org context
	if user.OrganizationID == nil {
		return false, nil
	}

	perms, err := pc.getPermissions(ctx, *user.OrganizationID, user.UserID, user.OrgRole)
	if err != nil {
		return false, err
	}

	return perms[permission], nil
}

Change to:

go
func (pc *PermissionChecker) HasPermission(ctx context.Context, user *UserContext, permission string) (bool, error) {
	// Global admin bypass (but still enforce token scopes even for admins)
	if user.Role == "admin" {
		return user.HasAPITokenScope(permission), nil
	}

	// Need org context
	if user.OrganizationID == nil {
		return false, nil
	}

	// If token has restricted scopes, check scope first (fast path)
	if !user.HasAPITokenScope(permission) {
		return false, nil
	}

	perms, err := pc.getPermissions(ctx, *user.OrganizationID, user.UserID, user.OrgRole)
	if err != nil {
		return false, err
	}

	return perms[permission], nil
}

Step 1: Apply all three changes above.

Step 2: Build check

bash
go build ./internal/controlplane/auth/...

Step 3: Run existing auth tests

bash
go test ./internal/controlplane/auth/... -v

Expected: all tests pass.

Step 4: Commit

bash
git add internal/controlplane/auth/middleware.go \
        internal/controlplane/auth/api_token_validator.go \
        internal/controlplane/auth/permission.go
git commit -m "feat(auth): add APITokenScopes to UserContext with scope enforcement"

Task 6: Update API token creation endpoint to accept and return scopes

Files:

  • Modify: internal/controlplane/api/api_token_handler.go

6a: Update request/response types

Current APITokenRequest (api_token_handler.go:34-37):

go
type APITokenRequest struct {
	Name      string `json:"name"`
	ExpiresIn int    `json:"expires_in,omitempty"` // seconds
}

Add Scopes field:

go
type APITokenRequest struct {
	Name      string   `json:"name"`
	ExpiresIn int      `json:"expires_in,omitempty"` // seconds
	Scopes    []string `json:"scopes,omitempty"`
}

Current APITokenResponse (api_token_handler.go:40-47):

go
type APITokenResponse struct {
	ID         string  `json:"id"`
	Name       string  `json:"name"`
	Token      string  `json:"token,omitempty"`
	LastUsedAt *string `json:"last_used_at,omitempty"`
	ExpiresAt  *string `json:"expires_at,omitempty"`
	CreatedAt  string  `json:"created_at"`
}

Add Scopes field:

go
type APITokenResponse struct {
	ID         string   `json:"id"`
	Name       string   `json:"name"`
	Token      string   `json:"token,omitempty"`
	Scopes     []string `json:"scopes,omitempty"`
	LastUsedAt *string  `json:"last_used_at,omitempty"`
	ExpiresAt  *string  `json:"expires_at,omitempty"`
	CreatedAt  string   `json:"created_at"`
}

6b: Add scope validation constants

Add after the apiTokenPrefix const:

go
// validAPITokenScopes is the set of accepted scope values.
var validAPITokenScopes = map[string]struct{}{
	"nodes.create":     {},
	"nodes.read":       {},
	"nodes.update":     {},
	"nodes.delete":     {},
	"instances.create": {},
	"instances.read":   {},
	"instances.update": {},
	"instances.delete": {},
	"credentials.read": {},
}

6c: Validate scopes in CreateAPIToken

After the req.Name == "" check in CreateAPIToken, add scope validation:

go
for _, scope := range req.Scopes {
    if _, ok := validAPITokenScopes[scope]; !ok {
        RespondWithAPIError(w, ValidationError("invalid scope: "+scope, nil))
        return
    }
}

6d: Persist scopes when building apiToken

Change the apiToken struct literal:

go
apiToken := &store.APIToken{
    UserID:    currentUser.UserID,
    Name:      req.Name,
    TokenHash: tokenHash,
    ExpiresAt: expiresAt,
}

To:

go
apiToken := &store.APIToken{
    UserID:    currentUser.UserID,
    Name:      req.Name,
    TokenHash: tokenHash,
    Scopes:    req.Scopes,
    ExpiresAt: expiresAt,
}

6e: Return scopes in response

Change the response construction:

go
response := &APITokenResponse{
    ID:        apiToken.ID.String(),
    Name:      apiToken.Name,
    Token:     token,
    CreatedAt: apiToken.CreatedAt.Format(time.RFC3339),
}

To:

go
response := &APITokenResponse{
    ID:        apiToken.ID.String(),
    Name:      apiToken.Name,
    Token:     token,
    Scopes:    req.Scopes,
    CreatedAt: apiToken.CreatedAt.Format(time.RFC3339),
}

Also update ListAPITokens to include scopes in each response item. In the loop body, add:

go
resp.Scopes = t.Scopes

Step 1: Apply all changes above.

Step 2: Build check

bash
go build ./internal/controlplane/api/...

Step 3: Commit

bash
git add internal/controlplane/api/api_token_handler.go
git commit -m "feat(api): accept and return scopes in API token endpoints"

Task 7: Update agent config to support SHARDLYN_AGENT_ORGANIZATION_ID

Files:

  • Modify: internal/agent/config.go:17-19

Current identity fields:

go
// Agent identity
Name     string `env:"SHARDLYN_AGENT_NAME"`
Token    string `env:"SHARDLYN_AGENT_TOKEN"`     // Bootstrap token for registration
AuthFile string `env:"SHARDLYN_AGENT_AUTH_FILE"` // File to store auth credentials

Add OrganizationID:

go
// Agent identity
Name           string `env:"SHARDLYN_AGENT_NAME"`
Token          string `env:"SHARDLYN_AGENT_TOKEN"`           // Bootstrap token for registration
OrganizationID string `env:"SHARDLYN_AGENT_ORGANIZATION_ID"` // Required when Token is an API token
AuthFile       string `env:"SHARDLYN_AGENT_AUTH_FILE"`       // File to store auth credentials

Step 1: Apply the change.

Step 2: Build check

bash
go build ./internal/agent/...

Step 3: Commit

bash
git add internal/agent/config.go
git commit -m "feat(agent): add SHARDLYN_AGENT_ORGANIZATION_ID config field"

Task 8: Update agent register() to send organization_id

Files:

  • Modify: internal/agent/agent.go:297-311

Current request map (agent.go:297-311):

go
req := map[string]interface{}{
    "token":    a.config.Token,
    "hostname": hostname,
    "name":     a.config.Name,
    "version":  a.version,
    "resources": map[string]interface{}{
        "cpu_cores": cpuCores,
        "memory_mb": memInfo.Total / 1024 / 1024,
        "disk_gb":   diskInfo.Total / 1024 / 1024 / 1024,
    },
}

Change to include organization_id when set:

go
req := map[string]interface{}{
    "token":    a.config.Token,
    "hostname": hostname,
    "name":     a.config.Name,
    "version":  a.version,
    "resources": map[string]interface{}{
        "cpu_cores": cpuCores,
        "memory_mb": memInfo.Total / 1024 / 1024,
        "disk_gb":   diskInfo.Total / 1024 / 1024 / 1024,
    },
}

if a.config.OrganizationID != "" {
    req["organization_id"] = a.config.OrganizationID
}

Step 1: Apply the change.

Step 2: Build check

bash
go build ./internal/agent/...

Step 3: Commit

bash
git add internal/agent/agent.go
git commit -m "feat(agent): send organization_id in registration request when configured"

Task 9: Update RegisterRequest to accept organization_id

Files:

  • Modify: internal/controlplane/api/agent_handler.go:156-170

Current RegisterRequest struct:

go
type RegisterRequest struct {
	Token     string `json:"token"`
	Hostname  string `json:"hostname"`
	Name      string `json:"name"`
	Version   string `json:"version,omitempty"`
	Resources struct {
		CPUCores int   `json:"cpu_cores"`
		MemoryMB int64 `json:"memory_mb"`
		DiskGB   int   `json:"disk_gb"`
	} `json:"resources"`
	Cloud *RegisterCloudInfo `json:"cloud,omitempty"`
}

Add OrganizationID field:

go
type RegisterRequest struct {
	Token          string `json:"token"`
	OrganizationID string `json:"organization_id,omitempty"`
	Hostname       string `json:"hostname"`
	Name           string `json:"name"`
	Version        string `json:"version,omitempty"`
	Resources      struct {
		CPUCores int   `json:"cpu_cores"`
		MemoryMB int64 `json:"memory_mb"`
		DiskGB   int   `json:"disk_gb"`
	} `json:"resources"`
	Cloud *RegisterCloudInfo `json:"cloud,omitempty"`
}

Step 1: Apply the change.

Step 2: Build check

bash
go build ./internal/controlplane/api/...

Step 3: Commit

bash
git add internal/controlplane/api/agent_handler.go
git commit -m "feat(api): add organization_id field to RegisterRequest"

Task 10: Add CreateNodeForOrg to NodeRepo

Files:

  • Modify: internal/controlplane/store/node_repo.go (add after RegisterAgent)

This is needed for the API token registration path, where there is no node_token to consume. The node is created directly in the specified org.

Step 1: Write the new method

Add after RegisterAgent (around line 813):

go
// CreateNodeForOrg creates a new node in the specified organization directly.
// Used when registering via an API token instead of a one-time node token.
func (r *NodeRepo) CreateNodeForOrg(ctx context.Context, orgID uuid.UUID, node *Node) error {
	labelsJSON, _ := json.Marshal(node.Labels)
	annotationsJSON, _ := json.Marshal(node.Annotations)
	if node.ID == uuid.Nil {
		node.ID = uuid.New()
	}
	node.OrganizationID = &orgID

	query := `
		INSERT INTO nodes (id, organization_id, name, hostname, status, agent_version, agent_token_hash,
			cordoned, drain_requested, cpu_cores, memory_mb, disk_gb, labels, annotations)
		VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
	`
	_, err := r.db.Exec(ctx, query,
		node.ID,
		node.OrganizationID,
		node.Name,
		node.Hostname,
		node.Status,
		node.AgentVersion,
		node.AgentTokenHash,
		node.Cordoned,
		node.DrainRequested,
		node.CPUCores,
		node.MemoryMB,
		node.DiskGB,
		labelsJSON,
		annotationsJSON,
	)
	if err != nil {
		return fmt.Errorf("failed to create node for org: %w", err)
	}
	return nil
}

Step 2: Build check

bash
go build ./internal/controlplane/store/...

Step 3: Commit

bash
git add internal/controlplane/store/node_repo.go
git commit -m "feat(store): add CreateNodeForOrg for API token agent registration"

Task 11: Implement API token path in Register handler

Files:

  • Modify: internal/controlplane/api/agent_handler.goRegister method (~line 186)

This is the core logic change. Before calling nodeRepo.RegisterAgent, check if the token is an API token (prefix shardlyn_api_). If so:

  1. Validate it via apiTokenRepo.GetAPITokenByHash
  2. Check it has nodes.create scope (non-empty scopes list must include it)
  3. Parse organization_id from request (required)
  4. Call nodeRepo.CreateNodeForOrg instead

The AgentHandler needs access to apiTokenRepo. Check how it's created in di/container.go or wherever handlers are wired up — you'll need to add the field and a setter or update the constructor.

11a: Add apiTokenRepo to AgentHandler

Find AgentHandler struct definition (around line 1-30 of agent_handler.go). Add the field:

go
type AgentHandler struct {
	nodeRepo      *store.NodeRepo
	provisionRepo *store.ProvisionRepo
	apiTokenRepo  *store.APITokenRepo  // for API token registration path
	logger        *zap.Logger
}

Add a setter (follow existing patterns for optional repos):

go
// SetAPITokenRepo configures the API token repo for the API token registration path.
func (h *AgentHandler) SetAPITokenRepo(repo *store.APITokenRepo) {
	h.apiTokenRepo = repo
}

11b: Wire it up in DI container

Find where AgentHandler is created (search for NewAgentHandler in internal/controlplane/di/container.go or similar). Add:

go
agentHandler.SetAPITokenRepo(apiTokenRepo)

11c: Implement the detection logic in Register

Current flow (line 198-249):

go
tokenHash := auth.HashToken(req.Token)

// Generate auth token for the agent
agentAuthToken, err := auth.GenerateToken(32)
...

// Atomic registration
nodeID, err := h.nodeRepo.RegisterAgent(r.Context(), tokenHash, node)
if err != nil {
    if errors.Is(err, store.ErrNodeTokenNotFound) || errors.Is(err, store.ErrNodeTokenUsed) || errors.Is(err, store.ErrNodeTokenExpired) {
        RespondWithAPIError(w, NewAPIError(ErrCodeUnauthorized, err.Error()))
        return
    }
    ...
}

Replace the RegisterAgent call and its error handling with:

go
// Try API token path first (token has shardlyn_api_ prefix)
if strings.HasPrefix(req.Token, "shardlyn_api_") && h.apiTokenRepo != nil {
    tokenHash := auth.HashToken(req.Token)
    apiToken, err := h.apiTokenRepo.GetAPITokenByHash(r.Context(), tokenHash)
    if err != nil {
        RespondWithAPIError(w, NewAPIError(ErrCodeUnauthorized, "invalid token"))
        return
    }
    if apiToken.ExpiresAt != nil && time.Now().After(*apiToken.ExpiresAt) {
        RespondWithAPIError(w, NewAPIError(ErrCodeUnauthorized, "token expired"))
        return
    }

    // Check nodes.create scope: non-empty scopes must include it
    hasScope := len(apiToken.Scopes) == 0
    for _, s := range apiToken.Scopes {
        if s == "nodes.create" {
            hasScope = true
            break
        }
    }
    if !hasScope {
        RespondWithAPIError(w, NewAPIError(ErrCodeForbidden, "token does not have nodes.create scope"))
        return
    }

    if req.OrganizationID == "" {
        RespondWithAPIError(w, ValidationError("organization_id is required when using an API token", nil))
        return
    }
    orgID, err := uuid.Parse(req.OrganizationID)
    if err != nil {
        RespondWithAPIError(w, ValidationError("invalid organization_id", nil))
        return
    }

    if err := h.nodeRepo.CreateNodeForOrg(r.Context(), orgID, node); err != nil {
        h.logger.Error("failed to create node for org", zap.Error(err))
        RespondWithAPIError(w, NewAPIError(ErrCodeInternal, "failed to register agent"))
        return
    }
    nodeID := node.ID
    // Continue to response below...
    respondOK(w, RegisterResponse{
        AgentID:   nodeID.String(),
        AuthToken: agentAuthToken,
    })
    return
}

// Original node token path
tokenHash := auth.HashToken(req.Token)
nodeID, err := h.nodeRepo.RegisterAgent(r.Context(), tokenHash, node)
if err != nil {
    if errors.Is(err, store.ErrNodeTokenNotFound) || errors.Is(err, store.ErrNodeTokenUsed) || errors.Is(err, store.ErrNodeTokenExpired) {
        RespondWithAPIError(w, NewAPIError(ErrCodeUnauthorized, err.Error()))
        return
    }
    h.logger.Error("failed to register agent", zap.Error(err))
    RespondWithAPIError(w, NewAPIError(ErrCodeInternal, "failed to register agent"))
    return
}

Note: The existing code after the RegisterAgent call (provision linking, label enrichment) should remain for the node token path only.

You'll need to add "strings" and "time" to the imports if not already present.

Step 1: Apply all changes in 11a, 11b, 11c.

Step 2: Build check

bash
go build ./internal/controlplane/...

Step 3: Run tests

bash
go test ./internal/controlplane/... -v 2>&1 | tail -30

Expected: all tests pass.

Step 4: Commit

bash
git add internal/controlplane/api/agent_handler.go \
        internal/controlplane/di/container.go
git commit -m "feat(api): accept API token as agent registration token with nodes.create scope"

Task 12: Full build and test verification

Step 1: Build all packages

bash
go build ./...

Expected: no errors.

Step 2: Run all tests

bash
go test ./... 2>&1 | grep -E "^(ok|FAIL|---)" | head -50

Expected: all ok, no FAIL.

Step 3: Verify the feature end-to-end (manual)

  1. Create an API token with nodes.create scope:

    bash
    curl -X POST http://localhost:8080/v1/api-tokens \
      -H "Authorization: Bearer <user-jwt>" \
      -H "Content-Type: application/json" \
      -d '{"name":"ci-agent-token","scopes":["nodes.create"]}'

    Expected response includes "scopes":["nodes.create"] and a token starting with shardlyn_api_.

  2. Create an API token WITHOUT nodes.create scope:

    bash
    curl -X POST http://localhost:8080/v1/api-tokens \
      -H "Authorization: Bearer <user-jwt>" \
      -H "Content-Type: application/json" \
      -d '{"name":"read-only-token","scopes":["instances.read"]}'
  3. Try registering an agent with the read-only token:

    bash
    curl -X POST http://localhost:8080/v1/agents/register \
      -H "Content-Type: application/json" \
      -d '{"token":"<read-only-token>","organization_id":"<org-uuid>","hostname":"test","resources":{"cpu_cores":2,"memory_mb":4096,"disk_gb":50}}'

    Expected: 403 Forbidden with "token does not have nodes.create scope".

  4. Register with the nodes.create token:

    bash
    curl -X POST http://localhost:8080/v1/agents/register \
      -H "Content-Type: application/json" \
      -d '{"token":"<ci-agent-token>","organization_id":"<org-uuid>","hostname":"test","resources":{"cpu_cores":2,"memory_mb":4096,"disk_gb":50}}'

    Expected: 200 OK with agent_id and auth_token.

  5. Register a second agent with the same token (reuse test): Same command as step 4 with different hostname. Expected: 200 OK (not consumed).

  6. Set SHARDLYN_AGENT_TOKEN=<ci-agent-token> and SHARDLYN_AGENT_ORGANIZATION_ID=<org-uuid> in agent env, start agent. Expected: agent registers successfully.

  7. Verify existing node_token flow still works by creating a node token and registering without organization_id or shardlyn_api_ prefix.


Summary of Files Changed

FileChange
migrations/postgres/000072_api_token_scopes.up.sqlCreate
migrations/postgres/000072_api_token_scopes.down.sqlCreate
migrations/sqlite/000073_api_token_scopes.up.sqlCreate
migrations/sqlite/000073_api_token_scopes.down.sqlCreate
internal/controlplane/store/models.goAdd Scopes types.StringArray to APIToken
internal/controlplane/store/api_token_repo.goPersist and read scopes in all 3 queries
internal/controlplane/store/node_repo.goAdd CreateNodeForOrg method
internal/controlplane/auth/middleware.goAdd APITokenScopes []string to UserContext, add HasAPITokenScope helper
internal/controlplane/auth/api_token_validator.goPopulate APITokenScopes in returned UserContext
internal/controlplane/auth/permission.goEnforce scope restriction in HasPermission
internal/controlplane/api/api_token_handler.goAccept/validate/return scopes in create/list endpoints
internal/controlplane/api/agent_handler.goAdd apiTokenRepo field, API token detection branch in Register
internal/controlplane/di/container.goWire apiTokenRepo into AgentHandler
internal/agent/config.goAdd OrganizationID / SHARDLYN_AGENT_ORGANIZATION_ID
internal/agent/agent.goSend organization_id in register request when set

Built for teams that want control of their own infrastructure.