Providers
Each provider is a separate worker process in its own repository. Core never imports worker code — the contract is the proto messages + the NATS subjects (see Provider workers). You run only the workers you need.
Enrollment
In production each worker authenticates to NATS with a one-time enrollment token (NATS auth-callout mints scoped credentials; set it up as described in NATS auth-callout). Create an enrollment as an admin, in the admin UI (Worker onboarding) or over the API. The request is authenticated like any other API call: an admin's session cookie or personal access token.
curl -X POST https://cairn.example/api/admin/worker-enrollments \
-H "Authorization: Bearer cairn_pat_..." \
-H "Content-Type: application/json" \
-d '{"name": "strava-fetcher", "provider": "strava", "expires_in_hours": 720, "note": "prod Strava"}'
name(required): the worker name — lowercase letters, digits and hyphens, no leading or trailing hyphen. The worker'sCAIRN_WORKER_NAMEmust equal it.provider(required): the provider type the worker serves, set by the admin. It scopes the worker's NATS permissions. Any well-formed type is accepted, not onlystravaandgarmin: 1–48 lowercase letters, digits, hyphens or underscores ([a-z0-9_-]), so it can't carry NATS wildcards or subject separators. A missing or malformed provider is answered with400(provider required — lowercase letters, digits, hyphens, underscores). Since core v0.3.2; before that only the built-in providers were accepted.expires_in_hours(optional): defaults to8760(365 days), which is also the cap.note(optional): free text shown in the admin list.
The 201 response carries the enrollment record and the plaintext token,
shown once. The worker's configuration is CAIRN_WORKER_NAME (the name)
plus the token from the admin as CAIRN_WORKER_ENROLLMENT_TOKEN.
GET /api/admin/worker-enrollments lists enrollments and providers, the
built-in providers as suggestions (the admin UI offers them in the provider
field, which also takes any other well-formed type),
POST /api/admin/worker-enrollments/{id}/revoke revokes one and
POST /api/admin/worker-enrollments/{id}/prolong moves its expiry, even after
it has lapsed. Both take an optional JSON body: {"reason": "..."} for revoke,
{"expires_in_hours": 720} for prolong, with the same default and cap of
8760. The body can be left out; one that is not valid JSON is answered with
400.
Other providers
A worker for a provider core does not ship (say polar) is enrolled the same
way: the admin creates an enrollment with "provider": "polar" and hands the
worker its name and token. It is admitted and scoped to the polar subjects
only (cairn.jobs.*.polar, cairn.tokens.polar.>, …), never another
provider's. Linking user accounts for a new provider still needs core
support: OAuth connect and job dispatch use core's built-in provider list.
Re-enroll required
Before core v0.3.1 an enrollment carried no provider. Core refuses those at
connect (reason enrollment_missing_provider) and cannot fill the provider in
afterwards. The enrollment list marks them with "reenroll_required": true
and the admin UI with a re-enroll required badge: create a new enrollment
with a provider, give the worker its token, and revoke the old one.
NATS auth-callout
Core answers NATS auth-callout requests when CAIRN_NATS_AUTH_CALLOUT=true.
Core and the admitted workers share one NATS account, named by
CAIRN_NATS_WORKER_ACCOUNT; core connects as a user that bypasses the
callout. Worker enrollment under NATS auth requires core v0.3.1 or later.
-
Bootstrap the account signing key once:
POST /api/admin/nats/bootstrap-account-keyreturns itspublic_key. -
Run nats-server with this config (it is
docker/nats-auth-callout.confin cairn-core).$CAIRN_NATS_AUTH_ISSUER(the public key) and$CAIRN_NATS_PASSWORDare read from nats-server's environment.http_port: 8222jetstream { store_dir: "/data" }max_payload: 8MBaccounts {CAIRN {jetstream: enabledusers: [# core itself; listed in auth_users, so it bypasses the callout{ user: cairn-core, password: $CAIRN_NATS_PASSWORD }]}}authorization {auth_callout {issuer: $CAIRN_NATS_AUTH_ISSUERaccount: CAIRNauth_users: [ cairn-core ]}} -
Start core with
CAIRN_NATS_USERNAME=cairn-core, the sameCAIRN_NATS_PASSWORD,CAIRN_NATS_AUTH_CALLOUT=trueandCAIRN_NATS_WORKER_ACCOUNT=CAIRN. Core does not start if the auth-callout is on and the account is missing. -
Create an enrollment per worker (its
nameequal to the worker'sCAIRN_WORKER_NAME) and setCAIRN_WORKER_ENROLLMENT_TOKENon it. The Strava and Garmin workers (v0.3.1 or later) both present only the token on CONNECT.
Strava workers up to v0.3.0 also present an nkey and fail unless nats-server
sends a connect nonce. For them, add a seedless placeholder nkey user to the
CAIRN users, e.g.
{ nkey: UBZQLXX3CXHIQZ2VZ4WSKLWB3DOT6ZI4WMLTQHCFPRTFDQ2A7PPABH44 } (generate
your own with nk -gen user -pubout); core's docker/nats-auth-callout.conf
still contains one. Strava v0.3.1 and Garmin v0.3.1 do not need it.
A worker can still use a static NATS user (CAIRN_NATS_USER /
CAIRN_NATS_PASSWORD) instead, for example on a server without the
auth-callout; under the auth-callout add that user to the CAIRN account and
to auth_users. Grant it the permissions for its
provider.
Strava (Go)
Repo: cairn-provider-strava. Strava is read-only for Cairn. Each user brings their own Strava API app (per-user OAuth) for rate-limit isolation and webhook ownership; users connect their account from the Connections screen.
docker run --rm \
-e CAIRN_NATS_URL=nats://nats:4222 \
-e CAIRN_WORKER_NAME=strava-fetcher \
-e CAIRN_WORKER_ENROLLMENT_TOKEN=... \
ghcr.io/johnnycube/cairn-provider-strava:v0.3.1
Only one Strava worker instance should own the webhook subject — set
CAIRN_STRAVA_WEBHOOK_VERIFY_TOKEN on exactly one.
| Variable | Default | Notes |
|---|---|---|
CAIRN_NATS_URL | nats://localhost:4222 | CAIRN_NATS_USER / CAIRN_NATS_PASSWORD for authenticated servers |
CAIRN_WORKER_ENROLLMENT_TOKEN | — | one-time enrollment token from POST /api/admin/worker-enrollments |
CAIRN_WORKER_NAME | strava-fetcher | must equal the enrollment's name |
CAIRN_WORKER_INSTANCE_ID | the hostname | stable id when you run several instances |
CAIRN_STRAVA_WEBHOOK_VERIFY_TOKEN | — | set on exactly one instance |
CAIRN_STRAVA_TOKEN_URL | Strava's | override for testing against a mock |
CAIRN_LOG_LEVEL | info | debug, info, warn or error |
Garmin (Python)
Repo: cairn-provider-garmin. Garmin has no public activity OAuth API, so the
worker uses the garminconnect library (Garth SSO). Per-user credentials are
delivered over NATS. Like the Strava worker it enrolls with
CAIRN_WORKER_ENROLLMENT_TOKEN (v0.3.1 or later); without a token it connects
with CAIRN_NATS_USER / CAIRN_NATS_PASSWORD when set, otherwise anonymously.
docker run --rm \
-e CAIRN_NATS_URL=nats://nats:4222 \
-e CAIRN_WORKER_NAME=garmin-fetcher \
-e CAIRN_WORKER_ENROLLMENT_TOKEN=... \
-v garmin-tokens:/data \
ghcr.io/johnnycube/cairn-provider-garmin:v0.3.1
| Variable | Default | Notes |
|---|---|---|
CAIRN_NATS_URL | nats://localhost:4222 | CAIRN_NATS_USER / CAIRN_NATS_PASSWORD are used when set and no enrollment token is |
CAIRN_WORKER_ENROLLMENT_TOKEN | — | enrollment token from POST /api/admin/worker-enrollments (provider garmin); takes precedence over user/password |
CAIRN_WORKER_NAME | garmin-fetcher | must equal the enrollment's name; shared by every instance of the worker |
CAIRN_WORKER_INSTANCE_ID | random 12-hex id | unique per instance; in Kubernetes set it to the pod name so a restart reuses its presence key |
CAIRN_HEARTBEAT_INTERVAL_S | 20 | presence heartbeat to the cairn_worker_presence KV |
CAIRN_PULL_BATCH | 4 | jobs pulled per fetch |
CAIRN_GARTH_HOME | /data/garth in the image, ~/.cache/cairn-garth outside it | Garth token store, one subdirectory per account; keeps the Garmin session between jobs. Mount a volume on /data so MFA accounts are not re-prompted on every restart |
CAIRN_GARMIN_EMAIL / ..._PASSWORD | — | dev fallback only — in production each user's credentials are delivered over NATS |
CAIRN_PROTO_PATH | /opt/cairn-proto in the image, <repo>/proto outside it | location of the committed proto stubs |
The image runs as the unprivileged user cairn (uid/gid 10001) and declares
/data as a volume. The token directory must be readable and writable by that
uid: a fresh named volume or an emptyDir already is; a volume or bind mount
written by an image before 0.2.3 (which ran as root) needs
chown -R 10001:10001 once, and in Kubernetes a persistent volume needs
securityContext.fsGroup: 10001 on the pod. Without it the worker cannot read
or write cached tokens and logs in to Garmin on every job.
The worker cannot answer an MFA prompt. For MFA accounts, seed the token store once with an interactive login as described in the worker README, then mount it.
NATS permissions
When NATS runs with auth-callout, an enrolled worker gets core's standard
permission template, derived from the enrollment's provider and its worker
key <name>-<provider> (for example strava-fetcher-strava). For a provider
<p> and worker key <wk> it allows:
| Subjects | |
|---|---|
| Subscribe | cairn.jobs.{fetch_source,parse_blob,backfill,reconcile,import_metrics}.<p>, cairn.cmd.worker.<wk>.>, cairn.discover.<p> (serving discover requests), cairn.webhooks.<wk>.event, cairn.webhooks.<wk>.verify, _INBOX.> |
| Publish | cairn.results.{fetch_source,parse_blob,backfill,import_metrics}.<p>, cairn.results.reconcile.<p>.* (account-suffixed), cairn.jobs.fetch_source.<p> (sub-jobs from reconcile and webhooks), cairn.workers.<wk>.>, cairn.tokens.<p>.>, cairn.creds.<p>.get, cairn.blobs.presign_upload.<p>, cairn.blobs.presign_download.<p>, cairn.accounts.lookup_by_provider_ext, _INBOX.> |
| JetStream API | $JS.API.STREAM.NAMES (stream lookup by subject); consumer create on CAIRN_JOBS filtered to the five job subjects above and on CAIRN_WEBHOOKS filtered to cairn.webhooks.<wk>.event, plus $JS.API.CONSUMER.DURABLE.CREATE.CAIRN_JOBS.*; consumer info, MSG.NEXT and $JS.ACK.<stream>.> on both streams; stream info, direct get, message get and $KV.<bucket>.> on the cairn_worker_presence, cairn_worker_manifests and cairn_rate_limits KV buckets |
The job subjects are exact: core publishes cairn.jobs.fetch_source.<p> with
nothing after the provider, so consumers must filter on that subject (a
cairn.jobs.fetch_source.<p>.> filter matches nothing). Run core v0.3.1 or
later with NATS auth; earlier versions did not admit enrolled workers.
Installations running NATS without auth are unaffected.
If you give a worker a static NATS user instead (CAIRN_NATS_USER /
CAIRN_NATS_PASSWORD), grant that user the same subjects for its provider.
Writing your own provider
A provider only needs to: register via enrollment, consume the
cairn.jobs.<job_type>.<provider> subjects, and publish
cairn.worker.v1.JobResult events, staying within the
permissions above. Use
the committed proto stubs (Go or Python) as your contract. See
Provider workers.