axvault 1.8.2 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -192,6 +192,8 @@ This command requires `--force` or `--yes` to confirm.
192
192
 
193
193
  ### Container Deployments
194
194
 
195
+ Container images are published automatically to `registry.j4k.dev/axvault` on every release (multi-arch: amd64 + arm64). Use `workflow_dispatch` on the `publish-image` workflow to rebuild manually.
196
+
195
197
  #### Running the Container
196
198
 
197
199
  The image uses an external UID pattern—no user is baked into the image. **Always specify a non-root user** with `-u`/`--user` to limit container privileges:
@@ -6,7 +6,7 @@ import path from "node:path";
6
6
  import { getServerConfig } from "../config.js";
7
7
  import { getDatabase, closeDatabase } from "../db/client.js";
8
8
  import { runMigrations } from "../db/migrations.js";
9
- import { createHealthRouter, createCredentialRouter, } from "../server/routes.js";
9
+ import { createHealthRouter, createCredentialRouter, createKeyRouter, } from "../server/routes.js";
10
10
  import { createServer } from "../server/server.js";
11
11
  export async function handleServe(options) {
12
12
  let config;
@@ -68,6 +68,7 @@ export async function handleServe(options) {
68
68
  refreshThresholdSeconds: config.refreshThresholdSeconds,
69
69
  refreshTimeoutMs: config.refreshTimeoutMs,
70
70
  }),
71
+ createKeyRouter(database),
71
72
  ]);
72
73
  // Graceful shutdown handler
