Skip to content

Commit c75a40f

Browse files
committed
Separate staging credentials from production
Keep production as the default and add an explicit staging option. Select credentials from each Project's effective origin so an existing staging pin cannot receive a production token. Preserve pinned Projects and local output preflight. Bump the CLI minor version for the staging token configuration change while retaining the API 0.6 and Plan 0.22 contracts.
1 parent 38528f4 commit c75a40f

18 files changed

Lines changed: 787 additions & 119 deletions

‎README.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,10 @@ To exercise the checkout directly:
7575
node bin/firstdraft.js --help
7676
```
7777

78-
Remote commands read FIRSTDRAFT_API_TOKEN from the environment. See
79-
[Push a Foundation Plan](docs/commands.md#push-a-foundation-plan) for FIRSTDRAFT_API_URL and origin pinning. Keep
80-
tokens out of arguments, shell history, fixtures, snapshots, and logs.
78+
Remote commands default to production and read `FIRSTDRAFT_API_TOKEN`. Use `firstdraft --staging plan compile`
79+
and a separate `FIRSTDRAFT_STAGING_API_TOKEN` for staging. Existing Projects retain their saved origin. See
80+
[environment selection](docs/commands.md#select-an-environment-and-authenticate) for custom URLs and credential
81+
isolation. Keep tokens out of arguments, shell history, fixtures, snapshots, and logs.
8182

8283
## Package contract
8384

‎RELEASING.md‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,11 @@ release tag. An unpublished, untagged candidate can retain its proposed version
1616
The published version may remain in source during documentation and test maintenance; recording its release history
1717
does not require preparing another version. Choose an unused version when preparing the next publication.
1818

19-
CLI `0.6.x` requires API `0.6.x` and Plan `firstdraft.foundation-plan.sketch/0.22`. Plan `0.22` adds the optional
20-
`application.pwa` boolean and replaces the sole accepted `0.21` input format. The new API and CLI minor
21-
versions record that input and artifact compatibility break; they add no migration or compatibility bridge.
22-
The target stays `rails-sketch/2026-09`.
19+
CLI `0.7.x` requires API `0.6.x` and Plan `firstdraft.foundation-plan.sketch/0.22`. CLI `0.7.0` adds `--staging`
20+
and requires `FIRSTDRAFT_STAGING_API_TOKEN` for the staging origin, including existing pinned Projects. This token
21+
configuration change warrants a new CLI minor version; the API and Plan contracts stay unchanged. Production
22+
remains the default and uses `FIRSTDRAFT_API_TOKEN`, as do custom origins. No Project is migrated. The target stays
23+
`rails-sketch/2026-09`.
2324

2425
Local output remains the default introduced in CLI `0.4.x`: `firstdraft plan compile` is equivalent to
2526
`firstdraft plan compile --output .`, with GitHub publication selected by explicit `--github`. The root archive

‎docs/commands.md‎

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
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.6.x` source line contains the auditable command shell, local Foundation Plan initialization, local
6+
The current `0.7.x` source line contains the auditable command shell, local Foundation Plan initialization, local
77
application-key and UUID generation, conditional whole-document push, whole-graph analysis status polling, direct
8-
Compile-and-materialize and private publish orchestration, and retained-Compilation inspection. CLI `0.6.x`
8+
Compile-and-materialize and private publish orchestration, and retained-Compilation inspection. CLI `0.7.x`
99
requires the service's `0.6.x` API contract. See the [release policy](../RELEASING.md) for versioning and channel
1010
semantics and [release history](release-history.md) for the transition from prereleases.
1111

@@ -23,19 +23,46 @@ semantics and [release history](release-history.md) for the transition from prer
2323
| `firstdraft compilation status` | Yes | Inspect a retained Compilation by ID |
2424
| `firstdraft compilation download` | Yes | Verify and materialize a successful retained Compilation |
2525

26-
## Authenticate API commands
26+
## Select an environment and authenticate
2727

