axvault 1.8.2 → 1.9.1

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 (39) hide show
  1. package/README.md +2 -0
  2. package/dist/commands/credential.js +7 -4
  3. package/dist/commands/serve.js +2 -1
  4. package/dist/db/migrations.d.ts +1 -1
  5. package/dist/db/migrations.js +26 -1
  6. package/dist/db/repositories/credentials-queries.d.ts +5 -7
  7. package/dist/db/repositories/credentials-queries.js +7 -8
  8. package/dist/db/repositories/credentials.d.ts +2 -0
  9. package/dist/db/repositories/credentials.js +1 -1
  10. package/dist/db/repositories/list-credentials-paginated.js +1 -1
  11. package/dist/db/repositories/parse-credential-row.d.ts +4 -0
  12. package/dist/db/repositories/parse-credential-row.js +4 -0
  13. package/dist/db/types.d.ts +5 -1
  14. package/dist/handlers/create-key.d.ts +10 -0
  15. package/dist/handlers/create-key.js +63 -0
  16. package/dist/handlers/delete-key.d.ts +14 -0
  17. package/dist/handlers/delete-key.js +25 -0
  18. package/dist/handlers/get-credential.js +7 -1
  19. package/dist/handlers/get-key.d.ts +14 -0
  20. package/dist/handlers/get-key.js +20 -0
  21. package/dist/handlers/list-credentials.js +12 -10
  22. package/dist/handlers/list-keys.d.ts +10 -0
  23. package/dist/handlers/list-keys.js +15 -0
  24. package/dist/handlers/put-credential.js +11 -2
  25. package/dist/handlers/refresh-credential-on-read.d.ts +2 -0
  26. package/dist/handlers/refresh-credential-on-read.js +15 -4
  27. package/dist/handlers/serialize-key.d.ts +20 -0
  28. package/dist/handlers/serialize-key.js +21 -0
  29. package/dist/handlers/update-key.d.ts +14 -0
  30. package/dist/handlers/update-key.js +90 -0
  31. package/dist/index.d.ts +2 -2
  32. package/dist/index.js +2 -2
  33. package/dist/middleware/require-grant-access.d.ts +9 -0
  34. package/dist/middleware/require-grant-access.js +16 -0
  35. package/dist/middleware/validate-key-id.d.ts +9 -0
  36. package/dist/middleware/validate-key-id.js +19 -0
  37. package/dist/server/routes.d.ts +2 -0
  38. package/dist/server/routes.js +25 -0
  39. package/package.json +3 -3
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:
@@ -7,14 +7,16 @@ import { runMigrations } from "../db/migrations.js";
7
7
  import { deleteCredential, listCredentials, } from "../db/repositories/credentials.js";
8
8
  import { containsControlChars, formatRelativeTime, getErrorMessage, sanitizeForTsv, } from "../lib/format.js";
9
9
  import { CREDENTIAL_NAME_FORMAT_DESCRIPTION, isValidCredentialName, } from "../lib/credential-name.js";
10
- /** Print credentials as TSV table (opaque schema - no type/expires columns) */
10
+ /** Print credentials as TSV table */
11
11
  function printCredentialTable(credentials) {
12
- console.log("NAME\tCREATED\tUPDATED");
12
+ console.log("NAME\tAGENT\tPROVIDER\tCREATED\tUPDATED");
13
13
  for (const cred of credentials) {
14
14
  const name = sanitizeForTsv(cred.name);
15
+ const agent = cred.agent || "";
16
+ const provider = cred.provider ?? "";
15
17
  const created = formatRelativeTime(cred.createdAt);
16
18
  const updated = formatRelativeTime(cred.updatedAt);
17
- console.log(`${name}\t${created}\t${updated}`);
19
+ console.log(`${name}\t${agent}\t${provider}\t${created}\t${updated}`);
18
20
  }
19
21
  }
