Skip to content
Jennifer Deck Registry - Administrator Manual

The executables

Three Jennifer programs live in bin/, with no .j extension and run through their shebangs. They are thin: they read the environment, call a module in src/, and print the result. The logic and the tests are in the modules.

bin/servethe HTTP server and website, over src/apiview.j and src/webview.j
bin/deckadminthe operator CLI, over src/admin.j
bin/healthcheckthe container's health probe, not run by hand

bin/entrypoint.sh is the container's shell entrypoint (see Docker below).

serve and deckadmin read the same database, so deckadmin edits show up in the server without a restart: the server re-parses the file when its mtime or size changes, and at least every 30 seconds.

Configuration

Settings come from three places, each overriding the one before:

  1. built-in defaults: a read-only, plain-HTTP registry
  2. config.toml (or $REGISTRY_CONFIG), if present
  3. the environment

The file describes the deployment; the environment varies one instance or injects a secret without editing a file into an image. config.toml.example documents every key beside the variable that overrides it.

An environment variable set to an empty string counts as unset, so a stray blank cannot erase what the file configured. Booleans are the exception and parse their value (1 true yes on / 0 false no off), so that 0 can switch off what the file switched on.

Every variable serve and deckadmin read can instead be given as <NAME>_FILE, naming a file that holds the value (trimmed), the way Docker and Kubernetes mount secrets. The plain variable wins over _FILE, which wins over the legacy JVC_* name.

A configuration that cannot be served is refused at startup with the reason.

TLS and Let's Encrypt

The registry can obtain and renew its own certificate over the ACME HTTP-01 challenge, storing it in the data directory beside the database.

toml
[tls]
enabled = true
domains = ["registry.example.com"]
email = "[email protected]"
agreeTos = true

agreeTos defaults to false: accepting a CA's terms is the operator's decision.

Port 80 must reach the plain listener from the internet. The CA connects there for the challenge, so the plain listener is not a redirect to HTTPS. It starts first and stays up, so the registry keeps serving while a certificate is obtained or renewed.

Use the staging directory first. It issues untrusted certificates with far higher rate limits; production limits are per registered domain per week, and a misconfigured deployment can exhaust them. The registry logs at boot when it is pointed at staging, since otherwise the first sign is a browser warning.

REGISTRY_ACME_DIRECTORY=https://acme-staging-v02.api.letsencrypt.org/directory

Renewal is decided from the recorded issue time, renewBeforeDays before the 90 day lifetime expires, and is checked only at startup: a long-running server does not renew, so restart it within that window. renewBeforeDays must be between 1 and 88: below that the renewal would follow the expiry, above it every boot after the first day would re-issue, into the CA's duplicate-certificate limit. A renewal that fails while a certificate is still on disk serves the stored one, without logging the failure; a failed first issuance leaves the registry on plain HTTP with an error in the log.

Environment

Renamed. These variables were JVC_*, which collided with the jvc client on a host running both. The old names are still read and warn on use; a later version will stop reading them. They are not ignored because an unset variable falls back to a default, and a configured deployment would serve a different database instead of failing.

JVC_TOKEN is unaffected: it belongs to the client.

