Skip to content

Commit 72095c3

Browse files
committed
Add login/logout commands
Workshop participants already sign in to GitHub, Render, Neon, and Revyl with a browser-based login command. First Draft instead made them copy a token from /api-tokens into an environment variable, which is an extra manual step and easy to get wrong. A saved login only authenticates the host it was created for, and an environment variable can still be set to override it, so existing scripts and CI setups still behave as they did before.
1 parent b8fd697 commit 72095c3

24 files changed

Lines changed: 3614 additions & 71 deletions

‎CHANGELOG.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,24 @@ carry no release status. The `v<version>` tag and npm show whether a version is
99
Entries start with the first version prepared after this file was added, and earlier versions have none. The frozen
1010
[release history](docs/release-history.md) records releases through 0.4.0. The repository's
1111
[tags](https://github.com/firstdraft/cli/tags) identify the source of each published version, including later ones.
12+
13+
## 0.8.1
14+
15+
Adds `firstdraft login` and `firstdraft logout`, so a token no longer has to be copied from `/api-tokens` into the
16+
environment.
17+
18+
- `login` approves the CLI in a browser and saves a token for the selected origin. By default it uses the OAuth
19+
authorization code flow with PKCE and a loopback redirect. `login --interactive` (also `-i` or `--device`) uses the
20+
device flow for machines without a browser. `--staging` and `FIRSTDRAFT_API_URL` select the origin as they do for
21+
other remote commands.
22+
- Tokens are saved per exact origin in `$XDG_CONFIG_HOME/firstdraft/credentials.json`, or
23+
`~/.config/firstdraft/credentials.json`, with mode `0600`. The CLI never prints a saved token.
24+
- `logout` revokes the saved token for the selected origin and removes the local entry.
25+
- Remote commands use the saved token for their origin when `FIRSTDRAFT_API_TOKEN` or
26+
`FIRSTDRAFT_STAGING_API_TOKEN` is unset. A saved login never authenticates a different origin.
27+
- New `error` values: `authorization_denied`, `authorization_expired`, `login_failed`, and `logout_failed`. The
28+
`authentication_required` detail now points to `firstdraft login`.
29+
30+
Callers that set token environment variables need no change, because those variables still take precedence over a
31+
saved login. The release needs a Service that serves the `/oauth` endpoints. Those endpoints sit outside the
32+
versioned API, so the accepted API-contract range is unchanged.

‎README.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,8 @@ To exercise the checkout directly:
7676
node bin/firstdraft.js --help
7777
```
7878

79-
Remote commands default to production and read `FIRSTDRAFT_API_TOKEN`. Use `firstdraft --staging plan compile`
80-
and a separate `FIRSTDRAFT_STAGING_API_TOKEN` for staging. Existing Projects retain their saved origin. See
79+
Remote commands default to production. Run `firstdraft login` (or `firstdraft --staging login`) once, or set
80+
`FIRSTDRAFT_API_TOKEN` / `FIRSTDRAFT_STAGING_API_TOKEN`. Existing Projects retain their saved origin. See
8181
[environment selection](docs/commands.md#select-an-environment-and-authenticate) for custom URLs and credential
8282
isolation. Keep tokens out of arguments, shell history, fixtures, snapshots, and logs.
8383

@@ -98,7 +98,7 @@ The published package:
9898
- runs reviewed JavaScript source directly;
9999
- has no runtime dependencies or install scripts;
100100
- performs no telemetry, update check, or network request unless the caller invokes an API command;
101-
- reads Bearer credentials only from the environment; and
101+
- reads Bearer credentials from the environment or its private `firstdraft login` credentials file; and
102102
- carries npm provenance linking registry bytes to its GitHub workflow and commit.
103103

104104
The package includes this README, the documentation map, the command and error references, and the security

‎docs/commands.md‎

Lines changed: 106 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,63 +3,127 @@
33
This page owns the detailed public semantics of the current command surface. Run `firstdraft --help` or a command
44
group's `--help` for concise executable syntax. See [Errors and recovery](errors.md) before retrying a failed mutation.
55

6-
The current `0.8.x` source line contains the auditable command shell, local Foundation Plan initialization, local
6+
The current `0.8.x` source line contains the auditable command shell, browser and device login, local Foundation Plan initialization, local
77
application-key and UUID generation, conditional whole-document push, whole-graph analysis status polling, direct
88
Compile-and-materialize and private publish orchestration, and retained-Compilation inspection. CLI `0.8.x`
99
requires the service's `0.7.x` API contract. The
1010
[changelog](https://github.com/firstdraft/cli/blob/main/CHANGELOG.md) describes each version.
1111

1212
## Command map
1313

14-
| Command | Network | Purpose |
15-
| ------------------------------------- | ------- | ---------------------------------------------------------- |
16-
| `firstdraft plan init` | No | Create an empty local Foundation Plan and Project identity |
17-
| `firstdraft generate application-key` | No | Preview deterministic name-to-key derivation |
18-
| `firstdraft generate uuid` | No | Generate one or more Foundation Plan subject identities |
19-
| `firstdraft plan push` | Yes | Conditionally submit the exact whole Plan |
20-
| `firstdraft plan status` | Yes | Read or wait for the current whole-graph analysis |
21-
| `firstdraft plan compile` | Yes | Push and analyze, then materialize in the current folder |
22-
| `firstdraft plan compile --github` | Yes | Push and analyze, then publish to private GitHub |
23-
| `firstdraft compilation status` | Yes | Inspect a retained Compilation by ID |
24-
| `firstdraft compilation download` | Yes | Verify and materialize a successful retained Compilation |
14+
| Command | Network | Purpose |
15+
| ------------------------------------- | ------- | ----------------------------------------------------------- |
16+
| `firstdraft plan init` | No | Create an empty local Foundation Plan and Project identity |
17+
| `firstdraft generate application-key` | No | Preview deterministic name-to-key derivation |
18+
| `firstdraft generate uuid` | No | Generate one or more Foundation Plan subject identities |
19+
| `firstdraft login` | Yes | Approve the CLI in a browser and save a token for an origin |
20+
| `firstdraft logout` | Yes | Revoke and remove the saved token for an origin |
21+
| `firstdraft plan push` | Yes | Conditionally submit the exact whole Plan |
22+
| `firstdraft plan status` | Yes | Read or wait for the current whole-graph analysis |
23+
| `firstdraft plan compile` | Yes | Push and analyze, then materialize in the current folder |
24+
| `firstdraft plan compile --github` | Yes | Push and analyze, then publish to private GitHub |
25+
| `firstdraft compilation status` | Yes | Inspect a retained Compilation by ID |
26+
| `firstdraft compilation download` | Yes | Verify and materialize a successful retained Compilation |
2527

2628
## Select an environment and authenticate
2729

28-
Production at `https://firstdraft.com` is the default. Create a token at
29-
[First Draft](https://firstdraft.com/api-tokens) and provide it through `FIRSTDRAFT_API_TOKEN` when running a network
30-
command. Keep token values out of shell history and command arguments.
30+
Production at `https://firstdraft.com` is the default. Log in once per environment:
3131

3232
```sh
33-
firstdraft plan push
33+
firstdraft login
3434
```
3535

36-
For staging, create a separate token at [First Draft staging](https://staging.firstdraft.com/api-tokens), provide it
37-
through `FIRSTDRAFT_STAGING_API_TOKEN`, and select staging on the first remote command:
36+
The command prints an authorization URL on standard error. Open it in a browser on the same machine, sign in, and
37+
approve **First Draft CLI**. The CLI then saves a token and prints `Logged in to <origin>` on standard output.
38+
39+
For staging, select it on the login and on the first remote command:
3840

3941
```sh
42+
firstdraft --staging login
4043
firstdraft --staging plan push
4144
firstdraft plan compile --staging
4245
```
4346

44-
`--staging` may precede the command group or appear among a remote command's options. It selects
45-
`https://staging.firstdraft.com`. `plan init` and `generate` remain local; a global flag on a local command does not
46-
save an environment selection. The first successful push, including the push within `plan compile`, pins the API
47-
origin in `.firstdraft/state.json`.
47+
### How login works
48+
49+
The default login uses the OAuth 2.0 authorization code flow with PKCE (`S256`) and a loopback redirect
50+
(RFC 8252). The CLI listens on `127.0.0.1` at an ephemeral port for one `GET /callback`, sends a random `state`
51+
and code challenge, and waits up to five minutes. Only a callback with the exact `state` is accepted. Requests
52+
for other paths, with a missing or different `state`, or with another `Host` get an error page and do not end
53+
the wait, so a stray local request can neither inject a code nor cancel the login. The first matching callback
54+
closes the listener and shows a page that says you can close the tab. The CLI then exchanges the single-use code
55+
and its PKCE verifier at `POST /oauth/token`. The token never appears in a URL.
56+
57+
On a machine without a browser, such as an SSH session or a container, use the device flow (RFC 8628):
58+
59+
```sh
60+
firstdraft login --interactive # also -i or --device
61+
```
62+
63+
The CLI prints a verification URL, a short user code, and a URL that already includes the code. Open either URL on
64+
any device, confirm the code, and approve. The CLI polls at the server's interval, adds five seconds when asked to
65+
slow down, and stops when the code expires.
66+
67+
Both flows send this machine's hostname as the device name shown on the approval page and in the token's name on
68+
`/api-tokens`. A denied approval exits with `authorization_denied`. A login that is not approved in time exits with
69+
`authorization_expired`. Other failures exit with `login_failed`. No credential is saved unless the login succeeds.
70+
See [Errors and recovery](errors.md#login-and-logout-errors).
71+
72+
### Saved credentials
73+
74+
The token is saved in `$XDG_CONFIG_HOME/firstdraft/credentials.json`, or `~/.config/firstdraft/credentials.json`
75+
when `XDG_CONFIG_HOME` is unset or not absolute. The directory is created with mode `0700`. The file is written
76+
with mode `0600` through a temporary file and an atomic rename. Each update holds `credentials.json.lock` in the
77+
same directory from its read to its rename, so concurrent commands cannot drop each other's entries. An update
78+
waits up to 10 seconds for another command's lock, then fails with reason `credentials_locked`. The CLI never removes
79+
a lock by itself: if no other `firstdraft` command is running, the lock was left by one that exited mid-update, and
80+
you can delete it. The file stores one entry per exact origin, so production, staging, and local development logins
81+
can exist side by side. Logging in again to the same origin replaces its
82+
entry. Run `firstdraft logout` first if the old token should also be revoked. The CLI never prints a saved token or
83+
writes it to `.firstdraft`.
84+
85+
`firstdraft logout` (or `firstdraft --staging logout`) asks First Draft to revoke the saved token for the selected
86+
origin through `POST /oauth/revoke`. It then removes the local entry even when revocation cannot be confirmed; in
87+
that case it says so on standard error, and you can revoke the token on `/api-tokens`. With nothing saved, it
88+
reports that and exits 0. If a newer login saved a different token for the origin while revocation was pending,
89+
logout keeps that token and says so on standard error. If the local update fails after revocation, logout exits with `logout_failed` and reports
90+
whether revocation was confirmed; see [Errors and recovery](errors.md#login-and-logout-errors). Logout never changes
91+
token environment variables.
92+
93+
### Environment tokens
94+
95+
Tokens from the environment still work and take precedence over a saved login. Create one on
96+
[First Draft](https://firstdraft.com/api-tokens) and provide it through `FIRSTDRAFT_API_TOKEN`. For staging, create a
97+
separate token at [First Draft staging](https://staging.firstdraft.com/api-tokens) and provide it through
98+
`FIRSTDRAFT_STAGING_API_TOKEN`. Keep token values out of shell history and command arguments. `login` and `logout`
99+
note on standard error when an environment token will override the saved login.
100+
101+
### Origins and projects
102+
103+
`--staging` may precede the command group or appear among a remote command's options, including `login` and
104+
`logout`. It selects `https://staging.firstdraft.com`. `plan init` and `generate` remain local; a global flag on a
105+
local command does not save an environment selection. The first successful push, including the push within
106+
`plan compile`, pins the API origin in `.firstdraft/state.json`.
48107

49108
Existing Projects keep their pinned origin with or without the flag. A CLI upgrade does not migrate a Project or its
50109
credentials. A staging flag that disagrees with a Project's pin stops before any request.
51110
To work with another environment, initialize a separate project directory and submit the Plan there; do not edit
52111
the existing Project's private state to redirect it.
53112

54-
`FIRSTDRAFT_API_URL` remains available for an initial custom HTTPS origin or loopback HTTP development server.
55-
`--staging` together with a different URL is an error; the equivalent normalized staging URL is allowed. Later
56-
pushes and compilation reject an override that differs from the pin. Read-only status and retained download
57-
commands use the pin and ignore `FIRSTDRAFT_API_URL` unless checking its conflict with an explicit `--staging`.
58-
59-
Every remote command selects credentials from its effective origin: the exact `https://staging.firstdraft.com`
60-
origin requires `FIRSTDRAFT_STAGING_API_TOKEN`; production and custom origins use `FIRSTDRAFT_API_TOKEN`. Neither
61-
token is a fallback for the other. This includes existing staging Projects and retained status or artifact reads,
62-
even when no flag is supplied.
113+
`FIRSTDRAFT_API_URL` remains available for an initial custom HTTPS origin or loopback HTTP development server, and
114+
it also selects the origin for `login` and `logout`, for example `FIRSTDRAFT_API_URL=http://127.0.0.1:3000
115+
firstdraft login`. `--staging` together with a different URL is an error; the equivalent normalized staging URL is
116+
allowed. Later pushes and compilation reject an override that differs from the pin. Read-only status and retained
117+
download commands use the pin and ignore `FIRSTDRAFT_API_URL` unless checking its conflict with an explicit
118+
`--staging`.
119+
120+
Every remote command selects credentials from its effective origin. First it uses the environment: the exact
121+
`https://staging.firstdraft.com` origin uses `FIRSTDRAFT_STAGING_API_TOKEN`; production and custom origins use
122+
`FIRSTDRAFT_API_TOKEN`. When that variable is unset or empty, it uses the token saved by `firstdraft login` for that
123+
exact origin. No token is a fallback for a different origin: neither environment variable substitutes for the other,
124+
and a saved login for one origin never authenticates another. This includes existing staging Projects and retained
125+
status or artifact reads, even when no flag is supplied. An unreadable or malformed credentials file counts as no
126+
saved login.
63127

64128
The CLI sends the selected token as a Bearer credential on every API request. It does not save it in `.firstdraft`,
65129
print it, or require it for local commands. Revoke a token in the environment that issued it if it is exposed. A
@@ -402,8 +466,10 @@ contents, and digests without claiming POSIX mode bits. The declared and streame
402466

403467
## Service endpoints
404468

405-
The CLI calls these Service API routes at the origin pinned for the Project. Every request sends the selected token
406-
as a Bearer credential, refuses redirects, and has a bounded timeout. Each request takes its method and path from one
469+
The CLI calls these Service API routes. The `/v1` routes go to the origin pinned for the Project and send the
470+
selected token as a Bearer credential. The `/oauth` routes go to the origin selected for `login` or `logout` and
471+
send form-encoded OAuth parameters without a Bearer credential. Every request refuses redirects and has a bounded
472+
timeout. Each request takes its method and path from one
407473
`SERVICE_ROUTES` entry in `src/api-response.js`. `test/service-endpoints.test.js` fails when this table and
408474
`SERVICE_ROUTES` differ, or when no file in `src/` uses a declared route.
409475

@@ -416,15 +482,21 @@ as a Bearer credential, refuses redirects, and has a bounded timeout. Each reque
416482
| `GET` | `/v1/projects/{project_id}/compilations/{compilation_id}/artifact` | Download that Compilation's artifact |
417483
| `PUT` | `/v1/projects/{project_id}/github-publication` | Start or rejoin the Publication |
418484
| `GET` | `/v1/projects/{project_id}/github-publication` | Poll or reconcile the Publication |
485+
| `POST` | `/oauth/token` | Exchange a login grant for a token |
486+
| `POST` | `/oauth/device_authorization` | Start a device-flow login |
487+
| `POST` | `/oauth/revoke` | Revoke a saved token |
419488

420489
`plan push` sends the Plan `PUT`, and `plan status` reads the analysis. `plan compile` does both. It then starts a
421490
Compilation, polls it, and downloads its artifact. With `--github`, it starts and polls the Publication instead.
422491
`compilation status` reads one retained Compilation. `compilation download` reads it and downloads its artifact.
492+
`login` exchanges its authorization code at `/oauth/token`. With `--interactive`, it starts at
493+
`/oauth/device_authorization` and then polls `/oauth/token`. `logout` calls `/oauth/revoke`. The browser, not the
494+
CLI, opens `/oauth/authorize` and `/device`, so they have no rows.
423495

424496
The first push sends `If-None-Match: *`. Later pushes, the Compilation `POST`, and the Publication `PUT` send the
425497
saved Head ETag in `If-Match`. The Service also has a Compilation cancel route, which the CLI does not call.
426498

427-
These routes belong to the API-contract range that `release/compatibility.json` accepts. The CLI does not read the
499+
The `/v1` routes belong to the API-contract range that `release/compatibility.json` accepts. The CLI does not read the
428500
Service's `FirstDraft-API-Contract` response header. Instead, the Service's release compatibility check compares the
429501
declared ranges before a release. The Service documents the routes in its Foundation Plan machine reference,
430502
`docs/architecture/reference/README.md` in the private `firstdraft/firstdraft` repository.

0 commit comments

Comments
 (0)