@opengeni/core 1.4.5-canary.38533733713001 → 2.0.1
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/domain/vaults/provider-registry.d.ts +6 -3
- package/dist/domain/vaults/provider-sync.d.ts +44 -1
- package/dist/domain/vaults/provider-writes.d.ts +1 -1
- package/dist/goal-admission.d.ts +0 -1
- package/dist/index.js +387 -286
- package/dist/index.js.map +1 -1
- package/package.json +13 -13
- package/src/access/service-initiator.ts +1 -1
- package/src/domain/capabilities.ts +2 -2
- package/src/domain/sessions.ts +1 -1
- package/src/domain/vaults/provider-registry.ts +22 -5
- package/src/domain/vaults/provider-sync.ts +98 -1
- package/src/domain/vaults/provider-writes.ts +4 -4
- package/src/domain/vaults/secret-resolver.ts +39 -14
- package/src/domain/vaults/vault-service.ts +19 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeni/core",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
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": "
|
|
54
|
-
"@opengeni/codex": "
|
|
55
|
-
"@opengeni/config": "
|
|
56
|
-
"@opengeni/contracts": "
|
|
57
|
-
"@opengeni/db": "
|
|
58
|
-
"@opengeni/documents": "
|
|
59
|
-
"@opengeni/events": "
|
|
60
|
-
"@opengeni/network": "
|
|
61
|
-
"@opengeni/observability": "
|
|
62
|
-
"@opengeni/runtime": "
|
|
63
|
-
"@opengeni/storage": "
|
|
64
|
-
"@opengeni/vaults": "
|
|
53
|
+
"@opengeni/capabilities": "2.0.1",
|
|
54
|
+
"@opengeni/codex": "2.0.1",
|
|
55
|
+
"@opengeni/config": "2.0.1",
|
|
56
|
+
"@opengeni/contracts": "2.0.1",
|
|
57
|
+
"@opengeni/db": "2.0.1",
|
|
58
|
+
"@opengeni/documents": "2.0.1",
|
|
59
|
+
"@opengeni/events": "2.0.1",
|
|
60
|
+
"@opengeni/network": "2.0.1",
|
|
61
|
+
"@opengeni/observability": "2.0.1",
|
|
62
|
+
"@opengeni/runtime": "2.0.1",
|
|
63
|
+
"@opengeni/storage": "2.0.1",
|
|
64
|
+
"@opengeni/vaults": "2.0.1",
|
|
65
65
|
"hono": "^4.12.18",
|
|
66
66
|
"zod": "^4.2.1"
|
|
67
67
|
},
|
|
@@ -34,7 +34,7 @@ export function serviceInitiatorFromHeaders(
|
|
|
34
34
|
)
|
|
35
35
|
throw new Error("flat");
|
|
36
36
|
context = parsed as typeof context;
|
|
37
|
-
// Keep delegated provenance's reserved,
|
|
37
|
+
// Keep delegated provenance's reserved, Opengeni-owned lineage fields.
|
|
38
38
|
if (!ServiceTurnInitiatorContext.safeParse(context).success) throw new Error("reserved");
|
|
39
39
|
} catch {
|
|
40
40
|
throw new HTTPException(422, {
|
|
@@ -1732,7 +1732,7 @@ export function nativeConnectionCapabilityRecommendations(
|
|
|
1732
1732
|
source: "built_in",
|
|
1733
1733
|
name: "Connected Machine",
|
|
1734
1734
|
description:
|
|
1735
|
-
"Run this chat on the person's own computer or server (Mac, Linux or Windows), and optionally let agents use their Chrome through the
|
|
1735
|
+
"Run this chat on the person's own computer or server (Mac, Linux or Windows), and optionally let agents use their Chrome through the Opengeni Browser extension.",
|
|
1736
1736
|
category: "compute",
|
|
1737
1737
|
tags: [
|
|
1738
1738
|
"machine",
|
|
@@ -1760,7 +1760,7 @@ export function nativeConnectionCapabilityRecommendations(
|
|
|
1760
1760
|
tools: [{ kind: "mcp", id: "opengeni" }],
|
|
1761
1761
|
runtime: {
|
|
1762
1762
|
available: true,
|
|
1763
|
-
notes: `The person runs a one-line connect command on their machine from the chat card. The
|
|
1763
|
+
notes: `The person runs a one-line connect command on their machine from the chat card. The Opengeni Browser Chrome extension (${OPENGENI_BROWSER_EXTENSION_URL}) works only after the machine is connected.`,
|
|
1764
1764
|
},
|
|
1765
1765
|
lifecycle: {
|
|
1766
1766
|
status: "available",
|
package/src/domain/sessions.ts
CHANGED
|
@@ -3246,7 +3246,7 @@ async function createSessionForRequestInFileScope(
|
|
|
3246
3246
|
// model and reasoning fields therefore inherit that calling turn rather than the
|
|
3247
3247
|
// deployment defaults. This is especially important for Codex subscription
|
|
3248
3248
|
// managers: falling back to the deployment model would silently move a child
|
|
3249
|
-
// onto the
|
|
3249
|
+
// onto the Opengeni-credits billing path. Legacy session-bound grants without
|
|
3250
3250
|
// exact attempt claims fall back to the parent session's persisted defaults.
|
|
3251
3251
|
const model = canonicalConfiguredModel(settings, effectiveModelId);
|
|
3252
3252
|
if (model === null || model === undefined) {
|
|
@@ -2,6 +2,7 @@ import type { Settings } from "@opengeni/config";
|
|
|
2
2
|
import type { ConnectionMetadata, VaultErrorCode } from "@opengeni/contracts";
|
|
3
3
|
import {
|
|
4
4
|
getConnectionMetadata,
|
|
5
|
+
getProviderConnectionHomeWorkspace,
|
|
5
6
|
loadConnectionCredentialForBroker,
|
|
6
7
|
type Database,
|
|
7
8
|
} from "@opengeni/db";
|
|
@@ -100,9 +101,12 @@ export function onePasswordCredentialBundle(token: string): Record<string, unkno
|
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
/**
|
|
103
|
-
* Decrypts the token of one 1Password connection the caller can
|
|
104
|
-
* (
|
|
105
|
-
*
|
|
104
|
+
* Decrypts the token of one 1Password connection the caller can use from
|
|
105
|
+
* `workspaceId`: one in this workspace (shared, or personal to `subjectId`),
|
|
106
|
+
* or one added in another workspace that still serves vaults reached here
|
|
107
|
+
* (the caller's own personal connection, or a shared connection behind an
|
|
108
|
+
* organization vault). The token goes only to the provider factory; it is
|
|
109
|
+
* never logged or returned to a client.
|
|
106
110
|
*/
|
|
107
111
|
export async function loadOnePasswordConnectionToken(
|
|
108
112
|
db: Database,
|
|
@@ -114,12 +118,25 @@ export async function loadOnePasswordConnectionToken(
|
|
|
114
118
|
"provider_auth_invalid",
|
|
115
119
|
"The 1Password connection for this vault was removed or is not available to you; reconnect 1Password",
|
|
116
120
|
);
|
|
117
|
-
|
|
121
|
+
let homeWorkspaceId = input.workspaceId;
|
|
122
|
+
let connection = await getConnectionMetadata(
|
|
118
123
|
db,
|
|
119
124
|
input.workspaceId,
|
|
120
125
|
input.connectionId,
|
|
121
126
|
input.subjectId,
|
|
122
127
|
);
|
|
128
|
+
if (!connection) {
|
|
129
|
+
const home = await getProviderConnectionHomeWorkspace(
|
|
130
|
+
db,
|
|
131
|
+
input.workspaceId,
|
|
132
|
+
input.connectionId,
|
|
133
|
+
input.subjectId,
|
|
134
|
+
);
|
|
135
|
+
if (home && home !== input.workspaceId) {
|
|
136
|
+
homeWorkspaceId = home;
|
|
137
|
+
connection = await getConnectionMetadata(db, home, input.connectionId, input.subjectId);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
123
140
|
if (!connection || !isOnePasswordConnection(connection)) throw unavailable();
|
|
124
141
|
if (connection.status !== "active") {
|
|
125
142
|
throw new VaultProviderError(
|
|
@@ -129,7 +146,7 @@ export async function loadOnePasswordConnectionToken(
|
|
|
129
146
|
}
|
|
130
147
|
const personal = connection.subjectId !== null;
|
|
131
148
|
const credential = await loadConnectionCredentialForBroker(db, settings, {
|
|
132
|
-
workspaceId:
|
|
149
|
+
workspaceId: homeWorkspaceId,
|
|
133
150
|
connectionId: connection.id,
|
|
134
151
|
providerDomain: ONEPASSWORD_PROVIDER_DOMAIN,
|
|
135
152
|
kind: "api_key",
|
|
@@ -168,7 +168,7 @@ export function providerPrimaryFieldId(
|
|
|
168
168
|
/**
|
|
169
169
|
* Deterministic mirror projection. Items are ordered by provider name then
|
|
170
170
|
* external id before de-duplication, so the same provider state always yields
|
|
171
|
-
* the same
|
|
171
|
+
* the same Opengeni names; field names are de-duplicated within each item.
|
|
172
172
|
*/
|
|
173
173
|
export function planProviderMirror(
|
|
174
174
|
items: readonly ProviderItemMeta[],
|
|
@@ -426,3 +426,100 @@ export async function syncLinkedProviderVault(input: {
|
|
|
426
426
|
...(input.force ? { force: true } : {}),
|
|
427
427
|
});
|
|
428
428
|
}
|
|
429
|
+
|
|
430
|
+
/** How stale a linked vault's mirror may be before a search refreshes it. */
|
|
431
|
+
export const LINKED_VAULT_SEARCH_MAX_AGE_MS = 10 * 60_000;
|
|
432
|
+
/** How stale it may be before a reference that found nothing refreshes it. */
|
|
433
|
+
export const LINKED_VAULT_MISS_MAX_AGE_MS = 2 * 60_000;
|
|
434
|
+
/** How long a search or resolution waits for refreshes before answering. */
|
|
435
|
+
export const LINKED_VAULT_REFRESH_WAIT_MS = 8_000;
|
|
436
|
+
|
|
437
|
+
/** The vault fields a freshness check needs (a `VaultRow` has them all). */
|
|
438
|
+
export type LinkedVaultFreshness = {
|
|
439
|
+
id: string;
|
|
440
|
+
source: string;
|
|
441
|
+
syncStatus: VaultSyncStatus;
|
|
442
|
+
lastSyncedAt: string | null;
|
|
443
|
+
rateLimitedUntil: string | null;
|
|
444
|
+
updatedAt: string;
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Whether a linked vault's mirror is older than `maxAgeMs`. A vault whose last
|
|
449
|
+
* sync failed counts from that failure (it is recorded as `updatedAt`), so a
|
|
450
|
+
* broken connection is retried once per `maxAgeMs`, not on every call. A vault
|
|
451
|
+
* under a provider rate limit is never stale until the limit lifts.
|
|
452
|
+
*/
|
|
453
|
+
export function linkedVaultNeedsRefresh(
|
|
454
|
+
vault: LinkedVaultFreshness,
|
|
455
|
+
maxAgeMs: number,
|
|
456
|
+
now: Date,
|
|
457
|
+
): boolean {
|
|
458
|
+
if (vault.source === "opengeni") return false;
|
|
459
|
+
if (vault.rateLimitedUntil && new Date(vault.rateLimitedUntil).getTime() > now.getTime()) {
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
const last =
|
|
463
|
+
vault.syncStatus === "ok" || vault.syncStatus === "syncing"
|
|
464
|
+
? vault.lastSyncedAt
|
|
465
|
+
: vault.updatedAt;
|
|
466
|
+
if (!last) return true;
|
|
467
|
+
return now.getTime() - new Date(last).getTime() >= maxAgeMs;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const linkedVaultRefreshes = new Map<string, Promise<ProviderVaultSyncResult | null>>();
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Bring stale linked vault mirrors up to date, so items added in the provider
|
|
474
|
+
* (for example straight in 1Password) are found without anyone pressing Sync.
|
|
475
|
+
*
|
|
476
|
+
* Each refresh is the ordinary non-forced sync: one `listVaults` call, and an
|
|
477
|
+
* item listing only when the provider says the vault changed. Refreshes of the
|
|
478
|
+
* same vault share one call per process. The caller waits at most `waitMs`;
|
|
479
|
+
* slower refreshes finish in the background and record their own status.
|
|
480
|
+
* Never throws: failures are recorded as the vault's sync status.
|
|
481
|
+
*
|
|
482
|
+
* Returns the ids of the vaults whose mirror changed before the wait ended.
|
|
483
|
+
*/
|
|
484
|
+
export async function refreshStaleLinkedVaults(input: {
|
|
485
|
+
db: Database;
|
|
486
|
+
context: VaultDbContext;
|
|
487
|
+
vaults: readonly LinkedVaultFreshness[];
|
|
488
|
+
providers: VaultProviderRegistry;
|
|
489
|
+
maxAgeMs: number;
|
|
490
|
+
waitMs?: number;
|
|
491
|
+
now?: () => Date;
|
|
492
|
+
}): Promise<string[]> {
|
|
493
|
+
const now = (input.now ?? (() => new Date()))();
|
|
494
|
+
const stale = input.vaults.filter((vault) => linkedVaultNeedsRefresh(vault, input.maxAgeMs, now));
|
|
495
|
+
if (stale.length === 0) return [];
|
|
496
|
+
const changed: string[] = [];
|
|
497
|
+
const runs = stale.map((vault) => {
|
|
498
|
+
let run = linkedVaultRefreshes.get(vault.id);
|
|
499
|
+
if (!run) {
|
|
500
|
+
run = syncLinkedProviderVault({
|
|
501
|
+
db: input.db,
|
|
502
|
+
context: input.context,
|
|
503
|
+
vaultId: vault.id,
|
|
504
|
+
providers: input.providers,
|
|
505
|
+
...(input.now ? { now: input.now } : {}),
|
|
506
|
+
})
|
|
507
|
+
.catch(() => null)
|
|
508
|
+
.finally(() => linkedVaultRefreshes.delete(vault.id));
|
|
509
|
+
linkedVaultRefreshes.set(vault.id, run);
|
|
510
|
+
}
|
|
511
|
+
return run.then((result) => {
|
|
512
|
+
if (result?.status === "synced") changed.push(vault.id);
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
516
|
+
await Promise.race([
|
|
517
|
+
Promise.all(runs),
|
|
518
|
+
new Promise<void>((resolve) => {
|
|
519
|
+
timer = setTimeout(resolve, input.waitMs ?? LINKED_VAULT_REFRESH_WAIT_MS);
|
|
520
|
+
timer.unref?.();
|
|
521
|
+
}),
|
|
522
|
+
]);
|
|
523
|
+
if (timer) clearTimeout(timer);
|
|
524
|
+
return [...changed];
|
|
525
|
+
}
|
|
@@ -28,7 +28,7 @@ import { syncLinkedProviderVault } from "./provider-sync";
|
|
|
28
28
|
* Edits of items in a read-write connected vault (OP-3): the change is written
|
|
29
29
|
* to the provider (1Password `items.get` → patch → `items.put`, with the
|
|
30
30
|
* mirrored version as the expected version), then the metadata mirror is
|
|
31
|
-
* refreshed. Values never touch
|
|
31
|
+
* refreshed. Values never touch Opengeni storage.
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
/** One field change; the REST `VaultFieldInput` and the agent `secret_save` field both fit. */
|
|
@@ -112,7 +112,7 @@ async function providerFieldWrites(
|
|
|
112
112
|
if (input.fileBase64 !== undefined || kind === "file") {
|
|
113
113
|
throw new VaultError(
|
|
114
114
|
"provider_unavailable",
|
|
115
|
-
"files in connected vaults are read-only in
|
|
115
|
+
"files in connected vaults are read-only in Opengeni",
|
|
116
116
|
{ field: input.name },
|
|
117
117
|
);
|
|
118
118
|
}
|
|
@@ -181,7 +181,7 @@ async function refreshAndFind(
|
|
|
181
181
|
if (error instanceof VaultError && error.code === "vault_item_not_found") {
|
|
182
182
|
throw new VaultError(
|
|
183
183
|
"provider_unavailable",
|
|
184
|
-
"saved in the provider;
|
|
184
|
+
"saved in the provider; Opengeni will show it after the next sync",
|
|
185
185
|
{ vaultId: vault.id },
|
|
186
186
|
);
|
|
187
187
|
}
|
|
@@ -210,7 +210,7 @@ export async function writeProviderVaultItem(
|
|
|
210
210
|
const { vault, existing, change } = input;
|
|
211
211
|
assertProviderVaultWritable(vault);
|
|
212
212
|
if ((change.type ?? existing?.type) === "passkey") {
|
|
213
|
-
throw new VaultError("vault_permission_denied", "passkeys live only in
|
|
213
|
+
throw new VaultError("vault_permission_denied", "passkeys live only in Opengeni vaults");
|
|
214
214
|
}
|
|
215
215
|
let externalItemId: string | undefined;
|
|
216
216
|
let expectedVersion: string | null | undefined;
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
} from "@opengeni/contracts";
|
|
24
24
|
import {
|
|
25
25
|
decryptEnvironmentValue,
|
|
26
|
+
listVaultsForSubject,
|
|
26
27
|
recordVaultSecretUse,
|
|
27
28
|
resolveSecretReferenceCandidates,
|
|
28
29
|
type SecretReferenceCandidate,
|
|
@@ -37,6 +38,7 @@ import {
|
|
|
37
38
|
type VaultProviderErrorCode,
|
|
38
39
|
type VaultProviderRegistry,
|
|
39
40
|
} from "@opengeni/vaults";
|
|
41
|
+
import { LINKED_VAULT_MISS_MAX_AGE_MS, refreshStaleLinkedVaults } from "./provider-sync";
|
|
40
42
|
import type { VaultServiceDeps } from "./types";
|
|
41
43
|
|
|
42
44
|
export type SecretResolverOptions = {
|
|
@@ -286,20 +288,43 @@ export function createSecretResolver(
|
|
|
286
288
|
);
|
|
287
289
|
}
|
|
288
290
|
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
291
|
+
const choose = async () => {
|
|
292
|
+
const { meta, candidates } = await resolveSecretReferenceCandidates(deps.db, context, {
|
|
293
|
+
refs: references.map((reference, index) => lookupFor(reference, index)),
|
|
294
|
+
});
|
|
295
|
+
const failures: SecretReferenceFailure[] = [];
|
|
296
|
+
const chosen: Chosen[] = [];
|
|
297
|
+
references.forEach((reference, index) => {
|
|
298
|
+
const outcome = chooseSecretCandidate(
|
|
299
|
+
reference,
|
|
300
|
+
candidates.filter((candidate) => candidate.refIndex === index),
|
|
301
|
+
meta,
|
|
302
|
+
);
|
|
303
|
+
if ("failure" in outcome) failures.push(outcome.failure);
|
|
304
|
+
else chosen.push({ reference, candidate: outcome.candidate });
|
|
305
|
+
});
|
|
306
|
+
return { meta, failures, chosen };
|
|
307
|
+
};
|
|
308
|
+
let { meta, failures, chosen } = await choose();
|
|
309
|
+
// A reference to an item added straight in a connected provider (1Password)
|
|
310
|
+
// finds nothing until the vault's mirror catches up: refresh stale linked
|
|
311
|
+
// vaults once and look again before reporting it missing.
|
|
312
|
+
if (failures.some((failure) => failure.code === "not_found")) {
|
|
313
|
+
const registry = deps.providers ?? opts.providersForSubject?.(meta.subjectId);
|
|
314
|
+
if (registry) {
|
|
315
|
+
const reachable = await listVaultsForSubject(deps.db, context, {
|
|
316
|
+
workspaceId: principal.workspaceId,
|
|
317
|
+
});
|
|
318
|
+
const changed = await refreshStaleLinkedVaults({
|
|
319
|
+
db: deps.db,
|
|
320
|
+
context,
|
|
321
|
+
vaults: reachable,
|
|
322
|
+
providers: registry,
|
|
323
|
+
maxAgeMs: LINKED_VAULT_MISS_MAX_AGE_MS,
|
|
324
|
+
});
|
|
325
|
+
if (changed.length > 0) ({ meta, failures, chosen } = await choose());
|
|
326
|
+
}
|
|
327
|
+
}
|
|
303
328
|
const hasActingHuman = meta.humanExpected && meta.humanValid;
|
|
304
329
|
if (failures.length > 0) {
|
|
305
330
|
throw new SecretReferenceError(
|
|
@@ -92,6 +92,7 @@ import {
|
|
|
92
92
|
type VaultCallerAuthority,
|
|
93
93
|
} from "./mapping";
|
|
94
94
|
import { vaultErrorCodeForProvider } from "./provider-registry";
|
|
95
|
+
import { LINKED_VAULT_SEARCH_MAX_AGE_MS, refreshStaleLinkedVaults } from "./provider-sync";
|
|
95
96
|
import {
|
|
96
97
|
archiveProviderVaultItem,
|
|
97
98
|
writeProviderVaultItem,
|
|
@@ -423,7 +424,7 @@ export function createVaultService(deps: VaultServiceDeps): VaultService {
|
|
|
423
424
|
* Values never live in both stores' databases at once: each field is read
|
|
424
425
|
* from the source (service-side, unaudited like other internal copies),
|
|
425
426
|
* written to the target through its own path, and a move then archives the
|
|
426
|
-
* source so the old item stays recoverable.
|
|
427
|
+
* source so the old item stays recoverable. Opengeni-to-Opengeni moves keep
|
|
427
428
|
* the single-statement database move, which preserves the item id.
|
|
428
429
|
*/
|
|
429
430
|
async function moveAcrossProviders(input: {
|
|
@@ -940,9 +941,25 @@ export function createVaultService(deps: VaultServiceDeps): VaultService {
|
|
|
940
941
|
|
|
941
942
|
async searchForAgent(principal, input) {
|
|
942
943
|
const context = principalContext(principal);
|
|
943
|
-
|
|
944
|
+
let reachable = await listVaultsForSubject(deps.db, context, {
|
|
944
945
|
workspaceId: principal.workspaceId,
|
|
945
946
|
});
|
|
947
|
+
// Items added straight in a connected provider (1Password) appear here
|
|
948
|
+
// without anyone pressing Sync: stale mirrors refresh before the scan.
|
|
949
|
+
if (deps.providers) {
|
|
950
|
+
const changed = await refreshStaleLinkedVaults({
|
|
951
|
+
db: deps.db,
|
|
952
|
+
context,
|
|
953
|
+
vaults: reachable,
|
|
954
|
+
providers: deps.providers,
|
|
955
|
+
maxAgeMs: LINKED_VAULT_SEARCH_MAX_AGE_MS,
|
|
956
|
+
});
|
|
957
|
+
if (changed.length > 0) {
|
|
958
|
+
reachable = await listVaultsForSubject(deps.db, context, {
|
|
959
|
+
workspaceId: principal.workspaceId,
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
}
|
|
946
963
|
const byTier = (vaults: VaultRow[]) =>
|
|
947
964
|
[...vaults].sort(
|
|
948
965
|
(left, right) => left.tier - right.tier || left.name.localeCompare(right.name),
|