VariableDefaultUsed byMeaning
REGISTRY_DBdata/decks.jsonboththe flatdb database path
REGISTRY_ADDR:8080servethe listen address
REGISTRY_URLunsetservethe canonical base URL advertised in discovery
REGISTRY_CLIENT_IP_HEADERunsetserveheader a trusted proxy puts the real client address in; read only on a connection from a loopback or private address
REGISTRY_POLICYderivedservewho may claim a scope: derived, operator, firstcome
REGISTRY_LOGunsetbothappend the operational log to this file
REGISTRY_LOG_LEVELinfobothdebug, info, warn, error
REGISTRY_LOG_FORMATlogfmtboththe file format: text, logfmt, json
REGISTRY_LOG_REQUESTS0serverecord one line per request
REGISTRY_IDENTITYgithubservewhich identity module: github, gitlab, gitea, forgejo, oidc
REGISTRY_IDENTITY_CLIENTIDunsetservethe OAuth application id
REGISTRY_IDENTITY_BASEURLunsetservethe provider's base URL, for a self-hosted instance
REGISTRY_IDENTITY_SECRETunsetservethe client secret, for a provider that needs one
REGISTRY_IDENTITY_SCOPESper moduleserveoverride the requested scopes
REGISTRY_ORG_MAX_AGE168servehours a login's organisation memberships stay usable; 0 is off
REGISTRY_AUTH_RATE_LIMIT30serverequests a minute one client address may make to the /auth routes; 0 is off
REGISTRY_WRITE_RATE_LIMIT60servewrites a minute per authenticated subject; 0 is off
REGISTRY_READ_RATE_LIMIT120serve/resolve-graph and full /search listings a minute per client address; 0 is off
REGISTRY_TOKEN_KEYunsetservethe HMAC secret bearer tokens are signed with
REGISTRY_CONFIGconfig.tomlservewhere the configuration file is
REGISTRY_TLS0serveserve HTTPS and obtain a certificate
REGISTRY_TLS_ADDR:8443servethe HTTPS listen address
REGISTRY_TLS_DOMAINSunsetservethe certificate's names, comma or space separated
REGISTRY_TLS_EMAILunsetservethe account contact the CA requires
REGISTRY_ACME_AGREE_TOS0serveaccept the CA's terms
REGISTRY_ACME_DIRECTORYLet's Encrypt productionservethe ACME directory URL
REGISTRY_TLS_CERTDIRdata/tlsservewhere the certificate is stored
REGISTRY_ACME_CHALLENGEDIRdata/acme-challengeservewhere challenge responses are written
REGISTRY_TLS_RENEW_BEFORE_DAYS30servehow early to renew
REGISTRY_FORGEgithubservewhich forge module: github, gitlab, gitea, forgejo
REGISTRY_FORGE_BASEURLunsetservethe forge's API root, for a self-hosted instance
REGISTRY_FORGE_TOKENunsetservea service token, where the forge needs one to read
REGISTRY_FORGE_HOSTunsetservethe hostname whose URLs this forge claims
REGISTRY_MAX_IN_FLIGHT32serverequests worked on at once (below)
REGISTRY_RESPOND_TIMEOUT120serveseconds before the server answers 503 itself (below)
REGISTRY_SOURCE_AUTHORITYchosenservehow a publish proves push access: login or service (below)
REGISTRY_TRUSTPUB_AUDIENCEunsetservethe aud a CI token must carry; gates trusted publishing
REGISTRY_TRUSTPUB_PROVIDERgithub-actionsservegithub-actions, gitlab-ci, or gitea-actions
REGISTRY_TRUSTPUB_ISSUERper providerservethe OIDC issuer, required for anything self-hosted

REGISTRY_IDENTITY_BASEURL and REGISTRY_FORGE_BASEURL must be https URLs, since users' provider tokens and the registry's forge token travel to them. An http URL is accepted only for a loopback host (localhost, 127.0.0.1, [::1]); anything else is refused at boot.

The server's console always receives the log as human-readable text; the file is an extra copy and the only one that survives a restart. Both binaries write to the same file: the server never sees a publish and deckadmin never sees a request. Nothing rotates it; point logrotate at it.

Behind a proxy the access log records the proxy unless you name the header carrying the real address. Unset is the safe default: the connection's peer cannot be forged, a header can be sent by anybody. Name one only if something in front of the registry sets it and strips any client-supplied copy; otherwise the audit log records what callers claim.

Behind Cloudflare use CF-Connecting-IP, which Cloudflare overwrites on every request, so it survives any number of hops. Behind one proxy alone use X-Forwarded-For; the last entry is taken, the address that proxy saw. The first entry is the client only when every proxy strips what the client sent, and a proxy that appends (the usual setting) leaves a forged one in front.

REGISTRY_LOG_REQUESTS is separate from the level because an access log is wanted at info far more often than the rest of debug, and a container with a 30-second health probe otherwise fills its log with probes.

REGISTRY_IDENTITY_CLIENTID and REGISTRY_TOKEN_KEY gate the login surface. Set both and the registry serves the token exchange, POST /claim, /owners, /yank and /unyank, and advertises them; leave either unset and those routes are not registered, no auth object appears in the discovery document, and a client reports that this registry accepts no logins. POST /publish is served either way, but without logins it accepts only trusted publishing and CI tokens.

REGISTRY_TOKEN_KEY is a secret of at least 32 characters (openssl rand -hex 32; a shorter one is refused at boot). Changing it invalidates every outstanding bearer token at once, the remedy if one leaks.

