config

package
v0.26.9 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 13, 2026 License: Apache-2.0 Imports: 13 Imported by: 0

Documentation

Overview

Package config owns the CLI's on-disk credential store.

The unit of storage is a PROFILE: a name over one (instance URL, organization) pair and the token that reaches it. Two facts force that shape rather than the obvious "one token per host":

  • A Ronja access token is HARD-BOUND to one organization. Token auth always carries the tenant recorded on the token row, and the only tenant-switch mechanism in the product (POST /api/v2/tenant/set) sets a browser COOKIE, which a token can neither see nor use. So a person who belongs to two organizations needs two tokens ON THE SAME URL — something a store keyed by URL alone cannot hold without silently evicting one of them.
  • Nothing else names an instance, so selecting one means retyping `--url https://…` forever, and "the default" drifts to whatever was logged into last.

A profile's NAME is a handle for humans and is never load-bearing; its IDENTITY is (URL, TenantID), which is what every lookup matches on.

A plain 0600 file is deliberate over an OS keychain: the primary consumers are agents, CI jobs and containers, where a keychain is either absent or blocks on an interactive unlock prompt. Anything more sensitive should use RONJA_TOKEN, which never touches disk.

Index

Constants

View Source
const (
	EnvURL     = "RONJA_URL"
	EnvToken   = "RONJA_TOKEN"
	EnvProfile = "RONJA_PROFILE"
	EnvConfig  = "RONJA_CONFIG_DIR"
)

EnvURL and EnvToken override the stored configuration entirely. They exist so an agent or CI job can authenticate without a login step and without writing anything to disk. EnvProfile selects a stored profile by name, for a shell that wants to pin one without passing --profile to every command.

View Source
const DefaultURL = "https://cloud.ronja.tech"

DefaultURL is where the CLI goes when nothing says otherwise — the hosted Ronja instance.

Production rather than a local backend, because the overwhelming majority of people running this have no local backend at all, and a default they cannot reach fails as "connection refused" rather than as anything that suggests what to do. Working on Ronja itself is the rare case, and it is the one with an obvious remedy: `--url localhost:8080` once, after which that login is a profile like any other.

View Source
const FileName = "config.json"

FileName is the credential store's basename. Named for what it holds rather than how it is keyed — an earlier "hosts.json" stopped being true the moment one host could carry several logins.

Variables

View Source
var ErrNoProfile = errors.New("no such profile")

ErrNoProfile reports that the named profile does not exist. Callers match on it to add "here is what does exist" to the message.

Functions

func Dir

func Dir() (string, error)

Dir is where the credential file lives. RONJA_CONFIG_DIR overrides it, which is what lets tests and sandboxed agents stay off the real profile.

func HostLabel

func HostLabel(rawURL string) string

HostLabel derives a name from an instance URL, for the cases where the organization cannot supply one: "https://staging.ronja.tech" → "staging", "http://localhost:8082" → "local-8082".

The first DNS label rather than the whole host, because the distinguishing part of an instance URL is its subdomain — every Ronja instance shares the rest.

The PORT is part of the name whenever it is not the scheme's default, and on loopback that is the whole point: several local backends at once is the normal way to work on Ronja (one per worktree, on 8081, 8082, 8083…), and they are identical in every respect a name could otherwise draw on. Without the port they all derive "local" and collide into local-2, local-3 — numbers that say nothing about which server they reach, in the one situation where picking the wrong one is easiest.

func NormalizeURL

func NormalizeURL(raw string) (string, error)

NormalizeURL canonicalises a base URL so the same instance always maps to the same credential-store key. It tolerates a bare host ("localhost:8080") by assuming http for loopback and https otherwise, which is what people type.

func Path

func Path() (string, error)

Path is the credential file itself.

func Save

func Save(f *File) error

Save writes the credential store with owner-only permissions.

The write is atomic (temp file + rename) so an interrupted save cannot leave a truncated file that the next run refuses to parse — losing a token to a crash would be a bad way to learn about non-atomic writes.

func Slug

func Slug(s string) string

Slug turns a display name — an organization's, usually — into a profile name.

Anything outside the allowed set is DROPPED rather than transliterated: an organization named "Ässä Oy" becomes "ss-oy", which is a poor name but a usable one, and the user can rename it. Guessing at transliteration for every script would be a lot of table for a string nobody is required to keep.

func Update

func Update(fn func(f *File) error) error

