Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,20 @@ There is intentionally no stable `latest` release yet. Pin an exact prerelease v
repeatable installation matters. Remote Plan push, status, and compilation commands require a compatible First
Draft service and are currently intended for coordinated trials.

## Authenticate API commands

Create an API token in First Draft and provide it only through the environment when running a network command:

```sh
export FIRSTDRAFT_API_TOKEN="your-token"
firstdraft plan push
```

`plan push`, `plan status`, and `plan compile` send the token as a Bearer credential on every API request. The CLI
does not save it in `.firstdraft`, print it, or require it for local commands such as `plan init` and
`plan subject-id`. Revoke the token in First Draft if it is exposed. A missing token, or First Draft's validated
`401` problem response with the `authentication_required` code, produces that stable CLI error.

## Development

```sh
Expand Down Expand Up @@ -139,6 +153,7 @@ exactly one JSON object to standard error. Agents should branch on its stable `e
| `plan init`, `plan subject-id`, `plan push`, `plan status`, `plan compile` | `invalid_arguments` | 2 | The command syntax is invalid; nothing was written and no request was made. |
| `plan init` | `local_initialization_failed` | 1 | Local initialization failed. The directory may be incomplete; existing files were not overwritten. |
| `plan push`, `plan compile` | `invalid_configuration` | 2 | API configuration or the saved ETag is incompatible with the requested command; no request was made. |
| `plan push`, `plan status`, `plan compile` | `authentication_required` | 1 | `FIRSTDRAFT_API_TOKEN` is missing, or First Draft returned a validated `401` problem with the `authentication_required` code; create or replace the token. |
| `plan push`, `plan status`, `plan compile` | `local_input_unreadable` | 1 | The required local Plan or private state could not be read; no request was made. |
| `plan status`, `plan compile` | `project_not_pushed` | 1 | Local state is valid but has no pinned remote Project yet; run `plan push` first. |
| `plan push`, `plan compile` | `request_outcome_unknown` | 1 | A sent mutation or its response could not be verified. Stop and reconcile instead of retrying it automatically. |
Expand Down
1 change: 1 addition & 0 deletions scripts/check-pack.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ if (result.status !== 0) {
"README.md",
"bin/firstdraft.js",
"package.json",
"src/api-authentication.js",
"src/api-response.js",
"src/cli.js",
"src/commands/plan-compile.js",
Expand Down
5 changes: 5 additions & 0 deletions scripts/smoke-package.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { tmpdir } from "node:os";
import path from "node:path";

const npmCli = requiredEnvironmentVariable("npm_execpath");
const apiToken = `fd_${"a".repeat(43)}`;

/** @type {{name: string, version: string}} */
const packageMetadata = JSON.parse(readFileSync("package.json", "utf8"));
Expand Down Expand Up @@ -223,6 +224,7 @@ function spawnPackedCli(arguments_, cwd = process.cwd()) {
return spawnSync(process.execPath, [packedExecutable, ...arguments_], {
cwd,
encoding: "utf8",
env: { ...process.env, FIRSTDRAFT_API_TOKEN: apiToken },
});
}

Expand All @@ -233,6 +235,7 @@ function spawnPackedCli(arguments_, cwd = process.cwd()) {
async function spawnPackedCliAsync(arguments_, cwd) {
const child = spawn(process.execPath, [packedExecutable, ...arguments_], {
cwd,
env: { ...process.env, FIRSTDRAFT_API_TOKEN: apiToken },
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
Expand Down Expand Up @@ -342,13 +345,15 @@ async function exercisePackedCompilation(projectDirectory) {
request.method === "POST" &&
request.url === `/v1/projects/${projectId}/compilations`
) {
assert.equal(request.headers.authorization, `Bearer ${apiToken}`);
assert.equal(request.headers["if-match"], `"sha256:${headSha256}"`);
assert.equal(requestBody.byteLength, 0);
startRequestSeen = true;
respondJson(response, 202, compilation, { Location: statusPath });
return;
}
if (request.method === "GET" && request.url === artifactPath) {
assert.equal(request.headers.authorization, `Bearer ${apiToken}`);
artifactRequestSeen = true;
response.writeHead(200, {
"Content-Type": "application/vnd.firstdraft.compilation-artifact+json",
Expand Down
37 changes: 37 additions & 0 deletions src/api-authentication.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* @param {typeof globalThis.fetch | undefined} fetchFunction
* @param {string | undefined} apiToken
* @returns {typeof globalThis.fetch | null}
*/
export function authenticatedFetch(fetchFunction, apiToken) {
if (apiToken === undefined || apiToken.trim().length === 0) return null;

const request = fetchFunction ?? globalThis.fetch;
return (input, init) =>
request(input, {
...init,
headers: {
...init?.headers,
Authorization: `Bearer ${apiToken}`,
},
});
}

/**
* @param {number | undefined} status
* @param {unknown} response
* @returns {response is Record<string, unknown>}
*/
export function isAuthenticationProblem(status, response) {
return (
status === 401 &&
isRecord(response) &&
response.status === 401 &&
response.code === "authentication_required"
);
}

/** @param {unknown} value @returns {value is Record<string, unknown>} */
function isRecord(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
Loading