@hasna/recordings 0.3.8 → 0.3.9

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/dist/mcp/index.js CHANGED
@@ -5057,7 +5057,7 @@ function setAgentFocus(idOrName, projectId, db) {
5057
5057
  // package.json
5058
5058
  var package_default = {
5059
5059
  name: "@hasna/recordings",
5060
- version: "0.3.8",
5060
+ version: "0.3.9",
5061
5061
  type: "module",
5062
5062
  description: "Speech-to-text recording tool with MCP and CLI \u2014 records, transcribes, and optionally enhances text using AI",
5063
5063
  repository: {
@@ -5141,7 +5141,7 @@ var package_default = {
5141
5141
  "LICENSE"
5142
5142
  ],
5143
5143
  dependencies: {
5144
- "@hasna/contracts": "0.13.3",
5144
+ "@hasna/contracts": "0.13.4",
5145
5145
  "@hasna/events": "0.1.11",
5146
5146
  "@modelcontextprotocol/sdk": "^1.12.1",
5147
5147
  chalk: "^5.4.1",
@@ -5173,27 +5173,207 @@ function saveFeedback(input) {
5173
5173
  db.query("INSERT INTO feedback (message, email, category, version) VALUES (?, ?, ?, ?)").run(input.message, input.email ?? null, input.category ?? "general", input.version ?? VERSION);
5174
5174
  }
5175
5175
 
5176
- // src/http/client.ts
5176
+ // ../contracts/dist/client/transport.js
5177
+ import { isIP } from "net";
5178
+ import { readFileSync as readFileSync2, statSync as statSync2 } from "fs";
5179
+ import { join as join3 } from "path";
5177
5180
  function envToken(name) {
5178
5181
  return name.toUpperCase().replace(/-/g, "_");
5179
5182
  }
5180
- function envKeys(name) {
5181
- const token = envToken(name);
5183
+ function clientTransportEnvKeys(name) {
5184
+ const envSegment = envToken(name);
5182
5185
  return {
5183
- storeKeys: [`HASNA_${token}_CLIENT_STORE`, `${token}_CLIENT_STORE`],
5184
- apiUrlKeys: [`HASNA_${token}_API_URL`],
5185
- apiKeyKeys: [`HASNA_${token}_API_KEY`]
5186
+ apiUrlKeys: [`HASNA_${envSegment}_API_URL`, `${envSegment}_API_URL`],
5187
+ apiKeyKeys: [`HASNA_${envSegment}_API_KEY`, `${envSegment}_API_KEY`]
5186
5188
  };
5187
5189
  }
5188
- function normalizeClientStore(value) {
5189
- const normalized = value.trim().toLowerCase();
5190
- if (normalized === "sqlite")
5191
- return "sqlite";
5192
- if (normalized === "http" || normalized === "https")
5193
- return "http";
5194
- throw new Error(`Unknown client store: ${value}. Use sqlite or http.`);
5190
+ function credentialOverrideEnvKey(name) {
5191
+ return `HASNA_${envToken(name)}_API_KEY_OVERRIDE`;
5192
+ }
5193
+ var CREDENTIAL_PROFILE_ENV_KEY = "HASNA_PROFILE";
5194
+
5195
+ class CredentialResolutionError extends Error {
5196
+ appName;
5197
+ attempted;
5198
+ constructor(appName, message, attempted) {
5199
+ super(message);
5200
+ this.name = "CredentialResolutionError";
5201
+ this.appName = appName;
5202
+ this.attempted = attempted;
5203
+ }
5204
+ }
5205
+ var HASNA_STATE_DIR = ".hasna";
5206
+ var FLEET_CREDENTIAL_DIR = "cloud";
5207
+ var CONFIG_DIR = ".config";
5208
+ var CONFIG_NAMESPACE = "hasna";
5209
+ var MAX_CREDENTIAL_FILE_BYTES = 64 * 1024;
5210
+ var SAFE_APP_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
5211
+ var SAFE_PROFILE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
5212
+ var ILLEGAL_IN_HEADER_VALUE = /[^\t\x20-\x7e]/;
5213
+ function homeDir(env) {
5214
+ const home = env.HOME?.trim();
5215
+ return home ? home : null;
5216
+ }
5217
+ function credentialDiskSources(name, env) {
5218
+ return profileDiskSources(name, env, null);
5219
+ }
5220
+ function profileDiskSources(name, env, profile) {
5221
+ const home = homeDir(env);
5222
+ if (!home || !SAFE_APP_SLUG.test(name))
5223
+ return [];
5224
+ const stem = profile ? `${name}.${profile}` : name;
5225
+ const configStem = profile ? `${name}-${profile}` : name;
5226
+ return [
5227
+ join3(home, HASNA_STATE_DIR, FLEET_CREDENTIAL_DIR, `${stem}.env`),
5228
+ join3(home, CONFIG_DIR, CONFIG_NAMESPACE, `${configStem}-cloud.env`)
5229
+ ];
5230
+ }
5231
+ function parseEnvFile(text) {
5232
+ const values = new Map;
5233
+ for (const rawLine of text.split(/\r?\n/)) {
5234
+ const line = rawLine.trim();
5235
+ if (line.length === 0 || line.startsWith("#"))
5236
+ continue;
5237
+ const withoutExport = line.startsWith("export ") ? line.slice("export ".length).trim() : line;
5238
+ const equals = withoutExport.indexOf("=");
5239
+ if (equals <= 0)
5240
+ continue;
5241
+ const key = withoutExport.slice(0, equals).trim();
5242
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
5243
+ continue;
5244
+ let value = withoutExport.slice(equals + 1).trim();
5245
+ const quote = value[0];
5246
+ if (quote === '"' || quote === "'") {
5247
+ if (value.length < 2 || !value.endsWith(quote))
5248
+ continue;
5249
+ value = value.slice(1, -1);
5250
+ }
5251
+ if (value.length === 0)
5252
+ continue;
5253
+ values.set(key, value);
5254
+ }
5255
+ return values;
5256
+ }
5257
+ function readAppConfigFile(path) {
5258
+ let text;
5259
+ try {
5260
+ const stats = statSync2(path);
5261
+ if (!stats.isFile() || stats.size > MAX_CREDENTIAL_FILE_BYTES)
5262
+ return null;
5263
+ text = readFileSync2(path, "utf8");
5264
+ } catch {
5265
+ return null;
5266
+ }
5267
+ return parseEnvFile(text);
5268
+ }
5269
+ function readCredentialFile(path, apiKeyKeys) {
5270
+ const values = readAppConfigFile(path);
5271
+ if (!values)
5272
+ return null;
5273
+ for (const key of apiKeyKeys) {
5274
+ const value = values.get(key)?.trim();
5275
+ if (value)
5276
+ return value;
5277
+ }
5278
+ return null;
5279
+ }
5280
+ var CREDENTIAL_SHAPED_KEY = /(?:^|_)(?:API_KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)(?:_|$)/;
5281
+ function appConfigDiskValue(name, env, keys) {
5282
+ const wanted = keys.filter((key) => !CREDENTIAL_SHAPED_KEY.test(key));
5283
+ if (wanted.length === 0)
5284
+ return null;
5285
+ for (const path of credentialDiskSources(name, env)) {
5286
+ const values = readAppConfigFile(path);
5287
+ if (!values)
5288
+ continue;
5289
+ for (const key of wanted) {
5290
+ const value = values.get(key)?.trim();
5291
+ if (value)
5292
+ return { key, value, path };
5293
+ }
5294
+ }
5295
+ return null;
5195
5296
  }
5196
- function firstEnv(env, keys) {
5297
+ function assertUsableCredential(appName, source, value) {
5298
+ if (!ILLEGAL_IN_HEADER_VALUE.test(value))
5299
+ return;
5300
+ 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]);
5301
+ }
5302
+ var INSPECT_CUSTOM = Symbol.for("nodejs.util.inspect.custom");
5303
+ var CREDENTIAL_SEAL = Symbol.for("hasna:contracts:sealedCredential");
5304
+ var CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE = "caller-supplied CredentialProvider";
5305
+ function sealCredential(fields) {
5306
+ const { apiKey } = fields;
5307
+ const visible = {
5308
+ tier: fields.tier,
5309
+ source: fields.source,
5310
+ deliberate: fields.deliberate,
5311
+ deprecated: fields.deprecated,
5312
+ diskCandidates: Object.freeze([...fields.diskCandidates]),
5313
+ warning: fields.warning
5314
+ };
5315
+ const sealed = { ...visible };
5316
+ Object.defineProperty(sealed, "apiKey", {
5317
+ value: apiKey,
5318
+ enumerable: false,
5319
+ writable: false,
5320
+ configurable: false
5321
+ });
5322
+ Object.defineProperty(sealed, INSPECT_CUSTOM, {
5323
+ value: () => ({ ...visible, apiKey: "[redacted]" }),
5324
+ enumerable: false,
5325
+ writable: false,
5326
+ configurable: false
5327
+ });
5328
+ Object.defineProperty(sealed, CREDENTIAL_SEAL, {
5329
+ value: true,
5330
+ enumerable: false,
5331
+ writable: false,
5332
+ configurable: false
5333
+ });
5334
+ return Object.freeze(sealed);
5335
+ }
5336
+ function isSealedCredential(credential) {
5337
+ return credential[CREDENTIAL_SEAL] === true;
5338
+ }
5339
+ function explicitCredential(appName, apiKey) {
5340
+ const source = "explicit apiKey option";
5341
+ assertUsableCredential(appName, source, apiKey);
5342
+ return sealCredential({
5343
+ apiKey,
5344
+ tier: "argument",
5345
+ source,
5346
+ deliberate: true,
5347
+ deprecated: false,
5348
+ diskCandidates: [],
5349
+ warning: null
5350
+ });
5351
+ }
5352
+ function validateAndSealResolvedCredential(appName, credential) {
5353
+ const apiKey = credential.apiKey;
5354
+ assertUsableCredential(appName, CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE, apiKey);
5355
+ if (!isSealedCredential(credential)) {
5356
+ return sealCredential({
5357
+ apiKey,
5358
+ tier: "argument",
5359
+ source: CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE,
5360
+ deliberate: true,
5361
+ deprecated: false,
5362
+ diskCandidates: [],
5363
+ warning: null
5364
+ });
5365
+ }
5366
+ return sealCredential({
5367
+ apiKey,
5368
+ tier: credential.tier,
5369
+ source: credential.source,
5370
+ deliberate: credential.deliberate,
5371
+ deprecated: credential.deprecated,
5372
+ diskCandidates: credential.diskCandidates,
5373
+ warning: credential.warning
5374
+ });
5375
+ }
5376
+ function firstEnvValue(env, keys) {
5197
5377
  for (const key of keys) {
5198
5378
  const value = env[key]?.trim();
5199
5379
  if (value)
@@ -5201,79 +5381,343 @@ function firstEnv(env, keys) {
5201
5381
  }
5202
5382
  return null;
5203
5383
  }
5384
+ var DEPRECATION_REGISTRY = Symbol.for("hasna:contracts:credentialDeprecationNotices");
5385
+ function deprecationNotified() {
5386
+ const host = globalThis;
5387
+ const existing = host[DEPRECATION_REGISTRY];
5388
+ if (existing instanceof Set)
5389
+ return existing;
5390
+ const created = new Set;
5391
+ host[DEPRECATION_REGISTRY] = created;
5392
+ return created;
5393
+ }
5394
+ function defaultDeprecationSink(message) {
5395
+ if (typeof process !== "undefined" && process.stderr) {
5396
+ process.stderr.write(`${message}
5397
+ `);
5398
+ }
5399
+ }
5400
+ function resolveCredential(name, env, options = {}) {
5401
+ const { apiKeyKeys } = clientTransportEnvKeys(name);
5402
+ const diskPaths = credentialDiskSources(name, env);
5403
+ const explicitKey = options.apiKey?.trim();
5404
+ if (explicitKey) {
5405
+ assertUsableCredential(name, "the explicit apiKey argument", explicitKey);
5406
+ return sealCredential({
5407
+ apiKey: explicitKey,
5408
+ tier: "argument",
5409
+ source: "explicit apiKey argument",
5410
+ deliberate: true,
5411
+ deprecated: false,
5412
+ diskCandidates: diskPaths,
5413
+ warning: null
5414
+ });
5415
+ }
5416
+ const overrideKeyName = credentialOverrideEnvKey(name);
5417
+ const overrideRaw = env[overrideKeyName];
5418
+ if (overrideRaw !== undefined) {
5419
+ const override = overrideRaw.trim();
5420
+ if (!override) {
5421
+ 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]);
5422
+ }
5423
+ assertUsableCredential(name, overrideKeyName, override);
5424
+ return sealCredential({
5425
+ apiKey: override,
5426
+ tier: "override",
5427
+ source: overrideKeyName,
5428
+ deliberate: true,
5429
+ deprecated: false,
5430
+ diskCandidates: diskPaths,
5431
+ warning: null
5432
+ });
5433
+ }
5434
+ const profile = options.profile?.trim() || env[CREDENTIAL_PROFILE_ENV_KEY]?.trim();
5435
+ if (profile) {
5436
+ const profileSource = options.profile?.trim() ? "explicit profile argument" : CREDENTIAL_PROFILE_ENV_KEY;
5437
+ if (!SAFE_PROFILE.test(profile)) {
5438
+ throw new CredentialResolutionError(name, `Profile name from ${profileSource} is not usable in a path. ` + `Use letters, digits, dot, dash, or underscore.`, [profileSource]);
5439
+ }
5440
+ const paths = profileDiskSources(name, env, profile);
5441
+ for (const path of paths) {
5442
+ const value = readCredentialFile(path, apiKeyKeys);
5443
+ if (value) {
5444
+ assertUsableCredential(name, path, value);
5445
+ return sealCredential({
5446
+ apiKey: value,
5447
+ tier: "profile",
5448
+ source: path,
5449
+ deliberate: true,
5450
+ deprecated: false,
5451
+ diskCandidates: paths,
5452
+ warning: null
5453
+ });
5454
+ }
5455
+ }
5456
+ 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);
5457
+ }
5458
+ const diskHits = diskPaths.map((path) => ({ path, value: readCredentialFile(path, apiKeyKeys) })).filter((hit) => hit.value !== null);
5459
+ if (diskHits.length > 0) {
5460
+ const winner = diskHits[0];
5461
+ assertUsableCredential(name, winner.path, winner.value);
5462
+ const divergentSources = [
5463
+ ...diskHits.slice(1).filter((hit) => hit.value !== winner.value).map((hit) => hit.path),
5464
+ ...(() => {
5465
+ const legacyHit = firstEnvValue(env, apiKeyKeys);
5466
+ return legacyHit && legacyHit.value !== winner.value ? [legacyHit.key] : [];
5467
+ })()
5468
+ ];
5469
+ 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;
5470
+ return sealCredential({
5471
+ apiKey: winner.value,
5472
+ tier: "disk",
5473
+ source: winner.path,
5474
+ deliberate: false,
5475
+ deprecated: false,
5476
+ diskCandidates: diskPaths,
5477
+ warning
5478
+ });
5479
+ }
5480
+ const legacy = firstEnvValue(env, apiKeyKeys);
5481
+ if (legacy) {
5482
+ assertUsableCredential(name, legacy.key, legacy.value);
5483
+ 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.`;
5484
+ 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}`;
5485
+ const sink = options.onDeprecation ?? defaultDeprecationSink;
5486
+ const notified = deprecationNotified();
5487
+ if (!notified.has(name)) {
5488
+ notified.add(name);
5489
+ sink(message);
5490
+ }
5491
+ return sealCredential({
5492
+ apiKey: legacy.value,
5493
+ tier: "legacy-env",
5494
+ source: legacy.key,
5495
+ deliberate: false,
5496
+ deprecated: true,
5497
+ diskCandidates: diskPaths,
5498
+ warning: message
5499
+ });
5500
+ }
5501
+ return null;
5502
+ }
5503
+ var ASCII_CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
5504
+ var DNS_LABEL_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
5505
+ function isValidDnsDomain(value) {
5506
+ if (value.length === 0 || value.length > 253 || ASCII_CONTROL_PATTERN.test(value) || /[^\x00-\x7f]/.test(value)) {
5507
+ return false;
5508
+ }
5509
+ return value.split(".").every((label) => label.length <= 63 && !label.startsWith("xn--") && DNS_LABEL_PATTERN.test(label));
5510
+ }
5511
+ function firstEnv(env, keys, options = {}) {
5512
+ for (const key of keys) {
5513
+ const raw = env[key];
5514
+ const value = raw?.trim();
5515
+ if (value)
5516
+ return { key, value: options.preserveRaw ? raw : value };
5517
+ }
5518
+ return null;
5519
+ }
5520
+ function firstEnvDefinedKey(env, keys) {
5521
+ for (const key of keys) {
5522
+ if (env[key] !== undefined)
5523
+ return key;
5524
+ }
5525
+ return null;
5526
+ }
5527
+ function rawAuthority(value) {
5528
+ const match = /^[a-z][a-z0-9+.-]*:\/\//i.exec(value);
5529
+ if (!match)
5530
+ throw new Error("API URL must be absolute.");
5531
+ const afterScheme = value.slice(match[0].length);
5532
+ const boundary = afterScheme.search(/[/?#]/);
5533
+ const authority = boundary === -1 ? afterScheme : afterScheme.slice(0, boundary);
5534
+ if (!authority)
5535
+ throw new Error("API URL must include a hostname.");
5536
+ return authority;
5537
+ }
5538
+ function assertCanonicalPort(port) {
5539
+ if (!/^[0-9]+$/.test(port) || port.length > 1 && port.startsWith("0")) {
5540
+ throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
5541
+ }
5542
+ const numericPort = Number(port);
5543
+ if (!Number.isSafeInteger(numericPort) || numericPort < 1 || numericPort > 65535) {
5544
+ throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
5545
+ }
5546
+ }
5547
+ function canonicalAuthorityHostname(authority) {
5548
+ let rawHostname;
5549
+ if (authority.startsWith("[")) {
5550
+ const closingBracket = authority.indexOf("]");
5551
+ if (closingBracket === -1) {
5552
+ throw new Error("API URL authority must contain a canonical hostname.");
5553
+ }
5554
+ rawHostname = authority.slice(0, closingBracket + 1);
5555
+ const portSuffix = authority.slice(closingBracket + 1);
5556
+ if (portSuffix) {
5557
+ if (!portSuffix.startsWith(":")) {
5558
+ throw new Error("API URL authority must contain a canonical hostname and port.");
5559
+ }
5560
+ assertCanonicalPort(portSuffix.slice(1));
5561
+ }
5562
+ if (isIP(rawHostname.slice(1, -1)) !== 6) {
5563
+ throw new Error("API URL authority must contain a canonical IPv6 literal.");
5564
+ }
5565
+ } else {
5566
+ const firstColon = authority.indexOf(":");
5567
+ const lastColon = authority.lastIndexOf(":");
5568
+ if (firstColon !== lastColon) {
5569
+ throw new Error("IPv6 API URL authorities must use brackets.");
5570
+ }
5571
+ if (lastColon !== -1) {
5572
+ const port = authority.slice(lastColon + 1);
5573
+ assertCanonicalPort(port);
5574
+ rawHostname = authority.slice(0, lastColon);
5575
+ } else {
5576
+ rawHostname = authority;
5577
+ }
5578
+ const ipVersion = isIP(rawHostname);
5579
+ const numericAddressParts = rawHostname.split(".");
5580
+ const looksLikeNonCanonicalIpv4 = numericAddressParts.every((part) => /^(?:0x[0-9a-f]+|[0-9]+)$/i.test(part));
5581
+ if (ipVersion !== 4 && looksLikeNonCanonicalIpv4 || ipVersion !== 4 && !isValidDnsDomain(rawHostname.toLowerCase())) {
5582
+ throw new Error("API URL authority must contain a canonical ASCII hostname.");
5583
+ }
5584
+ }
5585
+ return rawHostname.toLowerCase();
5586
+ }
5587
+ function isDeliberateLoopbackHttpAuthority(authority) {
5588
+ return /^(?:localhost|127\.0\.0\.1|\[::1\])(?::[0-9]+)?$/i.test(authority);
5589
+ }
5204
5590
  function toV1BaseUrl(apiUrl) {
5205
- const url = new URL(apiUrl);
5591
+ if (ASCII_CONTROL_PATTERN.test(apiUrl)) {
5592
+ throw new Error("API URL must not contain ASCII control characters.");
5593
+ }
5594
+ const input = apiUrl.trim();
5595
+ const authority = rawAuthority(input);
5596
+ if (authority.includes("@") || authority.includes("\\") || authority.includes("%") || /[^\x00-\x7f]/.test(authority)) {
5597
+ throw new Error("API URL authority must be canonical ASCII without credentials.");
5598
+ }
5599
+ const canonicalHostname = canonicalAuthorityHostname(authority);
5600
+ const url = new URL(input);
5206
5601
  if (url.protocol !== "http:" && url.protocol !== "https:") {
5207
5602
  throw new Error("API URL must use http or https.");
5208
5603
  }
5604
+ if (url.username || url.password) {
5605
+ throw new Error("API URL must not include credentials.");
5606
+ }
5607
+ if (!url.hostname || url.hostname.endsWith(".")) {
5608
+ throw new Error("API URL must include a canonical hostname.");
5609
+ }
5610
+ if (url.hostname.toLowerCase() !== canonicalHostname) {
5611
+ throw new Error("API URL authority must not rely on parser hostname normalization.");
5612
+ }
5613
+ if (url.hostname.split(".").some((label) => label.toLowerCase().startsWith("xn--"))) {
5614
+ throw new Error("API URL must not use IDN or punycode hostnames.");
5615
+ }
5616
+ if (url.protocol === "http:" && !isDeliberateLoopbackHttpAuthority(authority)) {
5617
+ throw new Error("API URL may use http only for an exact loopback authority.");
5618
+ }
5619
+ if (url.search || url.hash) {
5620
+ throw new Error("API URL must not include a query string or fragment.");
5621
+ }
5209
5622
  let path = url.pathname.replace(/\/+$/, "");
5210
5623
  if (path.endsWith("/v1"))
5211
5624
  path = path.slice(0, -"/v1".length);
5212
5625
  url.pathname = `${path}/v1`;
5213
- url.search = "";
5214
- url.hash = "";
5215
5626
  return url.toString().replace(/\/+$/, "");
5216
5627
  }
5217
- function resolveTransport(name, env = process.env) {
5218
- const keys = envKeys(name);
5219
- const storeHit = firstEnv(env, keys.storeKeys);
5220
- const urlHit = firstEnv(env, keys.apiUrlKeys);
5628
+ function resolveClientTransport(name, env = process.env, options = {}) {
5629
+ const keys = clientTransportEnvKeys(name);
5630
+ const envUrlHit = firstEnv(env, keys.apiUrlKeys, { preserveRaw: true });
5631
+ const explicitLocalKey = envUrlHit ? null : firstEnvDefinedKey(env, keys.apiUrlKeys);
5632
+ const diskUrlHit = envUrlHit || explicitLocalKey ? null : appConfigDiskValue(name, env, keys.apiUrlKeys);
5633
+ const urlHit = envUrlHit ?? (diskUrlHit ? { key: diskUrlHit.path, value: diskUrlHit.value } : null);
5221
5634
  const keyHit = firstEnv(env, keys.apiKeyKeys);
5222
- let requested = "sqlite";
5223
- let modeSource = "default";
5224
- if (storeHit) {
5225
- requested = normalizeClientStore(storeHit.value);
5226
- modeSource = storeHit.key;
5227
- } else if (urlHit && keyHit) {
5228
- requested = "http";
5229
- modeSource = "auto:api-url+api-key";
5230
- } else if (urlHit || keyHit) {
5231
- const missing = urlHit ? keys.apiKeyKeys[0] : keys.apiUrlKeys[0];
5232
- const present = urlHit ? keys.apiUrlKeys[0] : keys.apiKeyKeys[0];
5233
- return {
5234
- transport: "sqlite",
5235
- requested,
5236
- modeSource,
5237
- baseUrl: null,
5238
- apiKeyPresent: Boolean(keyHit),
5239
- misconfigured: true,
5240
- warning: `${present} is set but ${missing} is not: the hosted API is only ` + `selected when BOTH are present. Set ${missing}, or unset ${present} to ` + `use the on-box store.`
5241
- };
5242
- }
5243
- if (requested === "sqlite") {
5244
- return { transport: "sqlite", requested, modeSource, baseUrl: null, apiKeyPresent: Boolean(keyHit), misconfigured: false, warning: null };
5245
- }
5635
+ const warnings = [];
5246
5636
  if (!urlHit) {
5637
+ if (explicitLocalKey) {
5638
+ const overriddenPointer = appConfigDiskValue(name, env, keys.apiUrlKeys);
5639
+ if (overriddenPointer) {
5640
+ 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.`);
5641
+ }
5642
+ return {
5643
+ transport: "sqlite",
5644
+ transportSource: explicitLocalKey,
5645
+ baseUrl: null,
5646
+ apiUrlSource: null,
5647
+ apiKeyPresent: Boolean(keyHit),
5648
+ apiKeySource: keyHit ? keyHit.key : null,
5649
+ apiKeyTier: null,
5650
+ misconfigured: false,
5651
+ warning: warnings.length > 0 ? warnings.join(" ") : null
5652
+ };
5653
+ }
5247
5654
  return {
5248
5655
  transport: "sqlite",
5249
- requested,
5250
- modeSource,
5656
+ transportSource: "default",
5251
5657
  baseUrl: null,
5658
+ apiUrlSource: null,
5252
5659
  apiKeyPresent: Boolean(keyHit),
5253
- misconfigured: true,
5254
- warning: `${modeSource}=http but no API URL is set (${keys.apiUrlKeys[0]}). Refusing to route to the API.`
5660
+ apiKeySource: keyHit ? keyHit.key : null,
5661
+ apiKeyTier: null,
5662
+ misconfigured: false,
5663
+ warning: null
5255
5664
  };
5256
5665
  }
5257
- if (!keyHit) {
5666
+ if (diskUrlHit) {
5667
+ 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.`);
5668
+ }
5669
+ const credential = resolveCredential(name, env, options.credentials);
5670
+ if (!credential) {
5671
+ const diskHint = credentialDiskSourcesForMessage(name, env);
5672
+ 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.`);
5258
5673
  return {
5259
5674
  transport: "sqlite",
5260
- requested,
5261
- modeSource,
5675
+ transportSource: urlHit.key,
5262
5676
  baseUrl: null,
5677
+ apiUrlSource: urlHit.key,
5263
5678
  apiKeyPresent: false,
5679
+ apiKeySource: null,
5680
+ apiKeyTier: null,
5264
5681
  misconfigured: true,
5265
- warning: `${modeSource}=http but no API key is set (${keys.apiKeyKeys[0]}). Refusing to route to the API.`
5682
+ warning: warnings.join(" ")
5266
5683
  };
5267
5684
  }
5268
- const rawUrl = urlHit.value;
5685
+ if (credential.warning)
5686
+ warnings.push(credential.warning);
5687
+ const apiUrlSource = urlHit.key;
5269
5688
  let baseUrl;
5270
5689
  try {
5271
- baseUrl = toV1BaseUrl(rawUrl);
5690
+ baseUrl = toV1BaseUrl(urlHit.value);
5272
5691
  } catch (error) {
5273
5692
  const message = error instanceof Error ? error.message : String(error);
5274
- return { transport: "sqlite", requested, modeSource, baseUrl: null, apiKeyPresent: true, misconfigured: true, warning: `Invalid API URL: ${message}.` };
5693
+ warnings.push(`Invalid API URL from ${apiUrlSource}: ${message}. Using local store.`);
5694
+ return {
5695
+ transport: "sqlite",
5696
+ transportSource: urlHit.key,
5697
+ baseUrl: null,
5698
+ apiUrlSource: urlHit.key,
5699
+ apiKeyPresent: true,
5700
+ apiKeySource: credential.source,
5701
+ apiKeyTier: credential.tier,
5702
+ misconfigured: true,
5703
+ warning: warnings.join(" ")
5704
+ };
5275
5705
  }
5276
- return { transport: "http", requested, modeSource, baseUrl, apiKeyPresent: true, misconfigured: false, warning: null };
5706
+ return {
5707
+ transport: "http",
5708
+ transportSource: urlHit.key,
5709
+ baseUrl,
5710
+ apiUrlSource,
5711
+ apiKeyPresent: true,
5712
+ apiKeySource: credential.source,
5713
+ apiKeyTier: credential.tier,
5714
+ misconfigured: false,
5715
+ warning: warnings.length > 0 ? warnings.join(" ") : null
5716
+ };
5717
+ }
5718
+ function credentialDiskSourcesForMessage(name, env) {
5719
+ const paths = credentialDiskSources(name, env);
5720
+ return paths.length > 0 ? paths.join(" or ") : "<no HOME set in this environment, so no credential file was consulted>";
5277
5721
  }
5278
5722
 
5279
5723
  class HasnaHttpError extends Error {
@@ -5281,49 +5725,113 @@ class HasnaHttpError extends Error {
5281
5725
  method;
5282
5726
  path;
5283
5727
  body;
5284
- constructor(method, path, status, body) {
5285
- super(`Hasna request failed: ${method} ${path} -> ${status}`);
5728
+ credentialSource;
5729
+ credentialTier;
5730
+ constructor(method, path, status, body, credential) {
5731
+ const guidance = credential ? `. ${credential.guidance}` : "";
5732
+ super(`Hasna cloud request failed: ${method} ${path} -> ${status}${guidance}`);
5286
5733
  this.name = "HasnaHttpError";
5287
5734
  this.status = status;
5288
5735
  this.method = method;
5289
5736
  this.path = path;
5290
5737
  this.body = body;
5738
+ this.credentialSource = credential?.source ?? null;
5739
+ this.credentialTier = credential?.tier ?? null;
5740
+ }
5741
+ }
5742
+ function currentCredential(name, apiKey) {
5743
+ if (typeof apiKey === "function") {
5744
+ return validateAndSealResolvedCredential(name, apiKey());
5745
+ }
5746
+ return explicitCredential(name, apiKey);
5747
+ }
5748
+ function authFailureGuidance(credential) {
5749
+ const origin = `The API key for this request came from ${credential.source}`;
5750
+ if (credential.deliberate) {
5751
+ 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.`;
5752
+ 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}`;
5753
+ }
5754
+ if (credential.deprecated) {
5755
+ const target = credential.diskCandidates[0];
5756
+ 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.`;
5757
+ 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}`;
5758
+ }
5759
+ 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.`;
5760
+ }
5761
+ var DEFAULT_RETRY_STATUSES = [408, 425, 429, 500, 502, 503, 504];
5762
+ var IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
5763
+ var AUTHORITY_OVERRIDE_HEADERS = new Set([
5764
+ "host",
5765
+ ":authority",
5766
+ "forwarded",
5767
+ "x-forwarded-host",
5768
+ "x-original-host"
5769
+ ]);
5770
+ function assertNoAuthorityOverrideHeaders(headers, source) {
5771
+ if (!headers)
5772
+ return;
5773
+ const forbidden = Object.keys(headers).find((name) => AUTHORITY_OVERRIDE_HEADERS.has(name.trim().toLowerCase()));
5774
+ if (forbidden) {
5775
+ throw new Error(`Authenticated ${source} headers must not set authority header '${forbidden}'.`);
5291
5776
  }
5292
5777
  }
5293
5778
  function appendQuery(path, query) {
5294
5779
  if (!query)
5295
5780
  return path;
5296
- const params = new URLSearchParams;
5297
- for (const [key, value] of Object.entries(query)) {
5298
- if (value === null || value === undefined)
5299
- continue;
5300
- if (Array.isArray(value))
5301
- for (const v of value)
5302
- params.append(key, String(v));
5303
- else
5304
- params.append(key, String(value));
5781
+ const params = query instanceof URLSearchParams ? query : new URLSearchParams;
5782
+ if (!(query instanceof URLSearchParams)) {
5783
+ for (const [key, value] of Object.entries(query)) {
5784
+ if (value === null || value === undefined)
5785
+ continue;
5786
+ if (Array.isArray(value)) {
5787
+ for (const v of value)
5788
+ params.append(key, String(v));
5789
+ } else {
5790
+ params.append(key, String(value));
5791
+ }
5792
+ }
5305
5793
  }
5306
5794
  const qs = params.toString();
5307
- return qs ? `${path}${path.includes("?") ? "&" : "?"}${qs}` : path;
5795
+ if (!qs)
5796
+ return path;
5797
+ return `${path}${path.includes("?") ? "&" : "?"}${qs}`;
5308
5798
  }
5309
- var RETRY_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
5310
- var IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
5311
- var defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
5312
- function createHttpTransport(options) {
5799
+ var defaultSleep = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
5800
+ function createHasnaHttpTransport(options) {
5313
5801
  const fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
5314
- const base = options.baseUrl.replace(/\/+$/, "");
5802
+ const base = toV1BaseUrl(options.baseUrl);
5315
5803
  const timeoutMs = options.timeoutMs ?? 30000;
5316
5804
  const sleep = options.sleepImpl ?? defaultSleep;
5317
- async function once(method, rel, url, body, opts) {
5805
+ const defaultRetry = options.retry;
5806
+ function resolveRetry(callRetry) {
5807
+ const chosen = callRetry !== undefined ? callRetry : defaultRetry;
5808
+ if (chosen === false)
5809
+ return null;
5810
+ const r = chosen ?? {};
5811
+ return {
5812
+ retries: r.retries ?? 2,
5813
+ baseDelayMs: r.baseDelayMs ?? 200,
5814
+ maxDelayMs: r.maxDelayMs ?? 2000,
5815
+ retryStatuses: r.retryStatuses ?? [...DEFAULT_RETRY_STATUSES]
5816
+ };
5817
+ }
5818
+ async function once(method, rel, url, body, opts, credential) {
5819
+ assertNoAuthorityOverrideHeaders(options.headers, "transport");
5820
+ assertNoAuthorityOverrideHeaders(opts.headers, "request");
5318
5821
  const headers = {
5319
- "x-api-key": options.apiKey,
5320
- Authorization: `Bearer ${options.apiKey}`,
5822
+ "x-api-key": credential.apiKey,
5823
+ Authorization: `Bearer ${credential.apiKey}`,
5321
5824
  Accept: "application/json",
5825
+ ...options.headers ?? {},
5322
5826
  ...opts.headers ?? {}
5323
5827
  };
5324
5828
  if (opts.idempotencyKey)
5325
5829
  headers["Idempotency-Key"] = opts.idempotencyKey;
5326
- const init = { method, headers };
5830
+ const init = {
5831
+ method,
5832
+ headers,
5833
+ redirect: "manual"
5834
+ };
5327
5835
  if (body !== undefined) {
5328
5836
  headers["Content-Type"] = "application/json";
5329
5837
  init.body = JSON.stringify(body);
@@ -5361,7 +5869,27 @@ function createHttpTransport(options) {
5361
5869
  }
5362
5870
  }
5363
5871
  if (!response.ok) {
5364
- return { ok: false, retryable: RETRY_STATUSES.has(response.status), error: new HasnaHttpError(method, rel, response.status, parsed) };
5872
+ if (response.status >= 300 && response.status < 400) {
5873
+ return {
5874
+ ok: false,
5875
+ retryable: false,
5876
+ error: new HasnaHttpError(method, rel, response.status, parsed)
5877
+ };
5878
+ }
5879
+ if (response.status === 401 || response.status === 403) {
5880
+ return {
5881
+ ok: false,
5882
+ retryable: false,
5883
+ error: new HasnaHttpError(method, rel, response.status, parsed, {
5884
+ source: credential.source,
5885
+ tier: credential.tier,
5886
+ guidance: authFailureGuidance(credential)
5887
+ })
5888
+ };
5889
+ }
5890
+ const retry = resolveRetry(opts.retry);
5891
+ const retryable = retry ? retry.retryStatuses.includes(response.status) : false;
5892
+ return { ok: false, retryable, error: new HasnaHttpError(method, rel, response.status, parsed) };
5365
5893
  }
5366
5894
  return { ok: true, value: parsed };
5367
5895
  }
@@ -5369,24 +5897,23 @@ function createHttpTransport(options) {
5369
5897
  const upper = method.toUpperCase();
5370
5898
  const rel = appendQuery(path.startsWith("/") ? path : `/${path}`, opts.query);
5371
5899
  const url = `${base}${rel}`;
5372
- const methodRetryable = IDEMPOTENT.has(upper) || Boolean(opts.idempotencyKey);
5373
- const maxRetries = opts.retries ?? 2;
5374
- const maxAttempts = methodRetryable ? maxRetries + 1 : 1;
5900
+ const retry = resolveRetry(opts.retry);
5901
+ const methodRetryable = IDEMPOTENT_METHODS.has(upper) || Boolean(opts.idempotencyKey);
5902
+ const maxAttempts = retry && methodRetryable ? retry.retries + 1 : 1;
5903
+ const credential = currentCredential(options.name, options.apiKey);
5375
5904
  let last = null;
5376
5905
  for (let attempt = 1;attempt <= maxAttempts; attempt++) {
5377
- const result = await once(upper, rel, url, body, opts);
5906
+ const result = await once(upper, rel, url, body, opts, credential);
5378
5907
  if (result.ok)
5379
5908
  return result.value;
5380
5909
  last = result;
5381
- const canRetry = methodRetryable && result.retryable && attempt < maxAttempts;
5910
+ const canRetry = retry !== null && methodRetryable && result.retryable && attempt < maxAttempts;
5382
5911
  if (!canRetry)
5383
5912
  break;
5384
- const backoff = Math.min(2000, 200 * 2 ** (attempt - 1));
5913
+ const backoff = Math.min(retry.maxDelayMs, retry.baseDelayMs * 2 ** (attempt - 1));
5385
5914
  const jitter = Math.floor(Math.random() * (backoff / 2 + 1));
5386
5915
  await sleep(backoff + jitter);
5387
5916
  }
5388
- if (last === null)
5389
- throw new Error(`Request to ${rel} completed without a result`);
5390
5917
  throw last.error;
5391
5918
  }
5392
5919
  return {
@@ -5394,81 +5921,289 @@ function createHttpTransport(options) {
5394
5921
  request,
5395
5922
  get: (path, opts) => request("GET", path, undefined, opts),
5396
5923
  post: (path, body, opts) => request("POST", path, body, opts),
5397
- patch: (path, body, opts) => request("PATCH", path, body, opts),
5398
5924
  put: (path, body, opts) => request("PUT", path, body, opts),
5925
+ patch: (path, body, opts) => request("PATCH", path, body, opts),
5399
5926
  del: (path, body, opts) => request("DELETE", path, body, opts)
5400
5927
  };
5401
5928
  }
5929
+ function createClientTransport(name, env = process.env, overrides) {
5930
+ const credentialOptions = overrides?.credentials;
5931
+ const resolution = resolveClientTransport(name, env, { ...credentialOptions ? { credentials: credentialOptions } : {} });
5932
+ if (resolution.misconfigured) {
5933
+ throw new Error(resolution.warning ?? `Client for '${name}' is misconfigured for the API client.`);
5934
+ }
5935
+ if (resolution.transport === "sqlite" || !resolution.baseUrl) {
5936
+ return { transport: "sqlite", client: null, resolution };
5937
+ }
5938
+ const credentialProvider = () => {
5939
+ const resolved = resolveCredential(name, env, credentialOptions);
5940
+ if (!resolved) {
5941
+ 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.`);
5942
+ }
5943
+ return resolved;
5944
+ };
5945
+ return {
5946
+ transport: "http",
5947
+ client: createHasnaHttpTransport({
5948
+ name,
5949
+ baseUrl: resolution.baseUrl,
5950
+ apiKey: credentialProvider,
5951
+ ...overrides?.fetchImpl ? { fetchImpl: overrides.fetchImpl } : {},
5952
+ ...overrides?.headers ? { headers: overrides.headers } : {},
5953
+ ...overrides?.timeoutMs ? { timeoutMs: overrides.timeoutMs } : {},
5954
+ ...overrides?.retry !== undefined ? { retry: overrides.retry } : {},
5955
+ ...overrides?.sleepImpl ? { sleepImpl: overrides.sleepImpl } : {}
5956
+ }),
5957
+ resolution
5958
+ };
5959
+ }
5960
+
5961
+ // ../contracts/dist/client/storage.js
5962
+ var MAX_CREDENTIAL_FILE_BYTES2 = 64 * 1024;
5963
+ var INSPECT_CUSTOM2 = Symbol.for("nodejs.util.inspect.custom");
5964
+ var CREDENTIAL_SEAL2 = Symbol.for("hasna:contracts:sealedCredential");
5965
+ var DEPRECATION_REGISTRY2 = Symbol.for("hasna:contracts:credentialDeprecationNotices");
5966
+ var IDEMPOTENT_METHODS2 = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
5967
+ var AUTHORITY_OVERRIDE_HEADERS2 = new Set([
5968
+ "host",
5969
+ ":authority",
5970
+ "forwarded",
5971
+ "x-forwarded-host",
5972
+ "x-original-host"
5973
+ ]);
5974
+ function resourcePath(resource) {
5975
+ const trimmed = resource.replace(/^\/+|\/+$/g, "");
5976
+ if (!trimmed)
5977
+ throw new Error("resource must be a non-empty path segment");
5978
+ return `/${trimmed}`;
5979
+ }
5980
+ function entityPath(resource, id) {
5981
+ if (id === undefined || id === null || `${id}`.length === 0) {
5982
+ throw new Error("id must be a non-empty string");
5983
+ }
5984
+ return `${resourcePath(resource)}/${encodeURIComponent(String(id))}`;
5985
+ }
5402
5986
  function newIdempotencyKey() {
5403
5987
  const g = globalThis;
5404
5988
  if (g.crypto?.randomUUID)
5405
5989
  return g.crypto.randomUUID();
5406
5990
  return `idmp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
5407
5991
  }
5408
- function extractItems(raw, extraKeys = []) {
5992
+ function extractItems(raw) {
5409
5993
  if (Array.isArray(raw))
5410
5994
  return raw;
5411
5995
  if (raw && typeof raw === "object") {
5412
5996
  const obj = raw;
5413
- for (const key of [...extraKeys, "items", "data", "results", "rows", "records"]) {
5997
+ for (const key of ["items", "data", "results", "rows", "records"]) {
5414
5998
  if (Array.isArray(obj[key]))
5415
5999
  return obj[key];
5416
6000
  }
5417
6001
  }
5418
6002
  return [];
5419
6003
  }
5420
- function createStorageClient(name, transport) {
5421
- const rp = (r) => `/${r.replace(/^\/+|\/+$/g, "")}`;
5422
- const ep = (r, id) => `${rp(r)}/${encodeURIComponent(String(id))}`;
6004
+ function extractTotal(raw) {
6005
+ if (raw && typeof raw === "object") {
6006
+ const obj = raw;
6007
+ for (const key of ["total", "count", "totalCount", "total_count"]) {
6008
+ if (typeof obj[key] === "number")
6009
+ return obj[key];
6010
+ }
6011
+ }
6012
+ return null;
6013
+ }
6014
+ function extractCursor(raw) {
6015
+ if (raw && typeof raw === "object") {
6016
+ const obj = raw;
6017
+ for (const key of ["cursor", "nextCursor", "next_cursor", "next"]) {
6018
+ if (typeof obj[key] === "string")
6019
+ return obj[key];
6020
+ }
6021
+ }
6022
+ return null;
6023
+ }
6024
+ function isNotFoundHttpError(error) {
6025
+ return typeof error === "object" && error !== null && error.name === "HasnaHttpError" && error.status === 404;
6026
+ }
6027
+ function createHasnaStorageClient(name, transport) {
5423
6028
  return {
5424
6029
  name,
5425
6030
  baseUrl: transport.baseUrl,
5426
6031
  transport,
5427
- async list(resource, query) {
5428
- const raw = await transport.get(rp(resource), { query });
5429
- return { items: extractItems(raw, [resource]), raw };
6032
+ async list(resource, options = {}) {
6033
+ const raw = await transport.get(resourcePath(resource), options);
6034
+ return {
6035
+ items: extractItems(raw),
6036
+ total: extractTotal(raw),
6037
+ cursor: extractCursor(raw),
6038
+ raw
6039
+ };
5430
6040
  },
5431
- async get(resource, id) {
6041
+ async get(resource, id, options = {}) {
5432
6042
  try {
5433
- return await transport.get(ep(resource, id));
6043
+ return await transport.get(entityPath(resource, id), options);
5434
6044
  } catch (error) {
5435
- if (error instanceof HasnaHttpError && error.status === 404)
6045
+ if (isNotFoundHttpError(error))
5436
6046
  return null;
5437
6047
  throw error;
5438
6048
  }
5439
6049
  },
5440
- async create(resource, body, idempotencyKey) {
5441
- return transport.post(rp(resource), body, { idempotencyKey: idempotencyKey ?? newIdempotencyKey() });
6050
+ async create(resource, body, options = {}) {
6051
+ const { idempotencyKey, ...rest } = options;
6052
+ return transport.post(resourcePath(resource), body, {
6053
+ ...rest,
6054
+ idempotencyKey: idempotencyKey ?? newIdempotencyKey()
6055
+ });
5442
6056
  },
5443
- async update(resource, id, patch, method = "PATCH") {
6057
+ async update(resource, id, patch, options = {}) {
6058
+ const { method = "PATCH", idempotencyKey, ...rest } = options;
5444
6059
  const call = method === "PUT" ? transport.put : transport.patch;
5445
- return call(ep(resource, id), patch);
6060
+ return call(entityPath(resource, id), patch, { ...rest, ...idempotencyKey ? { idempotencyKey } : {} });
5446
6061
  },
5447
- async delete(resource, id) {
6062
+ async delete(resource, id, options = {}) {
5448
6063
  try {
5449
- await transport.del(ep(resource, id));
6064
+ await transport.del(entityPath(resource, id), undefined, options);
5450
6065
  } catch (error) {
5451
- if (error instanceof HasnaHttpError && error.status === 404)
6066
+ if (isNotFoundHttpError(error))
5452
6067
  return;
5453
6068
  throw error;
5454
6069
  }
5455
6070
  }
5456
6071
  };
5457
6072
  }
5458
- function resolveStorageClient(name, env = process.env, fetchImpl) {
6073
+
6074
+ // src/http/client.ts
6075
+ function envToken2(name) {
6076
+ return name.toUpperCase().replace(/-/g, "_");
6077
+ }
6078
+ function envKeys(name) {
6079
+ const token = envToken2(name);
6080
+ return {
6081
+ storeKeys: [`HASNA_${token}_CLIENT_STORE`, `${token}_CLIENT_STORE`],
6082
+ apiUrlKeys: [`HASNA_${token}_API_URL`],
6083
+ apiKeyKeys: [`HASNA_${token}_API_KEY`]
6084
+ };
6085
+ }
6086
+ function normalizeClientStore(value) {
6087
+ const normalized = value.trim().toLowerCase();
6088
+ if (normalized === "sqlite")
6089
+ return "sqlite";
6090
+ if (normalized === "http" || normalized === "https")
6091
+ return "http";
6092
+ throw new Error(`Unknown client store: ${value}. Use sqlite or http.`);
6093
+ }
6094
+ function firstEnv2(env, keys) {
6095
+ for (const key of keys) {
6096
+ const value = env[key]?.trim();
6097
+ if (value)
6098
+ return { key, value };
6099
+ }
6100
+ return null;
6101
+ }
6102
+ function toV1BaseUrl2(apiUrl) {
6103
+ const url = new URL(apiUrl);
6104
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
6105
+ throw new Error("API URL must use http or https.");
6106
+ }
6107
+ let path = url.pathname.replace(/\/+$/, "");
6108
+ if (path.endsWith("/v1"))
6109
+ path = path.slice(0, -"/v1".length);
6110
+ url.pathname = `${path}/v1`;
6111
+ url.search = "";
6112
+ url.hash = "";
6113
+ return url.toString().replace(/\/+$/, "");
6114
+ }
6115
+ function resolveTransport(name, env = process.env) {
6116
+ const keys = envKeys(name);
6117
+ const storeHit = firstEnv2(env, keys.storeKeys);
6118
+ const urlHit = firstEnv2(env, keys.apiUrlKeys);
6119
+ const keyHit = firstEnv2(env, keys.apiKeyKeys);
6120
+ let requested = "sqlite";
6121
+ let modeSource = "default";
6122
+ if (storeHit) {
6123
+ requested = normalizeClientStore(storeHit.value);
6124
+ modeSource = storeHit.key;
6125
+ } else if (urlHit && keyHit) {
6126
+ requested = "http";
6127
+ modeSource = "auto:api-url+api-key";
6128
+ } else if (urlHit || keyHit) {
6129
+ const missing = urlHit ? keys.apiKeyKeys[0] : keys.apiUrlKeys[0];
6130
+ const present = urlHit ? keys.apiUrlKeys[0] : keys.apiKeyKeys[0];
6131
+ return {
6132
+ transport: "sqlite",
6133
+ requested,
6134
+ modeSource,
6135
+ baseUrl: null,
6136
+ apiKeyPresent: Boolean(keyHit),
6137
+ misconfigured: true,
6138
+ warning: `${present} is set but ${missing} is not: the hosted API is only ` + `selected when BOTH are present. Set ${missing}, or unset ${present} to ` + `use the on-box store.`
6139
+ };
6140
+ }
6141
+ if (requested === "sqlite") {
6142
+ return { transport: "sqlite", requested, modeSource, baseUrl: null, apiKeyPresent: Boolean(keyHit), misconfigured: false, warning: null };
6143
+ }
6144
+ if (!urlHit) {
6145
+ return {
6146
+ transport: "sqlite",
6147
+ requested,
6148
+ modeSource,
6149
+ baseUrl: null,
6150
+ apiKeyPresent: Boolean(keyHit),
6151
+ misconfigured: true,
6152
+ warning: `${modeSource}=http but no API URL is set (${keys.apiUrlKeys[0]}). Refusing to route to the API.`
6153
+ };
6154
+ }
6155
+ if (!keyHit) {
6156
+ return {
6157
+ transport: "sqlite",
6158
+ requested,
6159
+ modeSource,
6160
+ baseUrl: null,
6161
+ apiKeyPresent: false,
6162
+ misconfigured: true,
6163
+ warning: `${modeSource}=http but no API key is set (${keys.apiKeyKeys[0]}). Refusing to route to the API.`
6164
+ };
6165
+ }
6166
+ const rawUrl = urlHit.value;
6167
+ let baseUrl;
6168
+ try {
6169
+ baseUrl = toV1BaseUrl2(rawUrl);
6170
+ } catch (error) {
6171
+ const message = error instanceof Error ? error.message : String(error);
6172
+ return { transport: "sqlite", requested, modeSource, baseUrl: null, apiKeyPresent: true, misconfigured: true, warning: `Invalid API URL: ${message}.` };
6173
+ }
6174
+ return { transport: "http", requested, modeSource, baseUrl, apiKeyPresent: true, misconfigured: false, warning: null };
6175
+ }
6176
+ var RETRY_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
6177
+ var IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
6178
+ function resolveStoreClient(name, env = process.env) {
5459
6179
  const resolution = resolveTransport(name, env);
5460
6180
  if (resolution.misconfigured) {
6181
+ const wired2 = createClientTransport(name, env);
6182
+ if (wired2.transport === "http") {
6183
+ return {
6184
+ transport: "http",
6185
+ client: createHasnaStorageClient(name, wired2.client),
6186
+ resolution: {
6187
+ transport: "http",
6188
+ requested: "http",
6189
+ modeSource: resolution.modeSource === "default" ? "auto:api-url+seam-credential" : resolution.modeSource,
6190
+ baseUrl: wired2.resolution.baseUrl,
6191
+ apiKeyPresent: true,
6192
+ misconfigured: false,
6193
+ warning: null
6194
+ }
6195
+ };
6196
+ }
5461
6197
  throw new Error(resolution.warning ?? `Client for '${name}' is misconfigured for the /v1 API.`);
5462
6198
  }
5463
6199
  if (resolution.transport === "sqlite" || !resolution.baseUrl) {
5464
6200
  return { transport: "sqlite", client: null, resolution };
5465
6201
  }
5466
- const keys = envKeys(name);
5467
- const apiKey = firstEnv(env, keys.apiKeyKeys)?.value;
5468
- if (!apiKey)
6202
+ const wired = createClientTransport(name, env);
6203
+ if (wired.transport !== "http") {
5469
6204
  throw new Error(`Client for '${name}' resolved to the /v1 API without an API key.`);
5470
- const transport = createHttpTransport({ name, baseUrl: resolution.baseUrl, apiKey, ...fetchImpl ? { fetchImpl } : {} });
5471
- return { transport: "http", client: createStorageClient(name, transport), resolution };
6205
+ }
6206
+ return { transport: "http", client: createHasnaStorageClient(name, wired.client), resolution };
5472
6207
  }
5473
6208
 
5474
6209
  // src/store.ts
@@ -5551,6 +6286,22 @@ var localStore = {
5551
6286
  await withLocalStoreReaderLease(() => saveFeedback(input));
5552
6287
  }
5553
6288
  };
6289
+ async function listResource(client, resource, query) {
6290
+ const raw = await client.transport.get(`/${resource}`, query ? { query } : undefined);
6291
+ return { items: extractEnvelopeItems(raw, resource), raw };
6292
+ }
6293
+ function extractEnvelopeItems(raw, resource) {
6294
+ if (Array.isArray(raw))
6295
+ return raw;
6296
+ if (raw && typeof raw === "object") {
6297
+ const obj = raw;
6298
+ for (const key of [resource, "items", "data", "results", "rows", "records"]) {
6299
+ if (Array.isArray(obj[key]))
6300
+ return obj[key];
6301
+ }
6302
+ }
6303
+ return [];
6304
+ }
5554
6305
  function apiStore(client) {
5555
6306
  return {
5556
6307
  mode: "http",
@@ -5558,7 +6309,7 @@ function apiStore(client) {
5558
6309
  async createRecording(input, idempotencyKey) {
5559
6310
  const keyCandidate = idempotencyKey === undefined && (input.id === undefined || input.id === null) ? randomUUID2() : idempotencyKey;
5560
6311
  const identity = recordingCreateIdentity(input, keyCandidate, { bindIdempotencyKeyToId: false });
5561
- const res = await client.create("recordings", identity.input, identity.idempotencyKey);
6312
+ const res = await client.create("recordings", identity.input, { idempotencyKey: identity.idempotencyKey });
5562
6313
  return unwrap(res, "recording");
5563
6314
  },
5564
6315
  async getRecording(id) {
@@ -5566,7 +6317,7 @@ function apiStore(client) {
5566
6317
  return res ? unwrap(res, "recording") : null;
5567
6318
  },
5568
6319
  async listRecordings(filter) {
5569
- const { items } = await client.list("recordings", listQuery(filter));
6320
+ const { items } = await listResource(client, "recordings", listQuery(filter));
5570
6321
  return items;
5571
6322
  },
5572
6323
  async countRecordings(filter) {
@@ -5577,7 +6328,7 @@ function apiStore(client) {
5577
6328
  const seenPageKeys = new Set;
5578
6329
  while (pageRequests < maxPageRequests) {
5579
6330
  pageRequests += 1;
5580
- const { items, raw } = await client.list("recordings", {
6331
+ const { items, raw } = await listResource(client, "recordings", {
5581
6332
  ...listQuery(filter),
5582
6333
  limit: pageLimit,
5583
6334
  offset
@@ -5600,7 +6351,7 @@ function apiStore(client) {
5600
6351
  throw new Error(`Recordings API exceeded ${maxPageRequests} pages while counting legacy results`);
5601
6352
  },
5602
6353
  async searchRecordings(query, filter) {
5603
- const { items } = await client.list("recordings", listQuery({ ...filter ?? {}, search: query }));
6354
+ const { items } = await listResource(client, "recordings", listQuery({ ...filter ?? {}, search: query }));
5604
6355
  return items;
5605
6356
  },
5606
6357
  async deleteRecording(id) {
@@ -5632,7 +6383,7 @@ function apiStore(client) {
5632
6383
  return res ? unwrap(res, "agent") : null;
5633
6384
  },
5634
6385
  async listAgents() {
5635
- const { items } = await client.list("agents");
6386
+ const { items } = await listResource(client, "agents");
5636
6387
  return items;
5637
6388
  },
5638
6389
  async heartbeatAgent(idOrName) {
@@ -5672,7 +6423,7 @@ function apiStore(client) {
5672
6423
  return res ? unwrap(res, "project") : null;
5673
6424
  },
5674
6425
  async listProjects() {
5675
- const { items } = await client.list("projects");
6426
+ const { items } = await listResource(client, "projects");
5676
6427
  return items;
5677
6428
  },
5678
6429
  async saveFeedback(input) {
@@ -5720,7 +6471,7 @@ var cached = null;
5720
6471
  function getStore(env = process.env) {
5721
6472
  if (env === process.env && cached)
5722
6473
  return cached;
5723
- const resolved = resolveStorageClient(APP, env);
6474
+ const resolved = resolveStoreClient(APP, env);
5724
6475
  const store = resolved.transport === "http" ? apiStore(resolved.client) : localStore;
5725
6476
  if (env === process.env)
5726
6477
  cached = store;