mindvest-atlas 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 FinManagerAI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # Atlas CLI (Node.js)
2
+
3
+ The Node.js implementation of the Atlas CLI. Zero runtime dependencies — just
4
+ Node ≥ 18 (uses the built-in `fetch`, Web Streams, `crypto`, and `http`).
5
+
6
+ ## Install
7
+
8
+ ```sh
9
+ npm install -g mindvest-atlas # installs the `atlas` command
10
+ atlas login
11
+ ```
12
+
13
+ Or run from a clone without installing:
14
+
15
+ ```sh
16
+ cd node
17
+ node bin/atlas.js --help
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ See the [top-level README](../README.md) for the full command reference. Quick
23
+ tour:
24
+
25
+ ```sh
26
+ atlas login
27
+ atlas tools
28
+ atlas stock-quote --symbol SPY
29
+ atlas options-chain --symbol SPY --expiration 2026-06-20
30
+ atlas alerts --symbol SPY --name greek_exposure --save-charts ./charts
31
+ atlas flow stream --symbol NVDA
32
+ ```
33
+
34
+ ## Layout
35
+
36
+ ```
37
+ bin/atlas.js # executable entry point
38
+ src/
39
+ cli.js # arg parsing + command dispatch
40
+ config.js # constants, endpoints, paths
41
+ auth/ # pkce, oauth, loopback callback server, 0600 token store
42
+ api/ # authenticated client (+ auto-refresh), catalog, SSE parser
43
+ commands/ # login, logout, whoami, tools, call, alerts, flow
44
+ util/ # args, help, formatting, terminal UI, stream loop
45
+ test/ # node --test suites (offline)
46
+ ```
47
+
48
+ ## Test
49
+
50
+ ```sh
51
+ npm test
52
+ ```
53
+
54
+ Runs PKCE/arg/SSE/storage unit tests plus integration tests that drive the real
55
+ OAuth client and loopback server against in-process mock servers — no network
56
+ required.
package/bin/atlas.js ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ // Atlas CLI entry point. Keeps the shebang file tiny: parse-and-dispatch lives
3
+ // in src/cli.js so it can be unit-tested without spawning a process.
4
+ import { main } from '../src/cli.js';
5
+
6
+ main(process.argv.slice(2)).then(
7
+ (code) => process.exit(typeof code === 'number' ? code : 0),
8
+ (err) => {
9
+ // Last-resort handler. Commands are expected to handle their own errors and
10
+ // return an exit code; anything that bubbles to here is a bug or a hard stop.
11
+ const msg = err && err.message ? err.message : String(err);
12
+ process.stderr.write(`atlas: ${msg}\n`);
13
+ if (process.env.ATLAS_DEBUG && err && err.stack) process.stderr.write(err.stack + '\n');
14
+ process.exit(1);
15
+ },
16
+ );
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "mindvest-atlas",
3
+ "version": "0.1.0",
4
+ "description": "Atlas CLI — OAuth login, tool calls, and live alert/flow streaming for the Atlas trading API",
5
+ "type": "module",
6
+ "bin": {
7
+ "atlas": "bin/atlas.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "src/",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "scripts": {
19
+ "test": "node --test",
20
+ "prepublishOnly": "node --test"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/finmanagerai/atlas-cli.git",
25
+ "directory": "node"
26
+ },
27
+ "homepage": "https://github.com/finmanagerai/atlas-cli#readme",
28
+ "bugs": {
29
+ "url": "https://github.com/finmanagerai/atlas-cli/issues"
30
+ },
31
+ "keywords": [
32
+ "atlas",
33
+ "mindvest",
34
+ "options",
35
+ "trading",
36
+ "oauth",
37
+ "cli",
38
+ "mcp"
39
+ ],
40
+ "author": "FinManagerAI",
41
+ "license": "MIT",
42
+ "publishConfig": {
43
+ "access": "public"
44
+ }
45
+ }
@@ -0,0 +1,62 @@
1
+ // Tool catalog: fetch + cache GET /api/v1/tools and resolve CLI slugs to the
2
+ // canonical tool name the server expects in the URL.
3
+ import { API, FILES } from '../config.js';
4
+ import { readJson, writeJson } from '../auth/store.js';
5
+ import { requestJson } from './client.js';
6
+
7
+ const MAX_AGE_MS = 24 * 60 * 60 * 1000; // refresh the cache daily
8
+
9
+ export async function fetchCatalog({ force = false } = {}) {
10
+ const cached = readJson(FILES.catalog);
11
+ if (!force && cached && cached.fetched_at && Date.now() - cached.fetched_at < MAX_AGE_MS) {
12
+ return cached;
13
+ }
14
+ const data = await requestJson(API.tools);
15
+ const catalog = {
16
+ fetched_at: Date.now(),
17
+ count: data.count ?? Object.keys(data.tools || {}).length,
18
+ tools: data.tools || {},
19
+ };
20
+ writeJson(FILES.catalog, catalog, 0o600);
21
+ return catalog;
22
+ }
23
+
24
+ export function cachedCatalog() {
25
+ return readJson(FILES.catalog);
26
+ }
27
+
28
+ // PascalCase display names carry an uppercase letter or hyphen; snake_case
29
+ // Python aliases do not. The display set is the clean, de-duplicated list.
30
+ export function isDisplayName(name) {
31
+ return /[A-Z]/.test(name) || name.includes('-');
32
+ }
33
+
34
+ export function slugify(name) {
35
+ return name.toLowerCase();
36
+ }
37
+
38
+ // Resolve "stock-quote" / "get_stock_quote" / "Stock-Quote" to a real key.
39
+ export function resolveToolName(catalog, input) {
40
+ const tools = catalog.tools || {};
41
+ if (tools[input]) return input;
42
+ const want = String(input).toLowerCase();
43
+ for (const key of Object.keys(tools)) {
44
+ if (key.toLowerCase() === want) return key;
45
+ }
46
+ return null;
47
+ }
48
+
49
+ export function listDisplayTools(catalog) {
50
+ const tools = catalog.tools || {};
51
+ const display = Object.keys(tools).filter(isDisplayName).sort((a, b) => a.localeCompare(b));
52
+ return display.length ? display : Object.keys(tools).sort();
53
+ }
54
+
55
+ // Up to `limit` slug suggestions for a typo, by simple substring distance.
56
+ export function suggestTools(catalog, input, limit = 5) {
57
+ const want = String(input).toLowerCase();
58
+ return listDisplayTools(catalog)
59
+ .map((name) => slugify(name))
60
+ .filter((slug) => slug.includes(want) || want.includes(slug.split('-')[0]))
61
+ .slice(0, limit);
62
+ }
@@ -0,0 +1,100 @@
1
+ // Authenticated HTTP client for the Atlas REST + streaming API. Handles token
2
+ // resolution (env > stored creds), proactive refresh near expiry, and a single
3
+ // refresh-and-retry on a 401.
4
+ import { baseUrl } from '../config.js';
5
+ import { loadCredentials, saveCredentials } from '../auth/store.js';
6
+ import { getEndpoints, refreshAccessToken } from '../auth/oauth.js';
7
+
8
+ export class AuthError extends Error {}
9
+ export class ApiError extends Error {
10
+ constructor(message, { status, data } = {}) {
11
+ super(message);
12
+ this.status = status;
13
+ this.data = data;
14
+ }
15
+ }
16
+
17
+ function envToken() {
18
+ const t = process.env.ATLAS_TOKEN;
19
+ return t && t.trim() ? t.trim() : null;
20
+ }
21
+
22
+ export function isLoggedIn() {
23
+ return !!(envToken() || (loadCredentials() || {}).access_token);
24
+ }
25
+
26
+ async function refreshAndStore(creds) {
27
+ const endpoints = await getEndpoints();
28
+ const tok = await refreshAccessToken(endpoints, {
29
+ refreshToken: creds.refresh_token,
30
+ clientId: creds.client_id,
31
+ });
32
+ const updated = {
33
+ ...creds,
34
+ access_token: tok.access_token,
35
+ token_type: tok.token_type || creds.token_type,
36
+ scope: tok.scope || creds.scope,
37
+ expires_at: tok.expires_in ? Date.now() + tok.expires_in * 1000 : creds.expires_at,
38
+ obtained_at: Date.now(),
39
+ };
40
+ saveCredentials(updated);
41
+ return updated;
42
+ }
43
+
44
+ async function currentAccessToken() {
45
+ const env = envToken();
46
+ if (env) return env;
47
+ let creds = loadCredentials();
48
+ if (!creds || !creds.access_token) {
49
+ throw new AuthError('Not authenticated. Run `atlas login` (or set ATLAS_TOKEN).');
50
+ }
51
+ const canRefresh = creds.refresh_token && creds.client_id;
52
+ if (canRefresh && creds.expires_at && Date.now() > creds.expires_at - 60_000) {
53
+ try { creds = await refreshAndStore(creds); } catch { /* fall through; 401 path retries */ }
54
+ }
55
+ return creds.access_token;
56
+ }
57
+
58
+ // Low-level request returning the raw Response (also used for SSE streams).
59
+ export async function request(pathname, { method = 'GET', body, headers = {}, accept = 'application/json', signal } = {}) {
60
+ const url = baseUrl() + pathname;
61
+ const token = await currentAccessToken();
62
+ const build = (tok) => fetch(url, {
63
+ method,
64
+ signal,
65
+ headers: {
66
+ Authorization: `Bearer ${tok}`,
67
+ Accept: accept,
68
+ ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
69
+ ...headers,
70
+ },
71
+ body: body !== undefined ? JSON.stringify(body) : undefined,
72
+ });
73
+
74
+ let res = await build(token);
75
+ if (res.status === 401 && !envToken()) {
76
+ // The 8h token may have died early (e.g. an OAuth-server restart). Try one
77
+ // refresh-and-retry before surfacing the 401.
78
+ const creds = loadCredentials();
79
+ if (creds && creds.refresh_token && creds.client_id) {
80
+ try {
81
+ const updated = await refreshAndStore(creds);
82
+ res = await build(updated.access_token);
83
+ } catch { /* return the original 401 */ }
84
+ }
85
+ }
86
+ return res;
87
+ }
88
+
89
+ // JSON request that surfaces transport errors (4xx/5xx). Note: the tools API
90
+ // often returns 200 with an { error } body for bad inputs — callers should
91
+ // inspect the returned object's `error` field too.
92
+ export async function requestJson(pathname, opts = {}) {
93
+ const res = await request(pathname, opts);
94
+ const text = await res.text();
95
+ let data;
96
+ try { data = text ? JSON.parse(text) : {}; } catch { data = { result: text }; }
97
+ if (res.status === 401) throw new AuthError(data.error || 'Unauthorized — run `atlas login` again.');
98
+ if (!res.ok) throw new ApiError(data.error || data.message || `HTTP ${res.status}`, { status: res.status, data });
99
+ return data;
100
+ }
@@ -0,0 +1,47 @@
1
+ // Minimal Server-Sent Events parser over a WHATWG ReadableStream (the body of a
2
+ // fetch() Response). Yields { event, data } per dispatched SSE message and
3
+ // silently drops `:` keepalive comments.
4
+ export async function* parseSSE(webStream) {
5
+ const reader = webStream.getReader();
6
+ const decoder = new TextDecoder();
7
+ let buffer = '';
8
+ let eventName = 'message';
9
+ let dataLines = [];
10
+
11
+ try {
12
+ while (true) {
13
+ const { value, done } = await reader.read();
14
+ if (done) break;
15
+ buffer += decoder.decode(value, { stream: true });
16
+
17
+ let nl;
18
+ while ((nl = buffer.indexOf('\n')) >= 0) {
19
+ let line = buffer.slice(0, nl);
20
+ buffer = buffer.slice(nl + 1);
21
+ if (line.endsWith('\r')) line = line.slice(0, -1);
22
+
23
+ if (line === '') {
24
+ // Blank line dispatches the buffered event.
25
+ if (dataLines.length > 0) {
26
+ yield { event: eventName, data: dataLines.join('\n') };
27
+ }
28
+ eventName = 'message';
29
+ dataLines = [];
30
+ continue;
31
+ }
32
+ if (line.startsWith(':')) continue; // comment / keepalive
33
+
34
+ const colon = line.indexOf(':');
35
+ const field = colon === -1 ? line : line.slice(0, colon);
36
+ let val = colon === -1 ? '' : line.slice(colon + 1);
37
+ if (val.startsWith(' ')) val = val.slice(1);
38
+
39
+ if (field === 'event') eventName = val;
40
+ else if (field === 'data') dataLines.push(val);
41
+ // `id` / `retry` are ignored.
42
+ }
43
+ }
44
+ } finally {
45
+ try { reader.releaseLock(); } catch { /* ignore */ }
46
+ }
47
+ }
@@ -0,0 +1,129 @@
1
+ // Loopback redirect catcher for the OAuth flow (RFC 8252 §7.3). Binds an
2
+ // ephemeral port on 127.0.0.1, advertises it as the redirect_uri, and resolves
3
+ // once the browser is redirected to /callback?code=...
4
+ import http from 'node:http';
5
+ import { REDIRECT_PATH } from '../config.js';
6
+
7
+ const PAGE = (title, heading, body, accent) => `<!doctype html>
8
+ <html lang="en"><head><meta charset="utf-8">
9
+ <meta name="viewport" content="width=device-width, initial-scale=1">
10
+ <title>${title}</title>
11
+ <style>
12
+ :root { color-scheme: light dark; }
13
+ body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;
14
+ font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
15
+ background:#0b0d12; color:#e6e8ee; }
16
+ .card { max-width:420px; padding:40px 36px; border-radius:16px; background:#151923;
17
+ box-shadow:0 10px 40px rgba(0,0,0,.45); text-align:center; }
18
+ .badge { width:56px; height:56px; border-radius:50%; margin:0 auto 20px; display:flex;
19
+ align-items:center; justify-content:center; font-size:28px; background:${accent}22; color:${accent}; }
20
+ h1 { font-size:20px; margin:0 0 8px; }
21
+ p { font-size:14px; line-height:1.5; color:#9aa3b2; margin:0; }
22
+ .brand { margin-top:24px; font-size:12px; letter-spacing:.08em; text-transform:uppercase; color:#5b6472; }
23
+ code { background:#0b0d12; padding:1px 6px; border-radius:6px; color:#cbd2e0; }
24
+ </style></head>
25
+ <body><div class="card">
26
+ <div class="badge">${heading}</div>
27
+ ${body}
28
+ <div class="brand" style="color:#5b6472">Atlas CLI</div>
29
+ </div>
30
+ <script>setTimeout(function(){ try { window.close(); } catch (e) {} }, 1200);</script>
31
+ </body></html>`;
32
+
33
+ const SUCCESS_HTML = PAGE(
34
+ 'Atlas — signed in', '✓',
35
+ '<h1>You are signed in</h1><p>Authentication complete. You can close this window and return to your terminal.</p>',
36
+ '#4ade80',
37
+ );
38
+
39
+ const ERROR_HTML = (msg) => PAGE(
40
+ 'Atlas — sign-in failed', '✕',
41
+ `<h1>Sign-in failed</h1><p>${escapeHtml(msg)}</p><p style="margin-top:10px">Return to your terminal and try <code>atlas login</code> again.</p>`,
42
+ '#f87171',
43
+ );
44
+
45
+ function escapeHtml(s) {
46
+ return String(s).replace(/[&<>"']/g, (c) => (
47
+ { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
48
+ ));
49
+ }
50
+
51
+ export class LoopbackServer {
52
+ constructor() {
53
+ this.server = null;
54
+ this.port = 0;
55
+ }
56
+
57
+ // Bind 127.0.0.1 on an OS-assigned port; returns { port, redirectUri }.
58
+ start() {
59
+ return new Promise((resolve, reject) => {
60
+ this.server = http.createServer();
61
+ this.server.on('error', reject);
62
+ this.server.listen(0, '127.0.0.1', () => {
63
+ this.port = this.server.address().port;
64
+ resolve({ port: this.port, redirectUri: `http://127.0.0.1:${this.port}${REDIRECT_PATH}` });
65
+ });
66
+ });
67
+ }
68
+
69
+ // Resolve with { code, state } on the first valid /callback hit; reject on
70
+ // an OAuth error, a state mismatch, or timeout.
71
+ waitForCallback({ expectedState, timeoutMs = 300000 } = {}) {
72
+ return new Promise((resolve, reject) => {
73
+ const timer = setTimeout(() => {
74
+ cleanup();
75
+ reject(new Error('Timed out waiting for the browser callback (5 min).'));
76
+ }, timeoutMs);
77
+
78
+ const onRequest = (req, res) => {
79
+ const url = new URL(req.url, `http://127.0.0.1:${this.port}`);
80
+ if (url.pathname !== REDIRECT_PATH) {
81
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
82
+ res.end('Not found');
83
+ return;
84
+ }
85
+ const p = url.searchParams;
86
+ const respond = (html, status = 200) => {
87
+ res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8' });
88
+ res.end(html);
89
+ };
90
+ const error = p.get('error');
91
+ const code = p.get('code');
92
+ const state = p.get('state');
93
+
94
+ if (error) {
95
+ const desc = p.get('error_description');
96
+ respond(ERROR_HTML(desc ? `${error}: ${desc}` : error), 400);
97
+ cleanup();
98
+ reject(new Error(`Authorization denied: ${error}`));
99
+ return;
100
+ }
101
+ if (!code) {
102
+ respond(ERROR_HTML('No authorization code was returned.'), 400);
103
+ cleanup();
104
+ reject(new Error('Callback did not include an authorization code.'));
105
+ return;
106
+ }
107
+ if (expectedState && state !== expectedState) {
108
+ respond(ERROR_HTML('State mismatch — request aborted.'), 400);
109
+ cleanup();
110
+ reject(new Error('State mismatch in OAuth callback (possible CSRF).'));
111
+ return;
112
+ }
113
+ respond(SUCCESS_HTML);
114
+ cleanup();
115
+ resolve({ code, state });
116
+ };
117
+
118
+ const cleanup = () => {
119
+ clearTimeout(timer);
120
+ if (this.server) this.server.removeListener('request', onRequest);
121
+ };
122
+ this.server.on('request', onRequest);
123
+ });
124
+ }
125
+
126
+ close() {
127
+ try { if (this.server) this.server.close(); } catch { /* ignore */ }
128
+ }
129
+ }
@@ -0,0 +1,145 @@
1
+ // OAuth 2.0 client: discovery, dynamic registration, authorize-URL building,
2
+ // code exchange, refresh, revoke, and userinfo. Plain fetch + form/JSON bodies.
3
+ import { defaultEndpoints, baseUrl, DEFAULT_SCOPE, CLIENT_NAME } from '../config.js';
4
+ import { loadConfig, saveConfig } from './store.js';
5
+
6
+ // Small JSON/form HTTP helper that turns non-2xx into a useful Error.
7
+ async function httpJson(url, { method = 'GET', headers = {}, body, form } = {}) {
8
+ const opts = { method, headers: { Accept: 'application/json', ...headers } };
9
+ if (form) {
10
+ opts.headers['Content-Type'] = 'application/x-www-form-urlencoded';
11
+ opts.body = new URLSearchParams(form).toString();
12
+ } else if (body !== undefined) {
13
+ opts.headers['Content-Type'] = 'application/json';
14
+ opts.body = JSON.stringify(body);
15
+ }
16
+ const res = await fetch(url, opts);
17
+ const text = await res.text();
18
+ let data;
19
+ try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; }
20
+ if (!res.ok) {
21
+ const msg = data.error_description || data.error || data.message || `HTTP ${res.status}`;
22
+ const err = new Error(msg);
23
+ err.status = res.status;
24
+ err.data = data;
25
+ throw err;
26
+ }
27
+ return data;
28
+ }
29
+
30
+ // Resolve OAuth endpoints, fetching /.well-known and caching it. Falls back to
31
+ // the derived defaults if discovery is unreachable.
32
+ export async function getEndpoints({ refresh = false } = {}) {
33
+ const base = baseUrl();
34
+ const cfg = loadConfig();
35
+ if (!refresh && cfg.endpoints && cfg.endpoints.issuer === base) return cfg.endpoints;
36
+
37
+ let endpoints = defaultEndpoints(base);
38
+ try {
39
+ const disc = await httpJson(endpoints.discovery);
40
+ endpoints = {
41
+ issuer: disc.issuer || base,
42
+ discovery: endpoints.discovery,
43
+ authorization_endpoint: disc.authorization_endpoint || endpoints.authorization_endpoint,
44
+ token_endpoint: disc.token_endpoint || endpoints.token_endpoint,
45
+ registration_endpoint: disc.registration_endpoint || endpoints.registration_endpoint,
46
+ revocation_endpoint: disc.revocation_endpoint || endpoints.revocation_endpoint,
47
+ userinfo_endpoint: disc.userinfo_endpoint || endpoints.userinfo_endpoint,
48
+ scopes_supported: disc.scopes_supported || ['atlas', 'broker'],
49
+ };
50
+ } catch {
51
+ // Discovery is optional — defaults already match production.
52
+ }
53
+ saveConfig({ ...loadConfig(), endpoints });
54
+ return endpoints;
55
+ }
56
+
57
+ // Register a public client once (RFC 7591) and cache its id. Honours
58
+ // ATLAS_CLIENT_ID and a previously cached id so we don't trip the server's
59
+ // 5-registrations/min/IP limit.
60
+ export async function ensureClientId(endpoints, redirectUri) {
61
+ if (process.env.ATLAS_CLIENT_ID) return process.env.ATLAS_CLIENT_ID;
62
+ const cfg = loadConfig();
63
+ if (cfg.client_id) return cfg.client_id;
64
+
65
+ const reg = await httpJson(endpoints.registration_endpoint, {
66
+ method: 'POST',
67
+ body: {
68
+ client_name: CLIENT_NAME,
69
+ redirect_uris: [redirectUri],
70
+ grant_types: ['authorization_code', 'refresh_token'],
71
+ response_types: ['code'],
72
+ token_endpoint_auth_method: 'none',
73
+ scope: DEFAULT_SCOPE,
74
+ },
75
+ });
76
+ if (!reg.client_id) throw new Error('Registration did not return a client_id.');
77
+ saveConfig({ ...loadConfig(), client_id: reg.client_id });
78
+ return reg.client_id;
79
+ }
80
+
81
+ export function buildAuthorizeUrl(endpoints, { clientId, redirectUri, scope, state, challenge, method }) {
82
+ const u = new URL(endpoints.authorization_endpoint);
83
+ u.search = new URLSearchParams({
84
+ response_type: 'code',
85
+ client_id: clientId,
86
+ redirect_uri: redirectUri,
87
+ scope,
88
+ state,
89
+ code_challenge: challenge,
90
+ code_challenge_method: method,
91
+ }).toString();
92
+ return u.toString();
93
+ }
94
+
95
+ export function exchangeCode(endpoints, { code, redirectUri, clientId, verifier }) {
96
+ return httpJson(endpoints.token_endpoint, {
97
+ method: 'POST',
98
+ form: {
99
+ grant_type: 'authorization_code',
100
+ code,
101
+ redirect_uri: redirectUri,
102
+ client_id: clientId,
103
+ code_verifier: verifier,
104
+ },
105
+ });
106
+ }
107
+
108
+ export function refreshAccessToken(endpoints, { refreshToken, clientId }) {
109
+ return httpJson(endpoints.token_endpoint, {
110
+ method: 'POST',
111
+ form: {
112
+ grant_type: 'refresh_token',
113
+ refresh_token: refreshToken,
114
+ client_id: clientId,
115
+ },
116
+ });
117
+ }
118
+
119
+ export async function revokeToken(endpoints, { token }) {
120
+ if (!endpoints.revocation_endpoint || !token) return false;
121
+ try { await httpJson(endpoints.revocation_endpoint, { method: 'POST', body: { token } }); return true; }
122
+ catch { return false; }
123
+ }
124
+
125
+ export function fetchUserinfo(endpoints, accessToken) {
126
+ return httpJson(endpoints.userinfo_endpoint, {
127
+ headers: { Authorization: `Bearer ${accessToken}` },
128
+ });
129
+ }
130
+
131
+ // Normalise a token-endpoint response into the persisted credential blob.
132
+ export function credentialsFromTokenResponse(tok, { clientId, base = baseUrl() }) {
133
+ const now = Date.now();
134
+ return {
135
+ base_url: base,
136
+ access_token: tok.access_token,
137
+ refresh_token: tok.refresh_token || null,
138
+ token_type: tok.token_type || 'Bearer',
139
+ scope: tok.scope || null,
140
+ expires_at: tok.expires_in ? now + tok.expires_in * 1000 : null,
141
+ obtained_at: now,
142
+ client_id: clientId,
143
+ source: 'oauth',
144
+ };
145
+ }
@@ -0,0 +1,22 @@
1
+ // PKCE (RFC 7636) and random state generation.
2
+ import crypto from 'node:crypto';
3
+
4
+ export function base64url(buf) {
5
+ return Buffer.from(buf).toString('base64')
6
+ .replace(/\+/g, '-')
7
+ .replace(/\//g, '_')
8
+ .replace(/=+$/, '');
9
+ }
10
+
11
+ // The server verifies S256 as base64url(sha256(verifier)) === challenge
12
+ // (oauth-server.js `verifyPkce`), so this produces a matching pair.
13
+ export function createPkce() {
14
+ const verifier = base64url(crypto.randomBytes(32));
15
+ const challenge = base64url(crypto.createHash('sha256').update(verifier).digest());
16
+ return { verifier, challenge, method: 'S256' };
17
+ }
18
+
19
+ // Opaque, URL-safe random value for the `state` parameter (CSRF defence).
20
+ export function randomState(bytes = 16) {
21
+ return base64url(crypto.randomBytes(bytes));
22
+ }