M8-10: Login and API client of the import tool¶
Status: ready | Depends on: M1-38, M1-71 | Milestone: M8
Goal¶
After this ticket the import tool can talk to a Bothy server as its owner without ever holding step-up: bothy import login --server <url> creates a device with platform = cli (an Ed25519 DPoP key, no step-up key), starts a device pairing (beginPairing), prints the pairing code alone on standard output, polls completePairing until the owner approves the code in the app or the web console (approvePairing with step-up there) and stores device ID, DPoP key and tokens in a session file that only the user can read; bothy import logout ends the session on the server and deletes the file. A bothyapi package gives the later tickets an authenticated client of the API generated from docs/api/openapi.yaml: every request carries a DPoP proof (RFC 9449, htu from the server URL, ath, nonce handling with one retry on use_dpop_nonce), an expired access token is refreshed once under a file lock, the rotated refresh token is written to the session file before it is used, 429 of the server is honoured with Retry-After within the tool's wait limits, and no token, key or session file content ever reaches output, log or error text. Why: the tool reads and writes only through the public API (22 2, R-IM-01), the dry run must be able to read Bothy, and a cli device is the only way a tool logs in (08 6.4); the step-up steps stay in the web console (22 8, D-W-124).
Read first¶
docs/spec/22-import-migration.md: 2, 4.3 item 5 (Bothy429), 8 (the paragraph on theclisession and the owner steps in the web console), 10.1 (rowsbothy import loginandlogout), 10.2 rules 4 and 5 (no secret in output; the dry run may refresh and rewrite only the session file), 14, 19.docs/spec/08-identity.md: 5.1 (registration fields), 5.2, 5.3 (device pairing), 6.4 (command-line tools: no step-up key, no step-up), 7.1, 7.2, 7.3 (DPoP: algorithms, nonces,htu,ath, errors), 7.4 (refresh rotation, reuse grace, reuse revokes the family), 13 (rate limits of public auth endpoints), ID-T29.docs/spec/06-api.md: "Code generation" (clients are generated fromopenapi.yaml), 7 (idempotency keys for the tool's writes).docs/spec/02-architecture.md3.3 (rowbothy import login|logout), 6, 7.2.docs/spec/19-security.md8 (egress), SEC-TLS-4.docs/api/openapi.yaml:beginPairing,completePairing,refreshToken,logout,getMe, schemasDeviceRegistration,PairingStarted,PairingCompleteRequest,TokenResponse,DevicePlatform, parameterDpopProof, the401problem for DPoP.docs/DECISIONS.md: D-W-100, D-W-120, D-W-124; section 10 format.- Briefs (merged names win, D-W-100):
M1-33-identity-devices-dpop.md(kernel/jose, the DPoP checks this client must pass,platform = cliwithout step-up key),M1-34-identity-sessions.md(refresh families, reuse grace),M1-38-identity-device-approval.md(pairing operations and states),M1-17-api-server-stubs.md(oapi-codegen pinned ingo.mod, theapicomponent),M1-22-egress-client.md,M1-71-contract-tests.md(the real-server harnessharness.Start,harness.NewUser),M8-01-discord-read-client.md(tool layout, the rate-limit guard). - Code on the base branch:
services/server/api,services/server/internal/kernel/jose,services/server/internal/platform/egress,services/server/cmd/bothy.
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory (tests pass a session file path insidet.TempDir()). - Limits, windows and thresholds only through named configuration keys listed in
docs/operations/configuration.md; code comments and docs name the key, never the number. - Specification first: a change to behaviour, API, event, right or configuration updates
docs/spec/ordocs/api/in the same change. The core contract (docs/spec/00-glossary.md,03-data-model.md,04-sync-protocol.md,05-actions.md,actions.yaml) is not edited by this ticket. Everything this ticket adds is English.
Product requirements implemented here:
- The tool logs in only as a device with
platform = cli, without a step-up key, through device pairing; it never asks for or stores a password, passkey or step-up (08 6.4, R-ID-17). - DPoP on every authenticated request and on the pairing calls:
typ = dpop+jwt,alg = EdDSA,jwkwithout private part,jti,htm,htu(origin of the server URL plus path, without query and fragment),iat,athwith a token,nonceonce the server sent one (08 7.3). - Refresh rotation: the new refresh token is stored before the new access token is used; a refresh happens under an exclusive lock of the session file so two tool processes never rotate the same token (08 7.4: a reuse would revoke the session).
- The session file is created with mode
0600in a directory with mode0700; a file or directory that group or others can read or write is refused (on Windows the file lives in the user profile and the mode check is skipped, documented). Its content never reaches output, log or error. - Every connection goes through
platform/egress:httpsonly, except a loopback server (127.0.0.0/8,::1,localhost) for whichhttpis allowed; the server host is the only allowed host of the client and may resolve to a private address (the owner names it explicitly); no environment proxy; redirects are not followed. - Server
429: waitRetry-Afterand retry at mostimport.discord.max_429_retriestimes, abort when the wait is longer thanimport.discord.max_rate_wait(22 4.3 item 5). - Clients are generated from
openapi.yaml; no hand-written request or response type for an operation of the contract (06 "Code generation", owner rule 3).
Scope¶
All paths under services/server/ unless they start with docs/. Every new Go file starts with // SPDX-License-Identifier: Apache-2.0.
Create:
api/restclient/oapi-codegen.yamland the generatedapi/restclient/restclient.gen.go(Design note 1); ago:generateline inapi/generate.go.cmd/bothy/importtool/bothyapi/:session.go(session file format, load, atomic store, lock),dpop.go(proof builder overkernel/jose),transport.go(thehttp.RoundTripperchain of note 3),client.go(New(ctx, Options) (*Client, error)returning the generated client with the transport, plusMe(ctx)andLimits(ctx)helpers),login.go(Login,Logout),errors.go,doc.go,README.md.cmd/bothy/importtool/bothyapi/bothytest/: a fake Bothy for the auth paths (note 6); only tests import it.cmd/bothy/import.go(or the file layout the mergedcmd/bothyuses for subcommands): the command groupbothy importwithloginandlogout(note 5);discordfollows in M8-04.- Tests named under "Tests".
Change:
docs/spec/22-import-migration.md19: new tool keyimport.login_poll_interval(default5s, "interval between polls ofcompletePairingduringbothy import login; the server limits public auth calls byratelimit.rl.public_auth.*"), and the session file and lock in 10.1;make config-reference,make config-doc-check.services/server/api/README.md(the client package),services/server/README.md(the import commands),services/server/CHANGELOG.md;docs/DECISIONS.mdsection 10 rowsD-M8-10-1toD-M8-10-7.
Out of scope¶
bothy import discordand its options, the tool configuration loader (--config, environmentBOTHY_IMPORT__...) beyond whatloginneeds (M8-04 owns the loader; this ticket readsimport.login_poll_interval,import.discord.max_429_retriesandimport.discord.max_rate_waitthrough a smallLimitsstruct that M8-04 fills from its loader; until then the defaults of 22 19).- Any step-up, any
import.managecall (web console, M8-08). - Device approval in the apps or the console (M1-38, M4, M6).
- Changes to the core contract.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M8-10-import-login-client; one writer per worktree. - Conventional Commits with the component as scope (for example
feat(import): ...,build(api): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Never contact Discord or any other real service: tests use only fixtures and fakes on
127.0.0.1with port 0 (D-W-124); the real-server test uses the harness of M1-71 withpgtest(P-09, P-10). Without a Docker-compatible runtime that test fails; it never skips. - Never write a session file outside
t.TempDir()in a test; never read the developer's real configuration directory. - Stop only processes you started, by PID. Temporary directories only via
t.TempDir(), removed at the end. - Never read or print secrets.
- Time only through
kernel/clock; tests use a fake clock. - Never lower a threshold, disable a gate or delete a test.
- If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11). - This brief was written before milestone M1 closed (D-W-100, extended to M8 by D-W-124). Where the merged code of an earlier ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>.
Design notes¶
-
Generated client (06 "Code generation"; Ruling for docs/DECISIONS.md).
api/restholds the server of the enabled operations and filters the spec by the enable list, so a client of all operations cannot share its request body types. Generate a separate packageapi/restclientwith the oapi-codegen pinned ingo.moddirectly fromdocs/api/openapi.yamlwithgenerate: {client: true, models: true},output-options: {skip-prune: true}, packagerestclient, outputrestclient.gen.go, no embedded spec.make codegen-checkregenerates it. The tool usesClientWithResponseswith its ownHTTPClient(the egress client's*http.Clientwith the transport of note 3). If oapi-codegen v2.8.0 cannot generate a part of the spec as a client (report which), stop withVerdict: BLOCKEDrather than writing that part by hand. -
Session file (08 6.4, 7.4; Ruling for docs/DECISIONS.md). Default path
<os.UserConfigDir()>/bothy/import-session.json, overridable with--session-fileon everybothy importcommand. JSON{version: 1, server_url, device_id, user_id, dpop_private_jwk, access_token, access_expires_at, refresh_token, refresh_expires_at}. Store atomically: write a temp file in the same directory with mode0600,fsync, rename,fsyncthe directory. Lock: an exclusive advisory lock on<file>.lock(flockon Unix,LockFileExon Windows) around every read-refresh-store sequence; after acquiring it, re-read the file, and when another process already rotated the tokens use those instead of refreshing again. A missing file means "not logged in" (exit2in M8-04 with the hint to runbothy import login).String,GoStringand%+vof the session type print onlyserver_urlanddevice_id. -
Transport chain (08 7.3, 22 4.3 item 5). Per request: take the access token (refresh first when the stored
access_expires_athas passed; the tool knows no server key and trusts only the stored expiry and a401); setAuthorization: DPoP <token>and a fresh proof (jtirandom 128 bits,iatnow,htufrom the storedserver_urlorigin plus the request path,athbase64url SHA-256 of the token,noncethe lastDPoP-Nonceseen); on401withWWW-Authenticate: DPoP error="use_dpop_nonce"take the new nonce and retry once; on a401whoseWWW-Authenticatesayserror="invalid_token"refresh once and retry once; on429wait per requirement 16; never follow redirects. Request bodies are replayable (GetBody) for the retries. Writes that the contract makes idempotent by header carry anIdempotency-Keygenerated per logical call and kept across retries. -
Refresh (08 7.4).
refreshTokenwith the DPoP proof of the same key; on success store first, then return; a401with a family revocation (refresh_reuseorinvalid_grantas the merged server names it) deletes nothing but reports "session revoked, run bothy import login" (exit2in callers). The tool never retries a refresh with an older token. -
bothy import loginandlogout(22 10.1, 02 3.3).login --server <url> [--name <device name>] [--session-file <path>] [--config <file>]: refuse when a valid session file for the same server exists (exit2, hintlogout); check the URL (requirement 15; the URL is an origin without path, likeserver.public_url); generate an Ed25519 key and a UUIDv7 device ID;beginPairingwithdevice{device_id, name (default "bothy import on <hostname>" cut to the device name limit), platform: cli, app_version: <build version>, public_jwk, alg: EdDSA}and nostep_up_key; print thepairing_codealone on standard output and the instruction on standard error ("In the Bothy app or web console choose Add device and enter this code; it expires at -
Fake Bothy for auth (
bothytest). Anhttptestserver on127.0.0.1:0that implementsbeginPairing,completePairing(202until the test approves, then201;410modes),refreshToken(rotation with family and reuse detection, a reuse revokes),logout,getMe, and one protectedGETroute; it verifies every DPoP proof (signature,htm,htu,ath,iat,jtireplay, nonce withuse_dpop_nonceon the first request and on rotation), counts methods per route, can answer429withRetry-After, and echoes tokens into error bodies for the leak test. M8-04 extends it with the import routes. -
Rulings for
docs/DECISIONS.md(D-M8-10-<n>): the separate generated client package (note 1); the session file format, location and modes (note 2, requirement 14); the lock and re-read rule (note 2);httponly for loopback servers (requirement 15); the new keyimport.login_poll_interval; the default device name;EdDSAas the DPoP algorithm of the tool.
Tests (write them first)¶
bothyapi/dpop_test.go: proofs verified by the verifier of M1-33 (kernel/joseand the identity DPoP check throughidentitytest, test-only import):htuwithout query,athmatches, nonce present after the firstuse_dpop_nonce.bothyapi/login_test.goagainstbothytest:TestLoginPairing(code on standard output only, session file with mode0600after approval,platform = cli, no step-up key in the registration);TestLoginExpired,TestLoginRejected,TestLoginInterrupted(no file written);TestLoginRefusesHTTPForNonLoopback;TestLogoutDeletesFile.bothyapi/session_test.go:TestSessionFileModes(a file or directory readable by group or others is refused; skipped only on Windows with a reason);TestAtomicStore(a crash simulated between temp write and rename leaves the old file intact);TestConcurrentRefreshRotatesOnce(two clients in parallel on one session file: exactly onerefreshTokenat the fake, no reuse revocation).bothyapi/transport_test.go:TestNonceRetryOnce;TestRefreshOnInvalidToken;TestRefreshStoredBeforeUse(the fake asserts that the file already holds the new refresh token when the first request with the new access token arrives);TestBothy429(wait recorded, limit abort);TestNoRedirectFollowed.bothyapi/secret_test.go: with echoing fakes, no access token, refresh token, private key or file content in any log line at debug level, standard output, standard error or error text.test/importtool/login_test.go(componenttestsupport): against the real server of the harness (M1-71) with Robin as owner on an active device:bothy import loginpairs, Robin approves withapprovePairingand step-up from his test device, the tool reachesgetMe;createStepUpChallengefrom the tool's session answers403(ID-T29);approvePairing(an action with step-up of M1-38) from the tool's session answers401 step_up_required(08 6.4 item 3).
Commands that must pass, from the repository root:
cd services/server && go test ./cmd/bothy/... ./test/importtool/... -race -count=1make codegen-check,make config-reference,make config-doc-check,make arch,make lint,make fmt-check,make semgrep,make coveragemake check
Acceptance criteria¶
- Login through pairing works against the fake and the real server; the device is
cliwithout a step-up key and cannot provide step-up. - The refresh rotation is stored before use, happens once under the lock, and a DPoP nonce retry works.
- A session file readable by others is refused; the file content never reaches output or log.
- The API client is generated from
openapi.yaml. - 22, the configuration reference, READMEs, CHANGELOG and rulings are updated.
-
make checkis green.
Definition of done¶
- Code and tests as specified; doc comments on every exported Go symbol; comments explain why.
cmd/bothy/importtool/bothyapi/README.mdand the touched READMEs in English.- Rulings of Design note 7 recorded in
docs/DECISIONS.mdsection 10. services/server/CHANGELOG.mdentry under[Unreleased].- An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make check. - Review and red team verdicts and the findings fixed.
- What is not proven (for example the Windows lock and file rules when no Windows runner ran the tests).
- Follow-ups that could not be done in this scope, each with its reason, every difference to the names of this brief (D-W-100), and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.