73
74
  const shutdown = () => {
@@ -0,0 +1,10 @@
1
+ /**
2
+ * POST /api/v1/keys handler.
3
+ *
4
+ * Creates a new API key and returns the one-time secret.
5
+ */
6
+ import type { RequestHandler } from "express";
7
+ import type Database from "better-sqlite3";
8
+ /** Create a new API key */
9
+ declare function createCreateKeyHandler(database: Database.Database): RequestHandler;
10
+ export { createCreateKeyHandler };
@@ -0,0 +1,63 @@
1
+ /**
2
+ * POST /api/v1/keys handler.
3
+ *
4
+ * Creates a new API key and returns the one-time secret.
5
+ */
6
+ import { createApiKey } from "../db/repositories/api-keys.js";
7
+ import { serializeKeyForResponse } from "./serialize-key.js";
8
+ /** Validate that the request body has valid access lists */
9
+ function isValidAccessLists(body) {
10
+ const { readAccess, writeAccess, grantAccess } = body;
11
+ if (!Array.isArray(readAccess) ||
12
+ !readAccess.every((v) => typeof v === "string"))
13
+ return false;
14
+ if (!Array.isArray(writeAccess) ||
15
+ !writeAccess.every((v) => typeof v === "string"))
16
+ return false;
17
+ if (!Array.isArray(grantAccess) ||
18
+ !grantAccess.every((v) => typeof v === "string"))
19
+ return false;
20
+ // At least one access list must be non-empty
21
+ if (readAccess.length === 0 &&
22
+ writeAccess.length === 0 &&
23
+ grantAccess.length === 0)
24
+ return false;
25
+ return true;
26
+ }
27
+ /** Create a new API key */
28
+ function createCreateKeyHandler(database) {
29
+ return (request, response) => {
30
+ const body = request.body;
31
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
32
+ response
33
+ .status(400)
34
+ .json({ error: "Request body must be a JSON object" });
35
+ return;
36
+ }
37
+ const record = body;
38
+ const { name } = record;
39
+ if (typeof name !== "string" || name.trim().length === 0) {
40
+ response
41
+ .status(400)
42
+ .json({ error: "name is required and must be a non-empty string" });
43
+ return;
44
+ }
45
+ if (!isValidAccessLists(record)) {
46
+ response.status(400).json({
47
+ error: "readAccess, writeAccess, and grantAccess must be string arrays; at least one must be non-empty",
48
+ });
49
+ return;
50
+ }
51
+ const keyWithSecret = createApiKey(database, {
52
+ name: name.trim(),
53
+ readAccess: record.readAccess,
54
+ writeAccess: record.writeAccess,
55
+ grantAccess: record.grantAccess,
56
+ });
57
+ response.status(201).json({
58
+ ...serializeKeyForResponse(keyWithSecret),
59
+ key: keyWithSecret.key,
60
+ });
61
+ };
62
+ }
63
+ export { createCreateKeyHandler };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * DELETE /api/v1/keys/:id handler.
3
+ *
4
+ * Revokes an API key.
5
+ */
6
+ import type { RequestHandler } from "express";
7
+ import type Database from "better-sqlite3";
8
+ /** Handler type for key routes with id param */
9
+ type KeyHandler = RequestHandler<{
10
+ id: string;
11
+ }, unknown, unknown, unknown>;
12
+ /** Revoke an API key */
13
+ declare function createDeleteKeyHandler(database: Database.Database): KeyHandler;
14
+ export { createDeleteKeyHandler };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * DELETE /api/v1/keys/:id handler.
3
+ *
4
+ * Revokes an API key.
5
+ */
6
+ import { deleteApiKey } from "../db/repositories/api-keys.js";
7
+ /** Revoke an API key */
8
+ function createDeleteKeyHandler(database) {
9
+ return (request, response) => {
10
+ const { apiKey } = request;
11
+ const { id } = request.params;
12
+ // Prevent self-deletion
13
+ if (apiKey.id === id) {
14
+ response.status(403).json({ error: "Cannot revoke your own API key" });
15
+ return;
16
+ }
17
+ const deleted = deleteApiKey(database, id);
18
+ if (!deleted) {
19
+ response.status(404).json({ error: "API key not found" });
20
+ return;
21
+ }
22
+ response.status(204).end();
23
+ };
24
+ }
25
+ export { createDeleteKeyHandler };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * GET /api/v1/keys/:id handler.
3
+ *
4
+ * Returns a single API key by ID.
5
+ */
6
+ import type { RequestHandler } from "express";
7
+ import type Database from "better-sqlite3";
8
+ /** Handler type for key routes with id param */
9
+ type KeyHandler = RequestHandler<{
10
+ id: string;
11
+ }, unknown, unknown, unknown>;
12
+ /** Get a single API key by ID */
13
+ declare function createGetKeyHandler(database: Database.Database): KeyHandler;
14
+ export { createGetKeyHandler };
@@ -0,0 +1,20 @@
1
+ /**
2
+ * GET /api/v1/keys/:id handler.
3
+ *
4
+ * Returns a single API key by ID.
5
+ */
6
+ import { findApiKeyById } from "../db/repositories/api-keys.js";
7
+ import { serializeKeyForResponse } from "./serialize-key.js";
8
+ /** Get a single API key by ID */
9
+ function createGetKeyHandler(database) {
10
+ return (request, response) => {
11
+ const { id } = request.params;
12
+ const key = findApiKeyById(database, id);
13
+ if (!key) {
14
+ response.status(404).json({ error: "API key not found" });
15
+ return;
16
+ }
17
+ response.json(serializeKeyForResponse(key));
18
+ };
19
+ }
20
+ export { createGetKeyHandler };
@@ -0,0 +1,10 @@
1
+ /**
2
+ * GET /api/v1/keys handler.
3
+ *
4
+ * Lists all API keys (metadata only, no secrets).
5
+ */
6
+ import type { RequestHandler } from "express";
7
+ import type Database from "better-sqlite3";
8
+ /** List all API keys */
9
+ declare function createListKeysHandler(database: Database.Database): RequestHandler;
10
+ export { createListKeysHandler };
@@ -0,0 +1,15 @@
1
+ /**
2
+ * GET /api/v1/keys handler.
3
+ *
4
+ * Lists all API keys (metadata only, no secrets).
5
+ */
6
+ import { listApiKeys } from "../db/repositories/api-keys.js";
7
+ import { serializeKeyForResponse } from "./serialize-key.js";
8
+ /** List all API keys */
9
+ function createListKeysHandler(database) {
10
+ return (_request, response) => {
11
+ const keys = listApiKeys(database);
12
+ response.json({ keys: keys.map((key) => serializeKeyForResponse(key)) });
13
+ };
14
+ }
15
+ export { createListKeysHandler };
@@ -0,0 +1,20 @@
1
+ /**
2
+ * API key serialization for HTTP responses.
3
+ *
4
+ * Converts ApiKeyRecord to a safe JSON shape (omits keyHash, converts dates).
5
+ */
6
+ import type { ApiKeyRecord } from "../db/repositories/api-keys.js";
7
+ /** JSON-safe API key response shape */
8
+ interface SerializedKey {
9
+ id: string;
10
+ name: string;
11
+ keyPrefix: string | null;
12
+ readAccess: string[];
13
+ writeAccess: string[];
14
+ grantAccess: string[];
15
+ createdAt: string;
16
+ lastUsedAt: string | null;
17
+ }
18
+ /** Convert an ApiKeyRecord to the JSON response shape */
19
+ declare function serializeKeyForResponse(key: ApiKeyRecord): SerializedKey;
20
+ export { serializeKeyForResponse };
@@ -0,0 +1,21 @@
1
+ /**
2
+ * API key serialization for HTTP responses.
3
+ *
4
+ * Converts ApiKeyRecord to a safe JSON shape (omits keyHash, converts dates).
5
+ */
6
+ /** Convert an ApiKeyRecord to the JSON response shape */
7
+ function serializeKeyForResponse(key) {
8
+ return {
9
+ id: key.id,
10
+ name: key.name,
11
+ // eslint-disable-next-line unicorn/no-null -- JSON API requires null for missing values
12
+ keyPrefix: key.keyPrefix ?? null,
13
+ readAccess: key.readAccess,
14
+ writeAccess: key.writeAccess,
15
+ grantAccess: key.grantAccess,
16
+ createdAt: key.createdAt.toISOString(),
17
+ // eslint-disable-next-line unicorn/no-null -- JSON API requires null for missing values
18
+ lastUsedAt: key.lastUsedAt?.toISOString() ?? null,
19
+ };
20
+ }
21
+ export { serializeKeyForResponse };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * PATCH /api/v1/keys/:id handler.
3
+ *
4
+ * Updates an API key's access permissions.
5
+ */
6
+ import type { RequestHandler } from "express";
7
+ import type Database from "better-sqlite3";
8
+ /** Handler type for key routes with id param */
9
+ type KeyHandler = RequestHandler<{
10
+ id: string;
11
+ }, unknown, unknown, unknown>;
12
+ /** Update an API key's permissions */
13
+ declare function createUpdateKeyHandler(database: Database.Database): KeyHandler;
14
+ export { createUpdateKeyHandler };
@@ -0,0 +1,90 @@
1
+ /**
2
+ * PATCH /api/v1/keys/:id handler.
3
+ *
4
+ * Updates an API key's access permissions.
5
+ */
6
+ import { findApiKeyById, updateApiKeyAccess, } from "../db/repositories/api-keys.js";
7
+ import { serializeKeyForResponse } from "./serialize-key.js";
8
+ /** Check if value is a string array */
9
+ function isStringArray(value) {
10
+ return Array.isArray(value) && value.every((v) => typeof v === "string");
11
+ }
12
+ /** Update an API key's permissions */
13
+ function createUpdateKeyHandler(database) {
14
+ return (request, response) => {
15
+ const { apiKey } = request;
16
+ const { id } = request.params;
17
+ // Prevent self-modification
18
+ if (apiKey.id === id) {
19
+ response.status(403).json({ error: "Cannot modify your own API key" });
20
+ return;
21
+ }
22
+ const body = request.body;
23
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
24
+ response
25
+ .status(400)
26
+ .json({ error: "Request body must be a JSON object" });
27
+ return;
28
+ }
29
+ const record = body;
30
+ const { readAccess, writeAccess, grantAccess } = record;
31
+ // At least one field must be provided
32
+ if (readAccess === undefined &&
33
+ writeAccess === undefined &&
34
+ grantAccess === undefined) {
35
+ response.status(400).json({
36
+ error: "At least one of readAccess, writeAccess, or grantAccess is required",
37
+ });
38
+ return;
39
+ }
40
+ // Validate types of provided fields
41
+ if (readAccess !== undefined && !isStringArray(readAccess)) {
42
+ response
43
+ .status(400)
44
+ .json({ error: "readAccess must be an array of strings" });
45
+ return;
46
+ }
47
+ if (writeAccess !== undefined && !isStringArray(writeAccess)) {
48
+ response
49
+ .status(400)
50
+ .json({ error: "writeAccess must be an array of strings" });
51
+ return;
52
+ }
53
+ if (grantAccess !== undefined && !isStringArray(grantAccess)) {
54
+ response
55
+ .status(400)
56
+ .json({ error: "grantAccess must be an array of strings" });
57
+ return;
58
+ }
59
+ // Check target key exists before update
60
+ const existingKey = findApiKeyById(database, id);
61
+ if (!existingKey) {
62
+ response.status(404).json({ error: "API key not found" });
63
+ return;
64
+ }
65
+ // Compute resulting access lists to validate at least one is non-empty
66
+ const resultReadAccess = readAccess ?? existingKey.readAccess;
67
+ const resultWriteAccess = writeAccess ?? existingKey.writeAccess;
68
+ const resultGrantAccess = grantAccess ?? existingKey.grantAccess;
69
+ if (resultReadAccess.length === 0 &&
70
+ resultWriteAccess.length === 0 &&
71
+ resultGrantAccess.length === 0) {
72
+ response.status(400).json({
73
+ error: "Key must retain at least one non-empty access list",
74
+ });
75
+ return;
76
+ }
77
+ updateApiKeyAccess(database, id, {
78
+ readAccess,
79
+ writeAccess,
80
+ grantAccess,
81
+ });
82
+ const updatedKey = findApiKeyById(database, id);
83
+ if (!updatedKey) {
84
+ response.status(404).json({ error: "API key not found" });
85
+ return;
86
+ }
87
+ response.json(serializeKeyForResponse(updatedKey));
88
+ };
89
+ }
90
+ export { createUpdateKeyHandler };
package/dist/index.d.ts CHANGED
@@ -7,11 +7,11 @@ export type { ServerConfig } from "./config.js";
7
7
  export { getServerConfig } from "./config.js";
