@opengeni/sdk 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/dist/artifacts.js +3 -3
- package/dist/browser.js +1 -1
- package/dist/chat/index.js +4 -4
- package/dist/{chunk-NEM5N6C6.js → chunk-JKG75TTU.js} +35 -1
- package/dist/chunk-JKG75TTU.js.map +1 -0
- package/dist/{chunk-YDVPI7WT.js → chunk-JP62FZSI.js} +2 -2
- package/dist/chunk-KGEABAID.js +71 -0
- package/dist/chunk-KGEABAID.js.map +1 -0
- package/dist/{chunk-S3D75WSQ.js → chunk-N2I422X4.js} +2 -2
- package/dist/{chunk-JXXCTW5W.js → chunk-PBVUAXSU.js} +2 -2
- package/dist/{chunk-OAKHWFSV.js → chunk-PDU6EU2M.js} +2 -2
- package/dist/{chunk-RJHCBZF7.js → chunk-VT4YM4XQ.js} +8 -68
- package/dist/{chunk-RJHCBZF7.js.map → chunk-VT4YM4XQ.js.map} +1 -1
- package/dist/core.js +2 -2
- package/dist/document-authority.js +2 -2
- package/dist/external-vault-receiver.d.ts +203 -0
- package/dist/external-vault-receiver.js +564 -0
- package/dist/external-vault-receiver.js.map +1 -0
- package/dist/index.js +13 -11
- package/dist/index.js.map +1 -1
- package/dist/site-bridge-host.js +2 -2
- package/dist/site-browser-runtime.gen.d.ts +1 -1
- package/dist/site-document.js +1 -1
- package/dist/site-document.js.map +1 -1
- package/dist/site.js +2 -2
- package/dist/vault-types.d.ts +91 -3
- package/dist/vaults-api.d.ts +9 -1
- package/dist/workspace-integrations.js +11 -9
- package/package.json +7 -3
- package/src/external-vault-receiver.ts +914 -0
- package/src/site-browser-runtime.gen.ts +1 -1
- package/src/vault-types.ts +88 -2
- package/src/vaults-api.ts +51 -0
- package/dist/chunk-NEM5N6C6.js.map +0 -1
- /package/dist/{chunk-YDVPI7WT.js.map → chunk-JP62FZSI.js.map} +0 -0
- /package/dist/{chunk-S3D75WSQ.js.map → chunk-N2I422X4.js.map} +0 -0
- /package/dist/{chunk-JXXCTW5W.js.map → chunk-PBVUAXSU.js.map} +0 -0
- /package/dist/{chunk-OAKHWFSV.js.map → chunk-PDU6EU2M.js.map} +0 -0
|
@@ -0,0 +1,914 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-only receiver for Opengeni's signed remote vault protocol
|
|
3
|
+
* (`opengeni.vault.v1`). Import from `@opengeni/sdk/external-vault-receiver`
|
|
4
|
+
* in the server that owns the customer's secrets; never in a browser bundle.
|
|
5
|
+
*
|
|
6
|
+
* Order of checks for every request: bounded body, connection lookup, exact
|
|
7
|
+
* body signature, strict schema, request age and replay, native authority
|
|
8
|
+
* bindings, then the host's own `authorize` mapping (a MAC is not an
|
|
9
|
+
* authorization decision), then the handler. Handler output is projected to
|
|
10
|
+
* the known wire fields and validated before it is signed, so metadata can
|
|
11
|
+
* never carry values and reads return only requested fields.
|
|
12
|
+
*
|
|
13
|
+
* Mutations are claimed in the caller-owned durable store under an
|
|
14
|
+
* idempotency key and an operation digest. A repeated key with the same
|
|
15
|
+
* digest returns the recorded result; a different digest is refused; an
|
|
16
|
+
* unfinished or uncertain claim reports `provider_outcome_unknown` until the
|
|
17
|
+
* host reconciles it. Nothing here retries a mutation.
|
|
18
|
+
*/
|
|
19
|
+
import {
|
|
20
|
+
OPENGENI_SIGNATURE_HEADER,
|
|
21
|
+
signOpenGeniPayload,
|
|
22
|
+
verifyOpenGeniSignature,
|
|
23
|
+
} from "@opengeni/contracts/workspace-integration-wire";
|
|
24
|
+
import {
|
|
25
|
+
VAULT_PROVIDER_WIRE_LIMITS,
|
|
26
|
+
VAULT_PROVIDER_WIRE_RESPONSE_SIGNATURE_HEADER,
|
|
27
|
+
VAULT_PROVIDER_WIRE_VERSION,
|
|
28
|
+
VaultProviderWireFreshness,
|
|
29
|
+
VaultProviderWireRequest,
|
|
30
|
+
VaultProviderWireResults,
|
|
31
|
+
isVaultProviderWireMutation,
|
|
32
|
+
vaultProviderWireNameKey,
|
|
33
|
+
vaultProviderWireResponseKey,
|
|
34
|
+
vaultProviderWireUtf8Bytes,
|
|
35
|
+
type VaultProviderWireAuthority,
|
|
36
|
+
type VaultProviderWireErrorCode,
|
|
37
|
+
type VaultProviderWireFailure,
|
|
38
|
+
type VaultProviderWireOperation,
|
|
39
|
+
type VaultProviderWireOperationName,
|
|
40
|
+
type VaultProviderWireOperationOf,
|
|
41
|
+
type VaultProviderWirePage,
|
|
42
|
+
type VaultProviderWireProvider,
|
|
43
|
+
type VaultProviderWireResult,
|
|
44
|
+
type VaultProviderWireSuccess,
|
|
45
|
+
type VaultProviderWireWriteItem,
|
|
46
|
+
} from "@opengeni/contracts/vault-provider-wire";
|
|
47
|
+
|
|
48
|
+
export {
|
|
49
|
+
VAULT_PROVIDER_WIRE_ERROR_CODES,
|
|
50
|
+
VAULT_PROVIDER_WIRE_LIMITS,
|
|
51
|
+
VAULT_PROVIDER_WIRE_RESPONSE_SIGNATURE_HEADER,
|
|
52
|
+
VAULT_PROVIDER_WIRE_VERSION,
|
|
53
|
+
vaultProviderWireResponseKey,
|
|
54
|
+
} from "@opengeni/contracts/vault-provider-wire";
|
|
55
|
+
export type {
|
|
56
|
+
VaultProviderWireAuthority,
|
|
57
|
+
VaultProviderWireDescribeResult,
|
|
58
|
+
VaultProviderWireErrorCode,
|
|
59
|
+
VaultProviderWireFieldMeta,
|
|
60
|
+
VaultProviderWireFieldValue,
|
|
61
|
+
VaultProviderWireFreshness,
|
|
62
|
+
VaultProviderWireItemMeta,
|
|
63
|
+
VaultProviderWireOperation,
|
|
64
|
+
VaultProviderWireOperationName,
|
|
65
|
+
VaultProviderWirePage,
|
|
66
|
+
VaultProviderWireProvider,
|
|
67
|
+
VaultProviderWireRequest,
|
|
68
|
+
VaultProviderWireResponse,
|
|
69
|
+
VaultProviderWireResult,
|
|
70
|
+
VaultProviderWireVault,
|
|
71
|
+
VaultProviderWireWriteField,
|
|
72
|
+
VaultProviderWireWriteItem,
|
|
73
|
+
} from "@opengeni/contracts/vault-provider-wire";
|
|
74
|
+
|
|
75
|
+
/** A definite, safe receiver failure. Its message is never sent to Opengeni. */
|
|
76
|
+
export class ExternalVaultReceiverError extends Error {
|
|
77
|
+
readonly code: VaultProviderWireErrorCode;
|
|
78
|
+
readonly retryAfterMs: number | null;
|
|
79
|
+
readonly freshness: VaultProviderWireFreshness | null;
|
|
80
|
+
constructor(
|
|
81
|
+
code: VaultProviderWireErrorCode,
|
|
82
|
+
options: {
|
|
83
|
+
retryAfterMs?: number;
|
|
84
|
+
freshness?: VaultProviderWireFreshness;
|
|
85
|
+
message?: string;
|
|
86
|
+
} = {},
|
|
87
|
+
) {
|
|
88
|
+
super(options.message ?? code);
|
|
89
|
+
this.name = "ExternalVaultReceiverError";
|
|
90
|
+
this.code = code;
|
|
91
|
+
this.retryAfterMs =
|
|
92
|
+
options.retryAfterMs === undefined
|
|
93
|
+
? null
|
|
94
|
+
: Math.min(Math.max(Math.trunc(options.retryAfterMs), 0), 86_400_000);
|
|
95
|
+
this.freshness = options.freshness ?? null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface ExternalVaultReceiverConnection<TConnection> {
|
|
100
|
+
provider: VaultProviderWireProvider;
|
|
101
|
+
/**
|
|
102
|
+
* Accepted signing secrets: the current one first, then a previous one
|
|
103
|
+
* during rotation. Responses are signed with the secret that verified.
|
|
104
|
+
*/
|
|
105
|
+
signingSecrets: readonly string[];
|
|
106
|
+
/** When set, requests for any other Opengeni account are refused. */
|
|
107
|
+
accountId?: string;
|
|
108
|
+
/** When set, requests for a connection owned by another workspace are refused. */
|
|
109
|
+
connectionWorkspaceId?: string;
|
|
110
|
+
/** Host data for this connection: its fixed inventory scope, not a user. */
|
|
111
|
+
context: TConnection;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type ExternalVaultMutationClaim =
|
|
115
|
+
| { state: "claimed" }
|
|
116
|
+
| { state: "completed"; result: unknown }
|
|
117
|
+
/** In flight, or uncertain after a failure: report an unknown outcome. */
|
|
118
|
+
| { state: "pending" }
|
|
119
|
+
/** Same key, different operation or caller. */
|
|
120
|
+
| { state: "mismatch" };
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Caller-owned replay and idempotency storage. Use durable shared storage
|
|
124
|
+
* (for example a database table with unique keys) when the receiver runs in
|
|
125
|
+
* more than one process or must survive restarts.
|
|
126
|
+
*/
|
|
127
|
+
export interface ExternalVaultReceiverStore {
|
|
128
|
+
/** Atomically record a request id. False when it was already recorded. */
|
|
129
|
+
rememberRequest(input: {
|
|
130
|
+
connectionId: string;
|
|
131
|
+
requestId: string;
|
|
132
|
+
expiresAt: Date;
|
|
133
|
+
}): Promise<boolean>;
|
|
134
|
+
/** Atomically claim a mutation key, or report its existing state. */
|
|
135
|
+
claimMutation(input: {
|
|
136
|
+
connectionId: string;
|
|
137
|
+
idempotencyKey: string;
|
|
138
|
+
digest: string;
|
|
139
|
+
expiresAt: Date;
|
|
140
|
+
}): Promise<ExternalVaultMutationClaim>;
|
|
141
|
+
/** Record the metadata-only result of a confirmed mutation. */
|
|
142
|
+
completeMutation(input: {
|
|
143
|
+
connectionId: string;
|
|
144
|
+
idempotencyKey: string;
|
|
145
|
+
digest: string;
|
|
146
|
+
result: unknown;
|
|
147
|
+
}): Promise<void>;
|
|
148
|
+
/** The handler definitely changed nothing: allow the key to be claimed again. */
|
|
149
|
+
releaseMutation(input: {
|
|
150
|
+
connectionId: string;
|
|
151
|
+
idempotencyKey: string;
|
|
152
|
+
digest: string;
|
|
153
|
+
}): Promise<void>;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface ExternalVaultHandlerOutput<R> {
|
|
157
|
+
result: R;
|
|
158
|
+
freshness?: VaultProviderWireFreshness | null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
type ReadContext = { forceRefresh: boolean; cacheEpoch: string | null };
|
|
162
|
+
|
|
163
|
+
export interface ExternalVaultCallContext<TConnection, TPrincipal> {
|
|
164
|
+
connectionId: string;
|
|
165
|
+
connection: TConnection;
|
|
166
|
+
/** The host principal returned by `authorize` for this exact request. */
|
|
167
|
+
principal: TPrincipal;
|
|
168
|
+
authority: VaultProviderWireAuthority;
|
|
169
|
+
requestId: string;
|
|
170
|
+
signal?: AbortSignal;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Inventory handlers receive the connection, not the acting principal: one
|
|
175
|
+
* connection has one fixed inventory, so listings must not vary by subject.
|
|
176
|
+
*/
|
|
177
|
+
export interface ExternalVaultInventoryContext<TConnection> {
|
|
178
|
+
connectionId: string;
|
|
179
|
+
connection: TConnection;
|
|
180
|
+
requestId: string;
|
|
181
|
+
signal?: AbortSignal;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface ExternalVaultReceiverHandlers<TConnection, TPrincipal> {
|
|
185
|
+
describe(
|
|
186
|
+
ctx: ExternalVaultInventoryContext<TConnection>,
|
|
187
|
+
): Promise<ExternalVaultHandlerOutput<VaultProviderWireResult<"describe">>>;
|
|
188
|
+
/** Deliberate upstream refresh; omit when the store is always live. */
|
|
189
|
+
refresh?(
|
|
190
|
+
ctx: ExternalVaultInventoryContext<TConnection>,
|
|
191
|
+
): Promise<ExternalVaultHandlerOutput<VaultProviderWireResult<"refresh">>>;
|
|
192
|
+
listVaults(
|
|
193
|
+
ctx: ExternalVaultInventoryContext<TConnection>,
|
|
194
|
+
input: { page: VaultProviderWirePage } & ReadContext,
|
|
195
|
+
): Promise<ExternalVaultHandlerOutput<VaultProviderWireResult<"list_vaults">>>;
|
|
196
|
+
listItems(
|
|
197
|
+
ctx: ExternalVaultInventoryContext<TConnection>,
|
|
198
|
+
input: { externalVaultId: string; page: VaultProviderWirePage } & ReadContext,
|
|
199
|
+
): Promise<ExternalVaultHandlerOutput<VaultProviderWireResult<"list_items">>>;
|
|
200
|
+
readFields(
|
|
201
|
+
ctx: ExternalVaultCallContext<TConnection, TPrincipal>,
|
|
202
|
+
input: {
|
|
203
|
+
externalVaultId: string;
|
|
204
|
+
externalItemId: string;
|
|
205
|
+
externalFieldIds: readonly string[];
|
|
206
|
+
} & ReadContext,
|
|
207
|
+
): Promise<ExternalVaultHandlerOutput<VaultProviderWireResult<"read_fields">>>;
|
|
208
|
+
/** Omit when the connection is read-only. Throw only definite failures. */
|
|
209
|
+
writeItem?(
|
|
210
|
+
ctx: ExternalVaultCallContext<TConnection, TPrincipal>,
|
|
211
|
+
input: { externalVaultId: string; item: VaultProviderWireWriteItem; idempotencyKey: string },
|
|
212
|
+
): Promise<ExternalVaultHandlerOutput<VaultProviderWireResult<"write_item">>>;
|
|
213
|
+
archiveItem?(
|
|
214
|
+
ctx: ExternalVaultCallContext<TConnection, TPrincipal>,
|
|
215
|
+
input: { externalVaultId: string; externalItemId: string; idempotencyKey: string },
|
|
216
|
+
): Promise<ExternalVaultHandlerOutput<VaultProviderWireResult<"archive_item">>>;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface ExternalVaultAuthorizeInput<TConnection> {
|
|
220
|
+
connectionId: string;
|
|
221
|
+
connection: TConnection;
|
|
222
|
+
authority: VaultProviderWireAuthority;
|
|
223
|
+
operation: {
|
|
224
|
+
op: VaultProviderWireOperationName;
|
|
225
|
+
externalVaultId?: string;
|
|
226
|
+
externalItemId?: string;
|
|
227
|
+
};
|
|
228
|
+
requestId: string;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export interface ExternalVaultReceiverOptions<TConnection, TPrincipal> {
|
|
232
|
+
/** Unknown connections are indistinguishable from bad signatures. */
|
|
233
|
+
resolveConnection(
|
|
234
|
+
connectionId: string,
|
|
235
|
+
): Promise<ExternalVaultReceiverConnection<TConnection> | null>;
|
|
236
|
+
/**
|
|
237
|
+
* Map Opengeni's native account/workspace/subject to the host tenant and
|
|
238
|
+
* user and recheck access for this operation. Return null to refuse.
|
|
239
|
+
* Called for every request, including inventory reads.
|
|
240
|
+
*/
|
|
241
|
+
authorize(input: ExternalVaultAuthorizeInput<TConnection>): Promise<TPrincipal | null>;
|
|
242
|
+
handlers: ExternalVaultReceiverHandlers<TConnection, TPrincipal>;
|
|
243
|
+
store: ExternalVaultReceiverStore;
|
|
244
|
+
/** How long mutation results stay replayable. Default 24 hours. */
|
|
245
|
+
mutationRetentionMs?: number;
|
|
246
|
+
now?: () => number;
|
|
247
|
+
/** Host diagnostics. Never forward these errors to Opengeni. */
|
|
248
|
+
onError?(event: {
|
|
249
|
+
stage: "authorize" | "handler" | "store" | "output";
|
|
250
|
+
op: VaultProviderWireOperationName;
|
|
251
|
+
connectionId: string;
|
|
252
|
+
error: unknown;
|
|
253
|
+
}): void;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export interface ExternalVaultReceiverHttpRequest {
|
|
257
|
+
/** The exact raw request body; parse nothing before calling `handle`. */
|
|
258
|
+
body: string;
|
|
259
|
+
headers: Headers | Record<string, string | string[] | undefined>;
|
|
260
|
+
signal?: AbortSignal;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export interface ExternalVaultReceiverHttpResponse {
|
|
264
|
+
status: number;
|
|
265
|
+
headers: Record<string, string>;
|
|
266
|
+
body: string;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export interface ExternalVaultReceiver {
|
|
270
|
+
handle(request: ExternalVaultReceiverHttpRequest): Promise<ExternalVaultReceiverHttpResponse>;
|
|
271
|
+
/** Fetch-style adapter: POST only, bounded body, then `handle`. */
|
|
272
|
+
fetch(request: Request): Promise<Response>;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const JSON_HEADERS = { "content-type": "application/json", "cache-control": "no-store" };
|
|
276
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
277
|
+
|
|
278
|
+
function plain(status: number, error: string): ExternalVaultReceiverHttpResponse {
|
|
279
|
+
return { status, headers: { ...JSON_HEADERS }, body: JSON.stringify({ error }) };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function headerValue(headers: ExternalVaultReceiverHttpRequest["headers"], name: string) {
|
|
283
|
+
if (headers instanceof Headers) return headers.get(name);
|
|
284
|
+
const wanted = name.toLowerCase();
|
|
285
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
286
|
+
if (key.toLowerCase() === wanted) {
|
|
287
|
+
return Array.isArray(value)
|
|
288
|
+
? value.length === 1
|
|
289
|
+
? (value[0] ?? null)
|
|
290
|
+
: null
|
|
291
|
+
: (value ?? null);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function canonicalJson(value: unknown): string {
|
|
298
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
299
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
300
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
301
|
+
.filter(([, entry]) => entry !== undefined)
|
|
302
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
303
|
+
return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`).join(",")}}`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function hmacHex(key: string, message: string): Promise<string> {
|
|
307
|
+
const encoder = new TextEncoder();
|
|
308
|
+
const cryptoKey = await crypto.subtle.importKey(
|
|
309
|
+
"raw",
|
|
310
|
+
encoder.encode(key),
|
|
311
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
312
|
+
false,
|
|
313
|
+
["sign"],
|
|
314
|
+
);
|
|
315
|
+
const mac = new Uint8Array(await crypto.subtle.sign("HMAC", cryptoKey, encoder.encode(message)));
|
|
316
|
+
return Array.from(mac, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Keyed digest of one mutation's exact scope: connection, provider, trusted
|
|
321
|
+
* caller tuple and operation (without its idempotency key). Keyed so the
|
|
322
|
+
* store never holds a plain hash of submitted secret values.
|
|
323
|
+
*/
|
|
324
|
+
export async function externalVaultMutationDigest(input: {
|
|
325
|
+
signingSecret: string;
|
|
326
|
+
request: VaultProviderWireRequest;
|
|
327
|
+
}): Promise<string> {
|
|
328
|
+
const { request } = input;
|
|
329
|
+
const { idempotencyKey: _ignored, ...operation } = request.operation as Extract<
|
|
330
|
+
VaultProviderWireOperation,
|
|
331
|
+
{ idempotencyKey: string }
|
|
332
|
+
>;
|
|
333
|
+
return hmacHex(
|
|
334
|
+
`${input.signingSecret}\u0000${VAULT_PROVIDER_WIRE_VERSION}.idempotency`,
|
|
335
|
+
canonicalJson({
|
|
336
|
+
connectionId: request.connectionId.toLowerCase(),
|
|
337
|
+
provider: request.provider,
|
|
338
|
+
accountId: request.authority.accountId,
|
|
339
|
+
workspaceId: request.authority.workspaceId,
|
|
340
|
+
subjectId: request.authority.subjectId,
|
|
341
|
+
connectionWorkspaceId: request.authority.connectionWorkspaceId,
|
|
342
|
+
operation,
|
|
343
|
+
}),
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function pick<T extends object, K extends keyof T>(value: T, keys: readonly K[]): Pick<T, K> {
|
|
348
|
+
const out = {} as Pick<T, K>;
|
|
349
|
+
for (const key of keys) if (value[key] !== undefined) out[key] = value[key];
|
|
350
|
+
return out;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Project handler output to known wire keys only; values never ride along. */
|
|
354
|
+
function project(op: VaultProviderWireOperationName, raw: unknown): unknown {
|
|
355
|
+
if (!raw || typeof raw !== "object") return raw;
|
|
356
|
+
const result = raw as Record<string, any>;
|
|
357
|
+
const field = (f: any) =>
|
|
358
|
+
f && typeof f === "object" ? pick(f, ["externalFieldId", "name", "kind"]) : f;
|
|
359
|
+
const item = (i: any) =>
|
|
360
|
+
i && typeof i === "object"
|
|
361
|
+
? {
|
|
362
|
+
...pick(i, ["externalItemId", "name", "type", "websites", "version", "updatedAt"]),
|
|
363
|
+
fields: Array.isArray(i.fields) ? i.fields.map(field) : i.fields,
|
|
364
|
+
}
|
|
365
|
+
: i;
|
|
366
|
+
const vault = (v: any) =>
|
|
367
|
+
v && typeof v === "object"
|
|
368
|
+
? pick(v, [
|
|
369
|
+
"externalVaultId",
|
|
370
|
+
"name",
|
|
371
|
+
"contentVersion",
|
|
372
|
+
"itemCount",
|
|
373
|
+
"writeAccess",
|
|
374
|
+
"operations",
|
|
375
|
+
])
|
|
376
|
+
: v;
|
|
377
|
+
switch (op) {
|
|
378
|
+
case "describe":
|
|
379
|
+
return pick(result, [
|
|
380
|
+
"accountId",
|
|
381
|
+
"accountName",
|
|
382
|
+
"operations",
|
|
383
|
+
"capabilities",
|
|
384
|
+
"inventoryScope",
|
|
385
|
+
]);
|
|
386
|
+
case "refresh":
|
|
387
|
+
return pick(result, ["refreshed"]);
|
|
388
|
+
case "list_vaults":
|
|
389
|
+
case "list_items":
|
|
390
|
+
return {
|
|
391
|
+
...pick(result, ["nextCursor", "snapshot"]),
|
|
392
|
+
items: Array.isArray(result.items)
|
|
393
|
+
? result.items.map(op === "list_vaults" ? vault : item)
|
|
394
|
+
: result.items,
|
|
395
|
+
};
|
|
396
|
+
case "read_fields":
|
|
397
|
+
return {
|
|
398
|
+
fields: Array.isArray(result.fields)
|
|
399
|
+
? result.fields.map((f: any) =>
|
|
400
|
+
f && typeof f === "object" ? pick(f, ["externalFieldId", "value"]) : f,
|
|
401
|
+
)
|
|
402
|
+
: result.fields,
|
|
403
|
+
};
|
|
404
|
+
case "write_item":
|
|
405
|
+
return { item: item(result.item) };
|
|
406
|
+
case "archive_item":
|
|
407
|
+
return pick(result, ["archived"]);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Mirrors Opengeni's secret-name rule: usable item/field names from upstream titles. */
|
|
412
|
+
export function safeExternalVaultName(raw: string, fallback = "Untitled"): string {
|
|
413
|
+
// oxlint-disable-next-line no-control-regex
|
|
414
|
+
const forbidden = /[/{}#\\\u0000-\u001f\u007f-\u009f]/g;
|
|
415
|
+
const clean = (value: string) =>
|
|
416
|
+
[...value.replace(forbidden, "-").trim()].slice(0, 120).join("").trim();
|
|
417
|
+
let name = clean(typeof raw === "string" ? raw : "");
|
|
418
|
+
if (!name) name = clean(fallback) || "Untitled";
|
|
419
|
+
if (/^id:/i.test(name)) name = `id-${name.slice(3)}`;
|
|
420
|
+
return name;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** A safe field name, unique case-insensitively within `taken` (which it updates). */
|
|
424
|
+
export function uniqueExternalVaultFieldName(raw: string, taken: Set<string>, fallback = "field") {
|
|
425
|
+
let base = safeExternalVaultName(raw, fallback);
|
|
426
|
+
if (vaultProviderWireNameKey(base) === "otp") base = "otp-code";
|
|
427
|
+
let candidate = base;
|
|
428
|
+
for (let n = 2; taken.has(vaultProviderWireNameKey(candidate)); n += 1) {
|
|
429
|
+
const suffix = ` (${n})`;
|
|
430
|
+
candidate = `${[...base].slice(0, 120 - suffix.length).join("")}${suffix}`;
|
|
431
|
+
}
|
|
432
|
+
taken.add(vaultProviderWireNameKey(candidate));
|
|
433
|
+
return candidate;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Process-local store for tests, development and a single-process bridge
|
|
438
|
+
* that accepts losing replay/idempotency memory on restart. It is not durable.
|
|
439
|
+
*/
|
|
440
|
+
export function createInMemoryExternalVaultReceiverStore(
|
|
441
|
+
options: { maxEntries?: number; now?: () => number } = {},
|
|
442
|
+
): ExternalVaultReceiverStore & { size(): number } {
|
|
443
|
+
const maxEntries = options.maxEntries ?? 10_000;
|
|
444
|
+
const now = options.now ?? (() => Date.now());
|
|
445
|
+
const requests = new Map<string, number>();
|
|
446
|
+
const mutations = new Map<
|
|
447
|
+
string,
|
|
448
|
+
{ digest: string; expiresAt: number; state: "pending" | "completed"; result?: unknown }
|
|
449
|
+
>();
|
|
450
|
+
const prune = () => {
|
|
451
|
+
const t = now();
|
|
452
|
+
for (const [key, expiresAt] of requests) if (expiresAt <= t) requests.delete(key);
|
|
453
|
+
for (const [key, entry] of mutations) if (entry.expiresAt <= t) mutations.delete(key);
|
|
454
|
+
};
|
|
455
|
+
const full = () => requests.size + mutations.size >= maxEntries;
|
|
456
|
+
return {
|
|
457
|
+
size: () => requests.size + mutations.size,
|
|
458
|
+
async rememberRequest({ connectionId, requestId, expiresAt }) {
|
|
459
|
+
prune();
|
|
460
|
+
const key = `${connectionId}\u0000${requestId}`;
|
|
461
|
+
if (requests.has(key)) return false;
|
|
462
|
+
// Refuse rather than forget: forgetting would reopen a replay window.
|
|
463
|
+
if (full()) throw new ExternalVaultReceiverError("provider_rate_limited");
|
|
464
|
+
requests.set(key, expiresAt.getTime());
|
|
465
|
+
return true;
|
|
466
|
+
},
|
|
467
|
+
async claimMutation({ connectionId, idempotencyKey, digest, expiresAt }) {
|
|
468
|
+
prune();
|
|
469
|
+
const key = `${connectionId}\u0000${idempotencyKey}`;
|
|
470
|
+
const existing = mutations.get(key);
|
|
471
|
+
if (existing) {
|
|
472
|
+
if (existing.digest !== digest) return { state: "mismatch" };
|
|
473
|
+
return existing.state === "completed"
|
|
474
|
+
? { state: "completed", result: structuredClone(existing.result) }
|
|
475
|
+
: { state: "pending" };
|
|
476
|
+
}
|
|
477
|
+
if (full()) throw new ExternalVaultReceiverError("provider_rate_limited");
|
|
478
|
+
mutations.set(key, { digest, expiresAt: expiresAt.getTime(), state: "pending" });
|
|
479
|
+
return { state: "claimed" };
|
|
480
|
+
},
|
|
481
|
+
async completeMutation({ connectionId, idempotencyKey, digest, result }) {
|
|
482
|
+
const entry = mutations.get(`${connectionId}\u0000${idempotencyKey}`);
|
|
483
|
+
if (!entry || entry.digest !== digest) throw new Error("mutation claim missing");
|
|
484
|
+
entry.state = "completed";
|
|
485
|
+
entry.result = structuredClone(result);
|
|
486
|
+
},
|
|
487
|
+
async releaseMutation({ connectionId, idempotencyKey, digest }) {
|
|
488
|
+
const key = `${connectionId}\u0000${idempotencyKey}`;
|
|
489
|
+
if (mutations.get(key)?.digest === digest) mutations.delete(key);
|
|
490
|
+
},
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function operationTarget(operation: VaultProviderWireOperation) {
|
|
495
|
+
return {
|
|
496
|
+
op: operation.op,
|
|
497
|
+
...("externalVaultId" in operation ? { externalVaultId: operation.externalVaultId } : {}),
|
|
498
|
+
...("externalItemId" in operation ? { externalItemId: operation.externalItemId } : {}),
|
|
499
|
+
...(operation.op === "write_item" && operation.item.externalItemId
|
|
500
|
+
? { externalItemId: operation.item.externalItemId }
|
|
501
|
+
: {}),
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
export function createExternalVaultReceiver<TConnection, TPrincipal>(
|
|
506
|
+
options: ExternalVaultReceiverOptions<TConnection, TPrincipal>,
|
|
507
|
+
): ExternalVaultReceiver {
|
|
508
|
+
const now = options.now ?? (() => Date.now());
|
|
509
|
+
const tolerance = VAULT_PROVIDER_WIRE_LIMITS.signatureToleranceSeconds;
|
|
510
|
+
const retentionMs = Math.max(options.mutationRetentionMs ?? 86_400_000, tolerance * 2_000);
|
|
511
|
+
const report = (
|
|
512
|
+
stage: "authorize" | "handler" | "store" | "output",
|
|
513
|
+
op: VaultProviderWireOperationName,
|
|
514
|
+
connectionId: string,
|
|
515
|
+
error: unknown,
|
|
516
|
+
) => {
|
|
517
|
+
try {
|
|
518
|
+
options.onError?.({ stage, op, connectionId, error });
|
|
519
|
+
} catch {
|
|
520
|
+
// Diagnostics never change the protocol outcome.
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
async function signed(
|
|
525
|
+
secret: string,
|
|
526
|
+
response: VaultProviderWireSuccess | VaultProviderWireFailure,
|
|
527
|
+
): Promise<ExternalVaultReceiverHttpResponse> {
|
|
528
|
+
const body = JSON.stringify(response);
|
|
529
|
+
const signature = await signOpenGeniPayload(
|
|
530
|
+
vaultProviderWireResponseKey(secret),
|
|
531
|
+
body,
|
|
532
|
+
Math.floor(now() / 1000),
|
|
533
|
+
);
|
|
534
|
+
return {
|
|
535
|
+
status: 200,
|
|
536
|
+
headers: { ...JSON_HEADERS, [VAULT_PROVIDER_WIRE_RESPONSE_SIGNATURE_HEADER]: signature },
|
|
537
|
+
body,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async function handle(
|
|
542
|
+
request: ExternalVaultReceiverHttpRequest,
|
|
543
|
+
): Promise<ExternalVaultReceiverHttpResponse> {
|
|
544
|
+
if (typeof request.body !== "string") return plain(400, "invalid_request");
|
|
545
|
+
if (vaultProviderWireUtf8Bytes(request.body) > VAULT_PROVIDER_WIRE_LIMITS.maxRequestBytes) {
|
|
546
|
+
return plain(413, "request_too_large");
|
|
547
|
+
}
|
|
548
|
+
const signature = headerValue(request.headers, OPENGENI_SIGNATURE_HEADER);
|
|
549
|
+
if (!signature) return plain(401, "unauthorized");
|
|
550
|
+
let raw: unknown;
|
|
551
|
+
try {
|
|
552
|
+
raw = JSON.parse(request.body);
|
|
553
|
+
} catch {
|
|
554
|
+
return plain(400, "invalid_request");
|
|
555
|
+
}
|
|
556
|
+
const claimedConnectionId =
|
|
557
|
+
raw && typeof raw === "object" ? (raw as { connectionId?: unknown }).connectionId : undefined;
|
|
558
|
+
if (typeof claimedConnectionId !== "string" || !UUID.test(claimedConnectionId)) {
|
|
559
|
+
return plain(401, "unauthorized");
|
|
560
|
+
}
|
|
561
|
+
const connectionId = claimedConnectionId.toLowerCase();
|
|
562
|
+
let connection: ExternalVaultReceiverConnection<TConnection> | null;
|
|
563
|
+
try {
|
|
564
|
+
connection = await options.resolveConnection(connectionId);
|
|
565
|
+
} catch (error) {
|
|
566
|
+
report("store", "describe", connectionId, error);
|
|
567
|
+
return plain(503, "unavailable");
|
|
568
|
+
}
|
|
569
|
+
if (!connection || connection.signingSecrets.length === 0) return plain(401, "unauthorized");
|
|
570
|
+
let secret: string | null = null;
|
|
571
|
+
for (const candidate of connection.signingSecrets.slice(0, 4)) {
|
|
572
|
+
if (
|
|
573
|
+
await verifyOpenGeniSignature({
|
|
574
|
+
secret: candidate,
|
|
575
|
+
body: request.body,
|
|
576
|
+
signature,
|
|
577
|
+
toleranceSeconds: tolerance,
|
|
578
|
+
nowSeconds: Math.floor(now() / 1000),
|
|
579
|
+
})
|
|
580
|
+
) {
|
|
581
|
+
secret = candidate;
|
|
582
|
+
break;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
if (!secret) return plain(401, "unauthorized");
|
|
586
|
+
const parsed = VaultProviderWireRequest.safeParse(raw);
|
|
587
|
+
if (!parsed.success) return plain(400, "invalid_request");
|
|
588
|
+
const wire = parsed.data;
|
|
589
|
+
const op = wire.operation.op;
|
|
590
|
+
const sentAt = Date.parse(wire.sentAt);
|
|
591
|
+
if (!Number.isFinite(sentAt) || Math.abs(now() - sentAt) > tolerance * 1000) {
|
|
592
|
+
return plain(401, "unauthorized");
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const failure = (
|
|
596
|
+
code: VaultProviderWireErrorCode,
|
|
597
|
+
extra: { retryAfterMs?: number | null; freshness?: VaultProviderWireFreshness | null } = {},
|
|
598
|
+
) =>
|
|
599
|
+
signed(secret!, {
|
|
600
|
+
version: VAULT_PROVIDER_WIRE_VERSION,
|
|
601
|
+
type: "response",
|
|
602
|
+
requestId: wire.requestId,
|
|
603
|
+
ok: false,
|
|
604
|
+
op,
|
|
605
|
+
error: { code, retryAfterMs: extra.retryAfterMs ?? null },
|
|
606
|
+
// Host-supplied freshness is validated like handler output; invalid is dropped.
|
|
607
|
+
freshness:
|
|
608
|
+
extra.freshness == null
|
|
609
|
+
? null
|
|
610
|
+
: (VaultProviderWireFreshness.safeParse(extra.freshness).data ?? null),
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
try {
|
|
614
|
+
const fresh = await options.store.rememberRequest({
|
|
615
|
+
connectionId,
|
|
616
|
+
requestId: wire.requestId,
|
|
617
|
+
expiresAt: new Date(now() + tolerance * 2_000),
|
|
618
|
+
});
|
|
619
|
+
if (!fresh) return plain(409, "replayed_request");
|
|
620
|
+
} catch (error) {
|
|
621
|
+
report("store", op, connectionId, error);
|
|
622
|
+
if (error instanceof ExternalVaultReceiverError) return failure(error.code);
|
|
623
|
+
return failure("provider_unavailable");
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (
|
|
627
|
+
wire.provider !== connection.provider ||
|
|
628
|
+
(connection.accountId !== undefined && wire.authority.accountId !== connection.accountId) ||
|
|
629
|
+
(connection.connectionWorkspaceId !== undefined &&
|
|
630
|
+
wire.authority.connectionWorkspaceId !== connection.connectionWorkspaceId)
|
|
631
|
+
) {
|
|
632
|
+
return failure("provider_permission_denied");
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
let principal: TPrincipal | null;
|
|
636
|
+
try {
|
|
637
|
+
principal = await options.authorize({
|
|
638
|
+
connectionId,
|
|
639
|
+
connection: connection.context,
|
|
640
|
+
authority: wire.authority,
|
|
641
|
+
operation: operationTarget(wire.operation),
|
|
642
|
+
requestId: wire.requestId,
|
|
643
|
+
});
|
|
644
|
+
} catch (error) {
|
|
645
|
+
report("authorize", op, connectionId, error);
|
|
646
|
+
if (error instanceof ExternalVaultReceiverError) return failure(error.code);
|
|
647
|
+
return failure("provider_unavailable");
|
|
648
|
+
}
|
|
649
|
+
if (principal === null || principal === undefined) {
|
|
650
|
+
return failure("provider_permission_denied");
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const inventory: ExternalVaultInventoryContext<TConnection> = {
|
|
654
|
+
connectionId,
|
|
655
|
+
connection: connection.context,
|
|
656
|
+
requestId: wire.requestId,
|
|
657
|
+
...(request.signal ? { signal: request.signal } : {}),
|
|
658
|
+
};
|
|
659
|
+
const call: ExternalVaultCallContext<TConnection, TPrincipal> = {
|
|
660
|
+
...inventory,
|
|
661
|
+
principal,
|
|
662
|
+
authority: wire.authority,
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
const respond = async (
|
|
666
|
+
output: ExternalVaultHandlerOutput<unknown>,
|
|
667
|
+
): Promise<
|
|
668
|
+
| { ok: true; response: ExternalVaultReceiverHttpResponse; result: unknown }
|
|
669
|
+
| { ok: false; code: VaultProviderWireErrorCode }
|
|
670
|
+
> => {
|
|
671
|
+
const projected = project(op, output?.result);
|
|
672
|
+
const result = VaultProviderWireResults[op].safeParse(projected);
|
|
673
|
+
const freshness =
|
|
674
|
+
output?.freshness == null ? null : VaultProviderWireFreshness.safeParse(output.freshness);
|
|
675
|
+
if (!result.success || (freshness && !freshness.success)) {
|
|
676
|
+
report("output", op, connectionId, result.error ?? freshness?.error);
|
|
677
|
+
return { ok: false, code: "provider_unavailable" };
|
|
678
|
+
}
|
|
679
|
+
const consistency = checkConsistency(wire.operation, result.data);
|
|
680
|
+
if (consistency) return { ok: false, code: consistency };
|
|
681
|
+
return {
|
|
682
|
+
ok: true,
|
|
683
|
+
result: result.data,
|
|
684
|
+
response: await signed(secret!, {
|
|
685
|
+
version: VAULT_PROVIDER_WIRE_VERSION,
|
|
686
|
+
type: "response",
|
|
687
|
+
requestId: wire.requestId,
|
|
688
|
+
ok: true,
|
|
689
|
+
op,
|
|
690
|
+
result: result.data,
|
|
691
|
+
freshness: freshness ? freshness.data : null,
|
|
692
|
+
} as VaultProviderWireSuccess),
|
|
693
|
+
};
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
if (!isVaultProviderWireMutation(op)) {
|
|
697
|
+
try {
|
|
698
|
+
const output = await runRead(wire.operation, inventory, call);
|
|
699
|
+
if (!output) return failure("provider_permission_denied");
|
|
700
|
+
const outcome = await respond(output);
|
|
701
|
+
return outcome.ok ? outcome.response : failure(outcome.code);
|
|
702
|
+
} catch (error) {
|
|
703
|
+
if (error instanceof ExternalVaultReceiverError) {
|
|
704
|
+
return failure(error.code, {
|
|
705
|
+
retryAfterMs: error.retryAfterMs,
|
|
706
|
+
freshness: error.freshness,
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
report("handler", op, connectionId, error);
|
|
710
|
+
return failure("provider_unavailable");
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const mutation = wire.operation as VaultProviderWireOperationOf<"write_item" | "archive_item">;
|
|
715
|
+
const handler =
|
|
716
|
+
mutation.op === "write_item" ? options.handlers.writeItem : options.handlers.archiveItem;
|
|
717
|
+
if (!handler) return failure("provider_permission_denied");
|
|
718
|
+
const digest = await externalVaultMutationDigest({ signingSecret: secret, request: wire });
|
|
719
|
+
const key = { connectionId, idempotencyKey: mutation.idempotencyKey, digest };
|
|
720
|
+
let claim: ExternalVaultMutationClaim;
|
|
721
|
+
try {
|
|
722
|
+
claim = await options.store.claimMutation({
|
|
723
|
+
...key,
|
|
724
|
+
expiresAt: new Date(now() + retentionMs),
|
|
725
|
+
});
|
|
726
|
+
} catch (error) {
|
|
727
|
+
report("store", op, connectionId, error);
|
|
728
|
+
// Nothing was claimed or run.
|
|
729
|
+
return failure(
|
|
730
|
+
error instanceof ExternalVaultReceiverError ? error.code : "provider_unavailable",
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
if (claim.state === "mismatch") return failure("provider_conflict");
|
|
734
|
+
if (claim.state === "pending") return failure("provider_outcome_unknown");
|
|
735
|
+
if (claim.state === "completed") {
|
|
736
|
+
const outcome = await respond({ result: claim.result });
|
|
737
|
+
return outcome.ok ? outcome.response : failure("provider_outcome_unknown");
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
let output: ExternalVaultHandlerOutput<unknown>;
|
|
741
|
+
try {
|
|
742
|
+
output =
|
|
743
|
+
mutation.op === "write_item"
|
|
744
|
+
? await options.handlers.writeItem!(call, {
|
|
745
|
+
externalVaultId: mutation.externalVaultId,
|
|
746
|
+
item: mutation.item,
|
|
747
|
+
idempotencyKey: mutation.idempotencyKey,
|
|
748
|
+
})
|
|
749
|
+
: await options.handlers.archiveItem!(call, {
|
|
750
|
+
externalVaultId: mutation.externalVaultId,
|
|
751
|
+
externalItemId: mutation.externalItemId,
|
|
752
|
+
idempotencyKey: mutation.idempotencyKey,
|
|
753
|
+
});
|
|
754
|
+
} catch (error) {
|
|
755
|
+
if (
|
|
756
|
+
error instanceof ExternalVaultReceiverError &&
|
|
757
|
+
error.code !== "provider_outcome_unknown"
|
|
758
|
+
) {
|
|
759
|
+
// Definite failure: the key may be used again.
|
|
760
|
+
try {
|
|
761
|
+
await options.store.releaseMutation(key);
|
|
762
|
+
} catch (storeError) {
|
|
763
|
+
report("store", op, connectionId, storeError);
|
|
764
|
+
}
|
|
765
|
+
return failure(error.code, {
|
|
766
|
+
retryAfterMs: error.retryAfterMs,
|
|
767
|
+
freshness: error.freshness,
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
// Unknown: keep the claim pending so a repeat cannot apply it twice.
|
|
771
|
+
report("handler", op, connectionId, error);
|
|
772
|
+
return failure("provider_outcome_unknown");
|
|
773
|
+
}
|
|
774
|
+
const outcome = await respond(output);
|
|
775
|
+
if (!outcome.ok) return failure("provider_outcome_unknown");
|
|
776
|
+
try {
|
|
777
|
+
await options.store.completeMutation({ ...key, result: outcome.result });
|
|
778
|
+
} catch (error) {
|
|
779
|
+
report("store", op, connectionId, error);
|
|
780
|
+
}
|
|
781
|
+
return outcome.response;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
async function runRead(
|
|
785
|
+
operation: VaultProviderWireOperation,
|
|
786
|
+
inventory: ExternalVaultInventoryContext<TConnection>,
|
|
787
|
+
call: ExternalVaultCallContext<TConnection, TPrincipal>,
|
|
788
|
+
): Promise<ExternalVaultHandlerOutput<unknown> | null> {
|
|
789
|
+
const handlers = options.handlers;
|
|
790
|
+
switch (operation.op) {
|
|
791
|
+
case "describe":
|
|
792
|
+
return handlers.describe(inventory);
|
|
793
|
+
case "refresh":
|
|
794
|
+
return handlers.refresh ? handlers.refresh(inventory) : null;
|
|
795
|
+
case "list_vaults":
|
|
796
|
+
return handlers.listVaults(inventory, {
|
|
797
|
+
page: operation.page,
|
|
798
|
+
forceRefresh: operation.forceRefresh,
|
|
799
|
+
cacheEpoch: operation.cacheEpoch,
|
|
800
|
+
});
|
|
801
|
+
case "list_items":
|
|
802
|
+
return handlers.listItems(inventory, {
|
|
803
|
+
externalVaultId: operation.externalVaultId,
|
|
804
|
+
page: operation.page,
|
|
805
|
+
forceRefresh: operation.forceRefresh,
|
|
806
|
+
cacheEpoch: operation.cacheEpoch,
|
|
807
|
+
});
|
|
808
|
+
case "read_fields":
|
|
809
|
+
return handlers.readFields(call, {
|
|
810
|
+
externalVaultId: operation.externalVaultId,
|
|
811
|
+
externalItemId: operation.externalItemId,
|
|
812
|
+
externalFieldIds: operation.externalFieldIds,
|
|
813
|
+
forceRefresh: operation.forceRefresh,
|
|
814
|
+
cacheEpoch: operation.cacheEpoch,
|
|
815
|
+
});
|
|
816
|
+
default:
|
|
817
|
+
return null;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
return {
|
|
822
|
+
handle,
|
|
823
|
+
async fetch(request) {
|
|
824
|
+
if (request.method !== "POST") {
|
|
825
|
+
return new Response(JSON.stringify({ error: "method_not_allowed" }), {
|
|
826
|
+
status: 405,
|
|
827
|
+
headers: { ...JSON_HEADERS, allow: "POST" },
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
const max = VAULT_PROVIDER_WIRE_LIMITS.maxRequestBytes;
|
|
831
|
+
const declared = Number(request.headers.get("content-length") ?? "0");
|
|
832
|
+
if (Number.isFinite(declared) && declared > max) {
|
|
833
|
+
await request.body?.cancel().catch(() => {});
|
|
834
|
+
const response = plain(413, "request_too_large");
|
|
835
|
+
return new Response(response.body, { status: response.status, headers: response.headers });
|
|
836
|
+
}
|
|
837
|
+
const chunks: Uint8Array[] = [];
|
|
838
|
+
let total = 0;
|
|
839
|
+
const reader = request.body?.getReader();
|
|
840
|
+
if (reader) {
|
|
841
|
+
for (;;) {
|
|
842
|
+
const { done, value } = await reader.read();
|
|
843
|
+
if (done) break;
|
|
844
|
+
total += value.byteLength;
|
|
845
|
+
if (total > max) {
|
|
846
|
+
await reader.cancel().catch(() => {});
|
|
847
|
+
const response = plain(413, "request_too_large");
|
|
848
|
+
return new Response(response.body, {
|
|
849
|
+
status: response.status,
|
|
850
|
+
headers: response.headers,
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
chunks.push(value);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
const bytes = new Uint8Array(total);
|
|
857
|
+
let offset = 0;
|
|
858
|
+
for (const chunk of chunks) {
|
|
859
|
+
bytes.set(chunk, offset);
|
|
860
|
+
offset += chunk.byteLength;
|
|
861
|
+
}
|
|
862
|
+
let body: string;
|
|
863
|
+
try {
|
|
864
|
+
body = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
865
|
+
} catch {
|
|
866
|
+
const response = plain(400, "invalid_request");
|
|
867
|
+
return new Response(response.body, { status: response.status, headers: response.headers });
|
|
868
|
+
}
|
|
869
|
+
const response = await handle({ body, headers: request.headers, signal: request.signal });
|
|
870
|
+
return new Response(response.body, { status: response.status, headers: response.headers });
|
|
871
|
+
},
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/** Cross-checks the schema cannot express on its own. */
|
|
876
|
+
function checkConsistency(
|
|
877
|
+
operation: VaultProviderWireOperation,
|
|
878
|
+
result: unknown,
|
|
879
|
+
): VaultProviderWireErrorCode | null {
|
|
880
|
+
switch (operation.op) {
|
|
881
|
+
case "list_vaults":
|
|
882
|
+
case "list_items": {
|
|
883
|
+
const page = result as VaultProviderWireResult<"list_items" | "list_vaults">;
|
|
884
|
+
if (page.items.length > operation.page.limit) return "provider_unavailable";
|
|
885
|
+
const ids = new Set<string>();
|
|
886
|
+
for (const entry of page.items as Array<{
|
|
887
|
+
externalVaultId?: string;
|
|
888
|
+
externalItemId?: string;
|
|
889
|
+
}>) {
|
|
890
|
+
const id = entry.externalVaultId ?? entry.externalItemId ?? "";
|
|
891
|
+
if (ids.has(id)) return "provider_unavailable";
|
|
892
|
+
ids.add(id);
|
|
893
|
+
}
|
|
894
|
+
return null;
|
|
895
|
+
}
|
|
896
|
+
case "read_fields": {
|
|
897
|
+
const fields = (result as VaultProviderWireResult<"read_fields">).fields;
|
|
898
|
+
const requested = new Set(operation.externalFieldIds);
|
|
899
|
+
if (fields.some((field) => !requested.has(field.externalFieldId))) {
|
|
900
|
+
return "provider_unavailable";
|
|
901
|
+
}
|
|
902
|
+
return fields.length === requested.size ? null : "provider_not_found";
|
|
903
|
+
}
|
|
904
|
+
case "write_item": {
|
|
905
|
+
const item = (result as VaultProviderWireResult<"write_item">).item;
|
|
906
|
+
const expected = operation.item.externalItemId;
|
|
907
|
+
return expected !== undefined && item.externalItemId !== expected
|
|
908
|
+
? "provider_outcome_unknown"
|
|
909
|
+
: null;
|
|
910
|
+
}
|
|
911
|
+
default:
|
|
912
|
+
return null;
|
|
913
|
+
}
|
|
914
|
+
}
|