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
- Variables
- func Dir() (string, error)
- func HostLabel(rawURL string) string
- func NormalizeURL(raw string) (string, error)
- func Path() (string, error)
- func Save(f *File) error
- func Slug(s string) string
- func Update(fn func(f *File) error) error
- func ValidateName(name string) error
- type AmbiguousURLError
- type File
- func (f *File) Allocate(stem, exclude string) string
- func (f *File) AutoName(instanceURL, tenantName, exclude string) string
- func (f *File) ClaimProfile(instanceURL, tenantID, preferred string) (string, *Profile, bool)
- func (f *File) FindIdentity(url, tenantID string) (string, *Profile)
- func (f *File) Names() []string
- func (f *File) NamesOn(url string) []string
- func (f *File) Remove(name string) bool
- func (f *File) Rename(from, to string) error
- type Profile
- type ProfileURLMismatchError
- type Resolved
Constants ¶
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.
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.
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 ¶
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 ¶
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 ¶
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 ¶
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 Save ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 (*File) Allocate ¶
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 ¶
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 ¶
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 ¶
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) NamesOn ¶
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.
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 ¶
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 ¶
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.