REGISTRY_TRUSTPUB_AUDIENCE gates publishing from CI (specification 8.9). Set it and POST /publish accepts a CI identity token in place of a bearer token, and the discovery document advertises the audience so a job knows what to ask its CI system for. It is not a secret, but it must be specific to this registry (its hostname, for example) so that a token minted for another service is refused.

The issuer's key set is fetched once at boot and held for the process, so a key rotation needs a restart. Fetching per request would add two round trips to every publish and make the registry unavailable whenever the issuer is.

REGISTRY_FORGE* configures how a publish reads a repository: resolving a tag to a commit and reading the manifest at it. Identity and forge are configured separately; they coincide only on github.com.

A missing database file opens as an empty registry, so a first run needs no seed file.

serve

sh
jennifer serve bin/serve
REGISTRY_DB=/srv/registry.json REGISTRY_ADDR=:9000 jennifer serve bin/serve

It serves the JSON routes in api.md and the website below. The write routes are POST /publish (always served), and POST /claim, /owners, /yank, /unyank and the token exchange (served only when logins are configured); everything else an operator changes goes through deckadmin on the server's filesystem.

Sizing: requests in flight

Every read shares one parsed copy of the database, parsed again only when the file changes (and at least every 30 seconds). A copy takes about 4 times the database file's size in memory (measured: a 3.9 MB file of 5,000 decks is about 15 MB), so concurrent lookups cost little. Every listing and search reads one shared deck index built from that copy, once per change of the file: at 5,000 decks building it takes about 0.7 seconds, and the request that triggers it waits that long while the others wait for its copy. Listing pages show 25 decks at a time (?page=), or 10, 50 or 100 (?size=); 100 is the most a page holds, so the measurements below are the largest page.

REGISTRY_MAX_IN_FLIGHT (maxInFlight in config.toml, default 32) bounds how many requests are handled at once; a request beyond the limit waits rather than being refused. A publish holds its place through its forge calls, so the default is not lower. Measured at 5,000 decks: with 8 in flight, twenty concurrent /browse requests peaked at about 0.2 GB; with 32, sixty-four concurrent /browse requests at about 0.3 GB and sixty-four searches for a term matching every deck at about 0.5 GB. A page of a listing answers in 10 to 50 ms once the index is built.

Slow uploads do not hold a place. The HTTP engine reads a request's body before the request takes one of the maxInFlight places, and answers 408 to a body that has not arrived within 10 seconds, so connections that trickle a body cannot stall other requests (measured: 40 such connections with the limit at 8 left health checks answering at once). Interpreters before 0.24.0-dev+167 take the place first, and there 8 such connections stop the server; run those behind a buffering reverse proxy.

A request not answered within REGISTRY_RESPOND_TIMEOUT seconds (default 120) is answered 503 by the HTTP engine, so a limit set too low for the load shows up as slow pages first and as those errors after. Keep the timeout above the slowest publish, which waits on several forge calls of up to 10 seconds each. A publish still waiting on its forge when the timeout passes is dropped, not recorded, and the operational log says so.

Repeat reads of pages and listings are answered 304 Not Modified from a tag of the database file without parsing it, so a browser or caching proxy in front costs nothing until the registry changes.

The website

Read-only: no form that writes anything, no session, no account UI.

RouteIs
/search, links to /browse and /tags, and the deck count (HTML), or the service index (JSON)
/search?q=results (HTML), or the JSON result set
/browseevery deck by deck name then scope, 25 to a page (?page=, ?size=10|25|50|100)
/tags, /tag/<keyword>the keywords in use, and the decks carrying one
/scope/<name>the decks under one scope
/deck/<scope>/<deck>a deck page: versions, pins, dependencies, engines, capabilities
/manual/the user manual: finding, using, and publishing decks
/reference/these pages, rendered
/specs/the server and client specifications

/ and /search pick their format from the request's Accept header, so a browser and a client can share one URL. Deck pages sit under /deck/ because /decks/:name/:version belongs to the JSON API and would match a two-segment scoped deck page as a name plus a version.

The three static sections are three Grimoire books; grimoire builds one source directory into one output directory. Each output directory must be named for the URL it serves at, because web.serveDir joins the whole request path onto the static root.

SourceConfigServed fromAt
manual/grimoire-manual.tomlpublic/manual//manual/
reference/grimoire.tomlpublic/reference//reference/
specs/grimoire-specs.tomlpublic/specs//specs/
sh
docker run --rm --pull always --user "$(id -u):$(id -g)" \
    -v "$PWD:/work" ghcr.io/jennifer-language/grimoire build --clean
