@opengeni/vaults-keeper 0.0.0-stage → 3.3.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/LICENSE +190 -0
- package/README.md +147 -2
- package/dist/bootstrap.d.ts +40 -0
- package/dist/config.d.ts +41 -0
- package/dist/connection.d.ts +37 -0
- package/dist/contract.d.ts +6 -0
- package/dist/errors.d.ts +36 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +1450 -0
- package/dist/index.js.map +1 -0
- package/dist/mapping.d.ts +59 -0
- package/dist/naming.d.ts +8 -0
- package/dist/operations.d.ts +7 -0
- package/dist/provider.d.ts +81 -0
- package/dist/sdk.d.ts +81 -0
- package/package.json +42 -4
- package/src/bootstrap.ts +182 -0
- package/src/config.ts +161 -0
- package/src/connection.ts +75 -0
- package/src/contract.ts +29 -0
- package/src/errors.ts +196 -0
- package/src/index.ts +45 -0
- package/src/mapping.ts +373 -0
- package/src/naming.ts +44 -0
- package/src/operations.ts +65 -0
- package/src/provider.ts +681 -0
- package/src/sdk.ts +282 -0
package/src/mapping.ts
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ProviderItemMeta,
|
|
3
|
+
type ProviderWriteField,
|
|
4
|
+
type ProviderWriteItem,
|
|
5
|
+
type TotpParams,
|
|
6
|
+
VAULT_LIMITS,
|
|
7
|
+
type VaultFieldKind,
|
|
8
|
+
type VaultItemType,
|
|
9
|
+
VaultProviderError,
|
|
10
|
+
parseTotpInput,
|
|
11
|
+
} from "./contract";
|
|
12
|
+
import { nameKey, safeFieldName, safeSecretName, totpUri, uniqueName } from "./naming";
|
|
13
|
+
import type { KeeperFile, KeeperRecord } from "./sdk";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* External field ids are positional, because Keeper record fields have no
|
|
17
|
+
* ids of their own: `f:<type>` for standard fields, `c:<label or type>` for
|
|
18
|
+
* custom fields, `#n` for repeats, `@notes` for the record notes and
|
|
19
|
+
* `@file:<fileUid>` for attachments.
|
|
20
|
+
*/
|
|
21
|
+
export const NOTES_FIELD_ID = "@notes";
|
|
22
|
+
export const FILE_FIELD_PREFIX = "@file:";
|
|
23
|
+
|
|
24
|
+
export interface KeeperFieldEntry {
|
|
25
|
+
type: string;
|
|
26
|
+
label?: string;
|
|
27
|
+
value?: unknown[];
|
|
28
|
+
[key: string]: unknown;
|
|
29
|
+
}
|
|
30
|
+
export interface KeeperRecordData {
|
|
31
|
+
title?: string;
|
|
32
|
+
type?: string;
|
|
33
|
+
fields?: KeeperFieldEntry[];
|
|
34
|
+
custom?: KeeperFieldEntry[];
|
|
35
|
+
notes?: string;
|
|
36
|
+
[key: string]: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type MappedSource =
|
|
40
|
+
| { kind: "field"; list: "fields" | "custom"; index: number }
|
|
41
|
+
| { kind: "notes" }
|
|
42
|
+
| { kind: "file"; file: KeeperFile };
|
|
43
|
+
|
|
44
|
+
export interface MappedKeeperField {
|
|
45
|
+
externalFieldId: string;
|
|
46
|
+
name: string;
|
|
47
|
+
kind: VaultFieldKind;
|
|
48
|
+
source: MappedSource;
|
|
49
|
+
/** Plain value for text/concealed/totp fields; files are read separately. */
|
|
50
|
+
value: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const CONCEALED_TYPES = new Set(["password", "secret", "pinCode", "note"]);
|
|
54
|
+
const TOTP_TYPES = new Set(["oneTimeCode", "otp"]);
|
|
55
|
+
const CONCEALED_COMPLEX = new Set(["paymentCard", "bankAccount", "keyPair", "securityQuestion"]);
|
|
56
|
+
const SKIPPED_TYPES = new Set(["fileRef", "passkey", "recordRef", "cardRef", "addressRef"]);
|
|
57
|
+
const DATE_TYPES = new Set(["date", "birthDate", "expirationDate"]);
|
|
58
|
+
const STANDARD_NAMES: Record<string, string> = {
|
|
59
|
+
login: "username",
|
|
60
|
+
password: "password",
|
|
61
|
+
oneTimeCode: "one-time password",
|
|
62
|
+
otp: "one-time password",
|
|
63
|
+
url: "website",
|
|
64
|
+
email: "email",
|
|
65
|
+
note: "note",
|
|
66
|
+
pinCode: "PIN",
|
|
67
|
+
};
|
|
68
|
+
const API_KEY_TYPES = new Set([
|
|
69
|
+
"serverCredentials",
|
|
70
|
+
"databaseCredentials",
|
|
71
|
+
"sshKeys",
|
|
72
|
+
"softwareLicense",
|
|
73
|
+
"pamDatabase",
|
|
74
|
+
"pamMachine",
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
export function itemTypeForRecordType(type: string | undefined): VaultItemType {
|
|
78
|
+
if (type === "login") return "login";
|
|
79
|
+
if (type === "encryptedNotes") return "note";
|
|
80
|
+
if (type === "file") return "file";
|
|
81
|
+
if (type && API_KEY_TYPES.has(type)) return "api_key";
|
|
82
|
+
return "note";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function kindFor(entry: KeeperFieldEntry): VaultFieldKind | null {
|
|
86
|
+
if (SKIPPED_TYPES.has(entry.type)) return null;
|
|
87
|
+
if (TOTP_TYPES.has(entry.type)) return "totp";
|
|
88
|
+
if (CONCEALED_TYPES.has(entry.type) || CONCEALED_COMPLEX.has(entry.type)) return "concealed";
|
|
89
|
+
return "text";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function valueText(entry: KeeperFieldEntry): string {
|
|
93
|
+
const values = Array.isArray(entry.value) ? entry.value : [];
|
|
94
|
+
if (values.length === 0) return "";
|
|
95
|
+
if (DATE_TYPES.has(entry.type) && typeof values[0] === "number") {
|
|
96
|
+
const date = new Date(values[0]);
|
|
97
|
+
return Number.isNaN(date.getTime()) ? String(values[0]) : date.toISOString();
|
|
98
|
+
}
|
|
99
|
+
if (
|
|
100
|
+
values.length === 1 &&
|
|
101
|
+
(typeof values[0] === "string" ||
|
|
102
|
+
typeof values[0] === "number" ||
|
|
103
|
+
typeof values[0] === "boolean")
|
|
104
|
+
) {
|
|
105
|
+
return String(values[0]);
|
|
106
|
+
}
|
|
107
|
+
return JSON.stringify(values.length === 1 ? values[0] : values);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
class Collector {
|
|
111
|
+
readonly fields: MappedKeeperField[] = [];
|
|
112
|
+
private readonly names = new Set<string>();
|
|
113
|
+
private readonly ids = new Map<string, number>();
|
|
114
|
+
add(baseId: string, rawName: string, kind: VaultFieldKind, source: MappedSource, value: string) {
|
|
115
|
+
const seen = (this.ids.get(baseId) ?? 0) + 1;
|
|
116
|
+
this.ids.set(baseId, seen);
|
|
117
|
+
this.fields.push({
|
|
118
|
+
externalFieldId: seen === 1 ? baseId : `${baseId}#${seen}`,
|
|
119
|
+
name: uniqueName(safeFieldName(rawName, "field"), this.names),
|
|
120
|
+
kind,
|
|
121
|
+
source,
|
|
122
|
+
value,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface MappedKeeperRecord {
|
|
128
|
+
meta: ProviderItemMeta;
|
|
129
|
+
fields: MappedKeeperField[];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function recordData(record: KeeperRecord): KeeperRecordData {
|
|
133
|
+
const data = record.data as unknown;
|
|
134
|
+
return data && typeof data === "object" ? (data as KeeperRecordData) : {};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function mapRecord(record: KeeperRecord): MappedKeeperRecord {
|
|
138
|
+
const data = recordData(record);
|
|
139
|
+
const collector = new Collector();
|
|
140
|
+
const websites: string[] = [];
|
|
141
|
+
const lists: Array<["fields" | "custom", KeeperFieldEntry[]]> = [
|
|
142
|
+
["fields", Array.isArray(data.fields) ? data.fields : []],
|
|
143
|
+
["custom", Array.isArray(data.custom) ? data.custom : []],
|
|
144
|
+
];
|
|
145
|
+
for (const [list, entries] of lists) {
|
|
146
|
+
entries.forEach((entry, index) => {
|
|
147
|
+
if (!entry || typeof entry !== "object" || typeof entry.type !== "string") return;
|
|
148
|
+
const kind = kindFor(entry);
|
|
149
|
+
if (!kind) return;
|
|
150
|
+
const value = valueText(entry);
|
|
151
|
+
if (entry.type === "url") {
|
|
152
|
+
for (const url of Array.isArray(entry.value) ? entry.value : []) {
|
|
153
|
+
if (typeof url === "string" && url.trim()) websites.push(url);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// A one-time code field without a seed has nothing to compute.
|
|
157
|
+
if (kind === "totp" && !value) return;
|
|
158
|
+
const label = typeof entry.label === "string" && entry.label.trim() ? entry.label : "";
|
|
159
|
+
const name =
|
|
160
|
+
list === "fields" ? label || STANDARD_NAMES[entry.type] || entry.type : label || entry.type;
|
|
161
|
+
const baseId = list === "fields" ? `f:${entry.type}` : `c:${label || entry.type}`;
|
|
162
|
+
collector.add(baseId, name, kind, { kind: "field", list, index }, value);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
if (typeof data.notes === "string" && data.notes) {
|
|
166
|
+
collector.add(NOTES_FIELD_ID, "notes", "concealed", { kind: "notes" }, data.notes);
|
|
167
|
+
}
|
|
168
|
+
for (const file of record.files ?? []) {
|
|
169
|
+
const fileData = (file.data ?? {}) as { title?: unknown; name?: unknown };
|
|
170
|
+
const name =
|
|
171
|
+
(typeof fileData.title === "string" && fileData.title) ||
|
|
172
|
+
(typeof fileData.name === "string" && fileData.name) ||
|
|
173
|
+
"file";
|
|
174
|
+
collector.add(`${FILE_FIELD_PREFIX}${file.fileUid}`, name, "file", { kind: "file", file }, "");
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
meta: {
|
|
178
|
+
externalItemId: record.recordUid,
|
|
179
|
+
name: safeSecretName(typeof data.title === "string" ? data.title : "", record.recordUid),
|
|
180
|
+
type: itemTypeForRecordType(typeof data.type === "string" ? data.type : undefined),
|
|
181
|
+
websites,
|
|
182
|
+
fields: collector.fields.map(({ externalFieldId, name, kind }) => ({
|
|
183
|
+
externalFieldId,
|
|
184
|
+
name,
|
|
185
|
+
kind,
|
|
186
|
+
})),
|
|
187
|
+
version: Number.isFinite(record.revision) ? String(record.revision) : null,
|
|
188
|
+
updatedAt: null,
|
|
189
|
+
},
|
|
190
|
+
fields: collector.fields,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The record's file size as Keeper reports it, if any. */
|
|
195
|
+
export function fileSize(file: KeeperFile): number | null {
|
|
196
|
+
const size = (file.data as { size?: unknown } | undefined)?.size;
|
|
197
|
+
return typeof size === "number" && size >= 0 ? size : null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function totpParamsFor(value: string): TotpParams | null {
|
|
201
|
+
try {
|
|
202
|
+
return parseTotpInput(value);
|
|
203
|
+
} catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function unavailable(message: string): never {
|
|
209
|
+
throw new VaultProviderError("provider_unavailable", message);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function textValue(field: ProviderWriteField): string {
|
|
213
|
+
const value = field.value ?? "";
|
|
214
|
+
if (Buffer.byteLength(value, "utf8") > VAULT_LIMITS.textValueMaxBytes) {
|
|
215
|
+
unavailable(`The value of "${field.name}" is larger than Opengeni stores`);
|
|
216
|
+
}
|
|
217
|
+
return value;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function totpValue(field: ProviderWriteField, label: string): string {
|
|
221
|
+
const params = totpParamsFor(textValue(field));
|
|
222
|
+
if (!params) unavailable(`"${field.name}" is not a TOTP seed or otpauth:// URI`);
|
|
223
|
+
return totpUri(params, label);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function customEntry(field: ProviderWriteField, title: string): KeeperFieldEntry {
|
|
227
|
+
if (field.kind === "file") unavailable("Keeper files cannot be written from Opengeni");
|
|
228
|
+
if (field.kind === "totp")
|
|
229
|
+
return { type: "oneTimeCode", label: field.name, value: [totpValue(field, title)] };
|
|
230
|
+
return {
|
|
231
|
+
type: field.kind === "concealed" ? "secret" : "text",
|
|
232
|
+
label: field.name,
|
|
233
|
+
value: [textValue(field)],
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Record data for a create; Keeper `login` and `encryptedNotes` record types only. */
|
|
238
|
+
export function buildCreateData(item: ProviderWriteItem): KeeperRecordData {
|
|
239
|
+
const title = item.name;
|
|
240
|
+
for (const field of item.fields) {
|
|
241
|
+
if (field.remove) unavailable("A new Keeper record has no fields to remove");
|
|
242
|
+
}
|
|
243
|
+
if (item.type === "login") {
|
|
244
|
+
let login: string | null = null;
|
|
245
|
+
let password: string | null = null;
|
|
246
|
+
let otp: string | null = null;
|
|
247
|
+
const custom: KeeperFieldEntry[] = [];
|
|
248
|
+
const concealed = item.fields.filter((field) => field.kind === "concealed");
|
|
249
|
+
for (const field of item.fields) {
|
|
250
|
+
const key = nameKey(field.name);
|
|
251
|
+
if (field.kind === "text" && key === "username" && login === null) login = textValue(field);
|
|
252
|
+
else if (
|
|
253
|
+
field.kind === "concealed" &&
|
|
254
|
+
password === null &&
|
|
255
|
+
(key === "password" || concealed.length === 1)
|
|
256
|
+
) {
|
|
257
|
+
password = textValue(field);
|
|
258
|
+
} else if (field.kind === "totp" && otp === null) otp = totpValue(field, title);
|
|
259
|
+
else custom.push(customEntry(field, title));
|
|
260
|
+
}
|
|
261
|
+
const [first, ...rest] = item.websites.filter((url) => url.trim().length > 0);
|
|
262
|
+
for (const url of rest) custom.push({ type: "url", label: "website", value: [url] });
|
|
263
|
+
return {
|
|
264
|
+
type: "login",
|
|
265
|
+
title,
|
|
266
|
+
notes: "",
|
|
267
|
+
fields: [
|
|
268
|
+
{ type: "login", value: login === null ? [] : [login] },
|
|
269
|
+
{ type: "password", value: password === null ? [] : [password] },
|
|
270
|
+
{ type: "url", value: first ? [first] : [] },
|
|
271
|
+
{ type: "fileRef", value: [] },
|
|
272
|
+
{ type: "oneTimeCode", value: otp === null ? [] : [otp] },
|
|
273
|
+
],
|
|
274
|
+
custom,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
if (item.type === "note") {
|
|
278
|
+
let note: string | null = null;
|
|
279
|
+
const custom: KeeperFieldEntry[] = [];
|
|
280
|
+
for (const field of item.fields) {
|
|
281
|
+
if (note === null && (field.kind === "text" || field.kind === "concealed"))
|
|
282
|
+
note = textValue(field);
|
|
283
|
+
else custom.push(customEntry(field, title));
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
type: "encryptedNotes",
|
|
287
|
+
title,
|
|
288
|
+
notes: "",
|
|
289
|
+
fields: [
|
|
290
|
+
{ type: "note", value: note === null ? [] : [note] },
|
|
291
|
+
{ type: "date", value: [] },
|
|
292
|
+
],
|
|
293
|
+
custom,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
return unavailable(
|
|
297
|
+
item.type === "passkey"
|
|
298
|
+
? "Passkeys live only in Opengeni vaults"
|
|
299
|
+
: item.type === "api_key"
|
|
300
|
+
? "Keeper has no API key record type; create a login or a secure note"
|
|
301
|
+
: "Keeper file records cannot be created from Opengeni",
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function setEntryValue(entry: KeeperFieldEntry, field: ProviderWriteField, title: string) {
|
|
306
|
+
if (field.kind === "file") unavailable("Keeper files cannot be written from Opengeni");
|
|
307
|
+
if (TOTP_TYPES.has(entry.type)) {
|
|
308
|
+
entry.value = [totpValue(field, title)];
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const current = Array.isArray(entry.value) ? entry.value : [];
|
|
312
|
+
const scalar = current.length <= 1 && (current.length === 0 || typeof current[0] === "string");
|
|
313
|
+
if (!scalar || CONCEALED_COMPLEX.has(entry.type) || DATE_TYPES.has(entry.type)) {
|
|
314
|
+
unavailable(`"${field.name}" is a structured Keeper field; edit it in Keeper`);
|
|
315
|
+
}
|
|
316
|
+
entry.value = [field.kind === "totp" ? totpValue(field, title) : textValue(field)];
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Applies a field patch to a fetched record in place. Untouched fields are
|
|
321
|
+
* preserved; custom fields can be removed, standard fields are emptied.
|
|
322
|
+
*/
|
|
323
|
+
export function applyPatch(record: KeeperRecord, item: ProviderWriteItem): void {
|
|
324
|
+
const data = recordData(record);
|
|
325
|
+
data.fields = Array.isArray(data.fields) ? data.fields : [];
|
|
326
|
+
data.custom = Array.isArray(data.custom) ? data.custom : [];
|
|
327
|
+
const title = item.name;
|
|
328
|
+
const mapped = mapRecord(record).fields;
|
|
329
|
+
const removals: number[] = [];
|
|
330
|
+
for (const field of item.fields) {
|
|
331
|
+
const target = field.externalFieldId
|
|
332
|
+
? mapped.find((candidate) => candidate.externalFieldId === field.externalFieldId)
|
|
333
|
+
: mapped.find((candidate) => nameKey(candidate.name) === nameKey(field.name));
|
|
334
|
+
if (field.externalFieldId && !target) {
|
|
335
|
+
throw new VaultProviderError(
|
|
336
|
+
"provider_not_found",
|
|
337
|
+
`The Keeper record has no field ${field.externalFieldId}`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
if (!target) {
|
|
341
|
+
if (field.remove) continue;
|
|
342
|
+
data.custom.push(customEntry(field, title));
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
const source = target.source;
|
|
346
|
+
if (source.kind === "file") unavailable("Keeper files cannot be changed from Opengeni");
|
|
347
|
+
if (source.kind === "notes") {
|
|
348
|
+
data.notes = field.remove ? "" : textValue(field);
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
const entry = data[source.list]![source.index]!;
|
|
352
|
+
if (field.remove) {
|
|
353
|
+
if (source.list === "custom") removals.push(source.index);
|
|
354
|
+
else entry.value = [];
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
setEntryValue(entry, field, title);
|
|
358
|
+
}
|
|
359
|
+
for (const index of removals.sort((a, b) => b - a)) data.custom.splice(index, 1);
|
|
360
|
+
data.title = title;
|
|
361
|
+
const websites = item.websites.filter((url) => url.trim().length > 0);
|
|
362
|
+
const current = mapRecord(record).meta.websites;
|
|
363
|
+
if (JSON.stringify(websites) !== JSON.stringify(current)) {
|
|
364
|
+
const standard = data.fields.find((entry) => entry.type === "url");
|
|
365
|
+
data.custom = data.custom.filter(
|
|
366
|
+
(entry) => !(entry.type === "url" && entry.label === "website"),
|
|
367
|
+
);
|
|
368
|
+
const [first, ...rest] = websites;
|
|
369
|
+
if (standard) standard.value = first ? [first] : [];
|
|
370
|
+
else if (first) rest.unshift(first);
|
|
371
|
+
for (const url of rest) data.custom.push({ type: "url", label: "website", value: [url] });
|
|
372
|
+
}
|
|
373
|
+
}
|
package/src/naming.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type TotpParams, VAULT_LIMITS } from "./contract";
|
|
2
|
+
|
|
3
|
+
// Mirrors contracts isValidSecretName (Q-10): no / { } # \ or control chars,
|
|
4
|
+
// trimmed, 1..120 chars, not "id:…"; field names additionally may not be "otp".
|
|
5
|
+
const FORBIDDEN_NAME_CHARS = /[/{}#\\\u0000-\u001f\u007f-\u009f]/g;
|
|
6
|
+
|
|
7
|
+
export function nameKey(name: string): string {
|
|
8
|
+
return name.normalize("NFC").trim().toLowerCase();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function safeSecretName(raw: string, fallback: string): string {
|
|
12
|
+
const max = VAULT_LIMITS.nameMaxChars;
|
|
13
|
+
let name = raw.replace(FORBIDDEN_NAME_CHARS, "-").trim().slice(0, max).trim();
|
|
14
|
+
if (!name) name = fallback.replace(FORBIDDEN_NAME_CHARS, "-").trim().slice(0, max) || "item";
|
|
15
|
+
if (/^id:/i.test(name)) name = `id-${name.slice(3)}`;
|
|
16
|
+
return name;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function safeFieldName(raw: string, fallback: string): string {
|
|
20
|
+
const name = safeSecretName(raw, fallback);
|
|
21
|
+
return nameKey(name) === "otp" ? "otp-code" : name;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Appends " (2)", " (3)" … so names are unique case-insensitively. */
|
|
25
|
+
export function uniqueName(name: string, taken: Set<string>): string {
|
|
26
|
+
let candidate = name;
|
|
27
|
+
for (let n = 2; taken.has(nameKey(candidate)); n += 1) {
|
|
28
|
+
const suffix = ` (${n})`;
|
|
29
|
+
candidate = `${name.slice(0, VAULT_LIMITS.nameMaxChars - suffix.length)}${suffix}`;
|
|
30
|
+
}
|
|
31
|
+
taken.add(nameKey(candidate));
|
|
32
|
+
return candidate;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A canonical otpauth URI for a parsed seed, for stores that require URIs. */
|
|
36
|
+
export function totpUri(params: TotpParams, label: string): string {
|
|
37
|
+
const query = new URLSearchParams({
|
|
38
|
+
secret: params.secretBase32,
|
|
39
|
+
algorithm: params.algorithm,
|
|
40
|
+
digits: String(params.digits),
|
|
41
|
+
period: String(params.periodSeconds),
|
|
42
|
+
});
|
|
43
|
+
return `otpauth://totp/${encodeURIComponent(label || "Opengeni")}?${query.toString()}`;
|
|
44
|
+
}
|
|
@@ -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
|
+
}
|