@hasna/hooks 0.7.9 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -6
- package/bin/index.js +1573 -695
- package/bin/serve.js +713 -335
- package/dist/config.d.ts +25 -19
- package/dist/db/index.d.ts +3 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1162 -473
- package/dist/lib/app-home.d.ts +74 -0
- package/dist/lib/installer.d.ts +0 -9
- package/dist/lib/local-opt-in.d.ts +80 -0
- package/dist/lib/profiles.d.ts +14 -1
- package/dist/lib/resolver-types.d.ts +45 -0
- package/dist/lib/sync.d.ts +13 -8
- package/dist/lib/transport.d.ts +87 -0
- package/dist/openapi.d.ts +1 -1
- package/dist/serve.d.ts +7 -0
- package/dist/storage.js +141 -27
- package/hooks/hook-affected-tests/package.json +1 -1
- package/hooks/hook-agent-rules-version-check/package.json +1 -1
- package/hooks/hook-agentmessages/package.json +1 -1
- package/hooks/hook-announce-start/package.json +1 -1
- package/hooks/hook-announce-stop/package.json +1 -1
- package/hooks/hook-autoformat/package.json +1 -1
- package/hooks/hook-branchprotect/package.json +1 -1
- package/hooks/hook-checkbugs/package.json +1 -1
- package/hooks/hook-checkdocs/package.json +1 -1
- package/hooks/hook-checkfiles/package.json +1 -1
- package/hooks/hook-checklint/package.json +1 -1
- package/hooks/hook-checkpoint/package.json +1 -1
- package/hooks/hook-checksecurity/package.json +1 -1
- package/hooks/hook-checktasks/package.json +1 -1
- package/hooks/hook-checktests/package.json +1 -1
- package/hooks/hook-conflict-detect/package.json +1 -1
- package/hooks/hook-contextrefresh/package.json +1 -1
- package/hooks/hook-desktopnotify/package.json +1 -1
- package/hooks/hook-dm-inject/package.json +1 -1
- package/hooks/hook-envsetup/package.json +1 -1
- package/hooks/hook-failure-to-task/package.json +1 -1
- package/hooks/hook-filelock/package.json +1 -1
- package/hooks/hook-fleet-blockers-gate/package.json +1 -1
- package/hooks/hook-fleet-catchup/package.json +1 -1
- package/hooks/hook-gitguard/package.json +1 -1
- package/hooks/hook-packageage/package.json +1 -1
- package/hooks/hook-permissionguard/package.json +1 -1
- package/hooks/hook-phonenotify/package.json +1 -1
- package/hooks/hook-precompact/package.json +1 -1
- package/hooks/hook-protectfiles/package.json +1 -1
- package/hooks/hook-stylescheck/package.json +1 -1
- package/hooks/hook-typecheck-gate/package.json +1 -1
- package/package.json +6 -9
- package/scripts/artifact-scan.ts +49 -0
- package/scripts/ensure-profiles-dir.mjs +99 -0
- package/scripts/validate-package.ts +280 -0
package/bin/serve.js
CHANGED
|
@@ -15,6 +15,121 @@ var __export = (target, all) => {
|
|
|
15
15
|
});
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
+
// src/lib/app-home.ts
|
|
19
|
+
import { existsSync } from "fs";
|
|
20
|
+
import { homedir } from "os";
|
|
21
|
+
import { join, resolve } from "path";
|
|
22
|
+
import { homedir as pathsResolverHomedir } from "os";
|
|
23
|
+
import { join as pathsResolverJoin } from "path";
|
|
24
|
+
var PATHS_RESOLVER_KIND_ENV = {
|
|
25
|
+
config: "HASNA_CONFIG_HOME",
|
|
26
|
+
data: "HASNA_DATA_HOME",
|
|
27
|
+
state: "HASNA_STATE_HOME",
|
|
28
|
+
cache: "HASNA_CACHE_HOME"
|
|
29
|
+
};
|
|
30
|
+
var PATHS_RESOLVER_APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
31
|
+
function pathsResolverAssertApp(app) {
|
|
32
|
+
if (typeof app !== "string" || app.length === 0) {
|
|
33
|
+
throw new TypeError("paths: app must be a non-empty string");
|
|
34
|
+
}
|
|
35
|
+
if (!PATHS_RESOLVER_APP_SLUG_RE.test(app)) {
|
|
36
|
+
throw new TypeError(`paths: invalid app slug "${app}" \u2014 expected lowercase kebab-case ([a-z0-9]+(-[a-z0-9]+)*)`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function pathsResolverAssertKind(kind) {
|
|
40
|
+
if (!Object.keys(PATHS_RESOLVER_KIND_ENV).includes(kind)) {
|
|
41
|
+
throw new TypeError(`paths: invalid path kind "${kind}" \u2014 expected one of ${Object.keys(PATHS_RESOLVER_KIND_ENV).join(", ")}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function pathsResolverBaseDir(kind, options) {
|
|
45
|
+
pathsResolverAssertKind(kind);
|
|
46
|
+
const env = options.env ?? process.env;
|
|
47
|
+
const override = env[PATHS_RESOLVER_KIND_ENV[kind]];
|
|
48
|
+
if (typeof override === "string" && override.length > 0)
|
|
49
|
+
return override;
|
|
50
|
+
const home = options.home ?? pathsResolverHomedir();
|
|
51
|
+
const platform = options.platform ?? process.platform;
|
|
52
|
+
if (platform === "darwin") {
|
|
53
|
+
switch (kind) {
|
|
54
|
+
case "config":
|
|
55
|
+
case "data":
|
|
56
|
+
return pathsResolverJoin(home, "Library", "Application Support", "Hasna");
|
|
57
|
+
case "cache":
|
|
58
|
+
return pathsResolverJoin(home, "Library", "Caches", "Hasna");
|
|
59
|
+
case "state":
|
|
60
|
+
return pathsResolverJoin(home, "Library", "Logs", "Hasna");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
switch (kind) {
|
|
64
|
+
case "config":
|
|
65
|
+
return pathsResolverJoin(home, ".config", "hasna");
|
|
66
|
+
case "data":
|
|
67
|
+
return pathsResolverJoin(home, ".local", "share", "hasna");
|
|
68
|
+
case "state":
|
|
69
|
+
return pathsResolverJoin(home, ".local", "state", "hasna");
|
|
70
|
+
case "cache":
|
|
71
|
+
return pathsResolverJoin(home, ".cache", "hasna");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function pathsResolverResolve(kind, options) {
|
|
75
|
+
pathsResolverAssertApp(options.app);
|
|
76
|
+
const appSegment = options.internal === true ? pathsResolverJoin("internal", options.app) : options.app;
|
|
77
|
+
return pathsResolverJoin(pathsResolverBaseDir(kind, options), appSegment);
|
|
78
|
+
}
|
|
79
|
+
function dataDir(options) {
|
|
80
|
+
return pathsResolverResolve("data", options);
|
|
81
|
+
}
|
|
82
|
+
var STORE_MARKER = "hooks.db";
|
|
83
|
+
function getHomeDir(env = process.env) {
|
|
84
|
+
return env.HOME || env.USERPROFILE || homedir();
|
|
85
|
+
}
|
|
86
|
+
function getResolverDataRoot(env = process.env) {
|
|
87
|
+
return dataDir({ app: "hooks", home: getHomeDir(env), env });
|
|
88
|
+
}
|
|
89
|
+
function getLegacyDataRoot(env = process.env) {
|
|
90
|
+
return join(getHomeDir(env), ".hasna", "hooks");
|
|
91
|
+
}
|
|
92
|
+
function adoptResolverDataRoot(resolved, env = process.env) {
|
|
93
|
+
const dataOverride = env.HASNA_DATA_HOME;
|
|
94
|
+
if (typeof dataOverride === "string" && dataOverride.trim().length > 0)
|
|
95
|
+
return true;
|
|
96
|
+
return existsSync(join(resolved, STORE_MARKER));
|
|
97
|
+
}
|
|
98
|
+
function getExplicitDataDir(env = process.env) {
|
|
99
|
+
const dir = env.HASNA_HOOKS_DATA_DIR ?? env.HOOKS_DATA_DIR;
|
|
100
|
+
if (typeof dir === "string" && dir.trim().length > 0)
|
|
101
|
+
return dir.trim();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
function getExactDataRoot(env = process.env) {
|
|
105
|
+
const dir = env.HASNA_HOOKS_HOME?.trim() || env.HOOKS_HOME?.trim();
|
|
106
|
+
if (dir)
|
|
107
|
+
return resolve(dir);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
function getEffectiveDataRoot(env = process.env) {
|
|
111
|
+
const explicit = getExplicitDataDir(env);
|
|
112
|
+
if (explicit)
|
|
113
|
+
return explicit;
|
|
114
|
+
const exact = getExactDataRoot(env);
|
|
115
|
+
if (exact)
|
|
116
|
+
return exact;
|
|
117
|
+
const resolved = getResolverDataRoot(env);
|
|
118
|
+
return adoptResolverDataRoot(resolved, env) ? resolve(resolved) : getLegacyDataRoot(env);
|
|
119
|
+
}
|
|
120
|
+
function getReportedDbPath(env = process.env) {
|
|
121
|
+
const explicit = getExplicitDbPath(env);
|
|
122
|
+
if (explicit)
|
|
123
|
+
return explicit;
|
|
124
|
+
return join(getEffectiveDataRoot(env), "hooks.db");
|
|
125
|
+
}
|
|
126
|
+
function getExplicitDbPath(env = process.env) {
|
|
127
|
+
const db = env.HASNA_HOOKS_DB_PATH ?? env.HOOKS_DB_PATH;
|
|
128
|
+
if (typeof db === "string" && db.trim().length > 0)
|
|
129
|
+
return db.trim();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
18
133
|
// src/lib/registry.ts
|
|
19
134
|
var HOOKS = [
|
|
20
135
|
{
|
|
@@ -331,7 +446,7 @@ var HOOKS = [
|
|
|
331
446
|
{
|
|
332
447
|
name: "sessionlog",
|
|
333
448
|
displayName: "Session Log",
|
|
334
|
-
description:
|
|
449
|
+
description: `Logs every tool call to SQLite (${getReportedDbPath()})`,
|
|
335
450
|
version: "0.1.0",
|
|
336
451
|
category: "Observability",
|
|
337
452
|
event: "PostToolUse",
|
|
@@ -341,7 +456,7 @@ var HOOKS = [
|
|
|
341
456
|
{
|
|
342
457
|
name: "commandlog",
|
|
343
458
|
displayName: "Command Log",
|
|
344
|
-
description:
|
|
459
|
+
description: `Logs every Bash command to SQLite (${getReportedDbPath()})`,
|
|
345
460
|
version: "0.1.0",
|
|
346
461
|
category: "Observability",
|
|
347
462
|
event: "PostToolUse",
|
|
@@ -361,7 +476,7 @@ var HOOKS = [
|
|
|
361
476
|
{
|
|
362
477
|
name: "errornotify",
|
|
363
478
|
displayName: "Error Notify",
|
|
364
|
-
description:
|
|
479
|
+
description: `Detects tool failures and logs errors to SQLite (${getReportedDbPath()})`,
|
|
365
480
|
version: "0.1.0",
|
|
366
481
|
category: "Observability",
|
|
367
482
|
event: "PostToolUse",
|
|
@@ -534,8 +649,8 @@ function getHook(name) {
|
|
|
534
649
|
}
|
|
535
650
|
|
|
536
651
|
// src/lib/manifest.ts
|
|
537
|
-
import { existsSync, mkdirSync, readFileSync
|
|
538
|
-
import { basename, dirname, isAbsolute, join as join3, normalize, resolve, sep } from "path";
|
|
652
|
+
import { existsSync as existsSync2, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "fs";
|
|
653
|
+
import { basename, dirname, isAbsolute, join as join3, normalize, resolve as resolve2, sep } from "path";
|
|
539
654
|
|
|
540
655
|
// ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/external.js
|
|
541
656
|
var exports_external = {};
|
|
@@ -4510,291 +4625,10 @@ var coerce = {
|
|
|
4510
4625
|
date: (arg) => ZodDate.create({ ...arg, coerce: true })
|
|
4511
4626
|
};
|
|
4512
4627
|
var NEVER = INVALID;
|
|
4513
|
-
// ../contracts/dist/client/transport.js
|
|
4514
|
-
import { readFileSync, statSync } from "fs";
|
|
4515
|
-
import { join } from "path";
|
|
4516
|
-
function envToken(name) {
|
|
4517
|
-
return name.toUpperCase().replace(/-/g, "_");
|
|
4518
|
-
}
|
|
4519
|
-
function clientTransportEnvKeys(name) {
|
|
4520
|
-
const envSegment = envToken(name);
|
|
4521
|
-
return {
|
|
4522
|
-
apiUrlKeys: [`HASNA_${envSegment}_API_URL`, `${envSegment}_API_URL`],
|
|
4523
|
-
apiKeyKeys: [`HASNA_${envSegment}_API_KEY`, `${envSegment}_API_KEY`]
|
|
4524
|
-
};
|
|
4525
|
-
}
|
|
4526
|
-
function credentialOverrideEnvKey(name) {
|
|
4527
|
-
return `HASNA_${envToken(name)}_API_KEY_OVERRIDE`;
|
|
4528
|
-
}
|
|
4529
|
-
var CREDENTIAL_PROFILE_ENV_KEY = "HASNA_PROFILE";
|
|
4530
|
-
|
|
4531
|
-
class CredentialResolutionError extends Error {
|
|
4532
|
-
appName;
|
|
4533
|
-
attempted;
|
|
4534
|
-
constructor(appName, message, attempted) {
|
|
4535
|
-
super(message);
|
|
4536
|
-
this.name = "CredentialResolutionError";
|
|
4537
|
-
this.appName = appName;
|
|
4538
|
-
this.attempted = attempted;
|
|
4539
|
-
}
|
|
4540
|
-
}
|
|
4541
|
-
var HASNA_STATE_DIR = ".hasna";
|
|
4542
|
-
var FLEET_CREDENTIAL_DIR = "cloud";
|
|
4543
|
-
var CONFIG_DIR = ".config";
|
|
4544
|
-
var CONFIG_NAMESPACE = "hasna";
|
|
4545
|
-
var MAX_CREDENTIAL_FILE_BYTES = 64 * 1024;
|
|
4546
|
-
var SAFE_APP_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
4547
|
-
var SAFE_PROFILE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
|
4548
|
-
var ILLEGAL_IN_HEADER_VALUE = /[^\t\x20-\x7e]/;
|
|
4549
|
-
function homeDir(env) {
|
|
4550
|
-
const home = env.HOME?.trim();
|
|
4551
|
-
return home ? home : null;
|
|
4552
|
-
}
|
|
4553
|
-
function credentialDiskSources(name, env) {
|
|
4554
|
-
return profileDiskSources(name, env, null);
|
|
4555
|
-
}
|
|
4556
|
-
function profileDiskSources(name, env, profile) {
|
|
4557
|
-
const home = homeDir(env);
|
|
4558
|
-
if (!home || !SAFE_APP_SLUG.test(name))
|
|
4559
|
-
return [];
|
|
4560
|
-
const stem = profile ? `${name}.${profile}` : name;
|
|
4561
|
-
const configStem = profile ? `${name}-${profile}` : name;
|
|
4562
|
-
return [
|
|
4563
|
-
join(home, HASNA_STATE_DIR, FLEET_CREDENTIAL_DIR, `${stem}.env`),
|
|
4564
|
-
join(home, CONFIG_DIR, CONFIG_NAMESPACE, `${configStem}-cloud.env`)
|
|
4565
|
-
];
|
|
4566
|
-
}
|
|
4567
|
-
function parseEnvFile(text) {
|
|
4568
|
-
const values = new Map;
|
|
4569
|
-
for (const rawLine of text.split(/\r?\n/)) {
|
|
4570
|
-
const line = rawLine.trim();
|
|
4571
|
-
if (line.length === 0 || line.startsWith("#"))
|
|
4572
|
-
continue;
|
|
4573
|
-
const withoutExport = line.startsWith("export ") ? line.slice("export ".length).trim() : line;
|
|
4574
|
-
const equals = withoutExport.indexOf("=");
|
|
4575
|
-
if (equals <= 0)
|
|
4576
|
-
continue;
|
|
4577
|
-
const key = withoutExport.slice(0, equals).trim();
|
|
4578
|
-
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
4579
|
-
continue;
|
|
4580
|
-
let value = withoutExport.slice(equals + 1).trim();
|
|
4581
|
-
const quote = value[0];
|
|
4582
|
-
if (quote === '"' || quote === "'") {
|
|
4583
|
-
if (value.length < 2 || !value.endsWith(quote))
|
|
4584
|
-
continue;
|
|
4585
|
-
value = value.slice(1, -1);
|
|
4586
|
-
}
|
|
4587
|
-
if (value.length === 0)
|
|
4588
|
-
continue;
|
|
4589
|
-
values.set(key, value);
|
|
4590
|
-
}
|
|
4591
|
-
return values;
|
|
4592
|
-
}
|
|
4593
|
-
function readAppConfigFile(path) {
|
|
4594
|
-
let text;
|
|
4595
|
-
try {
|
|
4596
|
-
const stats = statSync(path);
|
|
4597
|
-
if (!stats.isFile() || stats.size > MAX_CREDENTIAL_FILE_BYTES)
|
|
4598
|
-
return null;
|
|
4599
|
-
text = readFileSync(path, "utf8");
|
|
4600
|
-
} catch {
|
|
4601
|
-
return null;
|
|
4602
|
-
}
|
|
4603
|
-
return parseEnvFile(text);
|
|
4604
|
-
}
|
|
4605
|
-
function readCredentialFile(path, apiKeyKeys) {
|
|
4606
|
-
const values = readAppConfigFile(path);
|
|
4607
|
-
if (!values)
|
|
4608
|
-
return null;
|
|
4609
|
-
for (const key of apiKeyKeys) {
|
|
4610
|
-
const value = values.get(key)?.trim();
|
|
4611
|
-
if (value)
|
|
4612
|
-
return value;
|
|
4613
|
-
}
|
|
4614
|
-
return null;
|
|
4615
|
-
}
|
|
4616
|
-
function assertUsableCredential(appName, source, value) {
|
|
4617
|
-
if (!ILLEGAL_IN_HEADER_VALUE.test(value))
|
|
4618
|
-
return;
|
|
4619
|
-
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]);
|
|
4620
|
-
}
|
|
4621
|
-
var INSPECT_CUSTOM = Symbol.for("nodejs.util.inspect.custom");
|
|
4622
|
-
var CREDENTIAL_SEAL = Symbol.for("hasna:contracts:sealedCredential");
|
|
4623
|
-
function sealCredential(fields) {
|
|
4624
|
-
const { apiKey } = fields;
|
|
4625
|
-
const visible = {
|
|
4626
|
-
tier: fields.tier,
|
|
4627
|
-
source: fields.source,
|
|
4628
|
-
deliberate: fields.deliberate,
|
|
4629
|
-
deprecated: fields.deprecated,
|
|
4630
|
-
diskCandidates: Object.freeze([...fields.diskCandidates]),
|
|
4631
|
-
warning: fields.warning
|
|
4632
|
-
};
|
|
4633
|
-
const sealed = { ...visible };
|
|
4634
|
-
Object.defineProperty(sealed, "apiKey", {
|
|
4635
|
-
value: apiKey,
|
|
4636
|
-
enumerable: false,
|
|
4637
|
-
writable: false,
|
|
4638
|
-
configurable: false
|
|
4639
|
-
});
|
|
4640
|
-
Object.defineProperty(sealed, INSPECT_CUSTOM, {
|
|
4641
|
-
value: () => ({ ...visible, apiKey: "[redacted]" }),
|
|
4642
|
-
enumerable: false,
|
|
4643
|
-
writable: false,
|
|
4644
|
-
configurable: false
|
|
4645
|
-
});
|
|
4646
|
-
Object.defineProperty(sealed, CREDENTIAL_SEAL, {
|
|
4647
|
-
value: true,
|
|
4648
|
-
enumerable: false,
|
|
4649
|
-
writable: false,
|
|
4650
|
-
configurable: false
|
|
4651
|
-
});
|
|
4652
|
-
return Object.freeze(sealed);
|
|
4653
|
-
}
|
|
4654
|
-
function firstEnvValue(env, keys) {
|
|
4655
|
-
for (const key of keys) {
|
|
4656
|
-
const value = env[key]?.trim();
|
|
4657
|
-
if (value)
|
|
4658
|
-
return { key, value };
|
|
4659
|
-
}
|
|
4660
|
-
return null;
|
|
4661
|
-
}
|
|
4662
|
-
var DEPRECATION_REGISTRY = Symbol.for("hasna:contracts:credentialDeprecationNotices");
|
|
4663
|
-
function deprecationNotified() {
|
|
4664
|
-
const host = globalThis;
|
|
4665
|
-
const existing = host[DEPRECATION_REGISTRY];
|
|
4666
|
-
if (existing instanceof Set)
|
|
4667
|
-
return existing;
|
|
4668
|
-
const created = new Set;
|
|
4669
|
-
host[DEPRECATION_REGISTRY] = created;
|
|
4670
|
-
return created;
|
|
4671
|
-
}
|
|
4672
|
-
function defaultDeprecationSink(message) {
|
|
4673
|
-
if (typeof process !== "undefined" && process.stderr) {
|
|
4674
|
-
process.stderr.write(`${message}
|
|
4675
|
-
`);
|
|
4676
|
-
}
|
|
4677
|
-
}
|
|
4678
|
-
function resolveCredential(name, env, options = {}) {
|
|
4679
|
-
const { apiKeyKeys } = clientTransportEnvKeys(name);
|
|
4680
|
-
const diskPaths = credentialDiskSources(name, env);
|
|
4681
|
-
const explicitKey = options.apiKey?.trim();
|
|
4682
|
-
if (explicitKey) {
|
|
4683
|
-
assertUsableCredential(name, "the explicit apiKey argument", explicitKey);
|
|
4684
|
-
return sealCredential({
|
|
4685
|
-
apiKey: explicitKey,
|
|
4686
|
-
tier: "argument",
|
|
4687
|
-
source: "explicit apiKey argument",
|
|
4688
|
-
deliberate: true,
|
|
4689
|
-
deprecated: false,
|
|
4690
|
-
diskCandidates: diskPaths,
|
|
4691
|
-
warning: null
|
|
4692
|
-
});
|
|
4693
|
-
}
|
|
4694
|
-
const overrideKeyName = credentialOverrideEnvKey(name);
|
|
4695
|
-
const overrideRaw = env[overrideKeyName];
|
|
4696
|
-
if (overrideRaw !== undefined) {
|
|
4697
|
-
const override = overrideRaw.trim();
|
|
4698
|
-
if (!override) {
|
|
4699
|
-
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]);
|
|
4700
|
-
}
|
|
4701
|
-
assertUsableCredential(name, overrideKeyName, override);
|
|
4702
|
-
return sealCredential({
|
|
4703
|
-
apiKey: override,
|
|
4704
|
-
tier: "override",
|
|
4705
|
-
source: overrideKeyName,
|
|
4706
|
-
deliberate: true,
|
|
4707
|
-
deprecated: false,
|
|
4708
|
-
diskCandidates: diskPaths,
|
|
4709
|
-
warning: null
|
|
4710
|
-
});
|
|
4711
|
-
}
|
|
4712
|
-
const profile = options.profile?.trim() || env[CREDENTIAL_PROFILE_ENV_KEY]?.trim();
|
|
4713
|
-
if (profile) {
|
|
4714
|
-
const profileSource = options.profile?.trim() ? "explicit profile argument" : CREDENTIAL_PROFILE_ENV_KEY;
|
|
4715
|
-
if (!SAFE_PROFILE.test(profile)) {
|
|
4716
|
-
throw new CredentialResolutionError(name, `Profile name from ${profileSource} is not usable in a path. ` + `Use letters, digits, dot, dash, or underscore.`, [profileSource]);
|
|
4717
|
-
}
|
|
4718
|
-
const paths = profileDiskSources(name, env, profile);
|
|
4719
|
-
for (const path of paths) {
|
|
4720
|
-
const value = readCredentialFile(path, apiKeyKeys);
|
|
4721
|
-
if (value) {
|
|
4722
|
-
assertUsableCredential(name, path, value);
|
|
4723
|
-
return sealCredential({
|
|
4724
|
-
apiKey: value,
|
|
4725
|
-
tier: "profile",
|
|
4726
|
-
source: path,
|
|
4727
|
-
deliberate: true,
|
|
4728
|
-
deprecated: false,
|
|
4729
|
-
diskCandidates: paths,
|
|
4730
|
-
warning: null
|
|
4731
|
-
});
|
|
4732
|
-
}
|
|
4733
|
-
}
|
|
4734
|
-
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);
|
|
4735
|
-
}
|
|
4736
|
-
const diskHits = diskPaths.map((path) => ({ path, value: readCredentialFile(path, apiKeyKeys) })).filter((hit) => hit.value !== null);
|
|
4737
|
-
if (diskHits.length > 0) {
|
|
4738
|
-
const winner = diskHits[0];
|
|
4739
|
-
assertUsableCredential(name, winner.path, winner.value);
|
|
4740
|
-
const divergentSources = [
|
|
4741
|
-
...diskHits.slice(1).filter((hit) => hit.value !== winner.value).map((hit) => hit.path),
|
|
4742
|
-
...(() => {
|
|
4743
|
-
const legacyHit = firstEnvValue(env, apiKeyKeys);
|
|
4744
|
-
return legacyHit && legacyHit.value !== winner.value ? [legacyHit.key] : [];
|
|
4745
|
-
})()
|
|
4746
|
-
];
|
|
4747
|
-
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;
|
|
4748
|
-
return sealCredential({
|
|
4749
|
-
apiKey: winner.value,
|
|
4750
|
-
tier: "disk",
|
|
4751
|
-
source: winner.path,
|
|
4752
|
-
deliberate: false,
|
|
4753
|
-
deprecated: false,
|
|
4754
|
-
diskCandidates: diskPaths,
|
|
4755
|
-
warning
|
|
4756
|
-
});
|
|
4757
|
-
}
|
|
4758
|
-
const legacy = firstEnvValue(env, apiKeyKeys);
|
|
4759
|
-
if (legacy) {
|
|
4760
|
-
assertUsableCredential(name, legacy.key, legacy.value);
|
|
4761
|
-
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.`;
|
|
4762
|
-
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}`;
|
|
4763
|
-
const sink = options.onDeprecation ?? defaultDeprecationSink;
|
|
4764
|
-
const notified = deprecationNotified();
|
|
4765
|
-
if (!notified.has(name)) {
|
|
4766
|
-
notified.add(name);
|
|
4767
|
-
sink(message);
|
|
4768
|
-
}
|
|
4769
|
-
return sealCredential({
|
|
4770
|
-
apiKey: legacy.value,
|
|
4771
|
-
tier: "legacy-env",
|
|
4772
|
-
source: legacy.key,
|
|
4773
|
-
deliberate: false,
|
|
4774
|
-
deprecated: true,
|
|
4775
|
-
diskCandidates: diskPaths,
|
|
4776
|
-
warning: message
|
|
4777
|
-
});
|
|
4778
|
-
}
|
|
4779
|
-
return null;
|
|
4780
|
-
}
|
|
4781
|
-
var IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
4782
|
-
var AUTHORITY_OVERRIDE_HEADERS = new Set([
|
|
4783
|
-
"host",
|
|
4784
|
-
":authority",
|
|
4785
|
-
"forwarded",
|
|
4786
|
-
"x-forwarded-host",
|
|
4787
|
-
"x-original-host"
|
|
4788
|
-
]);
|
|
4789
|
-
|
|
4790
4628
|
// src/config.ts
|
|
4791
4629
|
import { join as join2 } from "path";
|
|
4792
|
-
import { homedir } from "os";
|
|
4793
4630
|
function getHooksDataDir() {
|
|
4794
|
-
|
|
4795
|
-
if (explicit)
|
|
4796
|
-
return explicit;
|
|
4797
|
-
return join2(homedir(), ".hasna", "hooks");
|
|
4631
|
+
return getEffectiveDataRoot();
|
|
4798
4632
|
}
|
|
4799
4633
|
function getCustomHooksDir() {
|
|
4800
4634
|
return join2(getHooksDataDir(), "hooks");
|
|
@@ -4805,9 +4639,6 @@ function getLockPath() {
|
|
|
4805
4639
|
return explicit;
|
|
4806
4640
|
return join2(getHooksDataDir(), "hooks.lock");
|
|
4807
4641
|
}
|
|
4808
|
-
function resolveApiKey() {
|
|
4809
|
-
return resolveCredential("hooks", process.env)?.apiKey;
|
|
4810
|
-
}
|
|
4811
4642
|
|
|
4812
4643
|
// src/lib/semver.ts
|
|
4813
4644
|
var NUMERIC_ID = "(0|[1-9]\\d{0,15})";
|
|
@@ -4845,8 +4676,8 @@ function isWithin(child, parent) {
|
|
|
4845
4676
|
return child === parent || child.startsWith(parent + sep);
|
|
4846
4677
|
}
|
|
4847
4678
|
function assertContainedLexical(target, root, raw) {
|
|
4848
|
-
const lexical =
|
|
4849
|
-
const rootLexical =
|
|
4679
|
+
const lexical = resolve2(target);
|
|
4680
|
+
const rootLexical = resolve2(root);
|
|
4850
4681
|
if (!isWithin(lexical, rootLexical)) {
|
|
4851
4682
|
throw new ScriptContainmentError(target, root, raw);
|
|
4852
4683
|
}
|
|
@@ -4855,7 +4686,7 @@ function assertContainedLexical(target, root, raw) {
|
|
|
4855
4686
|
function realpathNearest(path) {
|
|
4856
4687
|
const tail = [];
|
|
4857
4688
|
let cur = path;
|
|
4858
|
-
while (!
|
|
4689
|
+
while (!existsSync2(cur)) {
|
|
4859
4690
|
const parent = dirname(cur);
|
|
4860
4691
|
if (parent === cur)
|
|
4861
4692
|
break;
|
|
@@ -4896,10 +4727,10 @@ function resolveScript(manifest, manifestDir) {
|
|
|
4896
4727
|
return { path: scriptRelFor(manifest), content: manifest.script };
|
|
4897
4728
|
const rel = scriptRelFor(manifest);
|
|
4898
4729
|
const resolved = assertContained(join3(manifestDir, rel), manifestDir, manifest.script);
|
|
4899
|
-
if (!
|
|
4730
|
+
if (!existsSync2(resolved)) {
|
|
4900
4731
|
throw new Error(`manifest script file not found: ${resolved}`);
|
|
4901
4732
|
}
|
|
4902
|
-
return { path: rel, content:
|
|
4733
|
+
return { path: rel, content: readFileSync(resolved, "utf-8") };
|
|
4903
4734
|
}
|
|
4904
4735
|
function customHookDir(name) {
|
|
4905
4736
|
return join3(getCustomHooksDir(), shortManifestName(name));
|
|
@@ -4907,10 +4738,10 @@ function customHookDir(name) {
|
|
|
4907
4738
|
function readCustomManifest(name) {
|
|
4908
4739
|
const dir = customHookDir(name);
|
|
4909
4740
|
const manifestPath = join3(dir, "manifest.json");
|
|
4910
|
-
if (!
|
|
4741
|
+
if (!existsSync2(manifestPath))
|
|
4911
4742
|
return;
|
|
4912
4743
|
try {
|
|
4913
|
-
const manifest = parseManifest(
|
|
4744
|
+
const manifest = parseManifest(readFileSync(manifestPath, "utf-8"));
|
|
4914
4745
|
const script = resolveScript(manifest, dir);
|
|
4915
4746
|
const scriptIsInline = isInlineScript(manifest);
|
|
4916
4747
|
return { manifest, scriptPath: join3(dir, script.path), scriptContent: script.content, scriptIsInline };
|
|
@@ -4922,7 +4753,7 @@ function readCustomManifest(name) {
|
|
|
4922
4753
|
}
|
|
4923
4754
|
function listCustomHooks() {
|
|
4924
4755
|
const dir = getCustomHooksDir();
|
|
4925
|
-
if (!
|
|
4756
|
+
if (!existsSync2(dir))
|
|
4926
4757
|
return [];
|
|
4927
4758
|
const results = [];
|
|
4928
4759
|
for (const entry of readdirSafe(dir)) {
|
|
@@ -4942,14 +4773,13 @@ function readdirSafe(dir) {
|
|
|
4942
4773
|
|
|
4943
4774
|
// src/lib/store.ts
|
|
4944
4775
|
import { createHash } from "crypto";
|
|
4945
|
-
import { existsSync as
|
|
4776
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2, renameSync } from "fs";
|
|
4946
4777
|
import { readFile } from "fs/promises";
|
|
4947
4778
|
|
|
4948
4779
|
// src/db/index.ts
|
|
4949
4780
|
import { Database } from "bun:sqlite";
|
|
4950
|
-
import { existsSync as
|
|
4781
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync2, cpSync } from "fs";
|
|
4951
4782
|
import { join as join5 } from "path";
|
|
4952
|
-
import { homedir as homedir3 } from "os";
|
|
4953
4783
|
|
|
4954
4784
|
// src/db/schema.ts
|
|
4955
4785
|
var CREATE_HOOK_EVENTS_TABLE = `
|
|
@@ -5121,7 +4951,7 @@ function runMigrations(db) {
|
|
|
5121
4951
|
}
|
|
5122
4952
|
|
|
5123
4953
|
// src/db/legacy-import.ts
|
|
5124
|
-
import { existsSync as
|
|
4954
|
+
import { existsSync as existsSync3, readdirSync as readdirSync2, readFileSync as readFileSync2 } from "fs";
|
|
5125
4955
|
import { join as join4 } from "path";
|
|
5126
4956
|
import { homedir as homedir2 } from "os";
|
|
5127
4957
|
var META_KEY = "legacy_import_done";
|
|
@@ -5147,7 +4977,7 @@ function nanoid() {
|
|
|
5147
4977
|
function importJsonlFile(db, filePath) {
|
|
5148
4978
|
let count = 0;
|
|
5149
4979
|
try {
|
|
5150
|
-
const lines =
|
|
4980
|
+
const lines = readFileSync2(filePath, "utf-8").split(`
|
|
5151
4981
|
`).filter(Boolean);
|
|
5152
4982
|
for (const line of lines) {
|
|
5153
4983
|
try {
|
|
@@ -5173,7 +5003,7 @@ function importJsonlFile(db, filePath) {
|
|
|
5173
5003
|
function importErrorsLog(db, filePath) {
|
|
5174
5004
|
let count = 0;
|
|
5175
5005
|
try {
|
|
5176
|
-
const lines =
|
|
5006
|
+
const lines = readFileSync2(filePath, "utf-8").split(`
|
|
5177
5007
|
`).filter(Boolean);
|
|
5178
5008
|
const linePattern = /^\[(.+?)\]\s+(?:\[session:(\S+)\]\s+)?(.+?)\s+\u2014\s+(.+)$/;
|
|
5179
5009
|
for (const line of lines) {
|
|
@@ -5198,13 +5028,13 @@ function importErrorsLog(db, filePath) {
|
|
|
5198
5028
|
} catch {}
|
|
5199
5029
|
return count;
|
|
5200
5030
|
}
|
|
5201
|
-
function runLegacyImport(db,
|
|
5031
|
+
function runLegacyImport(db, homeDir = homedir2()) {
|
|
5202
5032
|
try {
|
|
5203
5033
|
if (isAlreadyDone(db))
|
|
5204
5034
|
return;
|
|
5205
5035
|
let total = 0;
|
|
5206
|
-
const claudeProjectsDir = join4(
|
|
5207
|
-
if (
|
|
5036
|
+
const claudeProjectsDir = join4(homeDir, ".claude", "projects");
|
|
5037
|
+
if (existsSync3(claudeProjectsDir)) {
|
|
5208
5038
|
try {
|
|
5209
5039
|
const projectDirs = readdirSync2(claudeProjectsDir);
|
|
5210
5040
|
for (const dir of projectDirs) {
|
|
@@ -5251,27 +5081,24 @@ function runRetention(db, days) {
|
|
|
5251
5081
|
// src/db/index.ts
|
|
5252
5082
|
var instance = null;
|
|
5253
5083
|
function resolveDataDir() {
|
|
5254
|
-
const
|
|
5255
|
-
|
|
5256
|
-
|
|
5257
|
-
|
|
5258
|
-
|
|
5259
|
-
if (!existsSync3(newDir) && existsSync3(oldDir)) {
|
|
5260
|
-
mkdirSync2(join5(homedir3(), ".hasna"), { recursive: true });
|
|
5261
|
-
cpSync(oldDir, newDir, { recursive: true });
|
|
5084
|
+
const effective = getEffectiveDataRoot();
|
|
5085
|
+
const oldDir = join5(getHomeDir(), ".hooks");
|
|
5086
|
+
if (!existsSync4(effective) && existsSync4(oldDir)) {
|
|
5087
|
+
mkdirSync2(effective, { recursive: true });
|
|
5088
|
+
cpSync(oldDir, effective, { recursive: true });
|
|
5262
5089
|
}
|
|
5263
|
-
return
|
|
5090
|
+
return effective;
|
|
5264
5091
|
}
|
|
5265
5092
|
function getDbPath() {
|
|
5266
5093
|
const explicitDb = process.env.HASNA_HOOKS_DB_PATH ?? process.env.HOOKS_DB_PATH;
|
|
5267
5094
|
if (explicitDb)
|
|
5268
5095
|
return explicitDb;
|
|
5269
|
-
const
|
|
5270
|
-
return join5(
|
|
5096
|
+
const dataDir2 = resolveDataDir();
|
|
5097
|
+
return join5(dataDir2, "hooks.db");
|
|
5271
5098
|
}
|
|
5272
5099
|
function ensureDir(dbPath) {
|
|
5273
5100
|
const dir = dbPath.substring(0, dbPath.lastIndexOf("/"));
|
|
5274
|
-
if (dir && !
|
|
5101
|
+
if (dir && !existsSync4(dir)) {
|
|
5275
5102
|
mkdirSync2(dir, { recursive: true });
|
|
5276
5103
|
}
|
|
5277
5104
|
}
|
|
@@ -5279,7 +5106,7 @@ function getDb() {
|
|
|
5279
5106
|
if (instance)
|
|
5280
5107
|
return instance;
|
|
5281
5108
|
const dbPath = getDbPath();
|
|
5282
|
-
const isNew = dbPath === ":memory:" || !
|
|
5109
|
+
const isNew = dbPath === ":memory:" || !existsSync4(dbPath);
|
|
5283
5110
|
ensureDir(dbPath);
|
|
5284
5111
|
instance = new Database(dbPath);
|
|
5285
5112
|
instance.exec("PRAGMA busy_timeout=5000");
|
|
@@ -5362,11 +5189,11 @@ function assertLockShape(parsed, path) {
|
|
|
5362
5189
|
}
|
|
5363
5190
|
function readLock() {
|
|
5364
5191
|
const path = getLockPath();
|
|
5365
|
-
if (!
|
|
5192
|
+
if (!existsSync5(path))
|
|
5366
5193
|
return { hooks: {} };
|
|
5367
5194
|
let raw;
|
|
5368
5195
|
try {
|
|
5369
|
-
raw =
|
|
5196
|
+
raw = readFileSync3(path, "utf-8");
|
|
5370
5197
|
} catch {
|
|
5371
5198
|
throw new LockFileError(`hooks.lock (${path}) could not be read. Repair: fix permissions or move the file aside and run 'hooks sync'.`);
|
|
5372
5199
|
}
|
|
@@ -5398,7 +5225,7 @@ function setPinnedHook(name, entry) {
|
|
|
5398
5225
|
return writeLock(lock);
|
|
5399
5226
|
}
|
|
5400
5227
|
function retrustHook(name, scriptPath, version, source) {
|
|
5401
|
-
const actual = sha256Of(
|
|
5228
|
+
const actual = sha256Of(readFileSync3(scriptPath));
|
|
5402
5229
|
const db = getDb();
|
|
5403
5230
|
upsertHookRecord(db, {
|
|
5404
5231
|
name,
|
|
@@ -5413,11 +5240,11 @@ function retrustHook(name, scriptPath, version, source) {
|
|
|
5413
5240
|
|
|
5414
5241
|
// src/lib/resolve.ts
|
|
5415
5242
|
import { dirname as dirname2, join as join6 } from "path";
|
|
5416
|
-
import { existsSync as
|
|
5243
|
+
import { existsSync as existsSync6 } from "fs";
|
|
5417
5244
|
import { fileURLToPath } from "url";
|
|
5418
5245
|
function resolveBundledHooksDir(moduleDir = dirname2(fileURLToPath(import.meta.url))) {
|
|
5419
5246
|
const sourceLayout = join6(moduleDir, "..", "..", "hooks");
|
|
5420
|
-
if (
|
|
5247
|
+
if (existsSync6(join6(sourceLayout, "hook-gitguard")))
|
|
5421
5248
|
return sourceLayout;
|
|
5422
5249
|
return join6(moduleDir, "..", "hooks");
|
|
5423
5250
|
}
|
|
@@ -5431,10 +5258,10 @@ function resolveHookDir(name) {
|
|
|
5431
5258
|
const short = shortManifestName(name);
|
|
5432
5259
|
const bundledBase = resolveBundledHooksDir();
|
|
5433
5260
|
const direct = join6(bundledBase, short);
|
|
5434
|
-
if (
|
|
5261
|
+
if (existsSync6(direct))
|
|
5435
5262
|
return direct;
|
|
5436
5263
|
const prefixed = join6(bundledBase, `hook-${short}`);
|
|
5437
|
-
return
|
|
5264
|
+
return existsSync6(prefixed) ? prefixed : undefined;
|
|
5438
5265
|
}
|
|
5439
5266
|
function resolveScriptPath(name) {
|
|
5440
5267
|
const custom2 = readCustomManifest(name);
|
|
@@ -5444,7 +5271,7 @@ function resolveScriptPath(name) {
|
|
|
5444
5271
|
if (!dir)
|
|
5445
5272
|
return;
|
|
5446
5273
|
const script = join6(dir, "src", "hook.ts");
|
|
5447
|
-
return
|
|
5274
|
+
return existsSync6(script) ? script : undefined;
|
|
5448
5275
|
}
|
|
5449
5276
|
function resolveHookMeta(name) {
|
|
5450
5277
|
const custom2 = readCustomManifest(name);
|
|
@@ -5497,9 +5324,554 @@ function resolveHook(name) {
|
|
|
5497
5324
|
|
|
5498
5325
|
// src/serve.ts
|
|
5499
5326
|
import { readFileSync as readFileSync5 } from "fs";
|
|
5500
|
-
import { dirname as dirname3, join as
|
|
5327
|
+
import { dirname as dirname3, join as join8 } from "path";
|
|
5501
5328
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
5502
5329
|
|
|
5330
|
+
// ../contracts/dist/client/transport.js
|
|
5331
|
+
import { spawnSync } from "child_process";
|
|
5332
|
+
import { closeSync, fstatSync, openSync, readFileSync as readFileSync4 } from "fs";
|
|
5333
|
+
import { O_NOFOLLOW, O_NONBLOCK, O_RDONLY } from "constants";
|
|
5334
|
+
import { createRequire } from "module";
|
|
5335
|
+
import { hostname as osHostname } from "os";
|
|
5336
|
+
import { isAbsolute as isAbsolute2, join as join7 } from "path";
|
|
5337
|
+
function envToken(name) {
|
|
5338
|
+
return name.toUpperCase().replace(/-/g, "_");
|
|
5339
|
+
}
|
|
5340
|
+
function clientTransportEnvKeys(name) {
|
|
5341
|
+
const envSegment = envToken(name);
|
|
5342
|
+
return {
|
|
5343
|
+
apiUrlKeys: [`HASNA_${envSegment}_API_URL`, `${envSegment}_API_URL`],
|
|
5344
|
+
apiKeyKeys: [`HASNA_${envSegment}_API_KEY`, `${envSegment}_API_KEY`]
|
|
5345
|
+
};
|
|
5346
|
+
}
|
|
5347
|
+
function credentialOverrideEnvKey(name) {
|
|
5348
|
+
return `HASNA_${envToken(name)}_API_KEY_OVERRIDE`;
|
|
5349
|
+
}
|
|
5350
|
+
var CREDENTIAL_PROFILE_ENV_KEY = "HASNA_PROFILE";
|
|
5351
|
+
function credentialPointerEnvKey(name) {
|
|
5352
|
+
return `HASNA_${envToken(name)}_API_KEY_REF`;
|
|
5353
|
+
}
|
|
5354
|
+
|
|
5355
|
+
class CredentialResolutionError extends Error {
|
|
5356
|
+
appName;
|
|
5357
|
+
attempted;
|
|
5358
|
+
constructor(appName, message, attempted) {
|
|
5359
|
+
super(message);
|
|
5360
|
+
this.name = "CredentialResolutionError";
|
|
5361
|
+
this.appName = appName;
|
|
5362
|
+
this.attempted = attempted;
|
|
5363
|
+
}
|
|
5364
|
+
}
|
|
5365
|
+
|
|
5366
|
+
class CredentialFileUnsafeError extends Error {
|
|
5367
|
+
path;
|
|
5368
|
+
constructor(path, reason) {
|
|
5369
|
+
super(`Refusing unsafe credential/config file ${path}: ${reason}.`);
|
|
5370
|
+
this.name = "CredentialFileUnsafeError";
|
|
5371
|
+
this.path = path;
|
|
5372
|
+
}
|
|
5373
|
+
}
|
|
5374
|
+
var HASNA_HOME_ENV_KEY = "HASNA_HOME";
|
|
5375
|
+
var HASNA_CONFIG_HOME_ENV_KEY = "HASNA_CONFIG_HOME";
|
|
5376
|
+
var KEYCHAIN_STATION_ENV_KEY = "HASNA_STATION";
|
|
5377
|
+
var HASNA_HOME_DIR = ".hasna";
|
|
5378
|
+
var CONFIG_SUBDIR = "config";
|
|
5379
|
+
var CREDENTIALS_FILE = "credentials";
|
|
5380
|
+
var KEYCHAIN_SECURITY_BIN = "/usr/bin/security";
|
|
5381
|
+
var KEYCHAIN_SERVICE_PREFIX = "hasna.credentials";
|
|
5382
|
+
var KEYCHAIN_ITEM_NOT_FOUND_STATUS = 44;
|
|
5383
|
+
var KEYCHAIN_SPAWN_TIMEOUT_MS = 1e4;
|
|
5384
|
+
var MAX_CREDENTIAL_FILE_BYTES = 64 * 1024;
|
|
5385
|
+
var SAFE_APP_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
5386
|
+
var SAFE_PROFILE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
|
5387
|
+
var ILLEGAL_IN_HEADER_VALUE = /[^\t\x20-\x7e]/;
|
|
5388
|
+
var VAULT_POINTER_SHAPE = /^[a-z0-9][a-z0-9-]*(?:\/[a-z0-9][a-z0-9-_.]*){2,}$/;
|
|
5389
|
+
function homeDir(env) {
|
|
5390
|
+
const home = env.HOME?.trim();
|
|
5391
|
+
return home ? home : null;
|
|
5392
|
+
}
|
|
5393
|
+
function absoluteOverride(env, key) {
|
|
5394
|
+
const value = env[key]?.trim();
|
|
5395
|
+
return value && isAbsolute2(value) ? value : null;
|
|
5396
|
+
}
|
|
5397
|
+
function hasnaHomeDir(env) {
|
|
5398
|
+
const override = absoluteOverride(env, HASNA_HOME_ENV_KEY);
|
|
5399
|
+
if (override)
|
|
5400
|
+
return override;
|
|
5401
|
+
const home = homeDir(env);
|
|
5402
|
+
return home ? join7(home, HASNA_HOME_DIR) : null;
|
|
5403
|
+
}
|
|
5404
|
+
function appConfigDir(name, env) {
|
|
5405
|
+
const configRoot = absoluteOverride(env, HASNA_CONFIG_HOME_ENV_KEY);
|
|
5406
|
+
if (configRoot)
|
|
5407
|
+
return join7(configRoot, name);
|
|
5408
|
+
const root = hasnaHomeDir(env);
|
|
5409
|
+
return root ? join7(root, name, CONFIG_SUBDIR) : null;
|
|
5410
|
+
}
|
|
5411
|
+
function credentialDiskSourceList(name, env, profile = null) {
|
|
5412
|
+
if (!SAFE_APP_SLUG.test(name))
|
|
5413
|
+
return [];
|
|
5414
|
+
const directory = appConfigDir(name, env);
|
|
5415
|
+
if (!directory)
|
|
5416
|
+
return [];
|
|
5417
|
+
const file = profile ? `${CREDENTIALS_FILE}-${profile}` : CREDENTIALS_FILE;
|
|
5418
|
+
return [{ path: join7(directory, file), tier: "disk" }];
|
|
5419
|
+
}
|
|
5420
|
+
function credentialDiskSources(name, env) {
|
|
5421
|
+
return credentialDiskSourceList(name, env, null).map((s) => s.path);
|
|
5422
|
+
}
|
|
5423
|
+
function profileDiskSources(name, env, profile) {
|
|
5424
|
+
return credentialDiskSourceList(name, env, profile).map((s) => s.path);
|
|
5425
|
+
}
|
|
5426
|
+
function parseEnvFile(text) {
|
|
5427
|
+
const values = new Map;
|
|
5428
|
+
const unusable = new Set;
|
|
5429
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
5430
|
+
const line = rawLine.trim();
|
|
5431
|
+
if (line.length === 0 || line.startsWith("#"))
|
|
5432
|
+
continue;
|
|
5433
|
+
const withoutExport = line.startsWith("export ") ? line.slice("export ".length).trim() : line;
|
|
5434
|
+
const equals = withoutExport.indexOf("=");
|
|
5435
|
+
if (equals <= 0)
|
|
5436
|
+
continue;
|
|
5437
|
+
const key = withoutExport.slice(0, equals).trim();
|
|
5438
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
5439
|
+
continue;
|
|
5440
|
+
let value = withoutExport.slice(equals + 1).trim();
|
|
5441
|
+
const quote = value[0];
|
|
5442
|
+
if (quote === '"' || quote === "'") {
|
|
5443
|
+
if (value.length < 2 || !value.endsWith(quote)) {
|
|
5444
|
+
unusable.add(key);
|
|
5445
|
+
continue;
|
|
5446
|
+
}
|
|
5447
|
+
value = value.slice(1, -1);
|
|
5448
|
+
}
|
|
5449
|
+
if (value.trim().length === 0) {
|
|
5450
|
+
unusable.add(key);
|
|
5451
|
+
continue;
|
|
5452
|
+
}
|
|
5453
|
+
if (values.has(key) && values.get(key) !== value)
|
|
5454
|
+
unusable.add(key);
|
|
5455
|
+
values.set(key, value);
|
|
5456
|
+
}
|
|
5457
|
+
return { values, unusable };
|
|
5458
|
+
}
|
|
5459
|
+
function configFileModeAllowed(mode) {
|
|
5460
|
+
const permissions = mode & 4095;
|
|
5461
|
+
return permissions === 256 || permissions === 384;
|
|
5462
|
+
}
|
|
5463
|
+
function configFileReadsCoherent(before, after) {
|
|
5464
|
+
return before.dev === after.dev && before.ino === after.ino && before.size === after.size && before.mtimeMs === after.mtimeMs && before.ctimeMs === after.ctimeMs;
|
|
5465
|
+
}
|
|
5466
|
+
function readAppConfigFile(path) {
|
|
5467
|
+
const unsafe = (reason) => {
|
|
5468
|
+
throw new CredentialFileUnsafeError(path, reason);
|
|
5469
|
+
};
|
|
5470
|
+
let fd = -1;
|
|
5471
|
+
try {
|
|
5472
|
+
fd = openSync(path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
|
|
5473
|
+
} catch (error) {
|
|
5474
|
+
const code = error.code;
|
|
5475
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
5476
|
+
return null;
|
|
5477
|
+
if (code === "ELOOP")
|
|
5478
|
+
unsafe("the path is a symlink");
|
|
5479
|
+
unsafe(`the path could not be opened (${code ?? "unknown error"})`);
|
|
5480
|
+
}
|
|
5481
|
+
try {
|
|
5482
|
+
const before = fstatSync(fd);
|
|
5483
|
+
if (!before.isFile())
|
|
5484
|
+
unsafe("the path is not a regular file");
|
|
5485
|
+
if (!configFileModeAllowed(before.mode)) {
|
|
5486
|
+
unsafe(`permission mode ${(before.mode & 4095).toString(8).padStart(4, "0")} is not owner-only 0400 or 0600`);
|
|
5487
|
+
}
|
|
5488
|
+
const uid = process.getuid?.() ?? process.geteuid?.();
|
|
5489
|
+
if (uid !== undefined && before.uid !== uid)
|
|
5490
|
+
unsafe("the file is not owned by the current user");
|
|
5491
|
+
if (before.size > MAX_CREDENTIAL_FILE_BYTES)
|
|
5492
|
+
unsafe("the file exceeds the size limit");
|
|
5493
|
+
const bytes = readFileSync4(fd);
|
|
5494
|
+
const after = fstatSync(fd);
|
|
5495
|
+
if (!configFileReadsCoherent(before, after)) {
|
|
5496
|
+
unsafe("the file changed while being read");
|
|
5497
|
+
}
|
|
5498
|
+
return parseEnvFile(bytes.toString("utf8"));
|
|
5499
|
+
} finally {
|
|
5500
|
+
if (fd !== -1)
|
|
5501
|
+
closeSync(fd);
|
|
5502
|
+
}
|
|
5503
|
+
}
|
|
5504
|
+
function readCredentialFile(path, apiKeyKeys) {
|
|
5505
|
+
const parsed = readAppConfigFile(path);
|
|
5506
|
+
if (!parsed)
|
|
5507
|
+
return null;
|
|
5508
|
+
for (const key of apiKeyKeys) {
|
|
5509
|
+
if (parsed.unusable.has(key)) {
|
|
5510
|
+
throw new CredentialFileUnsafeError(path, `${key} is declared but blank or malformed`);
|
|
5511
|
+
}
|
|
5512
|
+
}
|
|
5513
|
+
const values = apiKeyKeys.map((key) => parsed.values.get(key)?.trim()).filter((value) => Boolean(value));
|
|
5514
|
+
if (new Set(values).size > 1) {
|
|
5515
|
+
throw new CredentialFileUnsafeError(path, "credential aliases disagree");
|
|
5516
|
+
}
|
|
5517
|
+
return values[0] ?? null;
|
|
5518
|
+
}
|
|
5519
|
+
function assertUsableCredential(appName, source, value) {
|
|
5520
|
+
if (VAULT_POINTER_SHAPE.test(value)) {
|
|
5521
|
+
throw new CredentialResolutionError(appName, `The credential from ${source} looks like a secrets-vault pointer (a path-shaped reference like ` + `'namespace/app/live/api_key'). A vault path is NEVER accepted as a literal API key. ` + `Use ${credentialPointerEnvKey(appName)} to resolve the key through the vault, or provide the actual key value.`, [source]);
|
|
5522
|
+
}
|
|
5523
|
+
if (!ILLEGAL_IN_HEADER_VALUE.test(value))
|
|
5524
|
+
return;
|
|
5525
|
+
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]);
|
|
5526
|
+
}
|
|
5527
|
+
var INSPECT_CUSTOM = Symbol.for("nodejs.util.inspect.custom");
|
|
5528
|
+
var CREDENTIAL_SEAL = Symbol.for("hasna:contracts:sealedCredential");
|
|
5529
|
+
function sealCredential(fields) {
|
|
5530
|
+
const { apiKey } = fields;
|
|
5531
|
+
const visible = {
|
|
5532
|
+
tier: fields.tier,
|
|
5533
|
+
source: fields.source,
|
|
5534
|
+
deliberate: fields.deliberate,
|
|
5535
|
+
diskCandidates: Object.freeze([...fields.diskCandidates]),
|
|
5536
|
+
warning: fields.warning
|
|
5537
|
+
};
|
|
5538
|
+
const sealed = { ...visible };
|
|
5539
|
+
Object.defineProperty(sealed, "apiKey", {
|
|
5540
|
+
value: apiKey,
|
|
5541
|
+
enumerable: false,
|
|
5542
|
+
writable: false,
|
|
5543
|
+
configurable: false
|
|
5544
|
+
});
|
|
5545
|
+
if (fields.pointerVaultKey !== undefined) {
|
|
5546
|
+
Object.defineProperty(sealed, "pointerVaultKey", {
|
|
5547
|
+
value: fields.pointerVaultKey,
|
|
5548
|
+
enumerable: false,
|
|
5549
|
+
writable: false,
|
|
5550
|
+
configurable: false
|
|
5551
|
+
});
|
|
5552
|
+
}
|
|
5553
|
+
Object.defineProperty(sealed, INSPECT_CUSTOM, {
|
|
5554
|
+
value: () => ({ ...visible, apiKey: "[redacted]" }),
|
|
5555
|
+
enumerable: false,
|
|
5556
|
+
writable: false,
|
|
5557
|
+
configurable: false
|
|
5558
|
+
});
|
|
5559
|
+
Object.defineProperty(sealed, CREDENTIAL_SEAL, {
|
|
5560
|
+
value: true,
|
|
5561
|
+
enumerable: false,
|
|
5562
|
+
writable: false,
|
|
5563
|
+
configurable: false
|
|
5564
|
+
});
|
|
5565
|
+
return Object.freeze(sealed);
|
|
5566
|
+
}
|
|
5567
|
+
function firstEnvValue(env, keys) {
|
|
5568
|
+
for (const key of keys) {
|
|
5569
|
+
if (!Object.prototype.hasOwnProperty.call(env, key))
|
|
5570
|
+
continue;
|
|
5571
|
+
const value = env[key]?.trim();
|
|
5572
|
+
if (value)
|
|
5573
|
+
return { key, value };
|
|
5574
|
+
}
|
|
5575
|
+
return null;
|
|
5576
|
+
}
|
|
5577
|
+
var AMBIENT_ENVIRONMENT = Symbol.for("hasna:contracts:ambientClientEnvironment");
|
|
5578
|
+
function isAmbientEnvironment(env) {
|
|
5579
|
+
return env === process.env || env[AMBIENT_ENVIRONMENT] === true;
|
|
5580
|
+
}
|
|
5581
|
+
function defaultKeychainRunner(argv) {
|
|
5582
|
+
const result = spawnSync(KEYCHAIN_SECURITY_BIN, [...argv], {
|
|
5583
|
+
encoding: "utf8",
|
|
5584
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
5585
|
+
timeout: KEYCHAIN_SPAWN_TIMEOUT_MS
|
|
5586
|
+
});
|
|
5587
|
+
return {
|
|
5588
|
+
status: result.status,
|
|
5589
|
+
stdout: result.stdout ?? "",
|
|
5590
|
+
stderr: result.error ? result.error.message : result.stderr ?? ""
|
|
5591
|
+
};
|
|
5592
|
+
}
|
|
5593
|
+
function keychainTierEnabled(env, options) {
|
|
5594
|
+
if ((options.platform ?? process.platform) !== "darwin")
|
|
5595
|
+
return false;
|
|
5596
|
+
if (options.enabled !== undefined)
|
|
5597
|
+
return options.enabled;
|
|
5598
|
+
return options.run !== undefined || isAmbientEnvironment(env);
|
|
5599
|
+
}
|
|
5600
|
+
function keychainAccount(env, options) {
|
|
5601
|
+
const station = env[KEYCHAIN_STATION_ENV_KEY]?.trim();
|
|
5602
|
+
if (station)
|
|
5603
|
+
return station;
|
|
5604
|
+
const host = (options.hostname ?? osHostname)().split(".")[0]?.trim() ?? "";
|
|
5605
|
+
if (host)
|
|
5606
|
+
return host;
|
|
5607
|
+
const user = env.USER?.trim();
|
|
5608
|
+
return user || null;
|
|
5609
|
+
}
|
|
5610
|
+
function keychainFailureHint(text) {
|
|
5611
|
+
const line = text.split(/\r?\n/).find((entry) => entry.trim().length > 0)?.trim() ?? "";
|
|
5612
|
+
const clean = line.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 200);
|
|
5613
|
+
return clean ? `: ${clean}` : "";
|
|
5614
|
+
}
|
|
5615
|
+
function readKeychainItem(name, env, kind, options) {
|
|
5616
|
+
if (!SAFE_APP_SLUG.test(name) || !keychainTierEnabled(env, options))
|
|
5617
|
+
return null;
|
|
5618
|
+
const account = keychainAccount(env, options);
|
|
5619
|
+
if (!account)
|
|
5620
|
+
return null;
|
|
5621
|
+
const service = `${KEYCHAIN_SERVICE_PREFIX}.${name}.${kind}`;
|
|
5622
|
+
const source = `keychain:${service}@${account}`;
|
|
5623
|
+
const run = options.run ?? defaultKeychainRunner;
|
|
5624
|
+
let result;
|
|
5625
|
+
try {
|
|
5626
|
+
result = run(["find-generic-password", "-a", account, "-s", service, "-w"]);
|
|
5627
|
+
} catch (error) {
|
|
5628
|
+
const reason = keychainFailureHint(error instanceof Error ? error.message : String(error));
|
|
5629
|
+
throw new CredentialResolutionError(name, `The Keychain lookup for ${source} could not run${reason}. A Keychain failure is never resolved ` + `around: fix the keychain, or delete the item to fall through to the credential on disk.`, [source]);
|
|
5630
|
+
}
|
|
5631
|
+
if (result.status === KEYCHAIN_ITEM_NOT_FOUND_STATUS)
|
|
5632
|
+
return null;
|
|
5633
|
+
if (result.status !== 0) {
|
|
5634
|
+
throw new CredentialResolutionError(name, `The Keychain lookup for ${source} failed (security exited ` + `${result.status ?? "without a status"}${keychainFailureHint(result.stderr)}). A Keychain item that ` + `exists but cannot be read is never resolved around: unlock the keychain, run from a session that ` + `may use it, or delete the item to fall through to the credential on disk.`, [source]);
|
|
5635
|
+
}
|
|
5636
|
+
const value = result.stdout.trim();
|
|
5637
|
+
if (!value) {
|
|
5638
|
+
throw new CredentialResolutionError(name, `${source} exists but holds an empty value; a declared item never falls through to another ` + `identity. Store a value in it or delete the item.`, [source]);
|
|
5639
|
+
}
|
|
5640
|
+
return { value, source };
|
|
5641
|
+
}
|
|
5642
|
+
function snapshotClientEnvironment(name, env) {
|
|
5643
|
+
const keys = clientTransportEnvKeys(name);
|
|
5644
|
+
const ambient = isAmbientEnvironment(env);
|
|
5645
|
+
const snapshot = Object.create(null);
|
|
5646
|
+
for (const key of [
|
|
5647
|
+
...keys.apiUrlKeys,
|
|
5648
|
+
...keys.apiKeyKeys,
|
|
5649
|
+
credentialOverrideEnvKey(name),
|
|
5650
|
+
credentialPointerEnvKey(name),
|
|
5651
|
+
CREDENTIAL_PROFILE_ENV_KEY,
|
|
5652
|
+
"HOME",
|
|
5653
|
+
HASNA_HOME_ENV_KEY,
|
|
5654
|
+
HASNA_CONFIG_HOME_ENV_KEY,
|
|
5655
|
+
KEYCHAIN_STATION_ENV_KEY,
|
|
5656
|
+
"USER"
|
|
5657
|
+
]) {
|
|
5658
|
+
const descriptor = Object.getOwnPropertyDescriptor(env, key);
|
|
5659
|
+
if (!descriptor)
|
|
5660
|
+
continue;
|
|
5661
|
+
if (!("value" in descriptor)) {
|
|
5662
|
+
throw new CredentialResolutionError(name, `${key} is accessor-backed; client configuration requires own data properties.`, [key]);
|
|
5663
|
+
}
|
|
5664
|
+
if (descriptor.value !== undefined && typeof descriptor.value !== "string") {
|
|
5665
|
+
throw new CredentialResolutionError(name, `${key} must be a string data property.`, [key]);
|
|
5666
|
+
}
|
|
5667
|
+
snapshot[key] = descriptor.value;
|
|
5668
|
+
}
|
|
5669
|
+
if (ambient) {
|
|
5670
|
+
Object.defineProperty(snapshot, AMBIENT_ENVIRONMENT, {
|
|
5671
|
+
value: true,
|
|
5672
|
+
enumerable: false,
|
|
5673
|
+
writable: false,
|
|
5674
|
+
configurable: false
|
|
5675
|
+
});
|
|
5676
|
+
}
|
|
5677
|
+
return Object.freeze(snapshot);
|
|
5678
|
+
}
|
|
5679
|
+
function resolveCredential(name, env, options = {}) {
|
|
5680
|
+
env = snapshotClientEnvironment(name, env);
|
|
5681
|
+
const { apiKeyKeys } = clientTransportEnvKeys(name);
|
|
5682
|
+
const diskPaths = credentialDiskSources(name, env);
|
|
5683
|
+
if (options.apiKey !== undefined) {
|
|
5684
|
+
const explicitKey = options.apiKey.trim();
|
|
5685
|
+
if (!explicitKey) {
|
|
5686
|
+
throw new CredentialResolutionError(name, "The explicit apiKey argument is blank; an explicit credential never falls through to another identity.", ["explicit apiKey argument"]);
|
|
5687
|
+
}
|
|
5688
|
+
assertUsableCredential(name, "the explicit apiKey argument", explicitKey);
|
|
5689
|
+
return sealCredential({
|
|
5690
|
+
apiKey: explicitKey,
|
|
5691
|
+
tier: "argument",
|
|
5692
|
+
source: "explicit apiKey argument",
|
|
5693
|
+
deliberate: true,
|
|
5694
|
+
diskCandidates: diskPaths,
|
|
5695
|
+
warning: null
|
|
5696
|
+
});
|
|
5697
|
+
}
|
|
5698
|
+
const overrideKeyName = credentialOverrideEnvKey(name);
|
|
5699
|
+
const overrideRaw = Object.prototype.hasOwnProperty.call(env, overrideKeyName) ? env[overrideKeyName] : undefined;
|
|
5700
|
+
if (overrideRaw !== undefined) {
|
|
5701
|
+
const override = overrideRaw.trim();
|
|
5702
|
+
if (!override) {
|
|
5703
|
+
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]);
|
|
5704
|
+
}
|
|
5705
|
+
assertUsableCredential(name, overrideKeyName, override);
|
|
5706
|
+
return sealCredential({
|
|
5707
|
+
apiKey: override,
|
|
5708
|
+
tier: "override",
|
|
5709
|
+
source: overrideKeyName,
|
|
5710
|
+
deliberate: true,
|
|
5711
|
+
diskCandidates: diskPaths,
|
|
5712
|
+
warning: null
|
|
5713
|
+
});
|
|
5714
|
+
}
|
|
5715
|
+
const pointerKeyName = credentialPointerEnvKey(name);
|
|
5716
|
+
const pointerRaw = Object.prototype.hasOwnProperty.call(env, pointerKeyName) ? env[pointerKeyName] : undefined;
|
|
5717
|
+
if (pointerRaw !== undefined) {
|
|
5718
|
+
const pointer = pointerRaw.trim();
|
|
5719
|
+
if (!pointer) {
|
|
5720
|
+
throw new CredentialResolutionError(name, `${pointerKeyName} is set but empty. It is a deliberate vault pointer, so it is not resolved around: ` + `either give it a vault item key or unset it to fall back to the credential on disk.`, [pointerKeyName]);
|
|
5721
|
+
}
|
|
5722
|
+
if (!VAULT_POINTER_SHAPE.test(pointer)) {
|
|
5723
|
+
throw new CredentialResolutionError(name, `${pointerKeyName} must name a vault ITEM KEY (a path-shaped reference like ` + `'namespace/app/live/api_key'), not a credential value. A pointer that carries a literal is refused.`, [pointerKeyName]);
|
|
5724
|
+
}
|
|
5725
|
+
return sealCredential({
|
|
5726
|
+
apiKey: "",
|
|
5727
|
+
pointerVaultKey: pointer,
|
|
5728
|
+
tier: "pointer",
|
|
5729
|
+
source: pointerKeyName,
|
|
5730
|
+
deliberate: true,
|
|
5731
|
+
diskCandidates: diskPaths,
|
|
5732
|
+
warning: null
|
|
5733
|
+
});
|
|
5734
|
+
}
|
|
5735
|
+
if (options.profile !== undefined && !options.profile.trim()) {
|
|
5736
|
+
throw new CredentialResolutionError(name, "The explicit profile argument is blank; an explicit identity selection never falls through.", ["explicit profile argument"]);
|
|
5737
|
+
}
|
|
5738
|
+
const profileRaw = Object.prototype.hasOwnProperty.call(env, CREDENTIAL_PROFILE_ENV_KEY) ? env[CREDENTIAL_PROFILE_ENV_KEY] : undefined;
|
|
5739
|
+
if (profileRaw !== undefined && !profileRaw.trim()) {
|
|
5740
|
+
throw new CredentialResolutionError(name, `${CREDENTIAL_PROFILE_ENV_KEY} is set but blank.`, [CREDENTIAL_PROFILE_ENV_KEY]);
|
|
5741
|
+
}
|
|
5742
|
+
const profile = options.profile?.trim() || profileRaw?.trim();
|
|
5743
|
+
if (profile) {
|
|
5744
|
+
const profileSource = options.profile?.trim() ? "explicit profile argument" : CREDENTIAL_PROFILE_ENV_KEY;
|
|
5745
|
+
if (!SAFE_PROFILE.test(profile)) {
|
|
5746
|
+
throw new CredentialResolutionError(name, `Profile name from ${profileSource} is not usable in a path. ` + `Use letters, digits, dot, dash, or underscore.`, [profileSource]);
|
|
5747
|
+
}
|
|
5748
|
+
const paths = profileDiskSources(name, env, profile);
|
|
5749
|
+
for (const path of paths) {
|
|
5750
|
+
const value = readCredentialFile(path, apiKeyKeys);
|
|
5751
|
+
if (value) {
|
|
5752
|
+
assertUsableCredential(name, path, value);
|
|
5753
|
+
return sealCredential({
|
|
5754
|
+
apiKey: value,
|
|
5755
|
+
tier: "profile",
|
|
5756
|
+
source: path,
|
|
5757
|
+
deliberate: true,
|
|
5758
|
+
diskCandidates: paths,
|
|
5759
|
+
warning: null
|
|
5760
|
+
});
|
|
5761
|
+
}
|
|
5762
|
+
}
|
|
5763
|
+
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);
|
|
5764
|
+
}
|
|
5765
|
+
const definedEnvEntries = apiKeyKeys.filter((key) => Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined).map((key) => ({ key, value: String(env[key]).trim() }));
|
|
5766
|
+
const blankEnv = definedEnvEntries.find((entry) => entry.value.length === 0);
|
|
5767
|
+
if (blankEnv) {
|
|
5768
|
+
throw new CredentialResolutionError(name, `${blankEnv.key} is set but blank; a declared credential never falls through to another alias or identity.`, [blankEnv.key]);
|
|
5769
|
+
}
|
|
5770
|
+
if (definedEnvEntries.length > 1 && new Set(definedEnvEntries.map((entry) => entry.value)).size > 1) {
|
|
5771
|
+
throw new CredentialResolutionError(name, `${definedEnvEntries.map((entry) => entry.key).join(" and ")} disagree; credential aliases must be identical or only one may be set.`, definedEnvEntries.map((entry) => entry.key));
|
|
5772
|
+
}
|
|
5773
|
+
const envHit = firstEnvValue(env, apiKeyKeys);
|
|
5774
|
+
const keychainHit = readKeychainItem(name, env, "api-key", options.keychain ?? {});
|
|
5775
|
+
if (keychainHit) {
|
|
5776
|
+
assertUsableCredential(name, keychainHit.source, keychainHit.value);
|
|
5777
|
+
const warning = envHit && envHit.value !== keychainHit.value ? `Credential sources disagree for '${name}': ${keychainHit.source} and ${envHit.key} hold ` + `different keys. ${keychainHit.source} wins, because the Keychain is re-read on every call while ` + `an environment variable is a snapshot. Reconcile them \u2014 a rotation that updated only one leaves ` + `the other to fail 401 wherever it is loaded first.` : null;
|
|
5778
|
+
return sealCredential({
|
|
5779
|
+
apiKey: keychainHit.value,
|
|
5780
|
+
tier: "keychain",
|
|
5781
|
+
source: keychainHit.source,
|
|
5782
|
+
deliberate: false,
|
|
5783
|
+
diskCandidates: diskPaths,
|
|
5784
|
+
warning
|
|
5785
|
+
});
|
|
5786
|
+
}
|
|
5787
|
+
const diskSourceList = credentialDiskSourceList(name, env, null);
|
|
5788
|
+
const diskHits = diskSourceList.map((src) => ({ src, value: readCredentialFile(src.path, apiKeyKeys) })).filter((hit) => hit.value !== null);
|
|
5789
|
+
if (diskHits.length > 0) {
|
|
5790
|
+
const winner = diskHits[0];
|
|
5791
|
+
assertUsableCredential(name, winner.src.path, winner.value);
|
|
5792
|
+
const divergentSources = [
|
|
5793
|
+
...diskHits.slice(1).filter((hit) => hit.value !== winner.value).map((hit) => hit.src.path),
|
|
5794
|
+
...envHit && envHit.value !== winner.value ? [envHit.key] : []
|
|
5795
|
+
];
|
|
5796
|
+
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;
|
|
5797
|
+
return sealCredential({
|
|
5798
|
+
apiKey: winner.value,
|
|
5799
|
+
tier: winner.src.tier,
|
|
5800
|
+
source: winner.src.path,
|
|
5801
|
+
deliberate: false,
|
|
5802
|
+
diskCandidates: diskPaths,
|
|
5803
|
+
warning
|
|
5804
|
+
});
|
|
5805
|
+
}
|
|
5806
|
+
if (envHit) {
|
|
5807
|
+
assertUsableCredential(name, envHit.key, envHit.value);
|
|
5808
|
+
return sealCredential({
|
|
5809
|
+
apiKey: envHit.value,
|
|
5810
|
+
tier: "env",
|
|
5811
|
+
source: envHit.key,
|
|
5812
|
+
deliberate: false,
|
|
5813
|
+
diskCandidates: diskPaths,
|
|
5814
|
+
warning: null
|
|
5815
|
+
});
|
|
5816
|
+
}
|
|
5817
|
+
return null;
|
|
5818
|
+
}
|
|
5819
|
+
var SECRETS_PACKAGE_SPECIFIER = "@hasna/" + "secrets";
|
|
5820
|
+
var requireSecretsSdk = createRequire(import.meta.url);
|
|
5821
|
+
var IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
5822
|
+
var AUTHORITY_OVERRIDE_HEADERS = new Set([
|
|
5823
|
+
"host",
|
|
5824
|
+
":authority",
|
|
5825
|
+
"forwarded",
|
|
5826
|
+
"x-forwarded-host",
|
|
5827
|
+
"x-original-host"
|
|
5828
|
+
]);
|
|
5829
|
+
|
|
5830
|
+
// src/lib/local-opt-in.ts
|
|
5831
|
+
function hooksAuthorityEnvKeys() {
|
|
5832
|
+
const keys = clientTransportEnvKeys("hooks");
|
|
5833
|
+
return [
|
|
5834
|
+
...keys.apiUrlKeys,
|
|
5835
|
+
...keys.apiKeyKeys,
|
|
5836
|
+
credentialOverrideEnvKey("hooks"),
|
|
5837
|
+
credentialPointerEnvKey("hooks"),
|
|
5838
|
+
CREDENTIAL_PROFILE_ENV_KEY
|
|
5839
|
+
];
|
|
5840
|
+
}
|
|
5841
|
+
function hooksResolverEnv(env) {
|
|
5842
|
+
const blanks = hooksAuthorityEnvKeys().filter((key) => (key in env) && (env[key] ?? "").trim() === "");
|
|
5843
|
+
if (blanks.length === 0)
|
|
5844
|
+
return env;
|
|
5845
|
+
const next = { ...env };
|
|
5846
|
+
for (const key of blanks)
|
|
5847
|
+
delete next[key];
|
|
5848
|
+
return next;
|
|
5849
|
+
}
|
|
5850
|
+
var CONTRACTS_AMBIENT_ENVIRONMENT = Symbol.for("hasna:contracts:ambientClientEnvironment");
|
|
5851
|
+
function isAmbientHooksEnv(env) {
|
|
5852
|
+
if (typeof process !== "undefined" && env === process.env)
|
|
5853
|
+
return true;
|
|
5854
|
+
return env[CONTRACTS_AMBIENT_ENVIRONMENT] === true;
|
|
5855
|
+
}
|
|
5856
|
+
function hooksResolverInputs(env, credentials = {}) {
|
|
5857
|
+
const normalised = hooksResolverEnv(env);
|
|
5858
|
+
if (normalised === env)
|
|
5859
|
+
return { env: normalised, credentials };
|
|
5860
|
+
const keychain = { ...credentials.keychain };
|
|
5861
|
+
if (keychain.enabled === undefined && keychain.run === undefined) {
|
|
5862
|
+
keychain.enabled = isAmbientHooksEnv(env);
|
|
5863
|
+
}
|
|
5864
|
+
return { env: normalised, credentials: { ...credentials, keychain } };
|
|
5865
|
+
}
|
|
5866
|
+
|
|
5867
|
+
// src/lib/transport.ts
|
|
5868
|
+
function resolveHooksServePublishKey(env = process.env, options = {}) {
|
|
5869
|
+
const { env: resolverEnv, credentials } = hooksResolverInputs(env, options.credentials);
|
|
5870
|
+
const tier1 = options.credentials?.apiKey;
|
|
5871
|
+
const credentialOptions = tier1 !== undefined ? { ...credentials, apiKey: tier1 } : credentials;
|
|
5872
|
+
return resolveCredential("hooks", resolverEnv, credentialOptions)?.apiKey;
|
|
5873
|
+
}
|
|
5874
|
+
|
|
5503
5875
|
// src/lib/secure-compare.ts
|
|
5504
5876
|
function secureEqual(a, b) {
|
|
5505
5877
|
const enc = new TextEncoder;
|
|
@@ -5658,7 +6030,7 @@ var openApiDocument = {
|
|
|
5658
6030
|
apiKey: {
|
|
5659
6031
|
type: "http",
|
|
5660
6032
|
scheme: "bearer",
|
|
5661
|
-
description: "The registry API key (HASNA_HOOKS_API_KEY /
|
|
6033
|
+
description: "The registry API key \u2014 resolved by the client through the @hasna/contracts chain (HASNA_HOOKS_API_KEY, the Keychain item hasna.credentials.hooks.api-key, or ~/.hasna/hooks/config/credentials); the server compares the inbound bearer/x-api-key value."
|
|
5662
6034
|
}
|
|
5663
6035
|
}
|
|
5664
6036
|
}
|
|
@@ -5670,7 +6042,7 @@ var SERVE_HOST = "127.0.0.1";
|
|
|
5670
6042
|
var SERVE_SERVICE_NAME = "hooks-registry";
|
|
5671
6043
|
function packageVersion() {
|
|
5672
6044
|
try {
|
|
5673
|
-
const pkgPath =
|
|
6045
|
+
const pkgPath = join8(dirname3(fileURLToPath2(import.meta.url)), "..", "package.json");
|
|
5674
6046
|
return JSON.parse(readFileSync5(pkgPath, "utf-8")).version || "0.0.0";
|
|
5675
6047
|
} catch {
|
|
5676
6048
|
return "0.0.0";
|
|
@@ -5819,15 +6191,20 @@ function handleServeRequest(req, apiKey) {
|
|
|
5819
6191
|
}
|
|
5820
6192
|
return Promise.resolve(json({ error: "Not Found" }, 404));
|
|
5821
6193
|
}
|
|
6194
|
+
function resolveServeOptions(options) {
|
|
6195
|
+
const envPort = process.env.PORT?.trim();
|
|
6196
|
+
const envHost = process.env.HOST?.trim();
|
|
6197
|
+
const port = options.port ?? (envPort && /^[0-9]+$/.test(envPort) ? parseInt(envPort, 10) : DEFAULT_SERVE_PORT);
|
|
6198
|
+
const host = options.host ?? (envHost && envHost.length > 0 ? envHost : SERVE_HOST);
|
|
6199
|
+
return { port, host };
|
|
6200
|
+
}
|
|
5822
6201
|
function startServeServer(options) {
|
|
5823
|
-
const port = options
|
|
5824
|
-
const host = options.host ?? SERVE_HOST;
|
|
5825
|
-
const apiKey = resolveApiKey();
|
|
6202
|
+
const { port, host } = resolveServeOptions(options);
|
|
5826
6203
|
const server = Bun.serve({
|
|
5827
6204
|
hostname: host,
|
|
5828
6205
|
port,
|
|
5829
6206
|
async fetch(req) {
|
|
5830
|
-
return handleServeRequest(req,
|
|
6207
|
+
return handleServeRequest(req, resolveHooksServePublishKey());
|
|
5831
6208
|
}
|
|
5832
6209
|
});
|
|
5833
6210
|
console.error(`hooks registry listening on http://${host}:${port} (publish requires an API key)`);
|
|
@@ -5862,6 +6239,7 @@ options:
|
|
|
5862
6239
|
}
|
|
5863
6240
|
export {
|
|
5864
6241
|
startServeServer,
|
|
6242
|
+
resolveServeOptions,
|
|
5865
6243
|
handleServeRequest,
|
|
5866
6244
|
SERVE_SERVICE_NAME,
|
|
5867
6245
|
SERVE_HOST,
|