20
22
  /**
@@ -53,9 +55,10 @@ export function handleCredentialList(options) {
53
55
  runMigrations(database);
54
56
  const credentials = listCredentials(database);
55
57
  if (options.json) {
56
- // Opaque schema: type/provider/expiresAt not available without decrypting
57
58
  const output = credentials.map((cred) => ({
58
59
  name: cred.name,
60
+ ...(cred.agent !== "" && { agent: cred.agent }),
61
+ ...(cred.provider !== undefined && { provider: cred.provider }),
59
62
  createdAt: cred.createdAt.toISOString(),
60
63
  updatedAt: cred.updatedAt.toISOString(),
61
64
  }));
@@ -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 = () => {
@@ -5,7 +5,7 @@
5
5
  * Credentials are identified by name only - the blob contains all metadata.
6
6
  */
7
7
  import type Database from "better-sqlite3";
8
- declare const CURRENT_VERSION = 1;
8
+ declare const CURRENT_VERSION = 2;
9
9
  /** Run all pending migrations */
10
10
  declare function runMigrations(database: Database.Database): void;
11
11
  /** Get current schema version */
@@ -4,7 +4,7 @@
4
4
  * Simple schema with opaque credential storage.
5
5
  * Credentials are identified by name only - the blob contains all metadata.
6
6
  */
7
- const CURRENT_VERSION = 1;
7
+ const CURRENT_VERSION = 2;
8
8
  /** Run all pending migrations */
9
9
  function runMigrations(database) {
10
10
  let version = getSchemaVersion(database);
@@ -17,6 +17,11 @@ function runMigrations(database) {
17
17
  version = 1;
18
18
  continue;
19
19
  }
20
+ if (version === 1) {
21
+ migrateToV2(database);
22
+ version = 2;
23
+ continue;
24
+ }
20
25
  throw new Error(`Unsupported database schema version v${version} (expected v${CURRENT_VERSION}). Delete the database file to reinitialize.`);
21
26
  }
22
27
  }
@@ -88,4 +93,24 @@ function migrateToV1(database) {
88
93
  setSchemaVersion(database, 1);
89
94
  })();
90
95
  }
96
+ /**
97
+ * Migration to version 2: Add agent and provider columns
98
+ *
99
+ * Moves routing metadata (agent, provider) from the encrypted blob to
100
+ * unencrypted columns. This enables refresh-on-read to pass agent/provider
101
+ * to axauth without parsing the blob, and allows future filtering by agent.
102
+ *
103
+ * Existing rows get empty agent (new PUTs populate the column).
104
+ */
105
+ function migrateToV2(database) {
106
+ database.transaction(() => {
107
+ database.exec(`
108
+ ALTER TABLE credentials ADD COLUMN agent TEXT NOT NULL DEFAULT ''
109
+ `);
110
+ database.exec(`
111
+ ALTER TABLE credentials ADD COLUMN provider TEXT DEFAULT NULL
112
+ `);
113
+ setSchemaVersion(database, 2);
114
+ })();
115
+ }
91
116
  export { CURRENT_VERSION, getSchemaVersion, runMigrations };
@@ -1,12 +1,10 @@
1
1
  /**
2
2
  * SQL queries for credentials repository.
3
- *
4
- * Name-only primary key, opaque blob storage.
5
3
  */
6
- export declare const UPSERT_CREDENTIAL = "\n INSERT INTO credentials (name, encrypted_data, salt, iv, auth_tag, created_at, updated_at)\n VALUES (?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT(name) DO UPDATE SET\n encrypted_data = excluded.encrypted_data, salt = excluded.salt, iv = excluded.iv, auth_tag = excluded.auth_tag,\n updated_at = excluded.updated_at\n RETURNING created_at, updated_at";
4
+ export declare const UPSERT_CREDENTIAL = "\n INSERT INTO credentials (name, agent, provider, encrypted_data, salt, iv, auth_tag, created_at, updated_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT(name) DO UPDATE SET\n agent = excluded.agent, provider = excluded.provider,\n encrypted_data = excluded.encrypted_data, salt = excluded.salt, iv = excluded.iv, auth_tag = excluded.auth_tag,\n updated_at = excluded.updated_at\n RETURNING created_at, updated_at";
7
5
  export declare const UPDATE_CREDENTIAL_IF_UPDATED_AT_MATCHES = "\n UPDATE credentials\n SET encrypted_data = ?, salt = ?, iv = ?, auth_tag = ?, updated_at = ?\n WHERE name = ? AND updated_at = ?";
