Skip to content

Commit c875ab6

Browse files
committed
feat: harper login and logout
This saves jwts, per target, in your ~/.harper directory.
1 parent 45c16f7 commit c875ab6

11 files changed

Lines changed: 713 additions & 7 deletions

File tree

DESIGN.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ When adding a new commit-handler early-return path: reset `write.skipped = false
3737
When `table()` is called with an attribute newly marked `indexed: true` (or with any change that requires re-building the secondary index), `runIndexing` is launched asynchronously and `Table.indexingOperation` is set to its promise. While running:
3838

3939
**In-flight state tracking (persisted to `attributesDbi`):**
40+
4041
- `attribute.indexingPID = process.pid` — set at migration start; cleared on clean completion. On restart with a different PID, `indexingPID !== process.pid` triggers a re-migration.
4142
- `attribute.lastIndexedKey` — updated every 100 records as a resumable checkpoint. Cleared on clean completion; preserved on error so a retry starts from this key.
4243
- `attribute.indexingFailed = true` — set if any record's `index.put` errors during the backfill. `table()` checks this flag: a fresh call in the same or a new process re-triggers the backfill from `lastIndexedKey`.
@@ -46,6 +47,7 @@ When `table()` is called with an attribute newly marked `indexed: true` (or with
4647
When `signalSchemaChange('schema-change')` fires at the start of `runIndexing`, `syncSchemaMetadata` calls `resetDatabases()` which re-opens all tables via `table()`. This creates a _new_ dbi object and assigns it to `Table.indices[attribute.name]`. The condition `if (attributeDescriptor?.indexingPID) dbi.isIndexing = true` (just before `indices[name] = dbi` in the migration-detection block) ensures any dbi created while a migration is in progress also has `isIndexing = true`. Without this, a concurrent `resetDatabases()` would replace the in-progress dbi with a fresh one where `isIndexing` is false, allowing queries to read partial index results.
4748

4849
**Error handling:**
50+
4951
- Per-record sync errors: caught by the inner try-catch. Set `hadIndexingErrors = true`.
5052
- Per-record async rejections (`index.put` returning a rejected Promise): caught by the `when()` error handler. Set `hadIndexingErrors = true`.
5153
- The final `await lastResolution` is wrapped in its own try-catch because if the very last put in the loop was rejected, an unguarded `await lastResolution` would throw past the `hadIndexingErrors` check to the outer catch, silently bypassing the error path.

bin/cliCredentials.js

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
'use strict';
2+
3+
const path = require('node:path');
4+
const fs = require('node:fs');
5+
const { getHomeDir } = require('#js/utility/common_utils');
6+
7+
function getCredentialsFile() {
8+
return path.join(getHomeDir(), '.harper', 'credentials.json');
9+
}
10+
11+
function getCredentialsDir() {
12+
return path.join(getHomeDir(), '.harper');
13+
}
14+
15+
/**
16+
* Loads the JWT credentials from the ~/.harper/credentials.json file.
17+
* @returns {Object|null} The credentials object or null if not found.
18+
*/
19+
function loadCredentials() {
20+
const credentialsFile = getCredentialsFile();
21+
if (fs.existsSync(credentialsFile)) {
22+
try {
23+
return JSON.parse(fs.readFileSync(credentialsFile, 'utf8'));
24+
} catch (err) {
25+
console.error(`Error reading credentials file: ${err.message}`);
26+
}
27+
}
28+
return null;
29+
}
30+
31+
/**
32+
* Saves the JWT credentials to the ~/.harper/credentials.json file.
33+
* @param {Object} credentials The credentials object containing operation_token and refresh_token.
34+
*/
35+
function saveCredentials(credentials) {
36+
try {
37+
const allCredentials = loadCredentials() || {};
38+
let { target, ...tokens } = credentials;
39+
40+
if (target) {
41+
if (!target.endsWith('/')) {
42+
target += '/';
43+
}
44+
allCredentials.targets = allCredentials.targets || {};
45+
allCredentials.targets[target] = tokens;
46+
allCredentials.last_target = target;
47+
} else {
48+
// Fallback for when target is not provided (shouldn't happen with new login)
49+
Object.assign(allCredentials, tokens);
50+
}
51+
52+
fs.mkdirSync(getCredentialsDir(), { recursive: true });
53+
fs.writeFileSync(getCredentialsFile(), JSON.stringify(allCredentials, null, 2), { mode: 0o600 });
54+
} catch (err) {
55+
console.error(`Error saving credentials file: ${err.message}`);
56+
}
57+
}
58+
59+
/**
60+
* Deletes the credentials for a specific target or all if no target provided.
61+
* @param {string} [target] The target URL to logout from.
62+
*/
63+
function clearCredentials(target) {
64+
const credentialsFile = getCredentialsFile();
65+
try {
66+
if (target) {
67+
if (!target.endsWith('/')) {
68+
target += '/';
69+
}
70+
const allCredentials = loadCredentials();
71+
if (allCredentials && allCredentials.targets) {
72+
if (allCredentials.targets[target]) {
73+
delete allCredentials.targets[target];
74+
} else {
75+
// try without trailing slash just in case
76+
const altTarget = target.replace(/\/$/, '');
77+
delete allCredentials.targets[altTarget];
78+
}
79+
80+
if (allCredentials.last_target === target || allCredentials.last_target === target.replace(/\/$/, '')) {
81+
const remainingTargets = Object.keys(allCredentials.targets);
82+
allCredentials.last_target = remainingTargets.length > 0 ? remainingTargets[0] : null;
83+
}
84+
fs.writeFileSync(credentialsFile, JSON.stringify(allCredentials, null, 2), { mode: 0o600 });
85+
console.log(`Logged out from ${target}`);
86+
}
87+
} else if (fs.existsSync(credentialsFile)) {
88+
fs.unlinkSync(credentialsFile);
89+
console.log('Logged out from all targets');
90+
}
91+
} catch (err) {
92+
console.error(`Error clearing credentials file: ${err.message}`);
93+
}
94+
}
95+
96+
/**
97+
* Decodes a JWT and returns its payload.
98+
* @param {string} token The JWT token to decode.
99+
* @returns {Object|null} The decoded payload or null if invalid.
100+
*/
101+
function decodeJWT(token) {
102+
try {
103+
const parts = token.split('.');
104+
if (parts.length !== 3) return null;
105+
const payload = parts[1];
106+
const decoded = Buffer.from(payload, 'base64').toString('utf8');
107+
return JSON.parse(decoded);
108+
} catch {
109+
return null;
110+
}
111+
}
112+
113+
/**
114+
* Checks if a token is expired or close to expiring.
115+
* @param {string} token The JWT token to check.
116+
* @param {number} [bufferSeconds=300] The buffer in seconds (default 5 minutes).
117+
* @returns {boolean} True if expired or close to expiring.
118+
*/
119+
function isTokenExpired(token, bufferSeconds = 300) {
120+
const payload = decodeJWT(token);
121+
if (!payload || !payload.exp) return true;
122+
const now = Math.floor(Date.now() / 1000);
123+
return payload.exp < now + bufferSeconds;
124+
}
125+
126+
module.exports = {
127+
loadCredentials,
128+
saveCredentials,
129+
clearCredentials,
130+
isTokenExpired,
131+
};

bin/cliOperations.js

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const { packageDirectory } = require('../components/packageComponent.ts');
1111
const { encode } = require('cbor-x');
1212
const { getHdbPid } = require('../utility/processManagement/processManagement.js');
1313
const { initConfig, getConfigPath } = require('../config/configUtils.js');
14+
const { loadCredentials, isTokenExpired, saveCredentials } = require('./cliCredentials.js');
1415

1516
const OP_ALIASES = { deploy: 'deploy_component', package: 'package_component' };
1617

@@ -56,15 +57,32 @@ function buildRequest() {
5657
return req;
5758
}
5859

60+
/**
61+
* Resolves the target URL from various sources.
62+
* @param {Object} req The request object.
63+
* @param {Object} allCredentials Stored credentials.
64+
* @returns {string|null} The resolved target URL.
65+
*/
66+
function resolveTarget(req, allCredentials) {
67+
return (
68+
req.target ||
69+
process.env.HARPER_CLI_TARGET ||
70+
process.env.CLI_TARGET ||
71+
(allCredentials && allCredentials.last_target)
72+
);
73+
}
74+
5975
/**
6076
* Using a unix domain socket will send a request to hdb operations API server
6177
* @param req
78+
* @param skipResponseLog By default, the response is logged to the console. Set this to true to skip logging it, which can be useful for sensitive responses like login calls!
6279
* @returns {Promise<void>}
6380
*/
64-
async function cliOperations(req) {
65-
if (!req.target) {
66-
req.target = process.env.HARPER_CLI_TARGET || process.env.CLI_TARGET;
67-
}
81+
async function cliOperations(req, skipResponseLog = false) {
82+
require('dotenv').config();
83+
84+
const allCredentials = loadCredentials();
85+
req.target = resolveTarget(req, allCredentials);
6886
let target;
6987
if (req.target) {
7088
try {
@@ -76,15 +94,17 @@ async function cliOperations(req) {
7694
throw error;
7795
}
7896
}
97+
const resolvedTarget = `${target.protocol}//${target.hostname}${target.port ? ':' + target.port : ''}/`;
7998
target = {
8099
protocol: target.protocol,
81100
hostname: target.hostname,
82101
port: target.port,
83102
username: req.username || target.username || process.env.HARPER_CLI_USERNAME || process.env.CLI_TARGET_USERNAME,
84103
password: req.password || target.password || process.env.HARPER_CLI_PASSWORD || process.env.CLI_TARGET_PASSWORD,
85104
rejectUnauthorized: req.rejectUnauthorized,
105+
resolvedTarget,
86106
};
87-
console.error(`Connecting to ${target.protocol}//${target.hostname}:${target.port}`);
107+
console.error(`Connecting to ${resolvedTarget}`);
88108
} else {
89109
// if we aren't doing a targeted operation (like deploy), we initialize the config and verify that local harper
90110
// is running and that we can communicate with it.
@@ -110,6 +130,46 @@ async function cliOperations(req) {
110130
options.headers = { 'Content-Type': 'application/json' };
111131
if (target?.username) {
112132
options.headers.Authorization = `Basic ${Buffer.from(`${target.username}:${target.password}`).toString('base64')}`;
133+
} else if (allCredentials) {
134+
let tokens = null;
135+
let lookupKey = null;
136+
if (target && allCredentials.targets) {
137+
lookupKey = target.resolvedTarget.endsWith('/') ? target.resolvedTarget : target.resolvedTarget + '/';
138+
tokens =
139+
allCredentials.targets[lookupKey] ?? allCredentials.targets[target.resolvedTarget.replace(/\/$/, '')] ?? null;
140+
} else if (allCredentials.operation_token) {
141+
tokens = allCredentials;
142+
}
143+
144+
if (tokens?.operation_token) {
145+
if (tokens.refresh_token && isTokenExpired(tokens.operation_token)) {
146+
console.error('Operation token expired, attempting to refresh...');
147+
try {
148+
const refreshOptions = { ...options };
149+
refreshOptions.headers = { ...options.headers, Authorization: `Bearer ${tokens.refresh_token}` };
150+
const refreshResponse = await httpRequest(refreshOptions, {
151+
operation: 'refresh_operation_token',
152+
});
153+
if (refreshResponse.statusCode === 200) {
154+
const refreshData = JSON.parse(refreshResponse.body);
155+
if (refreshData.operation_token) {
156+
tokens.operation_token = refreshData.operation_token;
157+
saveCredentials({
158+
target: lookupKey || target?.resolvedTarget,
159+
operation_token: tokens.operation_token,
160+
refresh_token: tokens.refresh_token,
161+
});
162+
console.error('Operation token refreshed successfully.');
163+
}
164+
} else {
165+
console.error(`Failed to refresh operation token: ${refreshResponse.statusCode}`);
166+
}
167+
} catch (refreshErr) {
168+
console.error(`Error refreshing operation token: ${refreshErr.message}`);
169+
}
170+
}
171+
options.headers.Authorization = `Bearer ${tokens.operation_token}`;
172+
}
113173
}
114174
if (req.cborEncode) {
115175
options.headers['Content-Type'] = 'application/cbor';
@@ -141,7 +201,13 @@ async function cliOperations(req) {
141201
process.exit(1);
142202
}
143203

144-
console.log(responseLog);
204+
if (!skipResponseLog) {
205+
console.log(responseLog);
206+
}
207+
208+
if (target) {
209+
responseData.resolvedTarget = target.resolvedTarget;
210+
}
145211

146212
return responseData;
147213
} catch (err) {

bin/harper.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ copy-db <source> <target> - Copies a database from source path to target p
2525
dev <path> - Run the application in dev mode with debugging, foreground logging, no auth
2626
install - Install harperdb
2727
<api-operation> <param>=<value> - Run an API operation and return result to the CLI, not all operations are supported
28+
login [target] - Login to a remote or local Harper instance
29+
logout [target] - Logout from Harper and clear saved JWT
2830
register - Register harperdb
2931
renew-certs - Generate a new set of self-signed certificates
3032
restart - Restart the harperdb background process
@@ -81,6 +83,17 @@ async function harper() {
8183
.then(() => 'Your instance of Harper is up to date!');
8284
case SERVICE_ACTIONS_ENUM.STATUS:
8385
return require('./status.js')();
86+
case SERVICE_ACTIONS_ENUM.LOGIN: {
87+
const target = process.argv[3];
88+
const username = process.argv[4];
89+
const { login } = require('./login.js');
90+
return login(target, username);
91+
}
92+
case SERVICE_ACTIONS_ENUM.LOGOUT: {
93+
const target = process.argv[3];
94+
const { logout } = require('./logout.js');
95+
return logout(target);
96+
}
8497
case SERVICE_ACTIONS_ENUM.RENEWCERTS:
8598
return require('../security/keys.js')
8699
.renewSelfSigned()

0 commit comments

Comments
 (0)