8
8
  export type { AxvaultServer } from "./server/server.js";
9
9
  export { createServer } from "./server/server.js";
10
- export { createApiRouter } from "./server/routes.js";
10
+ export { createApiRouter, createKeyRouter } from "./server/routes.js";
11
11
  export { closeDatabase, getDatabase, isDatabaseConnected, } from "./db/client.js";
12
12
  export { CURRENT_VERSION, getSchemaVersion, runMigrations, } from "./db/migrations.js";
13
13
  export type { ApiKeyRecord, ApiKeyWithSecret, } from "./db/repositories/api-keys.js";
14
- export { createApiKey, deleteApiKey, findApiKeyById, findApiKeyByKey, hasReadAccess, hasWriteAccess, listApiKeys, updateLastUsed, } from "./db/repositories/api-keys.js";
14
+ export { createApiKey, deleteApiKey, findApiKeyById, findApiKeyByKey, hasGrantAccess, hasReadAccess, hasWriteAccess, listApiKeys, updateApiKeyAccess, updateLastUsed, } from "./db/repositories/api-keys.js";
15
15
  export type { AuditLogEntry } from "./db/repositories/audit-log.js";
16
16
  export { getLogsForCredential, getRecentLogs, logAccess, pruneOldLogs, } from "./db/repositories/audit-log.js";