Update runs a read-modify-write of the store under the lock, saving whatever fn leaves behind. fn returning an error aborts the write entirely.

The whole File is handed over rather than a narrow mutator per operation because the interesting operations are not single writes: allocating a unique profile name and storing under it is one indivisible decision, and so is "rename this profile unless someone took the name while we were away".

func ValidateName

func ValidateName(name string) error

ValidateName reports whether a user-supplied profile name is usable.

Typed names are VALIDATED rather than silently slugged, which derived names are. Quietly turning `--profile "Acme Prod"` into `acme-prod` means the name the user asked for is not the name any later command will accept, and they find that out from a "no such profile" much later.

Types

type AmbiguousURLError

type AmbiguousURLError struct {
	URL        string
	Candidates []string
}

AmbiguousURLError is returned when a URL alone does not pick out one profile.

A type rather than a formatted string because the CANDIDATES are the useful part: the caller prints them so the human can pick, instead of being told only that their command was unclear.

func (*AmbiguousURLError) Error

func (e *AmbiguousURLError) Error() string

type File

type File struct {
	// Current is the profile used when nothing on the command line or in the
	// environment selects one.
	Current  string              `json:"currentProfile,omitempty"`
	Profiles map[string]*Profile `json:"profiles"`
}

File is the whole credential store.

func Load

func Load() (*File, error)

Load reads the credential store. A missing file is not an error — it is a first run.

func (*File) Allocate

func (f *File) Allocate(stem, exclude string) string

Allocate returns stem, or the first free stem-2, stem-3, … .

MUST be called inside the store lock, together with the write that takes the name. Allocating and then writing as two separate locked steps is the same race one layer up: two fresh logins both find "app" free, and the second one evicts the first's token — which is the entire bug profiles exist to fix.

func (*File) AutoName

func (f *File) AutoName(instanceURL, tenantName, exclude string) string

AutoName derives a profile name for a login, unique within the store.

The organization is the right name for the case profiles exist to serve — several organizations on ONE instance, where the host is identical and the org is the only thing that distinguishes them. Two cases invert that:

  • A LOOPBACK instance. A dev's local backend and production frequently carry the same organization name, and "local" is what they actually call it.
  • A same-named organization already stored on a DIFFERENT URL. That is the multi-environment case (one org, local/staging/production), where the URL is the distinguishing axis and an org-derived name says nothing.

exclude is the profile being renamed, if any, so a profile does not collide with itself and end up as "acme-2".

func (*File) ClaimProfile

func (f *File) ClaimProfile(instanceURL, tenantID, preferred string) (string, *Profile, bool)

ClaimProfile finds or creates the profile for one identity, returning its name, the entry to fill in, and whether it was newly created. MUST be called inside Update, because deciding the name and taking it is one indivisible step (see Allocate).

`created` is what tells a caller whether the name is still PROVISIONAL and may be improved once the organization's name is known. Renaming without that check would rename the profile an existing user deliberately chose, every time they re-authenticate.

Three behaviours, each load-bearing:

  • An existing identity KEEPS ITS NAME. Re-authenticating updates the profile you already have; it never mints a second one beside it, and never renames the one your scripts refer to.
  • A free `preferred` name (from --profile) is used as given.
  • A `preferred` name already held by a DIFFERENT identity is NOT taken. The new credential lands under a derived name instead, and the caller reports that. Overwriting would evict a live token, and refusing outright would throw away one the server has already minted and shown to nobody — both worse than a slightly surprising name.

func (*File) FindIdentity

func (f *File) FindIdentity(url, tenantID string) (string, *Profile)

FindIdentity locates the profile for one (URL, organization) pair — the IDENTITY match a login uses, so that re-authenticating updates the profile you already have instead of minting a second one beside it.

An empty tenantID is a legitimate identity of its own (a login by someone who belongs to no organization yet), not a wildcard: it matches only other org-less profiles on that URL.

func (*File) Names

func (f *File) Names() []string

Names lists every profile, sorted.

func (*File) NamesOn

func (f *File) NamesOn(url string) []string

NamesOn lists the profiles on one normalized URL, sorted. Sorted because the result reaches both an error message and a single-candidate selection, and Go randomises map iteration — an un-sorted version would pick a different profile on different runs.

func (*File) Remove

func (f *File) Remove(name string) bool

Remove forgets one profile, re-pointing Current if it was the one removed. It reports whether anything was actually stored, so `logout` can tell the truth rather than always claiming success.

