@hasna/recordings 0.3.13 → 0.5.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/README.md +279 -43
- package/bun.lock +37 -38
- package/contracts/v1/fixtures.json +1206 -0
- package/dist/cli/index.js +1909 -619
- package/dist/cli/macos-shortcut.d.ts +2 -2
- package/dist/contracts/hosted-v1.d.ts +105 -0
- package/dist/contracts/hosted-v1.d.ts.map +1 -0
- package/dist/contracts/hosted-v1.js +41 -0
- package/dist/contracts/stream-v1.d.ts +117 -0
- package/dist/contracts/stream-v1.d.ts.map +1 -0
- package/dist/contracts/stream-v1.js +35 -0
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/pg-migrations.d.ts.map +1 -1
- package/dist/db/recordings.d.ts +6 -0
- package/dist/db/recordings.d.ts.map +1 -1
- package/dist/hosted/index.d.ts +58 -0
- package/dist/hosted/index.d.ts.map +1 -0
- package/dist/hosted/index.js +266 -0
- package/dist/hosted/transport.d.ts +36 -0
- package/dist/hosted/transport.d.ts.map +1 -0
- package/dist/hosted-v1-aavn7ktb.js +4114 -0
- package/dist/hosted-v1-gdr9extc.js +84 -0
- package/dist/http/client.d.ts +183 -22
- package/dist/http/client.d.ts.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2879 -2288
- package/dist/lib/audio-artifact-storage.d.ts +98 -0
- package/dist/lib/audio-artifact-storage.d.ts.map +1 -0
- package/dist/lib/capture-probe.d.ts +3 -3
- package/dist/lib/capture-probe.d.ts.map +1 -1
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/local-opt-in.d.ts +132 -0
- package/dist/lib/local-opt-in.d.ts.map +1 -0
- package/dist/lib/macos-bundle.d.ts +1 -1
- package/dist/lib/persistence-probe.d.ts +56 -17
- package/dist/lib/persistence-probe.d.ts.map +1 -1
- package/dist/lib/transcriber.d.ts.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +1713 -459
- package/dist/sdk/index.d.ts +13 -5
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/sdk/index.js +904 -1
- package/dist/sdk/resolve.d.ts +53 -0
- package/dist/sdk/resolve.d.ts.map +1 -0
- package/dist/sdk/v1.generated.d.ts +6 -0
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/client-ip.d.ts +39 -0
- package/dist/server/client-ip.d.ts.map +1 -0
- package/dist/server/cloud-config.d.ts +12 -0
- package/dist/server/cloud-config.d.ts.map +1 -1
- package/dist/server/cloud-readiness.d.ts.map +1 -1
- package/dist/server/cloud.d.ts +20 -2
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +590 -287
- package/dist/server/migrate-command.d.ts +1 -1
- package/dist/server/migrate-command.d.ts.map +1 -1
- package/dist/server/openapi.d.ts +21 -0
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/repo.d.ts.map +1 -1
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/storage.d.ts +2 -2
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +1358 -874
- package/dist/store.d.ts +12 -3
- package/dist/store.d.ts.map +1 -1
- package/dist/types/index.d.ts +15 -0
- package/dist/types/index.d.ts.map +1 -1
- package/docs/hosted-sdk.md +71 -0
- package/docs/wire-contracts.md +24 -0
- package/package.json +33 -11
- package/packaging/macos/build_release_pkg.sh +3 -3
- package/packaging/macos/managed_bootstrap.sh +4 -4
- package/packaging/macos/scripts/postinstall +2 -2
- package/packaging/macos/scripts/preinstall +2 -2
- package/scripts/build_companion_cli.sh +7 -1
- package/scripts/ci-linux-suite.ts +23 -13
- package/scripts/install_macos_app.sh +27 -24
- package/scripts/macos_artifact.ts +60 -49
- package/scripts/migrate.ts +4 -20
- package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
- package/scripts/native/recordings_fs_guard.c +36 -4
- package/scripts/native-core-receipt.py +171 -0
- package/scripts/native_fs_guard.ts +2 -0
- package/scripts/release-suite-gate.ts +227 -150
- package/scripts/resolve_tailscale_cli.sh +24 -3
- package/scripts/smoke_macos_app.sh +21 -21
- package/src/native/Recordings/App/ContentView.swift +53 -68
- package/src/native/Recordings/App/MenuBarStatusView.swift +171 -61
- package/src/native/Recordings/App/RecordWorkspaceView.swift +47 -449
- package/src/native/Recordings/App/RecorderSettingsView.swift +75 -0
- package/src/native/Recordings/App/RecordingDetailView.swift +3 -4
- package/src/native/Recordings/App/RecordingsApp.swift +185 -9
- package/src/native/Recordings/App/RecordingsListView.swift +60 -112
- package/src/native/Recordings/App/RecordingsStore.swift +124 -109
- package/src/native/Recordings/App/Theme.swift +73 -61
- package/src/native/Recordings/Package.swift +1 -0
- package/src/native/Recordings/RecordingsLib/BlockingOperation.swift +29 -0
- package/src/native/Recordings/RecordingsLib/CaptureMonitor.swift +65 -0
- package/src/native/Recordings/RecordingsLib/Info.plist +3 -3
- package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +75 -21
- package/src/native/Recordings/RecordingsLib/ProjectStore.swift +4 -4
- package/src/native/Recordings/RecordingsLib/Recording.swift +14 -2
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +539 -89
- package/src/native/Recordings/RecordingsLib/RecordingGlassBackground.swift +34 -0
- package/src/native/Recordings/RecordingsLib/RecordingPasteTarget.swift +114 -0
- package/src/native/Recordings/RecordingsLib/RecordingProvider.swift +112 -0
- package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +17 -10
- package/src/native/Recordings/RecordingsLib/ServiceAPIConfiguration.swift +107 -0
- package/src/native/Recordings/RecordingsLib/ServiceConnectionView.swift +76 -0
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -149
- package/src/native/Recordings/RecordingsLib/SettingsWindowController.swift +48 -0
- package/src/native/Recordings/RecordingsTests/BlockingOperationTests.swift +85 -0
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +454 -76
- package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +43 -38
- package/src/native/Recordings/RecordingsTests/PipeClosureFixture.swift +215 -0
- package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +10 -10
- package/src/native/Recordings/RecordingsTests/RecorderSurfaceTests.swift +60 -0
- package/src/native/Recordings/RecordingsTests/RecordingDateTests.swift +26 -0
- package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +1 -1
- package/src/native/Recordings/RecordingsTests/RecordingFrozenPasteTargetTests.swift +50 -0
- package/src/native/Recordings/RecordingsTests/RecordingPasteTargetTrackerTests.swift +48 -0
- package/src/native/Recordings/RecordingsTests/RecordingProviderTests.swift +308 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +1 -1
- package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +123 -21
- package/src/native/Recordings/RecordingsTests/ServiceAPIConfigurationTests.swift +75 -0
- package/src/native/Recordings/RecordingsTests/SettingsWindowControllerTests.swift +28 -0
- package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +5 -1
- package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +1 -1
- package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +2 -2
- package/src/native/Recordings/Updater/Broker/BrokerMain.swift +1 -1
- package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +1 -1
- package/src/native/Recordings/Updater/Broker/InstallJournal.swift +1 -1
- package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +2 -2
- package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +1 -1
- package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +2 -2
- package/src/native/Recordings/build.sh +8 -7
- package/dist/__tests__/helpers/installer-guard-execution.d.ts +0 -22
- package/dist/__tests__/helpers/installer-guard-execution.d.ts.map +0 -1
- package/dist/__tests__/helpers/installer-preflight.d.ts +0 -22
- package/dist/__tests__/helpers/installer-preflight.d.ts.map +0 -1
- package/dist/__tests__/helpers/native-fs-guard.d.ts +0 -2
- package/dist/__tests__/helpers/native-fs-guard.d.ts.map +0 -1
- package/dist/__tests__/helpers/source-assertions.d.ts +0 -171
- package/dist/__tests__/helpers/source-assertions.d.ts.map +0 -1
- package/dist/__tests__/preload.d.ts +0 -2
- package/dist/__tests__/preload.d.ts.map +0 -1
- package/src/native/Recordings/App/SidebarView.swift +0 -212
package/dist/sdk/index.js
CHANGED
|
@@ -1,6 +1,751 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
var __require = import.meta.require;
|
|
3
3
|
|
|
4
|
+
// ../contracts/dist/client/transport.js
|
|
5
|
+
import { isIP } from "net";
|
|
6
|
+
import { spawnSync } from "child_process";
|
|
7
|
+
import { closeSync, fstatSync, openSync, readFileSync } from "fs";
|
|
8
|
+
import { O_NOFOLLOW, O_NONBLOCK, O_RDONLY } from "constants";
|
|
9
|
+
import { createRequire } from "module";
|
|
10
|
+
import { hostname as osHostname } from "os";
|
|
11
|
+
import { isAbsolute, join } from "path";
|
|
12
|
+
function envToken(name) {
|
|
13
|
+
return name.toUpperCase().replace(/-/g, "_");
|
|
14
|
+
}
|
|
15
|
+
function clientTransportEnvKeys(name) {
|
|
16
|
+
const envSegment = envToken(name);
|
|
17
|
+
return {
|
|
18
|
+
apiUrlKeys: [`HASNA_${envSegment}_API_URL`, `${envSegment}_API_URL`],
|
|
19
|
+
apiKeyKeys: [`HASNA_${envSegment}_API_KEY`, `${envSegment}_API_KEY`]
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function credentialOverrideEnvKey(name) {
|
|
23
|
+
return `HASNA_${envToken(name)}_API_KEY_OVERRIDE`;
|
|
24
|
+
}
|
|
25
|
+
var CREDENTIAL_PROFILE_ENV_KEY = "HASNA_PROFILE";
|
|
26
|
+
function credentialPointerEnvKey(name) {
|
|
27
|
+
return `HASNA_${envToken(name)}_API_KEY_REF`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class CredentialResolutionError extends Error {
|
|
31
|
+
appName;
|
|
32
|
+
attempted;
|
|
33
|
+
constructor(appName, message, attempted) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = "CredentialResolutionError";
|
|
36
|
+
this.appName = appName;
|
|
37
|
+
this.attempted = attempted;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
class CredentialFileUnsafeError extends Error {
|
|
42
|
+
path;
|
|
43
|
+
constructor(path, reason) {
|
|
44
|
+
super(`Refusing unsafe credential/config file ${path}: ${reason}.`);
|
|
45
|
+
this.name = "CredentialFileUnsafeError";
|
|
46
|
+
this.path = path;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
var HASNA_HOME_ENV_KEY = "HASNA_HOME";
|
|
50
|
+
var HASNA_CONFIG_HOME_ENV_KEY = "HASNA_CONFIG_HOME";
|
|
51
|
+
var KEYCHAIN_STATION_ENV_KEY = "HASNA_STATION";
|
|
52
|
+
var HASNA_HOME_DIR = ".hasna";
|
|
53
|
+
var CONFIG_SUBDIR = "config";
|
|
54
|
+
var CREDENTIALS_FILE = "credentials";
|
|
55
|
+
var KEYCHAIN_SECURITY_BIN = "/usr/bin/security";
|
|
56
|
+
var KEYCHAIN_SERVICE_PREFIX = "hasna.credentials";
|
|
57
|
+
var KEYCHAIN_ITEM_NOT_FOUND_STATUS = 44;
|
|
58
|
+
var KEYCHAIN_SPAWN_TIMEOUT_MS = 1e4;
|
|
59
|
+
var MAX_CREDENTIAL_FILE_BYTES = 64 * 1024;
|
|
60
|
+
var SAFE_APP_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
61
|
+
var SAFE_PROFILE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
|
62
|
+
var ILLEGAL_IN_HEADER_VALUE = /[^\t\x20-\x7e]/;
|
|
63
|
+
var VAULT_POINTER_SHAPE = /^[a-z0-9][a-z0-9-]*(?:\/[a-z0-9][a-z0-9-_.]*){2,}$/;
|
|
64
|
+
function homeDir(env) {
|
|
65
|
+
const home = env.HOME?.trim();
|
|
66
|
+
return home ? home : null;
|
|
67
|
+
}
|
|
68
|
+
function absoluteOverride(env, key) {
|
|
69
|
+
const value = env[key]?.trim();
|
|
70
|
+
return value && isAbsolute(value) ? value : null;
|
|
71
|
+
}
|
|
72
|
+
function hasnaHomeDir(env) {
|
|
73
|
+
const override = absoluteOverride(env, HASNA_HOME_ENV_KEY);
|
|
74
|
+
if (override)
|
|
75
|
+
return override;
|
|
76
|
+
const home = homeDir(env);
|
|
77
|
+
return home ? join(home, HASNA_HOME_DIR) : null;
|
|
78
|
+
}
|
|
79
|
+
function appConfigDir(name, env) {
|
|
80
|
+
const configRoot = absoluteOverride(env, HASNA_CONFIG_HOME_ENV_KEY);
|
|
81
|
+
if (configRoot)
|
|
82
|
+
return join(configRoot, name);
|
|
83
|
+
const root = hasnaHomeDir(env);
|
|
84
|
+
return root ? join(root, name, CONFIG_SUBDIR) : null;
|
|
85
|
+
}
|
|
86
|
+
function credentialDiskSourceList(name, env, profile = null) {
|
|
87
|
+
if (!SAFE_APP_SLUG.test(name))
|
|
88
|
+
return [];
|
|
89
|
+
const directory = appConfigDir(name, env);
|
|
90
|
+
if (!directory)
|
|
91
|
+
return [];
|
|
92
|
+
const file = profile ? `${CREDENTIALS_FILE}-${profile}` : CREDENTIALS_FILE;
|
|
93
|
+
return [{ path: join(directory, file), tier: "disk" }];
|
|
94
|
+
}
|
|
95
|
+
function credentialDiskSources(name, env) {
|
|
96
|
+
return credentialDiskSourceList(name, env, null).map((s) => s.path);
|
|
97
|
+
}
|
|
98
|
+
function profileDiskSources(name, env, profile) {
|
|
99
|
+
return credentialDiskSourceList(name, env, profile).map((s) => s.path);
|
|
100
|
+
}
|
|
101
|
+
function parseEnvFile(text) {
|
|
102
|
+
const values = new Map;
|
|
103
|
+
const unusable = new Set;
|
|
104
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
105
|
+
const line = rawLine.trim();
|
|
106
|
+
if (line.length === 0 || line.startsWith("#"))
|
|
107
|
+
continue;
|
|
108
|
+
const withoutExport = line.startsWith("export ") ? line.slice("export ".length).trim() : line;
|
|
109
|
+
const equals = withoutExport.indexOf("=");
|
|
110
|
+
if (equals <= 0)
|
|
111
|
+
continue;
|
|
112
|
+
const key = withoutExport.slice(0, equals).trim();
|
|
113
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
114
|
+
continue;
|
|
115
|
+
let value = withoutExport.slice(equals + 1).trim();
|
|
116
|
+
const quote = value[0];
|
|
117
|
+
if (quote === '"' || quote === "'") {
|
|
118
|
+
if (value.length < 2 || !value.endsWith(quote)) {
|
|
119
|
+
unusable.add(key);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
value = value.slice(1, -1);
|
|
123
|
+
}
|
|
124
|
+
if (value.trim().length === 0) {
|
|
125
|
+
unusable.add(key);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (values.has(key) && values.get(key) !== value)
|
|
129
|
+
unusable.add(key);
|
|
130
|
+
values.set(key, value);
|
|
131
|
+
}
|
|
132
|
+
return { values, unusable };
|
|
133
|
+
}
|
|
134
|
+
function configFileModeAllowed(mode) {
|
|
135
|
+
const permissions = mode & 4095;
|
|
136
|
+
return permissions === 256 || permissions === 384;
|
|
137
|
+
}
|
|
138
|
+
function configFileReadsCoherent(before, after) {
|
|
139
|
+
return before.dev === after.dev && before.ino === after.ino && before.size === after.size && before.mtimeMs === after.mtimeMs && before.ctimeMs === after.ctimeMs;
|
|
140
|
+
}
|
|
141
|
+
function readAppConfigFile(path) {
|
|
142
|
+
const unsafe = (reason) => {
|
|
143
|
+
throw new CredentialFileUnsafeError(path, reason);
|
|
144
|
+
};
|
|
145
|
+
let fd = -1;
|
|
146
|
+
try {
|
|
147
|
+
fd = openSync(path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
const code = error.code;
|
|
150
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
151
|
+
return null;
|
|
152
|
+
if (code === "ELOOP")
|
|
153
|
+
unsafe("the path is a symlink");
|
|
154
|
+
unsafe(`the path could not be opened (${code ?? "unknown error"})`);
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
const before = fstatSync(fd);
|
|
158
|
+
if (!before.isFile())
|
|
159
|
+
unsafe("the path is not a regular file");
|
|
160
|
+
if (!configFileModeAllowed(before.mode)) {
|
|
161
|
+
unsafe(`permission mode ${(before.mode & 4095).toString(8).padStart(4, "0")} is not owner-only 0400 or 0600`);
|
|
162
|
+
}
|
|
163
|
+
const uid = process.getuid?.() ?? process.geteuid?.();
|
|
164
|
+
if (uid !== undefined && before.uid !== uid)
|
|
165
|
+
unsafe("the file is not owned by the current user");
|
|
166
|
+
if (before.size > MAX_CREDENTIAL_FILE_BYTES)
|
|
167
|
+
unsafe("the file exceeds the size limit");
|
|
168
|
+
const bytes = readFileSync(fd);
|
|
169
|
+
const after = fstatSync(fd);
|
|
170
|
+
if (!configFileReadsCoherent(before, after)) {
|
|
171
|
+
unsafe("the file changed while being read");
|
|
172
|
+
}
|
|
173
|
+
return parseEnvFile(bytes.toString("utf8"));
|
|
174
|
+
} finally {
|
|
175
|
+
if (fd !== -1)
|
|
176
|
+
closeSync(fd);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function readCredentialFile(path, apiKeyKeys) {
|
|
180
|
+
const parsed = readAppConfigFile(path);
|
|
181
|
+
if (!parsed)
|
|
182
|
+
return null;
|
|
183
|
+
for (const key of apiKeyKeys) {
|
|
184
|
+
if (parsed.unusable.has(key)) {
|
|
185
|
+
throw new CredentialFileUnsafeError(path, `${key} is declared but blank or malformed`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const values = apiKeyKeys.map((key) => parsed.values.get(key)?.trim()).filter((value) => Boolean(value));
|
|
189
|
+
if (new Set(values).size > 1) {
|
|
190
|
+
throw new CredentialFileUnsafeError(path, "credential aliases disagree");
|
|
191
|
+
}
|
|
192
|
+
return values[0] ?? null;
|
|
193
|
+
}
|
|
194
|
+
var CREDENTIAL_SHAPED_KEY = /(?:^|_)(?:API_KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)(?:_|$)/;
|
|
195
|
+
function appConfigDiskValue(name, env, keys) {
|
|
196
|
+
const wanted = keys.filter((key) => !CREDENTIAL_SHAPED_KEY.test(key));
|
|
197
|
+
if (wanted.length === 0)
|
|
198
|
+
return null;
|
|
199
|
+
for (const path of credentialDiskSources(name, env)) {
|
|
200
|
+
const parsed = readAppConfigFile(path);
|
|
201
|
+
if (!parsed)
|
|
202
|
+
continue;
|
|
203
|
+
if (wanted.some((key) => parsed.unusable.has(key))) {
|
|
204
|
+
return { key: wanted.find((key) => parsed.unusable.has(key)), value: "", path, unusable: true };
|
|
205
|
+
}
|
|
206
|
+
const values = wanted.map((key) => parsed.values.get(key)?.trim()).filter((value) => Boolean(value));
|
|
207
|
+
if (new Set(values).size > 1)
|
|
208
|
+
throw new CredentialFileUnsafeError(path, "configuration aliases disagree");
|
|
209
|
+
for (const key of wanted) {
|
|
210
|
+
if (parsed.unusable.has(key))
|
|
211
|
+
return { key, value: "", path, unusable: true };
|
|
212
|
+
const value = parsed.values.get(key)?.trim();
|
|
213
|
+
if (value)
|
|
214
|
+
return { key, value, path };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
function assertUsableCredential(appName, source, value) {
|
|
220
|
+
if (VAULT_POINTER_SHAPE.test(value)) {
|
|
221
|
+
throw new CredentialResolutionError(appName, `The credential from ${source} looks like a secrets-vault pointer (a path-shaped reference like ` + `'namespace/app/live/api_key'). A vault path is NEVER accepted as a literal API key. ` + `Use ${credentialPointerEnvKey(appName)} to resolve the key through the vault, or provide the actual key value.`, [source]);
|
|
222
|
+
}
|
|
223
|
+
if (!ILLEGAL_IN_HEADER_VALUE.test(value))
|
|
224
|
+
return;
|
|
225
|
+
throw new CredentialResolutionError(appName, `The credential from ${source} contains characters that cannot be sent in an HTTP header ` + `(a control character or non-ASCII byte). A file written with CR-only line endings is the usual ` + `cause. Rewrite that credential file with one LF-terminated KEY=value line. ` + `The value is not shown here, and is deliberately never logged.`, [source]);
|
|
226
|
+
}
|
|
227
|
+
var INSPECT_CUSTOM = Symbol.for("nodejs.util.inspect.custom");
|
|
228
|
+
var CREDENTIAL_SEAL = Symbol.for("hasna:contracts:sealedCredential");
|
|
229
|
+
function sealCredential(fields) {
|
|
230
|
+
const { apiKey } = fields;
|
|
231
|
+
const visible = {
|
|
232
|
+
tier: fields.tier,
|
|
233
|
+
source: fields.source,
|
|
234
|
+
deliberate: fields.deliberate,
|
|
235
|
+
diskCandidates: Object.freeze([...fields.diskCandidates]),
|
|
236
|
+
warning: fields.warning
|
|
237
|
+
};
|
|
238
|
+
const sealed = { ...visible };
|
|
239
|
+
Object.defineProperty(sealed, "apiKey", {
|
|
240
|
+
value: apiKey,
|
|
241
|
+
enumerable: false,
|
|
242
|
+
writable: false,
|
|
243
|
+
configurable: false
|
|
244
|
+
});
|
|
245
|
+
if (fields.pointerVaultKey !== undefined) {
|
|
246
|
+
Object.defineProperty(sealed, "pointerVaultKey", {
|
|
247
|
+
value: fields.pointerVaultKey,
|
|
248
|
+
enumerable: false,
|
|
249
|
+
writable: false,
|
|
250
|
+
configurable: false
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
Object.defineProperty(sealed, INSPECT_CUSTOM, {
|
|
254
|
+
value: () => ({ ...visible, apiKey: "[redacted]" }),
|
|
255
|
+
enumerable: false,
|
|
256
|
+
writable: false,
|
|
257
|
+
configurable: false
|
|
258
|
+
});
|
|
259
|
+
Object.defineProperty(sealed, CREDENTIAL_SEAL, {
|
|
260
|
+
value: true,
|
|
261
|
+
enumerable: false,
|
|
262
|
+
writable: false,
|
|
263
|
+
configurable: false
|
|
264
|
+
});
|
|
265
|
+
return Object.freeze(sealed);
|
|
266
|
+
}
|
|
267
|
+
function firstEnvValue(env, keys) {
|
|
268
|
+
for (const key of keys) {
|
|
269
|
+
if (!Object.prototype.hasOwnProperty.call(env, key))
|
|
270
|
+
continue;
|
|
271
|
+
const value = env[key]?.trim();
|
|
272
|
+
if (value)
|
|
273
|
+
return { key, value };
|
|
274
|
+
}
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
var AMBIENT_ENVIRONMENT = Symbol.for("hasna:contracts:ambientClientEnvironment");
|
|
278
|
+
function isAmbientEnvironment(env) {
|
|
279
|
+
return env === process.env || env[AMBIENT_ENVIRONMENT] === true;
|
|
280
|
+
}
|
|
281
|
+
function defaultKeychainRunner(argv) {
|
|
282
|
+
const result = spawnSync(KEYCHAIN_SECURITY_BIN, [...argv], {
|
|
283
|
+
encoding: "utf8",
|
|
284
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
285
|
+
timeout: KEYCHAIN_SPAWN_TIMEOUT_MS
|
|
286
|
+
});
|
|
287
|
+
return {
|
|
288
|
+
status: result.status,
|
|
289
|
+
stdout: result.stdout ?? "",
|
|
290
|
+
stderr: result.error ? result.error.message : result.stderr ?? ""
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
function keychainTierEnabled(env, options) {
|
|
294
|
+
if ((options.platform ?? process.platform) !== "darwin")
|
|
295
|
+
return false;
|
|
296
|
+
if (options.enabled !== undefined)
|
|
297
|
+
return options.enabled;
|
|
298
|
+
return options.run !== undefined || isAmbientEnvironment(env);
|
|
299
|
+
}
|
|
300
|
+
function keychainAccount(env, options) {
|
|
301
|
+
const station = env[KEYCHAIN_STATION_ENV_KEY]?.trim();
|
|
302
|
+
if (station)
|
|
303
|
+
return station;
|
|
304
|
+
const host = (options.hostname ?? osHostname)().split(".")[0]?.trim() ?? "";
|
|
305
|
+
if (host)
|
|
306
|
+
return host;
|
|
307
|
+
const user = env.USER?.trim();
|
|
308
|
+
return user || null;
|
|
309
|
+
}
|
|
310
|
+
function keychainFailureHint(text) {
|
|
311
|
+
const line = text.split(/\r?\n/).find((entry) => entry.trim().length > 0)?.trim() ?? "";
|
|
312
|
+
const clean = line.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 200);
|
|
313
|
+
return clean ? `: ${clean}` : "";
|
|
314
|
+
}
|
|
315
|
+
function readKeychainItem(name, env, kind, options) {
|
|
316
|
+
if (!SAFE_APP_SLUG.test(name) || !keychainTierEnabled(env, options))
|
|
317
|
+
return null;
|
|
318
|
+
const account = keychainAccount(env, options);
|
|
319
|
+
if (!account)
|
|
320
|
+
return null;
|
|
321
|
+
const service = `${KEYCHAIN_SERVICE_PREFIX}.${name}.${kind}`;
|
|
322
|
+
const source = `keychain:${service}@${account}`;
|
|
323
|
+
const run = options.run ?? defaultKeychainRunner;
|
|
324
|
+
let result;
|
|
325
|
+
try {
|
|
326
|
+
result = run(["find-generic-password", "-a", account, "-s", service, "-w"]);
|
|
327
|
+
} catch (error) {
|
|
328
|
+
const reason = keychainFailureHint(error instanceof Error ? error.message : String(error));
|
|
329
|
+
throw new CredentialResolutionError(name, `The Keychain lookup for ${source} could not run${reason}. A Keychain failure is never resolved ` + `around: fix the keychain, or delete the item to fall through to the credential on disk.`, [source]);
|
|
330
|
+
}
|
|
331
|
+
if (result.status === KEYCHAIN_ITEM_NOT_FOUND_STATUS)
|
|
332
|
+
return null;
|
|
333
|
+
if (result.status !== 0) {
|
|
334
|
+
throw new CredentialResolutionError(name, `The Keychain lookup for ${source} failed (security exited ` + `${result.status ?? "without a status"}${keychainFailureHint(result.stderr)}). A Keychain item that ` + `exists but cannot be read is never resolved around: unlock the keychain, run from a session that ` + `may use it, or delete the item to fall through to the credential on disk.`, [source]);
|
|
335
|
+
}
|
|
336
|
+
const value = result.stdout.trim();
|
|
337
|
+
if (!value) {
|
|
338
|
+
throw new CredentialResolutionError(name, `${source} exists but holds an empty value; a declared item never falls through to another ` + `identity. Store a value in it or delete the item.`, [source]);
|
|
339
|
+
}
|
|
340
|
+
return { value, source };
|
|
341
|
+
}
|
|
342
|
+
function keychainConfigValue(name, env, options = {}) {
|
|
343
|
+
return readKeychainItem(name, env, "api-url", options);
|
|
344
|
+
}
|
|
345
|
+
function snapshotClientEnvironment(name, env) {
|
|
346
|
+
const keys = clientTransportEnvKeys(name);
|
|
347
|
+
const ambient = isAmbientEnvironment(env);
|
|
348
|
+
const snapshot = Object.create(null);
|
|
349
|
+
for (const key of [
|
|
350
|
+
...keys.apiUrlKeys,
|
|
351
|
+
...keys.apiKeyKeys,
|
|
352
|
+
credentialOverrideEnvKey(name),
|
|
353
|
+
credentialPointerEnvKey(name),
|
|
354
|
+
CREDENTIAL_PROFILE_ENV_KEY,
|
|
355
|
+
"HOME",
|
|
356
|
+
HASNA_HOME_ENV_KEY,
|
|
357
|
+
HASNA_CONFIG_HOME_ENV_KEY,
|
|
358
|
+
KEYCHAIN_STATION_ENV_KEY,
|
|
359
|
+
"USER"
|
|
360
|
+
]) {
|
|
361
|
+
const descriptor = Object.getOwnPropertyDescriptor(env, key);
|
|
362
|
+
if (!descriptor)
|
|
363
|
+
continue;
|
|
364
|
+
if (!("value" in descriptor)) {
|
|
365
|
+
throw new CredentialResolutionError(name, `${key} is accessor-backed; client configuration requires own data properties.`, [key]);
|
|
366
|
+
}
|
|
367
|
+
if (descriptor.value !== undefined && typeof descriptor.value !== "string") {
|
|
368
|
+
throw new CredentialResolutionError(name, `${key} must be a string data property.`, [key]);
|
|
369
|
+
}
|
|
370
|
+
snapshot[key] = descriptor.value;
|
|
371
|
+
}
|
|
372
|
+
if (ambient) {
|
|
373
|
+
Object.defineProperty(snapshot, AMBIENT_ENVIRONMENT, {
|
|
374
|
+
value: true,
|
|
375
|
+
enumerable: false,
|
|
376
|
+
writable: false,
|
|
377
|
+
configurable: false
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
return Object.freeze(snapshot);
|
|
381
|
+
}
|
|
382
|
+
function resolveCredential(name, env, options = {}) {
|
|
383
|
+
env = snapshotClientEnvironment(name, env);
|
|
384
|
+
const { apiKeyKeys } = clientTransportEnvKeys(name);
|
|
385
|
+
const diskPaths = credentialDiskSources(name, env);
|
|
386
|
+
if (options.apiKey !== undefined) {
|
|
387
|
+
const explicitKey = options.apiKey.trim();
|
|
388
|
+
if (!explicitKey) {
|
|
389
|
+
throw new CredentialResolutionError(name, "The explicit apiKey argument is blank; an explicit credential never falls through to another identity.", ["explicit apiKey argument"]);
|
|
390
|
+
}
|
|
391
|
+
assertUsableCredential(name, "the explicit apiKey argument", explicitKey);
|
|
392
|
+
return sealCredential({
|
|
393
|
+
apiKey: explicitKey,
|
|
394
|
+
tier: "argument",
|
|
395
|
+
source: "explicit apiKey argument",
|
|
396
|
+
deliberate: true,
|
|
397
|
+
diskCandidates: diskPaths,
|
|
398
|
+
warning: null
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
const overrideKeyName = credentialOverrideEnvKey(name);
|
|
402
|
+
const overrideRaw = Object.prototype.hasOwnProperty.call(env, overrideKeyName) ? env[overrideKeyName] : undefined;
|
|
403
|
+
if (overrideRaw !== undefined) {
|
|
404
|
+
const override = overrideRaw.trim();
|
|
405
|
+
if (!override) {
|
|
406
|
+
throw new CredentialResolutionError(name, `${overrideKeyName} is set but empty. It is a deliberate override, so it is not resolved around: ` + `either give it a real key or unset it to fall back to the credential on disk.`, [overrideKeyName]);
|
|
407
|
+
}
|
|
408
|
+
assertUsableCredential(name, overrideKeyName, override);
|
|
409
|
+
return sealCredential({
|
|
410
|
+
apiKey: override,
|
|
411
|
+
tier: "override",
|
|
412
|
+
source: overrideKeyName,
|
|
413
|
+
deliberate: true,
|
|
414
|
+
diskCandidates: diskPaths,
|
|
415
|
+
warning: null
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
const pointerKeyName = credentialPointerEnvKey(name);
|
|
419
|
+
const pointerRaw = Object.prototype.hasOwnProperty.call(env, pointerKeyName) ? env[pointerKeyName] : undefined;
|
|
420
|
+
if (pointerRaw !== undefined) {
|
|
421
|
+
const pointer = pointerRaw.trim();
|
|
422
|
+
if (!pointer) {
|
|
423
|
+
throw new CredentialResolutionError(name, `${pointerKeyName} is set but empty. It is a deliberate vault pointer, so it is not resolved around: ` + `either give it a vault item key or unset it to fall back to the credential on disk.`, [pointerKeyName]);
|
|
424
|
+
}
|
|
425
|
+
if (!VAULT_POINTER_SHAPE.test(pointer)) {
|
|
426
|
+
throw new CredentialResolutionError(name, `${pointerKeyName} must name a vault ITEM KEY (a path-shaped reference like ` + `'namespace/app/live/api_key'), not a credential value. A pointer that carries a literal is refused.`, [pointerKeyName]);
|
|
427
|
+
}
|
|
428
|
+
return sealCredential({
|
|
429
|
+
apiKey: "",
|
|
430
|
+
pointerVaultKey: pointer,
|
|
431
|
+
tier: "pointer",
|
|
432
|
+
source: pointerKeyName,
|
|
433
|
+
deliberate: true,
|
|
434
|
+
diskCandidates: diskPaths,
|
|
435
|
+
warning: null
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
if (options.profile !== undefined && !options.profile.trim()) {
|
|
439
|
+
throw new CredentialResolutionError(name, "The explicit profile argument is blank; an explicit identity selection never falls through.", ["explicit profile argument"]);
|
|
440
|
+
}
|
|
441
|
+
const profileRaw = Object.prototype.hasOwnProperty.call(env, CREDENTIAL_PROFILE_ENV_KEY) ? env[CREDENTIAL_PROFILE_ENV_KEY] : undefined;
|
|
442
|
+
if (profileRaw !== undefined && !profileRaw.trim()) {
|
|
443
|
+
throw new CredentialResolutionError(name, `${CREDENTIAL_PROFILE_ENV_KEY} is set but blank.`, [CREDENTIAL_PROFILE_ENV_KEY]);
|
|
444
|
+
}
|
|
445
|
+
const profile = options.profile?.trim() || profileRaw?.trim();
|
|
446
|
+
if (profile) {
|
|
447
|
+
const profileSource = options.profile?.trim() ? "explicit profile argument" : CREDENTIAL_PROFILE_ENV_KEY;
|
|
448
|
+
if (!SAFE_PROFILE.test(profile)) {
|
|
449
|
+
throw new CredentialResolutionError(name, `Profile name from ${profileSource} is not usable in a path. ` + `Use letters, digits, dot, dash, or underscore.`, [profileSource]);
|
|
450
|
+
}
|
|
451
|
+
const paths = profileDiskSources(name, env, profile);
|
|
452
|
+
for (const path of paths) {
|
|
453
|
+
const value = readCredentialFile(path, apiKeyKeys);
|
|
454
|
+
if (value) {
|
|
455
|
+
assertUsableCredential(name, path, value);
|
|
456
|
+
return sealCredential({
|
|
457
|
+
apiKey: value,
|
|
458
|
+
tier: "profile",
|
|
459
|
+
source: path,
|
|
460
|
+
deliberate: true,
|
|
461
|
+
diskCandidates: paths,
|
|
462
|
+
warning: null
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
throw new CredentialResolutionError(name, `Profile '${profile}' (from ${profileSource}) has no ${apiKeyKeys[0]} for '${name}'. ` + `Looked in: ${paths.join(", ") || "<no HOME in this environment>"}. ` + `A profile names WHICH identity to use, so it is never resolved around \u2014 ` + `create the profile's credential file or unset ${CREDENTIAL_PROFILE_ENV_KEY}.`, paths);
|
|
467
|
+
}
|
|
468
|
+
const definedEnvEntries = apiKeyKeys.filter((key) => Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined).map((key) => ({ key, value: String(env[key]).trim() }));
|
|
469
|
+
const blankEnv = definedEnvEntries.find((entry) => entry.value.length === 0);
|
|
470
|
+
if (blankEnv) {
|
|
471
|
+
throw new CredentialResolutionError(name, `${blankEnv.key} is set but blank; a declared credential never falls through to another alias or identity.`, [blankEnv.key]);
|
|
472
|
+
}
|
|
473
|
+
if (definedEnvEntries.length > 1 && new Set(definedEnvEntries.map((entry) => entry.value)).size > 1) {
|
|
474
|
+
throw new CredentialResolutionError(name, `${definedEnvEntries.map((entry) => entry.key).join(" and ")} disagree; credential aliases must be identical or only one may be set.`, definedEnvEntries.map((entry) => entry.key));
|
|
475
|
+
}
|
|
476
|
+
const envHit = firstEnvValue(env, apiKeyKeys);
|
|
477
|
+
const keychainHit = readKeychainItem(name, env, "api-key", options.keychain ?? {});
|
|
478
|
+
if (keychainHit) {
|
|
479
|
+
assertUsableCredential(name, keychainHit.source, keychainHit.value);
|
|
480
|
+
const warning = envHit && envHit.value !== keychainHit.value ? `Credential sources disagree for '${name}': ${keychainHit.source} and ${envHit.key} hold ` + `different keys. ${keychainHit.source} wins, because the Keychain is re-read on every call while ` + `an environment variable is a snapshot. Reconcile them \u2014 a rotation that updated only one leaves ` + `the other to fail 401 wherever it is loaded first.` : null;
|
|
481
|
+
return sealCredential({
|
|
482
|
+
apiKey: keychainHit.value,
|
|
483
|
+
tier: "keychain",
|
|
484
|
+
source: keychainHit.source,
|
|
485
|
+
deliberate: false,
|
|
486
|
+
diskCandidates: diskPaths,
|
|
487
|
+
warning
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
const diskSourceList = credentialDiskSourceList(name, env, null);
|
|
491
|
+
const diskHits = diskSourceList.map((src) => ({ src, value: readCredentialFile(src.path, apiKeyKeys) })).filter((hit) => hit.value !== null);
|
|
492
|
+
if (diskHits.length > 0) {
|
|
493
|
+
const winner = diskHits[0];
|
|
494
|
+
assertUsableCredential(name, winner.src.path, winner.value);
|
|
495
|
+
const divergentSources = [
|
|
496
|
+
...diskHits.slice(1).filter((hit) => hit.value !== winner.value).map((hit) => hit.src.path),
|
|
497
|
+
...envHit && envHit.value !== winner.value ? [envHit.key] : []
|
|
498
|
+
];
|
|
499
|
+
const warning = divergentSources.length > 0 ? `Credential sources disagree for '${name}': ${winner.src.path} and ` + `${divergentSources.join(", ")} hold different keys. ${winner.src.path} wins, because a file on ` + `disk is re-read on every call while an environment variable is a snapshot. Reconcile them \u2014 ` + `a rotation that updated only one leaves the other to fail 401 wherever it is loaded first.` : null;
|
|
500
|
+
return sealCredential({
|
|
501
|
+
apiKey: winner.value,
|
|
502
|
+
tier: winner.src.tier,
|
|
503
|
+
source: winner.src.path,
|
|
504
|
+
deliberate: false,
|
|
505
|
+
diskCandidates: diskPaths,
|
|
506
|
+
warning
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
if (envHit) {
|
|
510
|
+
assertUsableCredential(name, envHit.key, envHit.value);
|
|
511
|
+
return sealCredential({
|
|
512
|
+
apiKey: envHit.value,
|
|
513
|
+
tier: "env",
|
|
514
|
+
source: envHit.key,
|
|
515
|
+
deliberate: false,
|
|
516
|
+
diskCandidates: diskPaths,
|
|
517
|
+
warning: null
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
var SECRETS_PACKAGE_SPECIFIER = "@hasna/" + "secrets";
|
|
523
|
+
var requireSecretsSdk = createRequire(import.meta.url);
|
|
524
|
+
var DEFAULT_FLEET_GATEWAY_ORIGIN = "https://api.hasna.com";
|
|
525
|
+
var DEFAULT_AUTHORITY_SOURCE = "default";
|
|
526
|
+
function defaultFleetGatewayBaseUrl(name) {
|
|
527
|
+
return `${DEFAULT_FLEET_GATEWAY_ORIGIN}/${validateAppSlug(name)}`;
|
|
528
|
+
}
|
|
529
|
+
var ASCII_CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
|
530
|
+
var DNS_LABEL_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
531
|
+
function isValidDnsDomain(value) {
|
|
532
|
+
if (value.length === 0 || value.length > 253 || ASCII_CONTROL_PATTERN.test(value) || /[^\x00-\x7f]/.test(value)) {
|
|
533
|
+
return false;
|
|
534
|
+
}
|
|
535
|
+
return value.split(".").every((label) => label.length <= 63 && !label.startsWith("xn--") && DNS_LABEL_PATTERN.test(label));
|
|
536
|
+
}
|
|
537
|
+
function validateAppSlug(name) {
|
|
538
|
+
if (name.length > 63 || !DNS_LABEL_PATTERN.test(name)) {
|
|
539
|
+
throw new Error("App name must be one lowercase DNS label.");
|
|
540
|
+
}
|
|
541
|
+
return name;
|
|
542
|
+
}
|
|
543
|
+
function rawAuthority(value) {
|
|
544
|
+
const match = /^[a-z][a-z0-9+.-]*:\/\//i.exec(value);
|
|
545
|
+
if (!match)
|
|
546
|
+
throw new Error("API URL must be absolute.");
|
|
547
|
+
const afterScheme = value.slice(match[0].length);
|
|
548
|
+
const boundary = afterScheme.search(/[/?#]/);
|
|
549
|
+
const authority = boundary === -1 ? afterScheme : afterScheme.slice(0, boundary);
|
|
550
|
+
if (!authority)
|
|
551
|
+
throw new Error("API URL must include a hostname.");
|
|
552
|
+
return authority;
|
|
553
|
+
}
|
|
554
|
+
function assertCanonicalPort(port) {
|
|
555
|
+
if (!/^[0-9]+$/.test(port) || port.length > 1 && port.startsWith("0")) {
|
|
556
|
+
throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
|
|
557
|
+
}
|
|
558
|
+
const numericPort = Number(port);
|
|
559
|
+
if (!Number.isSafeInteger(numericPort) || numericPort < 1 || numericPort > 65535) {
|
|
560
|
+
throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
function canonicalAuthorityHostname(authority) {
|
|
564
|
+
let rawHostname;
|
|
565
|
+
if (authority.startsWith("[")) {
|
|
566
|
+
const closingBracket = authority.indexOf("]");
|
|
567
|
+
if (closingBracket === -1) {
|
|
568
|
+
throw new Error("API URL authority must contain a canonical hostname.");
|
|
569
|
+
}
|
|
570
|
+
rawHostname = authority.slice(0, closingBracket + 1);
|
|
571
|
+
const portSuffix = authority.slice(closingBracket + 1);
|
|
572
|
+
if (portSuffix) {
|
|
573
|
+
if (!portSuffix.startsWith(":")) {
|
|
574
|
+
throw new Error("API URL authority must contain a canonical hostname and port.");
|
|
575
|
+
}
|
|
576
|
+
assertCanonicalPort(portSuffix.slice(1));
|
|
577
|
+
}
|
|
578
|
+
if (isIP(rawHostname.slice(1, -1)) !== 6) {
|
|
579
|
+
throw new Error("API URL authority must contain a canonical IPv6 literal.");
|
|
580
|
+
}
|
|
581
|
+
} else {
|
|
582
|
+
const firstColon = authority.indexOf(":");
|
|
583
|
+
const lastColon = authority.lastIndexOf(":");
|
|
584
|
+
if (firstColon !== lastColon) {
|
|
585
|
+
throw new Error("IPv6 API URL authorities must use brackets.");
|
|
586
|
+
}
|
|
587
|
+
if (lastColon !== -1) {
|
|
588
|
+
const port = authority.slice(lastColon + 1);
|
|
589
|
+
assertCanonicalPort(port);
|
|
590
|
+
rawHostname = authority.slice(0, lastColon);
|
|
591
|
+
} else {
|
|
592
|
+
rawHostname = authority;
|
|
593
|
+
}
|
|
594
|
+
const ipVersion = isIP(rawHostname);
|
|
595
|
+
const numericAddressParts = rawHostname.split(".");
|
|
596
|
+
const looksLikeNonCanonicalIpv4 = numericAddressParts.every((part) => /^(?:0x[0-9a-f]+|[0-9]+)$/i.test(part));
|
|
597
|
+
if (ipVersion !== 4 && looksLikeNonCanonicalIpv4 || ipVersion !== 4 && !isValidDnsDomain(rawHostname.toLowerCase())) {
|
|
598
|
+
throw new Error("API URL authority must contain a canonical ASCII hostname.");
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return rawHostname.toLowerCase();
|
|
602
|
+
}
|
|
603
|
+
function isDeliberateLoopbackHttpAuthority(authority) {
|
|
604
|
+
return /^(?:localhost|127\.0\.0\.1|\[::1\])(?::[0-9]+)?$/i.test(authority);
|
|
605
|
+
}
|
|
606
|
+
function toV1BaseUrl(apiUrl) {
|
|
607
|
+
if (ASCII_CONTROL_PATTERN.test(apiUrl)) {
|
|
608
|
+
throw new Error("API URL must not contain ASCII control characters.");
|
|
609
|
+
}
|
|
610
|
+
const input = apiUrl.trim();
|
|
611
|
+
const authority = rawAuthority(input);
|
|
612
|
+
if (authority.includes("@") || authority.includes("\\") || authority.includes("%") || /[^\x00-\x7f]/.test(authority)) {
|
|
613
|
+
throw new Error("API URL authority must be canonical ASCII without credentials.");
|
|
614
|
+
}
|
|
615
|
+
const canonicalHostname = canonicalAuthorityHostname(authority);
|
|
616
|
+
const url = new URL(input);
|
|
617
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
618
|
+
throw new Error("API URL must use http or https.");
|
|
619
|
+
}
|
|
620
|
+
if (url.username || url.password) {
|
|
621
|
+
throw new Error("API URL must not include credentials.");
|
|
622
|
+
}
|
|
623
|
+
if (!url.hostname || url.hostname.endsWith(".")) {
|
|
624
|
+
throw new Error("API URL must include a canonical hostname.");
|
|
625
|
+
}
|
|
626
|
+
if (url.hostname.toLowerCase() !== canonicalHostname) {
|
|
627
|
+
throw new Error("API URL authority must not rely on parser hostname normalization.");
|
|
628
|
+
}
|
|
629
|
+
if (url.hostname.split(".").some((label) => label.toLowerCase().startsWith("xn--"))) {
|
|
630
|
+
throw new Error("API URL must not use IDN or punycode hostnames.");
|
|
631
|
+
}
|
|
632
|
+
if (url.protocol === "http:" && !isDeliberateLoopbackHttpAuthority(authority)) {
|
|
633
|
+
throw new Error("API URL may use http only for an exact loopback authority.");
|
|
634
|
+
}
|
|
635
|
+
if (url.search || url.hash) {
|
|
636
|
+
throw new Error("API URL must not include a query string or fragment.");
|
|
637
|
+
}
|
|
638
|
+
let path = url.pathname.replace(/\/+$/, "");
|
|
639
|
+
if (path.endsWith("/v1"))
|
|
640
|
+
path = path.slice(0, -"/v1".length);
|
|
641
|
+
url.pathname = `${path}/v1`;
|
|
642
|
+
return url.toString().replace(/\/+$/, "");
|
|
643
|
+
}
|
|
644
|
+
class ClientTransportConfigurationError extends Error {
|
|
645
|
+
appName;
|
|
646
|
+
sources;
|
|
647
|
+
constructor(appName, message, sources = []) {
|
|
648
|
+
super(message);
|
|
649
|
+
this.name = "ClientTransportConfigurationError";
|
|
650
|
+
this.appName = appName;
|
|
651
|
+
this.sources = Object.freeze([...sources]);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
function resolveClientTransportSnapshot(name, env = process.env, options = {}) {
|
|
655
|
+
env = snapshotClientEnvironment(name, env);
|
|
656
|
+
const keys = clientTransportEnvKeys(name);
|
|
657
|
+
const definedUrlEntries = keys.apiUrlKeys.filter((key) => Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined).map((key) => ({ key, raw: String(env[key]) }));
|
|
658
|
+
const blankUrl = definedUrlEntries.find((entry) => entry.raw.trim().length === 0);
|
|
659
|
+
if (blankUrl) {
|
|
660
|
+
throw new ClientTransportConfigurationError(name, `${blankUrl.key} is set but blank; public clients require an explicit HTTPS API URL and never select local storage.`, [blankUrl.key]);
|
|
661
|
+
}
|
|
662
|
+
const controlledUrl = definedUrlEntries.find((entry) => ASCII_CONTROL_PATTERN.test(entry.raw));
|
|
663
|
+
if (controlledUrl) {
|
|
664
|
+
throw new ClientTransportConfigurationError(name, `${controlledUrl.key} contains ASCII control characters.`, [controlledUrl.key]);
|
|
665
|
+
}
|
|
666
|
+
const usableUrlEntries = definedUrlEntries.map((entry) => ({ key: entry.key, value: entry.raw.trim() }));
|
|
667
|
+
if (usableUrlEntries.length > 1 && new Set(usableUrlEntries.map((entry) => entry.value)).size > 1) {
|
|
668
|
+
throw new ClientTransportConfigurationError(name, `${usableUrlEntries.map((entry) => entry.key).join(" and ")} disagree; client authority aliases must be identical or only one may be set.`, usableUrlEntries.map((entry) => entry.key));
|
|
669
|
+
}
|
|
670
|
+
const envUrlHit = usableUrlEntries[0] ?? null;
|
|
671
|
+
const keychainUrlHit = keychainConfigValue(name, env, options.credentials?.keychain);
|
|
672
|
+
const diskConfigUrlHit = appConfigDiskValue(name, env, keys.apiUrlKeys);
|
|
673
|
+
if (diskConfigUrlHit?.unusable) {
|
|
674
|
+
throw new ClientTransportConfigurationError(name, `${diskConfigUrlHit.key} in ${diskConfigUrlHit.path} is declared but blank or malformed; public clients require a valid HTTPS service authority.`, [diskConfigUrlHit.path]);
|
|
675
|
+
}
|
|
676
|
+
const urlCandidates = [
|
|
677
|
+
...envUrlHit ? [envUrlHit] : [],
|
|
678
|
+
...keychainUrlHit ? [{ key: keychainUrlHit.source, value: keychainUrlHit.value }] : [],
|
|
679
|
+
...diskConfigUrlHit ? [{ key: diskConfigUrlHit.path, value: diskConfigUrlHit.value.trim() }] : []
|
|
680
|
+
];
|
|
681
|
+
const configuredUrl = urlCandidates[0] ?? null;
|
|
682
|
+
const divergentUrls = urlCandidates.filter((candidate) => candidate.value !== configuredUrl?.value);
|
|
683
|
+
if (configuredUrl && divergentUrls.length > 0) {
|
|
684
|
+
throw new ClientTransportConfigurationError(name, `${configuredUrl.key} and ${divergentUrls.map((candidate) => candidate.key).join(" and ")} select different service authorities; refusing to send a credential written for one authority to the other.`, urlCandidates.map((candidate) => candidate.key));
|
|
685
|
+
}
|
|
686
|
+
const warnings = [];
|
|
687
|
+
if (configuredUrl && !envUrlHit) {
|
|
688
|
+
warnings.push(`No ${keys.apiUrlKeys[0]} in the environment; the server URL in ${configuredUrl.key} was used, so this client connects to the server. ` + `Keep that entry aligned with the intended service authority.`);
|
|
689
|
+
}
|
|
690
|
+
const credential = resolveCredential(name, env, options.credentials);
|
|
691
|
+
if (!credential) {
|
|
692
|
+
const diskHint = credentialDiskSourcesForMessage(name, env);
|
|
693
|
+
const lead = configuredUrl ? `${configuredUrl.key} selects the HTTP server for '${name}', but no API key could be resolved` : `${keys.apiUrlKeys[0]} is not set and no API key could be resolved for '${name}'; a credential is required before the default fleet gateway authority applies`;
|
|
694
|
+
warnings.push(`${lead}; refusing to create an unauthenticated client \u2014 public clients never fall back to SQLite or another local store. ` + `Looked in the Keychain (macOS only), then for a credential file at ${diskHint}, then for ${keys.apiKeyKeys[0]} in the environment.`);
|
|
695
|
+
throw new ClientTransportConfigurationError(name, warnings.join(" "), [configuredUrl?.key ?? keys.apiUrlKeys[0]]);
|
|
696
|
+
}
|
|
697
|
+
if (credential.warning)
|
|
698
|
+
warnings.push(credential.warning);
|
|
699
|
+
let urlHit;
|
|
700
|
+
if (configuredUrl) {
|
|
701
|
+
urlHit = configuredUrl;
|
|
702
|
+
} else {
|
|
703
|
+
try {
|
|
704
|
+
urlHit = { key: DEFAULT_AUTHORITY_SOURCE, value: defaultFleetGatewayBaseUrl(name) };
|
|
705
|
+
} catch (error) {
|
|
706
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
707
|
+
throw new ClientTransportConfigurationError(name, `No ${keys.apiUrlKeys[0]} is configured and the default fleet gateway authority cannot be composed for '${name}': ${message}`, [keys.apiUrlKeys[0]]);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
const apiUrlSource = urlHit.key;
|
|
711
|
+
let baseUrl;
|
|
712
|
+
try {
|
|
713
|
+
baseUrl = toV1BaseUrl(urlHit.value);
|
|
714
|
+
} catch (error) {
|
|
715
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
716
|
+
throw new ClientTransportConfigurationError(name, `Invalid API URL from ${apiUrlSource}: ${message}`, [apiUrlSource]);
|
|
717
|
+
}
|
|
718
|
+
return {
|
|
719
|
+
resolution: {
|
|
720
|
+
transport: "http",
|
|
721
|
+
transportSource: urlHit.key,
|
|
722
|
+
baseUrl,
|
|
723
|
+
apiUrlSource,
|
|
724
|
+
apiKeyPresent: true,
|
|
725
|
+
apiKeySource: credential.source,
|
|
726
|
+
apiKeyTier: credential.tier,
|
|
727
|
+
misconfigured: false,
|
|
728
|
+
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
729
|
+
},
|
|
730
|
+
credential
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
function resolveClientTransport(name, env = process.env, options = {}) {
|
|
734
|
+
return resolveClientTransportSnapshot(name, env, options).resolution;
|
|
735
|
+
}
|
|
736
|
+
function credentialDiskSourcesForMessage(name, env) {
|
|
737
|
+
const paths = credentialDiskSources(name, env);
|
|
738
|
+
return paths.length > 0 ? paths.join(" or ") : "<no HOME or HASNA_HOME set in this environment, so no credential file was consulted>";
|
|
739
|
+
}
|
|
740
|
+
var IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
741
|
+
var AUTHORITY_OVERRIDE_HEADERS = new Set([
|
|
742
|
+
"host",
|
|
743
|
+
":authority",
|
|
744
|
+
"forwarded",
|
|
745
|
+
"x-forwarded-host",
|
|
746
|
+
"x-original-host"
|
|
747
|
+
]);
|
|
748
|
+
|
|
4
749
|
// src/sdk/v1.generated.ts
|
|
5
750
|
class ApiError extends Error {
|
|
6
751
|
status;
|
|
@@ -142,7 +887,165 @@ class RecordingsV1Client {
|
|
|
142
887
|
});
|
|
143
888
|
}
|
|
144
889
|
}
|
|
890
|
+
|
|
891
|
+
// src/lib/local-opt-in.ts
|
|
892
|
+
var RECORDINGS_LOCAL_OPT_IN_ENV_KEYS = [
|
|
893
|
+
"HASNA_RECORDINGS_LOCAL",
|
|
894
|
+
"RECORDINGS_LOCAL"
|
|
895
|
+
];
|
|
896
|
+
function isRecordingsLocalOptIn(env = process.env) {
|
|
897
|
+
return RECORDINGS_LOCAL_OPT_IN_ENV_KEYS.some((key) => (env[key] ?? "").trim() !== "");
|
|
898
|
+
}
|
|
899
|
+
var RECORDINGS_CARVED_LEGACY_ENV_KEYS = [
|
|
900
|
+
"RECORDINGS_API_URL",
|
|
901
|
+
"RECORDINGS_API_KEY"
|
|
902
|
+
];
|
|
903
|
+
function recordingsAuthorityEnvKeys() {
|
|
904
|
+
const keys = clientTransportEnvKeys("recordings");
|
|
905
|
+
return [
|
|
906
|
+
...keys.apiUrlKeys,
|
|
907
|
+
...keys.apiKeyKeys,
|
|
908
|
+
credentialOverrideEnvKey("recordings"),
|
|
909
|
+
credentialPointerEnvKey("recordings"),
|
|
910
|
+
CREDENTIAL_PROFILE_ENV_KEY
|
|
911
|
+
].filter((key) => !RECORDINGS_CARVED_LEGACY_ENV_KEYS.includes(key));
|
|
912
|
+
}
|
|
913
|
+
function hasRecordingsEnvAuthorityIntent(env = process.env) {
|
|
914
|
+
return recordingsAuthorityEnvKeys().some((key) => (env[key] ?? "").trim() !== "");
|
|
915
|
+
}
|
|
916
|
+
function selectsRecordingsLocalStore(env = process.env) {
|
|
917
|
+
return !hasRecordingsEnvAuthorityIntent(env) && isRecordingsLocalOptIn(env);
|
|
918
|
+
}
|
|
919
|
+
function recordingsResolverEnv(env) {
|
|
920
|
+
const carved = RECORDINGS_CARVED_LEGACY_ENV_KEYS.filter((key) => (key in env));
|
|
921
|
+
const blanks = recordingsAuthorityEnvKeys().filter((key) => (key in env) && (env[key] ?? "").trim() === "");
|
|
922
|
+
const removed = new Set([...carved, ...blanks]);
|
|
923
|
+
if (removed.size === 0)
|
|
924
|
+
return env;
|
|
925
|
+
const next = { ...env };
|
|
926
|
+
for (const key of removed)
|
|
927
|
+
delete next[key];
|
|
928
|
+
return next;
|
|
929
|
+
}
|
|
930
|
+
var CONTRACTS_AMBIENT_ENVIRONMENT = Symbol.for("hasna:contracts:ambientClientEnvironment");
|
|
931
|
+
function isAmbientRecordingsEnv(env) {
|
|
932
|
+
if (typeof process !== "undefined" && env === process.env) {
|
|
933
|
+
return true;
|
|
934
|
+
}
|
|
935
|
+
return env[CONTRACTS_AMBIENT_ENVIRONMENT] === true;
|
|
936
|
+
}
|
|
937
|
+
function recordingsResolverInputs(env, credentials = {}) {
|
|
938
|
+
const normalised = recordingsResolverEnv(env);
|
|
939
|
+
if (normalised === env)
|
|
940
|
+
return { env: normalised, credentials };
|
|
941
|
+
const keychain = { ...credentials.keychain };
|
|
942
|
+
if (keychain.enabled === undefined && keychain.run === undefined) {
|
|
943
|
+
keychain.enabled = isAmbientRecordingsEnv(env);
|
|
944
|
+
}
|
|
945
|
+
return { env: normalised, credentials: { ...credentials, keychain } };
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// src/sdk/resolve.ts
|
|
949
|
+
var RECORDINGS_LOCAL_SERVE_URL = "http://localhost:8874";
|
|
950
|
+
var localNoticePrinted = false;
|
|
951
|
+
function __resetRecordingsSdkLocalNotice() {
|
|
952
|
+
localNoticePrinted = false;
|
|
953
|
+
}
|
|
954
|
+
function stripV1(baseUrl) {
|
|
955
|
+
return baseUrl.replace(/\/+$/, "").replace(/\/v1$/, "");
|
|
956
|
+
}
|
|
957
|
+
function announceLocal(notice, reason) {
|
|
958
|
+
if (localNoticePrinted)
|
|
959
|
+
return;
|
|
960
|
+
localNoticePrinted = true;
|
|
961
|
+
const line = `recordings: LOCAL mode \u2014 ${reason}; reading and writing the local ` + `recordings-serve at ${RECORDINGS_LOCAL_SERVE_URL}, not the hosted fleet. Set HASNA_RECORDINGS_API_KEY, add the ` + `Keychain item hasna.credentials.recordings.api-key, or write ~/.hasna/recordings/config/credentials to go hosted.`;
|
|
962
|
+
if (notice)
|
|
963
|
+
notice(line);
|
|
964
|
+
else if (typeof process !== "undefined")
|
|
965
|
+
process.stderr.write(`${line}
|
|
966
|
+
`);
|
|
967
|
+
}
|
|
968
|
+
function resolveRecordingsSdkTransport(options = {}) {
|
|
969
|
+
const rawEnv = options.env ?? (typeof process !== "undefined" ? process.env : {});
|
|
970
|
+
const requestedCredentials = {
|
|
971
|
+
...options.credentials,
|
|
972
|
+
...options.apiKey !== undefined ? { apiKey: options.apiKey } : {}
|
|
973
|
+
};
|
|
974
|
+
const { env, credentials } = recordingsResolverInputs(rawEnv, requestedCredentials);
|
|
975
|
+
if (options.baseUrl) {
|
|
976
|
+
return {
|
|
977
|
+
mode: "http",
|
|
978
|
+
baseUrl: stripV1(options.baseUrl),
|
|
979
|
+
apiKey: options.apiKey ?? null,
|
|
980
|
+
apiKeySource: options.apiKey ? "explicit apiKey argument" : null,
|
|
981
|
+
apiUrlSource: "explicit baseUrl argument"
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
if (selectsRecordingsLocalStore(env)) {
|
|
985
|
+
announceLocal(options.notice, "HASNA_RECORDINGS_LOCAL is set and nothing configures an authority");
|
|
986
|
+
return {
|
|
987
|
+
mode: "local-serve",
|
|
988
|
+
baseUrl: RECORDINGS_LOCAL_SERVE_URL,
|
|
989
|
+
apiKey: options.apiKey ?? null,
|
|
990
|
+
apiKeySource: options.apiKey ? "explicit apiKey argument" : null,
|
|
991
|
+
apiUrlSource: "local-serve"
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
const credential = resolveCredential("recordings", env, credentials);
|
|
995
|
+
const chainOptions = {
|
|
996
|
+
credentials: credential ? { ...credentials, apiKey: credential.apiKey } : credentials
|
|
997
|
+
};
|
|
998
|
+
let resolution;
|
|
999
|
+
try {
|
|
1000
|
+
resolution = resolveClientTransport("recordings", env, chainOptions);
|
|
1001
|
+
} catch (error) {
|
|
1002
|
+
if (error instanceof ClientTransportConfigurationError && /is not set and no API key could be resolved/.test(error.message)) {
|
|
1003
|
+
throw new Error("RECORDINGS_CREDENTIAL_MISSING: no Hasna Recordings credential resolved and local mode is opt-in only. " + "Looked at HASNA_RECORDINGS_API_KEY_OVERRIDE / HASNA_PROFILE / HASNA_RECORDINGS_API_KEY_REF, the Keychain item " + "hasna.credentials.recordings.api-key, ~/.hasna/recordings/config/credentials, then HASNA_RECORDINGS_API_KEY.", { cause: error });
|
|
1004
|
+
}
|
|
1005
|
+
throw error;
|
|
1006
|
+
}
|
|
1007
|
+
return {
|
|
1008
|
+
mode: "http",
|
|
1009
|
+
baseUrl: stripV1(resolution.baseUrl),
|
|
1010
|
+
apiKey: credential ? credential.apiKey : null,
|
|
1011
|
+
apiKeySource: credential ? credential.source : resolution.apiKeySource,
|
|
1012
|
+
apiUrlSource: resolution.apiUrlSource ?? "default"
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
function createRecordingsV1Client(options = {}) {
|
|
1016
|
+
const resolved = resolveRecordingsSdkTransport(options);
|
|
1017
|
+
if (resolved.mode !== "http" || !resolved.apiKey) {
|
|
1018
|
+
throw new Error("RECORDINGS_CREDENTIAL_MISSING: the /v1 client is hosted-only and no Hasna Recordings credential resolved. " + "Looked at HASNA_RECORDINGS_API_KEY_OVERRIDE / HASNA_PROFILE / HASNA_RECORDINGS_API_KEY_REF, the Keychain item " + "hasna.credentials.recordings.api-key, ~/.hasna/recordings/config/credentials, then HASNA_RECORDINGS_API_KEY.");
|
|
1019
|
+
}
|
|
1020
|
+
const baseFetch = options.fetch ?? ((input, init) => fetch(input, init));
|
|
1021
|
+
const authorityPinned = options.baseUrl !== undefined;
|
|
1022
|
+
const refreshOptions = { ...options, notice: () => {} };
|
|
1023
|
+
const fetchWithFreshCredential = (input, init) => {
|
|
1024
|
+
const headers = {};
|
|
1025
|
+
new Headers(init?.headers ?? {}).forEach((value, key) => {
|
|
1026
|
+
headers[key] = value;
|
|
1027
|
+
});
|
|
1028
|
+
if (!authorityPinned) {
|
|
1029
|
+
try {
|
|
1030
|
+
const fresh = resolveRecordingsSdkTransport(refreshOptions).apiKey;
|
|
1031
|
+
if (fresh)
|
|
1032
|
+
headers["x-api-key"] = fresh;
|
|
1033
|
+
} catch {}
|
|
1034
|
+
}
|
|
1035
|
+
return baseFetch(input, { ...init, headers });
|
|
1036
|
+
};
|
|
1037
|
+
return new RecordingsV1Client({
|
|
1038
|
+
baseUrl: resolved.baseUrl,
|
|
1039
|
+
apiKey: resolved.apiKey,
|
|
1040
|
+
fetch: fetchWithFreshCredential,
|
|
1041
|
+
...options.headers ? { headers: options.headers } : {}
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
145
1044
|
export {
|
|
1045
|
+
resolveRecordingsSdkTransport,
|
|
1046
|
+
createRecordingsV1Client,
|
|
1047
|
+
__resetRecordingsSdkLocalNotice,
|
|
146
1048
|
RecordingsV1Client,
|
|
147
|
-
ApiError as RecordingsV1ApiError
|
|
1049
|
+
ApiError as RecordingsV1ApiError,
|
|
1050
|
+
RECORDINGS_LOCAL_SERVE_URL
|
|
148
1051
|
};
|