@harperfast/harper 5.2.0-beta.4 → 5.2.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/bin/backup.ts +169 -0
- package/bin/cliOperations.ts +92 -73
- package/bin/harper.ts +25 -6
- package/components/Scope.ts +52 -6
- package/components/componentLoader.ts +107 -9
- package/components/mcp/toolRegistry.ts +10 -0
- package/components/mcp/tools/application.ts +12 -5
- package/components/mcp/tools/operations.ts +3 -0
- package/components/mcp/tools/schemas/operationDescriptions.ts +3 -0
- package/components/mcp/tools/schemas/operations.ts +9 -0
- package/components/operations.js +1 -0
- package/components/operationsValidation.js +32 -2
- package/components/scopeMount.ts +150 -0
- package/config-root.schema.json +4 -0
- package/dataLayer/backupManifest.ts +102 -0
- package/dataLayer/blobBackup.ts +286 -0
- package/dataLayer/harperBridge/ResourceBridge.ts +43 -0
- package/dataLayer/restoreMarker.ts +276 -0
- package/dataLayer/rocksdbBackup.ts +1100 -0
- package/dist/bin/backup.d.ts +9 -0
- package/dist/bin/backup.js +192 -0
- package/dist/bin/backup.js.map +1 -0
- package/dist/bin/cliOperations.d.ts +13 -0
- package/dist/bin/cliOperations.js +89 -70
- package/dist/bin/cliOperations.js.map +1 -1
- package/dist/bin/harper.d.ts +7 -0
- package/dist/bin/harper.js +27 -6
- package/dist/bin/harper.js.map +1 -1
- package/dist/components/Scope.d.ts +37 -1
- package/dist/components/Scope.js +48 -6
- package/dist/components/Scope.js.map +1 -1
- package/dist/components/componentLoader.d.ts +2 -6
- package/dist/components/componentLoader.js +91 -8
- package/dist/components/componentLoader.js.map +1 -1
- package/dist/components/mcp/toolRegistry.d.ts +7 -0
- package/dist/components/mcp/toolRegistry.js +10 -0
- package/dist/components/mcp/toolRegistry.js.map +1 -1
- package/dist/components/mcp/tools/application.js +11 -5
- package/dist/components/mcp/tools/application.js.map +1 -1
- package/dist/components/mcp/tools/operations.js +3 -0
- package/dist/components/mcp/tools/operations.js.map +1 -1
- package/dist/components/mcp/tools/schemas/operationDescriptions.js +2 -0
- package/dist/components/mcp/tools/schemas/operationDescriptions.js.map +1 -1
- package/dist/components/mcp/tools/schemas/operations.js +9 -0
- package/dist/components/mcp/tools/schemas/operations.js.map +1 -1
- package/dist/components/operations.js +2 -0
- package/dist/components/operations.js.map +1 -1
- package/dist/components/operationsValidation.js +34 -2
- package/dist/components/operationsValidation.js.map +1 -1
- package/dist/components/scopeMount.d.ts +86 -0
- package/dist/components/scopeMount.js +131 -0
- package/dist/components/scopeMount.js.map +1 -0
- package/dist/dataLayer/backupManifest.d.ts +26 -0
- package/dist/dataLayer/backupManifest.js +97 -0
- package/dist/dataLayer/backupManifest.js.map +1 -0
- package/dist/dataLayer/blobBackup.d.ts +87 -0
- package/dist/dataLayer/blobBackup.js +282 -0
- package/dist/dataLayer/blobBackup.js.map +1 -0
- package/dist/dataLayer/harperBridge/ResourceBridge.d.ts +3 -0
- package/dist/dataLayer/harperBridge/ResourceBridge.js +33 -0
- package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
- package/dist/dataLayer/restoreMarker.d.ts +122 -0
- package/dist/dataLayer/restoreMarker.js +261 -0
- package/dist/dataLayer/restoreMarker.js.map +1 -0
- package/dist/dataLayer/rocksdbBackup.d.ts +127 -0
- package/dist/dataLayer/rocksdbBackup.js +1039 -0
- package/dist/dataLayer/rocksdbBackup.js.map +1 -0
- package/dist/resources/DatabaseTransaction.js +0 -6
- package/dist/resources/DatabaseTransaction.js.map +1 -1
- package/dist/resources/ResourceInterface.d.ts +0 -3
- package/dist/resources/ResourceInterface.js.map +1 -1
- package/dist/resources/Table.js +22 -22
- package/dist/resources/Table.js.map +1 -1
- package/dist/resources/blob.d.ts +8 -0
- package/dist/resources/blob.js +16 -7
- package/dist/resources/blob.js.map +1 -1
- package/dist/resources/databases.d.ts +42 -1
- package/dist/resources/databases.js +276 -40
- package/dist/resources/databases.js.map +1 -1
- package/dist/resources/transaction.js +0 -3
- package/dist/resources/transaction.js.map +1 -1
- package/dist/server/REST.js +25 -9
- package/dist/server/REST.js.map +1 -1
- package/dist/server/fastifyRoutes.js +15 -1
- package/dist/server/fastifyRoutes.js.map +1 -1
- package/dist/server/itc/serverHandlers.js +7 -1
- package/dist/server/itc/serverHandlers.js.map +1 -1
- package/dist/server/jobs/jobProcess.js +20 -1
- package/dist/server/jobs/jobProcess.js.map +1 -1
- package/dist/server/jobs/jobRunner.js +10 -0
- package/dist/server/jobs/jobRunner.js.map +1 -1
- package/dist/server/jobs/jobs.js +11 -0
- package/dist/server/jobs/jobs.js.map +1 -1
- package/dist/server/middlewareChain.d.ts +10 -1
- package/dist/server/middlewareChain.js +81 -21
- package/dist/server/middlewareChain.js.map +1 -1
- package/dist/server/serverHelpers/serverHandlers.js +8 -4
- package/dist/server/serverHelpers/serverHandlers.js.map +1 -1
- package/dist/server/serverHelpers/serverUtilities.js +11 -0
- package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
- package/dist/server/static.js +11 -3
- package/dist/server/static.js.map +1 -1
- package/dist/utility/OperationFunctionCaller.js +25 -6
- package/dist/utility/OperationFunctionCaller.js.map +1 -1
- package/dist/utility/hdbTerms.d.ts +11 -1
- package/dist/utility/hdbTerms.js +11 -1
- package/dist/utility/hdbTerms.js.map +1 -1
- package/dist/utility/logging/harper_logger.d.ts +40 -0
- package/dist/utility/logging/harper_logger.js +746 -14
- package/dist/utility/logging/harper_logger.js.map +1 -1
- package/dist/utility/operation_authorization.js +13 -3
- package/dist/utility/operation_authorization.js.map +1 -1
- package/npm-shrinkwrap.json +3 -2
- package/package.json +4 -1
- package/resources/DESIGN.md +2 -0
- package/resources/DatabaseTransaction.ts +0 -3
- package/resources/ResourceInterface.ts +0 -3
- package/resources/Table.ts +20 -20
- package/resources/blob.ts +16 -6
- package/resources/databases.ts +272 -42
- package/resources/transaction.ts +0 -3
- package/server/DESIGN.md +16 -0
- package/server/REST.ts +25 -9
- package/server/fastifyRoutes.ts +20 -1
- package/server/itc/serverHandlers.js +7 -1
- package/server/jobs/jobProcess.ts +18 -1
- package/server/jobs/jobRunner.ts +10 -0
- package/server/jobs/jobs.ts +11 -0
- package/server/middlewareChain.ts +79 -20
- package/server/serverHelpers/serverHandlers.js +8 -4
- package/server/serverHelpers/serverUtilities.ts +19 -0
- package/server/static.ts +12 -3
- package/static/defaultConfig.yaml +1 -0
- package/studio/web/assets/{Chat-DK3GlWEb.js → Chat-DoVWScmq.js} +2 -2
- package/studio/web/assets/{Chat-DK3GlWEb.js.map → Chat-DoVWScmq.js.map} +1 -1
- package/studio/web/assets/{FloatingChat-fBcC1Ew_.js → FloatingChat-UZ2NsUOZ.js} +4 -4
- package/studio/web/assets/{FloatingChat-fBcC1Ew_.js.map → FloatingChat-UZ2NsUOZ.js.map} +1 -1
- package/studio/web/assets/{apiToken-DJo1nakA.js → apiToken-BUI_04o7.js} +2 -2
- package/studio/web/assets/{apiToken-DJo1nakA.js.map → apiToken-BUI_04o7.js.map} +1 -1
- package/studio/web/assets/{applications-BDfH8urd.js → applications-D03NA7wW.js} +2 -2
- package/studio/web/assets/{applications-BDfH8urd.js.map → applications-D03NA7wW.js.map} +1 -1
- package/studio/web/assets/{index-BHo3c2Gk.js → index-Bh_CNAHr.js} +6 -6
- package/studio/web/assets/index-Bh_CNAHr.js.map +1 -0
- package/studio/web/assets/{index.lazy-BTo0y6UM.js → index.lazy-Dx3MpyDC.js} +4 -4
- package/studio/web/assets/{index.lazy-BTo0y6UM.js.map → index.lazy-Dx3MpyDC.js.map} +1 -1
- package/studio/web/assets/{notifications-CMxvWNnz.js → notifications-0edoFTsb.js} +2 -2
- package/studio/web/assets/{notifications-CMxvWNnz.js.map → notifications-0edoFTsb.js.map} +1 -1
- package/studio/web/assets/{notifications-D3GoB26g.js → notifications-CwKhipK7.js} +2 -2
- package/studio/web/assets/{notifications-D3GoB26g.js.map → notifications-CwKhipK7.js.map} +1 -1
- package/studio/web/assets/{profile-Doj5FVDE.js → profile-DUfEPQtx.js} +2 -2
- package/studio/web/assets/{profile-Doj5FVDE.js.map → profile-DUfEPQtx.js.map} +1 -1
- package/studio/web/assets/{setComponentFile-yinsqJy0.js → setComponentFile-DMPo4UjC.js} +2 -2
- package/studio/web/assets/{setComponentFile-yinsqJy0.js.map → setComponentFile-DMPo4UjC.js.map} +1 -1
- package/studio/web/assets/{setup-DJwR0BHd.js → setup-B56Oz1_u.js} +2 -2
- package/studio/web/assets/{setup-DJwR0BHd.js.map → setup-B56Oz1_u.js.map} +1 -1
- package/studio/web/assets/{status-Br_AbsJs.js → status-BAod7p3o.js} +2 -2
- package/studio/web/assets/{status-Br_AbsJs.js.map → status-BAod7p3o.js.map} +1 -1
- package/studio/web/assets/{swagger-ui-react-02XH5sVf.js → swagger-ui-react-lQrBxfwM.js} +2 -2
- package/studio/web/assets/{swagger-ui-react-02XH5sVf.js.map → swagger-ui-react-lQrBxfwM.js.map} +1 -1
- package/studio/web/assets/{tsMode-D1DMKY7h.js → tsMode-CrHCRjTK.js} +2 -2
- package/studio/web/assets/{tsMode-D1DMKY7h.js.map → tsMode-CrHCRjTK.js.map} +1 -1
- package/studio/web/assets/{useEntityRestURL-DtRblRgw.js → useEntityRestURL-DoaBMEvU.js} +2 -2
- package/studio/web/assets/{useEntityRestURL-DtRblRgw.js.map → useEntityRestURL-DoaBMEvU.js.map} +1 -1
- package/studio/web/index.html +1 -1
- package/utility/OperationFunctionCaller.ts +24 -3
- package/utility/hdbTerms.ts +11 -1
- package/utility/logging/harper_logger.ts +729 -15
- package/utility/operation_authorization.ts +31 -3
- package/studio/web/assets/index-BHo3c2Gk.js.map +0 -1
package/bin/backup.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { createWriteStream } from 'node:fs';
|
|
4
|
+
import { rename, unlink } from 'node:fs/promises';
|
|
5
|
+
import { basename } from 'node:path';
|
|
6
|
+
import { pipeline } from 'node:stream/promises';
|
|
7
|
+
import * as YAML from 'yaml';
|
|
8
|
+
import * as envMgr from '../utility/environment/environmentManager.ts';
|
|
9
|
+
envMgr.initSync();
|
|
10
|
+
import * as terms from '../utility/hdbTerms.ts';
|
|
11
|
+
import { buildRequest, cliOperations, resolveRequestOptions } from './cliOperations.ts';
|
|
12
|
+
import { loadCredentials } from './cliCredentials.ts';
|
|
13
|
+
import { httpRequest } from '../utility/common_utils.ts';
|
|
14
|
+
import { initConfig } from '../config/configUtils.ts';
|
|
15
|
+
import { getHdbPid } from '../utility/processManagement/processManagement.js';
|
|
16
|
+
import {
|
|
17
|
+
createBackupOffline,
|
|
18
|
+
deleteBackupOffline,
|
|
19
|
+
listBackupsOffline,
|
|
20
|
+
purgeBackupsOffline,
|
|
21
|
+
restoreBackupOffline,
|
|
22
|
+
verifyBackupOffline,
|
|
23
|
+
} from '../dataLayer/rocksdbBackup.ts';
|
|
24
|
+
|
|
25
|
+
const { OPERATIONS_ENUM } = terms;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Runs a backup operation from the CLI. `command` is the operation name (e.g. `create_backup`);
|
|
29
|
+
* there is no hyphenated alias. Each works whether or not Harper is running: RocksDB is
|
|
30
|
+
* single-writer, so when a server is reachable the running process must own the database handle
|
|
31
|
+
* and the operation is forwarded to it (also handling remote `target=` and auth); only when the
|
|
32
|
+
* *local* server is stopped do we open the database/backup files directly. `get_backup` is the
|
|
33
|
+
* exception — it streams a live snapshot from a running server and has no offline form.
|
|
34
|
+
*/
|
|
35
|
+
export async function runBackupCommand(command: string): Promise<void> {
|
|
36
|
+
// Load .env before anything reads process.env — useOperationApi and resolveRequestOptions key
|
|
37
|
+
// the local-vs-remote routing off HARPER_CLI_TARGET/CLI_TARGET, and without this a `.env`-
|
|
38
|
+
// configured remote target would be invisible, silently running a destructive op locally
|
|
39
|
+
// (cliOperations does the same on its first line).
|
|
40
|
+
require('dotenv').config();
|
|
41
|
+
const request = buildRequest();
|
|
42
|
+
const databaseName = request.database || 'data';
|
|
43
|
+
|
|
44
|
+
// We print results here (not by returning a string) so the CLI's top-level handler doesn't
|
|
45
|
+
// re-emit them through logger.notify — this keeps output identical to forwarding the operation
|
|
46
|
+
// to a running server, which the online branch below simply delegates to.
|
|
47
|
+
if (command === OPERATIONS_ENUM.GET_BACKUP) {
|
|
48
|
+
console.log(await downloadBackup(request, databaseName));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Fall back to direct file access only when there is no server to talk to (no target and the
|
|
53
|
+
// local instance is stopped). We never fall back on a *reachable* server's error response —
|
|
54
|
+
// e.g. restore_backup rejecting the system/component-held database (whose offline path is to
|
|
55
|
+
// stop the server and rerun this same command, which then takes the branch below).
|
|
56
|
+
if (useOperationApi(request)) {
|
|
57
|
+
request.operation = command;
|
|
58
|
+
await cliOperations(request); // prints its own result, exactly like any forwarded operation
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let result: any;
|
|
63
|
+
switch (command) {
|
|
64
|
+
case OPERATIONS_ENUM.CREATE_BACKUP:
|
|
65
|
+
result = await createBackupOffline(databaseName, request.exclude_blobs === true);
|
|
66
|
+
break;
|
|
67
|
+
case OPERATIONS_ENUM.LIST_BACKUPS:
|
|
68
|
+
result = await listBackupsOffline(databaseName);
|
|
69
|
+
break;
|
|
70
|
+
case OPERATIONS_ENUM.VERIFY_BACKUP:
|
|
71
|
+
result = await verifyBackupOffline(databaseName, request.backup_id, request.verify_checksum);
|
|
72
|
+
break;
|
|
73
|
+
case OPERATIONS_ENUM.DELETE_BACKUP:
|
|
74
|
+
result = await deleteBackupOffline(databaseName, request.backup_id);
|
|
75
|
+
break;
|
|
76
|
+
case OPERATIONS_ENUM.PURGE_BACKUPS:
|
|
77
|
+
result = await purgeBackupsOffline(databaseName, request.keep_count);
|
|
78
|
+
break;
|
|
79
|
+
case OPERATIONS_ENUM.RESTORE_BACKUP:
|
|
80
|
+
result = await restoreBackupOffline(databaseName, request.backup_id, request.target_database);
|
|
81
|
+
break;
|
|
82
|
+
default:
|
|
83
|
+
throw new Error(`Unknown backup command '${command}'`);
|
|
84
|
+
}
|
|
85
|
+
console.log(YAML.stringify(result).trim());
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Whether the target of a saved `last_target` is the local instance (localhost / loopback). A
|
|
90
|
+
* `harper login` against a local server saves `http://localhost:9925/`, which must NOT be treated as
|
|
91
|
+
* a remote target — otherwise the offline backup commands would try to reach a stopped local server
|
|
92
|
+
* instead of falling back to direct file access.
|
|
93
|
+
*/
|
|
94
|
+
function isLocalTarget(target: string): boolean {
|
|
95
|
+
try {
|
|
96
|
+
const url = new URL(target.includes('://') ? target : `http://${target}`);
|
|
97
|
+
const host = url.hostname.replace(/^\[|\]$/g, ''); // strip IPv6 brackets
|
|
98
|
+
return host === 'localhost' || host === '::1' || host.startsWith('127.');
|
|
99
|
+
} catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Whether to route a backup command through the operation API rather than direct file access. An
|
|
106
|
+
* explicit `target=`/env target, or a saved `last_target` pointing at a *remote* server, always
|
|
107
|
+
* implies talking to a server. A saved *local* `last_target` does not: the offline backup commands
|
|
108
|
+
* exist to work on local files while the local server is stopped, so a local/absent target falls
|
|
109
|
+
* through to the liveness check — the API only when the local instance is actually running
|
|
110
|
+
* (`getHdbPid` verifies process liveness, so a stale pid file reads as stopped).
|
|
111
|
+
*/
|
|
112
|
+
function useOperationApi(request: any): boolean {
|
|
113
|
+
const lastTarget = loadCredentials()?.last_target;
|
|
114
|
+
if (
|
|
115
|
+
request.target ||
|
|
116
|
+
process.env.HARPER_CLI_TARGET ||
|
|
117
|
+
process.env.CLI_TARGET ||
|
|
118
|
+
(lastTarget && !isLocalTarget(lastTarget))
|
|
119
|
+
) {
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
initConfig();
|
|
123
|
+
return Boolean(getHdbPid());
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* `get_backup` streams a full-snapshot tar of the database's current state from a running server
|
|
128
|
+
* into a local file. It works against a remote server (`target=…`) or the local instance, resolving
|
|
129
|
+
* the target and auth the same way as any other CLI operation; a local connection requires Harper to
|
|
130
|
+
* be running (there is no offline form). For RocksDB the stream is gzipped by default (pass
|
|
131
|
+
* `gzip=false` for a plain tar) and includes the database's file-backed blobs by default (pass
|
|
132
|
+
* `exclude_blobs=true` for an engine-only archive). While stopped, use `create_backup` — a local
|
|
133
|
+
* incremental directory backup — instead.
|
|
134
|
+
*/
|
|
135
|
+
async function downloadBackup(request: any, databaseName: string): Promise<string> {
|
|
136
|
+
// only forward gzip/exclude_blobs when the user passed them — they are RocksDB-only options
|
|
137
|
+
// (sending them unconditionally would fail LMDB downloads), and when unset the server applies its
|
|
138
|
+
// own defaults (RocksDB gzips and includes blobs)
|
|
139
|
+
const body: any = { operation: terms.OPERATIONS_ENUM.GET_BACKUP, database: databaseName };
|
|
140
|
+
if (request.gzip !== undefined) body.gzip = request.gzip;
|
|
141
|
+
if (request.exclude_blobs !== undefined) body.exclude_blobs = request.exclude_blobs;
|
|
142
|
+
// resolveRequestOptions connects to a remote target (target=/env/saved) or the local domain
|
|
143
|
+
// socket with auth; for a local connection it enforces that Harper is running.
|
|
144
|
+
const { options } = await resolveRequestOptions(request);
|
|
145
|
+
options.streamResponse = true;
|
|
146
|
+
const response = await httpRequest(options, body);
|
|
147
|
+
if (response.statusCode !== 200) {
|
|
148
|
+
const chunks: Buffer[] = [];
|
|
149
|
+
for await (const chunk of response) chunks.push(Buffer.from(chunk));
|
|
150
|
+
throw new Error(`get_backup failed (${response.statusCode}): ${Buffer.concat(chunks).toString('utf8')}`);
|
|
151
|
+
}
|
|
152
|
+
// name the file after what the server is sending (RocksDB: <db>.tar[.gz]; LMDB: the database file).
|
|
153
|
+
// basename() strips any path from the server-supplied filename — a malicious/compromised target
|
|
154
|
+
// could otherwise return `../../…` and steer the write outside the cwd.
|
|
155
|
+
const serverFilename = response.headers['content-disposition']?.match(/filename="([^"]+)"/)?.[1];
|
|
156
|
+
const outputPath = request.out || (serverFilename && basename(serverFilename)) || `${databaseName}.backup`;
|
|
157
|
+
// Stream to a temporary sibling and rename on success, so a network/server failure mid-download
|
|
158
|
+
// neither truncates an existing known-good backup at `outputPath` nor leaves a partial file that
|
|
159
|
+
// looks complete. The temp name is unique per process+time so concurrent downloads never collide.
|
|
160
|
+
const tempPath = `${outputPath}.${process.pid}-${Date.now()}.part`;
|
|
161
|
+
try {
|
|
162
|
+
await pipeline(response, createWriteStream(tempPath, { flags: 'wx' }));
|
|
163
|
+
await rename(tempPath, outputPath);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
await unlink(tempPath).catch(() => {});
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
return `Backup of database '${databaseName}' written to ${outputPath}`;
|
|
169
|
+
}
|
package/bin/cliOperations.ts
CHANGED
|
@@ -389,9 +389,16 @@ async function refreshExpiredOperationToken(
|
|
|
389
389
|
* @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!
|
|
390
390
|
* @returns {Promise<void>}
|
|
391
391
|
*/
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
392
|
+
/**
|
|
393
|
+
* Resolve the transport options for a CLI operation request: a remote target URL (with auth) when
|
|
394
|
+
* one is configured (`target=`, env, or a saved `last_target`), otherwise the local domain socket.
|
|
395
|
+
* Returns the `options` object ready for `httpRequest` (method + Content-Type, and an Authorization
|
|
396
|
+
* header for remote targets, refreshing an expired operation token when possible) plus the resolved
|
|
397
|
+
* `target` (undefined for a local connection). Exits the process if a local connection is required
|
|
398
|
+
* but Harper is not running or has no domain socket. Shared by `cliOperations` and the CLI's
|
|
399
|
+
* streaming `get_backup` download so both reach local and remote servers the same way.
|
|
400
|
+
*/
|
|
401
|
+
export async function resolveRequestOptions(req: any): Promise<{ options: any; target: any }> {
|
|
395
402
|
const allCredentials = loadCredentials();
|
|
396
403
|
const rawTarget = resolveTarget(req, allCredentials);
|
|
397
404
|
// Userinfo is a transport credential, and `normalizeTarget` strips it so the resolved target can
|
|
@@ -434,81 +441,93 @@ async function cliOperations(req: any, skipResponseLog = false) {
|
|
|
434
441
|
process.exit(1);
|
|
435
442
|
}
|
|
436
443
|
}
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
444
|
+
let options = target ?? {
|
|
445
|
+
protocol: 'http:',
|
|
446
|
+
socketPath: getConfigPath(terms.CONFIG_PARAMS.OPERATIONSAPI_NETWORK_DOMAINSOCKET),
|
|
447
|
+
};
|
|
448
|
+
options.method = 'POST';
|
|
449
|
+
options.headers = { 'Content-Type': 'application/json' };
|
|
450
|
+
options.timeout = SSE_OPERATIONS.has(req.operation) ? SSE_OPERATION_TIMEOUT_MS : CLI_OPERATION_TIMEOUT_MS;
|
|
451
|
+
// Authentication precedence: explicitly configured credentials (dedicated args, URL
|
|
452
|
+
// userinfo, env vars) beat everything, then env-var tokens, then the saved `harper login`
|
|
453
|
+
// token, and only then the legacy `username=`/`password=` payload fallback below. The
|
|
454
|
+
// tokens must outrank that fallback: for add_user/alter_user those args are the credentials
|
|
455
|
+
// of the user being created/altered, so treating them as auth would authenticate as a user
|
|
456
|
+
// who doesn't exist yet (or as the wrong identity) instead of using the admin's session.
|
|
457
|
+
const transportCredentials = target ? resolveTransportCredentials(req, urlCredentials) : undefined;
|
|
458
|
+
if (transportCredentials) {
|
|
459
|
+
options.headers.Authorization = basicAuthHeader(transportCredentials.username, transportCredentials.password);
|
|
460
|
+
} else if (target) {
|
|
461
|
+
// Bearer-token auth, for remote targets ONLY. A local operation goes over the domain
|
|
462
|
+
// socket, which the server trusts via `bypassLocalAuth` — but that bypass is an
|
|
463
|
+
// `else if` on "no Authorization header present" (security/auth.ts), so attaching a
|
|
464
|
+
// Bearer token to a local request opts out of the trust and gets validated instead,
|
|
465
|
+
// 401ing on a token minted for some other cluster. Since these env vars are meant to
|
|
466
|
+
// persist across a whole CI job (or a developer's shell), an ungated read here would
|
|
467
|
+
// break every local `harper` command run in that environment.
|
|
468
|
+
//
|
|
469
|
+
// Env-var tokens (for CI/CD — see `harper login --for-ci`) take precedence over the
|
|
470
|
+
// stored ~/.harperdb/credentials.json entry: they're an explicit per-invocation override
|
|
471
|
+
// that needs no prior `harper login` on the runner. A token refreshed from env vars is
|
|
472
|
+
// used in-memory only (there's no file to write back to); a token refreshed from the
|
|
473
|
+
// credentials file is persisted as before.
|
|
474
|
+
//
|
|
475
|
+
// Whichever namespace supplies a token owns both halves. Resolving them independently
|
|
476
|
+
// would let `HARPER_CLI_OPERATION_TOKEN` from one user pair with
|
|
477
|
+
// `CLI_TARGET_REFRESH_TOKEN` from another: commands would run as the first identity
|
|
478
|
+
// until its operation token expired, then silently continue as the second. `login.ts`
|
|
479
|
+
// selects its username/password namespace as a unit for exactly this reason.
|
|
480
|
+
const tokenPrefix = ['HARPER_CLI', 'CLI_TARGET'].find(
|
|
481
|
+
(prefix) =>
|
|
482
|
+
process.env[`${prefix}_OPERATION_TOKEN`] !== undefined || process.env[`${prefix}_REFRESH_TOKEN`] !== undefined
|
|
483
|
+
);
|
|
484
|
+
const envOperationToken = tokenPrefix ? process.env[`${tokenPrefix}_OPERATION_TOKEN`]?.trim() : undefined;
|
|
485
|
+
const envRefreshToken = tokenPrefix ? process.env[`${tokenPrefix}_REFRESH_TOKEN`]?.trim() : undefined;
|
|
486
|
+
// A namespace that is set but blank is a broken CI secret, not a request to fall back to
|
|
487
|
+
// whatever the developer last logged in as — say so rather than switching identity silently.
|
|
488
|
+
if (tokenPrefix && !envOperationToken && !envRefreshToken) {
|
|
489
|
+
console.error(
|
|
490
|
+
`Ignoring empty ${tokenPrefix}_OPERATION_TOKEN/${tokenPrefix}_REFRESH_TOKEN; falling back to saved login credentials.`
|
|
478
491
|
);
|
|
479
|
-
|
|
480
|
-
const envRefreshToken = tokenPrefix ? process.env[`${tokenPrefix}_REFRESH_TOKEN`]?.trim() : undefined;
|
|
481
|
-
// A namespace that is set but blank is a broken CI secret, not a request to fall back to
|
|
482
|
-
// whatever the developer last logged in as — say so rather than switching identity silently.
|
|
483
|
-
if (tokenPrefix && !envOperationToken && !envRefreshToken) {
|
|
484
|
-
console.error(
|
|
485
|
-
`Ignoring empty ${tokenPrefix}_OPERATION_TOKEN/${tokenPrefix}_REFRESH_TOKEN; falling back to saved login credentials.`
|
|
486
|
-
);
|
|
487
|
-
}
|
|
492
|
+
}
|
|
488
493
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
494
|
+
let tokens: { operation_token?: string; refresh_token?: string } | null = null;
|
|
495
|
+
let persistKey: string | null = null; // non-null => persist a refreshed operation token back to the file
|
|
496
|
+
if (envOperationToken || envRefreshToken) {
|
|
497
|
+
tokens = { operation_token: envOperationToken, refresh_token: envRefreshToken };
|
|
498
|
+
} else if (allCredentials?.targets) {
|
|
499
|
+
persistKey = target.resolvedTarget;
|
|
500
|
+
tokens = allCredentials.targets[persistKey] ?? null;
|
|
501
|
+
}
|
|
497
502
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
}
|
|
503
|
+
if (tokens?.operation_token || tokens?.refresh_token) {
|
|
504
|
+
await refreshExpiredOperationToken(options, tokens, persistKey);
|
|
505
|
+
if (tokens.operation_token) {
|
|
506
|
+
options.headers.Authorization = `Bearer ${tokens.operation_token}`;
|
|
503
507
|
}
|
|
504
508
|
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
509
|
+
}
|
|
510
|
+
// Legacy fallback for operations where `username=`/`password=` genuinely ARE the caller's
|
|
511
|
+
// credentials (e.g. `create_table username= password=`) and nothing else is configured.
|
|
512
|
+
// Both are required — a lone `username=` (as in `drop_user username=bob`) is payload, not
|
|
513
|
+
// a credential.
|
|
514
|
+
if (target && !options.headers.Authorization && req.username && req.password) {
|
|
515
|
+
options.headers.Authorization = basicAuthHeader(req.username, req.password);
|
|
516
|
+
}
|
|
517
|
+
return { options, target };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
async function cliOperations(req: any, skipResponseLog = false) {
|
|
521
|
+
require('dotenv').config();
|
|
522
|
+
|
|
523
|
+
// Resolve target/auth inside the try so a credential or connection error (e.g. an incomplete
|
|
524
|
+
// `auth_username=`/`auth_password=` pair, which resolveRequestOptions throws on) is mapped to the
|
|
525
|
+
// same console.error + process.exit(1) as every other failure below, rather than escaping as an
|
|
526
|
+
// unhandled rejection. `target` is declared out here so the catch can still reference it.
|
|
527
|
+
let options: any, target: any;
|
|
528
|
+
try {
|
|
529
|
+
({ options, target } = await resolveRequestOptions(req));
|
|
530
|
+
await PREPARE_OPERATION[req.operation]?.(req);
|
|
512
531
|
// Streaming deploy (multipart upload + SSE progress) only works against >= 5.1 servers.
|
|
513
532
|
// When deploying to a remote target, probe its version first and downgrade to the
|
|
514
533
|
// legacy JSON deploy if it predates 5.1. Local (domain-socket) deploys always
|
package/bin/harper.ts
CHANGED
|
@@ -8,7 +8,7 @@ import * as cliOperations from './cliOperations.ts';
|
|
|
8
8
|
import { packageJson } from '../utility/packageUtils.js';
|
|
9
9
|
import checkNode from '../launchServiceScripts/utility/checkNodeVersion.js';
|
|
10
10
|
import * as hdbTerms from '../utility/hdbTerms.ts';
|
|
11
|
-
const { SERVICE_ACTIONS_ENUM } = hdbTerms as any;
|
|
11
|
+
const { SERVICE_ACTIONS_ENUM, OPERATIONS_ENUM } = hdbTerms as any;
|
|
12
12
|
if (typeof process.setSourceMapsEnabled === 'function') {
|
|
13
13
|
process.setSourceMapsEnabled(true); // this is necessary for source maps to work, at least on the main thread.
|
|
14
14
|
}
|
|
@@ -62,6 +62,18 @@ version - Print the version
|
|
|
62
62
|
deploy - Deploy the application locally or remotely with target=<remote url>
|
|
63
63
|
`;
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Format a CLI error for the terminal. Expected, user-facing errors (a `ClientError` from an
|
|
67
|
+
* operation — bad args, not found, a locked backup repo — which carry a numeric `statusCode`) get
|
|
68
|
+
* just their message, not a Node stack trace. A genuinely unexpected error keeps its stack so a bug
|
|
69
|
+
* is still debuggable. Mirrors the clean `error: <message>` output of a forwarded operation.
|
|
70
|
+
*/
|
|
71
|
+
export function formatCliError(error: any): string {
|
|
72
|
+
const message = `error: ${error?.message ?? error}`;
|
|
73
|
+
if (error?.stack && typeof error?.statusCode !== 'number') return `${message}\n${error.stack}`;
|
|
74
|
+
return message;
|
|
75
|
+
}
|
|
76
|
+
|
|
65
77
|
async function harper() {
|
|
66
78
|
let nodeResults = checkNode();
|
|
67
79
|
|
|
@@ -140,6 +152,14 @@ async function harper() {
|
|
|
140
152
|
let targetDbPath = process.argv[4];
|
|
141
153
|
return require('./copyDb').copyDb(sourceDb, targetDbPath);
|
|
142
154
|
}
|
|
155
|
+
case OPERATIONS_ENUM.CREATE_BACKUP:
|
|
156
|
+
case OPERATIONS_ENUM.LIST_BACKUPS:
|
|
157
|
+
case OPERATIONS_ENUM.VERIFY_BACKUP:
|
|
158
|
+
case OPERATIONS_ENUM.DELETE_BACKUP:
|
|
159
|
+
case OPERATIONS_ENUM.PURGE_BACKUPS:
|
|
160
|
+
case OPERATIONS_ENUM.GET_BACKUP:
|
|
161
|
+
case OPERATIONS_ENUM.RESTORE_BACKUP:
|
|
162
|
+
return require('./backup').runBackupCommand(service);
|
|
143
163
|
case SERVICE_ACTIONS_ENUM.DEV:
|
|
144
164
|
process.env.DEV_MODE = 'true';
|
|
145
165
|
// fall through
|
|
@@ -191,18 +211,17 @@ if (require.main === module) {
|
|
|
191
211
|
harper()
|
|
192
212
|
.then((message) => {
|
|
193
213
|
if (message) {
|
|
214
|
+
// console.log is the canonical terminal output for CLI results; logger.notify would
|
|
215
|
+
// print the same message a second time (its Console transport is stdout in CLI mode),
|
|
216
|
+
// so `harper help` and friends were emitted twice.
|
|
194
217
|
console.log(message);
|
|
195
|
-
logger.notify(message);
|
|
196
218
|
}
|
|
197
219
|
// Intentionally not calling `process.exit(0);` so if a CLI
|
|
198
220
|
// command resulted in a long running process (aka `run`),
|
|
199
221
|
// it continues to run.
|
|
200
222
|
})
|
|
201
223
|
.catch((error) => {
|
|
202
|
-
if (error)
|
|
203
|
-
console.error(error);
|
|
204
|
-
logger.error(error);
|
|
205
|
-
}
|
|
224
|
+
if (error) console.error(formatCliError(error));
|
|
206
225
|
process.exit(1);
|
|
207
226
|
});
|
|
208
227
|
}
|
package/components/Scope.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { FileAndURLPathConfig } from './Component.ts';
|
|
|
11
11
|
import { FilesOption } from './deriveGlobOptions.ts';
|
|
12
12
|
import { requestRestart } from './requestRestart.ts';
|
|
13
13
|
import { resolveBaseURLPath } from './resolveBaseURLPath.ts';
|
|
14
|
+
import { composeMountedUrlPath, type ScopeMount } from './scopeMount.ts';
|
|
14
15
|
import { ApplicationScope } from './ApplicationScope.ts';
|
|
15
16
|
import {
|
|
16
17
|
getSecretsForComponent,
|
|
@@ -81,6 +82,18 @@ export class Scope extends EventEmitter<ScopeEventsMap> {
|
|
|
81
82
|
// process-global side effects should validate fully but skip activation.
|
|
82
83
|
isTransientValidation?: boolean;
|
|
83
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Routing the operator declared for this application in the root config. Applied automatically
|
|
87
|
+
* to handlers registered through `scope.server`, so plugins normally don't touch it — the
|
|
88
|
+
* router strips the mount before a handler runs and everything inside the application
|
|
89
|
+
* addresses itself mount-relative.
|
|
90
|
+
*
|
|
91
|
+
* Read it only when a plugin emits an absolute URL back to the client (e.g. a redirect
|
|
92
|
+
* `Location`, via `externalBasePath()`) or bypasses the routed chain entirely (legacy
|
|
93
|
+
* fastify routes register on the bare server).
|
|
94
|
+
*/
|
|
95
|
+
mount?: ScopeMount;
|
|
96
|
+
|
|
84
97
|
constructor(
|
|
85
98
|
appName: string,
|
|
86
99
|
pluginName: string,
|
|
@@ -88,10 +101,12 @@ export class Scope extends EventEmitter<ScopeEventsMap> {
|
|
|
88
101
|
configFilePath: string,
|
|
89
102
|
applicationScope: ApplicationScope,
|
|
90
103
|
origin: string = appName,
|
|
91
|
-
isRootConfig?: boolean
|
|
104
|
+
isRootConfig?: boolean,
|
|
105
|
+
mount?: ScopeMount
|
|
92
106
|
) {
|
|
93
107
|
super();
|
|
94
108
|
|
|
109
|
+
this.mount = mount;
|
|
95
110
|
this.#appName = appName;
|
|
96
111
|
this.#pluginName = pluginName;
|
|
97
112
|
this.#origin = typeof origin === 'string' ? origin : appName;
|
|
@@ -117,14 +132,10 @@ export class Scope extends EventEmitter<ScopeEventsMap> {
|
|
|
117
132
|
const method = Reflect.get(target, prop, receiver);
|
|
118
133
|
if (typeof method === 'function') {
|
|
119
134
|
return (listener: any, options?: any) => {
|
|
120
|
-
const scopeConfig = (scopeRef.options?.getAll() as any) ?? {};
|
|
121
135
|
return method.call(target, listener, {
|
|
122
136
|
name: pluginName,
|
|
123
|
-
// resolve to the same base the entry pipeline uses ('assets' -> '/assets/',
|
|
124
|
-
// './x' -> '/<name>/x/') so route matching sees a real pathname prefix (#1583)
|
|
125
|
-
urlPath: scopeConfig.urlPath ? resolveBaseURLPath(pluginName, scopeConfig.urlPath) : undefined,
|
|
126
|
-
host: scopeConfig.host || undefined,
|
|
127
137
|
...options,
|
|
138
|
+
...scopeRef.routeFor(options),
|
|
128
139
|
});
|
|
129
140
|
};
|
|
130
141
|
}
|
|
@@ -184,6 +195,41 @@ export class Scope extends EventEmitter<ScopeEventsMap> {
|
|
|
184
195
|
return this.#pluginName;
|
|
185
196
|
}
|
|
186
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Turns a mount-relative base path into the absolute path a client sees, by prefixing the
|
|
200
|
+
* application's mount. Use it for anything sent back to the client — a redirect `Location`, a
|
|
201
|
+
* generated link — since the router strips the mount before a handler runs and a handler's own
|
|
202
|
+
* view of the path therefore excludes it.
|
|
203
|
+
*/
|
|
204
|
+
externalBasePath(baseURLPath: string): string {
|
|
205
|
+
return this.mount?.urlPath ? `${this.mount.urlPath}${baseURLPath}` : baseURLPath;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The route a handler registered through `scope.server` with these options will answer on — the
|
|
210
|
+
* single place the application mount is applied. `scope.server` uses it, and a plugin that must
|
|
211
|
+
* identify its own route (e.g. REST deduplicating registration per mount) calls it rather than
|
|
212
|
+
* recomposing the parts, so there is one definition of "which route is this".
|
|
213
|
+
*
|
|
214
|
+
* An explicit call option wins over config, but either way `urlPath` is *resolved* rather than
|
|
215
|
+
* passed through: plugins that spread their whole config section into these options (REST does)
|
|
216
|
+
* would otherwise hand the router a raw value — './' became the literal, unmatchable route '/.'.
|
|
217
|
+
*/
|
|
218
|
+
routeFor(options?: { urlPath?: string; host?: string }): { host?: string; urlPath?: string } {
|
|
219
|
+
const scopeConfig = (this.options?.getAll() as any) ?? {};
|
|
220
|
+
const rawUrlPath = options?.urlPath ?? scopeConfig.urlPath;
|
|
221
|
+
// resolve to the same base the entry pipeline uses ('assets' -> '/assets/', './x' ->
|
|
222
|
+
// '/<name>/x/') so route matching sees a real pathname prefix (#1583), then prefix the
|
|
223
|
+
// application's mount. The mount is applied ONLY here, at the routing boundary: the router
|
|
224
|
+
// strips it before the handler runs, so entry URL paths — and the resource paths
|
|
225
|
+
// graphqlSchema/jsResource derive from them — stay mount-relative.
|
|
226
|
+
const pluginUrlPath = rawUrlPath ? resolveBaseURLPath(this.#pluginName, rawUrlPath) : undefined;
|
|
227
|
+
return {
|
|
228
|
+
host: this.mount?.host || options?.host || scopeConfig.host || undefined,
|
|
229
|
+
urlPath: composeMountedUrlPath(this.mount?.urlPath, this.#pluginName, pluginUrlPath) || undefined,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
187
233
|
get directory(): string {
|
|
188
234
|
return this.#directory;
|
|
189
235
|
}
|