@hasna/recordings 0.3.8 → 0.3.10
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/bun.lock +26 -26
- package/dist/cli/index.js +915 -164
- package/dist/http/client.d.ts +6 -4
- package/dist/http/client.d.ts.map +1 -1
- package/dist/index.js +1751 -44
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/mcp/index.js +872 -121
- package/dist/server/index.js +52 -192
- package/dist/storage.js +1744 -37
- package/dist/store.d.ts.map +1 -1
- package/package.json +2 -2
- package/scripts/release-suite-gate.ts +17 -24
- package/src/native/Recordings/RecordingsLib/Info.plist +2 -2
package/dist/index.js
CHANGED
|
@@ -1191,7 +1191,7 @@ function setAgentFocus(idOrName, projectId, db) {
|
|
|
1191
1191
|
// package.json
|
|
1192
1192
|
var package_default = {
|
|
1193
1193
|
name: "@hasna/recordings",
|
|
1194
|
-
version: "0.3.
|
|
1194
|
+
version: "0.3.10",
|
|
1195
1195
|
type: "module",
|
|
1196
1196
|
description: "Speech-to-text recording tool with MCP and CLI \u2014 records, transcribes, and optionally enhances text using AI",
|
|
1197
1197
|
repository: {
|
|
@@ -1275,7 +1275,7 @@ var package_default = {
|
|
|
1275
1275
|
"LICENSE"
|
|
1276
1276
|
],
|
|
1277
1277
|
dependencies: {
|
|
1278
|
-
"@hasna/contracts": "0.13.
|
|
1278
|
+
"@hasna/contracts": "0.13.4",
|
|
1279
1279
|
"@hasna/events": "0.1.11",
|
|
1280
1280
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
1281
1281
|
chalk: "^5.4.1",
|
|
@@ -1307,12 +1307,1688 @@ function saveFeedback(input) {
|
|
|
1307
1307
|
db.query("INSERT INTO feedback (message, email, category, version) VALUES (?, ?, ?, ?)").run(input.message, input.email ?? null, input.category ?? "general", input.version ?? VERSION);
|
|
1308
1308
|
}
|
|
1309
1309
|
|
|
1310
|
+
// ../contracts/dist/client/transport.js
|
|
1311
|
+
import { isIP } from "net";
|
|
1312
|
+
import { readFileSync as readFileSync2, statSync as statSync2 } from "fs";
|
|
1313
|
+
import { join as join3 } from "path";
|
|
1314
|
+
function envToken(name) {
|
|
1315
|
+
return name.toUpperCase().replace(/-/g, "_");
|
|
1316
|
+
}
|
|
1317
|
+
function clientTransportEnvKeys(name) {
|
|
1318
|
+
const envSegment = envToken(name);
|
|
1319
|
+
return {
|
|
1320
|
+
apiUrlKeys: [`HASNA_${envSegment}_API_URL`, `${envSegment}_API_URL`],
|
|
1321
|
+
apiKeyKeys: [`HASNA_${envSegment}_API_KEY`, `${envSegment}_API_KEY`]
|
|
1322
|
+
};
|
|
1323
|
+
}
|
|
1324
|
+
function credentialOverrideEnvKey(name) {
|
|
1325
|
+
return `HASNA_${envToken(name)}_API_KEY_OVERRIDE`;
|
|
1326
|
+
}
|
|
1327
|
+
var CREDENTIAL_PROFILE_ENV_KEY = "HASNA_PROFILE";
|
|
1328
|
+
|
|
1329
|
+
class CredentialResolutionError extends Error {
|
|
1330
|
+
appName;
|
|
1331
|
+
attempted;
|
|
1332
|
+
constructor(appName, message, attempted) {
|
|
1333
|
+
super(message);
|
|
1334
|
+
this.name = "CredentialResolutionError";
|
|
1335
|
+
this.appName = appName;
|
|
1336
|
+
this.attempted = attempted;
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
var HASNA_STATE_DIR = ".hasna";
|
|
1340
|
+
var FLEET_CREDENTIAL_DIR = "cloud";
|
|
1341
|
+
var CONFIG_DIR = ".config";
|
|
1342
|
+
var CONFIG_NAMESPACE = "hasna";
|
|
1343
|
+
var MAX_CREDENTIAL_FILE_BYTES = 64 * 1024;
|
|
1344
|
+
var SAFE_APP_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
1345
|
+
var SAFE_PROFILE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
|
1346
|
+
var ILLEGAL_IN_HEADER_VALUE = /[^\t\x20-\x7e]/;
|
|
1347
|
+
function homeDir(env) {
|
|
1348
|
+
const home = env.HOME?.trim();
|
|
1349
|
+
return home ? home : null;
|
|
1350
|
+
}
|
|
1351
|
+
function credentialDiskSources(name, env) {
|
|
1352
|
+
return profileDiskSources(name, env, null);
|
|
1353
|
+
}
|
|
1354
|
+
function profileDiskSources(name, env, profile) {
|
|
1355
|
+
const home = homeDir(env);
|
|
1356
|
+
if (!home || !SAFE_APP_SLUG.test(name))
|
|
1357
|
+
return [];
|
|
1358
|
+
const stem = profile ? `${name}.${profile}` : name;
|
|
1359
|
+
const configStem = profile ? `${name}-${profile}` : name;
|
|
1360
|
+
return [
|
|
1361
|
+
join3(home, HASNA_STATE_DIR, FLEET_CREDENTIAL_DIR, `${stem}.env`),
|
|
1362
|
+
join3(home, CONFIG_DIR, CONFIG_NAMESPACE, `${configStem}-cloud.env`)
|
|
1363
|
+
];
|
|
1364
|
+
}
|
|
1365
|
+
function parseEnvFile(text) {
|
|
1366
|
+
const values = new Map;
|
|
1367
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
1368
|
+
const line = rawLine.trim();
|
|
1369
|
+
if (line.length === 0 || line.startsWith("#"))
|
|
1370
|
+
continue;
|
|
1371
|
+
const withoutExport = line.startsWith("export ") ? line.slice("export ".length).trim() : line;
|
|
1372
|
+
const equals = withoutExport.indexOf("=");
|
|
1373
|
+
if (equals <= 0)
|
|
1374
|
+
continue;
|
|
1375
|
+
const key = withoutExport.slice(0, equals).trim();
|
|
1376
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
1377
|
+
continue;
|
|
1378
|
+
let value = withoutExport.slice(equals + 1).trim();
|
|
1379
|
+
const quote = value[0];
|
|
1380
|
+
if (quote === '"' || quote === "'") {
|
|
1381
|
+
if (value.length < 2 || !value.endsWith(quote))
|
|
1382
|
+
continue;
|
|
1383
|
+
value = value.slice(1, -1);
|
|
1384
|
+
}
|
|
1385
|
+
if (value.length === 0)
|
|
1386
|
+
continue;
|
|
1387
|
+
values.set(key, value);
|
|
1388
|
+
}
|
|
1389
|
+
return values;
|
|
1390
|
+
}
|
|
1391
|
+
function readAppConfigFile(path) {
|
|
1392
|
+
let text;
|
|
1393
|
+
try {
|
|
1394
|
+
const stats = statSync2(path);
|
|
1395
|
+
if (!stats.isFile() || stats.size > MAX_CREDENTIAL_FILE_BYTES)
|
|
1396
|
+
return null;
|
|
1397
|
+
text = readFileSync2(path, "utf8");
|
|
1398
|
+
} catch {
|
|
1399
|
+
return null;
|
|
1400
|
+
}
|
|
1401
|
+
return parseEnvFile(text);
|
|
1402
|
+
}
|
|
1403
|
+
function readCredentialFile(path, apiKeyKeys) {
|
|
1404
|
+
const values = readAppConfigFile(path);
|
|
1405
|
+
if (!values)
|
|
1406
|
+
return null;
|
|
1407
|
+
for (const key of apiKeyKeys) {
|
|
1408
|
+
const value = values.get(key)?.trim();
|
|
1409
|
+
if (value)
|
|
1410
|
+
return value;
|
|
1411
|
+
}
|
|
1412
|
+
return null;
|
|
1413
|
+
}
|
|
1414
|
+
var CREDENTIAL_SHAPED_KEY = /(?:^|_)(?:API_KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)(?:_|$)/;
|
|
1415
|
+
function appConfigDiskValue(name, env, keys) {
|
|
1416
|
+
const wanted = keys.filter((key) => !CREDENTIAL_SHAPED_KEY.test(key));
|
|
1417
|
+
if (wanted.length === 0)
|
|
1418
|
+
return null;
|
|
1419
|
+
for (const path of credentialDiskSources(name, env)) {
|
|
1420
|
+
const values = readAppConfigFile(path);
|
|
1421
|
+
if (!values)
|
|
1422
|
+
continue;
|
|
1423
|
+
for (const key of wanted) {
|
|
1424
|
+
const value = values.get(key)?.trim();
|
|
1425
|
+
if (value)
|
|
1426
|
+
return { key, value, path };
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
return null;
|
|
1430
|
+
}
|
|
1431
|
+
function assertUsableCredential(appName, source, value) {
|
|
1432
|
+
if (!ILLEGAL_IN_HEADER_VALUE.test(value))
|
|
1433
|
+
return;
|
|
1434
|
+
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]);
|
|
1435
|
+
}
|
|
1436
|
+
var INSPECT_CUSTOM = Symbol.for("nodejs.util.inspect.custom");
|
|
1437
|
+
var CREDENTIAL_SEAL = Symbol.for("hasna:contracts:sealedCredential");
|
|
1438
|
+
var CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE = "caller-supplied CredentialProvider";
|
|
1439
|
+
function sealCredential(fields) {
|
|
1440
|
+
const { apiKey } = fields;
|
|
1441
|
+
const visible = {
|
|
1442
|
+
tier: fields.tier,
|
|
1443
|
+
source: fields.source,
|
|
1444
|
+
deliberate: fields.deliberate,
|
|
1445
|
+
deprecated: fields.deprecated,
|
|
1446
|
+
diskCandidates: Object.freeze([...fields.diskCandidates]),
|
|
1447
|
+
warning: fields.warning
|
|
1448
|
+
};
|
|
1449
|
+
const sealed = { ...visible };
|
|
1450
|
+
Object.defineProperty(sealed, "apiKey", {
|
|
1451
|
+
value: apiKey,
|
|
1452
|
+
enumerable: false,
|
|
1453
|
+
writable: false,
|
|
1454
|
+
configurable: false
|
|
1455
|
+
});
|
|
1456
|
+
Object.defineProperty(sealed, INSPECT_CUSTOM, {
|
|
1457
|
+
value: () => ({ ...visible, apiKey: "[redacted]" }),
|
|
1458
|
+
enumerable: false,
|
|
1459
|
+
writable: false,
|
|
1460
|
+
configurable: false
|
|
1461
|
+
});
|
|
1462
|
+
Object.defineProperty(sealed, CREDENTIAL_SEAL, {
|
|
1463
|
+
value: true,
|
|
1464
|
+
enumerable: false,
|
|
1465
|
+
writable: false,
|
|
1466
|
+
configurable: false
|
|
1467
|
+
});
|
|
1468
|
+
return Object.freeze(sealed);
|
|
1469
|
+
}
|
|
1470
|
+
function isSealedCredential(credential) {
|
|
1471
|
+
return credential[CREDENTIAL_SEAL] === true;
|
|
1472
|
+
}
|
|
1473
|
+
function explicitCredential(appName, apiKey) {
|
|
1474
|
+
const source = "explicit apiKey option";
|
|
1475
|
+
assertUsableCredential(appName, source, apiKey);
|
|
1476
|
+
return sealCredential({
|
|
1477
|
+
apiKey,
|
|
1478
|
+
tier: "argument",
|
|
1479
|
+
source,
|
|
1480
|
+
deliberate: true,
|
|
1481
|
+
deprecated: false,
|
|
1482
|
+
diskCandidates: [],
|
|
1483
|
+
warning: null
|
|
1484
|
+
});
|
|
1485
|
+
}
|
|
1486
|
+
function validateAndSealResolvedCredential(appName, credential) {
|
|
1487
|
+
const apiKey = credential.apiKey;
|
|
1488
|
+
assertUsableCredential(appName, CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE, apiKey);
|
|
1489
|
+
if (!isSealedCredential(credential)) {
|
|
1490
|
+
return sealCredential({
|
|
1491
|
+
apiKey,
|
|
1492
|
+
tier: "argument",
|
|
1493
|
+
source: CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE,
|
|
1494
|
+
deliberate: true,
|
|
1495
|
+
deprecated: false,
|
|
1496
|
+
diskCandidates: [],
|
|
1497
|
+
warning: null
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
return sealCredential({
|
|
1501
|
+
apiKey,
|
|
1502
|
+
tier: credential.tier,
|
|
1503
|
+
source: credential.source,
|
|
1504
|
+
deliberate: credential.deliberate,
|
|
1505
|
+
deprecated: credential.deprecated,
|
|
1506
|
+
diskCandidates: credential.diskCandidates,
|
|
1507
|
+
warning: credential.warning
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1510
|
+
function firstEnvValue(env, keys) {
|
|
1511
|
+
for (const key of keys) {
|
|
1512
|
+
const value = env[key]?.trim();
|
|
1513
|
+
if (value)
|
|
1514
|
+
return { key, value };
|
|
1515
|
+
}
|
|
1516
|
+
return null;
|
|
1517
|
+
}
|
|
1518
|
+
var DEPRECATION_REGISTRY = Symbol.for("hasna:contracts:credentialDeprecationNotices");
|
|
1519
|
+
function deprecationNotified() {
|
|
1520
|
+
const host = globalThis;
|
|
1521
|
+
const existing = host[DEPRECATION_REGISTRY];
|
|
1522
|
+
if (existing instanceof Set)
|
|
1523
|
+
return existing;
|
|
1524
|
+
const created = new Set;
|
|
1525
|
+
host[DEPRECATION_REGISTRY] = created;
|
|
1526
|
+
return created;
|
|
1527
|
+
}
|
|
1528
|
+
function defaultDeprecationSink(message) {
|
|
1529
|
+
if (typeof process !== "undefined" && process.stderr) {
|
|
1530
|
+
process.stderr.write(`${message}
|
|
1531
|
+
`);
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
function resolveCredential(name, env, options = {}) {
|
|
1535
|
+
const { apiKeyKeys } = clientTransportEnvKeys(name);
|
|
1536
|
+
const diskPaths = credentialDiskSources(name, env);
|
|
1537
|
+
const explicitKey = options.apiKey?.trim();
|
|
1538
|
+
if (explicitKey) {
|
|
1539
|
+
assertUsableCredential(name, "the explicit apiKey argument", explicitKey);
|
|
1540
|
+
return sealCredential({
|
|
1541
|
+
apiKey: explicitKey,
|
|
1542
|
+
tier: "argument",
|
|
1543
|
+
source: "explicit apiKey argument",
|
|
1544
|
+
deliberate: true,
|
|
1545
|
+
deprecated: false,
|
|
1546
|
+
diskCandidates: diskPaths,
|
|
1547
|
+
warning: null
|
|
1548
|
+
});
|
|
1549
|
+
}
|
|
1550
|
+
const overrideKeyName = credentialOverrideEnvKey(name);
|
|
1551
|
+
const overrideRaw = env[overrideKeyName];
|
|
1552
|
+
if (overrideRaw !== undefined) {
|
|
1553
|
+
const override = overrideRaw.trim();
|
|
1554
|
+
if (!override) {
|
|
1555
|
+
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]);
|
|
1556
|
+
}
|
|
1557
|
+
assertUsableCredential(name, overrideKeyName, override);
|
|
1558
|
+
return sealCredential({
|
|
1559
|
+
apiKey: override,
|
|
1560
|
+
tier: "override",
|
|
1561
|
+
source: overrideKeyName,
|
|
1562
|
+
deliberate: true,
|
|
1563
|
+
deprecated: false,
|
|
1564
|
+
diskCandidates: diskPaths,
|
|
1565
|
+
warning: null
|
|
1566
|
+
});
|
|
1567
|
+
}
|
|
1568
|
+
const profile = options.profile?.trim() || env[CREDENTIAL_PROFILE_ENV_KEY]?.trim();
|
|
1569
|
+
if (profile) {
|
|
1570
|
+
const profileSource = options.profile?.trim() ? "explicit profile argument" : CREDENTIAL_PROFILE_ENV_KEY;
|
|
1571
|
+
if (!SAFE_PROFILE.test(profile)) {
|
|
1572
|
+
throw new CredentialResolutionError(name, `Profile name from ${profileSource} is not usable in a path. ` + `Use letters, digits, dot, dash, or underscore.`, [profileSource]);
|
|
1573
|
+
}
|
|
1574
|
+
const paths = profileDiskSources(name, env, profile);
|
|
1575
|
+
for (const path of paths) {
|
|
1576
|
+
const value = readCredentialFile(path, apiKeyKeys);
|
|
1577
|
+
if (value) {
|
|
1578
|
+
assertUsableCredential(name, path, value);
|
|
1579
|
+
return sealCredential({
|
|
1580
|
+
apiKey: value,
|
|
1581
|
+
tier: "profile",
|
|
1582
|
+
source: path,
|
|
1583
|
+
deliberate: true,
|
|
1584
|
+
deprecated: false,
|
|
1585
|
+
diskCandidates: paths,
|
|
1586
|
+
warning: null
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
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);
|
|
1591
|
+
}
|
|
1592
|
+
const diskHits = diskPaths.map((path) => ({ path, value: readCredentialFile(path, apiKeyKeys) })).filter((hit) => hit.value !== null);
|
|
1593
|
+
if (diskHits.length > 0) {
|
|
1594
|
+
const winner = diskHits[0];
|
|
1595
|
+
assertUsableCredential(name, winner.path, winner.value);
|
|
1596
|
+
const divergentSources = [
|
|
1597
|
+
...diskHits.slice(1).filter((hit) => hit.value !== winner.value).map((hit) => hit.path),
|
|
1598
|
+
...(() => {
|
|
1599
|
+
const legacyHit = firstEnvValue(env, apiKeyKeys);
|
|
1600
|
+
return legacyHit && legacyHit.value !== winner.value ? [legacyHit.key] : [];
|
|
1601
|
+
})()
|
|
1602
|
+
];
|
|
1603
|
+
const warning = divergentSources.length > 0 ? `Credential sources disagree for '${name}': ${winner.path} and ` + `${divergentSources.join(", ")} hold different keys. ${winner.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;
|
|
1604
|
+
return sealCredential({
|
|
1605
|
+
apiKey: winner.value,
|
|
1606
|
+
tier: "disk",
|
|
1607
|
+
source: winner.path,
|
|
1608
|
+
deliberate: false,
|
|
1609
|
+
deprecated: false,
|
|
1610
|
+
diskCandidates: diskPaths,
|
|
1611
|
+
warning
|
|
1612
|
+
});
|
|
1613
|
+
}
|
|
1614
|
+
const legacy = firstEnvValue(env, apiKeyKeys);
|
|
1615
|
+
if (legacy) {
|
|
1616
|
+
assertUsableCredential(name, legacy.key, legacy.value);
|
|
1617
|
+
const where = diskPaths.length > 0 ? `Put the current key in ${diskPaths[0]} \u2014 it is re-read on every call, so rotations take effect immediately.` : `This environment has no HOME, so no credential file could be consulted at all; the disk tier is ` + `unavailable here and this process will keep using the environment snapshot.`;
|
|
1618
|
+
const message = `[${name}] DEPRECATED: the API key came from ${legacy.key} in this process's environment. ` + `Environment variables are a snapshot taken when this process started, so a shell that started ` + `before a key rotation keeps using the old key until it exits. ${where}`;
|
|
1619
|
+
const sink = options.onDeprecation ?? defaultDeprecationSink;
|
|
1620
|
+
const notified = deprecationNotified();
|
|
1621
|
+
if (!notified.has(name)) {
|
|
1622
|
+
notified.add(name);
|
|
1623
|
+
sink(message);
|
|
1624
|
+
}
|
|
1625
|
+
return sealCredential({
|
|
1626
|
+
apiKey: legacy.value,
|
|
1627
|
+
tier: "legacy-env",
|
|
1628
|
+
source: legacy.key,
|
|
1629
|
+
deliberate: false,
|
|
1630
|
+
deprecated: true,
|
|
1631
|
+
diskCandidates: diskPaths,
|
|
1632
|
+
warning: message
|
|
1633
|
+
});
|
|
1634
|
+
}
|
|
1635
|
+
return null;
|
|
1636
|
+
}
|
|
1637
|
+
var ASCII_CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
|
1638
|
+
var DNS_LABEL_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
1639
|
+
function isValidDnsDomain(value) {
|
|
1640
|
+
if (value.length === 0 || value.length > 253 || ASCII_CONTROL_PATTERN.test(value) || /[^\x00-\x7f]/.test(value)) {
|
|
1641
|
+
return false;
|
|
1642
|
+
}
|
|
1643
|
+
return value.split(".").every((label) => label.length <= 63 && !label.startsWith("xn--") && DNS_LABEL_PATTERN.test(label));
|
|
1644
|
+
}
|
|
1645
|
+
function firstEnv(env, keys, options = {}) {
|
|
1646
|
+
for (const key of keys) {
|
|
1647
|
+
const raw = env[key];
|
|
1648
|
+
const value = raw?.trim();
|
|
1649
|
+
if (value)
|
|
1650
|
+
return { key, value: options.preserveRaw ? raw : value };
|
|
1651
|
+
}
|
|
1652
|
+
return null;
|
|
1653
|
+
}
|
|
1654
|
+
function firstEnvDefinedKey(env, keys) {
|
|
1655
|
+
for (const key of keys) {
|
|
1656
|
+
if (env[key] !== undefined)
|
|
1657
|
+
return key;
|
|
1658
|
+
}
|
|
1659
|
+
return null;
|
|
1660
|
+
}
|
|
1661
|
+
function rawAuthority(value) {
|
|
1662
|
+
const match = /^[a-z][a-z0-9+.-]*:\/\//i.exec(value);
|
|
1663
|
+
if (!match)
|
|
1664
|
+
throw new Error("API URL must be absolute.");
|
|
1665
|
+
const afterScheme = value.slice(match[0].length);
|
|
1666
|
+
const boundary = afterScheme.search(/[/?#]/);
|
|
1667
|
+
const authority = boundary === -1 ? afterScheme : afterScheme.slice(0, boundary);
|
|
1668
|
+
if (!authority)
|
|
1669
|
+
throw new Error("API URL must include a hostname.");
|
|
1670
|
+
return authority;
|
|
1671
|
+
}
|
|
1672
|
+
function assertCanonicalPort(port) {
|
|
1673
|
+
if (!/^[0-9]+$/.test(port) || port.length > 1 && port.startsWith("0")) {
|
|
1674
|
+
throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
|
|
1675
|
+
}
|
|
1676
|
+
const numericPort = Number(port);
|
|
1677
|
+
if (!Number.isSafeInteger(numericPort) || numericPort < 1 || numericPort > 65535) {
|
|
1678
|
+
throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
function canonicalAuthorityHostname(authority) {
|
|
1682
|
+
let rawHostname;
|
|
1683
|
+
if (authority.startsWith("[")) {
|
|
1684
|
+
const closingBracket = authority.indexOf("]");
|
|
1685
|
+
if (closingBracket === -1) {
|
|
1686
|
+
throw new Error("API URL authority must contain a canonical hostname.");
|
|
1687
|
+
}
|
|
1688
|
+
rawHostname = authority.slice(0, closingBracket + 1);
|
|
1689
|
+
const portSuffix = authority.slice(closingBracket + 1);
|
|
1690
|
+
if (portSuffix) {
|
|
1691
|
+
if (!portSuffix.startsWith(":")) {
|
|
1692
|
+
throw new Error("API URL authority must contain a canonical hostname and port.");
|
|
1693
|
+
}
|
|
1694
|
+
assertCanonicalPort(portSuffix.slice(1));
|
|
1695
|
+
}
|
|
1696
|
+
if (isIP(rawHostname.slice(1, -1)) !== 6) {
|
|
1697
|
+
throw new Error("API URL authority must contain a canonical IPv6 literal.");
|
|
1698
|
+
}
|
|
1699
|
+
} else {
|
|
1700
|
+
const firstColon = authority.indexOf(":");
|
|
1701
|
+
const lastColon = authority.lastIndexOf(":");
|
|
1702
|
+
if (firstColon !== lastColon) {
|
|
1703
|
+
throw new Error("IPv6 API URL authorities must use brackets.");
|
|
1704
|
+
}
|
|
1705
|
+
if (lastColon !== -1) {
|
|
1706
|
+
const port = authority.slice(lastColon + 1);
|
|
1707
|
+
assertCanonicalPort(port);
|
|
1708
|
+
rawHostname = authority.slice(0, lastColon);
|
|
1709
|
+
} else {
|
|
1710
|
+
rawHostname = authority;
|
|
1711
|
+
}
|
|
1712
|
+
const ipVersion = isIP(rawHostname);
|
|
1713
|
+
const numericAddressParts = rawHostname.split(".");
|
|
1714
|
+
const looksLikeNonCanonicalIpv4 = numericAddressParts.every((part) => /^(?:0x[0-9a-f]+|[0-9]+)$/i.test(part));
|
|
1715
|
+
if (ipVersion !== 4 && looksLikeNonCanonicalIpv4 || ipVersion !== 4 && !isValidDnsDomain(rawHostname.toLowerCase())) {
|
|
1716
|
+
throw new Error("API URL authority must contain a canonical ASCII hostname.");
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
return rawHostname.toLowerCase();
|
|
1720
|
+
}
|
|
1721
|
+
function isDeliberateLoopbackHttpAuthority(authority) {
|
|
1722
|
+
return /^(?:localhost|127\.0\.0\.1|\[::1\])(?::[0-9]+)?$/i.test(authority);
|
|
1723
|
+
}
|
|
1724
|
+
function toV1BaseUrl(apiUrl) {
|
|
1725
|
+
if (ASCII_CONTROL_PATTERN.test(apiUrl)) {
|
|
1726
|
+
throw new Error("API URL must not contain ASCII control characters.");
|
|
1727
|
+
}
|
|
1728
|
+
const input = apiUrl.trim();
|
|
1729
|
+
const authority = rawAuthority(input);
|
|
1730
|
+
if (authority.includes("@") || authority.includes("\\") || authority.includes("%") || /[^\x00-\x7f]/.test(authority)) {
|
|
1731
|
+
throw new Error("API URL authority must be canonical ASCII without credentials.");
|
|
1732
|
+
}
|
|
1733
|
+
const canonicalHostname = canonicalAuthorityHostname(authority);
|
|
1734
|
+
const url = new URL(input);
|
|
1735
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
1736
|
+
throw new Error("API URL must use http or https.");
|
|
1737
|
+
}
|
|
1738
|
+
if (url.username || url.password) {
|
|
1739
|
+
throw new Error("API URL must not include credentials.");
|
|
1740
|
+
}
|
|
1741
|
+
if (!url.hostname || url.hostname.endsWith(".")) {
|
|
1742
|
+
throw new Error("API URL must include a canonical hostname.");
|
|
1743
|
+
}
|
|
1744
|
+
if (url.hostname.toLowerCase() !== canonicalHostname) {
|
|
1745
|
+
throw new Error("API URL authority must not rely on parser hostname normalization.");
|
|
1746
|
+
}
|
|
1747
|
+
if (url.hostname.split(".").some((label) => label.toLowerCase().startsWith("xn--"))) {
|
|
1748
|
+
throw new Error("API URL must not use IDN or punycode hostnames.");
|
|
1749
|
+
}
|
|
1750
|
+
if (url.protocol === "http:" && !isDeliberateLoopbackHttpAuthority(authority)) {
|
|
1751
|
+
throw new Error("API URL may use http only for an exact loopback authority.");
|
|
1752
|
+
}
|
|
1753
|
+
if (url.search || url.hash) {
|
|
1754
|
+
throw new Error("API URL must not include a query string or fragment.");
|
|
1755
|
+
}
|
|
1756
|
+
let path = url.pathname.replace(/\/+$/, "");
|
|
1757
|
+
if (path.endsWith("/v1"))
|
|
1758
|
+
path = path.slice(0, -"/v1".length);
|
|
1759
|
+
url.pathname = `${path}/v1`;
|
|
1760
|
+
return url.toString().replace(/\/+$/, "");
|
|
1761
|
+
}
|
|
1762
|
+
function resolveClientTransport(name, env = process.env, options = {}) {
|
|
1763
|
+
const keys = clientTransportEnvKeys(name);
|
|
1764
|
+
const envUrlHit = firstEnv(env, keys.apiUrlKeys, { preserveRaw: true });
|
|
1765
|
+
const explicitLocalKey = envUrlHit ? null : firstEnvDefinedKey(env, keys.apiUrlKeys);
|
|
1766
|
+
const diskUrlHit = envUrlHit || explicitLocalKey ? null : appConfigDiskValue(name, env, keys.apiUrlKeys);
|
|
1767
|
+
const urlHit = envUrlHit ?? (diskUrlHit ? { key: diskUrlHit.path, value: diskUrlHit.value } : null);
|
|
1768
|
+
const keyHit = firstEnv(env, keys.apiKeyKeys);
|
|
1769
|
+
const warnings = [];
|
|
1770
|
+
if (!urlHit) {
|
|
1771
|
+
if (explicitLocalKey) {
|
|
1772
|
+
const overriddenPointer = appConfigDiskValue(name, env, keys.apiUrlKeys);
|
|
1773
|
+
if (overriddenPointer) {
|
|
1774
|
+
warnings.push(`${explicitLocalKey} is defined but blank, which selects the local store. ` + `The server URL in ${overriddenPointer.path} was NOT selected: an explicit blank wins over a disk pointer.`);
|
|
1775
|
+
}
|
|
1776
|
+
return {
|
|
1777
|
+
transport: "sqlite",
|
|
1778
|
+
transportSource: explicitLocalKey,
|
|
1779
|
+
baseUrl: null,
|
|
1780
|
+
apiUrlSource: null,
|
|
1781
|
+
apiKeyPresent: Boolean(keyHit),
|
|
1782
|
+
apiKeySource: keyHit ? keyHit.key : null,
|
|
1783
|
+
apiKeyTier: null,
|
|
1784
|
+
misconfigured: false,
|
|
1785
|
+
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
1786
|
+
};
|
|
1787
|
+
}
|
|
1788
|
+
return {
|
|
1789
|
+
transport: "sqlite",
|
|
1790
|
+
transportSource: "default",
|
|
1791
|
+
baseUrl: null,
|
|
1792
|
+
apiUrlSource: null,
|
|
1793
|
+
apiKeyPresent: Boolean(keyHit),
|
|
1794
|
+
apiKeySource: keyHit ? keyHit.key : null,
|
|
1795
|
+
apiKeyTier: null,
|
|
1796
|
+
misconfigured: false,
|
|
1797
|
+
warning: null
|
|
1798
|
+
};
|
|
1799
|
+
}
|
|
1800
|
+
if (diskUrlHit) {
|
|
1801
|
+
warnings.push(`No ${keys.apiUrlKeys[0]} in the environment; the server URL in ${diskUrlHit.path} was used, so this client connects to the server. ` + `Unset the pointer or remove the file to stay on the local store.`);
|
|
1802
|
+
}
|
|
1803
|
+
const credential = resolveCredential(name, env, options.credentials);
|
|
1804
|
+
if (!credential) {
|
|
1805
|
+
const diskHint = credentialDiskSourcesForMessage(name, env);
|
|
1806
|
+
warnings.push(`${urlHit.key} selects the HTTP server for '${name}', but no API key could be resolved; ` + `refusing to route and leaving the local sqlite store selected. ` + `Looked for a credential file at ${diskHint}, then for ${keys.apiKeyKeys[0]} in the environment.`);
|
|
1807
|
+
return {
|
|
1808
|
+
transport: "sqlite",
|
|
1809
|
+
transportSource: urlHit.key,
|
|
1810
|
+
baseUrl: null,
|
|
1811
|
+
apiUrlSource: urlHit.key,
|
|
1812
|
+
apiKeyPresent: false,
|
|
1813
|
+
apiKeySource: null,
|
|
1814
|
+
apiKeyTier: null,
|
|
1815
|
+
misconfigured: true,
|
|
1816
|
+
warning: warnings.join(" ")
|
|
1817
|
+
};
|
|
1818
|
+
}
|
|
1819
|
+
if (credential.warning)
|
|
1820
|
+
warnings.push(credential.warning);
|
|
1821
|
+
const apiUrlSource = urlHit.key;
|
|
1822
|
+
let baseUrl;
|
|
1823
|
+
try {
|
|
1824
|
+
baseUrl = toV1BaseUrl(urlHit.value);
|
|
1825
|
+
} catch (error) {
|
|
1826
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1827
|
+
warnings.push(`Invalid API URL from ${apiUrlSource}: ${message}. Using local store.`);
|
|
1828
|
+
return {
|
|
1829
|
+
transport: "sqlite",
|
|
1830
|
+
transportSource: urlHit.key,
|
|
1831
|
+
baseUrl: null,
|
|
1832
|
+
apiUrlSource: urlHit.key,
|
|
1833
|
+
apiKeyPresent: true,
|
|
1834
|
+
apiKeySource: credential.source,
|
|
1835
|
+
apiKeyTier: credential.tier,
|
|
1836
|
+
misconfigured: true,
|
|
1837
|
+
warning: warnings.join(" ")
|
|
1838
|
+
};
|
|
1839
|
+
}
|
|
1840
|
+
return {
|
|
1841
|
+
transport: "http",
|
|
1842
|
+
transportSource: urlHit.key,
|
|
1843
|
+
baseUrl,
|
|
1844
|
+
apiUrlSource,
|
|
1845
|
+
apiKeyPresent: true,
|
|
1846
|
+
apiKeySource: credential.source,
|
|
1847
|
+
apiKeyTier: credential.tier,
|
|
1848
|
+
misconfigured: false,
|
|
1849
|
+
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
1850
|
+
};
|
|
1851
|
+
}
|
|
1852
|
+
function credentialDiskSourcesForMessage(name, env) {
|
|
1853
|
+
const paths = credentialDiskSources(name, env);
|
|
1854
|
+
return paths.length > 0 ? paths.join(" or ") : "<no HOME set in this environment, so no credential file was consulted>";
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
class HasnaHttpError extends Error {
|
|
1858
|
+
status;
|
|
1859
|
+
method;
|
|
1860
|
+
path;
|
|
1861
|
+
body;
|
|
1862
|
+
credentialSource;
|
|
1863
|
+
credentialTier;
|
|
1864
|
+
constructor(method, path, status, body, credential) {
|
|
1865
|
+
const guidance = credential ? `. ${credential.guidance}` : "";
|
|
1866
|
+
super(`Hasna cloud request failed: ${method} ${path} -> ${status}${guidance}`);
|
|
1867
|
+
this.name = "HasnaHttpError";
|
|
1868
|
+
this.status = status;
|
|
1869
|
+
this.method = method;
|
|
1870
|
+
this.path = path;
|
|
1871
|
+
this.body = body;
|
|
1872
|
+
this.credentialSource = credential?.source ?? null;
|
|
1873
|
+
this.credentialTier = credential?.tier ?? null;
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
function currentCredential(name, apiKey) {
|
|
1877
|
+
if (typeof apiKey === "function") {
|
|
1878
|
+
return validateAndSealResolvedCredential(name, apiKey());
|
|
1879
|
+
}
|
|
1880
|
+
return explicitCredential(name, apiKey);
|
|
1881
|
+
}
|
|
1882
|
+
function authFailureGuidance(credential) {
|
|
1883
|
+
const origin = `The API key for this request came from ${credential.source}`;
|
|
1884
|
+
if (credential.deliberate) {
|
|
1885
|
+
const remedy = credential.source === CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE ? `Fix that provider so it returns the current key, or replace it with resolveCredential() ` + `so diagnostics can name the original source.` : `Rotate that key, or unset the override to use the credential on disk.`;
|
|
1886
|
+
return `${origin} \u2014 a credential you selected deliberately. It was NOT substituted with any other key: ` + `falling back here would authenticate as a different principal than the one you named, which is ` + `exactly the failure an override exists to prevent. ${remedy}`;
|
|
1887
|
+
}
|
|
1888
|
+
if (credential.deprecated) {
|
|
1889
|
+
const target = credential.diskCandidates[0];
|
|
1890
|
+
const remedy = target ? `Write the CURRENT key to ${target} \u2014 that file is re-read on every call, so rotations take ` + `effect immediately and in every shell. Do not simply unset ${credential.source}: nothing was ` + `found on disk, so that would leave this client with no credential at all.` : `This environment has no HOME, so no credential file could be consulted; the disk tier is ` + `unavailable here and there is nothing to fall back to. Set HOME, or supply the key explicitly.`;
|
|
1891
|
+
return `${origin}, a variable in this process's environment \u2014 which is a snapshot taken when the process ` + `started. A STALE SHELL is the most common cause of this error: this shell exported the key before ` + `it was rotated, and will keep sending the old one until it exits. ${remedy}`;
|
|
1892
|
+
}
|
|
1893
|
+
return `${origin}, which was re-read from disk on this very call \u2014 so a stale shell is NOT the cause here. ` + `The stored credential is genuinely being rejected: rotate it, or re-run the fleet key distribution ` + `so this machine gets the current key.`;
|
|
1894
|
+
}
|
|
1895
|
+
var DEFAULT_RETRY_STATUSES = [408, 425, 429, 500, 502, 503, 504];
|
|
1896
|
+
var IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
1897
|
+
var AUTHORITY_OVERRIDE_HEADERS = new Set([
|
|
1898
|
+
"host",
|
|
1899
|
+
":authority",
|
|
1900
|
+
"forwarded",
|
|
1901
|
+
"x-forwarded-host",
|
|
1902
|
+
"x-original-host"
|
|
1903
|
+
]);
|
|
1904
|
+
function assertNoAuthorityOverrideHeaders(headers, source) {
|
|
1905
|
+
if (!headers)
|
|
1906
|
+
return;
|
|
1907
|
+
const forbidden = Object.keys(headers).find((name) => AUTHORITY_OVERRIDE_HEADERS.has(name.trim().toLowerCase()));
|
|
1908
|
+
if (forbidden) {
|
|
1909
|
+
throw new Error(`Authenticated ${source} headers must not set authority header '${forbidden}'.`);
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
function appendQuery(path, query) {
|
|
1913
|
+
if (!query)
|
|
1914
|
+
return path;
|
|
1915
|
+
const params = query instanceof URLSearchParams ? query : new URLSearchParams;
|
|
1916
|
+
if (!(query instanceof URLSearchParams)) {
|
|
1917
|
+
for (const [key, value] of Object.entries(query)) {
|
|
1918
|
+
if (value === null || value === undefined)
|
|
1919
|
+
continue;
|
|
1920
|
+
if (Array.isArray(value)) {
|
|
1921
|
+
for (const v of value)
|
|
1922
|
+
params.append(key, String(v));
|
|
1923
|
+
} else {
|
|
1924
|
+
params.append(key, String(value));
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
const qs = params.toString();
|
|
1929
|
+
if (!qs)
|
|
1930
|
+
return path;
|
|
1931
|
+
return `${path}${path.includes("?") ? "&" : "?"}${qs}`;
|
|
1932
|
+
}
|
|
1933
|
+
var defaultSleep = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
1934
|
+
function createHasnaHttpTransport(options) {
|
|
1935
|
+
const fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
1936
|
+
const base = toV1BaseUrl(options.baseUrl);
|
|
1937
|
+
const timeoutMs = options.timeoutMs ?? 30000;
|
|
1938
|
+
const sleep = options.sleepImpl ?? defaultSleep;
|
|
1939
|
+
const defaultRetry = options.retry;
|
|
1940
|
+
function resolveRetry(callRetry) {
|
|
1941
|
+
const chosen = callRetry !== undefined ? callRetry : defaultRetry;
|
|
1942
|
+
if (chosen === false)
|
|
1943
|
+
return null;
|
|
1944
|
+
const r = chosen ?? {};
|
|
1945
|
+
return {
|
|
1946
|
+
retries: r.retries ?? 2,
|
|
1947
|
+
baseDelayMs: r.baseDelayMs ?? 200,
|
|
1948
|
+
maxDelayMs: r.maxDelayMs ?? 2000,
|
|
1949
|
+
retryStatuses: r.retryStatuses ?? [...DEFAULT_RETRY_STATUSES]
|
|
1950
|
+
};
|
|
1951
|
+
}
|
|
1952
|
+
async function once(method, rel, url, body, opts, credential) {
|
|
1953
|
+
assertNoAuthorityOverrideHeaders(options.headers, "transport");
|
|
1954
|
+
assertNoAuthorityOverrideHeaders(opts.headers, "request");
|
|
1955
|
+
const headers = {
|
|
1956
|
+
"x-api-key": credential.apiKey,
|
|
1957
|
+
Authorization: `Bearer ${credential.apiKey}`,
|
|
1958
|
+
Accept: "application/json",
|
|
1959
|
+
...options.headers ?? {},
|
|
1960
|
+
...opts.headers ?? {}
|
|
1961
|
+
};
|
|
1962
|
+
if (opts.idempotencyKey)
|
|
1963
|
+
headers["Idempotency-Key"] = opts.idempotencyKey;
|
|
1964
|
+
const init = {
|
|
1965
|
+
method,
|
|
1966
|
+
headers,
|
|
1967
|
+
redirect: "manual"
|
|
1968
|
+
};
|
|
1969
|
+
if (body !== undefined) {
|
|
1970
|
+
headers["Content-Type"] = "application/json";
|
|
1971
|
+
init.body = JSON.stringify(body);
|
|
1972
|
+
}
|
|
1973
|
+
const controller = new AbortController;
|
|
1974
|
+
const onAbort = () => controller.abort();
|
|
1975
|
+
if (opts.signal) {
|
|
1976
|
+
if (opts.signal.aborted)
|
|
1977
|
+
controller.abort();
|
|
1978
|
+
else
|
|
1979
|
+
opts.signal.addEventListener("abort", onAbort, { once: true });
|
|
1980
|
+
}
|
|
1981
|
+
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? timeoutMs);
|
|
1982
|
+
init.signal = controller.signal;
|
|
1983
|
+
let response;
|
|
1984
|
+
try {
|
|
1985
|
+
response = await fetchImpl(url, init);
|
|
1986
|
+
} catch (error) {
|
|
1987
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
1988
|
+
if (opts.signal?.aborted)
|
|
1989
|
+
return { ok: false, retryable: false, error: err };
|
|
1990
|
+
return { ok: false, retryable: true, error: err };
|
|
1991
|
+
} finally {
|
|
1992
|
+
clearTimeout(timer);
|
|
1993
|
+
if (opts.signal)
|
|
1994
|
+
opts.signal.removeEventListener("abort", onAbort);
|
|
1995
|
+
}
|
|
1996
|
+
const text = await response.text();
|
|
1997
|
+
let parsed = undefined;
|
|
1998
|
+
if (text.length > 0) {
|
|
1999
|
+
try {
|
|
2000
|
+
parsed = JSON.parse(text);
|
|
2001
|
+
} catch {
|
|
2002
|
+
parsed = text;
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
if (!response.ok) {
|
|
2006
|
+
if (response.status >= 300 && response.status < 400) {
|
|
2007
|
+
return {
|
|
2008
|
+
ok: false,
|
|
2009
|
+
retryable: false,
|
|
2010
|
+
error: new HasnaHttpError(method, rel, response.status, parsed)
|
|
2011
|
+
};
|
|
2012
|
+
}
|
|
2013
|
+
if (response.status === 401 || response.status === 403) {
|
|
2014
|
+
return {
|
|
2015
|
+
ok: false,
|
|
2016
|
+
retryable: false,
|
|
2017
|
+
error: new HasnaHttpError(method, rel, response.status, parsed, {
|
|
2018
|
+
source: credential.source,
|
|
2019
|
+
tier: credential.tier,
|
|
2020
|
+
guidance: authFailureGuidance(credential)
|
|
2021
|
+
})
|
|
2022
|
+
};
|
|
2023
|
+
}
|
|
2024
|
+
const retry = resolveRetry(opts.retry);
|
|
2025
|
+
const retryable = retry ? retry.retryStatuses.includes(response.status) : false;
|
|
2026
|
+
return { ok: false, retryable, error: new HasnaHttpError(method, rel, response.status, parsed) };
|
|
2027
|
+
}
|
|
2028
|
+
return { ok: true, value: parsed };
|
|
2029
|
+
}
|
|
2030
|
+
async function request(method, path, body, opts = {}) {
|
|
2031
|
+
const upper = method.toUpperCase();
|
|
2032
|
+
const rel = appendQuery(path.startsWith("/") ? path : `/${path}`, opts.query);
|
|
2033
|
+
const url = `${base}${rel}`;
|
|
2034
|
+
const retry = resolveRetry(opts.retry);
|
|
2035
|
+
const methodRetryable = IDEMPOTENT_METHODS.has(upper) || Boolean(opts.idempotencyKey);
|
|
2036
|
+
const maxAttempts = retry && methodRetryable ? retry.retries + 1 : 1;
|
|
2037
|
+
const credential = currentCredential(options.name, options.apiKey);
|
|
2038
|
+
let last = null;
|
|
2039
|
+
for (let attempt = 1;attempt <= maxAttempts; attempt++) {
|
|
2040
|
+
const result = await once(upper, rel, url, body, opts, credential);
|
|
2041
|
+
if (result.ok)
|
|
2042
|
+
return result.value;
|
|
2043
|
+
last = result;
|
|
2044
|
+
const canRetry = retry !== null && methodRetryable && result.retryable && attempt < maxAttempts;
|
|
2045
|
+
if (!canRetry)
|
|
2046
|
+
break;
|
|
2047
|
+
const backoff = Math.min(retry.maxDelayMs, retry.baseDelayMs * 2 ** (attempt - 1));
|
|
2048
|
+
const jitter = Math.floor(Math.random() * (backoff / 2 + 1));
|
|
2049
|
+
await sleep(backoff + jitter);
|
|
2050
|
+
}
|
|
2051
|
+
throw last.error;
|
|
2052
|
+
}
|
|
2053
|
+
return {
|
|
2054
|
+
baseUrl: base,
|
|
2055
|
+
request,
|
|
2056
|
+
get: (path, opts) => request("GET", path, undefined, opts),
|
|
2057
|
+
post: (path, body, opts) => request("POST", path, body, opts),
|
|
2058
|
+
put: (path, body, opts) => request("PUT", path, body, opts),
|
|
2059
|
+
patch: (path, body, opts) => request("PATCH", path, body, opts),
|
|
2060
|
+
del: (path, body, opts) => request("DELETE", path, body, opts)
|
|
2061
|
+
};
|
|
2062
|
+
}
|
|
2063
|
+
function createClientTransport(name, env = process.env, overrides) {
|
|
2064
|
+
const credentialOptions = overrides?.credentials;
|
|
2065
|
+
const resolution = resolveClientTransport(name, env, { ...credentialOptions ? { credentials: credentialOptions } : {} });
|
|
2066
|
+
if (resolution.misconfigured) {
|
|
2067
|
+
throw new Error(resolution.warning ?? `Client for '${name}' is misconfigured for the API client.`);
|
|
2068
|
+
}
|
|
2069
|
+
if (resolution.transport === "sqlite" || !resolution.baseUrl) {
|
|
2070
|
+
return { transport: "sqlite", client: null, resolution };
|
|
2071
|
+
}
|
|
2072
|
+
const credentialProvider = () => {
|
|
2073
|
+
const resolved = resolveCredential(name, env, credentialOptions);
|
|
2074
|
+
if (!resolved) {
|
|
2075
|
+
throw new Error(`Client for '${name}' resolved to the http transport but no API key is available any more. ` + `Looked at ${credentialDiskSourcesForMessage(name, env)}, then the environment. ` + `A credential file that was removed after this client was built is the usual cause.`);
|
|
2076
|
+
}
|
|
2077
|
+
return resolved;
|
|
2078
|
+
};
|
|
2079
|
+
return {
|
|
2080
|
+
transport: "http",
|
|
2081
|
+
client: createHasnaHttpTransport({
|
|
2082
|
+
name,
|
|
2083
|
+
baseUrl: resolution.baseUrl,
|
|
2084
|
+
apiKey: credentialProvider,
|
|
2085
|
+
...overrides?.fetchImpl ? { fetchImpl: overrides.fetchImpl } : {},
|
|
2086
|
+
...overrides?.headers ? { headers: overrides.headers } : {},
|
|
2087
|
+
...overrides?.timeoutMs ? { timeoutMs: overrides.timeoutMs } : {},
|
|
2088
|
+
...overrides?.retry !== undefined ? { retry: overrides.retry } : {},
|
|
2089
|
+
...overrides?.sleepImpl ? { sleepImpl: overrides.sleepImpl } : {}
|
|
2090
|
+
}),
|
|
2091
|
+
resolution
|
|
2092
|
+
};
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
// ../contracts/dist/client/storage.js
|
|
2096
|
+
import { isIP as isIP2 } from "net";
|
|
2097
|
+
import { readFileSync as readFileSync3, statSync as statSync3 } from "fs";
|
|
2098
|
+
import { join as join4 } from "path";
|
|
2099
|
+
function envToken2(name) {
|
|
2100
|
+
return name.toUpperCase().replace(/-/g, "_");
|
|
2101
|
+
}
|
|
2102
|
+
function clientTransportEnvKeys2(name) {
|
|
2103
|
+
const envSegment = envToken2(name);
|
|
2104
|
+
return {
|
|
2105
|
+
apiUrlKeys: [`HASNA_${envSegment}_API_URL`, `${envSegment}_API_URL`],
|
|
2106
|
+
apiKeyKeys: [`HASNA_${envSegment}_API_KEY`, `${envSegment}_API_KEY`]
|
|
2107
|
+
};
|
|
2108
|
+
}
|
|
2109
|
+
function credentialOverrideEnvKey2(name) {
|
|
2110
|
+
return `HASNA_${envToken2(name)}_API_KEY_OVERRIDE`;
|
|
2111
|
+
}
|
|
2112
|
+
var CREDENTIAL_PROFILE_ENV_KEY2 = "HASNA_PROFILE";
|
|
2113
|
+
|
|
2114
|
+
class CredentialResolutionError2 extends Error {
|
|
2115
|
+
appName;
|
|
2116
|
+
attempted;
|
|
2117
|
+
constructor(appName, message, attempted) {
|
|
2118
|
+
super(message);
|
|
2119
|
+
this.name = "CredentialResolutionError";
|
|
2120
|
+
this.appName = appName;
|
|
2121
|
+
this.attempted = attempted;
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
var HASNA_STATE_DIR2 = ".hasna";
|
|
2125
|
+
var FLEET_CREDENTIAL_DIR2 = "cloud";
|
|
2126
|
+
var CONFIG_DIR2 = ".config";
|
|
2127
|
+
var CONFIG_NAMESPACE2 = "hasna";
|
|
2128
|
+
var MAX_CREDENTIAL_FILE_BYTES2 = 64 * 1024;
|
|
2129
|
+
var SAFE_APP_SLUG2 = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
2130
|
+
var SAFE_PROFILE2 = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
|
2131
|
+
var ILLEGAL_IN_HEADER_VALUE2 = /[^\t\x20-\x7e]/;
|
|
2132
|
+
function homeDir2(env) {
|
|
2133
|
+
const home = env.HOME?.trim();
|
|
2134
|
+
return home ? home : null;
|
|
2135
|
+
}
|
|
2136
|
+
function credentialDiskSources2(name, env) {
|
|
2137
|
+
return profileDiskSources2(name, env, null);
|
|
2138
|
+
}
|
|
2139
|
+
function profileDiskSources2(name, env, profile) {
|
|
2140
|
+
const home = homeDir2(env);
|
|
2141
|
+
if (!home || !SAFE_APP_SLUG2.test(name))
|
|
2142
|
+
return [];
|
|
2143
|
+
const stem = profile ? `${name}.${profile}` : name;
|
|
2144
|
+
const configStem = profile ? `${name}-${profile}` : name;
|
|
2145
|
+
return [
|
|
2146
|
+
join4(home, HASNA_STATE_DIR2, FLEET_CREDENTIAL_DIR2, `${stem}.env`),
|
|
2147
|
+
join4(home, CONFIG_DIR2, CONFIG_NAMESPACE2, `${configStem}-cloud.env`)
|
|
2148
|
+
];
|
|
2149
|
+
}
|
|
2150
|
+
function parseEnvFile2(text) {
|
|
2151
|
+
const values = new Map;
|
|
2152
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
2153
|
+
const line = rawLine.trim();
|
|
2154
|
+
if (line.length === 0 || line.startsWith("#"))
|
|
2155
|
+
continue;
|
|
2156
|
+
const withoutExport = line.startsWith("export ") ? line.slice("export ".length).trim() : line;
|
|
2157
|
+
const equals = withoutExport.indexOf("=");
|
|
2158
|
+
if (equals <= 0)
|
|
2159
|
+
continue;
|
|
2160
|
+
const key = withoutExport.slice(0, equals).trim();
|
|
2161
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
2162
|
+
continue;
|
|
2163
|
+
let value = withoutExport.slice(equals + 1).trim();
|
|
2164
|
+
const quote = value[0];
|
|
2165
|
+
if (quote === '"' || quote === "'") {
|
|
2166
|
+
if (value.length < 2 || !value.endsWith(quote))
|
|
2167
|
+
continue;
|
|
2168
|
+
value = value.slice(1, -1);
|
|
2169
|
+
}
|
|
2170
|
+
if (value.length === 0)
|
|
2171
|
+
continue;
|
|
2172
|
+
values.set(key, value);
|
|
2173
|
+
}
|
|
2174
|
+
return values;
|
|
2175
|
+
}
|
|
2176
|
+
function readAppConfigFile2(path) {
|
|
2177
|
+
let text;
|
|
2178
|
+
try {
|
|
2179
|
+
const stats = statSync3(path);
|
|
2180
|
+
if (!stats.isFile() || stats.size > MAX_CREDENTIAL_FILE_BYTES2)
|
|
2181
|
+
return null;
|
|
2182
|
+
text = readFileSync3(path, "utf8");
|
|
2183
|
+
} catch {
|
|
2184
|
+
return null;
|
|
2185
|
+
}
|
|
2186
|
+
return parseEnvFile2(text);
|
|
2187
|
+
}
|
|
2188
|
+
function readCredentialFile2(path, apiKeyKeys) {
|
|
2189
|
+
const values = readAppConfigFile2(path);
|
|
2190
|
+
if (!values)
|
|
2191
|
+
return null;
|
|
2192
|
+
for (const key of apiKeyKeys) {
|
|
2193
|
+
const value = values.get(key)?.trim();
|
|
2194
|
+
if (value)
|
|
2195
|
+
return value;
|
|
2196
|
+
}
|
|
2197
|
+
return null;
|
|
2198
|
+
}
|
|
2199
|
+
var CREDENTIAL_SHAPED_KEY2 = /(?:^|_)(?:API_KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)(?:_|$)/;
|
|
2200
|
+
function appConfigDiskValue2(name, env, keys) {
|
|
2201
|
+
const wanted = keys.filter((key) => !CREDENTIAL_SHAPED_KEY2.test(key));
|
|
2202
|
+
if (wanted.length === 0)
|
|
2203
|
+
return null;
|
|
2204
|
+
for (const path of credentialDiskSources2(name, env)) {
|
|
2205
|
+
const values = readAppConfigFile2(path);
|
|
2206
|
+
if (!values)
|
|
2207
|
+
continue;
|
|
2208
|
+
for (const key of wanted) {
|
|
2209
|
+
const value = values.get(key)?.trim();
|
|
2210
|
+
if (value)
|
|
2211
|
+
return { key, value, path };
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
return null;
|
|
2215
|
+
}
|
|
2216
|
+
function assertUsableCredential2(appName, source, value) {
|
|
2217
|
+
if (!ILLEGAL_IN_HEADER_VALUE2.test(value))
|
|
2218
|
+
return;
|
|
2219
|
+
throw new CredentialResolutionError2(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]);
|
|
2220
|
+
}
|
|
2221
|
+
var INSPECT_CUSTOM2 = Symbol.for("nodejs.util.inspect.custom");
|
|
2222
|
+
var CREDENTIAL_SEAL2 = Symbol.for("hasna:contracts:sealedCredential");
|
|
2223
|
+
var CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE2 = "caller-supplied CredentialProvider";
|
|
2224
|
+
function sealCredential2(fields) {
|
|
2225
|
+
const { apiKey } = fields;
|
|
2226
|
+
const visible = {
|
|
2227
|
+
tier: fields.tier,
|
|
2228
|
+
source: fields.source,
|
|
2229
|
+
deliberate: fields.deliberate,
|
|
2230
|
+
deprecated: fields.deprecated,
|
|
2231
|
+
diskCandidates: Object.freeze([...fields.diskCandidates]),
|
|
2232
|
+
warning: fields.warning
|
|
2233
|
+
};
|
|
2234
|
+
const sealed = { ...visible };
|
|
2235
|
+
Object.defineProperty(sealed, "apiKey", {
|
|
2236
|
+
value: apiKey,
|
|
2237
|
+
enumerable: false,
|
|
2238
|
+
writable: false,
|
|
2239
|
+
configurable: false
|
|
2240
|
+
});
|
|
2241
|
+
Object.defineProperty(sealed, INSPECT_CUSTOM2, {
|
|
2242
|
+
value: () => ({ ...visible, apiKey: "[redacted]" }),
|
|
2243
|
+
enumerable: false,
|
|
2244
|
+
writable: false,
|
|
2245
|
+
configurable: false
|
|
2246
|
+
});
|
|
2247
|
+
Object.defineProperty(sealed, CREDENTIAL_SEAL2, {
|
|
2248
|
+
value: true,
|
|
2249
|
+
enumerable: false,
|
|
2250
|
+
writable: false,
|
|
2251
|
+
configurable: false
|
|
2252
|
+
});
|
|
2253
|
+
return Object.freeze(sealed);
|
|
2254
|
+
}
|
|
2255
|
+
function isSealedCredential2(credential) {
|
|
2256
|
+
return credential[CREDENTIAL_SEAL2] === true;
|
|
2257
|
+
}
|
|
2258
|
+
function explicitCredential2(appName, apiKey) {
|
|
2259
|
+
const source = "explicit apiKey option";
|
|
2260
|
+
assertUsableCredential2(appName, source, apiKey);
|
|
2261
|
+
return sealCredential2({
|
|
2262
|
+
apiKey,
|
|
2263
|
+
tier: "argument",
|
|
2264
|
+
source,
|
|
2265
|
+
deliberate: true,
|
|
2266
|
+
deprecated: false,
|
|
2267
|
+
diskCandidates: [],
|
|
2268
|
+
warning: null
|
|
2269
|
+
});
|
|
2270
|
+
}
|
|
2271
|
+
function validateAndSealResolvedCredential2(appName, credential) {
|
|
2272
|
+
const apiKey = credential.apiKey;
|
|
2273
|
+
assertUsableCredential2(appName, CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE2, apiKey);
|
|
2274
|
+
if (!isSealedCredential2(credential)) {
|
|
2275
|
+
return sealCredential2({
|
|
2276
|
+
apiKey,
|
|
2277
|
+
tier: "argument",
|
|
2278
|
+
source: CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE2,
|
|
2279
|
+
deliberate: true,
|
|
2280
|
+
deprecated: false,
|
|
2281
|
+
diskCandidates: [],
|
|
2282
|
+
warning: null
|
|
2283
|
+
});
|
|
2284
|
+
}
|
|
2285
|
+
return sealCredential2({
|
|
2286
|
+
apiKey,
|
|
2287
|
+
tier: credential.tier,
|
|
2288
|
+
source: credential.source,
|
|
2289
|
+
deliberate: credential.deliberate,
|
|
2290
|
+
deprecated: credential.deprecated,
|
|
2291
|
+
diskCandidates: credential.diskCandidates,
|
|
2292
|
+
warning: credential.warning
|
|
2293
|
+
});
|
|
2294
|
+
}
|
|
2295
|
+
function firstEnvValue2(env, keys) {
|
|
2296
|
+
for (const key of keys) {
|
|
2297
|
+
const value = env[key]?.trim();
|
|
2298
|
+
if (value)
|
|
2299
|
+
return { key, value };
|
|
2300
|
+
}
|
|
2301
|
+
return null;
|
|
2302
|
+
}
|
|
2303
|
+
var DEPRECATION_REGISTRY2 = Symbol.for("hasna:contracts:credentialDeprecationNotices");
|
|
2304
|
+
function deprecationNotified2() {
|
|
2305
|
+
const host = globalThis;
|
|
2306
|
+
const existing = host[DEPRECATION_REGISTRY2];
|
|
2307
|
+
if (existing instanceof Set)
|
|
2308
|
+
return existing;
|
|
2309
|
+
const created = new Set;
|
|
2310
|
+
host[DEPRECATION_REGISTRY2] = created;
|
|
2311
|
+
return created;
|
|
2312
|
+
}
|
|
2313
|
+
function defaultDeprecationSink2(message) {
|
|
2314
|
+
if (typeof process !== "undefined" && process.stderr) {
|
|
2315
|
+
process.stderr.write(`${message}
|
|
2316
|
+
`);
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
function resolveCredential2(name, env, options = {}) {
|
|
2320
|
+
const { apiKeyKeys } = clientTransportEnvKeys2(name);
|
|
2321
|
+
const diskPaths = credentialDiskSources2(name, env);
|
|
2322
|
+
const explicitKey = options.apiKey?.trim();
|
|
2323
|
+
if (explicitKey) {
|
|
2324
|
+
assertUsableCredential2(name, "the explicit apiKey argument", explicitKey);
|
|
2325
|
+
return sealCredential2({
|
|
2326
|
+
apiKey: explicitKey,
|
|
2327
|
+
tier: "argument",
|
|
2328
|
+
source: "explicit apiKey argument",
|
|
2329
|
+
deliberate: true,
|
|
2330
|
+
deprecated: false,
|
|
2331
|
+
diskCandidates: diskPaths,
|
|
2332
|
+
warning: null
|
|
2333
|
+
});
|
|
2334
|
+
}
|
|
2335
|
+
const overrideKeyName = credentialOverrideEnvKey2(name);
|
|
2336
|
+
const overrideRaw = env[overrideKeyName];
|
|
2337
|
+
if (overrideRaw !== undefined) {
|
|
2338
|
+
const override = overrideRaw.trim();
|
|
2339
|
+
if (!override) {
|
|
2340
|
+
throw new CredentialResolutionError2(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]);
|
|
2341
|
+
}
|
|
2342
|
+
assertUsableCredential2(name, overrideKeyName, override);
|
|
2343
|
+
return sealCredential2({
|
|
2344
|
+
apiKey: override,
|
|
2345
|
+
tier: "override",
|
|
2346
|
+
source: overrideKeyName,
|
|
2347
|
+
deliberate: true,
|
|
2348
|
+
deprecated: false,
|
|
2349
|
+
diskCandidates: diskPaths,
|
|
2350
|
+
warning: null
|
|
2351
|
+
});
|
|
2352
|
+
}
|
|
2353
|
+
const profile = options.profile?.trim() || env[CREDENTIAL_PROFILE_ENV_KEY2]?.trim();
|
|
2354
|
+
if (profile) {
|
|
2355
|
+
const profileSource = options.profile?.trim() ? "explicit profile argument" : CREDENTIAL_PROFILE_ENV_KEY2;
|
|
2356
|
+
if (!SAFE_PROFILE2.test(profile)) {
|
|
2357
|
+
throw new CredentialResolutionError2(name, `Profile name from ${profileSource} is not usable in a path. ` + `Use letters, digits, dot, dash, or underscore.`, [profileSource]);
|
|
2358
|
+
}
|
|
2359
|
+
const paths = profileDiskSources2(name, env, profile);
|
|
2360
|
+
for (const path of paths) {
|
|
2361
|
+
const value = readCredentialFile2(path, apiKeyKeys);
|
|
2362
|
+
if (value) {
|
|
2363
|
+
assertUsableCredential2(name, path, value);
|
|
2364
|
+
return sealCredential2({
|
|
2365
|
+
apiKey: value,
|
|
2366
|
+
tier: "profile",
|
|
2367
|
+
source: path,
|
|
2368
|
+
deliberate: true,
|
|
2369
|
+
deprecated: false,
|
|
2370
|
+
diskCandidates: paths,
|
|
2371
|
+
warning: null
|
|
2372
|
+
});
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
throw new CredentialResolutionError2(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_KEY2}.`, paths);
|
|
2376
|
+
}
|
|
2377
|
+
const diskHits = diskPaths.map((path) => ({ path, value: readCredentialFile2(path, apiKeyKeys) })).filter((hit) => hit.value !== null);
|
|
2378
|
+
if (diskHits.length > 0) {
|
|
2379
|
+
const winner = diskHits[0];
|
|
2380
|
+
assertUsableCredential2(name, winner.path, winner.value);
|
|
2381
|
+
const divergentSources = [
|
|
2382
|
+
...diskHits.slice(1).filter((hit) => hit.value !== winner.value).map((hit) => hit.path),
|
|
2383
|
+
...(() => {
|
|
2384
|
+
const legacyHit = firstEnvValue2(env, apiKeyKeys);
|
|
2385
|
+
return legacyHit && legacyHit.value !== winner.value ? [legacyHit.key] : [];
|
|
2386
|
+
})()
|
|
2387
|
+
];
|
|
2388
|
+
const warning = divergentSources.length > 0 ? `Credential sources disagree for '${name}': ${winner.path} and ` + `${divergentSources.join(", ")} hold different keys. ${winner.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;
|
|
2389
|
+
return sealCredential2({
|
|
2390
|
+
apiKey: winner.value,
|
|
2391
|
+
tier: "disk",
|
|
2392
|
+
source: winner.path,
|
|
2393
|
+
deliberate: false,
|
|
2394
|
+
deprecated: false,
|
|
2395
|
+
diskCandidates: diskPaths,
|
|
2396
|
+
warning
|
|
2397
|
+
});
|
|
2398
|
+
}
|
|
2399
|
+
const legacy = firstEnvValue2(env, apiKeyKeys);
|
|
2400
|
+
if (legacy) {
|
|
2401
|
+
assertUsableCredential2(name, legacy.key, legacy.value);
|
|
2402
|
+
const where = diskPaths.length > 0 ? `Put the current key in ${diskPaths[0]} \u2014 it is re-read on every call, so rotations take effect immediately.` : `This environment has no HOME, so no credential file could be consulted at all; the disk tier is ` + `unavailable here and this process will keep using the environment snapshot.`;
|
|
2403
|
+
const message = `[${name}] DEPRECATED: the API key came from ${legacy.key} in this process's environment. ` + `Environment variables are a snapshot taken when this process started, so a shell that started ` + `before a key rotation keeps using the old key until it exits. ${where}`;
|
|
2404
|
+
const sink = options.onDeprecation ?? defaultDeprecationSink2;
|
|
2405
|
+
const notified = deprecationNotified2();
|
|
2406
|
+
if (!notified.has(name)) {
|
|
2407
|
+
notified.add(name);
|
|
2408
|
+
sink(message);
|
|
2409
|
+
}
|
|
2410
|
+
return sealCredential2({
|
|
2411
|
+
apiKey: legacy.value,
|
|
2412
|
+
tier: "legacy-env",
|
|
2413
|
+
source: legacy.key,
|
|
2414
|
+
deliberate: false,
|
|
2415
|
+
deprecated: true,
|
|
2416
|
+
diskCandidates: diskPaths,
|
|
2417
|
+
warning: message
|
|
2418
|
+
});
|
|
2419
|
+
}
|
|
2420
|
+
return null;
|
|
2421
|
+
}
|
|
2422
|
+
var ASCII_CONTROL_PATTERN2 = /[\u0000-\u001f\u007f]/;
|
|
2423
|
+
var DNS_LABEL_PATTERN2 = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
2424
|
+
function isValidDnsDomain2(value) {
|
|
2425
|
+
if (value.length === 0 || value.length > 253 || ASCII_CONTROL_PATTERN2.test(value) || /[^\x00-\x7f]/.test(value)) {
|
|
2426
|
+
return false;
|
|
2427
|
+
}
|
|
2428
|
+
return value.split(".").every((label) => label.length <= 63 && !label.startsWith("xn--") && DNS_LABEL_PATTERN2.test(label));
|
|
2429
|
+
}
|
|
2430
|
+
function firstEnv2(env, keys, options = {}) {
|
|
2431
|
+
for (const key of keys) {
|
|
2432
|
+
const raw = env[key];
|
|
2433
|
+
const value = raw?.trim();
|
|
2434
|
+
if (value)
|
|
2435
|
+
return { key, value: options.preserveRaw ? raw : value };
|
|
2436
|
+
}
|
|
2437
|
+
return null;
|
|
2438
|
+
}
|
|
2439
|
+
function firstEnvDefinedKey2(env, keys) {
|
|
2440
|
+
for (const key of keys) {
|
|
2441
|
+
if (env[key] !== undefined)
|
|
2442
|
+
return key;
|
|
2443
|
+
}
|
|
2444
|
+
return null;
|
|
2445
|
+
}
|
|
2446
|
+
function rawAuthority2(value) {
|
|
2447
|
+
const match = /^[a-z][a-z0-9+.-]*:\/\//i.exec(value);
|
|
2448
|
+
if (!match)
|
|
2449
|
+
throw new Error("API URL must be absolute.");
|
|
2450
|
+
const afterScheme = value.slice(match[0].length);
|
|
2451
|
+
const boundary = afterScheme.search(/[/?#]/);
|
|
2452
|
+
const authority = boundary === -1 ? afterScheme : afterScheme.slice(0, boundary);
|
|
2453
|
+
if (!authority)
|
|
2454
|
+
throw new Error("API URL must include a hostname.");
|
|
2455
|
+
return authority;
|
|
2456
|
+
}
|
|
2457
|
+
function assertCanonicalPort2(port) {
|
|
2458
|
+
if (!/^[0-9]+$/.test(port) || port.length > 1 && port.startsWith("0")) {
|
|
2459
|
+
throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
|
|
2460
|
+
}
|
|
2461
|
+
const numericPort = Number(port);
|
|
2462
|
+
if (!Number.isSafeInteger(numericPort) || numericPort < 1 || numericPort > 65535) {
|
|
2463
|
+
throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
function canonicalAuthorityHostname2(authority) {
|
|
2467
|
+
let rawHostname;
|
|
2468
|
+
if (authority.startsWith("[")) {
|
|
2469
|
+
const closingBracket = authority.indexOf("]");
|
|
2470
|
+
if (closingBracket === -1) {
|
|
2471
|
+
throw new Error("API URL authority must contain a canonical hostname.");
|
|
2472
|
+
}
|
|
2473
|
+
rawHostname = authority.slice(0, closingBracket + 1);
|
|
2474
|
+
const portSuffix = authority.slice(closingBracket + 1);
|
|
2475
|
+
if (portSuffix) {
|
|
2476
|
+
if (!portSuffix.startsWith(":")) {
|
|
2477
|
+
throw new Error("API URL authority must contain a canonical hostname and port.");
|
|
2478
|
+
}
|
|
2479
|
+
assertCanonicalPort2(portSuffix.slice(1));
|
|
2480
|
+
}
|
|
2481
|
+
if (isIP2(rawHostname.slice(1, -1)) !== 6) {
|
|
2482
|
+
throw new Error("API URL authority must contain a canonical IPv6 literal.");
|
|
2483
|
+
}
|
|
2484
|
+
} else {
|
|
2485
|
+
const firstColon = authority.indexOf(":");
|
|
2486
|
+
const lastColon = authority.lastIndexOf(":");
|
|
2487
|
+
if (firstColon !== lastColon) {
|
|
2488
|
+
throw new Error("IPv6 API URL authorities must use brackets.");
|
|
2489
|
+
}
|
|
2490
|
+
if (lastColon !== -1) {
|
|
2491
|
+
const port = authority.slice(lastColon + 1);
|
|
2492
|
+
assertCanonicalPort2(port);
|
|
2493
|
+
rawHostname = authority.slice(0, lastColon);
|
|
2494
|
+
} else {
|
|
2495
|
+
rawHostname = authority;
|
|
2496
|
+
}
|
|
2497
|
+
const ipVersion = isIP2(rawHostname);
|
|
2498
|
+
const numericAddressParts = rawHostname.split(".");
|
|
2499
|
+
const looksLikeNonCanonicalIpv4 = numericAddressParts.every((part) => /^(?:0x[0-9a-f]+|[0-9]+)$/i.test(part));
|
|
2500
|
+
if (ipVersion !== 4 && looksLikeNonCanonicalIpv4 || ipVersion !== 4 && !isValidDnsDomain2(rawHostname.toLowerCase())) {
|
|
2501
|
+
throw new Error("API URL authority must contain a canonical ASCII hostname.");
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
return rawHostname.toLowerCase();
|
|
2505
|
+
}
|
|
2506
|
+
function isDeliberateLoopbackHttpAuthority2(authority) {
|
|
2507
|
+
return /^(?:localhost|127\.0\.0\.1|\[::1\])(?::[0-9]+)?$/i.test(authority);
|
|
2508
|
+
}
|
|
2509
|
+
function toV1BaseUrl2(apiUrl) {
|
|
2510
|
+
if (ASCII_CONTROL_PATTERN2.test(apiUrl)) {
|
|
2511
|
+
throw new Error("API URL must not contain ASCII control characters.");
|
|
2512
|
+
}
|
|
2513
|
+
const input = apiUrl.trim();
|
|
2514
|
+
const authority = rawAuthority2(input);
|
|
2515
|
+
if (authority.includes("@") || authority.includes("\\") || authority.includes("%") || /[^\x00-\x7f]/.test(authority)) {
|
|
2516
|
+
throw new Error("API URL authority must be canonical ASCII without credentials.");
|
|
2517
|
+
}
|
|
2518
|
+
const canonicalHostname = canonicalAuthorityHostname2(authority);
|
|
2519
|
+
const url = new URL(input);
|
|
2520
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
2521
|
+
throw new Error("API URL must use http or https.");
|
|
2522
|
+
}
|
|
2523
|
+
if (url.username || url.password) {
|
|
2524
|
+
throw new Error("API URL must not include credentials.");
|
|
2525
|
+
}
|
|
2526
|
+
if (!url.hostname || url.hostname.endsWith(".")) {
|
|
2527
|
+
throw new Error("API URL must include a canonical hostname.");
|
|
2528
|
+
}
|
|
2529
|
+
if (url.hostname.toLowerCase() !== canonicalHostname) {
|
|
2530
|
+
throw new Error("API URL authority must not rely on parser hostname normalization.");
|
|
2531
|
+
}
|
|
2532
|
+
if (url.hostname.split(".").some((label) => label.toLowerCase().startsWith("xn--"))) {
|
|
2533
|
+
throw new Error("API URL must not use IDN or punycode hostnames.");
|
|
2534
|
+
}
|
|
2535
|
+
if (url.protocol === "http:" && !isDeliberateLoopbackHttpAuthority2(authority)) {
|
|
2536
|
+
throw new Error("API URL may use http only for an exact loopback authority.");
|
|
2537
|
+
}
|
|
2538
|
+
if (url.search || url.hash) {
|
|
2539
|
+
throw new Error("API URL must not include a query string or fragment.");
|
|
2540
|
+
}
|
|
2541
|
+
let path = url.pathname.replace(/\/+$/, "");
|
|
2542
|
+
if (path.endsWith("/v1"))
|
|
2543
|
+
path = path.slice(0, -"/v1".length);
|
|
2544
|
+
url.pathname = `${path}/v1`;
|
|
2545
|
+
return url.toString().replace(/\/+$/, "");
|
|
2546
|
+
}
|
|
2547
|
+
function resolveClientTransport2(name, env = process.env, options = {}) {
|
|
2548
|
+
const keys = clientTransportEnvKeys2(name);
|
|
2549
|
+
const envUrlHit = firstEnv2(env, keys.apiUrlKeys, { preserveRaw: true });
|
|
2550
|
+
const explicitLocalKey = envUrlHit ? null : firstEnvDefinedKey2(env, keys.apiUrlKeys);
|
|
2551
|
+
const diskUrlHit = envUrlHit || explicitLocalKey ? null : appConfigDiskValue2(name, env, keys.apiUrlKeys);
|
|
2552
|
+
const urlHit = envUrlHit ?? (diskUrlHit ? { key: diskUrlHit.path, value: diskUrlHit.value } : null);
|
|
2553
|
+
const keyHit = firstEnv2(env, keys.apiKeyKeys);
|
|
2554
|
+
const warnings = [];
|
|
2555
|
+
if (!urlHit) {
|
|
2556
|
+
if (explicitLocalKey) {
|
|
2557
|
+
const overriddenPointer = appConfigDiskValue2(name, env, keys.apiUrlKeys);
|
|
2558
|
+
if (overriddenPointer) {
|
|
2559
|
+
warnings.push(`${explicitLocalKey} is defined but blank, which selects the local store. ` + `The server URL in ${overriddenPointer.path} was NOT selected: an explicit blank wins over a disk pointer.`);
|
|
2560
|
+
}
|
|
2561
|
+
return {
|
|
2562
|
+
transport: "sqlite",
|
|
2563
|
+
transportSource: explicitLocalKey,
|
|
2564
|
+
baseUrl: null,
|
|
2565
|
+
apiUrlSource: null,
|
|
2566
|
+
apiKeyPresent: Boolean(keyHit),
|
|
2567
|
+
apiKeySource: keyHit ? keyHit.key : null,
|
|
2568
|
+
apiKeyTier: null,
|
|
2569
|
+
misconfigured: false,
|
|
2570
|
+
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
2571
|
+
};
|
|
2572
|
+
}
|
|
2573
|
+
return {
|
|
2574
|
+
transport: "sqlite",
|
|
2575
|
+
transportSource: "default",
|
|
2576
|
+
baseUrl: null,
|
|
2577
|
+
apiUrlSource: null,
|
|
2578
|
+
apiKeyPresent: Boolean(keyHit),
|
|
2579
|
+
apiKeySource: keyHit ? keyHit.key : null,
|
|
2580
|
+
apiKeyTier: null,
|
|
2581
|
+
misconfigured: false,
|
|
2582
|
+
warning: null
|
|
2583
|
+
};
|
|
2584
|
+
}
|
|
2585
|
+
if (diskUrlHit) {
|
|
2586
|
+
warnings.push(`No ${keys.apiUrlKeys[0]} in the environment; the server URL in ${diskUrlHit.path} was used, so this client connects to the server. ` + `Unset the pointer or remove the file to stay on the local store.`);
|
|
2587
|
+
}
|
|
2588
|
+
const credential = resolveCredential2(name, env, options.credentials);
|
|
2589
|
+
if (!credential) {
|
|
2590
|
+
const diskHint = credentialDiskSourcesForMessage2(name, env);
|
|
2591
|
+
warnings.push(`${urlHit.key} selects the HTTP server for '${name}', but no API key could be resolved; ` + `refusing to route and leaving the local sqlite store selected. ` + `Looked for a credential file at ${diskHint}, then for ${keys.apiKeyKeys[0]} in the environment.`);
|
|
2592
|
+
return {
|
|
2593
|
+
transport: "sqlite",
|
|
2594
|
+
transportSource: urlHit.key,
|
|
2595
|
+
baseUrl: null,
|
|
2596
|
+
apiUrlSource: urlHit.key,
|
|
2597
|
+
apiKeyPresent: false,
|
|
2598
|
+
apiKeySource: null,
|
|
2599
|
+
apiKeyTier: null,
|
|
2600
|
+
misconfigured: true,
|
|
2601
|
+
warning: warnings.join(" ")
|
|
2602
|
+
};
|
|
2603
|
+
}
|
|
2604
|
+
if (credential.warning)
|
|
2605
|
+
warnings.push(credential.warning);
|
|
2606
|
+
const apiUrlSource = urlHit.key;
|
|
2607
|
+
let baseUrl;
|
|
2608
|
+
try {
|
|
2609
|
+
baseUrl = toV1BaseUrl2(urlHit.value);
|
|
2610
|
+
} catch (error) {
|
|
2611
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2612
|
+
warnings.push(`Invalid API URL from ${apiUrlSource}: ${message}. Using local store.`);
|
|
2613
|
+
return {
|
|
2614
|
+
transport: "sqlite",
|
|
2615
|
+
transportSource: urlHit.key,
|
|
2616
|
+
baseUrl: null,
|
|
2617
|
+
apiUrlSource: urlHit.key,
|
|
2618
|
+
apiKeyPresent: true,
|
|
2619
|
+
apiKeySource: credential.source,
|
|
2620
|
+
apiKeyTier: credential.tier,
|
|
2621
|
+
misconfigured: true,
|
|
2622
|
+
warning: warnings.join(" ")
|
|
2623
|
+
};
|
|
2624
|
+
}
|
|
2625
|
+
return {
|
|
2626
|
+
transport: "http",
|
|
2627
|
+
transportSource: urlHit.key,
|
|
2628
|
+
baseUrl,
|
|
2629
|
+
apiUrlSource,
|
|
2630
|
+
apiKeyPresent: true,
|
|
2631
|
+
apiKeySource: credential.source,
|
|
2632
|
+
apiKeyTier: credential.tier,
|
|
2633
|
+
misconfigured: false,
|
|
2634
|
+
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
2635
|
+
};
|
|
2636
|
+
}
|
|
2637
|
+
function credentialDiskSourcesForMessage2(name, env) {
|
|
2638
|
+
const paths = credentialDiskSources2(name, env);
|
|
2639
|
+
return paths.length > 0 ? paths.join(" or ") : "<no HOME set in this environment, so no credential file was consulted>";
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
class HasnaHttpError2 extends Error {
|
|
2643
|
+
status;
|
|
2644
|
+
method;
|
|
2645
|
+
path;
|
|
2646
|
+
body;
|
|
2647
|
+
credentialSource;
|
|
2648
|
+
credentialTier;
|
|
2649
|
+
constructor(method, path, status, body, credential) {
|
|
2650
|
+
const guidance = credential ? `. ${credential.guidance}` : "";
|
|
2651
|
+
super(`Hasna cloud request failed: ${method} ${path} -> ${status}${guidance}`);
|
|
2652
|
+
this.name = "HasnaHttpError";
|
|
2653
|
+
this.status = status;
|
|
2654
|
+
this.method = method;
|
|
2655
|
+
this.path = path;
|
|
2656
|
+
this.body = body;
|
|
2657
|
+
this.credentialSource = credential?.source ?? null;
|
|
2658
|
+
this.credentialTier = credential?.tier ?? null;
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2661
|
+
function currentCredential2(name, apiKey) {
|
|
2662
|
+
if (typeof apiKey === "function") {
|
|
2663
|
+
return validateAndSealResolvedCredential2(name, apiKey());
|
|
2664
|
+
}
|
|
2665
|
+
return explicitCredential2(name, apiKey);
|
|
2666
|
+
}
|
|
2667
|
+
function authFailureGuidance2(credential) {
|
|
2668
|
+
const origin = `The API key for this request came from ${credential.source}`;
|
|
2669
|
+
if (credential.deliberate) {
|
|
2670
|
+
const remedy = credential.source === CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE2 ? `Fix that provider so it returns the current key, or replace it with resolveCredential() ` + `so diagnostics can name the original source.` : `Rotate that key, or unset the override to use the credential on disk.`;
|
|
2671
|
+
return `${origin} \u2014 a credential you selected deliberately. It was NOT substituted with any other key: ` + `falling back here would authenticate as a different principal than the one you named, which is ` + `exactly the failure an override exists to prevent. ${remedy}`;
|
|
2672
|
+
}
|
|
2673
|
+
if (credential.deprecated) {
|
|
2674
|
+
const target = credential.diskCandidates[0];
|
|
2675
|
+
const remedy = target ? `Write the CURRENT key to ${target} \u2014 that file is re-read on every call, so rotations take ` + `effect immediately and in every shell. Do not simply unset ${credential.source}: nothing was ` + `found on disk, so that would leave this client with no credential at all.` : `This environment has no HOME, so no credential file could be consulted; the disk tier is ` + `unavailable here and there is nothing to fall back to. Set HOME, or supply the key explicitly.`;
|
|
2676
|
+
return `${origin}, a variable in this process's environment \u2014 which is a snapshot taken when the process ` + `started. A STALE SHELL is the most common cause of this error: this shell exported the key before ` + `it was rotated, and will keep sending the old one until it exits. ${remedy}`;
|
|
2677
|
+
}
|
|
2678
|
+
return `${origin}, which was re-read from disk on this very call \u2014 so a stale shell is NOT the cause here. ` + `The stored credential is genuinely being rejected: rotate it, or re-run the fleet key distribution ` + `so this machine gets the current key.`;
|
|
2679
|
+
}
|
|
2680
|
+
var DEFAULT_RETRY_STATUSES2 = [408, 425, 429, 500, 502, 503, 504];
|
|
2681
|
+
var IDEMPOTENT_METHODS2 = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
2682
|
+
var AUTHORITY_OVERRIDE_HEADERS2 = new Set([
|
|
2683
|
+
"host",
|
|
2684
|
+
":authority",
|
|
2685
|
+
"forwarded",
|
|
2686
|
+
"x-forwarded-host",
|
|
2687
|
+
"x-original-host"
|
|
2688
|
+
]);
|
|
2689
|
+
function assertNoAuthorityOverrideHeaders2(headers, source) {
|
|
2690
|
+
if (!headers)
|
|
2691
|
+
return;
|
|
2692
|
+
const forbidden = Object.keys(headers).find((name) => AUTHORITY_OVERRIDE_HEADERS2.has(name.trim().toLowerCase()));
|
|
2693
|
+
if (forbidden) {
|
|
2694
|
+
throw new Error(`Authenticated ${source} headers must not set authority header '${forbidden}'.`);
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
function appendQuery2(path, query) {
|
|
2698
|
+
if (!query)
|
|
2699
|
+
return path;
|
|
2700
|
+
const params = query instanceof URLSearchParams ? query : new URLSearchParams;
|
|
2701
|
+
if (!(query instanceof URLSearchParams)) {
|
|
2702
|
+
for (const [key, value] of Object.entries(query)) {
|
|
2703
|
+
if (value === null || value === undefined)
|
|
2704
|
+
continue;
|
|
2705
|
+
if (Array.isArray(value)) {
|
|
2706
|
+
for (const v of value)
|
|
2707
|
+
params.append(key, String(v));
|
|
2708
|
+
} else {
|
|
2709
|
+
params.append(key, String(value));
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
const qs = params.toString();
|
|
2714
|
+
if (!qs)
|
|
2715
|
+
return path;
|
|
2716
|
+
return `${path}${path.includes("?") ? "&" : "?"}${qs}`;
|
|
2717
|
+
}
|
|
2718
|
+
var defaultSleep2 = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
2719
|
+
function createHasnaHttpTransport2(options) {
|
|
2720
|
+
const fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
2721
|
+
const base = toV1BaseUrl2(options.baseUrl);
|
|
2722
|
+
const timeoutMs = options.timeoutMs ?? 30000;
|
|
2723
|
+
const sleep = options.sleepImpl ?? defaultSleep2;
|
|
2724
|
+
const defaultRetry = options.retry;
|
|
2725
|
+
function resolveRetry(callRetry) {
|
|
2726
|
+
const chosen = callRetry !== undefined ? callRetry : defaultRetry;
|
|
2727
|
+
if (chosen === false)
|
|
2728
|
+
return null;
|
|
2729
|
+
const r = chosen ?? {};
|
|
2730
|
+
return {
|
|
2731
|
+
retries: r.retries ?? 2,
|
|
2732
|
+
baseDelayMs: r.baseDelayMs ?? 200,
|
|
2733
|
+
maxDelayMs: r.maxDelayMs ?? 2000,
|
|
2734
|
+
retryStatuses: r.retryStatuses ?? [...DEFAULT_RETRY_STATUSES2]
|
|
2735
|
+
};
|
|
2736
|
+
}
|
|
2737
|
+
async function once(method, rel, url, body, opts, credential) {
|
|
2738
|
+
assertNoAuthorityOverrideHeaders2(options.headers, "transport");
|
|
2739
|
+
assertNoAuthorityOverrideHeaders2(opts.headers, "request");
|
|
2740
|
+
const headers = {
|
|
2741
|
+
"x-api-key": credential.apiKey,
|
|
2742
|
+
Authorization: `Bearer ${credential.apiKey}`,
|
|
2743
|
+
Accept: "application/json",
|
|
2744
|
+
...options.headers ?? {},
|
|
2745
|
+
...opts.headers ?? {}
|
|
2746
|
+
};
|
|
2747
|
+
if (opts.idempotencyKey)
|
|
2748
|
+
headers["Idempotency-Key"] = opts.idempotencyKey;
|
|
2749
|
+
const init = {
|
|
2750
|
+
method,
|
|
2751
|
+
headers,
|
|
2752
|
+
redirect: "manual"
|
|
2753
|
+
};
|
|
2754
|
+
if (body !== undefined) {
|
|
2755
|
+
headers["Content-Type"] = "application/json";
|
|
2756
|
+
init.body = JSON.stringify(body);
|
|
2757
|
+
}
|
|
2758
|
+
const controller = new AbortController;
|
|
2759
|
+
const onAbort = () => controller.abort();
|
|
2760
|
+
if (opts.signal) {
|
|
2761
|
+
if (opts.signal.aborted)
|
|
2762
|
+
controller.abort();
|
|
2763
|
+
else
|
|
2764
|
+
opts.signal.addEventListener("abort", onAbort, { once: true });
|
|
2765
|
+
}
|
|
2766
|
+
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? timeoutMs);
|
|
2767
|
+
init.signal = controller.signal;
|
|
2768
|
+
let response;
|
|
2769
|
+
try {
|
|
2770
|
+
response = await fetchImpl(url, init);
|
|
2771
|
+
} catch (error) {
|
|
2772
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
2773
|
+
if (opts.signal?.aborted)
|
|
2774
|
+
return { ok: false, retryable: false, error: err };
|
|
2775
|
+
return { ok: false, retryable: true, error: err };
|
|
2776
|
+
} finally {
|
|
2777
|
+
clearTimeout(timer);
|
|
2778
|
+
if (opts.signal)
|
|
2779
|
+
opts.signal.removeEventListener("abort", onAbort);
|
|
2780
|
+
}
|
|
2781
|
+
const text = await response.text();
|
|
2782
|
+
let parsed = undefined;
|
|
2783
|
+
if (text.length > 0) {
|
|
2784
|
+
try {
|
|
2785
|
+
parsed = JSON.parse(text);
|
|
2786
|
+
} catch {
|
|
2787
|
+
parsed = text;
|
|
2788
|
+
}
|
|
2789
|
+
}
|
|
2790
|
+
if (!response.ok) {
|
|
2791
|
+
if (response.status >= 300 && response.status < 400) {
|
|
2792
|
+
return {
|
|
2793
|
+
ok: false,
|
|
2794
|
+
retryable: false,
|
|
2795
|
+
error: new HasnaHttpError2(method, rel, response.status, parsed)
|
|
2796
|
+
};
|
|
2797
|
+
}
|
|
2798
|
+
if (response.status === 401 || response.status === 403) {
|
|
2799
|
+
return {
|
|
2800
|
+
ok: false,
|
|
2801
|
+
retryable: false,
|
|
2802
|
+
error: new HasnaHttpError2(method, rel, response.status, parsed, {
|
|
2803
|
+
source: credential.source,
|
|
2804
|
+
tier: credential.tier,
|
|
2805
|
+
guidance: authFailureGuidance2(credential)
|
|
2806
|
+
})
|
|
2807
|
+
};
|
|
2808
|
+
}
|
|
2809
|
+
const retry = resolveRetry(opts.retry);
|
|
2810
|
+
const retryable = retry ? retry.retryStatuses.includes(response.status) : false;
|
|
2811
|
+
return { ok: false, retryable, error: new HasnaHttpError2(method, rel, response.status, parsed) };
|
|
2812
|
+
}
|
|
2813
|
+
return { ok: true, value: parsed };
|
|
2814
|
+
}
|
|
2815
|
+
async function request(method, path, body, opts = {}) {
|
|
2816
|
+
const upper = method.toUpperCase();
|
|
2817
|
+
const rel = appendQuery2(path.startsWith("/") ? path : `/${path}`, opts.query);
|
|
2818
|
+
const url = `${base}${rel}`;
|
|
2819
|
+
const retry = resolveRetry(opts.retry);
|
|
2820
|
+
const methodRetryable = IDEMPOTENT_METHODS2.has(upper) || Boolean(opts.idempotencyKey);
|
|
2821
|
+
const maxAttempts = retry && methodRetryable ? retry.retries + 1 : 1;
|
|
2822
|
+
const credential = currentCredential2(options.name, options.apiKey);
|
|
2823
|
+
let last = null;
|
|
2824
|
+
for (let attempt = 1;attempt <= maxAttempts; attempt++) {
|
|
2825
|
+
const result = await once(upper, rel, url, body, opts, credential);
|
|
2826
|
+
if (result.ok)
|
|
2827
|
+
return result.value;
|
|
2828
|
+
last = result;
|
|
2829
|
+
const canRetry = retry !== null && methodRetryable && result.retryable && attempt < maxAttempts;
|
|
2830
|
+
if (!canRetry)
|
|
2831
|
+
break;
|
|
2832
|
+
const backoff = Math.min(retry.maxDelayMs, retry.baseDelayMs * 2 ** (attempt - 1));
|
|
2833
|
+
const jitter = Math.floor(Math.random() * (backoff / 2 + 1));
|
|
2834
|
+
await sleep(backoff + jitter);
|
|
2835
|
+
}
|
|
2836
|
+
throw last.error;
|
|
2837
|
+
}
|
|
2838
|
+
return {
|
|
2839
|
+
baseUrl: base,
|
|
2840
|
+
request,
|
|
2841
|
+
get: (path, opts) => request("GET", path, undefined, opts),
|
|
2842
|
+
post: (path, body, opts) => request("POST", path, body, opts),
|
|
2843
|
+
put: (path, body, opts) => request("PUT", path, body, opts),
|
|
2844
|
+
patch: (path, body, opts) => request("PATCH", path, body, opts),
|
|
2845
|
+
del: (path, body, opts) => request("DELETE", path, body, opts)
|
|
2846
|
+
};
|
|
2847
|
+
}
|
|
2848
|
+
function createClientTransport2(name, env = process.env, overrides) {
|
|
2849
|
+
const credentialOptions = overrides?.credentials;
|
|
2850
|
+
const resolution = resolveClientTransport2(name, env, { ...credentialOptions ? { credentials: credentialOptions } : {} });
|
|
2851
|
+
if (resolution.misconfigured) {
|
|
2852
|
+
throw new Error(resolution.warning ?? `Client for '${name}' is misconfigured for the API client.`);
|
|
2853
|
+
}
|
|
2854
|
+
if (resolution.transport === "sqlite" || !resolution.baseUrl) {
|
|
2855
|
+
return { transport: "sqlite", client: null, resolution };
|
|
2856
|
+
}
|
|
2857
|
+
const credentialProvider = () => {
|
|
2858
|
+
const resolved = resolveCredential2(name, env, credentialOptions);
|
|
2859
|
+
if (!resolved) {
|
|
2860
|
+
throw new Error(`Client for '${name}' resolved to the http transport but no API key is available any more. ` + `Looked at ${credentialDiskSourcesForMessage2(name, env)}, then the environment. ` + `A credential file that was removed after this client was built is the usual cause.`);
|
|
2861
|
+
}
|
|
2862
|
+
return resolved;
|
|
2863
|
+
};
|
|
2864
|
+
return {
|
|
2865
|
+
transport: "http",
|
|
2866
|
+
client: createHasnaHttpTransport2({
|
|
2867
|
+
name,
|
|
2868
|
+
baseUrl: resolution.baseUrl,
|
|
2869
|
+
apiKey: credentialProvider,
|
|
2870
|
+
...overrides?.fetchImpl ? { fetchImpl: overrides.fetchImpl } : {},
|
|
2871
|
+
...overrides?.headers ? { headers: overrides.headers } : {},
|
|
2872
|
+
...overrides?.timeoutMs ? { timeoutMs: overrides.timeoutMs } : {},
|
|
2873
|
+
...overrides?.retry !== undefined ? { retry: overrides.retry } : {},
|
|
2874
|
+
...overrides?.sleepImpl ? { sleepImpl: overrides.sleepImpl } : {}
|
|
2875
|
+
}),
|
|
2876
|
+
resolution
|
|
2877
|
+
};
|
|
2878
|
+
}
|
|
2879
|
+
function resourcePath(resource) {
|
|
2880
|
+
const trimmed = resource.replace(/^\/+|\/+$/g, "");
|
|
2881
|
+
if (!trimmed)
|
|
2882
|
+
throw new Error("resource must be a non-empty path segment");
|
|
2883
|
+
return `/${trimmed}`;
|
|
2884
|
+
}
|
|
2885
|
+
function entityPath(resource, id) {
|
|
2886
|
+
if (id === undefined || id === null || `${id}`.length === 0) {
|
|
2887
|
+
throw new Error("id must be a non-empty string");
|
|
2888
|
+
}
|
|
2889
|
+
return `${resourcePath(resource)}/${encodeURIComponent(String(id))}`;
|
|
2890
|
+
}
|
|
2891
|
+
function newIdempotencyKey() {
|
|
2892
|
+
const g = globalThis;
|
|
2893
|
+
if (g.crypto?.randomUUID)
|
|
2894
|
+
return g.crypto.randomUUID();
|
|
2895
|
+
return `idmp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
|
|
2896
|
+
}
|
|
2897
|
+
function extractItems(raw) {
|
|
2898
|
+
if (Array.isArray(raw))
|
|
2899
|
+
return raw;
|
|
2900
|
+
if (raw && typeof raw === "object") {
|
|
2901
|
+
const obj = raw;
|
|
2902
|
+
for (const key of ["items", "data", "results", "rows", "records"]) {
|
|
2903
|
+
if (Array.isArray(obj[key]))
|
|
2904
|
+
return obj[key];
|
|
2905
|
+
}
|
|
2906
|
+
}
|
|
2907
|
+
return [];
|
|
2908
|
+
}
|
|
2909
|
+
function extractTotal(raw) {
|
|
2910
|
+
if (raw && typeof raw === "object") {
|
|
2911
|
+
const obj = raw;
|
|
2912
|
+
for (const key of ["total", "count", "totalCount", "total_count"]) {
|
|
2913
|
+
if (typeof obj[key] === "number")
|
|
2914
|
+
return obj[key];
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
return null;
|
|
2918
|
+
}
|
|
2919
|
+
function extractCursor(raw) {
|
|
2920
|
+
if (raw && typeof raw === "object") {
|
|
2921
|
+
const obj = raw;
|
|
2922
|
+
for (const key of ["cursor", "nextCursor", "next_cursor", "next"]) {
|
|
2923
|
+
if (typeof obj[key] === "string")
|
|
2924
|
+
return obj[key];
|
|
2925
|
+
}
|
|
2926
|
+
}
|
|
2927
|
+
return null;
|
|
2928
|
+
}
|
|
2929
|
+
function isNotFoundHttpError(error) {
|
|
2930
|
+
return typeof error === "object" && error !== null && error.name === "HasnaHttpError" && error.status === 404;
|
|
2931
|
+
}
|
|
2932
|
+
function createHasnaStorageClient(name, transport) {
|
|
2933
|
+
return {
|
|
2934
|
+
name,
|
|
2935
|
+
baseUrl: transport.baseUrl,
|
|
2936
|
+
transport,
|
|
2937
|
+
async list(resource, options = {}) {
|
|
2938
|
+
const raw = await transport.get(resourcePath(resource), options);
|
|
2939
|
+
return {
|
|
2940
|
+
items: extractItems(raw),
|
|
2941
|
+
total: extractTotal(raw),
|
|
2942
|
+
cursor: extractCursor(raw),
|
|
2943
|
+
raw
|
|
2944
|
+
};
|
|
2945
|
+
},
|
|
2946
|
+
async get(resource, id, options = {}) {
|
|
2947
|
+
try {
|
|
2948
|
+
return await transport.get(entityPath(resource, id), options);
|
|
2949
|
+
} catch (error) {
|
|
2950
|
+
if (isNotFoundHttpError(error))
|
|
2951
|
+
return null;
|
|
2952
|
+
throw error;
|
|
2953
|
+
}
|
|
2954
|
+
},
|
|
2955
|
+
async create(resource, body, options = {}) {
|
|
2956
|
+
const { idempotencyKey, ...rest } = options;
|
|
2957
|
+
return transport.post(resourcePath(resource), body, {
|
|
2958
|
+
...rest,
|
|
2959
|
+
idempotencyKey: idempotencyKey ?? newIdempotencyKey()
|
|
2960
|
+
});
|
|
2961
|
+
},
|
|
2962
|
+
async update(resource, id, patch, options = {}) {
|
|
2963
|
+
const { method = "PATCH", idempotencyKey, ...rest } = options;
|
|
2964
|
+
const call = method === "PUT" ? transport.put : transport.patch;
|
|
2965
|
+
return call(entityPath(resource, id), patch, { ...rest, ...idempotencyKey ? { idempotencyKey } : {} });
|
|
2966
|
+
},
|
|
2967
|
+
async delete(resource, id, options = {}) {
|
|
2968
|
+
try {
|
|
2969
|
+
await transport.del(entityPath(resource, id), undefined, options);
|
|
2970
|
+
} catch (error) {
|
|
2971
|
+
if (isNotFoundHttpError(error))
|
|
2972
|
+
return;
|
|
2973
|
+
throw error;
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
};
|
|
2977
|
+
}
|
|
2978
|
+
function resolveStorageClient(name, env = process.env, overrides) {
|
|
2979
|
+
const wired = createClientTransport2(name, env, overrides);
|
|
2980
|
+
if (wired.transport === "http") {
|
|
2981
|
+
return { transport: "http", client: createHasnaStorageClient(name, wired.client) };
|
|
2982
|
+
}
|
|
2983
|
+
return { transport: "sqlite", client: null };
|
|
2984
|
+
}
|
|
2985
|
+
|
|
1310
2986
|
// src/http/client.ts
|
|
1311
|
-
function
|
|
2987
|
+
function envToken3(name) {
|
|
1312
2988
|
return name.toUpperCase().replace(/-/g, "_");
|
|
1313
2989
|
}
|
|
1314
2990
|
function envKeys(name) {
|
|
1315
|
-
const token =
|
|
2991
|
+
const token = envToken3(name);
|
|
1316
2992
|
return {
|
|
1317
2993
|
storeKeys: [`HASNA_${token}_CLIENT_STORE`, `${token}_CLIENT_STORE`],
|
|
1318
2994
|
apiUrlKeys: [`HASNA_${token}_API_URL`],
|
|
@@ -1327,7 +3003,7 @@ function normalizeClientStore(value) {
|
|
|
1327
3003
|
return "http";
|
|
1328
3004
|
throw new Error(`Unknown client store: ${value}. Use sqlite or http.`);
|
|
1329
3005
|
}
|
|
1330
|
-
function
|
|
3006
|
+
function firstEnv3(env, keys) {
|
|
1331
3007
|
for (const key of keys) {
|
|
1332
3008
|
const value = env[key]?.trim();
|
|
1333
3009
|
if (value)
|
|
@@ -1335,7 +3011,7 @@ function firstEnv(env, keys) {
|
|
|
1335
3011
|
}
|
|
1336
3012
|
return null;
|
|
1337
3013
|
}
|
|
1338
|
-
function
|
|
3014
|
+
function toV1BaseUrl3(apiUrl) {
|
|
1339
3015
|
const url = new URL(apiUrl);
|
|
1340
3016
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
1341
3017
|
throw new Error("API URL must use http or https.");
|
|
@@ -1350,9 +3026,9 @@ function toV1BaseUrl(apiUrl) {
|
|
|
1350
3026
|
}
|
|
1351
3027
|
function resolveTransport(name, env = process.env) {
|
|
1352
3028
|
const keys = envKeys(name);
|
|
1353
|
-
const storeHit =
|
|
1354
|
-
const urlHit =
|
|
1355
|
-
const keyHit =
|
|
3029
|
+
const storeHit = firstEnv3(env, keys.storeKeys);
|
|
3030
|
+
const urlHit = firstEnv3(env, keys.apiUrlKeys);
|
|
3031
|
+
const keyHit = firstEnv3(env, keys.apiKeyKeys);
|
|
1356
3032
|
let requested = "sqlite";
|
|
1357
3033
|
let modeSource = "default";
|
|
1358
3034
|
if (storeHit) {
|
|
@@ -1402,7 +3078,7 @@ function resolveTransport(name, env = process.env) {
|
|
|
1402
3078
|
const rawUrl = urlHit.value;
|
|
1403
3079
|
let baseUrl;
|
|
1404
3080
|
try {
|
|
1405
|
-
baseUrl =
|
|
3081
|
+
baseUrl = toV1BaseUrl3(rawUrl);
|
|
1406
3082
|
} catch (error) {
|
|
1407
3083
|
const message = error instanceof Error ? error.message : String(error);
|
|
1408
3084
|
return { transport: "sqlite", requested, modeSource, baseUrl: null, apiKeyPresent: true, misconfigured: true, warning: `Invalid API URL: ${message}.` };
|
|
@@ -1410,7 +3086,7 @@ function resolveTransport(name, env = process.env) {
|
|
|
1410
3086
|
return { transport: "http", requested, modeSource, baseUrl, apiKeyPresent: true, misconfigured: false, warning: null };
|
|
1411
3087
|
}
|
|
1412
3088
|
|
|
1413
|
-
class
|
|
3089
|
+
class HasnaHttpError3 extends Error {
|
|
1414
3090
|
status;
|
|
1415
3091
|
method;
|
|
1416
3092
|
path;
|
|
@@ -1424,7 +3100,7 @@ class HasnaHttpError extends Error {
|
|
|
1424
3100
|
this.body = body;
|
|
1425
3101
|
}
|
|
1426
3102
|
}
|
|
1427
|
-
function
|
|
3103
|
+
function appendQuery3(path, query) {
|
|
1428
3104
|
if (!query)
|
|
1429
3105
|
return path;
|
|
1430
3106
|
const params = new URLSearchParams;
|
|
@@ -1442,12 +3118,12 @@ function appendQuery(path, query) {
|
|
|
1442
3118
|
}
|
|
1443
3119
|
var RETRY_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
1444
3120
|
var IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
1445
|
-
var
|
|
3121
|
+
var defaultSleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1446
3122
|
function createHttpTransport(options) {
|
|
1447
3123
|
const fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
1448
3124
|
const base = options.baseUrl.replace(/\/+$/, "");
|
|
1449
3125
|
const timeoutMs = options.timeoutMs ?? 30000;
|
|
1450
|
-
const sleep = options.sleepImpl ??
|
|
3126
|
+
const sleep = options.sleepImpl ?? defaultSleep3;
|
|
1451
3127
|
async function once(method, rel, url, body, opts) {
|
|
1452
3128
|
const headers = {
|
|
1453
3129
|
"x-api-key": options.apiKey,
|
|
@@ -1495,13 +3171,13 @@ function createHttpTransport(options) {
|
|
|
1495
3171
|
}
|
|
1496
3172
|
}
|
|
1497
3173
|
if (!response.ok) {
|
|
1498
|
-
return { ok: false, retryable: RETRY_STATUSES.has(response.status), error: new
|
|
3174
|
+
return { ok: false, retryable: RETRY_STATUSES.has(response.status), error: new HasnaHttpError3(method, rel, response.status, parsed) };
|
|
1499
3175
|
}
|
|
1500
3176
|
return { ok: true, value: parsed };
|
|
1501
3177
|
}
|
|
1502
3178
|
async function request(method, path, body, opts = {}) {
|
|
1503
3179
|
const upper = method.toUpperCase();
|
|
1504
|
-
const rel =
|
|
3180
|
+
const rel = appendQuery3(path.startsWith("/") ? path : `/${path}`, opts.query);
|
|
1505
3181
|
const url = `${base}${rel}`;
|
|
1506
3182
|
const methodRetryable = IDEMPOTENT.has(upper) || Boolean(opts.idempotencyKey);
|
|
1507
3183
|
const maxRetries = opts.retries ?? 2;
|
|
@@ -1533,13 +3209,13 @@ function createHttpTransport(options) {
|
|
|
1533
3209
|
del: (path, body, opts) => request("DELETE", path, body, opts)
|
|
1534
3210
|
};
|
|
1535
3211
|
}
|
|
1536
|
-
function
|
|
3212
|
+
function newIdempotencyKey2() {
|
|
1537
3213
|
const g = globalThis;
|
|
1538
3214
|
if (g.crypto?.randomUUID)
|
|
1539
3215
|
return g.crypto.randomUUID();
|
|
1540
3216
|
return `idmp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
|
|
1541
3217
|
}
|
|
1542
|
-
function
|
|
3218
|
+
function extractItems2(raw, extraKeys = []) {
|
|
1543
3219
|
if (Array.isArray(raw))
|
|
1544
3220
|
return raw;
|
|
1545
3221
|
if (raw && typeof raw === "object") {
|
|
@@ -1560,19 +3236,19 @@ function createStorageClient(name, transport) {
|
|
|
1560
3236
|
transport,
|
|
1561
3237
|
async list(resource, query) {
|
|
1562
3238
|
const raw = await transport.get(rp(resource), { query });
|
|
1563
|
-
return { items:
|
|
3239
|
+
return { items: extractItems2(raw, [resource]), raw };
|
|
1564
3240
|
},
|
|
1565
3241
|
async get(resource, id) {
|
|
1566
3242
|
try {
|
|
1567
3243
|
return await transport.get(ep(resource, id));
|
|
1568
3244
|
} catch (error) {
|
|
1569
|
-
if (error instanceof
|
|
3245
|
+
if (error instanceof HasnaHttpError3 && error.status === 404)
|
|
1570
3246
|
return null;
|
|
1571
3247
|
throw error;
|
|
1572
3248
|
}
|
|
1573
3249
|
},
|
|
1574
3250
|
async create(resource, body, idempotencyKey) {
|
|
1575
|
-
return transport.post(rp(resource), body, { idempotencyKey: idempotencyKey ??
|
|
3251
|
+
return transport.post(rp(resource), body, { idempotencyKey: idempotencyKey ?? newIdempotencyKey2() });
|
|
1576
3252
|
},
|
|
1577
3253
|
async update(resource, id, patch, method = "PATCH") {
|
|
1578
3254
|
const call = method === "PUT" ? transport.put : transport.patch;
|
|
@@ -1582,27 +3258,42 @@ function createStorageClient(name, transport) {
|
|
|
1582
3258
|
try {
|
|
1583
3259
|
await transport.del(ep(resource, id));
|
|
1584
3260
|
} catch (error) {
|
|
1585
|
-
if (error instanceof
|
|
3261
|
+
if (error instanceof HasnaHttpError3 && error.status === 404)
|
|
1586
3262
|
return;
|
|
1587
3263
|
throw error;
|
|
1588
3264
|
}
|
|
1589
3265
|
}
|
|
1590
3266
|
};
|
|
1591
3267
|
}
|
|
1592
|
-
function
|
|
3268
|
+
function resolveStoreClient(name, env = process.env) {
|
|
1593
3269
|
const resolution = resolveTransport(name, env);
|
|
1594
3270
|
if (resolution.misconfigured) {
|
|
3271
|
+
const wired2 = createClientTransport(name, env);
|
|
3272
|
+
if (wired2.transport === "http") {
|
|
3273
|
+
return {
|
|
3274
|
+
transport: "http",
|
|
3275
|
+
client: createHasnaStorageClient(name, wired2.client),
|
|
3276
|
+
resolution: {
|
|
3277
|
+
transport: "http",
|
|
3278
|
+
requested: "http",
|
|
3279
|
+
modeSource: resolution.modeSource === "default" ? "auto:api-url+seam-credential" : resolution.modeSource,
|
|
3280
|
+
baseUrl: wired2.resolution.baseUrl,
|
|
3281
|
+
apiKeyPresent: true,
|
|
3282
|
+
misconfigured: false,
|
|
3283
|
+
warning: null
|
|
3284
|
+
}
|
|
3285
|
+
};
|
|
3286
|
+
}
|
|
1595
3287
|
throw new Error(resolution.warning ?? `Client for '${name}' is misconfigured for the /v1 API.`);
|
|
1596
3288
|
}
|
|
1597
3289
|
if (resolution.transport === "sqlite" || !resolution.baseUrl) {
|
|
1598
3290
|
return { transport: "sqlite", client: null, resolution };
|
|
1599
3291
|
}
|
|
1600
|
-
const
|
|
1601
|
-
|
|
1602
|
-
if (!apiKey)
|
|
3292
|
+
const wired = createClientTransport(name, env);
|
|
3293
|
+
if (wired.transport !== "http") {
|
|
1603
3294
|
throw new Error(`Client for '${name}' resolved to the /v1 API without an API key.`);
|
|
1604
|
-
|
|
1605
|
-
return { transport: "http", client:
|
|
3295
|
+
}
|
|
3296
|
+
return { transport: "http", client: createHasnaStorageClient(name, wired.client), resolution };
|
|
1606
3297
|
}
|
|
1607
3298
|
|
|
1608
3299
|
// src/store.ts
|
|
@@ -1685,6 +3376,22 @@ var localStore = {
|
|
|
1685
3376
|
await withLocalStoreReaderLease(() => saveFeedback(input));
|
|
1686
3377
|
}
|
|
1687
3378
|
};
|
|
3379
|
+
async function listResource(client, resource, query) {
|
|
3380
|
+
const raw = await client.transport.get(`/${resource}`, query ? { query } : undefined);
|
|
3381
|
+
return { items: extractEnvelopeItems(raw, resource), raw };
|
|
3382
|
+
}
|
|
3383
|
+
function extractEnvelopeItems(raw, resource) {
|
|
3384
|
+
if (Array.isArray(raw))
|
|
3385
|
+
return raw;
|
|
3386
|
+
if (raw && typeof raw === "object") {
|
|
3387
|
+
const obj = raw;
|
|
3388
|
+
for (const key of [resource, "items", "data", "results", "rows", "records"]) {
|
|
3389
|
+
if (Array.isArray(obj[key]))
|
|
3390
|
+
return obj[key];
|
|
3391
|
+
}
|
|
3392
|
+
}
|
|
3393
|
+
return [];
|
|
3394
|
+
}
|
|
1688
3395
|
function apiStore(client) {
|
|
1689
3396
|
return {
|
|
1690
3397
|
mode: "http",
|
|
@@ -1692,7 +3399,7 @@ function apiStore(client) {
|
|
|
1692
3399
|
async createRecording(input, idempotencyKey) {
|
|
1693
3400
|
const keyCandidate = idempotencyKey === undefined && (input.id === undefined || input.id === null) ? randomUUID2() : idempotencyKey;
|
|
1694
3401
|
const identity = recordingCreateIdentity(input, keyCandidate, { bindIdempotencyKeyToId: false });
|
|
1695
|
-
const res = await client.create("recordings", identity.input, identity.idempotencyKey);
|
|
3402
|
+
const res = await client.create("recordings", identity.input, { idempotencyKey: identity.idempotencyKey });
|
|
1696
3403
|
return unwrap(res, "recording");
|
|
1697
3404
|
},
|
|
1698
3405
|
async getRecording(id) {
|
|
@@ -1700,7 +3407,7 @@ function apiStore(client) {
|
|
|
1700
3407
|
return res ? unwrap(res, "recording") : null;
|
|
1701
3408
|
},
|
|
1702
3409
|
async listRecordings(filter) {
|
|
1703
|
-
const { items } = await client
|
|
3410
|
+
const { items } = await listResource(client, "recordings", listQuery(filter));
|
|
1704
3411
|
return items;
|
|
1705
3412
|
},
|
|
1706
3413
|
async countRecordings(filter) {
|
|
@@ -1711,7 +3418,7 @@ function apiStore(client) {
|
|
|
1711
3418
|
const seenPageKeys = new Set;
|
|
1712
3419
|
while (pageRequests < maxPageRequests) {
|
|
1713
3420
|
pageRequests += 1;
|
|
1714
|
-
const { items, raw } = await client
|
|
3421
|
+
const { items, raw } = await listResource(client, "recordings", {
|
|
1715
3422
|
...listQuery(filter),
|
|
1716
3423
|
limit: pageLimit,
|
|
1717
3424
|
offset
|
|
@@ -1734,7 +3441,7 @@ function apiStore(client) {
|
|
|
1734
3441
|
throw new Error(`Recordings API exceeded ${maxPageRequests} pages while counting legacy results`);
|
|
1735
3442
|
},
|
|
1736
3443
|
async searchRecordings(query, filter) {
|
|
1737
|
-
const { items } = await client
|
|
3444
|
+
const { items } = await listResource(client, "recordings", listQuery({ ...filter ?? {}, search: query }));
|
|
1738
3445
|
return items;
|
|
1739
3446
|
},
|
|
1740
3447
|
async deleteRecording(id) {
|
|
@@ -1766,7 +3473,7 @@ function apiStore(client) {
|
|
|
1766
3473
|
return res ? unwrap(res, "agent") : null;
|
|
1767
3474
|
},
|
|
1768
3475
|
async listAgents() {
|
|
1769
|
-
const { items } = await client
|
|
3476
|
+
const { items } = await listResource(client, "agents");
|
|
1770
3477
|
return items;
|
|
1771
3478
|
},
|
|
1772
3479
|
async heartbeatAgent(idOrName) {
|
|
@@ -1806,7 +3513,7 @@ function apiStore(client) {
|
|
|
1806
3513
|
return res ? unwrap(res, "project") : null;
|
|
1807
3514
|
},
|
|
1808
3515
|
async listProjects() {
|
|
1809
|
-
const { items } = await client
|
|
3516
|
+
const { items } = await listResource(client, "projects");
|
|
1810
3517
|
return items;
|
|
1811
3518
|
},
|
|
1812
3519
|
async saveFeedback(input) {
|
|
@@ -1829,7 +3536,7 @@ var cached = null;
|
|
|
1829
3536
|
function getStore(env = process.env) {
|
|
1830
3537
|
if (env === process.env && cached)
|
|
1831
3538
|
return cached;
|
|
1832
|
-
const resolved =
|
|
3539
|
+
const resolved = resolveStoreClient(APP, env);
|
|
1833
3540
|
const store = resolved.transport === "http" ? apiStore(resolved.client) : localStore;
|
|
1834
3541
|
if (env === process.env)
|
|
1835
3542
|
cached = store;
|
|
@@ -2120,7 +3827,7 @@ function combinePrompts(...prompts) {
|
|
|
2120
3827
|
}
|
|
2121
3828
|
// src/lib/recorder.ts
|
|
2122
3829
|
import { spawn } from "child_process";
|
|
2123
|
-
import { join as
|
|
3830
|
+
import { join as join5 } from "path";
|
|
2124
3831
|
import { existsSync as existsSync2 } from "fs";
|
|
2125
3832
|
var _recordProcess = null;
|
|
2126
3833
|
var _currentFile = null;
|
|
@@ -2147,7 +3854,7 @@ function startRecording(config) {
|
|
|
2147
3854
|
}
|
|
2148
3855
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
2149
3856
|
const filename = `recording-${timestamp}.${config.audio_format}`;
|
|
2150
|
-
const filepath =
|
|
3857
|
+
const filepath = join5(config.audio_dir, filename);
|
|
2151
3858
|
const args = buildRecordArgs(filepath, config);
|
|
2152
3859
|
const [command, ...commandArgs] = args;
|
|
2153
3860
|
if (command === undefined) {
|
|
@@ -2225,7 +3932,7 @@ function buildRecordArgs(filepath, config) {
|
|
|
2225
3932
|
async function recordDuration(seconds, config) {
|
|
2226
3933
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
2227
3934
|
const filename = `recording-${timestamp}.${config.audio_format}`;
|
|
2228
|
-
const filepath =
|
|
3935
|
+
const filepath = join5(config.audio_dir, filename);
|
|
2229
3936
|
const args = [
|
|
2230
3937
|
"rec",
|
|
2231
3938
|
"-r",
|
|
@@ -2257,8 +3964,8 @@ async function recordDuration(seconds, config) {
|
|
|
2257
3964
|
}
|
|
2258
3965
|
// src/lib/capture-probe.ts
|
|
2259
3966
|
import { spawnSync as spawnSync2 } from "child_process";
|
|
2260
|
-
import { existsSync as existsSync3, readFileSync as
|
|
2261
|
-
import { join as
|
|
3967
|
+
import { existsSync as existsSync3, readFileSync as readFileSync4, rmSync as rmSync2 } from "fs";
|
|
3968
|
+
import { join as join6 } from "path";
|
|
2262
3969
|
import { tmpdir } from "os";
|
|
2263
3970
|
|
|
2264
3971
|
// src/lib/macos-bundle.ts
|
|
@@ -2274,7 +3981,7 @@ var WAVE_FORMAT_PCM = 1;
|
|
|
2274
3981
|
var WAVE_FORMAT_EXTENSIBLE = 65534;
|
|
2275
3982
|
var SUBFORMAT_OFFSET_IN_EXTENSION = 8;
|
|
2276
3983
|
function readWavPeak(filepath) {
|
|
2277
|
-
const buf =
|
|
3984
|
+
const buf = readFileSync4(filepath);
|
|
2278
3985
|
if (buf.length < RIFF_HEADER_BYTES) {
|
|
2279
3986
|
throw new Error(`not a RIFF file (${buf.length} bytes): ${filepath}`);
|
|
2280
3987
|
}
|
|
@@ -2349,7 +4056,7 @@ function probeMicrophoneCapture(config, options = {}) {
|
|
|
2349
4056
|
peak: 0,
|
|
2350
4057
|
silent: null
|
|
2351
4058
|
};
|
|
2352
|
-
const filepath =
|
|
4059
|
+
const filepath = join6(tmpdir(), `recordings-capture-probe-${process.pid}-${Date.now()}.wav`);
|
|
2353
4060
|
try {
|
|
2354
4061
|
const result = spawnSync2(executable, [
|
|
2355
4062
|
"-q",
|
|
@@ -2521,7 +4228,7 @@ function microphoneGrantInstruction(options) {
|
|
|
2521
4228
|
steps.push(`AMBIGUOUS: ${candidates.length} HasnaRecordings.app bundles exist (${candidates.join(", ")}). ` + "A TCC grant is bound to the bundle's code signature, so granting one does not grant the " + "other, and the toggle in Settings does not say which is which. Remove the bundles you are " + "not running before granting, or the grant may attach to the wrong one.");
|
|
2522
4229
|
}
|
|
2523
4230
|
steps.push(`At the keyboard on the machine itself (not over SSH), launch ${bundlePath} and start a ` + "recording once. macOS shows the consent sheet titled " + `"\u201CRecordings\u201D would like to access the microphone" \u2014 click Allow.`);
|
|
2524
|
-
steps.push("If no sheet appears, open System Settings \u2192 Privacy & Security \u2192 Microphone " + "and switch ON the row named \u201CRecordings\u201D. " + `That row is bundle ${RECORDINGS_BUNDLE_IDENTIFIER} at ${bundlePath}; the binary that ` + `receives the grant is ${
|
|
4231
|
+
steps.push("If no sheet appears, open System Settings \u2192 Privacy & Security \u2192 Microphone " + "and switch ON the row named \u201CRecordings\u201D. " + `That row is bundle ${RECORDINGS_BUNDLE_IDENTIFIER} at ${bundlePath}; the binary that ` + `receives the grant is ${join6(bundlePath, "Contents", "MacOS", "Recordings")}.`);
|
|
2525
4232
|
if (options.requestState === "never_requested") {
|
|
2526
4233
|
steps.push("Note: the app has never requested microphone access on this machine (no TCC entry exists), " + "so the Microphone list will NOT contain a \u201CRecordings\u201D row until the app asks once. " + "Do the launch-and-record step first; the Settings toggle only exists afterwards.");
|
|
2527
4234
|
} else if (options.requestState === "unknown") {
|
|
@@ -2802,7 +4509,7 @@ export {
|
|
|
2802
4509
|
PERSISTENCE_PROBE_TAG,
|
|
2803
4510
|
PERSISTENCE_PROBE_MARKER_PREFIX,
|
|
2804
4511
|
MAX_PROBE_SECONDS,
|
|
2805
|
-
HasnaHttpError,
|
|
4512
|
+
HasnaHttpError3 as HasnaHttpError,
|
|
2806
4513
|
EnhancementError,
|
|
2807
4514
|
DEFAULT_TRANSCRIPTION_MODEL,
|
|
2808
4515
|
DEFAULT_RECORD_EXECUTABLE,
|