@opengeni/vaults 3.1.2 → 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.
- package/README.md +14 -0
- package/dist/{chunk-7S23LHTK.js → chunk-BD43JBPC.js} +9 -2
- package/dist/chunk-BD43JBPC.js.map +1 -0
- package/dist/{chunk-VKPGG2FW.js → chunk-BEKN77JO.js} +2 -2
- package/dist/index.d.ts +3 -0
- package/dist/index.js +736 -2
- package/dist/index.js.map +1 -1
- package/dist/provider-capabilities.d.ts +4 -0
- package/dist/provider-pages.d.ts +56 -0
- package/dist/provider.d.ts +59 -3
- package/dist/remote-provider.d.ts +117 -0
- package/dist/testing/fake-provider.js +2 -2
- package/dist/testing/index.js +2 -2
- package/package.json +2 -2
- package/src/index.ts +3 -0
- package/src/provider-capabilities.ts +64 -0
- package/src/provider-pages.ts +206 -0
- package/src/provider.ts +84 -2
- package/src/remote-provider.ts +784 -0
- package/dist/chunk-7S23LHTK.js.map +0 -1
- /package/dist/{chunk-VKPGG2FW.js.map → chunk-BEKN77JO.js.map} +0 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic remote vault provider over the signed `opengeni.vault.v1` protocol.
|
|
3
|
+
*
|
|
4
|
+
* The HTTP transport is injected: the API and worker supply their DNS-pinned,
|
|
5
|
+
* HTTPS-only, no-redirect egress. This adapter only signs exact request
|
|
6
|
+
* bytes, enforces the response byte cap again, verifies the response MAC and
|
|
7
|
+
* strictly parses the operation result. It never retries a mutation: any
|
|
8
|
+
* response that cannot be verified after a write may have been applied and is
|
|
9
|
+
* reported as `provider_outcome_unknown`.
|
|
10
|
+
*/
|
|
11
|
+
import { type VaultProviderFreshness, type VaultProviderOperations } from "@opengeni/contracts";
|
|
12
|
+
import { type ProviderItemMeta, type ProviderPage, type ProviderPageRequest, type ProviderVault, type VaultProvider, type VaultProviderContext } from "./provider.js";
|
|
13
|
+
export type RemoteVaultProviderKind = "external" | "bitwarden";
|
|
14
|
+
/** Case-insensitive header lookup, satisfied by `Headers`. */
|
|
15
|
+
export interface RemoteVaultHeaders {
|
|
16
|
+
get(name: string): string | null;
|
|
17
|
+
}
|
|
18
|
+
export interface RemoteVaultHttpRequest {
|
|
19
|
+
url: string;
|
|
20
|
+
method: "POST";
|
|
21
|
+
headers: Readonly<Record<string, string>>;
|
|
22
|
+
/** Send exactly these bytes; the signature covers them. */
|
|
23
|
+
body: string;
|
|
24
|
+
/** Hard deadline for connect, send and the complete response body. */
|
|
25
|
+
timeoutMs: number;
|
|
26
|
+
/** Fail instead of reading more response body bytes than this. */
|
|
27
|
+
maxResponseBytes: number;
|
|
28
|
+
signal?: AbortSignal;
|
|
29
|
+
}
|
|
30
|
+
export interface RemoteVaultHttpResponse {
|
|
31
|
+
status: number;
|
|
32
|
+
headers: RemoteVaultHeaders;
|
|
33
|
+
body: string;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Bounded HTTP egress for remote vault calls. Implementations must refuse
|
|
37
|
+
* redirects, enforce `timeoutMs` and `maxResponseBytes`, and apply the
|
|
38
|
+
* deployment's endpoint policy (HTTPS, DNS pinning, private-address refusal).
|
|
39
|
+
*/
|
|
40
|
+
export interface RemoteVaultTransport {
|
|
41
|
+
post(request: RemoteVaultHttpRequest): Promise<RemoteVaultHttpResponse>;
|
|
42
|
+
}
|
|
43
|
+
export type RemoteVaultTransportFailure = "refused" | "network" | "timeout" | "redirect" | "too_large" | "aborted";
|
|
44
|
+
/**
|
|
45
|
+
* Transport failure. Set `requestSent: false` only when the transport knows
|
|
46
|
+
* no request bytes left the process (for example a refused DNS policy);
|
|
47
|
+
* otherwise a failed mutation is reported as an unknown outcome.
|
|
48
|
+
*/
|
|
49
|
+
export declare class RemoteVaultTransportError extends Error {
|
|
50
|
+
readonly failure: RemoteVaultTransportFailure;
|
|
51
|
+
readonly requestSent: boolean;
|
|
52
|
+
constructor(failure: RemoteVaultTransportFailure, requestSent: boolean);
|
|
53
|
+
}
|
|
54
|
+
export type RemoteVaultFetch = (url: string, init: {
|
|
55
|
+
method: "POST";
|
|
56
|
+
headers: Record<string, string>;
|
|
57
|
+
body: string;
|
|
58
|
+
redirect: "manual";
|
|
59
|
+
signal: AbortSignal;
|
|
60
|
+
}) => Promise<{
|
|
61
|
+
status: number;
|
|
62
|
+
type?: string;
|
|
63
|
+
headers: RemoteVaultHeaders;
|
|
64
|
+
body: ReadableStream<Uint8Array> | null;
|
|
65
|
+
}>;
|
|
66
|
+
/**
|
|
67
|
+
* Adapts a fetch-compatible function (for example the deployment's pinned
|
|
68
|
+
* fetch) to the bounded transport: manual redirects are refused, the whole
|
|
69
|
+
* exchange shares one deadline and the body is read with a byte cap.
|
|
70
|
+
*/
|
|
71
|
+
export declare function createFetchRemoteVaultTransport(fetchImpl: RemoteVaultFetch): RemoteVaultTransport;
|
|
72
|
+
export interface RemoteVaultProviderOptions {
|
|
73
|
+
provider: RemoteVaultProviderKind;
|
|
74
|
+
/** Registered endpoint. HTTPS only unless `allowInsecureHttp` (local/test). */
|
|
75
|
+
endpoint: string;
|
|
76
|
+
/** Per-connection secret; signs requests and derives the response key. */
|
|
77
|
+
signingSecret: string;
|
|
78
|
+
transport: RemoteVaultTransport;
|
|
79
|
+
/** The Opengeni connection id; must match `ctx.connectionId` when both exist. */
|
|
80
|
+
connectionId?: string;
|
|
81
|
+
/**
|
|
82
|
+
* Operations verified by the last describe (stored with the connection), so
|
|
83
|
+
* a fresh instance can answer `capabilities()` without a call. Without it,
|
|
84
|
+
* and before `validate`, the provider is read-only.
|
|
85
|
+
*/
|
|
86
|
+
operations?: VaultProviderOperations;
|
|
87
|
+
timeoutMs?: number;
|
|
88
|
+
allowInsecureHttp?: boolean;
|
|
89
|
+
now?: () => number;
|
|
90
|
+
newRequestId?: () => string;
|
|
91
|
+
}
|
|
92
|
+
export interface RemoteVaultValidation {
|
|
93
|
+
accountName: string | null;
|
|
94
|
+
accountId: string;
|
|
95
|
+
operations: VaultProviderOperations;
|
|
96
|
+
capabilities: {
|
|
97
|
+
totp: boolean;
|
|
98
|
+
files: boolean;
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
export interface RemoteVaultProvider extends VaultProvider {
|
|
102
|
+
readonly id: RemoteVaultProviderKind;
|
|
103
|
+
validate(ctx: VaultProviderContext): Promise<RemoteVaultValidation>;
|
|
104
|
+
listVaultPage(ctx: VaultProviderContext, page: ProviderPageRequest): Promise<ProviderPage<ProviderVault>>;
|
|
105
|
+
listItemPage(ctx: VaultProviderContext, externalVaultId: string, page: ProviderPageRequest): Promise<ProviderPage<ProviderItemMeta>>;
|
|
106
|
+
/** Deliberate upstream refresh. Returns the receiver's freshness. */
|
|
107
|
+
refresh(ctx: VaultProviderContext): Promise<VaultProviderFreshness | null>;
|
|
108
|
+
}
|
|
109
|
+
/** Operations a remote connection can never perform through this protocol. */
|
|
110
|
+
export declare const REMOTE_VAULT_READ_ONLY_OPERATIONS: VaultProviderOperations;
|
|
111
|
+
/** Clamp a described operation set to what the v1 wire can express. */
|
|
112
|
+
export declare function remoteVaultOperations(operations: VaultProviderOperations): VaultProviderOperations;
|
|
113
|
+
/**
|
|
114
|
+
* Create a provider for one registered remote connection. Construction
|
|
115
|
+
* validates the endpoint and secret; no request is sent until a method runs.
|
|
116
|
+
*/
|
|
117
|
+
export declare function createRemoteVaultProvider(options: RemoteVaultProviderOptions): RemoteVaultProvider;
|
package/dist/testing/index.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createFakeVaultProvider
|
|
3
|
-
} from "../chunk-
|
|
3
|
+
} from "../chunk-BEKN77JO.js";
|
|
4
4
|
import {
|
|
5
5
|
VaultProviderError,
|
|
6
6
|
createProviderCallBudget
|
|
7
|
-
} from "../chunk-
|
|
7
|
+
} from "../chunk-BD43JBPC.js";
|
|
8
8
|
|
|
9
9
|
// src/testing/static-resolver.ts
|
|
10
10
|
import {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeni/vaults",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.2",
|
|
4
4
|
"description": "Vault provider interface, registry, shell splicers and test doubles for Opengeni secrets.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"prepublishOnly": "bash ../../scripts/prepublish-guard"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@opengeni/contracts": "3.
|
|
45
|
+
"@opengeni/contracts": "3.2.2"
|
|
46
46
|
},
|
|
47
47
|
"engines": {
|
|
48
48
|
"node": ">=18"
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { VaultProviderOperations } from "@opengeni/contracts";
|
|
2
|
+
import type { ProviderVault, VaultProviderCapabilities } from "./provider";
|
|
3
|
+
|
|
4
|
+
/** Preserve the published legacy port, while new adapters declare exact operations. */
|
|
5
|
+
export function providerOperations(
|
|
6
|
+
capabilities: VaultProviderCapabilities,
|
|
7
|
+
vault?: Pick<ProviderVault, "operations" | "writeAccess">,
|
|
8
|
+
): VaultProviderOperations {
|
|
9
|
+
const base: VaultProviderOperations = capabilities.operations ?? {
|
|
10
|
+
createItemTypes: capabilities.write ? ["login", "api_key", "note"] : [],
|
|
11
|
+
updateItem: capabilities.write,
|
|
12
|
+
removeField: capabilities.write,
|
|
13
|
+
archiveItem: capabilities.write,
|
|
14
|
+
archiveKind: capabilities.write ? "archive" : "none",
|
|
15
|
+
deleteItem: false,
|
|
16
|
+
restoreItem: false,
|
|
17
|
+
readFiles: capabilities.files,
|
|
18
|
+
writeFiles: false,
|
|
19
|
+
createTotp: capabilities.write && capabilities.totp,
|
|
20
|
+
updateTotp: capabilities.write && capabilities.totp,
|
|
21
|
+
createVault: capabilities.createVault,
|
|
22
|
+
restoreFieldHistory: false,
|
|
23
|
+
};
|
|
24
|
+
const override = vault?.operations;
|
|
25
|
+
// An upstream vault may narrow its adapter's abilities, never widen them.
|
|
26
|
+
const operations: VaultProviderOperations = {
|
|
27
|
+
createItemTypes: base.createItemTypes.filter(
|
|
28
|
+
(type) => type !== "passkey" && (!override || override.createItemTypes.includes(type)),
|
|
29
|
+
),
|
|
30
|
+
updateItem: base.updateItem && (override?.updateItem ?? true),
|
|
31
|
+
removeField: base.removeField && (override?.removeField ?? true),
|
|
32
|
+
archiveItem: base.archiveItem && (override?.archiveItem ?? true),
|
|
33
|
+
archiveKind: base.archiveKind,
|
|
34
|
+
deleteItem: false,
|
|
35
|
+
restoreItem: base.restoreItem && (override?.restoreItem ?? true),
|
|
36
|
+
readFiles: base.readFiles && (override?.readFiles ?? true),
|
|
37
|
+
writeFiles: base.writeFiles && (override?.writeFiles ?? true),
|
|
38
|
+
createTotp: base.createTotp && (override?.createTotp ?? true),
|
|
39
|
+
updateTotp: base.updateTotp && (override?.updateTotp ?? true),
|
|
40
|
+
createVault: base.createVault && (override?.createVault ?? true),
|
|
41
|
+
restoreFieldHistory: base.restoreFieldHistory && (override?.restoreFieldHistory ?? true),
|
|
42
|
+
};
|
|
43
|
+
if (override && override.archiveKind !== base.archiveKind) operations.archiveItem = false;
|
|
44
|
+
if (operations.archiveKind === "none") operations.archiveItem = false;
|
|
45
|
+
if (!operations.archiveItem) operations.archiveKind = "none";
|
|
46
|
+
operations.removeField &&= operations.updateItem;
|
|
47
|
+
operations.updateTotp &&= operations.updateItem;
|
|
48
|
+
operations.createTotp &&= operations.createItemTypes.length > 0;
|
|
49
|
+
if (vault?.writeAccess !== "read_only" && capabilities.write) return operations;
|
|
50
|
+
return {
|
|
51
|
+
...operations,
|
|
52
|
+
createItemTypes: [],
|
|
53
|
+
updateItem: false,
|
|
54
|
+
removeField: false,
|
|
55
|
+
archiveItem: false,
|
|
56
|
+
archiveKind: "none",
|
|
57
|
+
restoreItem: false,
|
|
58
|
+
writeFiles: false,
|
|
59
|
+
createTotp: false,
|
|
60
|
+
updateTotp: false,
|
|
61
|
+
createVault: false,
|
|
62
|
+
restoreFieldHistory: false,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { VAULT_LIMITS } from "@opengeni/contracts";
|
|
2
|
+
import {
|
|
3
|
+
VaultProviderError,
|
|
4
|
+
type ProviderItemMeta,
|
|
5
|
+
type ProviderPage,
|
|
6
|
+
type ProviderPageRequest,
|
|
7
|
+
type ProviderVault,
|
|
8
|
+
type VaultProvider,
|
|
9
|
+
type VaultProviderContext,
|
|
10
|
+
} from "./provider";
|
|
11
|
+
|
|
12
|
+
export const PROVIDER_PAGE_SIZE = 100;
|
|
13
|
+
export const PROVIDER_MAX_PAGES = 100;
|
|
14
|
+
|
|
15
|
+
function incomplete(): never {
|
|
16
|
+
// Do not echo untrusted cursors, item names or response bodies in errors.
|
|
17
|
+
throw new VaultProviderError(
|
|
18
|
+
"provider_inventory_incomplete",
|
|
19
|
+
"The provider inventory could not be read completely. The previous inventory was kept.",
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A bounded provider inventory and what it proves (external vaults §8).
|
|
25
|
+
*/
|
|
26
|
+
export interface ProviderInventory<T> {
|
|
27
|
+
items: T[];
|
|
28
|
+
/**
|
|
29
|
+
* Whether the listing also proves which items are absent. It does when it
|
|
30
|
+
* was one response (a single page or a legacy array listing), or when
|
|
31
|
+
* every page carried the same provider snapshot token. Offset or position
|
|
32
|
+
* cursors without a snapshot can skip an existing item that moved across
|
|
33
|
+
* a page boundary while the pages were read (an earlier item was deleted
|
|
34
|
+
* meanwhile), so such a multi-page listing proves only what it contains.
|
|
35
|
+
* Never remove, or report as missing, anything absent from an inventory
|
|
36
|
+
* that is not consistent.
|
|
37
|
+
*/
|
|
38
|
+
consistent: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ProviderPageTraversal<T> {
|
|
42
|
+
page: (request: ProviderPageRequest) => Promise<ProviderPage<T>>;
|
|
43
|
+
identity: (item: T) => string;
|
|
44
|
+
maxItems: number;
|
|
45
|
+
signal?: AbortSignal;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A full traversal or a refusal (`provider_inventory_incomplete`), never a
|
|
50
|
+
* prefix presented as the whole. A caller may remove items absent from it
|
|
51
|
+
* only when it is `consistent`.
|
|
52
|
+
*/
|
|
53
|
+
export async function traverseProviderPages<T>(
|
|
54
|
+
input: ProviderPageTraversal<T>,
|
|
55
|
+
): Promise<ProviderInventory<T>> {
|
|
56
|
+
if (!Number.isSafeInteger(input.maxItems) || input.maxItems < 1 || input.maxItems > 10_000)
|
|
57
|
+
incomplete();
|
|
58
|
+
const items: T[] = [];
|
|
59
|
+
const ids = new Set<string>();
|
|
60
|
+
const cursors = new Set<string>();
|
|
61
|
+
let cursor: string | undefined;
|
|
62
|
+
let snapshot: string | null | undefined;
|
|
63
|
+
for (let count = 0; count < PROVIDER_MAX_PAGES; count++) {
|
|
64
|
+
input.signal?.throwIfAborted();
|
|
65
|
+
const limit = Math.min(PROVIDER_PAGE_SIZE, input.maxItems - items.length);
|
|
66
|
+
if (limit < 1) incomplete();
|
|
67
|
+
const page = await input.page({
|
|
68
|
+
limit,
|
|
69
|
+
...(cursor !== undefined ? { cursor } : {}),
|
|
70
|
+
...(snapshot != null ? { snapshot } : {}),
|
|
71
|
+
});
|
|
72
|
+
input.signal?.throwIfAborted();
|
|
73
|
+
if (
|
|
74
|
+
!page ||
|
|
75
|
+
!Array.isArray(page.items) ||
|
|
76
|
+
page.items.length > limit ||
|
|
77
|
+
(page.snapshot !== null &&
|
|
78
|
+
(typeof page.snapshot !== "string" ||
|
|
79
|
+
page.snapshot.length === 0 ||
|
|
80
|
+
page.snapshot.length > 1024)) ||
|
|
81
|
+
(page.nextCursor !== null &&
|
|
82
|
+
(typeof page.nextCursor !== "string" ||
|
|
83
|
+
page.nextCursor.length === 0 ||
|
|
84
|
+
page.nextCursor.length > 1024))
|
|
85
|
+
)
|
|
86
|
+
incomplete();
|
|
87
|
+
if (snapshot !== undefined && page.snapshot !== snapshot) incomplete();
|
|
88
|
+
snapshot = page.snapshot;
|
|
89
|
+
if (page.freshness && page.freshness.state !== "current") incomplete();
|
|
90
|
+
for (const item of page.items) {
|
|
91
|
+
const id = input.identity(item);
|
|
92
|
+
if (typeof id !== "string" || id.length === 0 || id.length > 300 || ids.has(id)) incomplete();
|
|
93
|
+
ids.add(id);
|
|
94
|
+
items.push(item);
|
|
95
|
+
}
|
|
96
|
+
// One page is one response; later pages are consistent only under the
|
|
97
|
+
// same snapshot (a changed snapshot was refused above).
|
|
98
|
+
if (page.nextCursor === null) return { items, consistent: count === 0 || snapshot !== null };
|
|
99
|
+
if (items.length >= input.maxItems || cursors.has(page.nextCursor)) incomplete();
|
|
100
|
+
cursors.add(page.nextCursor);
|
|
101
|
+
cursor = page.nextCursor;
|
|
102
|
+
}
|
|
103
|
+
return incomplete();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Enumeration: everything a complete bounded traversal listed, or a refusal.
|
|
108
|
+
* It does not say whether absence is proven; use `traverseProviderPages`
|
|
109
|
+
* before removing anything that is not listed.
|
|
110
|
+
*/
|
|
111
|
+
export async function collectProviderPages<T>(input: ProviderPageTraversal<T>): Promise<T[]> {
|
|
112
|
+
return (await traverseProviderPages(input)).items;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function validateLegacyInventory<T>(items: T[], identity: (item: T) => string, max: number): T[] {
|
|
116
|
+
if (!Array.isArray(items) || items.length > max) incomplete();
|
|
117
|
+
const ids = new Set<string>();
|
|
118
|
+
for (const item of items) {
|
|
119
|
+
const id = identity(item);
|
|
120
|
+
if (typeof id !== "string" || !id || id.length > 300 || ids.has(id)) incomplete();
|
|
121
|
+
ids.add(id);
|
|
122
|
+
}
|
|
123
|
+
return items;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The provider's vaults and whether absence is proven. Legacy array
|
|
128
|
+
* providers remain compatible (one response is consistent); new remote
|
|
129
|
+
* providers page natively.
|
|
130
|
+
*/
|
|
131
|
+
export async function readProviderVaultInventory(
|
|
132
|
+
provider: VaultProvider,
|
|
133
|
+
ctx: VaultProviderContext,
|
|
134
|
+
): Promise<ProviderInventory<ProviderVault>> {
|
|
135
|
+
const identity = (vault: ProviderVault) => vault.externalVaultId;
|
|
136
|
+
if (provider.listVaultPage) {
|
|
137
|
+
return traverseProviderPages({
|
|
138
|
+
page: async (page) => {
|
|
139
|
+
const result = await provider.listVaultPage!(ctx, page);
|
|
140
|
+
if (result.freshness) ctx.onFreshness?.(result.freshness);
|
|
141
|
+
return result;
|
|
142
|
+
},
|
|
143
|
+
identity,
|
|
144
|
+
maxItems: VAULT_LIMITS.vaultsPerOwnerScope,
|
|
145
|
+
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
ctx.signal?.throwIfAborted();
|
|
149
|
+
const items = await provider.listVaults(ctx);
|
|
150
|
+
ctx.signal?.throwIfAborted();
|
|
151
|
+
return {
|
|
152
|
+
items: validateLegacyInventory(items, identity, VAULT_LIMITS.vaultsPerOwnerScope),
|
|
153
|
+
consistent: true,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Enumeration of the provider's vaults (discovery and linking). */
|
|
158
|
+
export async function listProviderVaults(
|
|
159
|
+
provider: VaultProvider,
|
|
160
|
+
ctx: VaultProviderContext,
|
|
161
|
+
): Promise<ProviderVault[]> {
|
|
162
|
+
return (await readProviderVaultInventory(provider, ctx)).items;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* A provider vault's item metadata and whether absence is proven: only a
|
|
167
|
+
* consistent inventory may replace a mirror.
|
|
168
|
+
*/
|
|
169
|
+
export async function readProviderItemInventory(
|
|
170
|
+
provider: VaultProvider,
|
|
171
|
+
ctx: VaultProviderContext,
|
|
172
|
+
externalVaultId: string,
|
|
173
|
+
): Promise<ProviderInventory<ProviderItemMeta>> {
|
|
174
|
+
const identity = (item: ProviderItemMeta) => item.externalItemId;
|
|
175
|
+
if (provider.listItemPage) {
|
|
176
|
+
return traverseProviderPages({
|
|
177
|
+
page: async (page) => {
|
|
178
|
+
const result = await provider.listItemPage!(ctx, externalVaultId, page);
|
|
179
|
+
if (result.freshness) ctx.onFreshness?.(result.freshness);
|
|
180
|
+
return result;
|
|
181
|
+
},
|
|
182
|
+
identity,
|
|
183
|
+
maxItems: VAULT_LIMITS.itemsPerVault,
|
|
184
|
+
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
ctx.signal?.throwIfAborted();
|
|
188
|
+
const items = await provider.listItems(ctx, externalVaultId);
|
|
189
|
+
ctx.signal?.throwIfAborted();
|
|
190
|
+
return {
|
|
191
|
+
items: validateLegacyInventory(items, identity, VAULT_LIMITS.itemsPerVault),
|
|
192
|
+
consistent: true,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Enumeration of a provider vault's item metadata. It does not prove which
|
|
198
|
+
* items are absent; mirror replacement uses `readProviderItemInventory`.
|
|
199
|
+
*/
|
|
200
|
+
export async function listProviderItems(
|
|
201
|
+
provider: VaultProvider,
|
|
202
|
+
ctx: VaultProviderContext,
|
|
203
|
+
externalVaultId: string,
|
|
204
|
+
): Promise<ProviderItemMeta[]> {
|
|
205
|
+
return (await readProviderItemInventory(provider, ctx, externalVaultId)).items;
|
|
206
|
+
}
|
package/src/provider.ts
CHANGED
|
@@ -4,6 +4,8 @@ import type {
|
|
|
4
4
|
VaultFieldKind,
|
|
5
5
|
VaultItemType,
|
|
6
6
|
VaultSource,
|
|
7
|
+
VaultProviderOperations,
|
|
8
|
+
VaultProviderFreshness,
|
|
7
9
|
} from "@opengeni/contracts";
|
|
8
10
|
|
|
9
11
|
export interface VaultProviderCapabilities {
|
|
@@ -14,12 +16,16 @@ export interface VaultProviderCapabilities {
|
|
|
14
16
|
groupGrants: boolean;
|
|
15
17
|
/** Passkeys live only in Opengeni-hosted vaults (D20). */
|
|
16
18
|
passkeys: false;
|
|
19
|
+
/** Additive: older in-process providers retain the legacy capability flags. */
|
|
20
|
+
operations?: VaultProviderOperations;
|
|
17
21
|
}
|
|
18
22
|
export interface ProviderVault {
|
|
19
23
|
externalVaultId: string;
|
|
20
24
|
name: string;
|
|
21
25
|
contentVersion: string | null;
|
|
22
26
|
itemCount: number | null;
|
|
27
|
+
writeAccess?: "read_only" | "read_write" | "unknown";
|
|
28
|
+
operations?: VaultProviderOperations;
|
|
23
29
|
}
|
|
24
30
|
export interface ProviderFieldMeta {
|
|
25
31
|
externalFieldId: string;
|
|
@@ -63,13 +69,74 @@ export interface VaultProviderContext {
|
|
|
63
69
|
connectionId: string | null;
|
|
64
70
|
budget: ProviderCallBudget;
|
|
65
71
|
signal?: AbortSignal;
|
|
72
|
+
/** Trusted native scope, supplied by the authorized registry, never model arguments. */
|
|
73
|
+
authority?: VaultProviderAuthority;
|
|
74
|
+
/** Last successful mirror-check identity; changes invalidate attempt value caches. */
|
|
75
|
+
cacheEpoch?: string | null;
|
|
76
|
+
/** Recheck upstream, without retrying a mutation or bypassing provider throttling. */
|
|
77
|
+
forceRefresh?: boolean;
|
|
78
|
+
/** Stable caller operation identity when available. No automatic replay is implied. */
|
|
79
|
+
idempotencyKey?: string;
|
|
80
|
+
/** Runtime-only observation; never supplied by an agent or persisted with values. */
|
|
81
|
+
onFreshness?: (freshness: VaultProviderFreshness) => void;
|
|
66
82
|
}
|
|
83
|
+
|
|
84
|
+
export interface VaultProviderAuthority {
|
|
85
|
+
accountId: string;
|
|
86
|
+
workspaceId: string;
|
|
87
|
+
subjectId: string | null;
|
|
88
|
+
/** Where the encrypted connection is owned, not a replacement for the calling workspace. */
|
|
89
|
+
connectionWorkspaceId: string;
|
|
90
|
+
sessionId?: string;
|
|
91
|
+
turnId?: string;
|
|
92
|
+
attemptId?: string;
|
|
93
|
+
siteId?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface ProviderPageRequest {
|
|
97
|
+
cursor?: string;
|
|
98
|
+
limit: number;
|
|
99
|
+
snapshot?: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface ProviderPage<T> {
|
|
103
|
+
items: T[];
|
|
104
|
+
nextCursor: string | null;
|
|
105
|
+
/**
|
|
106
|
+
* Null only when upstream cannot supply snapshot consistency. A single page
|
|
107
|
+
* needs none, but a multi-page listing without a snapshot proves only what
|
|
108
|
+
* it contains: Opengeni lists it for discovery, yet never replaces a mirror
|
|
109
|
+
* from it (that would drop items skipped while the pages were read). Every
|
|
110
|
+
* page of one listing carries the same snapshot.
|
|
111
|
+
*/
|
|
112
|
+
snapshot: string | null;
|
|
113
|
+
freshness?: VaultProviderFreshness;
|
|
114
|
+
}
|
|
115
|
+
|
|
67
116
|
export interface VaultProvider {
|
|
68
|
-
readonly id:
|
|
117
|
+
readonly id: Exclude<VaultSource, "opengeni"> | "fake";
|
|
69
118
|
capabilities(): VaultProviderCapabilities;
|
|
70
|
-
validate(ctx: VaultProviderContext): Promise<{
|
|
119
|
+
validate(ctx: VaultProviderContext): Promise<{
|
|
120
|
+
accountName: string | null;
|
|
121
|
+
/** Stable identity, when supplied by the upstream account or bridge. */
|
|
122
|
+
accountId?: string;
|
|
123
|
+
}>;
|
|
124
|
+
/**
|
|
125
|
+
* One complete response, trusted as a consistent inventory. An adapter
|
|
126
|
+
* that pages upstream should implement `listVaultPage`/`listItemPage`, so
|
|
127
|
+
* page consistency is checked, rather than join pages here.
|
|
128
|
+
*/
|
|
71
129
|
listVaults(ctx: VaultProviderContext): Promise<ProviderVault[]>;
|
|
72
130
|
listItems(ctx: VaultProviderContext, externalVaultId: string): Promise<ProviderItemMeta[]>;
|
|
131
|
+
listVaultPage?(
|
|
132
|
+
ctx: VaultProviderContext,
|
|
133
|
+
page: ProviderPageRequest,
|
|
134
|
+
): Promise<ProviderPage<ProviderVault>>;
|
|
135
|
+
listItemPage?(
|
|
136
|
+
ctx: VaultProviderContext,
|
|
137
|
+
externalVaultId: string,
|
|
138
|
+
page: ProviderPageRequest,
|
|
139
|
+
): Promise<ProviderPage<ProviderItemMeta>>;
|
|
73
140
|
readFields(
|
|
74
141
|
ctx: VaultProviderContext,
|
|
75
142
|
externalVaultId: string,
|
|
@@ -105,6 +172,9 @@ export const VAULT_PROVIDER_ERROR_CODES = [
|
|
|
105
172
|
* service reports `vault_read_only` for writes, `vault_permission_denied` otherwise.
|
|
106
173
|
*/
|
|
107
174
|
"provider_permission_denied",
|
|
175
|
+
"provider_locked",
|
|
176
|
+
"provider_inventory_incomplete",
|
|
177
|
+
"provider_outcome_unknown",
|
|
108
178
|
] as const;
|
|
109
179
|
export type VaultProviderErrorCode = (typeof VAULT_PROVIDER_ERROR_CODES)[number];
|
|
110
180
|
|
|
@@ -149,6 +219,10 @@ export interface VaultProviderRegistry {
|
|
|
149
219
|
export function createVaultProviderRegistry(factories: {
|
|
150
220
|
onepassword?: (connectionId: string | null) => Promise<VaultProvider>;
|
|
151
221
|
host?: () => Promise<VaultProvider>;
|
|
222
|
+
external?: (connectionId: string | null) => Promise<VaultProvider>;
|
|
223
|
+
protonpass?: (connectionId: string | null) => Promise<VaultProvider>;
|
|
224
|
+
keeper?: (connectionId: string | null) => Promise<VaultProvider>;
|
|
225
|
+
bitwarden?: (connectionId: string | null) => Promise<VaultProvider>;
|
|
152
226
|
}): VaultProviderRegistry {
|
|
153
227
|
return {
|
|
154
228
|
async forVault(vault) {
|
|
@@ -156,6 +230,14 @@ export function createVaultProviderRegistry(factories: {
|
|
|
156
230
|
return factories.onepassword(vault.providerConnectionId);
|
|
157
231
|
}
|
|
158
232
|
if (vault.source === "host" && factories.host) return factories.host();
|
|
233
|
+
if (
|
|
234
|
+
vault.source !== "opengeni" &&
|
|
235
|
+
vault.source !== "host" &&
|
|
236
|
+
vault.source !== "onepassword"
|
|
237
|
+
) {
|
|
238
|
+
const factory = factories[vault.source];
|
|
239
|
+
if (factory) return factory(vault.providerConnectionId);
|
|
240
|
+
}
|
|
159
241
|
throw new VaultProviderError(
|
|
160
242
|
"provider_unavailable",
|
|
161
243
|
vault.source === "opengeni"
|