17
17
  export type { CredentialMetadata, CredentialRecord, } from "./db/repositories/credentials.js";
package/dist/index.js CHANGED
@@ -5,10 +5,10 @@
5
5
  */
6
6
  export { getServerConfig } from "./config.js";
7
7
  export { createServer } from "./server/server.js";
8
- export { createApiRouter } from "./server/routes.js";
8
+ export { createApiRouter, createKeyRouter } from "./server/routes.js";
9
9
  // Database client
10
10
  export { closeDatabase, getDatabase, isDatabaseConnected, } from "./db/client.js";
11
11
  export { CURRENT_VERSION, getSchemaVersion, runMigrations, } from "./db/migrations.js";
12
- export { createApiKey, deleteApiKey, findApiKeyById, findApiKeyByKey, hasReadAccess, hasWriteAccess, listApiKeys, updateLastUsed, } from "./db/repositories/api-keys.js";
12
+ export { createApiKey, deleteApiKey, findApiKeyById, findApiKeyByKey, hasGrantAccess, hasReadAccess, hasWriteAccess, listApiKeys, updateApiKeyAccess, updateLastUsed, } from "./db/repositories/api-keys.js";
13
13
  export { getLogsForCredential, getRecentLogs, logAccess, pruneOldLogs, } from "./db/repositories/audit-log.js";
14
14
  export { deleteCredential, getCredential, listCredentials, listCredentialsForApiKey, listCredentialsPaginated, upsertCredential, } from "./db/repositories/credentials.js";
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Grant access authorization middleware.
3
+ *
4
+ * Ensures the authenticated API key has grant access to all resources ("*").
5
+ */
6
+ import type { NextFunction, Request, Response } from "express";
7
+ /** Require the caller's API key to have full grant access ("*") */
8
+ declare function requireGrantAccess(request: Request, response: Response, next: NextFunction): void;
9
+ export { requireGrantAccess };
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Grant access authorization middleware.
3
+ *
4
+ * Ensures the authenticated API key has grant access to all resources ("*").
5
+ */
6
+ import { hasGrantAccess } from "../db/repositories/api-keys.js";
7
+ /** Require the caller's API key to have full grant access ("*") */
8
+ function requireGrantAccess(request, response, next) {
9
+ const { apiKey } = request;
10
+ if (!hasGrantAccess(apiKey, "*")) {
11
+ response.status(403).json({ error: "Grant access required" });
12
+ return;
13
+ }
14
+ next();
15
+ }
16
+ export { requireGrantAccess };
@@ -0,0 +1,9 @@
1
+ /**
2
+ * API key ID parameter validation middleware.
3
+ *
4
+ * Validates that the :id path parameter matches the expected key ID format.
5
+ */
6
+ import type { NextFunction, Request, Response } from "express";
7
+ /** Validate API key ID path parameter */
8
+ declare function validateKeyId(request: Request, response: Response, next: NextFunction): void;
9
+ export { validateKeyId };
@@ -0,0 +1,19 @@
1
+ /**
2
+ * API key ID parameter validation middleware.
3
+ *
4
+ * Validates that the :id path parameter matches the expected key ID format.
5
+ */
6
+ /** Key ID format: "k_" followed by exactly 12 hex characters */
7
+ const KEY_ID_PATTERN = /^k_[\da-f]{12}$/u;
8
+ /** Validate API key ID path parameter */
9
+ function validateKeyId(request, response, next) {
10
+ const { id } = request.params;
11
+ if (id !== undefined && !KEY_ID_PATTERN.test(id)) {
12
+ response.status(400).json({
13
+ error: "Invalid key ID format. Must be 'k_' followed by 12 hex characters.",
14
+ });
15
+ return;
16
+ }
17
+ next();
18
+ }
19
+ export { validateKeyId };
@@ -8,5 +8,7 @@ import { type GetCredentialConfig } from "../handlers/get-credential.js";
8
8
  export declare function createHealthRouter(): Router;
