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,132 @@
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 { refreshCredentials } from "axauth";
8
+ import { getCredential, upsertCredential, } from "../db/repositories/credentials.js";
9
+ import { encryptCredential } from "../lib/encryption.js";
10
+ import { extractExpiryDate, toAxauthCredentials } from "./check-refresh.js";
11
+ import { getErrorMessage, logRefreshError, logRefreshSuccess, } from "./log-refresh.js";
12
+ /** Map of in-progress refreshes to prevent concurrent operations */
13
+ const pendingRefreshes = new Map();
14
+ /** Build credential key for mutex */
15
+ function buildKey(agent, name) {
16
+ return `${agent}/${name}`;
17
+ }
18
+ /**
19
+ * Execute the full refresh operation: call axauth, validate, persist.
20
+ * This is the inner operation that gets stored in the mutex.
21
+ */
22
+ async function executeRefresh(database, agent, name, data, apiKeyId, originalUpdatedAt, refreshStartedAt, options) {
23
+ const logContext = { database, apiKeyId, agent, name };
24
+ // Map to axauth format
25
+ const creds = toAxauthCredentials(agent, data);
26
+ if (!creds) {
27
+ return { ok: false, error: `Unknown agent: ${agent}` };
28
+ }
29
+ try {
30
+ const result = await refreshCredentials(creds, {
31
+ timeout: options.timeoutMs,
32
+ });
33
+ if (!result.ok) {
34
+ logRefreshError(logContext, result.error);
35
+ return { ok: false, error: result.error };
36
+ }
37
+ // Check if credential still exists and wasn't modified during refresh
38
+ const existingCredential = getCredential(database, agent, name);
39
+ if (!existingCredential) {
40
+ logRefreshError(logContext, "Credential was deleted during refresh");
41
+ return { ok: false, error: "Credential was deleted during refresh" };
42
+ }
43
+ // Optimistic locking: abort if credential was modified during refresh
44
+ if (existingCredential.updatedAt.getTime() !== originalUpdatedAt.getTime()) {
45
+ logRefreshError(logContext, "Credential was modified during refresh");
46
+ return { ok: false, error: "Credential was modified during refresh" };
47
+ }
48
+ // Persist newly refreshed credentials to database
49
+ // Merge original data with refreshed data to preserve fields like refresh_token
50
+ const newData = { ...data, ...result.credentials.data };
51
+ const encrypted = encryptCredential(newData);
52
+ const expiresAt = extractExpiryDate(newData);
53
+ try {
54
+ upsertCredential(database, {
55
+ agent,
56
+ name,
57
+ ...encrypted,
58
+ expiresAt,
59
+ });
60
+ logRefreshSuccess(logContext);
61
+ return {
62
+ ok: true,
63
+ data: newData,
64
+ expiresAt,
65
+ updatedAt: refreshStartedAt,
66
+ };
67
+ }
68
+ catch (error) {
69
+ const message = getErrorMessage(error);
70
+ logRefreshError(logContext, `Persistence failed: ${message}`);
71
+ return {
72
+ ok: false,
73
+ error: `Failed to persist refreshed credentials: ${message}`,
74
+ };
75
+ }
76
+ }
77
+ catch (error) {
78
+ // Handle unexpected errors (e.g., refreshCredentials promise rejection)
79
+ const message = getErrorMessage(error);
80
+ logRefreshError(logContext, `Refresh error: ${message}`);
81
+ return { ok: false, error: `Refresh failed: ${message}` };
82
+ }
83
+ }
84
+ /**
85
+ * Perform refresh with mutex locking.
86
+ *
87
+ * If a refresh is already in progress for this credential, waits for it
88
+ * rather than starting a new one. The mutex covers the FULL operation
89
+ * (refresh + validate + persist), ensuring all callers see the same result.
90
+ *
91
+ * Note: There is a small race window where a request that loaded stale
92
+ * credential data could start a duplicate refresh if it checks the mutex
93
+ * right after the previous refresh completes. This is rare (requires precise
94
+ * timing), harmless (produces correct results), and self-limiting (OAuth
95
+ * providers handle duplicate refreshes). The complexity of cooldown timers
96
+ * or reference counting isn't justified for this edge case.
97
+ *
98
+ * @param database - Database connection
99
+ * @param agent - Agent name
100
+ * @param name - Credential name
101
+ * @param data - Current decrypted credential data
102
+ * @param apiKeyId - API key ID for audit logging
103
+ * @param originalUpdatedAt - Original updatedAt for optimistic locking
104
+ * @param options - Refresh options
105
+ * @returns Refresh result with new data, expiresAt, updatedAt or error
106
+ */
107
+ async function refreshWithMutex(database, agent, name, data, apiKeyId, originalUpdatedAt, options) {
108
+ const key = buildKey(agent, name);
109
+ // Check if refresh already in progress
110
+ const existing = pendingRefreshes.get(key);
111
+ if (existing) {
112
+ // Wait for existing FULL operation to complete
113
+ // All callers observe the same final success/failure result
114
+ return existing.promise;
115
+ }
116
+ // Start new refresh - store promise for the FULL operation
117
+ const refreshStartedAt = new Date();
118
+ const fullOperationPromise = executeRefresh(database, agent, name, data, apiKeyId, originalUpdatedAt, refreshStartedAt, options);
119
+ pendingRefreshes.set(key, {
120
+ promise: fullOperationPromise,
121
+ startedAt: refreshStartedAt,
122
+ });
123
+ try {
124
+ return await fullOperationPromise;
125
+ }
126
+ finally {
127
+ // Always clean up mutex
128
+ pendingRefreshes.delete(key);
129
+ }
130
+ }
131
+ export { extractExpiryDate, isRefreshable, needsRefresh, toAxauthCredentials, } from "./check-refresh.js";
132
+ export { buildKey, refreshWithMutex };
@@ -0,0 +1,12 @@
1
+ /**
2
+ * API route definitions using Express Router.
3
+ */
4
+ import type Database from "better-sqlite3";
5
+ import { Router } from "express";
6
+ import { type GetCredentialConfig } from "../handlers/get-credential.js";
7
+ /** Create health check router (no auth required) */
8
+ export declare function createHealthRouter(): Router;
9
+ /** Create credential API router (auth required) */
10
+ export declare function createCredentialRouter(database: Database.Database, config: GetCredentialConfig): Router;
11
+ /** Create all API routers (legacy compatibility) */
12
+ export declare function createApiRouter(): Router;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * API route definitions using Express Router.
3
+ */
4
+ import { Router } from "express";
5
+ import packageJson from "../../package.json" with { type: "json" };
6
+ import { createDeleteCredentialHandler } from "../handlers/delete-credential.js";
7
+ import { createGetCredentialHandler, } from "../handlers/get-credential.js";
8
+ import { createListCredentialsHandler } from "../handlers/list-credentials.js";
9
+ import { createPutCredentialHandler } from "../handlers/put-credential.js";
10
+ import { createAuthMiddleware } from "../middleware/auth.js";
11
+ import { validateParameters } from "../middleware/validate-parameters.js";
12
+ /** Create health check router (no auth required) */
13
+ export function createHealthRouter() {
14
+ const router = Router();
15
+ router.get("/api/v1/health", (_request, response) => {
16
+ response.json({
17
+ status: "ok",
18
+ version: packageJson.version,
19
+ });
20
+ });
21
+ return router;
22
+ }
23
+ /** Create credential API router (auth required) */
24
+ export function createCredentialRouter(database, config) {
25
+ const router = Router();
26
+ const authMiddleware = createAuthMiddleware(database);
27
+ // All credential routes require authentication
28
+ router.use("/api/v1/credentials", authMiddleware);
29
+ // List all accessible credentials
30
+ router.get("/api/v1/credentials", createListCredentialsHandler(database));
31
+ // Get a specific credential (with automatic refresh)
32
+ router.get("/api/v1/credentials/:agent/:name", validateParameters, createGetCredentialHandler(database, config));
33
+ // Store/update a credential
34
+ router.put("/api/v1/credentials/:agent/:name", validateParameters, createPutCredentialHandler(database));
35
+ // Delete a credential
36
+ router.delete("/api/v1/credentials/:agent/:name", validateParameters, createDeleteCredentialHandler(database));
37
+ return router;
38
+ }
39
+ /** Create all API routers (legacy compatibility) */
40
+ export function createApiRouter() {
41
+ // For backward compatibility, returns just health router
42
+ // Use createHealthRouter() and createCredentialRouter(db, config) directly for full API
43
+ return createHealthRouter();
44
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Express HTTP server for axvault API.
3
+ *
4
+ * Designed for both CLI and library usage via factory pattern.
5
+ */
6
+ import { type Express, type Router } from "express";
7
+ import type { ServerConfig } from "../config.js";
8
+ /** Server instance with lifecycle methods */
9
+ export interface AxvaultServer {
10
+ /** The Express application instance */
11
+ readonly app: Express;
12
+ /** Start listening on the configured host:port */
13
+ start(): Promise<void>;
14
+ /** Stop the server gracefully */
15
+ stop(): Promise<void>;
16
+ }
17
+ /** Create an axvault server instance */
18
+ export declare function createServer(config: ServerConfig, routers: Router[]): AxvaultServer;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Express HTTP server for axvault API.
3
+ *
4
+ * Designed for both CLI and library usage via factory pattern.
5
+ */
6
+ import express, {} from "express";
7
+ /** Create and configure Express application */
8
+ function createApp(routers) {
9
+ const app = express();
10
+ // Security: disable X-Powered-By header
11
+ app.disable("x-powered-by");
12
+ // JSON body parsing middleware with size limit
13
+ app.use(express.json({ limit: "1mb" }));
14
+ // Register all routers
15
+ for (const router of routers) {
16
+ app.use(router);
17
+ }
18
+ // 404 handler for unmatched routes
19
+ app.use((_request, response) => {
20
+ response.status(404).json({ error: "Not found" });
21
+ });
22
+ // Error handling middleware - must be last and have 4 parameters
23
+ app.use((error, _request, response,
24
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Express requires 4 params
25
+ _next) => {
26
+ // Don't double-respond if headers already sent
27
+ if (response.headersSent) {
28
+ return;
29
+ }
30
+ // Handle JSON parse errors
31
+ if (error instanceof SyntaxError && "body" in error) {
32
+ response.status(400).json({ error: "Invalid JSON" });
33
+ return;
34
+ }
35
+ // Extract status from middleware errors (e.g., body-parser 413)
36
+ // Guard against null/primitive errors
37
+ const isErrorObject = error !== null && typeof error === "object";
38
+ const errorWithStatus = error;
39
+ const status = isErrorObject
40
+ ? (errorWithStatus.status ?? errorWithStatus.statusCode ?? 500)
41
+ : 500;
42
+ // Log server errors
43
+ if (status >= 500) {
44
+ console.error("Server error:", error);
45
+ }
46
+ // Use generic message for 5xx, actual message for 4xx
47
+ const message = status >= 500
48
+ ? "Internal server error"
49
+ : ((isErrorObject ? errorWithStatus.message : undefined) ??
50
+ "Request error");
51
+ response.status(status).json({ error: message });
52
+ });
53
+ return app;
54
+ }
55
+ /** Create an axvault server instance */
56
+ export function createServer(config, routers) {
57
+ const app = createApp(routers);
58
+ let server;
59
+ let stopPromise;
60
+ return {
61
+ app,
62
+ start() {
63
+ // Reset state for restart support
64
+ stopPromise = undefined;
65
+ return new Promise((resolve, reject) => {
66
+ // Startup error handler - removed after successful listen
67
+ const onStartupError = (error) => {
68
+ reject(error);
69
+ };
70
+ server = app.listen(config.port, config.host, () => {
71
+ server?.removeListener("error", onStartupError);
72
+ // Add persistent error handler only after startup succeeds
73
+ server?.on("error", (error) => {
74
+ console.error("Server error:", error);
75
+ });
76
+ console.error(`axvault listening on http://${config.host}:${config.port}`);
77
+ resolve();
78
+ });
79
+ server.once("error", onStartupError);
80
+ });
81
+ },
82
+ stop() {
83
+ // Return memoized promise for idempotency
84
+ if (stopPromise) {
85
+ return stopPromise;
86
+ }
87
+ if (!server) {
88
+ return Promise.resolve();
89
+ }
90
+ // Capture reference before async operation
91
+ const serverToClose = server;
92
+ server = undefined;
93
+ stopPromise = new Promise((resolve, reject) => {
94
+ serverToClose.close((error) => {
95
+ if (error) {
96
+ reject(error);
97
+ }
98
+ else {
99
+ resolve();
100
+ }
101
+ });
102
+ });
103
+ return stopPromise;
104
+ },
105
+ };
106
+ }
package/package.json ADDED
@@ -0,0 +1,93 @@
1
+ {
2
+ "name": "axvault",
3
+ "author": "Łukasz Jerciński",
4
+ "license": "MIT",
5
+ "version": "1.0.0",
6
+ "description": "Remote credential storage server for axpoint",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Jercik/axvault.git"
10
+ },
11
+ "homepage": "https://github.com/Jercik/axvault#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/Jercik/axvault/issues"
14
+ },
15
+ "type": "module",
16
+ "main": "./dist/index.js",
17
+ "types": "./dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.js"
22
+ }
23
+ },
24
+ "bin": {
25
+ "axvault": "bin/axvault"
26
+ },
27
+ "files": [
28
+ "bin/",
29
+ "dist/",
30
+ "README.md",
31
+ "LICENSE"
32
+ ],
33
+ "scripts": {
34
+ "prepare": "git config core.hooksPath .githooks",
35
+ "prepublishOnly": "pnpm run rebuild",
36
+ "build": "tsc -p tsconfig.app.json",
37
+ "clean": "rm -rf dist *.tsbuildinfo",
38
+ "format": "prettier --write .",
39
+ "format:check": "prettier --check .",
40
+ "fta": "fta-check",
41
+ "knip": "knip",
42
+ "lint": "eslint",
43
+ "rebuild": "pnpm run clean && pnpm run build",
44
+ "start": "pnpm -s run rebuild && node bin/axvault",
45
+ "test": "vitest run",
46
+ "test:coverage": "vitest run --coverage",
47
+ "test:watch": "vitest",
48
+ "typecheck": "tsc -b --noEmit"
49
+ },
50
+ "dependencies": {
51
+ "@commander-js/extra-typings": "^14.0.0",
52
+ "axauth": "^1.7.2",
53
+ "axshared": "^1.8.0",
54
+ "better-sqlite3": "^12.5.0",
55
+ "commander": "^14.0.2",
56
+ "express": "^5.2.1"
57
+ },
58
+ "keywords": [
59
+ "ai",
60
+ "agent",
61
+ "cli",
62
+ "auth",
63
+ "authentication",
64
+ "credentials",
65
+ "claude-code",
66
+ "codex",
67
+ "gemini",
68
+ "opencode",
69
+ "llm",
70
+ "automation",
71
+ "coding-assistant"
72
+ ],
73
+ "packageManager": "pnpm@10.27.0",
74
+ "engines": {
75
+ "node": ">=22.14.0"
76
+ },
77
+ "devDependencies": {
78
+ "@total-typescript/ts-reset": "^0.6.1",
79
+ "@types/better-sqlite3": "^7.6.13",
80
+ "@types/express": "^5.0.6",
81
+ "@types/node": "^25.0.3",
82
+ "@vitest/coverage-v8": "^4.0.16",
83
+ "eslint": "^9.39.2",
84
+ "eslint-config-axpoint": "^1.0.0",
85
+ "fta-check": "^1.5.1",
86
+ "fta-cli": "^3.0.0",
87
+ "knip": "^5.80.0",
88
+ "prettier": "3.7.4",
89
+ "semantic-release": "^25.0.2",
90
+ "typescript": "^5.9.3",
91
+ "vitest": "^4.0.16"
92
+ }
93
+ }