@letterstory/cli 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/oauth.mjs ADDED
@@ -0,0 +1,204 @@
1
+ // Browser-based OAuth 2.1 login (RFC 8252 loopback redirect + PKCE) for the
2
+ // Letterstory CLI. This talks to the app's own /api/oauth/* endpoints — see
3
+ // src/lib/oauth/core.ts on the server side for the matching implementation.
4
+
5
+ import { randomBytes, createHash } from "node:crypto";
6
+ import { createServer } from "node:http";
7
+ import { spawn } from "node:child_process";
8
+ import { CliError } from "./client.mjs";
9
+
10
+ export const CLIENT_ID = "letterstory_cli";
11
+ const CALLBACK_TIMEOUT_MS = 180_000;
12
+
13
+ function generatePkce() {
14
+ const verifier = randomBytes(32).toString("base64url");
15
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
16
+ return { verifier, challenge };
17
+ }
18
+
19
+ function openBrowser(url) {
20
+ let cmd, args;
21
+ if (process.platform === "darwin") {
22
+ cmd = "open";
23
+ args = [url];
24
+ } else if (process.platform === "win32") {
25
+ cmd = "cmd";
26
+ args = ["/c", "start", '""', url];
27
+ } else {
28
+ cmd = "xdg-open";
29
+ args = [url];
30
+ }
31
+ try {
32
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
33
+ child.on("error", () => {});
34
+ child.unref();
35
+ return true;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ const CALLBACK_HTML_OK = `<!doctype html><html><head><title>Letterstory CLI</title></head>
42
+ <body style="font-family: system-ui, sans-serif; max-width: 28rem; margin: 4rem auto; text-align: center;">
43
+ <h2>You're signed in.</h2><p>You can close this tab and return to your terminal.</p>
44
+ </body></html>`;
45
+
46
+ const CALLBACK_HTML_ERROR = `<!doctype html><html><head><title>Letterstory CLI</title></head>
47
+ <body style="font-family: system-ui, sans-serif; max-width: 28rem; margin: 4rem auto; text-align: center;">
48
+ <h2>Sign-in failed.</h2><p>Return to your terminal for details.</p>
49
+ </body></html>`;
50
+
51
+ // RFC 8252 §7.3: bind to loopback only, let the OS pick an ephemeral port,
52
+ // and use whatever the OS gave us as part of the registered-but-portless
53
+ // redirect_uri sent to /authorize.
54
+ function startLoopbackServer() {
55
+ return new Promise((resolve, reject) => {
56
+ const server = createServer();
57
+ let settled = false;
58
+
59
+ const resultPromise = new Promise((resolveResult) => {
60
+ server.on("request", (req, res) => {
61
+ const url = new URL(req.url, "http://127.0.0.1");
62
+ if (url.pathname !== "/callback") {
63
+ res.writeHead(404).end();
64
+ return;
65
+ }
66
+ const code = url.searchParams.get("code");
67
+ const state = url.searchParams.get("state");
68
+ const error = url.searchParams.get("error");
69
+ const errorDescription = url.searchParams.get("error_description");
70
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
71
+ res.end(error ? CALLBACK_HTML_ERROR : CALLBACK_HTML_OK);
72
+ if (!settled) {
73
+ settled = true;
74
+ resolveResult(error ? { error, errorDescription } : { code, state });
75
+ }
76
+ });
77
+ server.on("error", (err) => {
78
+ if (!settled) {
79
+ settled = true;
80
+ reject(err);
81
+ }
82
+ });
83
+ });
84
+
85
+ server.listen(0, "127.0.0.1", () => {
86
+ const { port } = server.address();
87
+ resolve({
88
+ port,
89
+ redirectUri: `http://127.0.0.1:${port}/callback`,
90
+ waitForCallback: () => resultPromise,
91
+ close: () => new Promise((r) => server.close(r)),
92
+ });
93
+ });
94
+ });
95
+ }
96
+
97
+ async function withTimeout(promise, ms, message) {
98
+ let timer;
99
+ const timeout = new Promise((_, reject) => {
100
+ timer = setTimeout(() => reject(new CliError(message)), ms);
101
+ });
102
+ try {
103
+ return await Promise.race([promise, timeout]);
104
+ } finally {
105
+ clearTimeout(timer);
106
+ }
107
+ }
108
+
109
+ async function tokenRequest(url, body, fetchImpl) {
110
+ const endpoint = `${url.replace(/\/+$/, "")}/api/oauth/token`;
111
+ let res;
112
+ try {
113
+ res = await fetchImpl(endpoint, {
114
+ method: "POST",
115
+ headers: { "content-type": "application/x-www-form-urlencoded" },
116
+ body: new URLSearchParams(body).toString(),
117
+ });
118
+ } catch (err) {
119
+ throw new CliError(`Could not reach ${endpoint}: ${err.message}`);
120
+ }
121
+ const json = await res.json().catch(() => null);
122
+ if (!res.ok || !json?.access_token) {
123
+ const detail = json?.error_description || json?.error || `HTTP ${res.status}`;
124
+ throw new CliError(`Login failed: ${detail}`);
125
+ }
126
+ return json;
127
+ }
128
+
129
+ // Opens the system browser, runs a one-shot loopback server, and exchanges
130
+ // the resulting code for a token pair. Scope is left to the server's
131
+ // default (the CLI client's full registered scope, including offline_access)
132
+ // so a plain `letterstory login` always comes back with a refresh token.
133
+ export async function browserLogin({ url, io, fetchImpl = globalThis.fetch }) {
134
+ const { verifier, challenge } = generatePkce();
135
+ const state = randomBytes(16).toString("hex");
136
+ const server = await startLoopbackServer();
137
+
138
+ const authUrl = new URL(`${url.replace(/\/+$/, "")}/api/oauth/authorize`);
139
+ authUrl.searchParams.set("response_type", "code");
140
+ authUrl.searchParams.set("client_id", CLIENT_ID);
141
+ authUrl.searchParams.set("redirect_uri", server.redirectUri);
142
+ authUrl.searchParams.set("state", state);
143
+ authUrl.searchParams.set("code_challenge", challenge);
144
+ authUrl.searchParams.set("code_challenge_method", "S256");
145
+
146
+ io.log(`Opening your browser to sign in…`);
147
+ io.log(`If it doesn't open automatically, visit:\n ${authUrl.toString()}\n`);
148
+ openBrowser(authUrl.toString());
149
+
150
+ try {
151
+ const result = await withTimeout(
152
+ server.waitForCallback(),
153
+ CALLBACK_TIMEOUT_MS,
154
+ "Timed out waiting for the browser sign-in. Please try again."
155
+ );
156
+ if (result.error) {
157
+ throw new CliError(`Sign-in was not completed: ${result.errorDescription || result.error}`);
158
+ }
159
+ if (result.state !== state) {
160
+ throw new CliError("Sign-in response failed a security check (state mismatch). Please try again.");
161
+ }
162
+ if (!result.code) {
163
+ throw new CliError("Sign-in did not return an authorization code. Please try again.");
164
+ }
165
+
166
+ const tokens = await tokenRequest(
167
+ url,
168
+ {
169
+ grant_type: "authorization_code",
170
+ client_id: CLIENT_ID,
171
+ code: result.code,
172
+ redirect_uri: server.redirectUri,
173
+ code_verifier: verifier,
174
+ },
175
+ fetchImpl
176
+ );
177
+ return tokens;
178
+ } finally {
179
+ await server.close();
180
+ }
181
+ }
182
+
183
+ export async function refreshAccessToken({ url, refreshToken, fetchImpl = globalThis.fetch }) {
184
+ return tokenRequest(
185
+ url,
186
+ { grant_type: "refresh_token", client_id: CLIENT_ID, refresh_token: refreshToken },
187
+ fetchImpl
188
+ );
189
+ }
190
+
191
+ // Best-effort: RFC 7009 revocation always "succeeds" server-side, and a
192
+ // network failure here shouldn't block a local logout.
193
+ export async function revokeToken({ url, token, fetchImpl = globalThis.fetch }) {
194
+ const endpoint = `${url.replace(/\/+$/, "")}/api/oauth/revoke`;
195
+ try {
196
+ await fetchImpl(endpoint, {
197
+ method: "POST",
198
+ headers: { "content-type": "application/x-www-form-urlencoded" },
199
+ body: new URLSearchParams({ token, client_id: CLIENT_ID }).toString(),
200
+ });
201
+ } catch {
202
+ // ignore — logout still clears local state
203
+ }
204
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -23,7 +23,8 @@
23
23
  "mcp"
24
24
  ],
25
25
  "bin": {
26
- "letterstory": "bin/letterstory.mjs"
26
+ "letterstory": "bin/letterstory.mjs",
27
+ "phantom": "bin/phantom.mjs"
27
28
  },
28
29
  "engines": {
29
30
  "node": ">=20"