docker run --rm --pull always --user "$(id -u):$(id -g)" -v "$PWD:/work" \
    ghcr.io/jennifer-language/grimoire build --clean \
    --config grimoire-manual.toml
docker run --rm --pull always --user "$(id -u):$(id -g)" -v "$PWD:/work" \
    ghcr.io/jennifer-language/grimoire build --clean \
    --config grimoire-specs.toml

public/ is gitignored, and the container image builds all three books in its own stage, so no rendered copy is committed or shipped stale. A deployment that has not built a section answers it with a page naming the command that produces it.

--clean empties the output directory first. Grimoire defaults to --no-clean, so without it a deleted or renamed page keeps serving under its old URL.

deckadmin

sh
jennifer run bin/deckadmin [logging flags] <command> [args]

Logging flags

These are root flags and go before the command, where the argument parser resolves them. deckadmin --log x.log add ... works; deckadmin add ... --log x.log is rejected as an unknown flag on add.

FlagEnvironmentDefaultMeans
--log <path>REGISTRY_LOGnoneappend events to this file
--log-level <level>REGISTRY_LOG_LEVELinfodebug, info, warn, error
--log-format <fmt>REGISTRY_LOG_FORMATlogfmttext, logfmt, json
-v, --verboseoffalso print each event to the console
-q, --quietoffsuppress the result message

A flag beats the environment, which beats the default, so a container can set REGISTRY_LOG once and a single command can still be redirected. --verbose is off by default because deckadmin already prints a result line; turn it on to read the structured form.

The file is the same one the server appends to. The server never sees a publish and deckadmin never sees a request, so only the shared file shows both.

CommandDoes
add <deck> <version> <url> [description] [flags]publish a version (an upsert; see below)
update ...an alias of add
remove <deck> [version]remove one version, or a whole deck (a scope: remove-namespace)
list [deck]list decks, or one deck's versions
register-namespace <scope> [flags]grant a scope to a principal, or reserve it
remove-namespace <scope>unregister a scope no deck exists under
add-owner <scope> <subject>let another principal write under a scope
remove-owner <scope> <subject>revoke a co-owner
namespaces [--owned|--reserved]list registered scopes and who owns each
trust <deck or @scope> <repositoryId> [flags]let a CI workflow publish a deck, or every deck in a scope (8.9)
untrust <deck or @scope>remove a trusted publisher
publisherslist trusted publishers
reserve-defaults [-n|--dry-run]hold every reserved scope so nobody can claim one
keywords [words...]print the refused-keyword list decoded, or encode terms for it
mint-token <scope> [flags]mint a CI token, shown once (8.10)
tokenslist CI tokens and when each was last used
revoke-token <fingerprint>revoke one CI token
yank <deck> <version>withdraw a version from new resolutions
unyank <deck> <version>restore a withdrawn version
revoke <subject>invalidate an identity's refresh tokens and drop its kept provider token

Organisation scopes

--org marks the owner as an organisation rather than a person. Every later write then asks whether the caller is an active member of that organisation.

sh
jennifer run bin/deckadmin register-namespace acme \
    --owner 77777 --login acme-inc --org

--owner is then the organisation's numeric id. Active members can publish under the scope and removed members cannot, with no list for an operator to maintain. Pending invitations and billing managers are excluded.

Memberships are read at login and carried in the registry's own token; nothing asks the provider again for them. The provider token is kept sealed for every login: each refresh uses it to check that the grant still stands (a revoked grant ends the login), and in login source authority a publish uses it to ask whether the caller may push. It is never used to re-read memberships. A refresh reissues them without renewing the timestamp, and refuses once they are older than REGISTRY_ORG_MAX_AGE hours (identity.orgMaxAge, default 168; 0 turns the check off). Somebody removed from an organisation therefore keeps write access for at most that window plus one bearer-token lifetime (1 h); a login without memberships is not affected.

Implemented for GitHub. The other identity modules return no memberships, which makes an organisation scope unwritable there rather than writable by anybody.

Co-owners

A scope has one owner and any number of co-owners. A co-owner publishes and yanks under the scope as the owner does but does not become the scope's identity; register-namespace remains the way to hand a scope on.

sh
jennifer run bin/deckadmin add-owner acme 67890
jennifer run bin/deckadmin remove-owner acme 67890