28-
Create an API token in First Draft and provide it only through the environment when running a network command:
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.
31+
32+
For staging, create a separate token at [First Draft staging](https://staging.firstdraft.com/api-tokens), provide it
33+
through `FIRSTDRAFT_STAGING_API_TOKEN`, and select staging on the first remote command:
2934

3035
```sh
31-
export FIRSTDRAFT_API_TOKEN="your-token"
3236
firstdraft plan push
37+
firstdraft --staging plan push
38+
firstdraft plan compile --staging
3339
```
3440

35-
`plan push`, `plan status`, `plan compile`, and `compilation` subcommands send the token as a Bearer credential on
36-
every API request. The CLI does not save it in `.firstdraft`, print it, or require it for local commands such as
37-
`plan init` and `generate`. Revoke the token in First Draft if it is exposed. A missing token, or First Draft's
38-
validated `401` problem response with the `authentication_required` code, produces that stable CLI error.
41+
`--staging` may precede the command group or appear among a remote command's options. It selects
42+
`https://staging.firstdraft.com`. `plan init` and `generate` remain local; a global flag on a local command does not
43+
save an environment selection. The first successful push, including the push within `plan compile`, pins the API
44+
origin in `.firstdraft/state.json`.
45+
46+
Existing Projects keep their pinned origin with or without the flag. The CLI does not migrate a Project or its
47+
credentials when the default changes. A staging flag that disagrees with a Project's pin stops before any request.
48+
To work with another environment, initialize a separate project directory and submit the Plan there; do not edit
49+
the existing Project's private state to redirect it.
50+
51+
`FIRSTDRAFT_API_URL` remains available for an initial custom HTTPS origin or loopback HTTP development server.
52+
`--staging` together with a different URL is an error; the equivalent normalized staging URL is allowed. Later
53+
pushes and compilation reject an override that differs from the pin. Read-only status and retained download
54+
commands use the pin and ignore `FIRSTDRAFT_API_URL` unless checking its conflict with an explicit `--staging`.
55+
56+
Every remote command selects credentials from its effective origin: the exact `https://staging.firstdraft.com`
57+
origin requires `FIRSTDRAFT_STAGING_API_TOKEN`; production and custom origins use `FIRSTDRAFT_API_TOKEN`. Neither
58+
token is a fallback for the other. This includes existing staging Projects and retained status or artifact reads,
59+
even when no flag is supplied. Upgrading from CLI `0.6.x` therefore requires moving the staging credential to
60+
`FIRSTDRAFT_STAGING_API_TOKEN`; production tokens stay in `FIRSTDRAFT_API_TOKEN`.
61+
62+
The CLI sends the selected token as a Bearer credential on every API request. It does not save it in `.firstdraft`,
63+
print it, or require it for local commands. Revoke a token in the environment that issued it if it is exposed. A
64+
missing token, or First Draft's validated `401` problem response with the `authentication_required` code, produces
65+
that stable CLI error.
3966

4067
## Start a Foundation Plan
4168

@@ -101,9 +128,9 @@ The command sends the exact bytes in `.firstdraft/foundation-plan.json`. The fir
101128
Project; later pushes replay the complete ETag saved in `.firstdraft/state.json` so a stale writer cannot replace a
102129
newer Plan. Successful responses and server diagnostics are printed as JSON for an agent to inspect.
103130

104-
The initial API origin defaults to `https://firstdraft.com`. Set `FIRSTDRAFT_API_URL` to use another HTTPS origin or
105-
a loopback HTTP development server. The first successful push pins the normalized origin in local state, and a later
106-
override must match it.
131+
The first successful push pins the normalized API origin in local state. See
132+
[environment selection and authentication](#select-an-environment-and-authenticate) for production, staging, custom
133+
origins, and the credentials each requires.
107134

108135
If a failure happens after sending the request, the CLI leaves local state unchanged. It never constructs an ETag
109136
from the Plan digest or trusts an ETag from a response it could not fully verify. Follow

‎docs/errors.md‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,18 @@ errors, or unvalidated response bodies. `local_state_not_saved` is the sole exce
1414
its `recovery_state` is required to repair the accepted ETag locally. Root-level and command-group usage failures
1515
remain human-readable text on standard error with exit 2. Unexpected programming defects remain loud.
1616

17+
## Environment and credential errors
18+
19+
`invalid_configuration` stops before a request when `--staging` conflicts with `FIRSTDRAFT_API_URL` or a Project's
20+
saved origin. Push and Compile also reject any URL override that differs from that pin. Unset the conflicting
21+
override or use a separate initialized project directory for the other environment; do not redirect existing
22+
private Project state. Status and retained download commands continue using their pin.
23+
24+
`authentication_required` means the selected environment's token is missing or rejected. Staging requires
25+
`FIRSTDRAFT_STAGING_API_TOKEN`, including old staging Projects with no flag. Production and custom origins require
26+
`FIRSTDRAFT_API_TOKEN`. The CLI never substitutes one for the other. Obtain or refresh the credential from the
27+
same environment, then follow the command's recovery instructions below.
28+
1729
## Ambiguous mutations
1830

1931
`plan compile` supplies `phase: "push" | "compilation" | "publication"` when `request_outcome_unknown` requires
@@ -110,7 +122,7 @@ stopped without following the replacement.
110122
| -------------------------------------------- | -------------------------------------------------------------------------------------------------- | ---: | ------------------------------------------------------------------------------------------------------ |
111123
| Any leaf command | `invalid_arguments` | 2 | Syntax was invalid; no request was made. |
112124
| `plan init` | `local_initialization_failed` | 1 | Initialization failed without overwriting an existing path. |
113-
| `plan push`, `plan compile` | `invalid_configuration` | 2 | API origin or saved Head state is incompatible. |
125+
| Network commands | `invalid_configuration` | 2 | API origin or saved Head state is incompatible. |
114126
| Network commands | `authentication_required` | 1 | The token is missing or First Draft returned a validated authentication problem. |
115127
| Plan commands, `compilation *` | `local_input_unreadable` | 1 | Required local Plan or private state could not be read. |
116128
| Status, Compile, Compilation commands | `project_not_pushed` | 1 | No API origin is pinned for the local Project. |

‎package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@firstdraft.com/cli",
3-
"version": "0.6.0",
3+
"version": "0.7.0",
44
"description": "Command-line interface for First Draft",
55
"license": "MIT",
66
"type": "module",

‎release/compatibility.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"format": "firstdraft.release-compatibility/1",
33
"component": "cli",
4-
"version": "0.6.0",
4+
"version": "0.7.0",
55
"requires": {
66
"api_contract": [">= 0.6.0", "< 0.7.0"],
77
"foundation_plan_formats": ["firstdraft.foundation-plan.sketch/0.22"]

‎scripts/smoke-package.js‎

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@ try {
256256
"Invalid arguments. Run 'firstdraft compilation status --help' for usage.",
257257
});
258258

259+
exercisePackedEnvironmentSelection(temporaryDirectory);
259260
await exercisePackedCompilation(projectDirectory);
260261
} finally {
261262
rmSync(temporaryDirectory, { recursive: true, force: true });
@@ -291,12 +292,19 @@ function spawnNpm(arguments_, cwd = process.cwd()) {
291292
/**
292293
* @param {string[]} arguments_
293294
* @param {string} [cwd]
295+
* @param {Record<string, string>} [environment]
294296
*/
295-
function spawnPackedCli(arguments_, cwd = process.cwd()) {
297+
function spawnPackedCli(arguments_, cwd = process.cwd(), environment = {}) {
296298
return spawnSync(process.execPath, [packedExecutable, ...arguments_], {
297299
cwd,
298300
encoding: "utf8",
299-
env: { ...process.env, FIRSTDRAFT_API_TOKEN: apiToken },
301+
env: {
302+
...process.env,
303+
FIRSTDRAFT_API_URL: undefined,
304+
FIRSTDRAFT_API_TOKEN: apiToken,
305+
FIRSTDRAFT_STAGING_API_TOKEN: "",
306+
...environment,
307+
},
300308
});
301309
}
302310

@@ -307,7 +315,12 @@ function spawnPackedCli(arguments_, cwd = process.cwd()) {
307315
async function spawnPackedCliAsync(arguments_, cwd) {
308316
const child = spawn(process.execPath, [packedExecutable, ...arguments_], {
309317
cwd,
310-
env: { ...process.env, FIRSTDRAFT_API_TOKEN: apiToken },
318+
env: {
319+
...process.env,
320+
FIRSTDRAFT_API_URL: undefined,
321+
FIRSTDRAFT_API_TOKEN: apiToken,
322+
FIRSTDRAFT_STAGING_API_TOKEN: "",
323+
},
311324
stdio: ["ignore", "pipe", "pipe"],
312325
});
313326
let stdout = "";
@@ -325,6 +338,41 @@ async function spawnPackedCliAsync(arguments_, cwd) {
325338
return { status, stdout, stderr };
326339
}
327340

341+
/** @param {string} temporaryDirectory */
342+
function exercisePackedEnvironmentSelection(temporaryDirectory) {
343+
const cwd = path.join(temporaryDirectory, "staging-project");
344+
mkdirSync(cwd);
345+
const initialized = spawnPackedCli(
346+
["plan", "init", "--name", "Staging Project"],
347+
cwd,
348+
);
349+
assert.equal(initialized.status, 0);
350+
351+
const missing = spawnPackedCli(["--staging", "plan", "push"], cwd);
352+
assert.equal(missing.status, 1);
353+
assert.equal(JSON.parse(missing.stderr).error, "authentication_required");
354+
assert.match(missing.stderr, /FIRSTDRAFT_STAGING_API_TOKEN/);
355+
356+
const conflict = spawnPackedCli(["plan", "push", "--staging"], cwd, {
357+
FIRSTDRAFT_API_URL: "http://127.0.0.1:1",
358+
FIRSTDRAFT_STAGING_API_TOKEN: "canary-staging-token",
359+
});
360+
assert.equal(conflict.status, 2);
361+
assert.equal(JSON.parse(conflict.stderr).error, "invalid_configuration");
362+
assert.match(conflict.stderr, /--staging conflicts with FIRSTDRAFT_API_URL/);
363+
assert.doesNotMatch(conflict.stderr, /canary/);
364+
365+
const statePath = path.join(cwd, ".firstdraft", "state.json");
366+
const state = JSON.parse(readFileSync(statePath, "utf8"));
367+
state.api_url = "https://staging.firstdraft.com";
368+
state.foundation_plan_etag = '"retained-staging-head"';
369+
writeFileSync(statePath, `${JSON.stringify(state)}\n`);
370+
const retained = spawnPackedCli(["plan", "status"], cwd);
371+
assert.equal(retained.status, 1);
372+
assert.equal(JSON.parse(retained.stderr).error, "authentication_required");
373+
assert.match(retained.stderr, /FIRSTDRAFT_STAGING_API_TOKEN/);
374+
}
375+
328376
/** @param {string} projectDirectory */
329377
async function exercisePackedCompilation(projectDirectory) {
330378
const projectId = "01900000-0000-7000-8000-000000000901";

‎src/api-authentication.js‎

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,65 @@
1+
import { PlanStateConfigurationError, normalizeApiUrl } from "./plan-state.js";
2+
3+
export const STAGING_API_URL = "https://staging.firstdraft.com";
4+
5+
export class ApiAuthenticationRequiredError extends Error {}
6+
17
/**
2-
* @param {typeof globalThis.fetch | undefined} fetchFunction
3-
* @param {string | undefined} apiToken
4-
* @returns {typeof globalThis.fetch | null}
8+
* @param {object} options
9+
* @param {typeof globalThis.fetch} [options.fetchFunction]
10+
* @param {string} [options.apiToken]
11+
* @param {string} [options.stagingApiToken]
12+
* @param {string} [options.apiUrl]
13+
* @param {boolean} [options.staging]
514
*/
6-
export function authenticatedFetch(fetchFunction, apiToken) {
7-
if (apiToken === undefined || apiToken.trim().length === 0) return null;
15+
export function authenticateApiCommand({
16+
fetchFunction,
17+
apiToken,
18+
stagingApiToken,
19+
apiUrl,
20+
staging = false,
21+
}) {
22+
if (
23+
staging &&
24+
apiUrl !== undefined &&
25+
normalizeApiUrl(apiUrl) !== STAGING_API_URL
26+
) {
27+
throw new PlanStateConfigurationError(
28+
"--staging conflicts with FIRSTDRAFT_API_URL. Unset it or select the staging origin.",
29+
);
30+
}
831

32+
const configured = staging ? STAGING_API_URL : apiUrl;
33+
if (!hasToken(apiToken) && !hasToken(stagingApiToken)) return null;
34+
35+
let selectedOrigin = staging ? STAGING_API_URL : undefined;
936
const request = fetchFunction ?? globalThis.fetch;
10-
return (input, init) =>
11-
request(input, {
37+
/** @type {typeof globalThis.fetch} */
38+
const authorizedFetch = (input, init) => {
39+
const endpoint = new URL(input instanceof Request ? input.url : input);
40+
if (selectedOrigin !== undefined && endpoint.origin !== selectedOrigin) {
41+
throw new PlanStateConfigurationError(
42+
"The requested API environment does not match the Project origin. No request was made.",
43+
);
44+
}
45+
const token =
46+
endpoint.origin === STAGING_API_URL ? stagingApiToken : apiToken;
47+
if (!hasToken(token)) throw new ApiAuthenticationRequiredError();
48+
selectedOrigin = endpoint.origin;
49+
return request(input, {
1250
...init,
1351
headers: {
1452
...init?.headers,
15-
Authorization: `Bearer ${apiToken}`,
53+
Authorization: `Bearer ${token}`,
1654
},
1755
});
56+
};
57+
return { apiUrl: configured, fetchFunction: authorizedFetch };
58+
}
59+
60+
/** @param {string | undefined} token */
61+
function hasToken(token) {
62+
return token !== undefined && token.trim().length > 0;
1863
}
1964

2065
/**

‎src/api-response.js‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { ApiAuthenticationRequiredError } from "./api-authentication.js";
2+
import { PlanStateConfigurationError } from "./plan-state.js";
3+
14
const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
25

36
export class FirstDraftNetworkError extends Error {
@@ -28,6 +31,11 @@ export async function sendRequest(fetchFunction, endpoint, request) {
2831
try {
2932
return await fetchFunction(endpoint, request);
3033
} catch (error) {
34+
if (
35+
error instanceof PlanStateConfigurationError ||
36+
error instanceof ApiAuthenticationRequiredError
37+
)
38+
throw error;
3139
if (!(error instanceof Error)) throw error;
3240

3341
throw new FirstDraftNetworkError("The First Draft request failed.", {

0 commit comments

Comments
 (0)