@opengeni/core 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/core",
3
- "version": "3.1.2",
3
+ "version": "3.2.2",
4
4
  "description": "Opengeni framework-agnostic core: the domain, access, and billing layers (neutral access, off-HTTP V2 surface). Behavior-preserving extraction from apps/api — keeps Hono's HTTPException for error throwing (typed-errors cleanup deferred).",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -50,18 +50,18 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "@modelcontextprotocol/sdk": "^1.29.0",
53
- "@opengeni/capabilities": "3.1.2",
54
- "@opengeni/codex": "3.1.2",
55
- "@opengeni/config": "3.1.2",
56
- "@opengeni/contracts": "3.1.2",
57
- "@opengeni/db": "3.1.2",
58
- "@opengeni/documents": "3.1.2",
59
- "@opengeni/events": "3.1.2",
60
- "@opengeni/network": "3.1.2",
61
- "@opengeni/observability": "3.1.2",
62
- "@opengeni/runtime": "3.1.2",
63
- "@opengeni/storage": "3.1.2",
64
- "@opengeni/vaults": "3.1.2",
53
+ "@opengeni/capabilities": "3.2.2",
54
+ "@opengeni/codex": "3.2.2",
55
+ "@opengeni/config": "3.2.2",
56
+ "@opengeni/contracts": "3.2.2",
57
+ "@opengeni/db": "3.2.2",
58
+ "@opengeni/documents": "3.2.2",
59
+ "@opengeni/events": "3.2.2",
60
+ "@opengeni/network": "3.2.2",
61
+ "@opengeni/observability": "3.2.2",
62
+ "@opengeni/runtime": "3.2.2",
63
+ "@opengeni/storage": "3.2.2",
64
+ "@opengeni/vaults": "3.2.2",
65
65
  "hono": "^4.12.18",
66
66
  "zod": "^4.2.1"
67
67
  },
