@opengeni/vaults-protonpass 0.0.0-stage → 3.2.2

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.
@@ -0,0 +1,65 @@
1
+ import { providerOperations } from "@opengeni/vaults";
2
+ import {
3
+ VaultProviderError,
4
+ type ProviderWriteItem,
5
+ type VaultProvider,
6
+ type VaultProviderOperations,
7
+ } from "./contract";
8
+
9
+ function refused(what: string): never {
10
+ throw new VaultProviderError(
11
+ "provider_permission_denied",
12
+ `${what} is not enabled for this vault connection`,
13
+ );
14
+ }
15
+
16
+ function checkWrite(operations: VaultProviderOperations, item: ProviderWriteItem) {
17
+ const totp = item.fields.some((field) => field.kind === "totp" && !field.remove);
18
+ if (item.externalItemId !== undefined) {
19
+ if (!operations.updateItem) refused("Updating items");
20
+ if (totp && !operations.updateTotp) refused("Changing one-time password seeds");
21
+ if (item.fields.some((field) => field.remove) && !operations.removeField)
22
+ refused("Removing fields");
23
+ return;
24
+ }
25
+ if (!operations.createItemTypes.includes(item.type)) refused(`Creating ${item.type} items`);
26
+ if (totp && !operations.createTotp) refused("Adding one-time password seeds");
27
+ }
28
+
29
+ /**
30
+ * Narrows an adapter to the operations verified when its connection was set
31
+ * up: what it reports and what it will do. It never widens the adapter's own
32
+ * operations, and refused calls make no provider request.
33
+ */
34
+ export function restrictToOperations<P extends VaultProvider>(
35
+ provider: P,
36
+ verified: VaultProviderOperations | undefined,
37
+ fileFieldPrefix: string,
38
+ ): P {
39
+ if (!verified) return provider;
40
+ const effective = () =>
41
+ providerOperations(provider.capabilities(), { operations: verified, writeAccess: "unknown" });
42
+ return {
43
+ ...provider,
44
+ capabilities() {
45
+ const base = provider.capabilities();
46
+ const operations = effective();
47
+ const writes =
48
+ operations.createItemTypes.length > 0 || operations.updateItem || operations.archiveItem;
49
+ return { ...base, write: base.write && writes, operations };
50
+ },
51
+ async readFields(ctx, vaultId, itemId, fieldIds) {
52
+ if (!effective().readFiles && fieldIds.some((id) => id.startsWith(fileFieldPrefix)))
53
+ refused("Reading files");
54
+ return provider.readFields(ctx, vaultId, itemId, fieldIds);
55
+ },
56
+ async writeItem(ctx, vaultId, item) {
57
+ checkWrite(effective(), item);
58
+ return provider.writeItem(ctx, vaultId, item);
59
+ },
60
+ async archiveItem(ctx, vaultId, itemId) {
61
+ if (!effective().archiveItem) refused("Archiving items");
62
+ return provider.archiveItem(ctx, vaultId, itemId);
63
+ },
64
+ };
65
+ }
package/src/pool.ts ADDED
@@ -0,0 +1,199 @@
1
+ import { createHmac, randomBytes, randomUUID } from "node:crypto";
2
+ import { VaultProviderError, type VaultProvider, type VaultProviderOperations } from "./contract";
3
+ import {
4
+ type ProtonPassVaultProvider,
5
+ type ProtonPassVaultProviderOptions,
6
+ createProtonPassVaultProvider,
7
+ protonPassCapabilities,
8
+ } from "./provider";
9
+ import { FILE_FIELD_PREFIX } from "./mapping";
10
+ import { restrictToOperations } from "./operations";
11
+
12
+ export const PROTON_PASS_POOL_MAX_SESSIONS = 32;
13
+ export const PROTON_PASS_POOL_IDLE_MS = 15 * 60_000;
14
+ /** Retry hint when every pooled session is busy. */
15
+ export const PROTON_PASS_POOL_BUSY_RETRY_MS = 5_000;
16
+
17
+ export type ProtonPassProviderPoolOptions = Omit<
18
+ ProtonPassVaultProviderOptions,
19
+ "token" | "operations"
20
+ > & {
21
+ /**
22
+ * Hard limit on CLI sessions held at once. At the limit the least recently
23
+ * used idle session is logged out; when every session is busy the call is
24
+ * refused with `provider_rate_limited` instead of growing the pool.
25
+ */
26
+ maxSessions?: number;
27
+ /** A session unused this long is logged out, wiped and forgotten (token included). */
28
+ poolIdleMs?: number;
29
+ /** Builds one session's provider (tests). */
30
+ create?: (options: ProtonPassVaultProviderOptions) => ProtonPassVaultProvider;
31
+ };
32
+
33
+ export type ProtonPassPoolProviderOptions = {
34
+ /**
35
+ * The session scope, normally the Opengeni connection id. Providers with
36
+ * the same connection id and token share one logged-in CLI session. Without
37
+ * it the provider gets its own scope: a session no other provider uses.
38
+ */
39
+ connectionId?: string;
40
+ /** Operations verified at setup; the provider is narrowed to them. */
41
+ operations?: VaultProviderOperations;
42
+ };
43
+
44
+ export interface ProtonPassProviderPool {
45
+ /**
46
+ * A provider for one connection's token. Sessions are never shared across
47
+ * connections, even for the same token, nor across tokens: each has its own
48
+ * CLI login, state directory, key and cache. The session is created on the
49
+ * first call, not here.
50
+ */
51
+ provider(token: string, options?: ProtonPassPoolProviderOptions): VaultProvider;
52
+ /** Logs every session out and wipes their state directories; later calls are refused. */
53
+ close(): Promise<void>;
54
+ /** Sessions currently held (tests and diagnostics). */
55
+ size(): number;
56
+ }
57
+
58
+ type Entry = {
59
+ provider: ProtonPassVaultProvider;
60
+ scope: string;
61
+ lastUsed: number;
62
+ active: number;
63
+ };
64
+
65
+ /**
66
+ * A process-wide pool of Proton Pass CLI sessions. Logging in creates a
67
+ * Proton session, so each connection logs in once and its session is reused
68
+ * until it goes idle. Entries are keyed by an HMAC of scope and token under a
69
+ * random per-pool key, so the map holds no token-derived value that outlives
70
+ * the process.
71
+ */
72
+ export function createProtonPassProviderPool(
73
+ options: ProtonPassProviderPoolOptions,
74
+ ): ProtonPassProviderPool {
75
+ const {
76
+ maxSessions = PROTON_PASS_POOL_MAX_SESSIONS,
77
+ poolIdleMs = PROTON_PASS_POOL_IDLE_MS,
78
+ create = createProtonPassVaultProvider,
79
+ ...providerOptions
80
+ } = options;
81
+ if (!Number.isInteger(maxSessions) || maxSessions < 1) {
82
+ throw new RangeError("maxSessions must be a positive integer");
83
+ }
84
+ const now = providerOptions.now ?? Date.now;
85
+ const key = randomBytes(32);
86
+ const entries = new Map<string, Entry>();
87
+ let sweeper: ReturnType<typeof setInterval> | null = null;
88
+ let closed = false;
89
+
90
+ function retire(id: string, entry: Entry) {
91
+ entries.delete(id);
92
+ void entry.provider.close().catch(() => {});
93
+ if (entries.size === 0 && sweeper) {
94
+ clearInterval(sweeper);
95
+ sweeper = null;
96
+ }
97
+ }
98
+
99
+ function sweep() {
100
+ const at = now();
101
+ for (const [id, entry] of entries) {
102
+ if (entry.active === 0 && at - entry.lastUsed >= poolIdleMs) retire(id, entry);
103
+ }
104
+ }
105
+
106
+ function evictForRoom() {
107
+ while (entries.size >= maxSessions) {
108
+ let oldest: [string, Entry] | null = null;
109
+ for (const pair of entries) {
110
+ if (pair[1].active > 0) continue;
111
+ if (!oldest || pair[1].lastUsed < oldest[1].lastUsed) oldest = pair;
112
+ }
113
+ if (!oldest) return;
114
+ retire(oldest[0], oldest[1]);
115
+ }
116
+ }
117
+
118
+ function entryFor(scope: string, token: string): Entry {
119
+ if (closed) {
120
+ throw new VaultProviderError(
121
+ "provider_unavailable",
122
+ "The Proton Pass session pool is closed",
123
+ );
124
+ }
125
+ const id = createHmac("sha256", key)
126
+ .update(JSON.stringify([scope, token]))
127
+ .digest("hex");
128
+ sweep();
129
+ const existing = entries.get(id);
130
+ if (existing) return existing;
131
+ // A new token for this scope (rotation): its old idle sessions go now.
132
+ for (const [otherId, other] of entries) {
133
+ if (other.scope === scope && other.active === 0) retire(otherId, other);
134
+ }
135
+ evictForRoom();
136
+ if (entries.size >= maxSessions) {
137
+ throw new VaultProviderError(
138
+ "provider_rate_limited",
139
+ "Every Proton Pass CLI session on this server is busy; try again shortly",
140
+ PROTON_PASS_POOL_BUSY_RETRY_MS,
141
+ );
142
+ }
143
+ const entry: Entry = {
144
+ provider: create({ ...providerOptions, token }),
145
+ scope,
146
+ lastUsed: now(),
147
+ active: 0,
148
+ };
149
+ entries.set(id, entry);
150
+ if (!sweeper) {
151
+ sweeper = setInterval(sweep, Math.max(1_000, Math.min(poolIdleMs, 60_000)));
152
+ sweeper.unref?.();
153
+ }
154
+ return entry;
155
+ }
156
+
157
+ function tracked(scope: string, token: string): VaultProvider {
158
+ const run = async <T>(work: (provider: ProtonPassVaultProvider) => Promise<T>): Promise<T> => {
159
+ const entry = entryFor(scope, token);
160
+ entry.active += 1;
161
+ entry.lastUsed = now();
162
+ try {
163
+ return await work(entry.provider);
164
+ } finally {
165
+ entry.active -= 1;
166
+ entry.lastUsed = now();
167
+ }
168
+ };
169
+ return {
170
+ id: "protonpass",
171
+ capabilities: protonPassCapabilities,
172
+ validate: (ctx) => run((provider) => provider.validate(ctx)),
173
+ listVaults: (ctx) => run((provider) => provider.listVaults(ctx)),
174
+ listItems: (ctx, vaultId) => run((provider) => provider.listItems(ctx, vaultId)),
175
+ readFields: (ctx, vaultId, itemId, fieldIds) =>
176
+ run((provider) => provider.readFields(ctx, vaultId, itemId, fieldIds)),
177
+ writeItem: (ctx, vaultId, item) => run((provider) => provider.writeItem(ctx, vaultId, item)),
178
+ archiveItem: (ctx, vaultId, itemId) =>
179
+ run((provider) => provider.archiveItem(ctx, vaultId, itemId)),
180
+ };
181
+ }
182
+
183
+ return {
184
+ provider(token, { connectionId, operations } = {}) {
185
+ const scope =
186
+ connectionId === undefined ? `standalone:${randomUUID()}` : `connection:${connectionId}`;
187
+ return restrictToOperations(tracked(scope, token), operations, FILE_FIELD_PREFIX);
188
+ },
189
+ async close() {
190
+ closed = true;
191
+ if (sweeper) clearInterval(sweeper);
192
+ sweeper = null;
193
+ const all = [...entries.values()];
194
+ entries.clear();
195
+ await Promise.all(all.map((entry) => entry.provider.close().catch(() => {})));
196
+ },
197
+ size: () => entries.size,
198
+ };
199
+ }