@alphafox/cli 0.1.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.
@@ -0,0 +1,29 @@
1
+ export type ProfileName = "production" | "staging" | "local";
2
+ export interface ProfileConfig {
3
+ readonly name: ProfileName;
4
+ readonly apiBaseUrl: string;
5
+ readonly issuer: string;
6
+ readonly audience: string;
7
+ readonly clientId: string;
8
+ readonly contractVersion?: string;
9
+ /** Only for local / unsafe custom. */
10
+ readonly localOrigin?: string;
11
+ }
12
+ export interface CliConfigFile {
13
+ readonly activeProfile: ProfileName;
14
+ readonly profiles: Partial<Record<ProfileName, Partial<ProfileConfig>>>;
15
+ readonly unsafeCustomEndpoint?: string;
16
+ /**
17
+ * Tokens MUST NOT be stored here. Key is reserved to detect accidental writes.
18
+ * @deprecated never use
19
+ */
20
+ readonly __tokensForbidden?: never;
21
+ }
22
+ export declare function defaultConfigDir(env?: NodeJS.ProcessEnv): string;
23
+ export declare function configFilePath(env?: NodeJS.ProcessEnv): string;
24
+ export declare function loadConfigFile(env?: NodeJS.ProcessEnv): CliConfigFile;
25
+ export declare function saveConfigFile(config: CliConfigFile, env?: NodeJS.ProcessEnv): void;
26
+ export declare function resolveProfile(name?: ProfileName | string, env?: NodeJS.ProcessEnv, options?: {
27
+ readonly unsafeCustomEndpoint?: string;
28
+ }): ProfileConfig;
29
+ export declare function assertNoTokenFields(config: unknown): void;
@@ -0,0 +1,134 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.defaultConfigDir = defaultConfigDir;
4
+ exports.configFilePath = configFilePath;
5
+ exports.loadConfigFile = loadConfigFile;
6
+ exports.saveConfigFile = saveConfigFile;
7
+ exports.resolveProfile = resolveProfile;
8
+ exports.assertNoTokenFields = assertNoTokenFields;
9
+ const node_fs_1 = require("node:fs");
10
+ const node_os_1 = require("node:os");
11
+ const node_path_1 = require("node:path");
12
+ const DEFAULTS = {
13
+ production: {
14
+ name: "production",
15
+ apiBaseUrl: "https://alphafox.app/api/v1",
16
+ issuer: "https://alphafox.app/api/auth",
17
+ audience: "https://alphafox.app/api/v1",
18
+ clientId: "alphafox-cli-prod",
19
+ contractVersion: "2026-08-11",
20
+ },
21
+ staging: {
22
+ name: "staging",
23
+ apiBaseUrl: "https://staging.alphafox.app/api/v1",
24
+ issuer: "https://staging.alphafox.app/api/auth",
25
+ audience: "https://staging.alphafox.app/api/v1",
26
+ clientId: "alphafox-cli-staging",
27
+ contractVersion: "2026-08-11",
28
+ },
29
+ local: {
30
+ name: "local",
31
+ apiBaseUrl: "http://127.0.0.1:3000/api/v1",
32
+ issuer: "http://127.0.0.1:3000/api/auth",
33
+ audience: "http://127.0.0.1:3000/api/v1",
34
+ clientId: "alphafox-cli-local",
35
+ localOrigin: "http://127.0.0.1:3000",
36
+ contractVersion: "2026-08-11",
37
+ },
38
+ };
39
+ function defaultConfigDir(env = process.env) {
40
+ if (env.ALPHAFOX_CONFIG_DIR?.trim()) {
41
+ return env.ALPHAFOX_CONFIG_DIR.trim();
42
+ }
43
+ return (0, node_path_1.join)((0, node_os_1.homedir)(), ".config", "alphafox");
44
+ }
45
+ function configFilePath(env = process.env) {
46
+ return (0, node_path_1.join)(defaultConfigDir(env), "config.json");
47
+ }
48
+ function loadConfigFile(env = process.env) {
49
+ const path = configFilePath(env);
50
+ if (!(0, node_fs_1.existsSync)(path)) {
51
+ return { activeProfile: "production", profiles: {} };
52
+ }
53
+ const raw = JSON.parse((0, node_fs_1.readFileSync)(path, "utf8"));
54
+ // Fail closed if someone stuffed tokens into config.
55
+ if (raw.tokens != null ||
56
+ raw.accessToken != null ||
57
+ raw.refreshToken != null ||
58
+ raw.secret != null) {
59
+ throw new Error("Refusing to load config: tokens/secrets must not be stored in config files. Remove them and use the OS keychain.");
60
+ }
61
+ return {
62
+ activeProfile: raw.activeProfile ?? "production",
63
+ profiles: raw.profiles ?? {},
64
+ unsafeCustomEndpoint: raw.unsafeCustomEndpoint,
65
+ };
66
+ }
67
+ function saveConfigFile(config, env = process.env) {
68
+ // Strip any accidental token fields
69
+ const safe = {
70
+ activeProfile: config.activeProfile,
71
+ profiles: config.profiles,
72
+ ...(config.unsafeCustomEndpoint
73
+ ? { unsafeCustomEndpoint: config.unsafeCustomEndpoint }
74
+ : {}),
75
+ };
76
+ const dir = defaultConfigDir(env);
77
+ (0, node_fs_1.mkdirSync)(dir, { recursive: true });
78
+ (0, node_fs_1.writeFileSync)(configFilePath(env), `${JSON.stringify(safe, null, 2)}\n`, {
79
+ mode: 0o600,
80
+ });
81
+ }
82
+ function resolveProfile(name, env = process.env, options = {}) {
83
+ const file = loadConfigFile(env);
84
+ const profileName = (name ||
85
+ env.ALPHAFOX_PROFILE ||
86
+ file.activeProfile ||
87
+ "production");
88
+ if (!DEFAULTS[profileName]) {
89
+ throw new Error(`Unknown profile "${profileName}". Use production|staging|local.`);
90
+ }
91
+ const base = { ...DEFAULTS[profileName], ...(file.profiles[profileName] ?? {}) };
92
+ if (profileName === "local" && env.ALPHAFOX_LOCAL_ORIGIN?.trim()) {
93
+ const origin = env.ALPHAFOX_LOCAL_ORIGIN.trim().replace(/\/$/, "");
94
+ return {
95
+ ...base,
96
+ name: "local",
97
+ localOrigin: origin,
98
+ apiBaseUrl: `${origin}/api/v1`,
99
+ issuer: `${origin}/api/auth`,
100
+ audience: `${origin}/api/v1`,
101
+ };
102
+ }
103
+ const custom = options.unsafeCustomEndpoint ||
104
+ env.ALPHAFOX_UNSAFE_CUSTOM_ENDPOINT ||
105
+ file.unsafeCustomEndpoint;
106
+ if (custom) {
107
+ // Custom endpoint never inherits stored prod/staging tokens (caller must not send them).
108
+ const origin = custom.replace(/\/$/, "").replace(/\/api\/v1$/, "");
109
+ return {
110
+ ...base,
111
+ apiBaseUrl: custom.includes("/api/v1")
112
+ ? custom
113
+ : `${origin}/api/v1`,
114
+ issuer: `${origin}/api/auth`,
115
+ audience: custom.includes("/api/v1") ? custom : `${origin}/api/v1`,
116
+ };
117
+ }
118
+ return base;
119
+ }
120
+ function assertNoTokenFields(config) {
121
+ if (!config || typeof config !== "object") {
122
+ return;
123
+ }
124
+ const obj = config;
125
+ for (const key of Object.keys(obj)) {
126
+ const lower = key.toLowerCase();
127
+ if (lower.includes("token") ||
128
+ lower.includes("secret") ||
129
+ lower.includes("password") ||
130
+ lower === "authorization") {
131
+ throw new Error(`Forbidden config field: ${key}`);
132
+ }
133
+ }
134
+ }
@@ -0,0 +1,37 @@
1
+ export type ExitCode = 0 | 1 | 2 | 3 | 4 | 10 | 64 | 65 | 66 | 69 | 70 | 75 | 77 | 78;
2
+ export interface SuccessEnvelope<T = unknown> {
3
+ readonly ok: true;
4
+ readonly data: T;
5
+ readonly meta?: Record<string, unknown>;
6
+ readonly requestId?: string;
7
+ }
8
+ export interface ErrorBody {
9
+ readonly type: string;
10
+ readonly subtype?: string;
11
+ readonly code?: string | number;
12
+ readonly message: string;
13
+ readonly hint?: string;
14
+ readonly status?: number;
15
+ readonly risk?: string;
16
+ readonly action?: string;
17
+ readonly details?: unknown;
18
+ }
19
+ export interface ErrorEnvelope {
20
+ readonly ok: false;
21
+ readonly error: ErrorBody;
22
+ readonly requestId?: string;
23
+ }
24
+ export declare function newRequestId(): string;
25
+ export declare function successEnvelope<T>(data: T, meta?: Record<string, unknown>, requestId?: string): SuccessEnvelope<T>;
26
+ export declare function errorEnvelope(error: ErrorBody, requestId?: string): ErrorEnvelope;
27
+ export declare function writeSuccess(data: unknown, options?: {
28
+ readonly meta?: Record<string, unknown>;
29
+ readonly requestId?: string;
30
+ readonly format?: "json" | "jsonl" | "text";
31
+ }): void;
32
+ export declare function writeError(error: ErrorBody, options?: {
33
+ readonly requestId?: string;
34
+ readonly exitCode?: number;
35
+ }): never;
36
+ export declare function mapErrorToExitCode(error: ErrorBody): number;
37
+ export declare function parseJsonEnvelope(text: string): SuccessEnvelope | ErrorEnvelope;
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.newRequestId = newRequestId;
4
+ exports.successEnvelope = successEnvelope;
5
+ exports.errorEnvelope = errorEnvelope;
6
+ exports.writeSuccess = writeSuccess;
7
+ exports.writeError = writeError;
8
+ exports.mapErrorToExitCode = mapErrorToExitCode;
9
+ exports.parseJsonEnvelope = parseJsonEnvelope;
10
+ const node_crypto_1 = require("node:crypto");
11
+ function newRequestId() {
12
+ return (0, node_crypto_1.randomUUID)();
13
+ }
14
+ function successEnvelope(data, meta, requestId) {
15
+ return {
16
+ ok: true,
17
+ data,
18
+ ...(meta ? { meta } : {}),
19
+ ...(requestId ? { requestId } : {}),
20
+ };
21
+ }
22
+ function errorEnvelope(error, requestId) {
23
+ return {
24
+ ok: false,
25
+ error,
26
+ ...(requestId ? { requestId } : {}),
27
+ };
28
+ }
29
+ function writeSuccess(data, options = {}) {
30
+ const envelope = successEnvelope(data, options.meta, options.requestId);
31
+ if (options.format === "text" && data && typeof data === "object") {
32
+ process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
33
+ return;
34
+ }
35
+ process.stdout.write(`${JSON.stringify(envelope)}\n`);
36
+ }
37
+ function writeError(error, options = {}) {
38
+ const envelope = errorEnvelope(error, options.requestId);
39
+ process.stderr.write(`${JSON.stringify(envelope)}\n`);
40
+ process.exit(options.exitCode ?? mapErrorToExitCode(error));
41
+ }
42
+ function mapErrorToExitCode(error) {
43
+ if (error.type === "confirmation") {
44
+ return 10;
45
+ }
46
+ if (error.status === 401) {
47
+ return 77;
48
+ }
49
+ if (error.status === 403) {
50
+ return 77;
51
+ }
52
+ if (error.status === 404) {
53
+ return 66;
54
+ }
55
+ if (error.status === 409) {
56
+ return 75;
57
+ }
58
+ if (error.status === 422 || error.status === 400) {
59
+ return 64;
60
+ }
61
+ if (error.status === 429) {
62
+ return 75;
63
+ }
64
+ if (error.status && error.status >= 500) {
65
+ return 69;
66
+ }
67
+ if (error.type === "usage") {
68
+ return 64;
69
+ }
70
+ return 1;
71
+ }
72
+ function parseJsonEnvelope(text) {
73
+ const parsed = JSON.parse(text);
74
+ if (typeof parsed !== "object" || parsed === null || !("ok" in parsed)) {
75
+ throw new Error("Invalid envelope: missing ok field");
76
+ }
77
+ return parsed;
78
+ }
@@ -0,0 +1,19 @@
1
+ import type { ProfileConfig } from "../config/profiles";
2
+ export interface ApiRequestOptions {
3
+ readonly method: string;
4
+ readonly path: string;
5
+ readonly body?: unknown;
6
+ readonly headers?: Record<string, string>;
7
+ readonly profile: ProfileConfig;
8
+ readonly requestId?: string;
9
+ readonly skipAuth?: boolean;
10
+ readonly idempotencyKey?: string;
11
+ }
12
+ export interface ApiResponse {
13
+ readonly status: number;
14
+ readonly headers: Headers;
15
+ readonly bodyText: string;
16
+ readonly requestId: string;
17
+ readonly json: unknown;
18
+ }
19
+ export declare function apiRequest(options: ApiRequestOptions, env?: NodeJS.ProcessEnv, fetchImpl?: typeof fetch): Promise<ApiResponse>;
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.apiRequest = apiRequest;
4
+ const envelope_1 = require("../envelope");
5
+ const store_1 = require("../keychain/store");
6
+ const allowlist_1 = require("../catalog/allowlist");
7
+ async function apiRequest(options, env = process.env, fetchImpl = fetch) {
8
+ const path = (0, allowlist_1.normalizeApiPath)(options.path);
9
+ if ((0, allowlist_1.isInternalDisallowedPath)(path)) {
10
+ throw Object.assign(new Error(`Path is internal and not allowed: ${path}`), {
11
+ status: 403,
12
+ type: "authorization",
13
+ subtype: "internal_path_forbidden",
14
+ });
15
+ }
16
+ // Product raw API: /api/v1 only. Auth AS paths under /api/auth/oauth are allowed.
17
+ const isOAuthAsPath = path.startsWith("/api/auth/oauth");
18
+ if (!isOAuthAsPath &&
19
+ !(0, allowlist_1.isFacadeAllowlistedPath)(path) &&
20
+ path.startsWith("/api/") &&
21
+ !path.startsWith("/api/v1")) {
22
+ throw Object.assign(new Error(`Path is outside Public API facade: ${path}`), {
23
+ status: 403,
24
+ type: "authorization",
25
+ subtype: "facade_only",
26
+ });
27
+ }
28
+ const requestId = options.requestId ?? (0, envelope_1.newRequestId)();
29
+ const base = options.profile.apiBaseUrl.replace(/\/$/, "");
30
+ // path may be full /api/v1/... while base already ends with /api/v1
31
+ let url;
32
+ if (path.startsWith("/api/v1")) {
33
+ const origin = base.replace(/\/api\/v1$/, "");
34
+ url = `${origin}${path}`;
35
+ }
36
+ else if (path.startsWith("/api/auth")) {
37
+ const origin = base.replace(/\/api\/v1$/, "");
38
+ url = `${origin}${path}`;
39
+ }
40
+ else {
41
+ url = `${base}${path.startsWith("/") ? path : `/${path}`}`;
42
+ }
43
+ const headers = {
44
+ Accept: "application/json",
45
+ "X-Request-Id": requestId,
46
+ "X-Alphafox-Client": "alphafox-cli",
47
+ "X-Alphafox-Client-Version": env.ALPHAFOX_CLI_VERSION ?? "0.1.0",
48
+ ...(options.headers ?? {}),
49
+ };
50
+ if (!options.skipAuth) {
51
+ const tokens = (0, store_1.loadTokens)(options.profile.name, env);
52
+ if (tokens) {
53
+ // Never send tokens to a different origin than the profile audience.
54
+ const tokenAudienceOrigin = originOf(tokens.audience);
55
+ const targetOrigin = originOf(url);
56
+ if (tokens.audience &&
57
+ tokenAudienceOrigin &&
58
+ targetOrigin &&
59
+ tokenAudienceOrigin !== targetOrigin) {
60
+ throw Object.assign(new Error("Refusing to send stored tokens to a different origin than token audience (fail-closed)."), {
61
+ status: 403,
62
+ type: "authorization",
63
+ subtype: "cross_origin_token",
64
+ });
65
+ }
66
+ headers.Authorization = `Bearer ${tokens.accessToken}`;
67
+ }
68
+ }
69
+ if (options.idempotencyKey) {
70
+ headers["Idempotency-Key"] = options.idempotencyKey;
71
+ }
72
+ const init = {
73
+ method: options.method.toUpperCase(),
74
+ headers,
75
+ };
76
+ if (options.body !== undefined) {
77
+ headers["Content-Type"] = "application/json";
78
+ init.body = JSON.stringify(options.body);
79
+ }
80
+ const response = await fetchImpl(url, init);
81
+ const bodyText = await response.text();
82
+ let json = null;
83
+ try {
84
+ json = bodyText ? JSON.parse(bodyText) : null;
85
+ }
86
+ catch {
87
+ json = { raw: bodyText };
88
+ }
89
+ const responseRequestId = response.headers.get("x-request-id") ??
90
+ response.headers.get("X-Request-Id") ??
91
+ requestId;
92
+ return {
93
+ status: response.status,
94
+ headers: response.headers,
95
+ bodyText,
96
+ requestId: responseRequestId,
97
+ json,
98
+ };
99
+ }
100
+ function originOf(value) {
101
+ try {
102
+ if (value.startsWith("http://") || value.startsWith("https://")) {
103
+ return new URL(value).origin;
104
+ }
105
+ return null;
106
+ }
107
+ catch {
108
+ return null;
109
+ }
110
+ }
@@ -0,0 +1,8 @@
1
+ export { runCli, parseGlobalFlags } from "./commands/run";
2
+ export { successEnvelope, errorEnvelope, parseJsonEnvelope, writeSuccess, writeError, } from "./envelope";
3
+ export { resolveProfile, loadConfigFile, saveConfigFile, assertNoTokenFields, } from "./config/profiles";
4
+ export { saveTokens, loadTokens, deleteTokens, tokenFingerprint, } from "./keychain/store";
5
+ export { isFacadeAllowlistedPath, isInternalDisallowedPath, normalizeApiPath, } from "./catalog/allowlist";
6
+ export { assertHighRiskConfirmation } from "./safety/confirmation";
7
+ export { CATALOG_OPERATIONS, findCatalogOperation, buildCapabilityManifest, } from "./catalog/operations";
8
+ export { CLI_VERSION, CLI_PACKAGE, CLI_CONTRACT_VERSION } from "./version";
package/dist/index.js ADDED
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CLI_CONTRACT_VERSION = exports.CLI_PACKAGE = exports.CLI_VERSION = exports.buildCapabilityManifest = exports.findCatalogOperation = exports.CATALOG_OPERATIONS = exports.assertHighRiskConfirmation = exports.normalizeApiPath = exports.isInternalDisallowedPath = exports.isFacadeAllowlistedPath = exports.tokenFingerprint = exports.deleteTokens = exports.loadTokens = exports.saveTokens = exports.assertNoTokenFields = exports.saveConfigFile = exports.loadConfigFile = exports.resolveProfile = exports.writeError = exports.writeSuccess = exports.parseJsonEnvelope = exports.errorEnvelope = exports.successEnvelope = exports.parseGlobalFlags = exports.runCli = void 0;
4
+ var run_1 = require("./commands/run");
5
+ Object.defineProperty(exports, "runCli", { enumerable: true, get: function () { return run_1.runCli; } });
6
+ Object.defineProperty(exports, "parseGlobalFlags", { enumerable: true, get: function () { return run_1.parseGlobalFlags; } });
7
+ var envelope_1 = require("./envelope");
8
+ Object.defineProperty(exports, "successEnvelope", { enumerable: true, get: function () { return envelope_1.successEnvelope; } });
9
+ Object.defineProperty(exports, "errorEnvelope", { enumerable: true, get: function () { return envelope_1.errorEnvelope; } });
10
+ Object.defineProperty(exports, "parseJsonEnvelope", { enumerable: true, get: function () { return envelope_1.parseJsonEnvelope; } });
11
+ Object.defineProperty(exports, "writeSuccess", { enumerable: true, get: function () { return envelope_1.writeSuccess; } });
12
+ Object.defineProperty(exports, "writeError", { enumerable: true, get: function () { return envelope_1.writeError; } });
13
+ var profiles_1 = require("./config/profiles");
14
+ Object.defineProperty(exports, "resolveProfile", { enumerable: true, get: function () { return profiles_1.resolveProfile; } });
15
+ Object.defineProperty(exports, "loadConfigFile", { enumerable: true, get: function () { return profiles_1.loadConfigFile; } });
16
+ Object.defineProperty(exports, "saveConfigFile", { enumerable: true, get: function () { return profiles_1.saveConfigFile; } });
17
+ Object.defineProperty(exports, "assertNoTokenFields", { enumerable: true, get: function () { return profiles_1.assertNoTokenFields; } });
18
+ var store_1 = require("./keychain/store");
19
+ Object.defineProperty(exports, "saveTokens", { enumerable: true, get: function () { return store_1.saveTokens; } });
20
+ Object.defineProperty(exports, "loadTokens", { enumerable: true, get: function () { return store_1.loadTokens; } });
21
+ Object.defineProperty(exports, "deleteTokens", { enumerable: true, get: function () { return store_1.deleteTokens; } });
22
+ Object.defineProperty(exports, "tokenFingerprint", { enumerable: true, get: function () { return store_1.tokenFingerprint; } });
23
+ var allowlist_1 = require("./catalog/allowlist");
24
+ Object.defineProperty(exports, "isFacadeAllowlistedPath", { enumerable: true, get: function () { return allowlist_1.isFacadeAllowlistedPath; } });
25
+ Object.defineProperty(exports, "isInternalDisallowedPath", { enumerable: true, get: function () { return allowlist_1.isInternalDisallowedPath; } });
26
+ Object.defineProperty(exports, "normalizeApiPath", { enumerable: true, get: function () { return allowlist_1.normalizeApiPath; } });
27
+ var confirmation_1 = require("./safety/confirmation");
28
+ Object.defineProperty(exports, "assertHighRiskConfirmation", { enumerable: true, get: function () { return confirmation_1.assertHighRiskConfirmation; } });
29
+ var operations_1 = require("./catalog/operations");
30
+ Object.defineProperty(exports, "CATALOG_OPERATIONS", { enumerable: true, get: function () { return operations_1.CATALOG_OPERATIONS; } });
31
+ Object.defineProperty(exports, "findCatalogOperation", { enumerable: true, get: function () { return operations_1.findCatalogOperation; } });
32
+ Object.defineProperty(exports, "buildCapabilityManifest", { enumerable: true, get: function () { return operations_1.buildCapabilityManifest; } });
33
+ var version_1 = require("./version");
34
+ Object.defineProperty(exports, "CLI_VERSION", { enumerable: true, get: function () { return version_1.CLI_VERSION; } });
35
+ Object.defineProperty(exports, "CLI_PACKAGE", { enumerable: true, get: function () { return version_1.CLI_PACKAGE; } });
36
+ Object.defineProperty(exports, "CLI_CONTRACT_VERSION", { enumerable: true, get: function () { return version_1.CLI_CONTRACT_VERSION; } });
@@ -0,0 +1,19 @@
1
+ /**
2
+ * OS keychain token storage. Config files never receive tokens.
3
+ * Test injection: ALPHAFOX_TEST_ACCESS_TOKEN / ALPHAFOX_TEST_REFRESH_TOKEN
4
+ * (local unit tests only; not a production automation path — ADR 0004).
5
+ */
6
+ export interface StoredTokens {
7
+ readonly accessToken: string;
8
+ readonly refreshToken: string;
9
+ readonly expiresAt: number;
10
+ readonly environment: string;
11
+ readonly issuer: string;
12
+ readonly audience: string;
13
+ readonly clientId: string;
14
+ readonly scopes: readonly string[];
15
+ }
16
+ export declare function saveTokens(profile: string, tokens: StoredTokens, env?: NodeJS.ProcessEnv): void;
17
+ export declare function loadTokens(profile: string, env?: NodeJS.ProcessEnv): StoredTokens | null;
18
+ export declare function deleteTokens(profile: string, env?: NodeJS.ProcessEnv): void;
19
+ export declare function tokenFingerprint(token: string): string;
@@ -0,0 +1,149 @@
1
+ "use strict";
2
+ /**
3
+ * OS keychain token storage. Config files never receive tokens.
4
+ * Test injection: ALPHAFOX_TEST_ACCESS_TOKEN / ALPHAFOX_TEST_REFRESH_TOKEN
5
+ * (local unit tests only; not a production automation path — ADR 0004).
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.saveTokens = saveTokens;
9
+ exports.loadTokens = loadTokens;
10
+ exports.deleteTokens = deleteTokens;
11
+ exports.tokenFingerprint = tokenFingerprint;
12
+ const node_crypto_1 = require("node:crypto");
13
+ const node_fs_1 = require("node:fs");
14
+ const node_os_1 = require("node:os");
15
+ const node_path_1 = require("node:path");
16
+ const node_child_process_1 = require("node:child_process");
17
+ function serviceName(profile) {
18
+ return `alphafox-cli.${profile}`;
19
+ }
20
+ function accountName() {
21
+ return "oauth-tokens";
22
+ }
23
+ /** File fallback under secure mode 0600 when OS keychain is unavailable (CI/Linux headless). */
24
+ function fileFallbackPath(profile, env) {
25
+ const base = env.ALPHAFOX_KEYCHAIN_DIR?.trim() ||
26
+ (0, node_path_1.join)((0, node_os_1.homedir)(), ".config", "alphafox", "keychain");
27
+ return (0, node_path_1.join)(base, `${profile}.tokens.json`);
28
+ }
29
+ function saveTokens(profile, tokens, env = process.env) {
30
+ const payload = JSON.stringify(tokens);
31
+ if (tryKeychainWrite(profile, payload, env)) {
32
+ return;
33
+ }
34
+ const path = fileFallbackPath(profile, env);
35
+ (0, node_fs_1.mkdirSync)((0, node_path_1.join)(path, ".."), { recursive: true });
36
+ (0, node_fs_1.writeFileSync)(path, payload, { mode: 0o600 });
37
+ }
38
+ function loadTokens(profile, env = process.env) {
39
+ // Controlled test injection — never document as prod automation.
40
+ if (env.ALPHAFOX_TEST_ACCESS_TOKEN?.trim()) {
41
+ return {
42
+ accessToken: env.ALPHAFOX_TEST_ACCESS_TOKEN.trim(),
43
+ refreshToken: env.ALPHAFOX_TEST_REFRESH_TOKEN?.trim() ?? "",
44
+ expiresAt: Date.now() + 3600_000,
45
+ environment: profile,
46
+ issuer: env.ALPHAFOX_TEST_ISSUER ?? "",
47
+ audience: env.ALPHAFOX_TEST_AUDIENCE ?? "",
48
+ clientId: env.ALPHAFOX_TEST_CLIENT_ID ?? "",
49
+ scopes: (env.ALPHAFOX_TEST_SCOPES ?? "openid profile").split(/\s+/),
50
+ };
51
+ }
52
+ const fromKc = tryKeychainRead(profile, env);
53
+ if (fromKc) {
54
+ return JSON.parse(fromKc);
55
+ }
56
+ const path = fileFallbackPath(profile, env);
57
+ if (!(0, node_fs_1.existsSync)(path)) {
58
+ return null;
59
+ }
60
+ return JSON.parse((0, node_fs_1.readFileSync)(path, "utf8"));
61
+ }
62
+ function deleteTokens(profile, env = process.env) {
63
+ tryKeychainDelete(profile, env);
64
+ const path = fileFallbackPath(profile, env);
65
+ if ((0, node_fs_1.existsSync)(path)) {
66
+ (0, node_fs_1.unlinkSync)(path);
67
+ }
68
+ }
69
+ function tokenFingerprint(token) {
70
+ return (0, node_crypto_1.createHash)("sha256").update(token).digest("hex").slice(0, 12);
71
+ }
72
+ function tryKeychainWrite(profile, payload, env) {
73
+ if (env.ALPHAFOX_FORCE_FILE_KEYCHAIN === "1") {
74
+ return false;
75
+ }
76
+ if (process.platform === "darwin") {
77
+ try {
78
+ // delete existing silently
79
+ try {
80
+ (0, node_child_process_1.execFileSync)("security", [
81
+ "delete-generic-password",
82
+ "-s",
83
+ serviceName(profile),
84
+ "-a",
85
+ accountName(),
86
+ ], { stdio: "ignore" });
87
+ }
88
+ catch {
89
+ // none
90
+ }
91
+ (0, node_child_process_1.execFileSync)("security", [
92
+ "add-generic-password",
93
+ "-s",
94
+ serviceName(profile),
95
+ "-a",
96
+ accountName(),
97
+ "-w",
98
+ payload,
99
+ "-U",
100
+ ], { stdio: "ignore" });
101
+ return true;
102
+ }
103
+ catch {
104
+ return false;
105
+ }
106
+ }
107
+ return false;
108
+ }
109
+ function tryKeychainRead(profile, env) {
110
+ if (env.ALPHAFOX_FORCE_FILE_KEYCHAIN === "1") {
111
+ return null;
112
+ }
113
+ if (process.platform === "darwin") {
114
+ try {
115
+ const out = (0, node_child_process_1.execFileSync)("security", [
116
+ "find-generic-password",
117
+ "-s",
118
+ serviceName(profile),
119
+ "-a",
120
+ accountName(),
121
+ "-w",
122
+ ], { encoding: "utf8" });
123
+ return out.trim();
124
+ }
125
+ catch {
126
+ return null;
127
+ }
128
+ }
129
+ return null;
130
+ }
131
+ function tryKeychainDelete(profile, env) {
132
+ if (env.ALPHAFOX_FORCE_FILE_KEYCHAIN === "1") {
133
+ return;
134
+ }
135
+ if (process.platform === "darwin") {
136
+ try {
137
+ (0, node_child_process_1.execFileSync)("security", [
138
+ "delete-generic-password",
139
+ "-s",
140
+ serviceName(profile),
141
+ "-a",
142
+ accountName(),
143
+ ], { stdio: "ignore" });
144
+ }
145
+ catch {
146
+ // none
147
+ }
148
+ }
149
+ }
@@ -0,0 +1,22 @@
1
+ export type RiskLevel = "read" | "write" | "high-risk-write";
2
+ export interface ConfirmationGateResult {
3
+ readonly allowed: boolean;
4
+ readonly error?: {
5
+ readonly type: "confirmation";
6
+ readonly subtype: "confirmation_required";
7
+ readonly message: string;
8
+ readonly hint: string;
9
+ readonly risk: "high-risk-write";
10
+ readonly action: string;
11
+ };
12
+ }
13
+ /**
14
+ * High-risk writes require explicit --yes (or equivalent confirmation token).
15
+ * Server still enforces scopes/roles; this is CLI UX only.
16
+ */
17
+ export declare function assertHighRiskConfirmation(input: {
18
+ readonly risk: RiskLevel | string;
19
+ readonly yes: boolean;
20
+ readonly action: string;
21
+ readonly dryRun?: boolean;
22
+ }): ConfirmationGateResult;