func (*File) Rename

func (f *File) Rename(from, to string) error

Rename moves a profile to a new name, carrying Current with it.

type Profile

type Profile struct {
	// URL and TenantID are the IDENTITY. A login matches an existing profile on
	// both, which is what lets two organizations coexist on one instance.
	URL      string `json:"url"`
	TenantID string `json:"tenantID,omitempty"`
	Token    string `json:"token"`
	// The rest is a snapshot captured at login, kept so the credential file is
	// legible to a human (or an agent) opening it: "which token is this, whose
	// is it, and when did it get here". It is deliberately NOT read back as a
	// substitute for asking the server — `whoami` always round-trips to
	// /me, because a role can change and a token can be revoked without
	// anything on this machine noticing.
	//
	// TokenExpiry below is the one exception, and only because the server
	// states it exactly once.
	TenantName string `json:"tenantName,omitempty"`
	UserID     string `json:"userID,omitempty"`
	UserEmail  string `json:"userEmail,omitempty"`
	TokenID    string `json:"tokenID,omitempty"`
	TokenName  string `json:"tokenName,omitempty"`
	// TokenExpiry is RFC 3339, recorded at login. CLI-minted tokens are
	// time-bounded, and the expiry is reported exactly once by the server — so
	// if it is not kept here, nothing can tell the user their credential is
	// about to stop working. Empty for a token supplied with --with-token,
	// whose lifetime we were never told.
	TokenExpiry string `json:"tokenExpiresAt,omitempty"`
	LoggedIn    string `json:"loggedInAt,omitempty"`
}

Profile is one authenticated (instance, organization) pair.

type ProfileURLMismatchError

type ProfileURLMismatchError struct {
	Profile      string
	ProfileURL   string
	RequestedURL string
}

ProfileURLMismatchError is returned when an explicitly named profile and an explicitly named URL disagree.

func (*ProfileURLMismatchError) Error

func (e *ProfileURLMismatchError) Error() string

type Resolved

type Resolved struct {
	URL   string
	Token string
	// FromEnv reports that the token came from RONJA_TOKEN rather than the
	// credential file. `logout` uses this to explain why clearing the file
	// will not actually log the caller out.
	FromEnv bool
	// Profile is the name of the selected profile, or "" when none was. It can
	// be set even when FromEnv is true: the profile still decided which URL to
	// talk to, it just did not supply the credential.
	Profile string
	Entry   *Profile
	// TenantID is the organization the credential reaches — and is deliberately
	// EMPTY whenever FromEnv is true, however confidently the selected profile
	// names one.
	//
	// An environment token and a profile are independent: `eval "$(ronja env)"`
	// sets RONJA_TOKEN and RONJA_URL, and a later `ronja profile use other-org`
	// in that same shell leaves a profile whose TenantID describes a DIFFERENT
	// organization from the token in play. Trusting it there does not fail
	// cleanly — `wf push` would match one organization's binding and send its
	// workflow id under the other's token. Callers that need the tenant under
	// an environment token must ask the server (see commands.resolveInstance).
	TenantID string
}

Resolved is the effective configuration for one command invocation, after flags, environment and file have been layered.

func Resolve

func Resolve(flagURL, flagProfile string) (*Resolved, error)

Resolve layers flags, environment and file into the configuration for one invocation.

The token still comes from RONJA_TOKEN whenever it is set, ahead of anything stored: an agent or CI job that sets it must never accidentally act as whoever last ran `ronja login` on the machine.

Profile selection obeys ONE invariant, which is more important than the order of the rules below:

An explicitly named URL is a FILTER, never a fallback.

If --url or RONJA_URL names an instance with no profile, the answer is "not signed in TO THAT INSTANCE" — never "fall back to the current profile". The alternative sends whatever token happens to be current to a host the user deliberately named, which is exactly how a production credential ends up on a staging box. The old URL-keyed store got this right by accident (a map miss yielded no token); here it has to be deliberate.

Ambiguity is likewise never resolved by picking: two profiles on one URL differ by ORGANIZATION, and the gap between them is the gap between reading test data and writing to a customer's.

When a URL and a profile are both given and disagree, the more DELIBERATE one wins — a typed flag over an exported variable — and only a conflict WITHIN one layer is an error. Erroring on `--profile acme` in a shell that happens to export RONJA_URL would punish the user for being specific; erroring on `--profile acme --url elsewhere` is refusing to guess which half of a typed command was meant.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL