axvault 1.0.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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +213 -0
  3. package/bin/axvault +2 -0
  4. package/dist/cli.d.ts +7 -0
  5. package/dist/cli.js +108 -0
  6. package/dist/commands/credential.d.ts +13 -0
  7. package/dist/commands/credential.js +113 -0
  8. package/dist/commands/init.d.ts +8 -0
  9. package/dist/commands/init.js +50 -0
  10. package/dist/commands/key.d.ts +21 -0
  11. package/dist/commands/key.js +157 -0
  12. package/dist/commands/serve.d.ts +12 -0
  13. package/dist/commands/serve.js +93 -0
  14. package/dist/config.d.ts +22 -0
  15. package/dist/config.js +44 -0
  16. package/dist/db/client.d.ts +13 -0
  17. package/dist/db/client.js +38 -0
  18. package/dist/db/migrations.d.ts +12 -0
  19. package/dist/db/migrations.js +96 -0
  20. package/dist/db/repositories/api-keys.d.ts +42 -0
  21. package/dist/db/repositories/api-keys.js +86 -0
  22. package/dist/db/repositories/audit-log.d.ts +37 -0
  23. package/dist/db/repositories/audit-log.js +58 -0
  24. package/dist/db/repositories/credentials.d.ts +48 -0
  25. package/dist/db/repositories/credentials.js +79 -0
  26. package/dist/db/types.d.ts +44 -0
  27. package/dist/db/types.js +4 -0
  28. package/dist/handlers/delete-credential.d.ts +15 -0
  29. package/dist/handlers/delete-credential.js +50 -0
  30. package/dist/handlers/get-credential.d.ts +21 -0
  31. package/dist/handlers/get-credential.js +143 -0
  32. package/dist/handlers/list-credentials.d.ts +12 -0
  33. package/dist/handlers/list-credentials.js +29 -0
  34. package/dist/handlers/put-credential.d.ts +15 -0
  35. package/dist/handlers/put-credential.js +94 -0
  36. package/dist/index.d.ts +18 -0
  37. package/dist/index.js +14 -0
  38. package/dist/lib/encryption.d.ts +17 -0
  39. package/dist/lib/encryption.js +38 -0
  40. package/dist/lib/format.d.ts +92 -0
  41. package/dist/lib/format.js +216 -0
  42. package/dist/middleware/auth.d.ts +21 -0
  43. package/dist/middleware/auth.js +50 -0
  44. package/dist/middleware/validate-parameters.d.ts +10 -0
  45. package/dist/middleware/validate-parameters.js +26 -0
  46. package/dist/refresh/check-refresh.d.ts +40 -0
  47. package/dist/refresh/check-refresh.js +83 -0
  48. package/dist/refresh/log-refresh.d.ts +17 -0
  49. package/dist/refresh/log-refresh.js +35 -0
  50. package/dist/refresh/refresh-manager.d.ts +51 -0
  51. package/dist/refresh/refresh-manager.js +132 -0
  52. package/dist/server/routes.d.ts +12 -0
  53. package/dist/server/routes.js +44 -0
  54. package/dist/server/server.d.ts +18 -0
  55. package/dist/server/server.js +106 -0
  56. package/package.json +93 -0
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Encryption utilities for credential storage.
3
+ *
4
+ * Wraps axshared encryption functions with vault-specific configuration.
5
+ */
6
+ import { decrypt, encrypt } from "axshared";
7
+ /** Validate encryption key is configured */
8
+ function getEncryptionKey() {
9
+ const key = process.env["AXVAULT_ENCRYPTION_KEY"];
10
+ if (!key || key.length < 32) {
11
+ throw new Error("AXVAULT_ENCRYPTION_KEY must be set to at least 32 characters");
12
+ }
13
+ return key;
14
+ }
15
+ /** Encrypt credential data for storage */
16
+ function encryptCredential(data) {
17
+ const encryptionKey = getEncryptionKey();
18
+ const plaintext = JSON.stringify(data);
19
+ const encrypted = encrypt(plaintext, encryptionKey);
20
+ return {
21
+ encryptedData: encrypted.ciphertext,
22
+ salt: encrypted.salt,
23
+ iv: encrypted.iv,
24
+ authTag: encrypted.tag,
25
+ };
26
+ }
27
+ /** Decrypt credential data from storage */
28
+ function decryptCredential(credential) {
29
+ const encryptionKey = getEncryptionKey();
30
+ const decrypted = decrypt({
31
+ ciphertext: credential.encryptedData,
32
+ salt: credential.salt,
33
+ iv: credential.iv,
34
+ tag: credential.authTag,
35
+ }, encryptionKey);
36
+ return JSON.parse(decrypted);
37
+ }
38
+ export { decryptCredential, encryptCredential };
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Shared formatting utilities for CLI output.
3
+ */
4
+ /**
5
+ * Strip control characters and ANSI escape sequences to prevent terminal
6
+ * injection and preserve TSV structure.
7
+ *
8
+ * Removes:
9
+ * - 7-bit ANSI/CSI escape sequences (ESC [ ... final-byte)
10
+ * - 8-bit C1 CSI sequences (0x9B ... final-byte)
11
+ * - C0 controls (0x00-0x1F) including ESC, tab, newline
12
+ * - DEL (0x7F)
13
+ * - C1 controls (0x80-0x9F)
14
+ */
15
+ export declare function sanitizeForTsv(value: string): string;
16
+ /**
17
+ * Format relative time from a Date.
18
+ * Returns human-readable strings like "just now", "5m ago", "2h ago".
19
+ *
20
+ * This helper is intended for past events (e.g., "last updated"), so future
21
+ * dates are treated as unexpected and return the generic string "in the future"
22
+ * instead of using granular units. For precise future-oriented formatting
23
+ * (e.g., "in 2h", "in 3d"), use {@link formatExpiresAt}.
24
+ */
25
+ export declare function formatRelativeTime(date: Date | undefined): string;
26
+ /**
27
+ * Check if a string contains control characters (would be changed by sanitization).
28
+ */
29
+ export declare function containsControlChars(value: string): boolean;
30
+ /**
31
+ * Validate API key ID format (k_ + 12 hex chars).
32
+ */
33
+ export declare function isValidKeyId(id: string): boolean;
34
+ /**
35
+ * Format a single API key row for TSV output.
36
+ */
37
+ export declare function formatKeyRow(key: {
38
+ id: string;
39
+ name: string;
40
+ readAccess: string[];
41
+ writeAccess: string[];
42
+ lastUsedAt?: Date;
43
+ }): string;
44
+ /**
45
+ * Parse comma-separated access list into array of entries.
46
+ *
47
+ * Returns object with:
48
+ * - `entries`: parsed entries if valid, undefined if invalid
49
+ * - `error`: error type if invalid ('control-chars' or 'invalid-format')
50
+ *
51
+ * Control characters are checked BEFORE trimming to prevent silent bypass
52
+ * (e.g., "\n*" would otherwise become "*" after trim).
53
+ */
54
+ export declare function parseAccessList(value: string | undefined): {
55
+ entries: string[];
56
+ error?: never;
57
+ } | {
58
+ entries?: never;
59
+ error: "control-chars" | "invalid-format";
60
+ };
61
+ /**
62
+ * Get error message for access list parsing error.
63
+ */
64
+ export declare function getAccessListErrorMessage(error: "control-chars" | "invalid-format"): string;
65
+ /**
66
+ * Normalize access list by collapsing to ['*'] if wildcard is present with other entries.
67
+ * Returns the normalized list and an optional warning message.
68
+ */
69
+ export declare function normalizeAccessList(access: string[], optionName?: string): {
70
+ normalized: string[];
71
+ warning: string | undefined;
72
+ };
73
+ /**
74
+ * Format access list for display.
75
+ * Returns "(none)" for empty, "*" if wildcard present, otherwise comma-joined.
76
+ */
77
+ export declare function formatAccessList(access: string[]): string;
78
+ /**
79
+ * Format optional date for JSON output.
80
+ * Returns ISO string or null for consistent JSON schema.
81
+ */
82
+ export declare function formatDateForJson(date: Date | undefined): string | null;
83
+ /**
84
+ * Extract error message from unknown error value.
85
+ * Returns the error's message if it's an Error, otherwise stringifies it.
86
+ */
87
+ export declare function getErrorMessage(error: unknown): string;
88
+ /**
89
+ * Format expiration time relative to now.
90
+ * Returns "(never)" for no expiration, or relative time like "in 2h", "expired 5m ago".
91
+ */
92
+ export declare function formatExpiresAt(date: Date | undefined): string;
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Shared formatting utilities for CLI output.
3
+ */
4
+ /**
5
+ * Strip control characters and ANSI escape sequences to prevent terminal
6
+ * injection and preserve TSV structure.
7
+ *
8
+ * Removes:
9
+ * - 7-bit ANSI/CSI escape sequences (ESC [ ... final-byte)
10
+ * - 8-bit C1 CSI sequences (0x9B ... final-byte)
11
+ * - C0 controls (0x00-0x1F) including ESC, tab, newline
12
+ * - DEL (0x7F)
13
+ * - C1 controls (0x80-0x9F)
14
+ */
15
+ export function sanitizeForTsv(value) {
16
+ /* eslint-disable no-control-regex */
17
+ return (value
18
+ // Strip 7-bit ANSI CSI sequences: ESC [ <params> <intermediate> <final>
19
+ // Parameters: 0x30-0x3F, Intermediate: 0x20-0x2F, Final: 0x40-0x7E
20
+ .replaceAll(/\u001B\[[0-?]*[ -/]*[@-~]/gu, "")
21
+ // Strip 8-bit C1 CSI sequences: 0x9B <params> <intermediate> <final>
22
+ .replaceAll(/\u009B[0-?]*[ -/]*[@-~]/gu, "")
23
+ // Strip C0 controls (0x00-0x1F), DEL (0x7F), and C1 controls (0x80-0x9F)
24
+ .replaceAll(/[\u0000-\u001F\u007F-\u009F]/gu, ""));
25
+ /* eslint-enable no-control-regex */
26
+ }
27
+ /**
28
+ * Format relative time from a Date.
29
+ * Returns human-readable strings like "just now", "5m ago", "2h ago".
30
+ *
31
+ * This helper is intended for past events (e.g., "last updated"), so future
32
+ * dates are treated as unexpected and return the generic string "in the future"
33
+ * instead of using granular units. For precise future-oriented formatting
34
+ * (e.g., "in 2h", "in 3d"), use {@link formatExpiresAt}.
35
+ */
36
+ export function formatRelativeTime(date) {
37
+ if (!date)
38
+ return "never";
39
+ const now = Date.now();
40
+ const diff = now - date.getTime();
41
+ if (diff < 0)
42
+ return "in the future";
43
+ const seconds = Math.floor(diff / 1000);
44
+ if (seconds < 60)
45
+ return "just now";
46
+ const minutes = Math.floor(seconds / 60);
47
+ if (minutes < 60)
48
+ return `${minutes}m ago`;
49
+ const hours = Math.floor(minutes / 60);
50
+ if (hours < 24)
51
+ return `${hours}h ago`;
52
+ const days = Math.floor(hours / 24);
53
+ return `${days}d ago`;
54
+ }
55
+ /**
56
+ * Check if a string contains control characters (would be changed by sanitization).
57
+ */
58
+ export function containsControlChars(value) {
59
+ return sanitizeForTsv(value) !== value;
60
+ }
61
+ /** API key ID format: k_ followed by 12 hex characters */
62
+ const KEY_ID_PATTERN = /^k_[0-9a-f]{12}$/u;
63
+ /**
64
+ * Validate API key ID format (k_ + 12 hex chars).
65
+ */
66
+ export function isValidKeyId(id) {
67
+ return KEY_ID_PATTERN.test(id);
68
+ }
69
+ /**
70
+ * Format a single API key row for TSV output.
71
+ */
72
+ export function formatKeyRow(key) {
73
+ const id = sanitizeForTsv(key.id);
74
+ const name = sanitizeForTsv(key.name);
75
+ const readAccess = sanitizeForTsv(formatAccessList(key.readAccess));
76
+ const writeAccess = sanitizeForTsv(formatAccessList(key.writeAccess));
77
+ const lastUsed = formatRelativeTime(key.lastUsedAt);
78
+ return `${id}\t${name}\t${readAccess}\t${writeAccess}\t${lastUsed}`;
79
+ }
80
+ /**
81
+ * Validate access list entry format.
82
+ * Valid formats:
83
+ * - "*" (full wildcard granting access to all credentials)
84
+ * - "agent/name" (exactly one slash, both parts non-empty, no wildcards)
85
+ *
86
+ * Partial wildcards like "claude/*" are rejected because the authorization
87
+ * logic uses exact matching, not pattern matching.
88
+ */
89
+ function isValidAccessEntry(entry) {
90
+ if (entry === "*")
91
+ return true;
92
+ const parts = entry.split("/");
93
+ const agent = parts[0];
94
+ const name = parts[1];
95
+ return (parts.length === 2 &&
96
+ agent !== undefined &&
97
+ agent.length > 0 &&
98
+ !agent.includes("*") &&
99
+ name !== undefined &&
100
+ name.length > 0 &&
101
+ !name.includes("*"));
102
+ }
103
+ /**
104
+ * Parse comma-separated access list into array of entries.
105
+ *
106
+ * Returns object with:
107
+ * - `entries`: parsed entries if valid, undefined if invalid
108
+ * - `error`: error type if invalid ('control-chars' or 'invalid-format')
109
+ *
110
+ * Control characters are checked BEFORE trimming to prevent silent bypass
111
+ * (e.g., "\n*" would otherwise become "*" after trim).
112
+ */
113
+ export function parseAccessList(value) {
114
+ if (!value)
115
+ return { entries: [] };
116
+ const rawEntries = value.split(",");
117
+ // Check for control characters in raw entries before trimming
118
+ if (rawEntries.some((entry) => containsControlChars(entry))) {
119
+ return { error: "control-chars" };
120
+ }
121
+ const entries = rawEntries
122
+ .map((item) => item.trim())
123
+ .filter((item) => item.length > 0);
124
+ // Validate entry format: must be "*" or "agent/name"
125
+ if (!entries.every((entry) => isValidAccessEntry(entry))) {
126
+ return { error: "invalid-format" };
127
+ }
128
+ return { entries };
129
+ }
130
+ /**
131
+ * Get error message for access list parsing error.
132
+ */
133
+ export function getAccessListErrorMessage(error) {
134
+ if (error === "control-chars") {
135
+ return "Access list contains control characters.";
136
+ }
137
+ return 'Invalid access list entry format. Entries must be "*" or "agent/name" (e.g., claude/work).';
138
+ }
139
+ /**
140
+ * Normalize access list by collapsing to ['*'] if wildcard is present with other entries.
141
+ * Returns the normalized list and an optional warning message.
142
+ */
143
+ export function normalizeAccessList(access, optionName) {
144
+ if (access.includes("*") && access.length > 1) {
145
+ const warning = optionName
146
+ ? `--${optionName} '*' grants full access; ignoring other entries`
147
+ : "'*' grants full access; ignoring other entries";
148
+ return { normalized: ["*"], warning };
149
+ }
150
+ return { normalized: access, warning: undefined };
151
+ }
152
+ /**
153
+ * Format access list for display.
154
+ * Returns "(none)" for empty, "*" if wildcard present, otherwise comma-joined.
155
+ */
156
+ export function formatAccessList(access) {
157
+ if (access.length === 0)
158
+ return "(none)";
159
+ if (access.includes("*"))
160
+ return "*";
161
+ return access.join(", ");
162
+ }
163
+ /**
164
+ * Format optional date for JSON output.
165
+ * Returns ISO string or null for consistent JSON schema.
166
+ */
167
+ export function formatDateForJson(date) {
168
+ // eslint-disable-next-line unicorn/no-null
169
+ return date?.toISOString() ?? null;
170
+ }
171
+ /**
172
+ * Extract error message from unknown error value.
173
+ * Returns the error's message if it's an Error, otherwise stringifies it.
174
+ */
175
+ export function getErrorMessage(error) {
176
+ return error instanceof Error ? error.message : String(error);
177
+ }
178
+ /**
179
+ * Format expiration time relative to now.
180
+ * Returns "(never)" for no expiration, or relative time like "in 2h", "expired 5m ago".
181
+ */
182
+ export function formatExpiresAt(date) {
183
+ if (!date)
184
+ return "(never)";
185
+ const now = Date.now();
186
+ const diff = date.getTime() - now;
187
+ // Already expired
188
+ if (diff < 0) {
189
+ const elapsed = Math.abs(diff);
190
+ const seconds = Math.floor(elapsed / 1000);
191
+ if (seconds < 60)
192
+ return "expired just now";
193
+ const minutes = Math.floor(seconds / 60);
194
+ if (minutes < 60)
195
+ return `expired ${minutes}m ago`;
196
+ const hours = Math.floor(minutes / 60);
197
+ if (hours < 24)
198
+ return `expired ${hours}h ago`;
199
+ const days = Math.floor(hours / 24);
200
+ return `expired ${days}d ago`;
201
+ }
202
+ // Future expiration
203
+ const seconds = Math.floor(diff / 1000);
204
+ if (seconds === 0)
205
+ return "expires soon";
206
+ if (seconds < 60)
207
+ return `in ${seconds}s`;
208
+ const minutes = Math.floor(seconds / 60);
209
+ if (minutes < 60)
210
+ return `in ${minutes}m`;
211
+ const hours = Math.floor(minutes / 60);
212
+ if (hours < 24)
213
+ return `in ${hours}h`;
214
+ const days = Math.floor(hours / 24);
215
+ return `in ${days}d`;
216
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * API key authentication middleware.
3
+ *
4
+ * Validates Bearer token and attaches API key record to request.
5
+ */
6
+ import type { NextFunction, Request, Response } from "express";
7
+ import type Database from "better-sqlite3";
8
+ import { type ApiKeyRecord } from "../db/repositories/api-keys.js";
9
+ /** Extended request with API key */
10
+ interface AuthenticatedRequest extends Request {
11
+ apiKey: ApiKeyRecord;
12
+ }
13
+ /**
14
+ * Create authentication middleware.
15
+ *
16
+ * Validates API key from Authorization header and attaches to request.
17
+ * Returns 401 for missing/invalid key.
18
+ */
19
+ declare function createAuthMiddleware(database: Database.Database): (request: Request, response: Response, next: NextFunction) => void;
20
+ export { createAuthMiddleware };
21
+ export type { AuthenticatedRequest };
@@ -0,0 +1,50 @@
1
+ /**
2
+ * API key authentication middleware.
3
+ *
4
+ * Validates Bearer token and attaches API key record to request.
5
+ */
6
+ import { findApiKeyByKey, updateLastUsed, } from "../db/repositories/api-keys.js";
7
+ import { logAccess } from "../db/repositories/audit-log.js";
8
+ /** Extract Bearer token from Authorization header */
9
+ function extractBearerToken(authorizationHeader) {
10
+ if (!authorizationHeader)
11
+ return undefined;
12
+ const match = /^Bearer\s+(\S+)$/iu.exec(authorizationHeader);
13
+ return match?.[1];
14
+ }
15
+ /**
16
+ * Create authentication middleware.
17
+ *
18
+ * Validates API key from Authorization header and attaches to request.
19
+ * Returns 401 for missing/invalid key.
20
+ */
21
+ function createAuthMiddleware(database) {
22
+ return (request, response, next) => {
23
+ const token = extractBearerToken(request.headers.authorization);
24
+ if (!token) {
25
+ logAccess(database, {
26
+ action: "auth",
27
+ success: false,
28
+ errorMessage: "Missing authorization header",
29
+ });
30
+ response.status(401).json({ error: "Missing authorization header" });
31
+ return;
32
+ }
33
+ const apiKey = findApiKeyByKey(database, token);
34
+ if (!apiKey) {
35
+ logAccess(database, {
36
+ action: "auth",
37
+ success: false,
38
+ errorMessage: "Invalid API key",
39
+ });
40
+ response.status(401).json({ error: "Invalid API key" });
41
+ return;
42
+ }
43
+ // Update last used timestamp
44
+ updateLastUsed(database, apiKey.id);
45
+ // Attach API key to request
46
+ request.apiKey = apiKey;
47
+ next();
48
+ };
49
+ }
50
+ export { createAuthMiddleware };
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Path parameter validation middleware.
3
+ *
4
+ * Validates agent and name parameters to prevent injection attacks
5
+ * and ensure consistent credential naming.
6
+ */
7
+ import type { NextFunction, Request, Response } from "express";
8
+ /** Validate agent and name path parameters */
9
+ declare function validateParameters(request: Request, response: Response, next: NextFunction): void;
10
+ export { validateParameters };
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Path parameter validation middleware.
3
+ *
4
+ * Validates agent and name parameters to prevent injection attacks
5
+ * and ensure consistent credential naming.
6
+ */
7
+ /** Valid pattern for agent and name path segments */
8
+ const VALID_PATH_SEGMENT = /^[\w.-]{1,64}$/u;
9
+ /** Validate agent and name path parameters */
10
+ function validateParameters(request, response, next) {
11
+ const { agent, name } = request.params;
12
+ if (agent !== undefined && !VALID_PATH_SEGMENT.test(agent)) {
13
+ response.status(400).json({
14
+ error: "Invalid agent format. Must be 1-64 characters: letters, numbers, dots, hyphens, underscores.",
15
+ });
16
+ return;
17
+ }
18
+ if (name !== undefined && !VALID_PATH_SEGMENT.test(name)) {
19
+ response.status(400).json({
20
+ error: "Invalid name format. Must be 1-64 characters: letters, numbers, dots, hyphens, underscores.",
21
+ });
22
+ return;
23
+ }
24
+ next();
25
+ }
26
+ export { validateParameters };
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Pure functions for checking if credentials need refresh.
3
+ *
4
+ * Extracted from refresh-manager to reduce complexity.
5
+ */
6
+ import { type Credentials } from "axauth";
7
+ /**
8
+ * Check if credential is refreshable (has refresh_token).
9
+ *
10
+ * axauth's refreshCredentials requires a refresh_token to work.
11
+ * API keys and tokens without refresh_token cannot be refreshed.
12
+ * Returns false for non-object data to avoid TypeError from `in` operator.
13
+ */
14
+ declare function isRefreshable(data: unknown): data is Record<string, unknown>;
15
+ /**
16
+ * Check if credential needs refresh based on expiry.
17
+ *
18
+ * Only returns true if:
19
+ * 1. Credential data is an object with refresh_token (required for axauth refresh)
20
+ * 2. Credential has expiry info that is within threshold
21
+ *
22
+ * @param data - Decrypted credential data (accepts unknown for safety)
23
+ * @param thresholdSeconds - Refresh if expires within this many seconds
24
+ * @returns true if refresh needed, false otherwise
25
+ */
26
+ declare function needsRefresh(data: unknown, thresholdSeconds: number): boolean;
27
+ /**
28
+ * Map vault credential data to axauth Credentials type.
29
+ *
30
+ * @param agent - Agent CLI name
31
+ * @param data - Decrypted credential data from vault (must have refresh_token)
32
+ * @returns Credentials object for axauth, or undefined if not mappable
33
+ */
34
+ declare function toAxauthCredentials(agent: string, data: Record<string, unknown>): Credentials | undefined;
35
+ /**
36
+ * Extract expiry date from refreshed credentials.
37
+ * Looks for common expiry fields (expires_at, expiry_date).
38
+ */
39
+ declare function extractExpiryDate(data: Record<string, unknown>): Date | undefined;
40
+ export { extractExpiryDate, isRefreshable, needsRefresh, toAxauthCredentials };
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Pure functions for checking if credentials need refresh.
3
+ *
4
+ * Extracted from refresh-manager to reduce complexity.
5
+ */
6
+ import { AGENT_CLIS } from "axshared";
7
+ import { isCredentialExpired } from "axauth";
8
+ /** Valid agent CLI names - sourced from axshared for consistency */
9
+ const VALID_AGENTS = new Set(AGENT_CLIS);
10
+ /**
11
+ * Check if credential is refreshable (has refresh_token).
12
+ *
13
+ * axauth's refreshCredentials requires a refresh_token to work.
14
+ * API keys and tokens without refresh_token cannot be refreshed.
15
+ * Returns false for non-object data to avoid TypeError from `in` operator.
16
+ */
17
+ function isRefreshable(data) {
18
+ return (typeof data === "object" &&
19
+ data !== null &&
20
+ "refresh_token" in data &&
21
+ typeof data.refresh_token === "string");
22
+ }
23
+ /**
24
+ * Check if credential needs refresh based on expiry.
25
+ *
26
+ * Only returns true if:
27
+ * 1. Credential data is an object with refresh_token (required for axauth refresh)
28
+ * 2. Credential has expiry info that is within threshold
29
+ *
30
+ * @param data - Decrypted credential data (accepts unknown for safety)
31
+ * @param thresholdSeconds - Refresh if expires within this many seconds
32
+ * @returns true if refresh needed, false otherwise
33
+ */
34
+ function needsRefresh(data, thresholdSeconds) {
35
+ // Must be an object with refresh_token to be refreshable
36
+ if (!isRefreshable(data))
37
+ return false;
38
+ const expired = isCredentialExpired(data, thresholdSeconds);
39
+ // undefined means no expiry info found - don't refresh
40
+ return expired === true;
41
+ }
42
+ /**
43
+ * Map vault credential data to axauth Credentials type.
44
+ *
45
+ * @param agent - Agent CLI name
46
+ * @param data - Decrypted credential data from vault (must have refresh_token)
47
+ * @returns Credentials object for axauth, or undefined if not mappable
48
+ */
49
+ function toAxauthCredentials(agent, data) {
50
+ if (!VALID_AGENTS.has(agent)) {
51
+ return undefined;
52
+ }
53
+ // Only OAuth credentials can be refreshed
54
+ return {
55
+ agent: agent,
56
+ type: "oauth",
57
+ data,
58
+ };
59
+ }
60
+ /**
61
+ * Timestamp threshold to distinguish seconds from milliseconds.
62
+ * Values below this are assumed to be Unix timestamps in seconds.
63
+ */
64
+ const SECONDS_THRESHOLD = 10_000_000_000;
65
+ /**
66
+ * Extract expiry date from refreshed credentials.
67
+ * Looks for common expiry fields (expires_at, expiry_date).
68
+ */
69
+ function extractExpiryDate(data) {
70
+ // Google OAuth uses expiry_date (milliseconds)
71
+ if (typeof data.expiry_date === "number") {
72
+ return new Date(data.expiry_date);
73
+ }
74
+ // Generic OAuth uses expires_at (seconds or milliseconds)
75
+ if (typeof data.expires_at === "number") {
76
+ const ts = data.expires_at < SECONDS_THRESHOLD
77
+ ? data.expires_at * 1000
78
+ : data.expires_at;
79
+ return new Date(ts);
80
+ }
81
+ return undefined;
82
+ }
83
+ export { extractExpiryDate, isRefreshable, needsRefresh, toAxauthCredentials };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Logging helpers for refresh operations.
3
+ */
4
+ import type Database from "better-sqlite3";
5
+ interface RefreshLogContext {
6
+ database: Database.Database;
7
+ apiKeyId: string;
8
+ agent: string;
9
+ name: string;
10
+ }
11
+ /** Log a successful refresh */
12
+ declare function logRefreshSuccess(context: RefreshLogContext): void;
13
+ /** Log a failed refresh (best-effort, ignores logging errors) */
14
+ declare function logRefreshError(context: RefreshLogContext, errorMessage: string): void;
15
+ /** Extract error message from unknown error */
16
+ declare function getErrorMessage(error: unknown): string;
17
+ export { getErrorMessage, logRefreshError, logRefreshSuccess };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Logging helpers for refresh operations.
3
+ */
4
+ import { logAccess } from "../db/repositories/audit-log.js";
5
+ /** Log a successful refresh */
6
+ function logRefreshSuccess(context) {
7
+ logAccess(context.database, {
8
+ apiKeyId: context.apiKeyId,
9
+ action: "refresh",
10
+ agent: context.agent,
11
+ name: context.name,
12
+ success: true,
13
+ });
14
+ }
15
+ /** Log a failed refresh (best-effort, ignores logging errors) */
16
+ function logRefreshError(context, errorMessage) {
17
+ try {
18
+ logAccess(context.database, {
19
+ apiKeyId: context.apiKeyId,
20
+ action: "refresh",
21
+ agent: context.agent,
22
+ name: context.name,
23
+ success: false,
24
+ errorMessage,
25
+ });
26
+ }
27
+ catch {
28
+ // Ignore logging failures
29
+ }
30
+ }
31
+ /** Extract error message from unknown error */
32
+ function getErrorMessage(error) {
33
+ return error instanceof Error ? error.message : String(error);
34
+ }
35
+ export { getErrorMessage, logRefreshError, logRefreshSuccess };
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Refresh manager with per-credential mutex locking.
3
+ *
4
+ * Prevents concurrent refreshes of the same credential and coordinates
5
+ * with axauth's refresh functionality.
6
+ */
7
+ import type Database from "better-sqlite3";
8
+ /** Key for credential-specific mutex */
9
+ type CredentialKey = `${string}/${string}`;
10
+ /** Result type for the full refresh operation */
11
+ type FullRefreshResult = {
12
+ ok: true;
13
+ data: unknown;
14
+ expiresAt: Date | undefined;
15
+ updatedAt: Date;
16
+ } | {
17
+ ok: false;
18
+ error: string;
19
+ };
20
+ /** Options for refresh operation */
21
+ interface RefreshManagerOptions {
22
+ timeoutMs: number;
23
+ }
24
+ /** Build credential key for mutex */
25
+ declare function buildKey(agent: string, name: string): CredentialKey;
26
+ /**
27
+ * Perform refresh with mutex locking.
28
+ *
29
+ * If a refresh is already in progress for this credential, waits for it
30
+ * rather than starting a new one. The mutex covers the FULL operation
31
+ * (refresh + validate + persist), ensuring all callers see the same result.
32
+ *
33
+ * Note: There is a small race window where a request that loaded stale
34
+ * credential data could start a duplicate refresh if it checks the mutex
35
+ * right after the previous refresh completes. This is rare (requires precise
36
+ * timing), harmless (produces correct results), and self-limiting (OAuth
37
+ * providers handle duplicate refreshes). The complexity of cooldown timers
38
+ * or reference counting isn't justified for this edge case.
39
+ *
40
+ * @param database - Database connection
41
+ * @param agent - Agent name
42
+ * @param name - Credential name
43
+ * @param data - Current decrypted credential data
44
+ * @param apiKeyId - API key ID for audit logging
45
+ * @param originalUpdatedAt - Original updatedAt for optimistic locking
46
+ * @param options - Refresh options
47
+ * @returns Refresh result with new data, expiresAt, updatedAt or error
48
+ */
49
+ declare function refreshWithMutex(database: Database.Database, agent: string, name: string, data: Record<string, unknown>, apiKeyId: string, originalUpdatedAt: Date, options: RefreshManagerOptions): Promise<FullRefreshResult>;
50
+ export { extractExpiryDate, isRefreshable, needsRefresh, toAxauthCredentials, } from "./check-refresh.js";
51
+ export { buildKey, refreshWithMutex };