The argument is the principal id, not a login, so a rename never changes who may write; it is checked as a subject id under the scope's provider (numeric for all but OIDC), as POST /owners checks it. Removing is not checked that strictly, so an entry stored before the check can still be taken out. Co-owners live under the scope's own provider; the same numeric id at a different provider is a different person and is refused.

  • A reserved scope cannot take a co-owner. There is no owner to co-own with, and accepting one would leave a scope nobody holds that somebody can write to. Grant it first.
  • The owner cannot be removed this way. Losing the last owner would leave decks nobody can yank. Reassign with register-namespace instead.

The scope's owner can also add and remove co-owners itself, through POST /owners (jvc owners). A co-owner cannot: one compromised account could otherwise add others that outlive its own removal, or remove the rest.

Reassigning a scope to a different owner drops its CI tokens and trusted publishers, since the old owner granted them; the new owner mints and binds their own. Re-granting to the same owner keeps them. Removing a deck removes its trusted publisher too.

Removing a scope

remove-namespace undoes a mistaken grant or reservation, so the scope can be claimed again:

sh
jennifer run bin/deckadmin remove-namespace acme
  • Refused while any deck exists under the scope, yanked ones included. A name somebody installs must never pass to another publisher.
  • Refused for a scope on the reserved list. reserve-defaults holds those on purpose.
  • What goes with the record: the owner and the co-owners. The scope's CI tokens and trusted publishers are dropped, as on a reassignment.
  • The log records it as a warning, scope removed, with the former owner.

A deck removed with remove leaves no record behind, so its name cannot be told apart from one never used. Removing a scope's decks first therefore does free the scope; do that only when the names were never in use.

A team that only needs to publish rarely needs co-owners: a CI token scoped to the namespace, or a trusted publisher per deck, lets people and pipelines publish without a second administrative credential. Add a co-owner when somebody needs to manage the scope.

Handing a reserved scope to somebody

A reserved scope is registered with no owner, so the command that grants any scope hands it over:

sh
jennifer run bin/deckadmin register-namespace jennifer --owner 12345 --login mplx

This makes @jennifer, @official, or any other held name publishable; from then on the ordinary ownership rules apply. reserve-defaults does not take it back; it reports the scope as owned and leaves it alone.

namespaces shows which is which; --owned and --reserved filter, since a registry that has reserved the defaults holds scores of names:

namespaces: 1 owned, 76 reserved
  @jennifer  mplx [github 12345]
  @admin  (reserved, no owner)

reserve-defaults

Registers every scope in src/reserved.j as operator-held, so no self-service claim can take one. Run it on a fresh registry before it is public, and again whenever the list grows.

Idempotent. It never seizes a scope somebody already owns; it reports the conflict and leaves it. --dry-run prints the plan without writing.

The list holds names that would mislead a reader whoever owns them: official, admin, security, api, www, the project's own names, and placeholders like test and example.

Brand names are not on it. Under the derived claim policy, @microsoft can only be claimed by the account called microsoft, so the identity provider decides; a brand list has no clear edge and would imply that this registry adjudicates trademarks. Disputes go through the operator path.

mint-token

FlagDefaultMeans
--deck <name>nonenarrow it to one deck within the scope
--name <label>cia label, shown in listings
--days <n>90how long it lives (0 = never); a whole number, never negative

The fallback for non-interactive publishing where trusted publishing (8.9) is unavailable: a laptop, a cron job, a CI system that mints no identity token. Prefer trust where possible; a credential that does not exist cannot leak.

The scope must already be owned, as for trust: a token is a delegated credential, and a reserved scope has nobody to delegate for. The mint is refused, so the failure does not surface later at a publish.

The token is printed once and never stored; only its SHA-256 is kept, so a lost token is replaced, not recovered. It expires by default, since a forgotten token is the one that leaks; --days 0 must be asked for. Present it as Authorization: Bearer jvcp_...; the prefix distinguishes it from a login token and lets a secret scanner find one in a public repository.

tokens shows when each was last used, so unused ones can be found and retired. revoke-token drops one without disturbing the others.

A CI token is name authority only (specification 8.10): it says the holder may publish under the scope, and nothing about the repository. A publish with one is still checked for push access against the account it acts for: the minting account where the token records one, otherwise the scope's owner. So an operator-minted token needs an owner who can push to the repository and, in login mode, who has logged in since credentials were kept. An organisation scope has no person as its owner, so a token for one cannot pass this check.

yank

