@hypequery/cli 1.10.4 → 1.12.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.
@@ -0,0 +1,21 @@
1
+ import { deleteCloudCredential, loadCloudCredential, saveCloudCredential } from '../utils/cloud-credential-store.js';
2
+ export interface LoginOptions {
3
+ readonly cloudUrl?: string;
4
+ }
5
+ export interface LoginDependencies {
6
+ readonly fetch?: typeof fetch;
7
+ readonly openBrowser?: (url: string) => Promise<unknown>;
8
+ readonly now?: () => number;
9
+ readonly requestTimeoutMs?: number;
10
+ readonly saveCredential?: typeof saveCloudCredential;
11
+ readonly timeoutMs?: number;
12
+ }
13
+ export interface LogoutDependencies {
14
+ readonly fetch?: typeof fetch;
15
+ readonly loadCredential?: typeof loadCloudCredential;
16
+ readonly deleteCredential?: typeof deleteCloudCredential;
17
+ readonly requestTimeoutMs?: number;
18
+ }
19
+ export declare function loginCommand(options?: LoginOptions, dependencies?: LoginDependencies): Promise<void>;
20
+ export declare function logoutCommand(dependencies?: LogoutDependencies): Promise<void>;
21
+ //# sourceMappingURL=login.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"login.d.ts","sourceRoot":"","sources":["../../src/commands/login.ts"],"names":[],"mappings":"AAMA,OAAO,EAEL,qBAAqB,EACrB,mBAAmB,EAGnB,mBAAmB,EAEpB,MAAM,oCAAoC,CAAC;AAgB5C,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;IAC9B,QAAQ,CAAC,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IACzD,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IAC5B,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IACnC,QAAQ,CAAC,cAAc,CAAC,EAAE,OAAO,mBAAmB,CAAC;IACrD,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;IAC9B,QAAQ,CAAC,cAAc,CAAC,EAAE,OAAO,mBAAmB,CAAC;IACrD,QAAQ,CAAC,gBAAgB,CAAC,EAAE,OAAO,qBAAqB,CAAC;IACzD,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;CACpC;AAsJD,wBAAsB,YAAY,CAChC,OAAO,GAAE,YAAiB,EAC1B,YAAY,GAAE,iBAAsB,iBAoDrC;AAED,wBAAsB,aAAa,CAAC,YAAY,GAAE,kBAAuB,iBAoCxE"}
@@ -0,0 +1,241 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { createServer } from 'node:http';
3
+ import open from 'open';
4
+ import { validateProtocolDeploymentReleaseTarget } from '@hypequery/protocol';
5
+ import { CLOUD_DEPLOYMENT_SCOPE, deleteCloudCredential, loadCloudCredential, normalizeCloudDeploymentEndpoint, normalizeCloudOrigin, saveCloudCredential, } from '../utils/cloud-credential-store.js';
6
+ import { logger } from '../utils/logger.js';
7
+ const DEFAULT_CLOUD_URL = 'https://cloud.hypequery.com';
8
+ const LOGIN_TIMEOUT_MS = 5 * 60_000;
9
+ const REQUEST_TIMEOUT_MS = 30_000;
10
+ const MAX_TOKEN_RESPONSE_BYTES = 64 * 1024;
11
+ // Cloud issues 12-hour credentials; allow limited client/server clock skew.
12
+ const MAX_TOKEN_LIFETIME_MS = 13 * 60 * 60_000;
13
+ // Cloud owns the token format. Validate only what protects this client — an
14
+ // opaque, header-safe bearer credential of a sane length — rather than pinning
15
+ // a version prefix or exact length that would break every already-published
16
+ // CLI the day Cloud rotates its token format.
17
+ const TOKEN_PATTERN = /^hqdp_[A-Za-z0-9_-]{16,512}$/;
18
+ const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
19
+ function callbackHtml(success) {
20
+ const title = success ? 'CLI authorized' : 'Authorization failed';
21
+ const message = success
22
+ ? 'You can close this window and return to your terminal.'
23
+ : 'Return to your terminal and run the login command again.';
24
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${title}</title></head><body><main><h1>${title}</h1><p>${message}</p></main></body></html>`;
25
+ }
26
+ async function callbackServer(state, timeoutMs) {
27
+ let settle;
28
+ let reject;
29
+ const code = new Promise((resolve, rejectPromise) => {
30
+ settle = resolve;
31
+ reject = rejectPromise;
32
+ });
33
+ // The caller only awaits `code` after the browser has been opened, which
34
+ // takes long enough for a rejection to be seen as unhandled and terminate
35
+ // the process. This keeps a handler attached from the very first tick.
36
+ code.catch(() => undefined);
37
+ const server = createServer((request, response) => {
38
+ const url = new URL(request.url ?? '/', 'http://127.0.0.1');
39
+ if (request.method !== 'GET' || url.pathname !== '/callback') {
40
+ response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
41
+ response.end('Not found');
42
+ return;
43
+ }
44
+ const returnedState = url.searchParams.get('state');
45
+ const authorizationCode = url.searchParams.get('code');
46
+ const authorizationError = url.searchParams.get('error');
47
+ const stateMatches = returnedState === state;
48
+ const valid = stateMatches
49
+ && typeof authorizationCode === 'string'
50
+ && authorizationCode.length >= 1
51
+ && authorizationCode.length <= 4096;
52
+ response.writeHead(valid ? 200 : 400, {
53
+ 'Cache-Control': 'no-store',
54
+ 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'",
55
+ 'Content-Type': 'text/html; charset=utf-8',
56
+ 'Referrer-Policy': 'no-referrer',
57
+ 'X-Content-Type-Options': 'nosniff',
58
+ });
59
+ response.end(callbackHtml(valid));
60
+ // Only a matching callback completes the login. Any web page the user has
61
+ // open can issue a no-CORS request to this port, so a mismatch is ignored
62
+ // rather than settled: aborting here would let a background tab cancel a
63
+ // legitimate login. A matching Cloud error still ends the transaction.
64
+ if (valid)
65
+ settle?.(authorizationCode);
66
+ else if (stateMatches && authorizationError) {
67
+ reject?.(new Error('Cloud authorization was not completed.'));
68
+ }
69
+ });
70
+ server.listen(0, '127.0.0.1');
71
+ await new Promise((resolve, rejectListen) => {
72
+ server.once('listening', resolve);
73
+ server.once('error', rejectListen);
74
+ });
75
+ const address = server.address();
76
+ const timer = setTimeout(() => {
77
+ reject?.(new Error('CLI authorization timed out. Run `hypequery login` again.'));
78
+ }, timeoutMs);
79
+ timer.unref();
80
+ return {
81
+ code,
82
+ redirectUri: `http://127.0.0.1:${address.port}/callback`,
83
+ close: () => {
84
+ clearTimeout(timer);
85
+ server.close();
86
+ },
87
+ };
88
+ }
89
+ async function boundedJsonResponse(response) {
90
+ if (!response.body)
91
+ return null;
92
+ const reader = response.body.getReader();
93
+ const chunks = [];
94
+ let total = 0;
95
+ try {
96
+ for (;;) {
97
+ const { done, value } = await reader.read();
98
+ if (done)
99
+ break;
100
+ total += value.byteLength;
101
+ if (total > MAX_TOKEN_RESPONSE_BYTES) {
102
+ await reader.cancel().catch(() => undefined);
103
+ throw new Error('Cloud returned an oversized CLI token response.');
104
+ }
105
+ chunks.push(value);
106
+ }
107
+ }
108
+ finally {
109
+ reader.releaseLock();
110
+ }
111
+ const bytes = new Uint8Array(total);
112
+ let offset = 0;
113
+ for (const chunk of chunks) {
114
+ bytes.set(chunk, offset);
115
+ offset += chunk.byteLength;
116
+ }
117
+ try {
118
+ return JSON.parse(utf8Decoder.decode(bytes));
119
+ }
120
+ catch {
121
+ return null;
122
+ }
123
+ }
124
+ function tokenResponse(input, origin, now) {
125
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
126
+ throw new Error('Cloud returned an invalid CLI token response.');
127
+ }
128
+ const value = input;
129
+ if (typeof value.access_token !== 'string'
130
+ || !TOKEN_PATTERN.test(value.access_token)
131
+ || value.token_type !== 'Bearer'
132
+ || typeof value.expires_at !== 'string'
133
+ || !Number.isFinite(Date.parse(value.expires_at))
134
+ || value.scope !== CLOUD_DEPLOYMENT_SCOPE
135
+ || typeof value.deployment_endpoint !== 'string'
136
+ || value.deployment_target === undefined) {
137
+ throw new Error('Cloud returned an invalid CLI token response.');
138
+ }
139
+ const expiresAt = Date.parse(value.expires_at);
140
+ if (expiresAt <= now || expiresAt > now + MAX_TOKEN_LIFETIME_MS) {
141
+ throw new Error('Cloud returned an invalid CLI token expiration.');
142
+ }
143
+ const deploymentEndpoint = normalizeCloudDeploymentEndpoint(value.deployment_endpoint, origin);
144
+ let target;
145
+ try {
146
+ target = validateProtocolDeploymentReleaseTarget(value.deployment_target);
147
+ }
148
+ catch {
149
+ throw new Error('Cloud returned an invalid CLI deployment target.');
150
+ }
151
+ return {
152
+ cloudUrl: origin,
153
+ deploymentEndpoint,
154
+ expiresAt: value.expires_at,
155
+ scope: value.scope,
156
+ target,
157
+ token: value.access_token,
158
+ };
159
+ }
160
+ export async function loginCommand(options = {}, dependencies = {}) {
161
+ const origin = normalizeCloudOrigin(options.cloudUrl ?? process.env.HYPEQUERY_CLOUD_URL ?? DEFAULT_CLOUD_URL);
162
+ const state = randomBytes(24).toString('base64url');
163
+ const verifier = randomBytes(32).toString('base64url');
164
+ const challenge = createHash('sha256').update(verifier).digest('base64url');
165
+ const callback = await callbackServer(state, dependencies.timeoutMs ?? LOGIN_TIMEOUT_MS);
166
+ const authorizeUrl = new URL('/cli/authorize', origin);
167
+ authorizeUrl.searchParams.set('redirect_uri', callback.redirectUri);
168
+ authorizeUrl.searchParams.set('code_challenge', challenge);
169
+ authorizeUrl.searchParams.set('code_challenge_method', 'S256');
170
+ authorizeUrl.searchParams.set('state', state);
171
+ try {
172
+ logger.info('Opening your browser to authorize Hypequery CLI…');
173
+ await (dependencies.openBrowser ?? open)(authorizeUrl.toString());
174
+ logger.info(`If the browser did not open, visit:\n${authorizeUrl}`);
175
+ const code = await callback.code;
176
+ callback.close();
177
+ const request = dependencies.fetch ?? fetch;
178
+ const response = await request(new URL('/api/cli/token', origin), {
179
+ method: 'POST',
180
+ headers: { 'Content-Type': 'application/json' },
181
+ body: JSON.stringify({
182
+ grant_type: 'authorization_code',
183
+ code,
184
+ code_verifier: verifier,
185
+ redirect_uri: callback.redirectUri,
186
+ }),
187
+ redirect: 'error',
188
+ signal: AbortSignal.timeout(dependencies.requestTimeoutMs ?? REQUEST_TIMEOUT_MS),
189
+ });
190
+ if (!response.ok) {
191
+ await response.body?.cancel().catch(() => undefined);
192
+ throw new Error('Cloud rejected the CLI authorization code. Run `hypequery login` again.');
193
+ }
194
+ const body = await boundedJsonResponse(response);
195
+ const credential = tokenResponse(body, origin, (dependencies.now ?? Date.now)());
196
+ await (dependencies.saveCredential ?? saveCloudCredential)(credential);
197
+ logger.success('Logged in to Hypequery Cloud');
198
+ logger.info(`Credential expires ${new Date(credential.expiresAt).toLocaleString()}`);
199
+ }
200
+ finally {
201
+ callback.close();
202
+ }
203
+ }
204
+ export async function logoutCommand(dependencies = {}) {
205
+ const load = dependencies.loadCredential ?? loadCloudCredential;
206
+ const remove = dependencies.deleteCredential ?? deleteCloudCredential;
207
+ let credential = null;
208
+ let unreadable = false;
209
+ try {
210
+ credential = await load();
211
+ }
212
+ catch {
213
+ // Logout is the command users reach for when local state is broken, so a
214
+ // corrupt profile or an unreachable vault must not block the cleanup below.
215
+ unreadable = true;
216
+ logger.warn('The stored Cloud credential could not be read; removing local state without revoking. The token will expire automatically.');
217
+ }
218
+ if (!credential && !unreadable) {
219
+ logger.info('You are not logged in to Hypequery Cloud.');
220
+ return;
221
+ }
222
+ if (credential) {
223
+ try {
224
+ const request = dependencies.fetch ?? fetch;
225
+ const response = await request(new URL('/api/cli/token', credential.cloudUrl), {
226
+ method: 'DELETE',
227
+ headers: { Authorization: `Bearer ${credential.token}` },
228
+ redirect: 'error',
229
+ signal: AbortSignal.timeout(dependencies.requestTimeoutMs ?? REQUEST_TIMEOUT_MS),
230
+ });
231
+ await response.body?.cancel().catch(() => undefined);
232
+ if (!response.ok)
233
+ throw new Error(`Cloud returned HTTP ${response.status}.`);
234
+ }
235
+ catch {
236
+ logger.warn('Cloud could not be reached; the local credential was removed and the token will expire automatically.');
237
+ }
238
+ }
239
+ await remove();
240
+ logger.success('Logged out of Hypequery Cloud');
241
+ }
@@ -0,0 +1,28 @@
1
+ import { type ProtocolDeploymentReleaseTarget } from '@hypequery/protocol';
2
+ export declare const CLOUD_DEPLOYMENT_SCOPE = "deploy:submit";
3
+ export interface StoredCloudCredential {
4
+ readonly cloudUrl: string;
5
+ readonly deploymentEndpoint: string;
6
+ readonly expiresAt: string;
7
+ readonly scope: string;
8
+ readonly target?: ProtocolDeploymentReleaseTarget;
9
+ readonly token: string;
10
+ }
11
+ interface KeyringEntry {
12
+ setPassword(password: string): void | Promise<void>;
13
+ getPassword(): string | null | Promise<string | null>;
14
+ deletePassword(): unknown;
15
+ }
16
+ export interface CloudCredentialStoreDependencies {
17
+ readonly configDirectory?: string;
18
+ readonly createKeyringEntry?: (service: string, account: string) => Promise<KeyringEntry> | KeyringEntry;
19
+ readonly env?: Readonly<Record<string, string | undefined>>;
20
+ readonly platform?: NodeJS.Platform;
21
+ }
22
+ export declare function normalizeCloudOrigin(input: string): string;
23
+ export declare function normalizeCloudDeploymentEndpoint(input: string, cloudOrigin: string): string;
24
+ export declare function saveCloudCredential(credential: StoredCloudCredential, dependencies?: CloudCredentialStoreDependencies): Promise<void>;
25
+ export declare function loadCloudCredential(dependencies?: CloudCredentialStoreDependencies): Promise<StoredCloudCredential | null>;
26
+ export declare function deleteCloudCredential(dependencies?: CloudCredentialStoreDependencies): Promise<void>;
27
+ export {};
28
+ //# sourceMappingURL=cloud-credential-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cloud-credential-store.d.ts","sourceRoot":"","sources":["../../src/utils/cloud-credential-store.ts"],"names":[],"mappings":"AAIA,OAAO,EAEL,KAAK,+BAA+B,EACrC,MAAM,qBAAqB,CAAC;AAI7B,eAAO,MAAM,sBAAsB,kBAAkB,CAAC;AAGtD,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;IACpC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,CAAC,EAAE,+BAA+B,CAAC;IAClD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAYD,UAAU,YAAY;IACpB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpD,WAAW,IAAI,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACtD,cAAc,IAAI,OAAO,CAAC;CAC3B;AAQD,MAAM,WAAW,gCAAgC;IAC/C,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,kBAAkB,CAAC,EAAE,CAC5B,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,KACZ,OAAO,CAAC,YAAY,CAAC,GAAG,YAAY,CAAC;IAC1C,QAAQ,CAAC,GAAG,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;IAC5D,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC;CACrC;AAyCD,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAc1D;AAED,wBAAgB,gCAAgC,CAC9C,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,MAAM,GAClB,MAAM,CAmBR;AAmFD,wBAAsB,mBAAmB,CACvC,UAAU,EAAE,qBAAqB,EACjC,YAAY,GAAE,gCAAqC,iBAiEpD;AAED,wBAAsB,mBAAmB,CACvC,YAAY,GAAE,gCAAqC,GAClD,OAAO,CAAC,qBAAqB,GAAG,IAAI,CAAC,CAsBvC;AA0BD,wBAAsB,qBAAqB,CACzC,YAAY,GAAE,gCAAqC,iBA0BpD"}
@@ -0,0 +1,293 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { chmod, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { validateProtocolDeploymentReleaseTarget, } from '@hypequery/protocol';
6
+ const KEYCHAIN_SERVICE = 'dev.hypequery.cli';
7
+ const PROFILE_FILE = 'cloud-profile.json';
8
+ export const CLOUD_DEPLOYMENT_SCOPE = 'deploy:submit';
9
+ const MAX_KEYCHAIN_ACCOUNT_LENGTH = 2048;
10
+ function defaultConfigDirectory(env, platform) {
11
+ if (env.HYPEQUERY_CONFIG_DIR)
12
+ return env.HYPEQUERY_CONFIG_DIR;
13
+ if (platform === 'win32') {
14
+ return path.join(env.APPDATA ?? env.LOCALAPPDATA ?? homedir(), 'hypequery');
15
+ }
16
+ if (platform === 'darwin') {
17
+ return path.join(homedir(), 'Library', 'Application Support', 'hypequery');
18
+ }
19
+ return path.join(env.XDG_CONFIG_HOME ?? path.join(homedir(), '.config'), 'hypequery');
20
+ }
21
+ function vaultUnavailable(error) {
22
+ return new Error('The operating-system credential vault is unavailable. '
23
+ + 'Install its keyring service or use HYPEQUERY_API_TOKEN for manual authentication.', { cause: error });
24
+ }
25
+ async function defaultKeyringEntry(service, account) {
26
+ try {
27
+ const { Entry } = await import('@napi-rs/keyring');
28
+ return new Entry(service, account);
29
+ }
30
+ catch (error) {
31
+ throw vaultUnavailable(error);
32
+ }
33
+ }
34
+ function paths(dependencies) {
35
+ const env = dependencies.env ?? process.env;
36
+ const platform = dependencies.platform ?? process.platform;
37
+ const directory = dependencies.configDirectory
38
+ ?? defaultConfigDirectory(env, platform);
39
+ return { directory, profile: path.join(directory, PROFILE_FILE) };
40
+ }
41
+ export function normalizeCloudOrigin(input) {
42
+ let url;
43
+ try {
44
+ url = new URL(input);
45
+ }
46
+ catch {
47
+ throw new Error('Cloud URL must be an absolute HTTPS URL.');
48
+ }
49
+ const loopback = url.protocol === 'http:'
50
+ && (url.hostname === '127.0.0.1' || url.hostname === 'localhost');
51
+ if ((url.protocol !== 'https:' && !loopback) || url.username || url.password
52
+ || url.pathname !== '/' || url.search || url.hash) {
53
+ throw new Error('Cloud URL must be an HTTPS origin without a path, query, or credentials.');
54
+ }
55
+ return url.origin;
56
+ }
57
+ export function normalizeCloudDeploymentEndpoint(input, cloudOrigin) {
58
+ let endpoint;
59
+ try {
60
+ endpoint = new URL(input);
61
+ }
62
+ catch {
63
+ throw new Error('Cloud returned an invalid deployment endpoint.');
64
+ }
65
+ // Origin equality is what binds the token to the Cloud that issued it, and it
66
+ // is the only check that has to hold for a tampered profile to be harmless.
67
+ // Cloud owns its own path layout: pinning an exact submission path here would
68
+ // break every already-published CLI the day that path is versioned or moved.
69
+ if (endpoint.origin !== cloudOrigin
70
+ || endpoint.username
71
+ || endpoint.password
72
+ || endpoint.search
73
+ || endpoint.hash) {
74
+ throw new Error('Cloud returned an invalid deployment endpoint.');
75
+ }
76
+ return endpoint.toString();
77
+ }
78
+ function parseProfile(input) {
79
+ const parsed = JSON.parse(input);
80
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
81
+ throw new Error('The stored Hypequery Cloud profile is invalid. Run `hypequery login` again.');
82
+ }
83
+ const value = parsed;
84
+ if (value.version !== 1
85
+ || typeof value.cloudUrl !== 'string'
86
+ || typeof value.deploymentEndpoint !== 'string'
87
+ || typeof value.expiresAt !== 'string'
88
+ || typeof value.scope !== 'string'
89
+ || typeof value.keychainAccount !== 'string') {
90
+ throw new Error('The stored Hypequery Cloud profile is invalid. Run `hypequery login` again.');
91
+ }
92
+ let cloudUrl;
93
+ let deploymentEndpoint;
94
+ let target;
95
+ try {
96
+ cloudUrl = normalizeCloudOrigin(value.cloudUrl);
97
+ deploymentEndpoint = normalizeCloudDeploymentEndpoint(value.deploymentEndpoint, cloudUrl);
98
+ if (value.keychainAccount !== cloudUrl
99
+ || !Number.isFinite(Date.parse(value.expiresAt))
100
+ || value.scope !== CLOUD_DEPLOYMENT_SCOPE) {
101
+ throw new Error('profile invariant mismatch');
102
+ }
103
+ target = value.target === undefined
104
+ ? undefined
105
+ : validateProtocolDeploymentReleaseTarget(value.target);
106
+ }
107
+ catch {
108
+ throw new Error('The stored Hypequery Cloud profile is invalid. Run `hypequery login` again.');
109
+ }
110
+ return {
111
+ version: 1,
112
+ cloudUrl,
113
+ deploymentEndpoint,
114
+ expiresAt: value.expiresAt,
115
+ scope: value.scope,
116
+ ...(target ? { target } : {}),
117
+ keychainAccount: value.keychainAccount,
118
+ };
119
+ }
120
+ async function entry(account, dependencies) {
121
+ const create = dependencies.createKeyringEntry ?? defaultKeyringEntry;
122
+ const created = await create(KEYCHAIN_SERVICE, account);
123
+ // Constructing an entry is lazy — the vault is only contacted on get/set/
124
+ // delete — so a locked keyring or a missing Secret Service surfaces here, not
125
+ // at construction. Translate those failures into the same actionable error.
126
+ return {
127
+ async setPassword(password) {
128
+ try {
129
+ await created.setPassword(password);
130
+ }
131
+ catch (error) {
132
+ throw vaultUnavailable(error);
133
+ }
134
+ },
135
+ async getPassword() {
136
+ try {
137
+ return await created.getPassword();
138
+ }
139
+ catch (error) {
140
+ throw vaultUnavailable(error);
141
+ }
142
+ },
143
+ async deletePassword() {
144
+ try {
145
+ return await created.deletePassword();
146
+ }
147
+ catch (error) {
148
+ throw vaultUnavailable(error);
149
+ }
150
+ },
151
+ };
152
+ }
153
+ export async function saveCloudCredential(credential, dependencies = {}) {
154
+ const location = paths(dependencies);
155
+ let previousProfile = null;
156
+ try {
157
+ previousProfile = parseProfile(await readFile(location.profile, 'utf8'));
158
+ }
159
+ catch {
160
+ // A new login must be able to replace a missing or malformed profile; the
161
+ // only thing lost is the chance to purge a superseded vault entry.
162
+ previousProfile = null;
163
+ }
164
+ const cloudUrl = normalizeCloudOrigin(credential.cloudUrl);
165
+ const deploymentEndpoint = normalizeCloudDeploymentEndpoint(credential.deploymentEndpoint, cloudUrl);
166
+ if (!Number.isFinite(Date.parse(credential.expiresAt))
167
+ || credential.scope !== CLOUD_DEPLOYMENT_SCOPE) {
168
+ throw new Error('Cannot store an invalid Hypequery Cloud credential.');
169
+ }
170
+ const keychainAccount = cloudUrl;
171
+ const keyring = await entry(keychainAccount, dependencies);
172
+ const previousPassword = await keyring.getPassword();
173
+ await keyring.setPassword(credential.token);
174
+ const profile = {
175
+ version: 1,
176
+ cloudUrl,
177
+ deploymentEndpoint,
178
+ expiresAt: credential.expiresAt,
179
+ scope: credential.scope,
180
+ ...(credential.target ? { target: credential.target } : {}),
181
+ keychainAccount,
182
+ };
183
+ const temporary = `${location.profile}.${randomUUID()}.tmp`;
184
+ try {
185
+ await mkdir(location.directory, { recursive: true, mode: 0o700 });
186
+ await chmod(location.directory, 0o700);
187
+ await writeFile(temporary, `${JSON.stringify(profile, null, 2)}\n`, {
188
+ encoding: 'utf8',
189
+ flag: 'wx',
190
+ mode: 0o600,
191
+ });
192
+ await chmod(temporary, 0o600);
193
+ await rename(temporary, location.profile);
194
+ }
195
+ catch (error) {
196
+ await unlink(temporary).catch(() => undefined);
197
+ if (previousPassword === null) {
198
+ await keyring.deletePassword().catch(() => undefined);
199
+ }
200
+ else {
201
+ await keyring.setPassword(previousPassword).catch(() => undefined);
202
+ }
203
+ throw error;
204
+ }
205
+ if (previousProfile && previousProfile.keychainAccount !== keychainAccount) {
206
+ try {
207
+ const previousKeyring = await entry(previousProfile.keychainAccount, dependencies);
208
+ await previousKeyring.deletePassword();
209
+ }
210
+ catch {
211
+ // The new profile is already committed; the previous token will expire.
212
+ }
213
+ }
214
+ }
215
+ export async function loadCloudCredential(dependencies = {}) {
216
+ const location = paths(dependencies);
217
+ let profile;
218
+ try {
219
+ profile = parseProfile(await readFile(location.profile, 'utf8'));
220
+ }
221
+ catch (error) {
222
+ if (error.code === 'ENOENT')
223
+ return null;
224
+ throw error;
225
+ }
226
+ const keyring = await entry(profile.keychainAccount, dependencies);
227
+ const token = await keyring.getPassword();
228
+ if (!token) {
229
+ throw new Error('The Hypequery Cloud token is missing from your credential vault. Run `hypequery login` again.');
230
+ }
231
+ return {
232
+ cloudUrl: profile.cloudUrl,
233
+ deploymentEndpoint: profile.deploymentEndpoint,
234
+ expiresAt: profile.expiresAt,
235
+ scope: profile.scope,
236
+ ...(profile.target ? { target: profile.target } : {}),
237
+ token,
238
+ };
239
+ }
240
+ /**
241
+ * Recovers just the vault account from a profile that failed validation, so a
242
+ * corrupted profile can still be cleaned up. Only the account is salvaged, and
243
+ * only under our own keychain service, so the worst a tampered value can do is
244
+ * delete another Hypequery entry that `hypequery login` recreates.
245
+ */
246
+ function salvageKeychainAccount(input) {
247
+ let parsed;
248
+ try {
249
+ parsed = JSON.parse(input);
250
+ }
251
+ catch {
252
+ return null;
253
+ }
254
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
255
+ return null;
256
+ }
257
+ const account = parsed.keychainAccount;
258
+ return typeof account === 'string'
259
+ && account.length > 0
260
+ && account.length <= MAX_KEYCHAIN_ACCOUNT_LENGTH
261
+ ? account
262
+ : null;
263
+ }
264
+ export async function deleteCloudCredential(dependencies = {}) {
265
+ const location = paths(dependencies);
266
+ let contents = null;
267
+ try {
268
+ contents = await readFile(location.profile, 'utf8');
269
+ }
270
+ catch (error) {
271
+ if (error.code !== 'ENOENT')
272
+ throw error;
273
+ }
274
+ if (contents !== null) {
275
+ let account;
276
+ try {
277
+ account = parseProfile(contents).keychainAccount;
278
+ }
279
+ catch {
280
+ // `logout` is what users reach for when their profile is broken, so a
281
+ // malformed profile has to stay removable instead of throwing here.
282
+ account = salvageKeychainAccount(contents);
283
+ }
284
+ if (account !== null) {
285
+ const keyring = await entry(account, dependencies).catch(() => null);
286
+ await keyring?.deletePassword().catch(() => undefined);
287
+ }
288
+ }
289
+ await unlink(location.profile).catch(error => {
290
+ if (error.code !== 'ENOENT')
291
+ throw error;
292
+ });
293
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"deployment-upload.d.ts","sourceRoot":"","sources":["../../src/utils/deployment-upload.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACtD,OAAO,EAIL,KAAK,yCAAyC,EAC/C,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAEL,KAAK,wBAAwB,EAC9B,MAAM,wBAAwB,CAAC;AAChC,OAAO,KAAK,EAAE,4BAA4B,EAAE,MAAM,uBAAuB,CAAC;AAE1E,YAAY,EAAE,4BAA4B,EAAE,MAAM,uBAAuB,CAAC;AAQ1E,MAAM,MAAM,yBAAyB,GACjC,yBAAyB,GACzB,6BAA6B,GAC7B,0BAA0B,GAC1B,mBAAmB,GACnB,oBAAoB,GACpB,4BAA4B,CAAC;AAEjC,qBAAa,qBAAsB,SAAQ,KAAK;IAC9C,QAAQ,CAAC,IAAI,EAAE,yBAAyB,CAAC;IACzC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;gBAEb,IAAI,EAAE,yBAAyB,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM;CAM9E;AAED,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAC;IACrB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAC,UAAU,CAAC,GAAG,IAAI,CAAC;CAClD;AAED,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACnD,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC,UAAU,CAAC,CAAC;IACzC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC;CAC9B;AAED,MAAM,MAAM,eAAe,GAAG,CAC5B,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,mBAAmB,KACtB,OAAO,CAAC,sBAAsB,CAAC,CAAC;AAErC,MAAM,WAAW,oCAAoC;IACnD,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,KAAK,CAAC,EAAE,eAAe,CAAC;CAClC;AAED,MAAM,WAAW,yBAAyB;IACxC,MAAM,CACJ,MAAM,EAAE,wBAAwB,EAChC,OAAO,EAAE,yCAAyC,GACjD,OAAO,CAAC,4BAA4B,CAAC,CAAC;CAC1C;AAmVD,wBAAgB,mCAAmC,CACjD,OAAO,EAAE,oCAAoC,GAC5C,yBAAyB,CAsF3B"}
1
+ {"version":3,"file":"deployment-upload.d.ts","sourceRoot":"","sources":["../../src/utils/deployment-upload.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACtD,OAAO,EAIL,KAAK,yCAAyC,EAC/C,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAEL,KAAK,wBAAwB,EAC9B,MAAM,wBAAwB,CAAC;AAEhC,OAAO,KAAK,EAAE,4BAA4B,EAAE,MAAM,uBAAuB,CAAC;AAE1E,YAAY,EAAE,4BAA4B,EAAE,MAAM,uBAAuB,CAAC;AAQ1E,MAAM,MAAM,yBAAyB,GACjC,yBAAyB,GACzB,6BAA6B,GAC7B,0BAA0B,GAC1B,mBAAmB,GACnB,oBAAoB,GACpB,4BAA4B,CAAC;AAEjC,qBAAa,qBAAsB,SAAQ,KAAK;IAC9C,QAAQ,CAAC,IAAI,EAAE,yBAAyB,CAAC;IACzC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;gBAEb,IAAI,EAAE,yBAAyB,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM;CAM9E;AAED,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAC;IACrB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAC,UAAU,CAAC,GAAG,IAAI,CAAC;CAClD;AAED,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACnD,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC,UAAU,CAAC,CAAC;IACzC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC;CAC9B;AAED,MAAM,MAAM,eAAe,GAAG,CAC5B,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,mBAAmB,KACtB,OAAO,CAAC,sBAAsB,CAAC,CAAC;AAErC,MAAM,WAAW,oCAAoC;IACnD,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,KAAK,CAAC,EAAE,eAAe,CAAC;CAClC;AAED,MAAM,WAAW,yBAAyB;IACxC,MAAM,CACJ,MAAM,EAAE,wBAAwB,EAChC,OAAO,EAAE,yCAAyC,GACjD,OAAO,CAAC,4BAA4B,CAAC,CAAC;CAC1C;AA+VD,wBAAgB,mCAAmC,CACjD,OAAO,EAAE,oCAAoC,GAC5C,yBAAyB,CAsF3B"}
@@ -3,6 +3,7 @@ import { constants, open } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { prepareProtocolDeploymentBundleManifest, prepareProtocolDeploymentReleaseEnvelope, } from '@hypequery/protocol';
5
5
  import { DEPLOYMENT_BUNDLE_MANIFEST, } from './deployment-bundle.js';
6
+ import { logger } from './logger.js';
6
7
  const SHA256_PATTERN = /^[0-9a-f]{64}$/;
7
8
  const MAX_RESPONSE_BYTES = 64 * 1024;
8
9
  const DEFAULT_TIMEOUT_MS = 120_000;
@@ -30,8 +31,18 @@ function endpointUrl(input) {
30
31
  catch {
31
32
  configurationError('Deployment endpoint must be an absolute HTTPS URL.');
32
33
  }
33
- if (url.protocol !== 'https:') {
34
- configurationError('Deployment endpoint must use HTTPS.');
34
+ // Loopback HTTP exists so a local Cloud instance can be developed against.
35
+ // It still puts the bearer token on the wire in cleartext, where any local
36
+ // process listening on that port can read it, so say so rather than let a
37
+ // production token be pointed at localhost silently.
38
+ const loopbackHttp = url.protocol === 'http:'
39
+ && (url.hostname === '127.0.0.1' || url.hostname === 'localhost');
40
+ if (url.protocol !== 'https:' && !loopbackHttp) {
41
+ configurationError('Deployment endpoint must use HTTPS (HTTP is allowed only for loopback development).');
42
+ }
43
+ if (loopbackHttp) {
44
+ logger.warn(`Submitting to ${url.origin} over plaintext HTTP; the deployment token is `
45
+ + 'sent unencrypted. Use this only for local Cloud development.');
35
46
  }
36
47
  if (url.username || url.password) {
37
48
  configurationError('Deployment endpoint must not contain credentials.');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hypequery/cli",
3
- "version": "1.10.4",
3
+ "version": "1.12.0",
4
4
  "description": "Command-line interface for hypequery",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -22,6 +22,9 @@
22
22
  "@hypequery/deployment": "0.6.0",
23
23
  "@hypequery/protocol": "0.9.0"
24
24
  },
25
+ "optionalDependencies": {
26
+ "@napi-rs/keyring": "1.3.0"
27
+ },
25
28
  "peerDependencies": {
26
29
  "@hypequery/clickhouse": "*",
27
30
  "@hypequery/serve": "*"