@hasna/hooks 0.9.3 → 0.9.5

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.
@@ -3,7 +3,7 @@
3
3
  * across its public seam, spelled locally.
4
4
  *
5
5
  * WHY THIS FILE EXISTS. `@hasna/contracts` is a BUILD-TIME dependency of this
6
- * package (pinned 1.0.2, inlined by `bun build --target bun`), so the emitted
6
+ * package (pinned in package.json, inlined by `bun build --target bun`), so the emitted
7
7
  * `dist/**/*.d.ts` must never `import ... from "@hasna/contracts/client"` —
8
8
  * a published declaration that references a package consumers do not have is
9
9
  * a broken install (hasna/apps#1782). Every public signature therefore uses
@@ -6,6 +6,7 @@
6
6
  * failure mid-sync leaves the local store untouched. Local-only hooks are
7
7
  * never deleted by a remote sync.
8
8
  */
9
+ import { type HooksRemoteAuthority } from "./transport.js";
9
10
  import type { HooksCredentialOptions } from "./resolver-types.js";
10
11
  import { parseManifest } from "./manifest.js";
11
12
  type Env = Record<string, string | undefined>;
@@ -69,7 +70,7 @@ export interface StagedSyncArtifact {
69
70
  * written. A failure here (network, sha mismatch, malformed manifest,
70
71
  * containment violation) throws with the store untouched.
71
72
  */
72
- export declare function stageSyncArtifacts(apiUrl: string, apiKey: string, names: string[], remoteLock: RemoteLock): Promise<StagedSyncArtifact[]>;
73
+ export declare function stageSyncArtifacts(apiUrl: string, apiKey: string | HooksRemoteAuthority, names: string[], remoteLock: RemoteLock): Promise<StagedSyncArtifact[]>;
73
74
  /**
74
75
  * P1-9 commit phase, ordered so a mid-commit failure cannot leave a partial
75
76
  * store that reads as trusted:
@@ -107,7 +108,7 @@ export interface PinnedHookInstall {
107
108
  * the user is fetched from the versioned registry — older-than-latest pins
108
109
  * are first-class, never rejected as "not the latest".
109
110
  */
110
- export declare function fetchPinnedHook(name: string, version: string, apiUrl: string, apiKey: string): Promise<PinnedHookInstall>;
111
+ export declare function fetchPinnedHook(name: string, version: string, apiUrl: string, apiKey: string | HooksRemoteAuthority): Promise<PinnedHookInstall>;
111
112
  /**
112
113
  * Pin an exact hook version from the BUNDLED registry (the on-box catalog).
113
114
  * Powers `hooks install <name>@<version>` / `hooks update <name>@<version>`
@@ -8,7 +8,7 @@ export interface HooksRemoteAuthority {
8
8
  * client composes `/api/v1/...` routes on top of it.
9
9
  */
10
10
  origin: string;
11
- /** The credential, resolved together with the authority it will be sent to. */
11
+ /** Literal credential, or empty for a vault reference. Requests use resolveHooksRequestAuthority(). */
12
12
  apiKey: string;
13
13
  /** WHERE the authority came from: an env key NAME, a Keychain item reference, a file PATH, or "default". */
14
14
  apiUrlSource: string | null;
@@ -63,6 +63,8 @@ export interface HooksTransportOptions {
63
63
  /** Where the one-line local-mode notice goes. Defaults to `process.stderr`. */
64
64
  notice?: HooksTransportNotice;
65
65
  }
66
+ /** Resolve the selected credential through the shared consumer immediately before dispatch. */
67
+ export declare function resolveHooksRequestAuthority(authority: HooksRemoteAuthority): Promise<HooksRemoteAuthority>;
66
68
  /**
67
69
  * Resolve the hooks transport. The deliberate unhosted opt-in is answered
68
70
  * first and WITHOUT consulting the resolver; otherwise `@hasna/contracts`
@@ -85,3 +87,5 @@ export declare function resolveHooksTransport(env?: HooksLocalOptInEnv, options?
85
87
  * end.
86
88
  */
87
89
  export declare function resolveHooksServePublishKey(env?: HooksLocalOptInEnv, options?: HooksTransportOptions): string | undefined;
90
+ /** Server request boundary: references remain terminal and are never cached as a literal key. */
91
+ export declare function resolveHooksServePublishKeyAsync(env?: HooksLocalOptInEnv, options?: HooksTransportOptions): Promise<string | undefined>;
@@ -71,8 +71,8 @@ export interface HooksClientOptions {
71
71
  */
72
72
  export declare function resolveHooksSdkAuthority(env?: HooksLocalOptInEnv, options?: Omit<HooksTransportOptions, "notice">): HooksRemoteAuthority;
73
73
  /**
74
- * Hosted registry client. Holds the resolved credential privately and sends
75
- * it only to the authority it was resolved together with (strict pair).
74
+ * Hosted registry client. Holds the authority binding privately and resolves
75
+ * its current credential for each request before dispatch to that authority.
76
76
  */
77
77
  export declare class HooksClient {
78
78
  #private;
package/dist/sdk/index.js CHANGED
@@ -1,12 +1,17 @@
1
1
  // @bun
2
- // ../../node_modules/.bun/@hasna+contracts@1.0.2+e0db24ae03127387/node_modules/@hasna/contracts/dist/client/transport.js
2
+ // ../contracts/dist/client/transport.js
3
3
  import { isIP } from "net";
4
4
  import { spawnSync } from "child_process";
5
- import { closeSync, fstatSync, openSync, readFileSync } from "fs";
6
- import { O_NOFOLLOW, O_NONBLOCK, O_RDONLY } from "constants";
7
- import { createRequire } from "module";
5
+ import { closeSync as closeSync2, fstatSync as fstatSync2, openSync as openSync2, readFileSync as readFileSync2 } from "fs";
6
+ import { O_NOFOLLOW as O_NOFOLLOW2, O_NONBLOCK as O_NONBLOCK2, O_RDONLY as O_RDONLY2 } from "constants";
8
7
  import { hostname as osHostname } from "os";
9
- import { isAbsolute, join } from "path";
8
+ import { isAbsolute, join as join2 } from "path";
9
+ import { createRequire } from "module";
10
+ import { fileURLToPath as fileURLToPath2 } from "url";
11
+ import { closeSync, fstatSync, lstatSync, openSync, readFileSync, realpathSync, statSync } from "fs";
12
+ import { O_NOFOLLOW, O_NONBLOCK, O_RDONLY } from "constants";
13
+ import { basename, dirname, join, relative, sep } from "path";
14
+ import { fileURLToPath, pathToFileURL } from "url";
10
15
  function envToken(name) {
11
16
  return name.toUpperCase().replace(/-/g, "_");
12
17
  }
@@ -24,6 +29,114 @@ var CREDENTIAL_PROFILE_ENV_KEY = "HASNA_PROFILE";
24
29
  function credentialPointerEnvKey(name) {
25
30
  return `HASNA_${envToken(name)}_API_KEY_REF`;
26
31
  }
32
+ function e(e2, n, r) {
33
+ throw new Error(r ? `No known conditions for "${n}" specifier in "${e2}" package` : `Missing "${n}" specifier in "${e2}" package`);
34
+ }
35
+ function n(n2, i, o, f) {
36
+ let s, u, l = r(n2, o), c = function(e2) {
37
+ let n3 = new Set(["default", ...e2.conditions || []]);
38
+ return e2.unsafe || n3.add(e2.require ? "require" : "import"), e2.unsafe || n3.add(e2.browser ? "browser" : "node"), n3;
39
+ }(f || {}), a = i[l];
40
+ if (a === undefined) {
41
+ let e2, n3, r, t;
42
+ for (t in i)
43
+ n3 && t.length < n3.length || (t[t.length - 1] === "/" && l.startsWith(t) ? (u = l.substring(t.length), n3 = t) : t.length > 1 && (r = t.indexOf("*", 1), ~r && (e2 = RegExp("^" + t.substring(0, r) + "(.*)" + t.substring(1 + r) + "$").exec(l), e2 && e2[1] && (u = e2[1], n3 = t))));
44
+ a = i[n3];
45
+ }
46
+ return a || e(n2, l), s = t(a, c), s || e(n2, l, 1), u && function(e2, n3) {
47
+ let r, t = 0, i2 = e2.length, o2 = /[*]/g, f2 = /[/]$/;
48
+ for (;t < i2; t++)
49
+ e2[t] = o2.test(r = e2[t]) ? r.replace(o2, n3) : f2.test(r) ? r + n3 : r;
50
+ }(s, u), s;
51
+ }
52
+ function r(e2, n2, r2) {
53
+ if (e2 === n2 || n2 === ".")
54
+ return ".";
55
+ let t = e2 + "/", i = t.length, o = n2.slice(0, i) === t, f = o ? n2.slice(i) : n2;
56
+ return f[0] === "#" ? f : o || !r2 ? f.slice(0, 2) === "./" ? f : "./" + f : f;
57
+ }
58
+ function t(e2, n2, r2) {
59
+ if (e2) {
60
+ if (typeof e2 == "string")
61
+ return r2 && r2.add(e2), [e2];
62
+ let i, o;
63
+ if (Array.isArray(e2)) {
64
+ for (o = r2 || new Set, i = 0;i < e2.length; i++)
65
+ t(e2[i], n2, o);
66
+ if (!r2 && o.size)
67
+ return [...o];
68
+ } else
69
+ for (i in e2)
70
+ if (n2.has(i))
71
+ return t(e2[i], n2, r2);
72
+ }
73
+ }
74
+ function o(e2, r2, t2) {
75
+ let i, o2 = e2.exports;
76
+ if (o2) {
77
+ if (typeof o2 == "string")
78
+ o2 = { ".": o2 };
79
+ else
80
+ for (i in o2) {
81
+ i[0] !== "." && (o2 = { ".": o2 });
82
+ break;
83
+ }
84
+ return n(e2.name, o2, r2 || ".", t2);
85
+ }
86
+ }
87
+ var PACKAGE = "@hasna/secrets";
88
+ var MAX_PACKAGE_BYTES = 1024 * 1024;
89
+ function entryPoint(directory) {
90
+ const root = realpathSync(directory);
91
+ const fd = openSync(join(root, "package.json"), O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
92
+ let pkg;
93
+ try {
94
+ const stat = fstatSync(fd);
95
+ if (!stat.isFile() || stat.size > MAX_PACKAGE_BYTES)
96
+ throw new Error("Invalid Secrets package metadata");
97
+ pkg = JSON.parse(readFileSync(fd, "utf8"));
98
+ } finally {
99
+ closeSync(fd);
100
+ }
101
+ if (!pkg || typeof pkg !== "object" || !("name" in pkg) || pkg.name !== PACKAGE) {
102
+ throw new Error("Invalid Secrets package identity");
103
+ }
104
+ const target = o(pkg, ".")?.[0];
105
+ if (typeof target !== "string" || !target.startsWith("./"))
106
+ throw new Error("Missing Secrets import export");
107
+ const parts = target.slice(2).split("/");
108
+ if (/[\\%?#\0]/.test(target) || parts.some((part) => !part || part === "." || part === ".." || part === "node_modules")) {
109
+ throw new Error("Invalid Secrets import export");
110
+ }
111
+ const file = realpathSync(join(root, ...parts));
112
+ const within = relative(root, file);
113
+ if (!within || within === ".." || within.startsWith(`..${sep}`) || !statSync(file).isFile()) {
114
+ throw new Error("Secrets import export escapes its package or is not a file");
115
+ }
116
+ return pathToFileURL(file).href;
117
+ }
118
+ function resolveInstalledSecrets(parentUrl) {
119
+ let directory = dirname(fileURLToPath(parentUrl));
120
+ for (;; ) {
121
+ if (basename(directory) !== "node_modules") {
122
+ const candidate = join(directory, "node_modules", PACKAGE);
123
+ let absent = false;
124
+ try {
125
+ lstatSync(candidate);
126
+ } catch (error) {
127
+ if (error.code !== "ENOENT")
128
+ throw error;
129
+ absent = true;
130
+ }
131
+ if (!absent)
132
+ return entryPoint(candidate);
133
+ }
134
+ const parent = dirname(directory);
135
+ if (parent === directory)
136
+ throw new Error("Secrets SDK is not installed for this consumer");
137
+ directory = parent;
138
+ }
139
+ }
27
140
 
28
141
  class CredentialResolutionError extends Error {
29
142
  appName;
@@ -72,14 +185,14 @@ function hasnaHomeDir(env) {
72
185
  if (override)
73
186
  return override;
74
187
  const home = homeDir(env);
75
- return home ? join(home, HASNA_HOME_DIR) : null;
188
+ return home ? join2(home, HASNA_HOME_DIR) : null;
76
189
  }
77
190
  function appConfigDir(name, env) {
78
191
  const configRoot = absoluteOverride(env, HASNA_CONFIG_HOME_ENV_KEY);
79
192
  if (configRoot)
80
- return join(configRoot, name);
193
+ return join2(configRoot, name);
81
194
  const root = hasnaHomeDir(env);
82
- return root ? join(root, name, CONFIG_SUBDIR) : null;
195
+ return root ? join2(root, name, CONFIG_SUBDIR) : null;
83
196
  }
84
197
  function credentialDiskSourceList(name, env, profile = null) {
85
198
  if (!SAFE_APP_SLUG.test(name))
@@ -88,7 +201,7 @@ function credentialDiskSourceList(name, env, profile = null) {
88
201
  if (!directory)
89
202
  return [];
90
203
  const file = profile ? `${CREDENTIALS_FILE}-${profile}` : CREDENTIALS_FILE;
91
- return [{ path: join(directory, file), tier: "disk" }];
204
+ return [{ path: join2(directory, file), tier: "disk" }];
92
205
  }
93
206
  function credentialDiskSources(name, env) {
94
207
  return credentialDiskSourceList(name, env, null).map((s) => s.path);
@@ -142,7 +255,7 @@ function readAppConfigFile(path) {
142
255
  };
143
256
  let fd = -1;
144
257
  try {
145
- fd = openSync(path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
258
+ fd = openSync2(path, O_RDONLY2 | O_NOFOLLOW2 | O_NONBLOCK2);
146
259
  } catch (error) {
147
260
  const code = error.code;
148
261
  if (code === "ENOENT" || code === "ENOTDIR")
@@ -152,7 +265,7 @@ function readAppConfigFile(path) {
152
265
  unsafe(`the path could not be opened (${code ?? "unknown error"})`);
153
266
  }
154
267
  try {
155
- const before = fstatSync(fd);
268
+ const before = fstatSync2(fd);
156
269
  if (!before.isFile())
157
270
  unsafe("the path is not a regular file");
158
271
  if (!configFileModeAllowed(before.mode)) {
@@ -163,31 +276,39 @@ function readAppConfigFile(path) {
163
276
  unsafe("the file is not owned by the current user");
164
277
  if (before.size > MAX_CREDENTIAL_FILE_BYTES)
165
278
  unsafe("the file exceeds the size limit");
166
- const bytes = readFileSync(fd);
167
- const after = fstatSync(fd);
279
+ const bytes = readFileSync2(fd);
280
+ const after = fstatSync2(fd);
168
281
  if (!configFileReadsCoherent(before, after)) {
169
282
  unsafe("the file changed while being read");
170
283
  }
171
284
  return parseEnvFile(bytes.toString("utf8"));
172
285
  } finally {
173
286
  if (fd !== -1)
174
- closeSync(fd);
287
+ closeSync2(fd);
175
288
  }
176
289
  }
177
- function readCredentialFile(path, apiKeyKeys) {
290
+ function readCredentialFile(path, apiKeyKeys, pointerKey) {
178
291
  const parsed = readAppConfigFile(path);
179
292
  if (!parsed)
180
293
  return null;
181
- for (const key of apiKeyKeys) {
294
+ for (const key of [...apiKeyKeys, pointerKey]) {
182
295
  if (parsed.unusable.has(key)) {
183
296
  throw new CredentialFileUnsafeError(path, `${key} is declared but blank or malformed`);
184
297
  }
185
298
  }
186
299
  const values = apiKeyKeys.map((key) => parsed.values.get(key)?.trim()).filter((value) => Boolean(value));
300
+ const pointer = parsed.values.get(pointerKey)?.trim();
301
+ if (pointer !== undefined) {
302
+ if (!VAULT_POINTER_SHAPE.test(pointer))
303
+ throw new CredentialFileUnsafeError(path, `${pointerKey} must name a vault item`);
304
+ if (values.length)
305
+ throw new CredentialFileUnsafeError(path, "a credential file cannot select both a literal key and a vault reference");
306
+ return { apiKey: "", pointerVaultKey: pointer };
307
+ }
187
308
  if (new Set(values).size > 1) {
188
309
  throw new CredentialFileUnsafeError(path, "credential aliases disagree");
189
310
  }
190
- return values[0] ?? null;
311
+ return values[0] === undefined ? null : { apiKey: values[0] };
191
312
  }
192
313
  var CREDENTIAL_SHAPED_KEY = /(?:^|_)(?:API_KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)(?:_|$)/;
193
314
  function appConfigDiskValue(name, env, keys) {
@@ -262,6 +383,18 @@ function sealCredential(fields) {
262
383
  });
263
384
  return Object.freeze(sealed);
264
385
  }
386
+ function explicitCredential(appName, apiKey) {
387
+ const source = "explicit apiKey option";
388
+ assertUsableCredential(appName, source, apiKey);
389
+ return sealCredential({
390
+ apiKey,
391
+ tier: "argument",
392
+ source,
393
+ deliberate: true,
394
+ diskCandidates: [],
395
+ warning: null
396
+ });
397
+ }
265
398
  function firstEnvValue(env, keys) {
266
399
  for (const key of keys) {
267
400
  if (!Object.prototype.hasOwnProperty.call(env, key))
@@ -448,12 +581,13 @@ function resolveCredential(name, env, options = {}) {
448
581
  }
449
582
  const paths = profileDiskSources(name, env, profile);
450
583
  for (const path of paths) {
451
- const value = readCredentialFile(path, apiKeyKeys);
584
+ const value = readCredentialFile(path, apiKeyKeys, pointerKeyName);
452
585
  if (value) {
453
- assertUsableCredential(name, path, value);
586
+ if (!value.pointerVaultKey)
587
+ assertUsableCredential(name, path, value.apiKey);
454
588
  return sealCredential({
455
- apiKey: value,
456
- tier: "profile",
589
+ ...value,
590
+ tier: value.pointerVaultKey ? "pointer" : "profile",
457
591
  source: path,
458
592
  deliberate: true,
459
593
  diskCandidates: paths,
@@ -461,7 +595,7 @@ function resolveCredential(name, env, options = {}) {
461
595
  });
462
596
  }
463
597
  }
464
- 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);
598
+ throw new CredentialResolutionError(name, `Profile '${profile}' (from ${profileSource}) has no ${apiKeyKeys[0]} or ${pointerKeyName} 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);
465
599
  }
466
600
  const definedEnvEntries = apiKeyKeys.filter((key) => Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined).map((key) => ({ key, value: String(env[key]).trim() }));
467
601
  const blankEnv = definedEnvEntries.find((entry) => entry.value.length === 0);
@@ -486,17 +620,27 @@ function resolveCredential(name, env, options = {}) {
486
620
  });
487
621
  }
488
622
  const diskSourceList = credentialDiskSourceList(name, env, null);
489
- const diskHits = diskSourceList.map((src) => ({ src, value: readCredentialFile(src.path, apiKeyKeys) })).filter((hit) => hit.value !== null);
623
+ const diskHits = diskSourceList.map((src) => ({ src, value: readCredentialFile(src.path, apiKeyKeys, pointerKeyName) })).filter((hit) => hit.value !== null);
490
624
  if (diskHits.length > 0) {
491
625
  const winner = diskHits[0];
492
- assertUsableCredential(name, winner.src.path, winner.value);
626
+ if (winner.value.pointerVaultKey) {
627
+ return sealCredential({
628
+ ...winner.value,
629
+ tier: "pointer",
630
+ source: winner.src.path,
631
+ deliberate: false,
632
+ diskCandidates: diskPaths,
633
+ warning: null
634
+ });
635
+ }
636
+ assertUsableCredential(name, winner.src.path, winner.value.apiKey);
493
637
  const divergentSources = [
494
- ...diskHits.slice(1).filter((hit) => hit.value !== winner.value).map((hit) => hit.src.path),
495
- ...envHit && envHit.value !== winner.value ? [envHit.key] : []
638
+ ...diskHits.slice(1).filter((hit) => hit.value.apiKey !== winner.value.apiKey || hit.value.pointerVaultKey !== winner.value.pointerVaultKey).map((hit) => hit.src.path),
639
+ ...envHit && envHit.value !== winner.value.apiKey ? [envHit.key] : []
496
640
  ];
497
641
  const warning = divergentSources.length > 0 ? `Credential sources disagree for '${name}': ${winner.src.path} and ` + `${divergentSources.join(", ")} hold different keys. ${winner.src.path} wins, because a file on ` + `disk is re-read on every call while an environment variable is a snapshot. Reconcile them \u2014 ` + `a rotation that updated only one leaves the other to fail 401 wherever it is loaded first.` : null;
498
642
  return sealCredential({
499
- apiKey: winner.value,
643
+ apiKey: winner.value.apiKey,
500
644
  tier: winner.src.tier,
501
645
  source: winner.src.path,
502
646
  deliberate: false,
@@ -517,8 +661,54 @@ function resolveCredential(name, env, options = {}) {
517
661
  }
518
662
  return null;
519
663
  }
520
- var SECRETS_PACKAGE_SPECIFIER = "@hasna/" + "secrets";
521
664
  var requireSecretsSdk = createRequire(import.meta.url);
665
+ async function completePointerCredential(name, pointerResolution, env = process.env) {
666
+ const secretsEnv = snapshotClientEnvironment("secrets", env);
667
+ const vaultKey = pointerResolution.pointerVaultKey;
668
+ const pointerEnvKey = pointerResolution.source;
669
+ if (!vaultKey) {
670
+ throw new CredentialResolutionError(name, `Pointer resolution from ${pointerEnvKey} carries no vault item key; this is a defect in the resolver.`, [pointerEnvKey]);
671
+ }
672
+ if (name === "secrets") {
673
+ throw new CredentialResolutionError(name, "The Secrets bootstrap credential cannot reference the same hosted vault; configure an independent bootstrap provider.", [pointerEnvKey]);
674
+ }
675
+ let secretsSdk;
676
+ try {
677
+ const sdkUrl = resolveInstalledSecrets(import.meta.url);
678
+ secretsSdk = requireSecretsSdk(fileURLToPath2(sdkUrl));
679
+ } catch {
680
+ throw new CredentialResolutionError(name, `${pointerEnvKey} names vault item '${vaultKey}', but the secrets SDK (@hasna/secrets) is not installed ` + `in this process. A vault pointer is TERMINAL: install @hasna/secrets to resolve it, or unset ${pointerEnvKey}.`, [pointerEnvKey]);
681
+ }
682
+ let client;
683
+ try {
684
+ const bootstrap = resolveCredential("secrets", secretsEnv);
685
+ if (!bootstrap || bootstrap.tier === "pointer") {
686
+ throw new CredentialResolutionError("secrets", "The Secrets vault requires an independent, non-reference bootstrap credential.", bootstrap ? [bootstrap.source] : []);
687
+ }
688
+ client = secretsSdk.createSecretsClientFromEnv(secretsEnv);
689
+ } catch {
690
+ throw new CredentialResolutionError(name, `${pointerEnvKey} names vault item '${vaultKey}', but the secrets client could not be configured from this ` + `environment (the secrets service URL and key env are missing or invalid). A vault pointer is TERMINAL and ` + `never falls through to a literal or disk credential.`, [pointerEnvKey]);
691
+ }
692
+ let secret;
693
+ try {
694
+ secret = await client.getSecret({ key: vaultKey });
695
+ } catch {
696
+ throw new CredentialResolutionError(name, `${pointerEnvKey} names vault item '${vaultKey}', but the vault could not be reached or the item is ` + `unavailable. A vault pointer is TERMINAL and never falls through to a literal or disk credential.`, [pointerEnvKey]);
697
+ }
698
+ const value = secret.value;
699
+ if (!value) {
700
+ throw new CredentialResolutionError(name, `${pointerEnvKey} resolved vault item '${vaultKey}', but it holds no value. A vault pointer is TERMINAL.`, [pointerEnvKey]);
701
+ }
702
+ assertUsableCredential(name, `${pointerEnvKey} -> vault:${vaultKey}`, value);
703
+ return sealCredential({
704
+ apiKey: value,
705
+ tier: "pointer",
706
+ source: `${pointerEnvKey} -> vault:${vaultKey}`,
707
+ deliberate: pointerResolution.deliberate,
708
+ diskCandidates: pointerResolution.diskCandidates,
709
+ warning: null
710
+ });
711
+ }
522
712
  var DEFAULT_FLEET_GATEWAY_ORIGIN = "https://api.hasna.com";
523
713
  var DEFAULT_AUTHORITY_SOURCE = "default";
524
714
  function defaultFleetGatewayBaseUrl(name) {
@@ -747,7 +937,7 @@ var AUTHORITY_OVERRIDE_HEADERS = new Set([
747
937
  // src/lib/app-home.ts
748
938
  import { existsSync } from "fs";
749
939
  import { homedir } from "os";
750
- import { join as join2, resolve } from "path";
940
+ import { join as join3, resolve } from "path";
751
941
  import { homedir as pathsResolverHomedir } from "os";
752
942
  import { join as pathsResolverJoin } from "path";
753
943
  var PATHS_RESOLVER_KIND_ENV = {
@@ -816,13 +1006,13 @@ function getResolverDataRoot(env = process.env) {
816
1006
  return dataDir({ app: "hooks", home: getHomeDir(env), env });
817
1007
  }
818
1008
  function getLegacyDataRoot(env = process.env) {
819
- return join2(getHomeDir(env), ".hasna", "hooks");
1009
+ return join3(getHomeDir(env), ".hasna", "hooks");
820
1010
  }
821
1011
  function adoptResolverDataRoot(resolved, env = process.env) {
822
1012
  const dataOverride = env.HASNA_DATA_HOME;
823
1013
  if (typeof dataOverride === "string" && dataOverride.trim().length > 0)
824
1014
  return true;
825
- return existsSync(join2(resolved, STORE_MARKER));
1015
+ return existsSync(join3(resolved, STORE_MARKER));
826
1016
  }
827
1017
  function getExplicitDataDir(env = process.env) {
828
1018
  const dir = env.HASNA_HOOKS_DATA_DIR ?? env.HOOKS_DATA_DIR;
@@ -931,6 +1121,41 @@ function rethrowHooksAuthorityFailure(error) {
931
1121
  }
932
1122
  throw new Error(`REMOTE_API_URL_INVALID: ${message} local mode fallback is disabled`, { cause: error });
933
1123
  }
1124
+ var authorityBindings = new WeakMap;
1125
+ function sameCredential(left, right) {
1126
+ return left === null || right === null ? left === right : left.apiKey === right.apiKey && left.pointerVaultKey === right.pointerVaultKey && left.source === right.source && left.tier === right.tier;
1127
+ }
1128
+ function changedBinding() {
1129
+ throw new Error("REMOTE_API_CREDENTIAL_INVALID: The configured Hooks authority or credential changed while preparing a request; no request was sent.");
1130
+ }
1131
+ async function resolveHooksRequestAuthority(authority) {
1132
+ if (authority.origin !== hooksRegistryOrigin(authority.v1BaseUrl))
1133
+ changedBinding();
1134
+ const binding = authorityBindings.get(authority);
1135
+ if (!binding)
1136
+ return { ...authority, apiKey: explicitCredential("hooks", authority.apiKey).apiKey };
1137
+ const snapshot = () => {
1138
+ const next = resolveHooksTransport(binding.env, binding.options);
1139
+ if (next.mode !== "remote" || !next.authority || next.authority.v1BaseUrl !== authority.v1BaseUrl)
1140
+ changedBinding();
1141
+ return { authority: next.authority, binding: authorityBindings.get(next.authority) };
1142
+ };
1143
+ const first = snapshot();
1144
+ const reviewed = snapshot();
1145
+ if (!sameCredential(first.binding.credential, reviewed.binding.credential))
1146
+ changedBinding();
1147
+ let credential = reviewed.binding.credential;
1148
+ try {
1149
+ if (credential.tier === "pointer")
1150
+ credential = await completePointerCredential("hooks", credential, binding.env);
1151
+ } catch (error) {
1152
+ rethrowHooksAuthorityFailure(error);
1153
+ }
1154
+ const final = snapshot();
1155
+ if (!sameCredential(reviewed.binding.credential, final.binding.credential))
1156
+ changedBinding();
1157
+ return { ...reviewed.authority, apiKey: credential.apiKey };
1158
+ }
934
1159
  function resolveHooksTransport(env = process.env, options = {}) {
935
1160
  if (selectsHooksLocalStore(env)) {
936
1161
  announceLocal(options.notice, "HASNA_HOOKS_LOCAL is set and nothing configures a registry authority");
@@ -944,7 +1169,7 @@ function resolveHooksTransport(env = process.env, options = {}) {
944
1169
  try {
945
1170
  credential = resolveCredential("hooks", resolverEnv, credentialOptions);
946
1171
  resolution = resolveClientTransport("hooks", resolverEnv, {
947
- credentials: credential ? { ...credentialOptions, apiKey: credential.apiKey } : credentialOptions
1172
+ credentials: credential && credential.tier !== "pointer" ? { ...credentialOptions, apiKey: credential.apiKey } : credentialOptions
948
1173
  });
949
1174
  } catch (error) {
950
1175
  rethrowHooksAuthorityFailure(error);
@@ -952,18 +1177,20 @@ function resolveHooksTransport(env = process.env, options = {}) {
952
1177
  if (!credential) {
953
1178
  throw new Error("REMOTE_API_CONFIG_MISSING: no hooks registry credential resolved; " + "local mode fallback is disabled");
954
1179
  }
1180
+ const authority = {
1181
+ origin: hooksRegistryOrigin(resolution.baseUrl),
1182
+ apiKey: credential.apiKey,
1183
+ apiUrlSource: resolution.apiUrlSource,
1184
+ apiKeySource: credential.source,
1185
+ apiKeyTier: credential.tier,
1186
+ v1BaseUrl: resolution.baseUrl,
1187
+ warning: resolution.warning
1188
+ };
1189
+ authorityBindings.set(authority, { env, options, credential });
955
1190
  return {
956
1191
  mode: "remote",
957
1192
  source: `${credential.source}+${resolution.apiUrlSource ?? "default"}`,
958
- authority: {
959
- origin: hooksRegistryOrigin(resolution.baseUrl),
960
- apiKey: credential.apiKey,
961
- apiUrlSource: resolution.apiUrlSource,
962
- apiKeySource: credential.source,
963
- apiKeyTier: credential.tier,
964
- v1BaseUrl: resolution.baseUrl,
965
- warning: resolution.warning
966
- }
1193
+ authority
967
1194
  };
968
1195
  }
969
1196
 
@@ -984,14 +1211,14 @@ class HooksClient {
984
1211
  v1BaseUrl;
985
1212
  apiKeySource;
986
1213
  apiUrlSource;
987
- #apiKey;
1214
+ #authority;
988
1215
  #fetch;
989
1216
  constructor(authority, options = {}) {
990
1217
  this.origin = authority.origin;
991
1218
  this.v1BaseUrl = authority.v1BaseUrl;
992
1219
  this.apiKeySource = authority.apiKeySource;
993
1220
  this.apiUrlSource = authority.apiUrlSource;
994
- this.#apiKey = authority.apiKey;
1221
+ this.#authority = authority;
995
1222
  this.#fetch = options.fetch ?? fetch;
996
1223
  }
997
1224
  urlFor(path) {
@@ -1023,13 +1250,15 @@ class HooksClient {
1023
1250
  return { ...body, sha256: res.headers.get("x-hook-sha256") };
1024
1251
  }
1025
1252
  async request(path) {
1026
- const res = await this.#fetch(this.urlFor(path), {
1027
- headers: { accept: "application/json", "x-api-key": this.#apiKey },
1253
+ const authority = await resolveHooksRequestAuthority(this.#authority);
1254
+ const url = `${authority.origin}${path.startsWith("/") ? path : `/${path}`}`;
1255
+ const res = await this.#fetch(url, {
1256
+ headers: { accept: "application/json", "x-api-key": authority.apiKey },
1028
1257
  redirect: "manual"
1029
1258
  });
1030
1259
  if (!res.ok) {
1031
1260
  const code = res.status === 401 || res.status === 403 ? "REMOTE_API_CREDENTIAL_INVALID" : "REMOTE_API_HTTP_ERROR";
1032
- throw new Error(`${code}: hooks registry ${this.urlFor(path)} responded ${res.status}`);
1261
+ throw new Error(`${code}: hooks registry ${url} responded ${res.status}`);
1033
1262
  }
1034
1263
  return res;
1035
1264
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/hooks",
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
4
4
  "description": "Open source hooks library for AI coding agents - Install safety, quality, and automation hooks with a single command",
5
5
  "type": "module",
6
6
  "bin": {
@@ -54,11 +54,11 @@
54
54
  "@types/react": "^18.2.0",
55
55
  "typescript": "^5",
56
56
  "bun-types": "1.3.14",
57
- "@hasna/contracts": "1.0.2"
57
+ "@hasna/contracts": "1.2.1"
58
58
  },
59
59
  "dependencies": {
60
60
  "@hasna/events": "^0.1.16",
61
- "@hasna/secrets": "0.3.10",
61
+ "@hasna/secrets": "0.4.2",
62
62
  "@modelcontextprotocol/sdk": "^1.26.0",
63
63
  "chalk": "^5.3.0",
64
64
  "commander": "^12.1.0",