8
- export declare const SELECT_CREDENTIAL = "\n SELECT name, encrypted_data, salt, iv, auth_tag, created_at, updated_at\n FROM credentials WHERE name = ?";
9
- export declare const SELECT_ALL_METADATA = "\n SELECT name, created_at, updated_at\n FROM credentials ORDER BY name";
10
- export declare const SELECT_METADATA_PAGINATED = "\n SELECT name, created_at, updated_at\n FROM credentials\n WHERE name > ?\n ORDER BY name\n LIMIT ?";
11
- export declare const SELECT_METADATA_FIRST_PAGE = "\n SELECT name, created_at, updated_at\n FROM credentials\n ORDER BY name\n LIMIT ?";
6
+ export declare const SELECT_CREDENTIAL = "\n SELECT name, agent, provider, encrypted_data, salt, iv, auth_tag, created_at, updated_at\n FROM credentials WHERE name = ?";
7
+ export declare const SELECT_ALL_METADATA = "\n SELECT name, agent, provider, created_at, updated_at\n FROM credentials ORDER BY name";
8
+ export declare const SELECT_METADATA_PAGINATED = "\n SELECT name, agent, provider, created_at, updated_at\n FROM credentials\n WHERE name > ?\n ORDER BY name\n LIMIT ?";
9
+ export declare const SELECT_METADATA_FIRST_PAGE = "\n SELECT name, agent, provider, created_at, updated_at\n FROM credentials\n ORDER BY name\n LIMIT ?";
12
10
  export declare const DELETE_CREDENTIAL = "\n DELETE FROM credentials WHERE name = ?";
@@ -1,12 +1,11 @@
1
1
  /**
2
2
  * SQL queries for credentials repository.
3
- *
4
- * Name-only primary key, opaque blob storage.
5
3
  */
6
4
  export const UPSERT_CREDENTIAL = `
7
- INSERT INTO credentials (name, encrypted_data, salt, iv, auth_tag, created_at, updated_at)
8
- VALUES (?, ?, ?, ?, ?, ?, ?)
5
+ INSERT INTO credentials (name, agent, provider, encrypted_data, salt, iv, auth_tag, created_at, updated_at)
6
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
9
7
  ON CONFLICT(name) DO UPDATE SET
8
+ agent = excluded.agent, provider = excluded.provider,
10
9
  encrypted_data = excluded.encrypted_data, salt = excluded.salt, iv = excluded.iv, auth_tag = excluded.auth_tag,
11
10
  updated_at = excluded.updated_at
12
11
  RETURNING created_at, updated_at`;
@@ -15,19 +14,19 @@ export const UPDATE_CREDENTIAL_IF_UPDATED_AT_MATCHES = `
15
14
  SET encrypted_data = ?, salt = ?, iv = ?, auth_tag = ?, updated_at = ?
16
15
  WHERE name = ? AND updated_at = ?`;
17
16
  export const SELECT_CREDENTIAL = `
18
- SELECT name, encrypted_data, salt, iv, auth_tag, created_at, updated_at
17
+ SELECT name, agent, provider, encrypted_data, salt, iv, auth_tag, created_at, updated_at
19
18
  FROM credentials WHERE name = ?`;
20
19
  export const SELECT_ALL_METADATA = `
21
- SELECT name, created_at, updated_at
20
+ SELECT name, agent, provider, created_at, updated_at
22
21
  FROM credentials ORDER BY name`;
23
22
  export const SELECT_METADATA_PAGINATED = `
24
- SELECT name, created_at, updated_at
23
+ SELECT name, agent, provider, created_at, updated_at
25
24
  FROM credentials
26
25
  WHERE name > ?
27
26
  ORDER BY name
28
27
  LIMIT ?`;