@@ -12,6 +12,7 @@ import type {
12
12
  import type { Database, SessionWorkflowWakeDeliveryResult } from "@opengeni/db";
13
13
  import type { VaultConnectionCredentialsPort } from "@opengeni/vaults";
14
14
  import type { OnePasswordVaultProviderFactory } from "./domain/vaults/provider-registry";
15
+ import type { ExternalVaultProviderFactories } from "./domain/vaults/external-provider-connections";
15
16
  import type { DocumentServices } from "@opengeni/documents";
16
17
  import type { EventBus } from "@opengeni/events";
17
18
  import type { Observability } from "@opengeni/observability";
@@ -214,6 +215,8 @@ export type AppDependencies = {
214
215
  * 1Password vaults report `provider_unavailable`.
215
216
  */
216
217
  onePasswordVaultProvider?: OnePasswordVaultProviderFactory | null;
218
+ /** Lazy native/remote vault adapters; null disables the first-wave integrations. */
219
+ externalVaultProviders?: ExternalVaultProviderFactories | null;
217
220
  /**
218
221
  * Optional embedding-host session ACL. Unset preserves standalone workspace
219
222
  * authorization; once bound, every session-addressed surface fails closed on
@@ -340,7 +340,10 @@ export function createRealtimeVoiceBilling(deps: { db: Database; settings: Setti
340
340
  }
341
341
  const attribution: CreditDebitAttribution =
342
342
  input.callerSubjectId === facts.ownerSubjectId ? input.attribution : { kind: "unknown" };
343
- const cost = realtimeVoiceMinuteCreditMicros(voice.pricing);
343
+ const cost = realtimeVoiceMinuteCreditMicros(
344
+ voice.pricing,
345
+ deps.settings.creditMarginBpsOverride,
346
+ );
344
347
  const minutes = facts.connections.flatMap((connection) =>
345
348
  Array.from(
346
349
  { length: realtimeVoiceStartedMinutes(connection.startedAt, connection.observedUntil) },
@@ -0,0 +1,395 @@
1
+ import { environmentsEncryptionKeyBytes, type Settings } from "@opengeni/config";
2
+ import { isDeepStrictEqual } from "node:util";
3
+ import {
4
+ ExternalVaultProviderKind,
5
+ VaultProviderCredentials,
6
+ VaultProviderOperations,
7
+ type ConnectionMetadata,
8
+ } from "@opengeni/contracts";
9
+ import {
10
+ encryptEnvironmentValue,
11
+ getConnectionMetadata,
12
+ getProviderConnectionHomeWorkspace,
13
+ loadConnectionCredentialForBroker,
14
+ updateConnection,
15
+ type Database,
16
+ } from "@opengeni/db";
17
+ import {
18
+ providerOperations,
19
+ VaultProviderError,
20
+ type VaultProvider,
21
+ type VaultProviderAuthority,
22
+ type VaultProviderContext,
23
+ } from "@opengeni/vaults";
24
+ import { z } from "zod";
25
+
26
+ export const EXTERNAL_VAULT_CREDENTIAL_ROLE = "external_vault_provider";
27
+
28
+ const ExternalVaultConnectionDetails = z
29
+ .object({
30
+ version: z.literal(1),
31
+ provider: ExternalVaultProviderKind,
32
+ ownership: z.enum(["user", "workspace", "organization"]),
33
+ accountId: z.string().min(1).max(300).nullable(),
34
+ accountName: z.string().max(300).nullable(),
35
+ label: z.string().max(120).nullable(),
36
+ verifiedAt: z.string().datetime(),
37
+ operations: VaultProviderOperations,
38
+ verificationStatus: z.enum(["ready", "pending"]).optional(),
39
+ })
40
+ .strict();
41
+ export type ExternalVaultConnectionDetails = z.infer<typeof ExternalVaultConnectionDetails>;
42
+
43
+ export function externalVaultProviderDomain(provider: ExternalVaultProviderKind): string {
44
+ return `vault:${provider}`;
45
+ }
46
+
47
+ /** Metadata is a projection, never a credential/configuration passthrough. */
48
+ export function externalVaultConnectionDetails(
49
+ metadata: Record<string, unknown> | null | undefined,
50
+ ): ExternalVaultConnectionDetails | null {
51
+ if (metadata?.credentialRole !== EXTERNAL_VAULT_CREDENTIAL_ROLE) return null;
52
+ const result = ExternalVaultConnectionDetails.safeParse(metadata.vaultProvider);
53
+ return result.success ? result.data : null;
54
+ }
55
+
56
+ export function isExternalVaultConnection(connection: ConnectionMetadata): boolean {
57
+ const details = externalVaultConnectionDetails(connection.metadata);
58
+ return (
59
+ details !== null &&
60
+ connection.kind === "api_key" &&
61
+ connection.providerDomain === externalVaultProviderDomain(details.provider) &&
62
+ (details.ownership === "user") === (connection.subjectId !== null)
63
+ );
64
+ }
65
+
66
+ /** Keeper bootstrap tokens are never a stored runtime credential. */
67
+ export const StoredVaultProviderCredentials = z.union([
68
+ VaultProviderCredentials.options[0],
69
+ VaultProviderCredentials.options[1],
70
+ VaultProviderCredentials.options[2],
71
+ z
72
+ .object({
73
+ provider: z.literal("keeper"),
74
+ configuration: z
75
+ .record(z.string().max(100), z.string().max(65_536))
76
+ .refine((value) => Object.keys(value).length <= 32),
77
+ })
78
+ .strict(),
79
+ ]);
80
+ export type StoredVaultProviderCredentials = z.infer<typeof StoredVaultProviderCredentials>;
81
+
82
+ const PendingVaultCredentialEnvelope = z
83
+ .object({
84
+ vaultCredentialEnvelope: z.literal(1),
85
+ current: StoredVaultProviderCredentials.nullable(),
86
+ pending: StoredVaultProviderCredentials,
87
+ })
88
+ .strict();
89
+
90
+ /** Encrypted only: preserve the working credential while a reconnect is checked. */
91
+ export function pendingVaultCredentialBundle(
92
+ current: StoredVaultProviderCredentials | null,
93
+ pending: StoredVaultProviderCredentials,
94
+ ): Record<string, unknown> {
95
+ return { vaultCredentialEnvelope: 1, current, pending };
96
+ }
97
+
98
+ export type ExternalVaultProviderFactory = (input: {
99
+ credentials: VaultProviderCredentials | StoredVaultProviderCredentials;
100
+ /** Stable native connection identity; adapter sessions must not cross it. */
101
+ connectionId?: string;
102
+ /** Includes credential binding and setup, before ordinary provider calls. */
103
+ signal?: AbortSignal;
104
+ /** Persist any later SDK key rotation before reporting successful readiness. */
105
+ persistCredentials?: (credentials: StoredVaultProviderCredentials) => Promise<void>;
106
+ /** Verified setup capabilities; the adapter may narrow, never widen them. */
107
+ operations?: VaultProviderOperations;
108
+ }) => Promise<{ provider: VaultProvider; credentials: StoredVaultProviderCredentials }>;
109
+
110
+ export type ExternalVaultProviderFactories = Partial<
111
+ Record<ExternalVaultProviderKind, ExternalVaultProviderFactory>
112
+ >;
113
+
114
+ export function externalVaultConnectionMetadata(
115
+ details: ExternalVaultConnectionDetails,
116
+ ): Record<string, unknown> {
117
+ return {
118
+ credentialRole: EXTERNAL_VAULT_CREDENTIAL_ROLE,
119
+ vaultProvider: ExternalVaultConnectionDetails.parse(details),
120
+ };
121
+ }
122
+
123
+ function unavailable(): VaultProviderError {
124
+ return new VaultProviderError(
125
+ "provider_auth_invalid",
126
+ "This vault connection was removed or is not available to you. Reconnect it in Vaults.",
127
+ );
128
+ }
129
+
130
+ /** Credentials can only be decrypted after the native reach and connection tuple agree. */
131
+ export async function loadExternalVaultConnection(
132
+ db: Database,
133
+ settings: Settings,
134
+ input: {
135
+ workspaceId: string;
136
+ connectionId: string;
137
+ subjectId: string | null;
138
+ provider: ExternalVaultProviderKind;
139
+ /** Setup-only recovery, after independent connection-management authorization. */
140
+ allowPendingVerification?: boolean;
141
+ },
142
+ ): Promise<{
143
+ connection: ConnectionMetadata;
144
+ details: ExternalVaultConnectionDetails;
145
+ credentials: StoredVaultProviderCredentials;
146
+ currentCredentials: StoredVaultProviderCredentials | null;
147
+ pendingCredentials: StoredVaultProviderCredentials | null;
148
+ }> {
149
+ let connection = await getConnectionMetadata(
150
+ db,
151
+ input.workspaceId,
152
+ input.connectionId,
153
+ input.subjectId,
154
+ );
155
+ if (!connection) {
156
+ const home = await getProviderConnectionHomeWorkspace(
157
+ db,
158
+ input.workspaceId,
159
+ input.connectionId,
160
+ input.subjectId,
161
+ );
162
+ if (home && home !== input.workspaceId)
163
+ connection = await getConnectionMetadata(db, home, input.connectionId, input.subjectId);
164
+ }
165
+ if (!connection || !isExternalVaultConnection(connection)) throw unavailable();
166
+ const details = externalVaultConnectionDetails(connection.metadata)!;
167
+ if (details.provider !== input.provider) throw unavailable();
168
+ if (
169
+ connection.status !== "active" &&
170
+ !(
171
+ input.allowPendingVerification &&
172
+ (connection.status === "needs_reauth" || connection.status === "error")
173
+ )
174
+ )
175
+ throw new VaultProviderError(
176
+ "provider_auth_expired",
177
+ "This vault connection needs to be reconnected.",
178
+ );
179
+ if (connection.subjectId !== null && connection.subjectId !== input.subjectId)
180
+ throw unavailable();
181
+ const loaded = await loadConnectionCredentialForBroker(db, settings, {
182
+ workspaceId: connection.workspaceId,
183
+ connectionId: connection.id,
184
+ providerDomain: externalVaultProviderDomain(input.provider),
185
+ kind: "api_key",
186
+ ...(connection.subjectId !== null
187
+ ? { allowSubjectOwned: true, subjectId: input.subjectId }
188
+ : {}),
189
+ });
190
+ // The broker decrypts by exact ID, so repeat tuple/status/version checks on
191
+ // that same read rather than trusting the earlier metadata snapshot.
192
+ if (
193
+ !loaded ||
194
+ loaded.status !== connection.status ||
195
+ loaded.version !== connection.version ||
196
+ loaded.accountId !== connection.accountId ||
197
+ loaded.subjectId !== connection.subjectId ||
198
+ loaded.providerDomain !== connection.providerDomain ||
199
+ loaded.kind !== "api_key" ||
200
+ externalVaultConnectionDetails(loaded.metadata)?.provider !== input.provider
201
+ )
202
+ throw unavailable();
203
+ const envelope = PendingVaultCredentialEnvelope.safeParse(loaded.credential);
204
+ const raw = envelope.success
205
+ ? input.allowPendingVerification
206
+ ? envelope.data.pending
207
+ : envelope.data.current
208
+ : loaded.credential;
209
+ const parsed = StoredVaultProviderCredentials.safeParse(raw);
210
+ if (!parsed.success || parsed.data.provider !== input.provider) throw unavailable();
211
+ if (
212
+ envelope.success &&
213
+ (envelope.data.pending.provider !== input.provider ||
214
+ (envelope.data.current !== null && envelope.data.current.provider !== input.provider))
215
+ )
216
+ throw unavailable();
217
+ return {
218
+ connection,
219
+ details,
220
+ credentials: parsed.data,
221
+ currentCredentials: envelope.success ? envelope.data.current : parsed.data,
222
+ pendingCredentials: envelope.success ? envelope.data.pending : null,
223
+ };
224
+ }
225
+
226
+ /** A bound adapter never accepts model-supplied identity, even via a copied context. */
227
+ export function bindVaultProviderAuthority(
228
+ provider: VaultProvider,
229
+ input: {
230
+ connectionId: string;
231
+ authority: VaultProviderAuthority;
232
+ operations: VaultProviderOperations;
233
+ revalidate: () => Promise<void>;
234
+ },
235
+ ): VaultProvider {
236
+ async function context(ctx: VaultProviderContext): Promise<VaultProviderContext> {
237
+ ctx.signal?.throwIfAborted();
238
+ await input.revalidate();
239
+ ctx.signal?.throwIfAborted();
240
+ return { ...ctx, connectionId: input.connectionId, authority: { ...input.authority } };
241
+ }
242
+ return {
243
+ id: provider.id,
244
+ capabilities: () => ({
245
+ ...provider.capabilities(),
246
+ operations: providerOperations(provider.capabilities(), { operations: input.operations }),
247
+ }),
248
+ validate: async (ctx) => provider.validate(await context(ctx)),
249
+ listVaults: async (ctx) => provider.listVaults(await context(ctx)),
250
+ listItems: async (ctx, vaultId) => provider.listItems(await context(ctx), vaultId),
251
+ readFields: async (ctx, vaultId, itemId, fields) =>
252
+ provider.readFields(await context(ctx), vaultId, itemId, fields),
253
+ writeItem: async (ctx, vaultId, item) => provider.writeItem(await context(ctx), vaultId, item),
254
+ archiveItem: async (ctx, vaultId, itemId) =>
255
+ provider.archiveItem(await context(ctx), vaultId, itemId),
256
+ ...(provider.listVaultPage
257
+ ? { listVaultPage: async (ctx, page) => provider.listVaultPage!(await context(ctx), page) }
258
+ : {}),
259
+ ...(provider.listItemPage
260
+ ? {
261
+ listItemPage: async (ctx, vaultId, page) =>
262
+ provider.listItemPage!(await context(ctx), vaultId, page),
263
+ }
264
+ : {}),
265
+ ...(provider.createVault
266
+ ? { createVault: async (ctx, value) => provider.createVault!(await context(ctx), value) }
267
+ : {}),
268
+ };
269
+ }
270
+
271
+ export async function externalVaultProviderForConnection(input: {
272
+ db: Database;
273
+ settings: Settings;
274
+ workspaceId: string;
275
+ subjectId: string | null;
276
+ connectionId: string;
277
+ provider: ExternalVaultProviderKind;
278
+ factory: ExternalVaultProviderFactory;
279
+ executionContext?: Pick<VaultProviderAuthority, "sessionId" | "turnId" | "attemptId" | "siteId">;
280
+ }): Promise<VaultProvider> {
281
+ const loaded = await loadExternalVaultConnection(input.db, input.settings, input);
282
+ let connection = loaded.connection;
283
+ const prepared = await input.factory({
284
+ credentials: loaded.credentials,
285
+ connectionId: connection.id,
286
+ signal: AbortSignal.timeout(30_000),
287
+ operations: loaded.details.operations,
288
+ persistCredentials: async (next) => {
289
+ const parsed = StoredVaultProviderCredentials.safeParse(next);
290
+ if (!parsed.success || parsed.data.provider !== loaded.details.provider) throw unavailable();
291
+ const credentials = parsed.data;
292
+ const key = environmentsEncryptionKeyBytes(input.settings);
293
+ if (!key)
294
+ throw new VaultProviderError(
295
+ "provider_unavailable",
296
+ "Vault credential encryption is unavailable.",
297
+ );
298
+ const updated = await updateConnection(input.db, {
299
+ workspaceId: connection.workspaceId,
300
+ connectionId: connection.id,
301
+ visibleToSubjectId: input.subjectId,
302
+ expectedVersion: connection.version,
303
+ credentialEncrypted: encryptEnvironmentValue(
304
+ key,
305
+ JSON.stringify(
306
+ loaded.pendingCredentials
307
+ ? pendingVaultCredentialBundle(credentials, loaded.pendingCredentials)
308
+ : credentials,
309
+ ),
310
+ ),
311
+ });
312
+ if (updated) {
313
+ connection = updated;
314
+ return;
315
+ }
316
+ // Another API/worker may already have persisted the identical SDK key
317
+ // rotation. Reconcile that exact result without retrying a stale write.
318
+ // Loading again rechecks current reach, status, ownership and credentials.
319
+ const live = await loadExternalVaultConnection(input.db, input.settings, input);
320
+ if (
321
+ isDeepStrictEqual(
322
+ rotationConnectionIdentity(live.connection),
323
+ rotationConnectionIdentity(connection),
324
+ ) &&
325
+ isDeepStrictEqual(live.currentCredentials, credentials) &&
326
+ isDeepStrictEqual(live.pendingCredentials, loaded.pendingCredentials)
327
+ ) {
328
+ connection = live.connection;
329
+ return;
330
+ }
331
+ throw new VaultProviderError(
332
+ "provider_conflict",
333
+ "This vault connection changed while its credentials were renewed.",
334
+ );
335
+ },
336
+ });
337
+ if (prepared.provider.id !== input.provider) throw unavailable();
338
+ return bindVaultProviderAuthority(prepared.provider, {
339
+ connectionId: connection.id,
340
+ authority: {
341
+ accountId: connection.accountId,
342
+ workspaceId: input.workspaceId,
343
+ connectionWorkspaceId: connection.workspaceId,
344
+ subjectId: input.subjectId,
345
+ ...input.executionContext,
346
+ },
347
+ operations: loaded.details.operations,
348
+ revalidate: async () => {
349
+ // Revalidate cross-workspace reach as well as the exact connection generation.
350
+ if (connection.workspaceId !== input.workspaceId) {
351
+ const home = await getProviderConnectionHomeWorkspace(
352
+ input.db,
353
+ input.workspaceId,
354
+ connection.id,
355
+ input.subjectId,
356
+ );
357
+ if (home !== connection.workspaceId) throw unavailable();
358
+ }
359
+ const live = await getConnectionMetadata(
360
+ input.db,
361
+ connection.workspaceId,
362
+ connection.id,
363
+ input.subjectId,
364
+ );
365
+ if (
366
+ !live ||
367
+ !isExternalVaultConnection(live) ||
368
+ live.status !== "active" ||
369
+ live.accountId !== connection.accountId ||
370
+ live.subjectId !== connection.subjectId ||
371
+ live.providerDomain !== connection.providerDomain
372
+ )
373
+ throw unavailable();
374
+ if (live.version !== connection.version)
375
+ throw new VaultProviderError(
376
+ "provider_conflict",
377
+ "This vault connection changed. Reload it before starting another operation.",
378
+ );
379
+ },
380
+ });
381
+ }
382
+
383
+ /** Credential rotation changes generations and observation timestamps, not identity or grants. */
384
+ function rotationConnectionIdentity(connection: ConnectionMetadata) {
385
+ const {
386
+ version: _version,
387
+ connectionAuthorityGeneration: _authorityGeneration,
388
+ updatedAt: _updatedAt,
389
+ updatedBySubjectId: _updatedBy,
390
+ lastUsedAt: _lastUsedAt,
391
+ lastRefreshAt: _lastRefreshAt,
392
+ ...identity
393
+ } = connection;
394
+ return identity;
395
+ }
@@ -2,6 +2,9 @@
2
2
  export { buildMissingSecretAskCard } from "@opengeni/contracts";
3
3
  export { isVaultError, VaultError, vaultErrorFromDatabase } from "@opengeni/db";
4
4
  export {
5
+ intersectProviderOperations,
6
+ providerOperationsForMode,
7
+ storedProviderOperations,
5
8
  toVaultItem,
6
9
  toVaultItemSummary,
7
10
  toVaultSummary,
@@ -10,6 +13,11 @@ export {
10
13
  vaultItemReferences,
11
14
  type VaultCallerAuthority,
12
15
  } from "./mapping";
16
+ export {
17
+ effectiveProviderOperations,
18
+ type ProviderWriteOperation,
19
+ type ProviderWriteOutcome,
20
+ } from "./provider-writes";
13
21
  export {
14
22
  chooseSecretCandidate,
15
23
  createSecretResolver,
@@ -6,10 +6,12 @@
6
6
  import {
7
7
  formatSecretReference,
8
8
  secretNameKey,
9
+ VaultProviderOperations,
9
10
  type VaultItem,
10
11
  type VaultItemField,
11
12
  type VaultItemReferences,
12
13
  type VaultItemSummary,
14
+ type VaultMode,
13
15
  type VaultSummary,
14
16
  } from "@opengeni/contracts";
15
17
  import {
@@ -18,6 +20,7 @@ import {
18
20
  type VaultItemRow,
19
21
  type VaultRow,
20
22
  } from "@opengeni/db";
23
+ import { providerVaultFreshness } from "./provider-sync";
21
24
 
22
25
  /** What the caller may do beyond reading; decided by the route's grant. */
23
26
  export type VaultCallerAuthority = {
@@ -27,9 +30,90 @@ export type VaultCallerAuthority = {
27
30
  accountAdmin: boolean;
28
31
  };
29
32
 
33
+ /** No writes at all: what a read-only link or a read-only provider vault allows. */
34
+ export function withoutProviderWrites(
35
+ operations: VaultProviderOperations,
36
+ ): VaultProviderOperations {
37
+ return {
38
+ ...operations,
39
+ createItemTypes: [],
40
+ updateItem: false,
41
+ removeField: false,
42
+ archiveItem: false,
43
+ archiveKind: "none",
44
+ deleteItem: false,
45
+ restoreItem: false,
46
+ writeFiles: false,
47
+ createTotp: false,
48
+ updateTotp: false,
49
+ createVault: false,
50
+ restoreFieldHistory: false,
51
+ };
52
+ }
53
+
54
+ /** Operations both sides allow (an archive needs both to agree on its kind). */
55
+ export function intersectProviderOperations(
56
+ left: VaultProviderOperations,
57
+ right: VaultProviderOperations,
58
+ ): VaultProviderOperations {
59
+ const archiveItem =
60
+ left.archiveItem &&
61
+ right.archiveItem &&
62
+ left.archiveKind === right.archiveKind &&
63
+ left.archiveKind !== "none";
64
+ return {
65
+ createItemTypes: left.createItemTypes.filter(
66
+ (type) => type !== "passkey" && right.createItemTypes.includes(type),
67
+ ),
68
+ updateItem: left.updateItem && right.updateItem,
69
+ removeField: left.removeField && right.removeField,
70
+ archiveItem,
71
+ archiveKind: archiveItem ? left.archiveKind : "none",
72
+ deleteItem: false,
73
+ restoreItem: left.restoreItem && right.restoreItem,
74
+ readFiles: left.readFiles && right.readFiles,
75
+ writeFiles: left.writeFiles && right.writeFiles,
76
+ createTotp: left.createTotp && right.createTotp,
77
+ updateTotp: left.updateTotp && right.updateTotp,
78
+ createVault: left.createVault && right.createVault,
79
+ restoreFieldHistory: left.restoreFieldHistory && right.restoreFieldHistory,
80
+ };
81
+ }
82
+
83
+ /**
84
+ * What a linked vault allows: the operations its provider last reported for
85
+ * it, narrowed by the link mode. Null for hosted vaults and for links not yet
86
+ * checked since operations were recorded (callers then ask the provider).
87
+ */
88
+ export function storedProviderOperations(
89
+ row: Pick<VaultRow, "source" | "mode"> & {
90
+ providerOperations?: VaultProviderOperations | null;
91
+ },
92
+ ): VaultProviderOperations | null {
93
+ if (row.source === "opengeni" || !row.providerOperations) return null;
94
+ // Stored JSON is checked against the contract before it grants anything.
95
+ const parsed = VaultProviderOperations.safeParse(row.providerOperations);
96
+ return parsed.success ? providerOperationsForMode(parsed.data, row.mode) : null;
97
+ }
98
+
99
+ export function providerOperationsForMode(
100
+ operations: VaultProviderOperations,
101
+ mode: VaultMode,
102
+ ): VaultProviderOperations {
103
+ const normalized = intersectProviderOperations(operations, operations);
104
+ return mode === "read_write" ? normalized : withoutProviderWrites(normalized);
105
+ }
106
+
107
+ function providerAllowsAnyWrite(operations: VaultProviderOperations | null): boolean {
108
+ if (!operations) return true;
109
+ return operations.createItemTypes.length > 0 || operations.updateItem || operations.archiveItem;
110
+ }
111
+
30
112
  export function vaultCanWrite(row: VaultRow, authority: VaultCallerAuthority): boolean {
31
113
  // Read-write connected vaults are edited through their provider (W5, OP-3).
32
114
  if (row.mode !== "read_write" || !authority.write) return false;
115
+ // A provider that reported no item writes for this vault makes it read-only here too.
116
+ if (!providerAllowsAnyWrite(storedProviderOperations(row))) return false;
33
117
  if (row.ownerKind === "user") return row.ownedByCaller;
34
118
  return true;
35
119
  }
@@ -47,7 +131,15 @@ export function vaultCanManage(row: VaultRow, authority: VaultCallerAuthority):
47
131
  }
48
132
  }
49
133
 
50
- export function toVaultSummary(row: VaultRow, authority: VaultCallerAuthority): VaultSummary {
134
+ export function toVaultSummary(
135
+ row: VaultRow,
136
+ authority: VaultCallerAuthority,
137
+ options: { now?: Date; refreshing?: boolean } = {},
138
+ ): VaultSummary {
139
+ const operations = storedProviderOperations(row);
140
+ const freshness = providerVaultFreshness(row, options.now ?? new Date(), {
141
+ ...(options.refreshing ? { refreshing: true } : {}),
142
+ });
51
143
  return {
52
144
  id: row.id,
53
145
  workspaceId: row.workspaceId,
@@ -64,6 +156,8 @@ export function toVaultSummary(row: VaultRow, authority: VaultCallerAuthority):
64
156
  lastSyncedAt: row.lastSyncedAt,
65
157
  rateLimitedUntil: row.rateLimitedUntil,
66
158
  providerConnectionId: row.providerConnectionId,
159
+ ...(operations ? { providerOperations: operations } : {}),
160
+ ...(freshness ? { freshness } : {}),
67
161
  availability: row.availability,
68
162
  isDefault: row.isDefault,
69
163
  itemCount: Number(row.itemCount),
@@ -12,7 +12,12 @@ import {
12
12
  type VaultProvider,
13
13
  type VaultProviderErrorCode,
14
14
  type VaultProviderRegistry,
15
+ type VaultProviderAuthority,
15
16
  } from "@opengeni/vaults";
17
+ import {
18
+ externalVaultProviderForConnection,
19
+ type ExternalVaultProviderFactories,
20
+ } from "./external-provider-connections";
16
21
 
17
22
  /**
18
23
  * Provider error codes as vault API error codes (§10B.1.1). `provider_not_found`
@@ -170,6 +175,8 @@ export function createWorkspaceVaultProviderRegistry(input: {
170
175
  subjectId: string | null;
171
176
  hostVaultProvider?: VaultProvider | null;
172
177
  onePasswordVaultProvider?: OnePasswordVaultProviderFactory | null;
178
+ externalVaultProviders?: ExternalVaultProviderFactories | null;
179
+ executionContext?: Pick<VaultProviderAuthority, "sessionId" | "turnId" | "attemptId" | "siteId">;
173
180
  }): VaultProviderRegistry {
174
181
  const factory = input.onePasswordVaultProvider;
175
182
  const host = input.hostVaultProvider;
@@ -197,5 +204,27 @@ export function createWorkspaceVaultProviderRegistry(input: {
197
204
  }
198
205
  : {}),
199
206
  ...(host ? { host: async () => host } : {}),
207
+ ...Object.fromEntries(
208
+ Object.entries(input.externalVaultProviders ?? {}).map(([provider, providerFactory]) => [
209
+ provider,
210
+ async (connectionId: string | null) => {
211
+ if (!connectionId || !providerFactory)
212
+ throw new VaultProviderError(
213
+ "provider_unavailable",
214
+ "This vault has no available connection.",
215
+ );
216
+ return externalVaultProviderForConnection({
217
+ db: input.db,
218
+ settings: input.settings,
219
+ workspaceId: input.workspaceId,
220
+ subjectId: input.subjectId,
221
+ connectionId,
222
+ provider: provider as keyof ExternalVaultProviderFactories,
223
+ factory: providerFactory,
224
+ ...(input.executionContext ? { executionContext: input.executionContext } : {}),
225
+ });
226
+ },
227
+ ]),
228
+ ),
200
229
  });
201
230
  }