Withdraws a version from new resolutions. It stays fetchable, so a lockfile that already pins it keeps installing; for that reason deletion is not offered. Reversible with unyank.

Also available over HTTP as POST /yank and POST /unyank for the scope owner.

register-namespace

FlagDefaultMeans
--owner <subject>nonethe principal the scope binds to
--provider <name>githubwhich identity provider issued that subject
--login <name>nonethe owner's username, kept as a display label
--orgoffthe owner is an organisation; authorise by live membership (8.7)

The operator grant of section 8.2. It bypasses the policy, to cover what self-service derivation cannot: an organisation, a name the provider does not have, a dispute, a departure.

With --owner it grants. Without --owner it reserves: the scope is registered, so nobody can claim it, and bound to nobody, so nobody can publish under it.

Re-running it on an existing scope reassigns it. This is the only way a scope changes hands. A change of owner or provider drops the scope's co-owners, CI tokens and trusted publishers; re-running it for the same owner (to set --org, say) keeps all three and says so.

--provider must be one of the identity modules (github, gitlab, gitea, forgejo, oidc), and --owner a subject id under it (identity.subjectProblem). For GitHub, GitLab, Gitea and Forgejo that is the numeric account id, never the login: a login presents the number, so a scope bound to mplx would match nobody. The GitHub id is the id field of https://api.github.com/users/<login>. An OIDC sub is opaque and only has to be 1 to 255 printable ASCII characters without whitespace. Anything else is refused, since a scope bound to it could never be written under.

trust