9
9
  /** Create credential API router (auth required) */
10
10
  export declare function createCredentialRouter(database: Database.Database, config: GetCredentialConfig): Router;
11
+ /** Create API key management router (auth + grant access required) */
12
+ export declare function createKeyRouter(database: Database.Database): Router;
11
13
  /** Create all API routers (legacy compatibility) */
12
14
  export declare function createApiRouter(): Router;
@@ -3,11 +3,18 @@
3
3
  */
4
4
  import { Router } from "express";
5
5
  import packageJson from "../../package.json" with { type: "json" };
6
+ import { createCreateKeyHandler } from "../handlers/create-key.js";
6
7
  import { createDeleteCredentialHandler } from "../handlers/delete-credential.js";
8
+ import { createDeleteKeyHandler } from "../handlers/delete-key.js";
7
9
  import { createGetCredentialHandler, } from "../handlers/get-credential.js";
10
+ import { createGetKeyHandler } from "../handlers/get-key.js";
8
11
  import { createListCredentialsHandler } from "../handlers/list-credentials.js";
12
+ import { createListKeysHandler } from "../handlers/list-keys.js";
9
13
  import { createPutCredentialHandler } from "../handlers/put-credential.js";
14
+ import { createUpdateKeyHandler } from "../handlers/update-key.js";
10
15
  import { createAuthMiddleware } from "../middleware/auth.js";
16
+ import { requireGrantAccess } from "../middleware/require-grant-access.js";
17
+ import { validateKeyId } from "../middleware/validate-key-id.js";
11
18
  import { validateParameters } from "../middleware/validate-parameters.js";
12
19
  /** Create health check router (no auth required) */
13
20
  export function createHealthRouter() {
@@ -36,6 +43,24 @@ export function createCredentialRouter(database, config) {
36
43
  router.delete("/api/v1/credentials/:name", validateParameters, createDeleteCredentialHandler(database));
37
44
  return router;
38
45
  }
46
+ /** Create API key management router (auth + grant access required) */
47
+ export function createKeyRouter(database) {
48
+ const router = Router();
49
+ const authMiddleware = createAuthMiddleware(database);
50
+ // All key routes require authentication and grant access
51
+ router.use("/api/v1/keys", authMiddleware, requireGrantAccess);
52
+ // List all keys
53
+ router.get("/api/v1/keys", createListKeysHandler(database));
54
+ // Create a new key
55
+ router.post("/api/v1/keys", createCreateKeyHandler(database));
56
+ // Get a single key
57
+ router.get("/api/v1/keys/:id", validateKeyId, createGetKeyHandler(database));
58
+ // Update key permissions
59
+ router.patch("/api/v1/keys/:id", validateKeyId, createUpdateKeyHandler(database));
60
+ // Revoke a key
61
+ router.delete("/api/v1/keys/:id", validateKeyId, createDeleteKeyHandler(database));
62
+ return router;
63
+ }
39
64
  /** Create all API routers (legacy compatibility) */
40
65
  export function createApiRouter() {
41
66
  // For backward compatibility, returns just health router
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "axvault",
3
3
  "author": "Łukasz Jerciński",
4
4
  "license": "MIT",
5
- "version": "1.8.2",
5
+ "version": "1.9.0",
6
6
  "description": "Remote credential storage server for axkit",
7
7
  "repository": {
8
8
  "type": "git",