@opengeni/core 3.1.3-canary.38890816741001 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/access/index.d.ts +13 -0
- package/dist/application/site-session-cancellation.d.ts +14 -0
- package/dist/dependencies.d.ts +3 -0
- package/dist/domain/insights-usage.d.ts +24 -1
- package/dist/domain/vaults/external-provider-connections.d.ts +126 -0
- package/dist/domain/vaults/index.d.ts +2 -1
- package/dist/domain/vaults/mapping.d.ts +18 -2
- package/dist/domain/vaults/provider-registry.d.ts +4 -1
- package/dist/domain/vaults/provider-sync.d.ts +135 -21
- package/dist/domain/vaults/provider-writes.d.ts +102 -12
- package/dist/domain/vaults/remote-provider-runtime.d.ts +7 -0
- package/dist/domain/vaults/secret-resolver.d.ts +29 -1
- package/dist/goal-admission.d.ts +2 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1578 -394
- package/dist/index.js.map +1 -1
- package/package.json +13 -13
- package/src/access/index.ts +100 -0
- package/src/application/site-session-cancellation.ts +47 -0
- package/src/dependencies.ts +3 -0
- package/src/domain/file-owner.ts +1 -0
- package/src/domain/insights-usage.ts +20 -1
- package/src/domain/realtime-voice-billing.ts +4 -1
- package/src/domain/vaults/external-provider-connections.ts +395 -0
- package/src/domain/vaults/index.ts +8 -0
- package/src/domain/vaults/mapping.ts +95 -1
- package/src/domain/vaults/provider-registry.ts +29 -0
- package/src/domain/vaults/provider-sync.ts +492 -136
- package/src/domain/vaults/provider-writes.ts +415 -71
- package/src/domain/vaults/remote-provider-runtime.ts +129 -0
- package/src/domain/vaults/secret-resolver.ts +203 -33
- package/src/domain/vaults/vault-service.ts +226 -34
- package/src/domain/voice-input-billing.ts +5 -1
- package/src/index.ts +3 -0
- package/src/sandbox/fleet.ts +1 -0
- package/src/session-authorization.ts +21 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeni/core",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0",
|
|
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.
|
|
54
|
-
"@opengeni/codex": "3.
|
|
55
|
-
"@opengeni/config": "3.
|
|
56
|
-
"@opengeni/contracts": "3.
|
|
57
|
-
"@opengeni/db": "3.
|
|
58
|
-
"@opengeni/documents": "3.
|
|
59
|
-
"@opengeni/events": "3.
|
|
60
|
-
"@opengeni/network": "3.
|
|
61
|
-
"@opengeni/observability": "3.
|
|
62
|
-
"@opengeni/runtime": "3.
|
|
63
|
-
"@opengeni/storage": "3.
|
|
64
|
-
"@opengeni/vaults": "3.
|
|
53
|
+
"@opengeni/capabilities": "3.3.0",
|
|
54
|
+
"@opengeni/codex": "3.3.0",
|
|
55
|
+
"@opengeni/config": "3.3.0",
|
|
56
|
+
"@opengeni/contracts": "3.3.0",
|
|
57
|
+
"@opengeni/db": "3.3.0",
|
|
58
|
+
"@opengeni/documents": "3.3.0",
|
|
59
|
+
"@opengeni/events": "3.3.0",
|
|
60
|
+
"@opengeni/network": "3.3.0",
|
|
61
|
+
"@opengeni/observability": "3.3.0",
|
|
62
|
+
"@opengeni/runtime": "3.3.0",
|
|
63
|
+
"@opengeni/storage": "3.3.0",
|
|
64
|
+
"@opengeni/vaults": "3.3.0",
|
|
65
65
|
"hono": "^4.12.18",
|
|
66
66
|
"zod": "^4.2.1"
|
|
67
67
|
},
|
package/src/access/index.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
listWorkspacesForSubject,
|
|
38
38
|
findActiveApiKeyByHash,
|
|
39
39
|
getWorkspaceGrant,
|
|
40
|
+
subjectHasLiveWorkspaceAuthorityInScope,
|
|
40
41
|
requireWorkspace,
|
|
41
42
|
resolveNamedManagedPersonalWorkspaceGrant,
|
|
42
43
|
type Database,
|
|
@@ -216,6 +217,7 @@ const delegatedHumanAuthorizationSchema = z.object({
|
|
|
216
217
|
|
|
217
218
|
const delegatedHumanRequests = new WeakMap<Request, DelegatedHumanAuthorization>();
|
|
218
219
|
const delegatedHumanContexts = new WeakMap<AccessContext, DelegatedHumanAuthorization>();
|
|
220
|
+
const verifiedSiteViewerProofs = new WeakSet<DelegatedHumanAuthorization>();
|
|
219
221
|
const delegatedHumanProfileSchema = z.object({
|
|
220
222
|
id: z.string(),
|
|
221
223
|
name: z.string().nullable(),
|
|
@@ -283,6 +285,48 @@ export function stampDelegatedHumanAuthorization(
|
|
|
283
285
|
delegatedHumanRequests.set(request, Object.freeze(proof));
|
|
284
286
|
}
|
|
285
287
|
|
|
288
|
+
/**
|
|
289
|
+
* Verified published-Site dispatch only: the caller has verified the signed
|
|
290
|
+
* gateway, served host and opaque viewer cookie. This is exact-workspace
|
|
291
|
+
* ownership for session routes, not a console login or a generic Site bearer.
|
|
292
|
+
* Native people use the existing delegated-human resolver; external owning
|
|
293
|
+
* users reload their existing membership without provisioning a new identity.
|
|
294
|
+
*/
|
|
295
|
+
export function stampSiteViewerSessionAuthorization(
|
|
296
|
+
request: Request,
|
|
297
|
+
input: {
|
|
298
|
+
organizationId: string;
|
|
299
|
+
workspaceId: string;
|
|
300
|
+
subjectId: string;
|
|
301
|
+
permissions: Permission[];
|
|
302
|
+
},
|
|
303
|
+
): void {
|
|
304
|
+
if (input.subjectId.startsWith("user:")) {
|
|
305
|
+
stampDelegatedHumanAuthorization(request, {
|
|
306
|
+
...input,
|
|
307
|
+
workspaceScope: { kind: "selected", workspaceIds: [input.workspaceId] },
|
|
308
|
+
});
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (delegatedHumanRequests.has(request) || accessResolvedRequests.has(request))
|
|
312
|
+
throw new HTTPException(403, { message: "request access authorization already resolved" });
|
|
313
|
+
const parsed = delegatedHumanAuthorizationSchema
|
|
314
|
+
.extend({
|
|
315
|
+
subjectId: z
|
|
316
|
+
.string()
|
|
317
|
+
.max(1024)
|
|
318
|
+
.regex(/^external_user:[^\s\u0000-\u001f\u007f]+$/),
|
|
319
|
+
})
|
|
320
|
+
.parse({ ...input, workspaceScope: { kind: "selected", workspaceIds: [input.workspaceId] } });
|
|
321
|
+
if (parsed.workspaceScope.kind !== "selected") throw new Error("Site workspace scope required");
|
|
322
|
+
Object.freeze(parsed.permissions);
|
|
323
|
+
Object.freeze(parsed.workspaceScope.workspaceIds);
|
|
324
|
+
Object.freeze(parsed.workspaceScope);
|
|
325
|
+
const proof = Object.freeze(parsed);
|
|
326
|
+
verifiedSiteViewerProofs.add(proof);
|
|
327
|
+
delegatedHumanRequests.set(request, proof);
|
|
328
|
+
}
|
|
329
|
+
|
|
286
330
|
/** Token prefix of personal API keys, which act as the person who made them. */
|
|
287
331
|
export const PERSONAL_API_KEY_TOKEN_PREFIX = "ogp_";
|
|
288
332
|
|
|
@@ -1521,6 +1565,62 @@ async function resolveDelegatedHumanAccessContext(
|
|
|
1521
1565
|
message: "delegated humans require native organization access",
|
|
1522
1566
|
});
|
|
1523
1567
|
}
|
|
1568
|
+
if (verifiedSiteViewerProofs.has(proof)) {
|
|
1569
|
+
const workspaceId =
|
|
1570
|
+
proof.workspaceScope.kind === "selected" && proof.workspaceScope.workspaceIds.length === 1
|
|
1571
|
+
? proof.workspaceScope.workspaceIds[0]
|
|
1572
|
+
: null;
|
|
1573
|
+
if (!workspaceId)
|
|
1574
|
+
throw new HTTPException(403, { message: "Site viewer workspace unavailable" });
|
|
1575
|
+
const grant = await withWorkspaceSubjectRls(
|
|
1576
|
+
deps.db,
|
|
1577
|
+
workspaceId,
|
|
1578
|
+
proof.subjectId,
|
|
1579
|
+
async (tx) => {
|
|
1580
|
+
if (
|
|
1581
|
+
!(await subjectHasLiveWorkspaceAuthorityInScope(tx, {
|
|
1582
|
+
accountId: proof.organizationId,
|
|
1583
|
+
workspaceId,
|
|
1584
|
+
subjectId: proof.subjectId,
|
|
1585
|
+
}))
|
|
1586
|
+
)
|
|
1587
|
+
return null;
|
|
1588
|
+
return getWorkspaceGrant(tx, proof.subjectId, workspaceId, {
|
|
1589
|
+
principalKind: "human_session",
|
|
1590
|
+
});
|
|
1591
|
+
},
|
|
1592
|
+
);
|
|
1593
|
+
if (!grant || grant.accountId !== proof.organizationId)
|
|
1594
|
+
throw new HTTPException(403, { message: "Site viewer membership unavailable" });
|
|
1595
|
+
const bounded = Object.freeze({
|
|
1596
|
+
...grant,
|
|
1597
|
+
permissionMode: "explicit" as const,
|
|
1598
|
+
permissions: Object.freeze(
|
|
1599
|
+
explicitPermissions(
|
|
1600
|
+
Permission.options.filter(
|
|
1601
|
+
(permission) =>
|
|
1602
|
+
hasPermission(grant.permissions, permission) &&
|
|
1603
|
+
hasPermission(proof.permissions, permission, "explicit"),
|
|
1604
|
+
),
|
|
1605
|
+
),
|
|
1606
|
+
) as Permission[],
|
|
1607
|
+
});
|
|
1608
|
+
const accountGrant = Object.freeze({
|
|
1609
|
+
accountId: proof.organizationId,
|
|
1610
|
+
subjectId: proof.subjectId,
|
|
1611
|
+
permissions: Object.freeze([] as Permission[]) as Permission[],
|
|
1612
|
+
});
|
|
1613
|
+
const context: AccessContext = Object.freeze({
|
|
1614
|
+
mode: "managed",
|
|
1615
|
+
subjectId: proof.subjectId,
|
|
1616
|
+
accountGrants: Object.freeze([accountGrant]) as AccountGrant[],
|
|
1617
|
+
workspaceGrants: Object.freeze([bounded]) as AccessGrant[],
|
|
1618
|
+
defaultAccountId: proof.organizationId,
|
|
1619
|
+
defaultWorkspaceId: workspaceId,
|
|
1620
|
+
});
|
|
1621
|
+
delegatedHumanContexts.set(context, proof);
|
|
1622
|
+
return context;
|
|
1623
|
+
}
|
|
1524
1624
|
const userId = proof.subjectId.slice("user:".length);
|
|
1525
1625
|
const profiles = await getManagedUserProfilesByIds(deps.db, [userId]);
|
|
1526
1626
|
const parsedProfile = delegatedHumanProfileSchema.safeParse(
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { cancelSiteRuntimeConversation, type Database } from "@opengeni/db";
|
|
2
|
+
import {
|
|
3
|
+
publishDurableSessionEvents,
|
|
4
|
+
publishDurableWorkspaceControlEvent,
|
|
5
|
+
type EventBus,
|
|
6
|
+
} from "@opengeni/events";
|
|
7
|
+
import type { SessionWorkflowClient } from "../dependencies";
|
|
8
|
+
|
|
9
|
+
/** Internal host lifecycle, not a session-read/control API or a viewer grant. */
|
|
10
|
+
export async function cancelPublishedSiteSession(
|
|
11
|
+
deps: {
|
|
12
|
+
db: Database;
|
|
13
|
+
bus: EventBus;
|
|
14
|
+
workflowClient: Pick<SessionWorkflowClient, "requestSessionWorkflowWakeDispatch">;
|
|
15
|
+
},
|
|
16
|
+
input: Parameters<typeof cancelSiteRuntimeConversation>[1],
|
|
17
|
+
) {
|
|
18
|
+
const result = await cancelSiteRuntimeConversation(deps.db, input);
|
|
19
|
+
if (!result) return null;
|
|
20
|
+
// Capture only this command's event envelopes under its transaction witness.
|
|
21
|
+
// Never re-open private transcript visibility to do post-commit fanout.
|
|
22
|
+
const bySession = new Map<string, typeof result.events>();
|
|
23
|
+
for (const event of result.events) {
|
|
24
|
+
const group = bySession.get(event.sessionId) ?? [];
|
|
25
|
+
group.push(event);
|
|
26
|
+
bySession.set(event.sessionId, group);
|
|
27
|
+
}
|
|
28
|
+
const effects = await Promise.allSettled([
|
|
29
|
+
...[...bySession].map(([sessionId, events]) =>
|
|
30
|
+
publishDurableSessionEvents(deps.bus, input.workspaceId, sessionId, events),
|
|
31
|
+
),
|
|
32
|
+
...(result.workspaceEvent
|
|
33
|
+
? [publishDurableWorkspaceControlEvent(deps.bus, input.workspaceId, result.workspaceEvent)]
|
|
34
|
+
: []),
|
|
35
|
+
...(result.wakeCount > 0 ? [deps.workflowClient.requestSessionWorkflowWakeDispatch()] : []),
|
|
36
|
+
]);
|
|
37
|
+
if (effects.some((effect) => effect.status === "rejected"))
|
|
38
|
+
console.warn(
|
|
39
|
+
"[site-lifecycle] post-commit fanout failed; durable events and wake outbox retained",
|
|
40
|
+
);
|
|
41
|
+
return {
|
|
42
|
+
receiptId: result.receiptId,
|
|
43
|
+
replay: result.replay,
|
|
44
|
+
cancelledSessionCount: result.cancelledSessionCount,
|
|
45
|
+
cancelledTurnCount: result.cancelledTurnCount,
|
|
46
|
+
};
|
|
47
|
+
}
|
package/src/dependencies.ts
CHANGED
|
@@ -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
|
package/src/domain/file-owner.ts
CHANGED
|
@@ -62,6 +62,7 @@ export async function fileOwnerContextForAgent(
|
|
|
62
62
|
},
|
|
63
63
|
subjectId: grant.subjectId,
|
|
64
64
|
initiatingHumanSubjectId: attempt.initiatingHumanSubjectId,
|
|
65
|
+
agentRootSessionId: attempt.callerRootSessionId,
|
|
65
66
|
privateFileOwnerSubjectId: snapshot.defaultScope === "personal" ? snapshot.subjectId : null,
|
|
66
67
|
};
|
|
67
68
|
}
|
|
@@ -4,8 +4,27 @@ import type {
|
|
|
4
4
|
InsightsUsageQuery,
|
|
5
5
|
InsightsUsageRange,
|
|
6
6
|
InsightsUsageResponse,
|
|
7
|
+
InsightsSiteUsageQuery,
|
|
7
8
|
} from "@opengeni/contracts/insights-usage";
|
|
8
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
readInsightsCalls,
|
|
11
|
+
readInsightsUsage,
|
|
12
|
+
readInsightsSiteUsage,
|
|
13
|
+
type Database,
|
|
14
|
+
} from "@opengeni/db";
|
|
15
|
+
|
|
16
|
+
export async function getInsightsSiteUsage(
|
|
17
|
+
db: Database,
|
|
18
|
+
input: {
|
|
19
|
+
accountId: string;
|
|
20
|
+
workspaceId: string;
|
|
21
|
+
query: InsightsSiteUsageQuery;
|
|
22
|
+
viewerSubjectId: string | null;
|
|
23
|
+
now?: Date;
|
|
24
|
+
},
|
|
25
|
+
) {
|
|
26
|
+
return readInsightsSiteUsage(db, { ...input, now: input.now ?? new Date() });
|
|
27
|
+
}
|
|
9
28
|
|
|
10
29
|
export type InsightsQueryScope = {
|
|
11
30
|
accountId: string;
|
|
@@ -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(
|
|
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
|
+
}
|