@datacules/agent-identity-store-vault 0.2.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.
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@datacules/agent-identity-store-vault",
3
+ "version": "0.2.1",
4
+ "private": false,
5
+ "description": "HashiCorp Vault KV v2 credential store for @datacules/agent-identity",
6
+ "main": "./dist/cjs/index.js",
7
+ "module": "./dist/esm/index.js",
8
+ "types": "./dist/types/index.d.ts",
9
+ "scripts": {
10
+ "build": "tsc -p tsconfig.build.json",
11
+ "type-check": "tsc --noEmit"
12
+ },
13
+ "peerDependencies": {
14
+ "@datacules/agent-identity": "^0.1.0"
15
+ },
16
+ "devDependencies": {
17
+ "@datacules/agent-identity": "*",
18
+ "typescript": "^5"
19
+ }
20
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * HashiCorp Vault KV v2 CredentialStore implementation.
3
+ *
4
+ * Each credential is stored as a JSON object under:
5
+ * <mountPath>/data/<ref>
6
+ *
7
+ * Example write:
8
+ * vault kv put secret/agent-identity/linear-service-account-slot \
9
+ * id=cred-linear kind=fixed scope='All projects' status=active ref=linear-service-account-slot
10
+ *
11
+ * Vault reservation uses a separate KV path for migration locks:
12
+ * <mountPath>/data/_locks/<ref>
13
+ *
14
+ * Required Vault policy:
15
+ * path "secret/data/agent-identity/*" { capabilities = ["read", "list"] }
16
+ * path "secret/data/agent-identity/_locks/*" { capabilities = ["create", "update", "delete", "read"] }
17
+ */
18
+ import type { Credential, CredentialKind, CredentialStore } from '@datacules/agent-identity';
19
+
20
+ export interface VaultCredentialStoreOptions {
21
+ /** Vault server address e.g. https://vault.example.com */
22
+ address: string;
23
+ /** Vault token or AppRole token */
24
+ token: string;
25
+ /** KV v2 mount path (default: 'secret') */
26
+ mountPath?: string;
27
+ /** Path prefix under mountPath (default: 'agent-identity') */
28
+ prefix?: string;
29
+ }
30
+
31
+ interface VaultKVResponse {
32
+ data: { data: Record<string, unknown> };
33
+ }
34
+
35
+ export class VaultCredentialStore implements CredentialStore {
36
+ private readonly address: string;
37
+ private readonly token: string;
38
+ private readonly mountPath: string;
39
+ private readonly prefix: string;
40
+
41
+ constructor(options: VaultCredentialStoreOptions) {
42
+ this.address = options.address.replace(/\/$/, '');
43
+ this.token = options.token;
44
+ this.mountPath = options.mountPath ?? 'secret';
45
+ this.prefix = options.prefix ?? 'agent-identity';
46
+ }
47
+
48
+ private get headers(): Record<string, string> {
49
+ return { 'X-Vault-Token': this.token, 'Content-Type': 'application/json' };
50
+ }
51
+
52
+ private credPath(ref: string): string {
53
+ return `${this.address}/v1/${this.mountPath}/data/${this.prefix}/${ref}`;
54
+ }
55
+
56
+ private lockPath(ref: string): string {
57
+ return `${this.address}/v1/${this.mountPath}/data/${this.prefix}/_locks/${ref}`;
58
+ }
59
+
60
+ async findByRef(ref: string): Promise<Credential | null> {
61
+ try {
62
+ const res = await fetch(this.credPath(ref), { headers: this.headers });
63
+ if (!res.ok) return null;
64
+ const body = (await res.json()) as VaultKVResponse;
65
+ const cred = body.data?.data as unknown as Credential;
66
+ return cred?.status === 'active' ? cred : null;
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ async listActive(): Promise<Credential[]> {
73
+ try {
74
+ const res = await fetch(
75
+ `${this.address}/v1/${this.mountPath}/metadata/${this.prefix}?list=true`,
76
+ { headers: this.headers }
77
+ );
78
+ if (!res.ok) return [];
79
+ const body = await res.json() as { data: { keys: string[] } };
80
+ const keys = body.data?.keys ?? [];
81
+ const creds = await Promise.all(keys.map((k: string) => this.findByRef(k)));
82
+ return creds.filter((c): c is Credential => c !== null);
83
+ } catch {
84
+ return [];
85
+ }
86
+ }
87
+
88
+ async listByKind(kind: CredentialKind): Promise<Credential[]> {
89
+ const all = await this.listActive();
90
+ return all.filter((c) => c.kind === kind);
91
+ }
92
+
93
+ async reserve(ref: string, migrationId: string, ttlSeconds: number): Promise<boolean> {
94
+ // Read existing lock
95
+ try {
96
+ const res = await fetch(this.lockPath(ref), { headers: this.headers });
97
+ if (res.ok) {
98
+ const body = (await res.json()) as VaultKVResponse;
99
+ const lock = body.data?.data as unknown as { migrationId: string; expiresAt: number };
100
+ if (lock?.migrationId !== migrationId && lock?.expiresAt > Date.now() / 1000) {
101
+ return false; // held by another migration
102
+ }
103
+ }
104
+ } catch { /* no existing lock */ }
105
+
106
+ // Write lock
107
+ const expiresAt = Math.floor(Date.now() / 1000) + ttlSeconds;
108
+ try {
109
+ const res = await fetch(this.lockPath(ref), {
110
+ method: 'POST',
111
+ headers: this.headers,
112
+ body: JSON.stringify({ data: { migrationId, expiresAt } }),
113
+ });
114
+ return res.ok;
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
119
+
120
+ async release(ref: string, migrationId: string): Promise<void> {
121
+ try {
122
+ const res = await fetch(this.lockPath(ref), { headers: this.headers });
123
+ if (!res.ok) return;
124
+ const body = (await res.json()) as VaultKVResponse;
125
+ const lock = body.data?.data as unknown as { migrationId: string };
126
+ if (lock?.migrationId !== migrationId) return;
127
+ await fetch(`${this.address}/v1/${this.mountPath}/data/${this.prefix}/_locks/${ref}`, {
128
+ method: 'DELETE',
129
+ headers: this.headers,
130
+ });
131
+ } catch { /* idempotent */ }
132
+ }
133
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { VaultCredentialStore } from './VaultCredentialStore';
2
+ export type { VaultCredentialStoreOptions } from './VaultCredentialStore';