@gitpagedocs/tools 1.1.44

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.
Files changed (55) hide show
  1. package/README.md +33 -0
  2. package/package.json +37 -0
  3. package/src/ai/bootstrap.ts +50 -0
  4. package/src/ai/catalog.ts +188 -0
  5. package/src/ai/factory.ts +14 -0
  6. package/src/ai/http/streaming.ts +60 -0
  7. package/src/ai/index.ts +27 -0
  8. package/src/ai/legacy-adapter.ts +33 -0
  9. package/src/ai/model-registry.ts +13 -0
  10. package/src/ai/providers/anthropic-provider.ts +83 -0
  11. package/src/ai/providers/cohere-provider.ts +66 -0
  12. package/src/ai/providers/gemini-provider.ts +84 -0
  13. package/src/ai/providers/ollama-provider.ts +69 -0
  14. package/src/ai/providers/openai-compatible-provider.ts +75 -0
  15. package/src/ai/providers/shared.ts +72 -0
  16. package/src/ai/registry.ts +29 -0
  17. package/src/cache/file-cache.ts +86 -0
  18. package/src/cache/index.ts +9 -0
  19. package/src/cache/memory-cache.ts +47 -0
  20. package/src/cache/web-storage-cache.ts +91 -0
  21. package/src/config/config-loader.ts +59 -0
  22. package/src/config/index.ts +1 -0
  23. package/src/constants/config.ts +8 -0
  24. package/src/constants/index.ts +1 -0
  25. package/src/crypto/index.ts +2 -0
  26. package/src/crypto/node-crypto-service.ts +87 -0
  27. package/src/crypto/web-crypto-service.ts +102 -0
  28. package/src/crypto/web.ts +2 -0
  29. package/src/documentation/doc-generator.ts +74 -0
  30. package/src/documentation/doc-updater.ts +47 -0
  31. package/src/documentation/index.ts +18 -0
  32. package/src/documentation/marker-patcher.ts +33 -0
  33. package/src/documentation/sections.ts +82 -0
  34. package/src/errors/app-error.ts +84 -0
  35. package/src/errors/index.ts +1 -0
  36. package/src/filesystem/file-service.ts +121 -0
  37. package/src/filesystem/index.ts +2 -0
  38. package/src/index.ts +24 -0
  39. package/src/logger/index.ts +2 -0
  40. package/src/logger/logger.ts +73 -0
  41. package/src/logger/redaction.ts +43 -0
  42. package/src/ports/ai.ts +109 -0
  43. package/src/ports/cache.ts +16 -0
  44. package/src/ports/config.ts +21 -0
  45. package/src/ports/crypto.ts +33 -0
  46. package/src/ports/index.ts +15 -0
  47. package/src/ports/logger.ts +23 -0
  48. package/src/ports/security.ts +42 -0
  49. package/src/security/credential-vault.ts +117 -0
  50. package/src/security/file-vault-storage.ts +25 -0
  51. package/src/security/index.ts +8 -0
  52. package/src/security/migrate-plaintext-key.ts +38 -0
  53. package/src/security/password-gate.ts +62 -0
  54. package/src/security/web-storage-vault-storage.ts +23 -0
  55. package/src/security/web.ts +9 -0
