@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/core",
3
- "version": "1.4.5-canary.38533733713001",
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": "1.4.5-canary.38533733713001",
54
- "@opengeni/codex": "1.4.5-canary.38533733713001",
55
- "@opengeni/config": "1.4.5-canary.38533733713001",
56
- "@opengeni/contracts": "1.4.5-canary.38533733713001",
57
- "@opengeni/db": "1.4.5-canary.38533733713001",
58
- "@opengeni/documents": "1.4.5-canary.38533733713001",
59
- "@opengeni/events": "1.4.5-canary.38533733713001",
60
- "@opengeni/network": "1.4.5-canary.38533733713001",
61
- "@opengeni/observability": "1.4.5-canary.38533733713001",
62
- "@opengeni/runtime": "1.4.5-canary.38533733713001",
63
- "@opengeni/storage": "1.4.5-canary.38533733713001",
64
- "@opengeni/vaults": "1.4.5-canary.38533733713001",
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, OpenGeni-owned lineage fields.
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 OpenGeni Browser extension.",
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 OpenGeni Browser Chrome extension (${OPENGENI_BROWSER_EXTENSION_URL}) works only after the machine is connected.`,
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",
@@ -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 OpenGeni-credits billing path. Legacy session-bound grants without
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 see
104
- * (workspace-shared, or personal to `subjectId`). The token goes only to the
105
- * provider factory; it is never logged or returned to a client.
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
- const connection = await getConnectionMetadata(
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: input.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 OpenGeni names; field names are de-duplicated within each item.
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 OpenGeni storage.
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 OpenGeni",
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; OpenGeni will show it after the next sync",
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 OpenGeni vaults");
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 { meta, candidates } = await resolveSecretReferenceCandidates(deps.db, context, {
290
- refs: references.map((reference, index) => lookupFor(reference, index)),
291
- });
292
- const failures: SecretReferenceFailure[] = [];
293
- const chosen: Chosen[] = [];
294
- references.forEach((reference, index) => {
295
- const outcome = chooseSecretCandidate(
296
- reference,
297
- candidates.filter((candidate) => candidate.refIndex === index),
298
- meta,
299
- );
300
- if ("failure" in outcome) failures.push(outcome.failure);
301
- else chosen.push({ reference, candidate: outcome.candidate });
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. OpenGeni-to-OpenGeni moves keep
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
- const reachable = await listVaultsForSubject(deps.db, context, {
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),