29
28
  export const SELECT_METADATA_FIRST_PAGE = `
30
- SELECT name, created_at, updated_at
29
+ SELECT name, agent, provider, created_at, updated_at
31
30
  FROM credentials
32
31
  ORDER BY name
33
32
  LIMIT ?`;
@@ -14,6 +14,8 @@ interface UpsertTimestamps {
14
14
  /** Store or update a credential, returning the resulting timestamps */
15
15
  declare function upsertCredential(database: Database.Database, credential: {
16
16
  name: string;
17
+ agent: string;
18
+ provider: string | undefined;
17
19
  encryptedData: Buffer;
18
20
  salt: Buffer;
19
21
  iv: Buffer;
@@ -11,7 +11,7 @@ function upsertCredential(database, credential) {
11
11
  const now = Date.now();
12
12
  const row = database
13
13
  .prepare(SQL.UPSERT_CREDENTIAL)
14
- .get(credential.name, credential.encryptedData, credential.salt, credential.iv, credential.authTag, now, now);
14
+ .get(credential.name, credential.agent, credential.provider ?? undefined, credential.encryptedData, credential.salt, credential.iv, credential.authTag, now, now);
15
15
  return {
16
16
  createdAt: new Date(row.created_at),
17
17
  updatedAt: new Date(row.updated_at),
@@ -16,7 +16,7 @@ function buildFilteredQuery(names, cursor, limit) {
16
16
  const placeholders = names.map(() => "?").join(", ");
17
17
  const cursorClause = cursor ? " AND name > ?" : "";
18
18
  const sql = `
19
- SELECT name, created_at, updated_at
19
+ SELECT name, agent, provider, created_at, updated_at
20
20
  FROM credentials
21
21
  WHERE name IN (${placeholders})${cursorClause}
22
22
  ORDER BY name
@@ -7,6 +7,8 @@ import type { CredentialRow, MetadataRow } from "../types.js";
7
7
  /** Credential record stored in database */
8
8
  interface CredentialRecord {
9
9
  name: string;
10
+ agent: string;
11
+ provider: string | undefined;
10
12
  encryptedData: Buffer;
11
13
  salt: Buffer;
12
14
  iv: Buffer;
@@ -17,6 +19,8 @@ interface CredentialRecord {
17
19
  /** Credential metadata (without encrypted data) */
18
20
  interface CredentialMetadata {
19
21
  name: string;
22
+ agent: string;
23
+ provider: string | undefined;
20
24
  createdAt: Date;
21
25
  updatedAt: Date;
22
26
  }
@@ -7,6 +7,8 @@
7
7
  function rowToRecord(row) {
8
8
  return {
9
9
  name: row.name,
10
+ agent: row.agent,
11
+ provider: row.provider ?? undefined,
10
12
  encryptedData: row.encrypted_data,
11
13
  salt: row.salt,
12
14
  iv: row.iv,
@@ -19,6 +21,8 @@ function rowToRecord(row) {
19
21
  function rowToMetadata(row) {
20
22
  return {
21
23
  name: row.name,
24
+ agent: row.agent,
25
+ provider: row.provider ?? undefined,
22
26
  createdAt: new Date(row.created_at),
23
27
  updatedAt: new Date(row.updated_at),
24
28
  };
@@ -23,9 +23,11 @@ export interface AuditLogRow {
23
23
  success: number;
24
24
  error_message: string | null;
25
25
  }
26
- /** Raw credential row from database (opaque blob storage) */
26
+ /** Raw credential row from database */
27
27
  export interface CredentialRow {
28
28
  name: string;
29
+ agent: string;
30
+ provider: string | null;
29
31
  encrypted_data: Buffer;
30
32
  salt: Buffer;
31
33
  iv: Buffer;
@@ -36,6 +38,8 @@ export interface CredentialRow {
36
38
  /** Raw credential metadata row from database */
37
39
  export interface MetadataRow {
38
40
  name: string;
41
+ agent: string;
42
+ provider: string | null;
39
43
  created_at: number;
40
44
  updated_at: number;
41
45
  }
@@ -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 };
@@ -67,6 +67,8 @@ function createGetCredentialHandler(database, config) {
67
67
  apiKeyId: apiKey.id,
68
68
  name,
69
69
  blob,
70
+ agent: credential.agent,
71
+ provider: credential.provider,
70
72
  expectedUpdatedAt: credential.updatedAt,
71
73
  refreshThresholdSeconds: config.refreshThresholdSeconds,
72
74
  refreshTimeoutMs: config.refreshTimeoutMs,
@@ -94,9 +96,13 @@ function createGetCredentialHandler(database, config) {
94
96
  if (refreshFailed) {
95
97
  response.setHeader("X-Axvault-Refresh-Failed", "true");
96
98
  }
97
- // Return the blob as-is (opaque to axvault)
99
+ // Return the blob along with routing metadata from columns
98
100
  response.json({
99
101
  name,
102
+ ...(credential.agent !== "" && { agent: credential.agent }),
103
+ ...(credential.provider !== undefined && {
104
+ provider: credential.provider,
105
+ }),
100
106
  credential: finalBlob,
101
107
  updatedAt: finalUpdatedAt.toISOString(),
102
108
  });
@@ -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 };
@@ -10,6 +10,16 @@
10
10
  */
11
11
  import { listCredentialsForApiKey, listCredentialsPaginated, } from "../db/repositories/credentials.js";
12
12
  import { logAccess } from "../db/repositories/audit-log.js";
13
+ /** Format credential metadata for API response */
14
+ function formatCredentialMetadata(cred) {
15
+ return {
16
+ name: cred.name,
17
+ ...(cred.agent !== "" && { agent: cred.agent }),
18
+ ...(cred.provider !== undefined && { provider: cred.provider }),
19
+ createdAt: cred.createdAt.toISOString(),
20
+ updatedAt: cred.updatedAt.toISOString(),
21
+ };
22
+ }
13
23
  /** Maximum page size when limit is specified */
14
24
  const MAX_LIMIT = 1000;
15
25
  /**
@@ -61,11 +71,7 @@ function createListCredentialsHandler(database) {
61
71
  success: true,
62
72
  });
63
73
  response.json({
64
- credentials: credentials.map((cred) => ({
65
- name: cred.name,
66
- createdAt: cred.createdAt.toISOString(),
67
- updatedAt: cred.updatedAt.toISOString(),
68
- })),
74
+ credentials: credentials.map((cred) => formatCredentialMetadata(cred)),
69
75
  });
70
76
  return;
71
77
  }
@@ -92,11 +98,7 @@ function createListCredentialsHandler(database) {
92
98
  success: true,
93
99
  });
94
100
  response.json({
95
- credentials: result.credentials.map((cred) => ({
96
- name: cred.name,
97
- createdAt: cred.createdAt.toISOString(),
98
- updatedAt: cred.updatedAt.toISOString(),
99
- })),
101
+ credentials: result.credentials.map((cred) => formatCredentialMetadata(cred)),
100
102
  ...(result.nextCursor !== undefined && {
101
103
  nextCursor: result.nextCursor,
102
104
  }),
@@ -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 };
@@ -48,10 +48,19 @@ function createPutCredentialHandler(database) {
48
48
  return;
49
49
  }
50
50
  try {
51
- // Encrypt the blob as-is (opaque storage)
52
- const encrypted = encryptCredential(body);
51
+ // Extract routing metadata from body before encrypting
52
+ const bodyRecord = body;
53
+ const agent = typeof bodyRecord.agent === "string" ? bodyRecord.agent : "";
54
+ const provider = typeof bodyRecord.provider === "string"
55
+ ? bodyRecord.provider
56
+ : undefined;
57
+ // Encrypt the credential blob (agent/provider stored as columns, not in blob)
58
+ const credentialBlob = Object.fromEntries(Object.entries(bodyRecord).filter(([key]) => key !== "agent" && key !== "provider"));
59
+ const encrypted = encryptCredential(credentialBlob);
53
60
  const timestamps = upsertCredential(database, {
54
61
  name,
62
+ agent,
63
+ provider,
55
64
  ...encrypted,
56
65
  });
57
66
  logAccess(database, {
@@ -19,6 +19,8 @@ declare function refreshCredentialOnRead(options: {
19
19
  apiKeyId: string;
20
20
  name: string;
21
21
  blob: unknown;
22
+ agent: string;
23
+ provider: string | undefined;
22
24
  expectedUpdatedAt: Date;
23
25
  refreshThresholdSeconds: number;
24
26
  refreshTimeoutMs: number;
@@ -5,17 +5,17 @@
5
5
  * stays small enough for static complexity checks (FTA).
6
6
  */
7
7
  import { isCredentialExpired, isRefreshable, refreshBlob, } from "axauth";
8
- import { isRefreshableCredentialType, parseCredentials } from "axshared";
8
+ import { isValidAgentCli, isRefreshableCredentialType, parseCredentials } from "axshared";
9
9
  import { getCredential, updateCredentialIfUnchanged, } from "../db/repositories/credentials.js";
10
10
  import { logAccess } from "../db/repositories/audit-log.js";
11
11
  import { decryptCredential, encryptCredential } from "../lib/encryption.js";
12
12
  /** Per-credential mutex to prevent concurrent refreshes */
13
13
  const pendingRefreshes = new Map();
14
- function getRefreshPromise(name, blob, refreshTimeoutMs) {
14
+ function getRefreshPromise(name, blob, agent, provider, refreshTimeoutMs) {
15
15
  const existing = pendingRefreshes.get(name);
16
16
  if (existing)
17
17
  return existing;
18
- const promise = refreshBlob(blob, { timeout: refreshTimeoutMs });
18
+ const promise = refreshBlob(blob, { agent, provider, timeout: refreshTimeoutMs });
19
19
  pendingRefreshes.set(name, promise);
20
20
  void promise.finally(() => {
21
21
  if (pendingRefreshes.get(name) === promise) {
@@ -34,6 +34,17 @@ async function refreshCredentialOnRead(options) {
34
34
  refreshFailed: false,
35
35
  };
36
36
  }
37
+ // Refresh requires a valid agent ID (empty means pre-v2 credential)
38
+ if (!isValidAgentCli(options.agent)) {
39
+ return {
40
+ status: "ok",
41
+ blob: options.blob,
42
+ updatedAt: options.expectedUpdatedAt,
43
+ wasRefreshed: false,
44
+ refreshFailed: false,
45
+ };
46
+ }
47
+ const agent = options.agent;
37
48
  const parsedCredentials = parseCredentials(options.blob);
38
49
  if (parsedCredentials === undefined ||
39
50
  !isRefreshableCredentialType(parsedCredentials.type) ||
@@ -48,7 +59,7 @@ async function refreshCredentialOnRead(options) {
48
59
  };
49
60
  }
50
61
  try {
51
- const refreshResult = await getRefreshPromise(options.name, options.blob, options.refreshTimeoutMs);
62
+ const refreshResult = await getRefreshPromise(options.name, options.blob, agent, options.provider, options.refreshTimeoutMs);
52
63
  if (refreshResult.ok) {
53
64
  const encrypted = encryptCredential(refreshResult.blob);
54
65
  const updateResult = updateCredentialIfUnchanged(options.database, {
@@ -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.1",
6
6
  "description": "Remote credential storage server for axkit",
7
7
  "repository": {
8
8
  "type": "git",
@@ -49,8 +49,8 @@
49
49
  },
50
50
  "dependencies": {
51
51
  "@commander-js/extra-typings": "^14.0.0",
52
- "axauth": "^3.1.1",
53
- "axshared": "4.0.0",
52
+ "axauth": "^3.1.4",
53
+ "axshared": "5.0.0",
54
54
  "better-sqlite3": "^12.6.2",
55
55
  "commander": "^14.0.2",
56
56
  "express": "^5.2.1"