package/src/index.ts ADDED
@@ -0,0 +1,24 @@
1
+ // @gitpagedocs/tools — shared business-logic core.
2
+ //
3
+ // The single home for domain logic consumed by the frontend, the CLI and the
4
+ // MCP server. Consumers import from here (or subpath modules) and depend on the
5
+ // port interfaces, never on concrete adapters they don't need.
6
+
7
+ // Port contracts (type-only).
8
+ export type * from "./ports";
9
+
10
+ // Phase 4 — cross-cutting core implementations.
11
+ export * from "./errors";
12
+ export * from "./logger";
13
+ export * from "./crypto";
14
+ export * from "./cache";
15
+ export * from "./security";
16
+ export * from "./config";
17
+ export * from "./constants";
18
+
19
+ // Phase 5 — shared AI system.
20
+ export * from "./ai";
21
+
22
+ // Phase 7 — filesystem + documentation services.
23
+ export * from "./filesystem";
24
+ export * from "./documentation";
@@ -0,0 +1,2 @@
1
+ export * from "./logger";
2
+ export { redact, redactFields, REDACTED } from "./redaction";
@@ -0,0 +1,73 @@
1
+ import type { Logger, LogFields, LogLevel, LogSink } from "../ports/logger";
2
+ import { redact, redactFields } from "./redaction";
3
+
4
+ const LEVEL_ORDER: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40 };
5
+
6
+ export interface LoggerOptions {
7
+ readonly level?: LogLevel;
8
+ readonly sink?: LogSink;
9
+ readonly baseFields?: LogFields;
10
+ }
11
+
12
+ /** Sink that writes redacted records to the console. */
13
+ export class ConsoleSink implements LogSink {
14
+ write(level: LogLevel, message: string, fields: LogFields): void {
15
+ const safeMessage = redact(message) as string;
16
+ const hasFields = Object.keys(fields).length > 0;
17
+ const payload = hasFields ? redactFields(fields as Record<string, unknown>) : undefined;
18
+ const line = `[${level}] ${safeMessage}`;
19
+ if (level === "error") console.error(line, payload ?? "");
20
+ else if (level === "warn") console.warn(line, payload ?? "");
21
+ else console.log(line, payload ?? "");
22
+ }
23
+ }
24
+
25
+ /** Discards all records (useful for tests / silent CLI runs). */
26
+ export class NullSink implements LogSink {
27
+ write(): void {
28
+ /* no-op */
29
+ }
30
+ }
31
+
32
+ class StandardLogger implements Logger {
33
+ private readonly level: LogLevel;
34
+ private readonly sink: LogSink;
35
+ private readonly baseFields: LogFields;
36
+
37
+ constructor(options: LoggerOptions) {
38
+ this.level = options.level ?? "info";
39
+ this.sink = options.sink ?? new ConsoleSink();
40
+ this.baseFields = options.baseFields ?? {};
41
+ }
42
+
43
+ private emit(level: LogLevel, message: string, fields?: LogFields): void {
44
+ if (LEVEL_ORDER[level] < LEVEL_ORDER[this.level]) return;
45
+ const merged = fields ? { ...this.baseFields, ...fields } : this.baseFields;
46
+ this.sink.write(level, message, merged);
47
+ }
48
+
49
+ debug(message: string, fields?: LogFields): void {
50
+ this.emit("debug", message, fields);
51
+ }
52
+ info(message: string, fields?: LogFields): void {
53
+ this.emit("info", message, fields);
54
+ }
55
+ warn(message: string, fields?: LogFields): void {
56
+ this.emit("warn", message, fields);
57
+ }
58
+ error(message: string, fields?: LogFields): void {
59
+ this.emit("error", message, fields);
60
+ }
61
+
62
+ child(fields: LogFields): Logger {
63
+ return new StandardLogger({
64
+ level: this.level,
65
+ sink: this.sink,
66
+ baseFields: { ...this.baseFields, ...fields },
67
+ });
68
+ }
69
+ }
70
+
71
+ export function createLogger(options: LoggerOptions = {}): Logger {
72
+ return new StandardLogger(options);
73
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Secret redaction. Applied to every log record before it reaches a sink so
3
+ * API keys and other credentials can never appear in logs.
4
+ */
5
+ const SENSITIVE_KEY_PATTERN = /(api[_-]?key|secret|token|password|passwd|authorization|bearer|x-api-key)/i;
6
+
7
+ /** Token-like values: long opaque strings, optionally with a provider prefix. */
8
+ const TOKEN_VALUE_PATTERN = /\b((?:sk|pk|gsk|xai|or|hf|ghp|gho|github_pat|AIza)[-_][A-Za-z0-9-_]{8,}|[A-Za-z0-9-_]{32,})\b/g;
9
+
10
+ export const REDACTED = "[REDACTED]";
11
+
12
+ /** Mask a string value, keeping a short, non-reversible hint. */
13
+ function maskValue(value: string): string {
14
+ if (value.length <= 8) return REDACTED;
15
+ return `${value.slice(0, 3)}…${value.slice(-4)}`;
16
+ }
17
+
18
+ function redactString(input: string): string {
19
+ return input.replace(TOKEN_VALUE_PATTERN, (match) => maskValue(match));
20
+ }
21
+
22
+ /** Recursively redact a value: sensitive keys are masked, strings scrubbed. */
23
+ export function redact(value: unknown, keyHint?: string): unknown {
24
+ if (typeof value === "string") {
25
+ if (keyHint && SENSITIVE_KEY_PATTERN.test(keyHint)) return maskValue(value);
26
+ return redactString(value);
27
+ }
28
+ if (Array.isArray(value)) {
29
+ return value.map((item) => redact(item));
30
+ }
31
+ if (value && typeof value === "object") {
32
+ const out: Record<string, unknown> = {};
33
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
34
+ out[k] = SENSITIVE_KEY_PATTERN.test(k) && typeof v === "string" ? maskValue(v) : redact(v, k);
35
+ }
36
+ return out;
37
+ }
38
+ return value;
39
+ }
40
+
41
+ export function redactFields(fields: Record<string, unknown>): Record<string, unknown> {
42
+ return redact(fields) as Record<string, unknown>;
43
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * AI ports (Strategy + Registry + Factory). Implemented in Phase 5 in
3
+ * tools/src/ai, unifying the current browser (src/features/ask-ai) and CLI
4
+ * (src/cli/infrastructure/llm) provider implementations behind one contract.
5
+ */
6
+ export type AiProviderId =
7
+ | "openai"
8
+ | "anthropic"
9
+ | "gemini"
10
+ | "openrouter"
11
+ | "ollama"
12
+ | "azure-openai"
13
+ | "mistral"
14
+ | "deepseek"
15
+ | "cohere"
16
+ | "groq"
17
+ | "xai"
18
+ | "together"
19
+ | "fireworks"
20
+ | "perplexity";
21
+
22
+ export type AiRole = "system" | "user" | "assistant";
23
+
24
+ export interface AiAttachment {
25
+ readonly kind: "image" | "audio";
26
+ readonly mimeType: string;
27
+ /** Base64-encoded data. */
28
+ readonly data: string;
29
+ }
30
+
31
+ export interface AiMessage {
32
+ readonly role: AiRole;
33
+ readonly content: string;
34
+ readonly attachments?: readonly AiAttachment[];
35
+ }
36
+
37
+ export interface ModelDescriptor {
38
+ readonly id: string;
39
+ readonly label?: string;
40
+ readonly contextWindow?: number;
41
+ readonly supportsStreaming?: boolean;
42
+ readonly supportsVision?: boolean;
43
+ readonly supportsAudio?: boolean;
44
+ }
45
+
46
+ export interface ProviderConfig {
47
+ readonly providerId: AiProviderId;
48
+ readonly model: string;
49
+ readonly apiKey?: string;
50
+ /** For self-hosted/proxy providers (e.g. Ollama, Azure). */
51
+ readonly baseUrl?: string;
52
+ readonly extraHeaders?: Readonly<Record<string, string>>;
53
+ }
54
+
55
+ export interface GenerateRequest {
56
+ readonly messages: readonly AiMessage[];
57
+ readonly system?: string;
58
+ readonly temperature?: number;
59
+ readonly maxTokens?: number;
60
+ readonly signal?: AbortSignal;
61
+ }
62
+
63
+ export interface GenerateResponse {
64
+ readonly text: string;
65
+ readonly model: string;
66
+ readonly finishReason?: string;
67
+ }
68
+
69
+ /** Streaming responses surface as an async iterable of text deltas. */
70
+ export type StreamResponse = AsyncIterable<string>;
71
+
72
+ export interface ProviderCapabilities {
73
+ readonly streaming: boolean;
74
+ readonly vision: boolean;
75
+ readonly audio: boolean;
76
+ }
77
+
78
+ /** A single provider strategy. */
79
+ export interface AIProvider {
80
+ readonly id: AiProviderId;
81
+ readonly capabilities: ProviderCapabilities;
82
+ generate(request: GenerateRequest, config: ProviderConfig): Promise<GenerateResponse>;
83
+ stream(request: GenerateRequest, config: ProviderConfig): StreamResponse;
84
+ }
85
+
86
+ /** Provider metadata + factory registration (Registry pattern). */
87
+ export interface ProviderRegistration {
88
+ readonly id: AiProviderId;
89
+ readonly label: string;
90
+ readonly defaultModel: string;
91
+ readonly capabilities: ProviderCapabilities;
92
+ create(): AIProvider;
93
+ }
94
+
95
+ export interface ProviderRegistry {
96
+ register(registration: ProviderRegistration): void;
97
+ has(id: AiProviderId): boolean;
98
+ get(id: AiProviderId): ProviderRegistration | undefined;
99
+ list(): readonly ProviderRegistration[];
100
+ }
101
+
102
+ export interface ProviderFactory {
103
+ create(id: AiProviderId): AIProvider;
104
+ }
105
+
106
+ export interface ModelRegistry {
107
+ list(providerId: AiProviderId): readonly ModelDescriptor[];
108
+ defaultModel(providerId: AiProviderId): string | undefined;
109
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Cache port (Strategy). Implemented in Phase 4 by MemoryCache, FileCache,
3
+ * LocalStorageCache and SessionStorageCache.
4
+ */
5
+ export interface CacheEntryOptions {
6
+ /** Time-to-live in milliseconds. Omitted = no expiry. */
7
+ readonly ttlMs?: number;
8
+ }
9
+
10
+ export interface Cache<TValue = unknown> {
11
+ get(key: string): Promise<TValue | undefined>;
12
+ set(key: string, value: TValue, options?: CacheEntryOptions): Promise<void>;
13
+ has(key: string): Promise<boolean>;
14
+ delete(key: string): Promise<void>;
15
+ clear(): Promise<void>;
16
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Config-loading port. Implemented in Phase 4 by relocating the shared loader
3
+ * logic currently in src/entities/docs/api/io/config-loader.ts. Must preserve
4
+ * the legacy CONFIG_EXTENSIONS = [".json", ".js", ".ts"] contract.
5
+ */
6
+ export type ConfigExtension = ".json" | ".js" | ".ts";
7
+
8
+ export interface LoadedConfig<TConfig = Record<string, unknown>> {
9
+ readonly config: TConfig;
10
+ /** Absolute path the config was resolved from. */
11
+ readonly sourcePath: string;
12
+ readonly extension: ConfigExtension;
13
+ }
14
+
15
+ export interface ConfigLoader {
16
+ /** Resolve gitpagedocs/config.{json,js,ts} relative to a working directory. */
17
+ resolveConfigPath(cwd: string): Promise<string | undefined>;
18
+ loadGitPageDocsConfig<TConfig = Record<string, unknown>>(
19
+ cwd: string,
20
+ ): Promise<LoadedConfig<TConfig>>;
21
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Crypto port. Implemented in Phase 4/5 with Node `crypto` and Web Crypto
3
+ * adapters. Powers the password-gated, encrypted credential vault.
4
+ */
5
+ export interface KeyDerivationParams {
6
+ /** Base64/hex salt. Generated per vault, persisted alongside the ciphertext. */
7
+ readonly salt: string;
8
+ /** Iteration count (PBKDF2) or cost parameter (scrypt). */
9
+ readonly iterations: number;
10
+ }
11
+
12
+ export interface EncryptedPayload {
13
+ /** Base64 ciphertext. */
14
+ readonly ciphertext: string;
15
+ /** Base64 initialization vector / nonce. */
16
+ readonly iv: string;
17
+ /** Authentication tag for AEAD ciphers (base64). */
18
+ readonly authTag?: string;
19
+ readonly kdf: KeyDerivationParams;
20
+ }
21
+
22
+ export interface CryptoService {
23
+ /** Hex-encoded SHA-256 digest of the input. */
24
+ sha256(input: string): Promise<string>;
25
+ /** Derive a symmetric key from a password + KDF params. */
26
+ deriveKey(password: string, params: KeyDerivationParams): Promise<Uint8Array>;
27
+ encrypt(plaintext: string, password: string): Promise<EncryptedPayload>;
28
+ decrypt(payload: EncryptedPayload, password: string): Promise<string>;
29
+ /** Non-reversible display mask, e.g. "sk-…a1b2" — never reveals the secret. */
30
+ mask(secret: string): string;
31
+ /** Best-effort secure wipe of an in-memory buffer. */
32
+ wipe(buffer: Uint8Array): void;
33
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Public port interfaces for @gitpagedocs/tools.
3
+ *
4
+ * These are the stable contracts that the frontend, CLI and MCP server consume.
5
+ * Implementations are added in Phase 4 (core) and Phase 5 (AI + security)
6
+ * behind these types, so consumers never depend on concrete modules.
7
+ *
8
+ * Type-only: importing this file pulls in no runtime code.
9
+ */
10
+ export type * from "./logger";
11
+ export type * from "./cache";
12
+ export type * from "./crypto";
13
+ export type * from "./security";
14
+ export type * from "./config";
15
+ export type * from "./ai";
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Logging port. Implemented in Phase 4 (tools/src/logger).
3
+ * The implementation MUST redact secrets (API keys) before any sink write.
4
+ */
5
+ export type LogLevel = "debug" | "info" | "warn" | "error";
6
+
7
+ export interface LogFields {
8
+ readonly [key: string]: unknown;
9
+ }
10
+
11
+ export interface Logger {
12
+ debug(message: string, fields?: LogFields): void;
13
+ info(message: string, fields?: LogFields): void;
14
+ warn(message: string, fields?: LogFields): void;
15
+ error(message: string, fields?: LogFields): void;
16
+ /** Returns a child logger that merges the given fields into every record. */
17
+ child(fields: LogFields): Logger;
18
+ }
19
+
20
+ /** A destination for log records. */
21
+ export interface LogSink {
22
+ write(level: LogLevel, message: string, fields: LogFields): void;
23
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Security ports: password gate + credential vault. Implemented in Phase 5.
3
+ * Sensitive operations (generate/update docs, analyze, run AI, deploy, run MCP)
4
+ * must pass through the gate before executing.
5
+ */
6
+ import type { EncryptedPayload } from "./crypto";
7
+
8
+ export type SensitiveOperation =
9
+ | "generate-docs"
10
+ | "update-docs"
11
+ | "analyze-project"
12
+ | "run-ai"
13
+ | "deploy"
14
+ | "run-mcp";
15
+
16
+ export interface StoredCredential {
17
+ readonly providerId: string;
18
+ /** Encrypted secret; plaintext is never persisted. */
19
+ readonly secret: EncryptedPayload;
20
+ readonly updatedAtIso?: string;
21
+ }
22
+
23
+ export interface CredentialVault {
24
+ isInitialized(): Promise<boolean>;
25
+ /** Establish the local password (first run) and create an empty vault. */
26
+ initialize(password: string): Promise<void>;
27
+ unlock(password: string): Promise<boolean>;
28
+ setCredential(password: string, providerId: string, secret: string): Promise<void>;
29
+ /** Returns the decrypted secret only when the password is correct. */
30
+ getCredential(password: string, providerId: string): Promise<string | undefined>;
31
+ listProviders(): Promise<readonly string[]>;
32
+ removeCredential(password: string, providerId: string): Promise<void>;
33
+ }
34
+
35
+ export interface PasswordGate {
36
+ /**
37
+ * Authorize a sensitive operation. Implementations prompt for / verify the
38
+ * local password (one unlock-per-session policy) and throw SecurityError on
39
+ * failure. Returns the unlock token/password handle for downstream vault use.
40
+ */
41
+ authorize(operation: SensitiveOperation): Promise<string>;
42
+ }
@@ -0,0 +1,117 @@
1
+ import type { CredentialVault } from "../ports/security";
2
+ import type { CryptoService, EncryptedPayload } from "../ports/crypto";
3
+ import { SecurityError } from "../errors/app-error";
4
+
5
+ /** Pluggable persistence for the encrypted vault (file, web storage, …). */
6
+ export interface VaultStorage {
7
+ load(): Promise<string | null>;
8
+ save(serialized: string): Promise<void>;
9
+ /** Permanently remove the stored vault (used by a password reset). */
10
+ clear(): Promise<void>;
11
+ }
12
+
13
+ interface VaultFile {
14
+ readonly version: 1;
15
+ /** Encrypts a fixed sentinel so a password can be verified before use. */
16
+ readonly verifier: EncryptedPayload;
17
+ readonly credentials: Record<string, EncryptedPayload>;
18
+ }
19
+
20
+ const SENTINEL = "gitpagedocs::vault::v1";
21
+
22
+ /**
23
+ * Password-encrypted credential store. Plaintext secrets are never persisted;
24
+ * every secret is sealed with AES-256-GCM under a PBKDF2-derived key.
25
+ */
26
+ export class EncryptedCredentialVault implements CredentialVault {
27
+ private file?: VaultFile;
28
+
29
+ constructor(
30
+ private readonly storage: VaultStorage,
31
+ private readonly crypto: CryptoService,
32
+ ) {}
33
+
34
+ private async read(): Promise<VaultFile | undefined> {
35
+ if (this.file) return this.file;
36
+ const raw = await this.storage.load();
37
+ if (!raw) return undefined;
38
+ this.file = JSON.parse(raw) as VaultFile;
39
+ return this.file;
40
+ }
41
+
42
+ private async write(file: VaultFile): Promise<void> {
43
+ this.file = file;
44
+ await this.storage.save(JSON.stringify(file));
45
+ }
46
+
47
+ async isInitialized(): Promise<boolean> {
48
+ return (await this.read()) !== undefined;
49
+ }
50
+
51
+ async initialize(password: string): Promise<void> {
52
+ if (await this.isInitialized()) {
53
+ throw new SecurityError("Vault is already initialized.");
54
+ }
55
+ const verifier = await this.crypto.encrypt(SENTINEL, password);
56
+ await this.write({ version: 1, verifier, credentials: {} });
57
+ }
58
+
59
+ async unlock(password: string): Promise<boolean> {
60
+ const file = await this.read();
61
+ if (!file) throw new SecurityError("Vault is not initialized.");
62
+ try {
63
+ const value = await this.crypto.decrypt(file.verifier, password);
64
+ return value === SENTINEL;
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ private async requireUnlocked(password: string): Promise<VaultFile> {
71
+ const file = await this.read();
72
+ if (!file) throw new SecurityError("Vault is not initialized.");
73
+ if (!(await this.unlock(password))) {
74
+ throw new SecurityError("Incorrect vault password.");
75
+ }
76
+ return file;
77
+ }
78
+
79
+ async setCredential(password: string, providerId: string, secret: string): Promise<void> {
80
+ const file = await this.requireUnlocked(password);
81
+ const sealed = await this.crypto.encrypt(secret, password);
82
+ await this.write({
83
+ ...file,
84
+ credentials: { ...file.credentials, [providerId]: sealed },
85
+ });
86
+ }
87
+
88
+ async getCredential(password: string, providerId: string): Promise<string | undefined> {
89
+ const file = await this.requireUnlocked(password);
90
+ const sealed = file.credentials[providerId];
91
+ if (!sealed) return undefined;
92
+ return this.crypto.decrypt(sealed, password);
93
+ }
94
+
95
+ async listProviders(): Promise<readonly string[]> {
96
+ const file = await this.read();
97
+ return file ? Object.keys(file.credentials) : [];
98
+ }
99
+
100
+ async removeCredential(password: string, providerId: string): Promise<void> {
101
+ const file = await this.requireUnlocked(password);
102
+ const next = { ...file.credentials };
103
+ delete next[providerId];
104
+ await this.write({ ...file, credentials: next });
105
+ }
106
+
107
+ /**
108
+ * Wipe the whole vault — every stored credential and the password verifier.
109
+ * Used by a "forgot password" reset: the user loses all saved keys and then
110
+ * creates a fresh password. No password is required (the point is recovery
111
+ * when the password is lost).
112
+ */
113
+ async reset(): Promise<void> {
114
+ this.file = undefined;
115
+ await this.storage.clear();
116
+ }
117
+ }
@@ -0,0 +1,25 @@
1
+ import { readFile, writeFile, mkdir, rm } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import path from "node:path";
4
+ import type { VaultStorage } from "./credential-vault";
5
+
6
+ /** Node file-backed vault storage (CLI + MCP). */
7
+ export class FileVaultStorage implements VaultStorage {
8
+ constructor(private readonly filePath: string) {}
9
+
10
+ async load(): Promise<string | null> {
11
+ if (!existsSync(this.filePath)) return null;
12
+ const raw = await readFile(this.filePath, "utf8");
13
+ return raw.trim() ? raw : null;
14
+ }
15
+
16
+ async save(serialized: string): Promise<void> {
17
+ const dir = path.dirname(this.filePath);
18
+ if (!existsSync(dir)) await mkdir(dir, { recursive: true });
19
+ await writeFile(this.filePath, serialized, { encoding: "utf8", mode: 0o600 });
20
+ }
21
+
22
+ async clear(): Promise<void> {
23
+ if (existsSync(this.filePath)) await rm(this.filePath, { force: true });
24
+ }
25
+ }
@@ -0,0 +1,8 @@
1
+ export { EncryptedCredentialVault } from "./credential-vault";
2
+ export type { VaultStorage } from "./credential-vault";
3
+ export { SessionPasswordGate } from "./password-gate";
4
+ export type { PasswordPrompt, SessionPasswordGateOptions } from "./password-gate";
5
+ export { FileVaultStorage } from "./file-vault-storage";
6
+ export { WebStorageVaultStorage } from "./web-storage-vault-storage";
7
+ export { migratePlaintextKey } from "./migrate-plaintext-key";
8
+ export type { PlaintextMigrationInput, PlaintextMigrationResult } from "./migrate-plaintext-key";
@@ -0,0 +1,38 @@
1
+ import type { CredentialVault } from "../ports/security";
2
+
3
+ export interface PlaintextMigrationInput {
4
+ readonly vault: CredentialVault;
5
+ readonly password: string;
6
+ /** Catalog provider id the key belongs to (e.g. "anthropic"). */
7
+ readonly providerId: string;
8
+ /** The legacy plaintext secret to import. */
9
+ readonly plaintextKey: string;
10
+ /** Clears the legacy plaintext store once the key is sealed. */
11
+ readonly clearPlaintext: () => void | Promise<void>;
12
+ }
13
+
14
+ export interface PlaintextMigrationResult {
15
+ readonly migrated: boolean;
16
+ readonly initializedVault: boolean;
17
+ }
18
+
19
+ /**
20
+ * Move a legacy plaintext API key into the encrypted vault, then wipe the
21
+ * plaintext source. Initializes the vault on first run. Idempotent: a blank key
22
+ * is a no-op.
23
+ */
24
+ export async function migratePlaintextKey(
25
+ input: PlaintextMigrationInput,
26
+ ): Promise<PlaintextMigrationResult> {
27
+ if (!input.plaintextKey) {
28
+ return { migrated: false, initializedVault: false };
29
+ }
30
+ let initializedVault = false;
31
+ if (!(await input.vault.isInitialized())) {
32
+ await input.vault.initialize(input.password);
33
+ initializedVault = true;
34
+ }
35
+ await input.vault.setCredential(input.password, input.providerId, input.plaintextKey);
36
+ await input.clearPlaintext();
37
+ return { migrated: true, initializedVault };
38
+ }
@@ -0,0 +1,62 @@
1
+ import type { CredentialVault, PasswordGate, SensitiveOperation } from "../ports/security";
2
+ import { SecurityError } from "../errors/app-error";
3
+
4
+ /** Prompts the host environment for a password (CLI prompt, browser modal, …). */
5
+ export type PasswordPrompt = (context: {
6
+ operation: SensitiveOperation;
7
+ firstRun: boolean;
8
+ attempt: number;
9
+ }) => Promise<string>;
10
+
11
+ export interface SessionPasswordGateOptions {
12
+ readonly vault: CredentialVault;
13
+ readonly prompt: PasswordPrompt;
14
+ /** Max password attempts before authorize() throws. Default 3. */
15
+ readonly maxAttempts?: number;
16
+ /** Cache the verified password for the process lifetime. Default true. */
17
+ readonly cacheForSession?: boolean;
18
+ }
19
+
20
+ /**
21
+ * Authorizes sensitive operations behind the local password. On first run it
22
+ * initializes the vault; subsequent calls verify (one unlock-per-session by
23
+ * default). Returns the verified password so callers can read the vault.
24
+ */
25
+ export class SessionPasswordGate implements PasswordGate {
26
+ private cached?: string;
27
+
28
+ constructor(private readonly options: SessionPasswordGateOptions) {}
29
+
30
+ async authorize(operation: SensitiveOperation): Promise<string> {
31
+ if (this.options.cacheForSession !== false && this.cached) {
32
+ return this.cached;
33
+ }
34
+
35
+ const firstRun = !(await this.options.vault.isInitialized());
36
+ const maxAttempts = this.options.maxAttempts ?? 3;
37
+
38
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
39
+ const password = await this.options.prompt({ operation, firstRun, attempt });
40
+
41
+ if (firstRun) {
42
+ await this.options.vault.initialize(password);
43
+ return this.remember(password);
44
+ }
45
+ if (await this.options.vault.unlock(password)) {
46
+ return this.remember(password);
47
+ }
48
+ }
49
+
50
+ throw new SecurityError(`Authorization failed for "${operation}" after ${maxAttempts} attempts.`);
51
+ }
52
+
53
+ private remember(password: string): string {
54
+ if (this.options.cacheForSession !== false) this.cached = password;
55
+ return password;
56
+ }
57
+
58
+ /** Clears any cached password (e.g. on logout). */
59
+ reset(): void {
60
+ this.cached = undefined;
61
+ }
62
+ }