FlagDefaultMeans
--provider <name>github-actionsgithub-actions, gitlab-ci, or gitea-actions
--repository <path>nonethe repository path, a display label
--workflow <path>nonethe workflow file permitted to publish
--refs <pattern>refs/tags/*which refs may publish

Publishing from CI with no stored credential (specification 8.9). The workflow presents the identity token its CI system mints for the job; the registry verifies it against the issuer's keys and matches its claims to this binding.

The positional is the repository id, not its path: the immutable numeric id the forge assigns. A path can be renamed and the freed path registered by somebody else, as a username can, so a binding on the path would follow the name rather than the project. Find it with:

sh
gh api repos/acme/deck-routeros --jq .id

The scope must already be owned: a binding is a standing grant to write under it, so it is gated as a publish is. A binding on a deck with no versions is pending until the first publish, so a deck can be bound before it exists. A pending binding unused for 30 days expires: publishes through it are refused, trust lists it as [pending, expired], and registering it again renews it.

trust @acme <repositoryId> binds a whole scope instead: every deck under @acme, existing or not, may be published by that workflow. A deck's own binding takes precedence over its scope's. Scope owners register bindings themselves with POST /publishers (see the API reference); trust is the operator's path.

--workflow and --refs stop other jobs in that repository from publishing. Without a workflow, a pull-request build proposed by a contributor runs with the same repository identity as the release job.

--owner and revoke both take the subject identifier the identity provider issued (a numeric id on GitHub), not a login, because a login can be renamed or released to somebody else while the subject cannot.

sh
# @acme belongs to GitHub user 1234567, whatever they call themselves later
jennifer run bin/deckadmin register-namespace acme --owner 1234567 --login alice

# hold @jennifer against anybody claiming it
jennifer run bin/deckadmin register-namespace jennifer

revoke drops every refresh token the subject holds, so the holder cannot mint new bearer tokens; bearer tokens already issued are signed and stateless and expire on their own within an hour (TOKEN_TTL in bin/serve).

Running against something other than GitHub

A private registry usually delegates identity to its own SSO and hosts code on its own forge. These are two independent choices (specs-server.md section 12):

RoleAnswersExamples
identity providerwho is this caller, stably?GitHub (default), Authelia, GitLab, Gitea, Forgejo, any OIDC provider
forgewhat is in this repository, and who may push?GitHub (default), GitLab, Gitea, Forgejo

On github.com one service plays both roles, so the defaults look like a single setting. Inside an organisation they are usually different services, and the registry must be configured for each.

Before deploying:

  • Scopes should be operator-granted, not derived. Derived claiming stops strangers squatting names; inside an organisation there are none, and an operator-granted namespace maps onto teams rather than onto usernames. Section 12.5 makes the policy configurable and recommends operator for private deployments.
  • Source authority needs a route to the forge. Every publish by a person or a CI token asks the forge whether that account may push to the repository (specification 7.1, route A), and refuses unless the answer is yes. REGISTRY_SOURCE_AUTHORITY (forge.sourceAuthority) says how:
ValueAsks withUse when
loginthe caller's own provider token, kept sealed from their loginidentity and forge are one service
serviceREGISTRY_FORGE_TOKEN, about the forge account linked at loginidentity and forge differ but share one user directory

Unset, it is login when identity and forge are the same service (same provider family and base URL) and service otherwise. The server refuses to start with login across two services, or with service and no forge token.

The token is kept in both modes (refresh re-checks the grant with it), sealed with AES-256-GCM under a key derived from REGISTRY_TOKEN_KEY, replaced at each login, never returned, and dropped by revoke. Rotating the signing key makes every kept token unreadable, so those accounts are asked to log in again. Accounts that logged in before tokens were kept are in the same position.

In service mode each login is linked to a forge account: the registry looks up the username the identity provider asserted and links the forge account that reports exactly that username, recorded by its immutable id. A publish then asks about that account under its current username, so later renames on either side do not matter. This assumes both services read one directory (one LDAP or OIDC source); display names are never matched (12.5). An account with no link (it never logged in since, or the forge has no such user) cannot publish until it logs in again, and a CI token acts through the link of its minter or the scope owner. The forge token must see every repository decks are published from, and be allowed to look users up.

There is one module per provider, src/identity/<name>.j and src/forge/<name>.j, each exporting a provider() that returns a struct of func values the core calls without knowing which module supplied them. Before adding one:

  • Self-hosted is configuration, not a new module. GitLab, Gitea, and Forgejo self-hosted speak the same API as their hosted counterparts and differ only in base URL, so there is one module per API family. Forgejo is a Gitea fork and needs no module of its own. A generic OIDC module covers Authelia and anything else standards-compliant; write a bespoke module only for a service that deviates from the standard.
  • A pair carries policy, and policy may only subtract. Here a policy decides who may claim a scope (operator, derived, firstcome); reserved names are operator records. Narrowing the name grammar, which the specification allows, is not built. A policy may never widen section 2.1, because a name outside it produces records a conforming client cannot install.

The identity modules for GitHub, GitLab, Gitea, and generic OIDC, and the forge modules for GitHub, GitLab, and Gitea, exist behind that interface. Organisation memberships are read from GitHub only; the others report none.

The grammar is declared once with args, which generates the help text. --help works at both levels:

sh
jennifer run bin/deckadmin --help         # the subcommands
jennifer run bin/deckadmin add --help     # add's positionals and flags

The process exits non-zero when a command fails, so it composes in a script. A malformed command line (an unknown flag, a missing positional, a bad value) is reported the same way, before anything touches the store.

Flags for add and update

FlagMeaning
--ref <tag>the tag this version was published from (git)
--commit <sha>the full 40-character lowercase SHA that tag pointed at (git)
--checksum sha256:<hex>the digest of the uploaded artifact (tar.gz)
--requires "dep constraint, ..."this version's runtime dependencies
--engines "engine range, ..."the interpreters that can run it
--capabilities "net, exec"the host capabilities its code needs

Flags may appear anywhere; they are stripped before the positional arguments are read, so [description] keeps its place whatever precedes or follows it. In --requires and --engines, each comma-separated entry is a name constraint pair separated by a space, and an entry with no space defaults to *.

add over an existing version replaces the whole record: a yanked flag, and the licence and keywords a POST /publish recorded, are reset, so re-add a yanked version only to restore it.

The refused-keyword list: keywords

With no arguments, prints the refused-keyword list decoded, by group. With terms, prints each folded and base64-encoded, ready to paste into src/keywords.j, and says whether it is already refused. It reads no registry and takes no lock.

The two delivery kinds

The pin supplied selects the kind; supplying the wrong one is an error.

git, the normal case. Pass --ref and --commit together. The commit is the integrity boundary: git verifies object hashes on fetch, so the commit that arrives is the commit that was published, and no checksum is recorded. The tag is kept for display and provenance only. The registry hands out the commit, so force-pushing the tag afterwards changes nothing for consumers.

sh
jennifer run bin/deckadmin add @acme/routeros 0.1.0 \
    https://github.com/acme/deck-routeros.git \
    --ref v0.1.0 --commit 9f2c1d4e5a6b7c8d9e0f1a2b3c4d5e6f70819293 \
    "MikroTik RouterOS client" \
    --requires "@acme/net ^1.0.0" \
    --engines "jennifer >=0.24.0" \
    --capabilities "net"

An abbreviated SHA is rejected: a version is pinned to the whole hash, and a short prefix can turn ambiguous as a repository grows.

A --ref shaped like an object id is rejected too: 7 to 64 hex digits, in either case. A git ref and a git object id share one syntactic space, so a tag called 4a3b1c... is ambiguous with the commit of that id, and wherever a name is resolved before an object it answers in the commit's place. The fetch then verifies an object hash correctly, but the object is not the pin. The publish endpoint refuses such a ref as well, since a record written here is indistinguishable from one written over HTTP. The commit is the integrity boundary only if the client asks for the object and checks what it got, which the client specification section 4.1.1 requires.

tar.gz, for an uploaded artifact. Pass --checksum instead. Uploaded bytes are stable, so the digest is meaningful and is verified before unpacking.

sh
jennifer run bin/deckadmin add @acme/routeros 0.1.0 \
    https://example.com/routeros-0.1.0.tar.gz \
    --checksum sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 \
    "MikroTik RouterOS client"

Do not point a tar.gz version at a GitHub-generated source tarball. Those bytes are not stable over time, so a digest over one can stop matching without anybody changing anything. Publish such a deck as git.

Scopes

Every registry deck is scoped, as @scope/deck. A bare name is a module bundled with the interpreter or a local file, so add rejects it.

A scope must be registered before anything can publish under it:

sh
jennifer run bin/deckadmin register-namespace acme     # or @acme
jennifer run bin/deckadmin namespaces

Registration is idempotent and is the operator's grant. The specification also describes deriving scope ownership from a GitHub account; see specs-server.md section 8.

Docker

sh
REGISTRY_UID=$(id -u) REGISTRY_GID=$(id -g) docker compose up -d --build
docker compose logs -f
docker compose exec jvc jennifer run bin/deckadmin list
docker compose down

The image builds on the official interpreter image, ghcr.io/jennifer-language/jennifer, whose module library ships everything this app imports (webapi, args, html, http, web, flatdb, semver), so imports resolve with no -I flag. The documentation is rendered in its own stage.

Who the container runs as

The database is bind-mounted from ./data, so it is readable on the host and survives a rebuild.

A bind mount keeps the host's ownership. The container process is a numeric uid to the kernel, so if it does not match the host directory's owner, every write is denied. store.save is crash-atomic and writes a temp file beside the target, so the directory must be writable, not only the file. The error looks like:

fs.writeString: /app/data/decks.json.tmp.59fcd1d6-...: permission denied

Two build arguments set the runtime user:

ArgumentDefaultMeaning
UID10001the uid the server runs as
GID999its gid

The Dockerfile defaults are the base image's jennifer user, which suits a named volume: Docker initialises one from the image, so ownership matches. Compose passes ${REGISTRY_UID:-1000} / ${REGISTRY_GID:-1000} instead, because it bind-mounts.

sh
REGISTRY_UID=$(id -u) REGISTRY_GID=$(id -g) docker compose up -d --build

Put them in a .env beside docker-compose.yml to avoid repeating them.

They are not called UID and GID at the compose level because bash makes UID readonly and does not export it, so ${UID} in a compose file resolves to nothing and the default is used without warning.

The uid need not name an account inside the container: the USER is numeric, so any host id works without a passwd entry.

Started as root instead (user: "0:0"), bin/entrypoint.sh chowns the data directory to the build-time uid and gid when they differ, then drops to them before running the interpreter, so the registry never runs as root. Its variables are REGISTRY_DATA_DIR (default /app/data) and REGISTRY_RUN_UID / REGISTRY_RUN_GID, which the image bakes from the build arguments.

Before changing the base:

  • The image is pinned to the 0.25.0 release. Every .j file here declares pragma-jennifer-version: >=0.25.0, and so does the bundled webapi module, so no older release runs it. Move the pin only once the tests pass and the server starts on the newer version.
  • dev is a moving tag. A base taken from it needs --pull, or a stale local copy is used without warning: docker run does not re-check a tag it already has, and an older dev predates webapi.

There is no curl in the base image, so the container healthcheck is bin/healthcheck, which polls /health and exits non-zero when it does not answer. It probes http://127.0.0.1:8080/health and does not follow REGISTRY_ADDR; REGISTRY_HEALTHURL overrides the URL.