@opengeni/core 3.0.1 → 3.1.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.
Files changed (257) hide show
  1. package/README.md +4 -4
  2. package/dist/access/index.d.ts +131 -7
  3. package/dist/access/service-initiator.d.ts +3 -0
  4. package/dist/application/archived-session-imports.d.ts +6 -0
  5. package/dist/application/composer-submit.d.ts +1 -1
  6. package/dist/application/connect-operation.d.ts +6 -0
  7. package/dist/application/external-workspace-members.d.ts +13 -1
  8. package/dist/application/modal-native-live-declaration-join.d.ts +37 -0
  9. package/dist/application/modal-native-original-configuration.d.ts +79 -0
  10. package/dist/application/modal-native-worker-host-transport.d.ts +297 -0
  11. package/dist/application/new-session-drafts.d.ts +29 -2
  12. package/dist/application/organization-privacy.d.ts +57 -0
  13. package/dist/application/prepared-mcp-connection.d.ts +31 -0
  14. package/dist/application/sandbox-recovery.d.ts +58 -0
  15. package/dist/application/session-commands.d.ts +3 -0
  16. package/dist/application/session-tenancy.d.ts +13 -4
  17. package/dist/billing/agent-run-admission.d.ts +21 -0
  18. package/dist/billing/limits.d.ts +27 -1
  19. package/dist/billing/model-call-admission.d.ts +34 -0
  20. package/dist/billing/model-usage-settlement.d.ts +112 -0
  21. package/dist/canonical-human-identities.js +1 -1
  22. package/dist/{chunk-VCZFFEPB.js → chunk-7T2SZ7BI.js} +16 -2
  23. package/dist/chunk-7T2SZ7BI.js.map +1 -0
  24. package/dist/{chunk-ZYCNLYGH.js → chunk-OMJFIIWK.js} +178 -12
  25. package/dist/chunk-OMJFIIWK.js.map +1 -0
  26. package/dist/{chunk-H7X52RI2.js → chunk-QI2M2B2K.js} +83 -8
  27. package/dist/chunk-QI2M2B2K.js.map +1 -0
  28. package/dist/{chunk-OF65T3PM.js → chunk-TTWTXQB2.js} +94 -6
  29. package/dist/chunk-TTWTXQB2.js.map +1 -0
  30. package/dist/codex-model-availability.d.ts +55 -0
  31. package/dist/default-session-model.d.ts +192 -0
  32. package/dist/dependencies.d.ts +41 -7
  33. package/dist/developer-setup-credential-ceiling.d.ts +10 -0
  34. package/dist/domain/agent-config-resolution.d.ts +49 -0
  35. package/dist/domain/automations.d.ts +8 -1
  36. package/dist/domain/capabilities.d.ts +112 -8
  37. package/dist/domain/connection-account-selection-error.d.ts +8 -0
  38. package/dist/domain/connector-tool-permissions.d.ts +62 -0
  39. package/dist/domain/direct-model-provider.d.ts +3 -0
  40. package/dist/domain/editable-artifacts/agent-application.d.ts +10 -1
  41. package/dist/domain/editable-artifacts/durable-export.d.ts +22 -1
  42. package/dist/domain/editable-artifacts/ports.d.ts +10 -0
  43. package/dist/domain/editable-artifacts/service.d.ts +3 -1
  44. package/dist/domain/editable-artifacts/types.d.ts +1 -1
  45. package/dist/domain/encryption-key.d.ts +12 -0
  46. package/dist/domain/insights-timing.d.ts +16 -0
  47. package/dist/domain/insights-usage.d.ts +27 -0
  48. package/dist/domain/insights.d.ts +21 -3
  49. package/dist/domain/knowledge-files.d.ts +1 -1
  50. package/dist/domain/knowledge-preparation.d.ts +10 -0
  51. package/dist/domain/knowledge-preparation.test.d.ts +1 -0
  52. package/dist/domain/knowledge-search.d.ts +15 -76
  53. package/dist/domain/mcp-account-bindings.d.ts +17 -0
  54. package/dist/domain/mcp-account-routes.d.ts +21 -0
  55. package/dist/domain/native-mcp-connection-admission.d.ts +3 -0
  56. package/dist/domain/organization-integration-catalog.d.ts +15 -0
  57. package/dist/domain/personal-connection-delegations.d.ts +73 -12
  58. package/dist/domain/pr-review.d.ts +121 -6
  59. package/dist/domain/realtime-voice-billing.d.ts +103 -0
  60. package/dist/domain/resources.d.ts +17 -1
  61. package/dist/domain/scheduled-task-access.d.ts +265 -0
  62. package/dist/domain/scheduled-task-account-audit.d.ts +27 -0
  63. package/dist/domain/scheduled-task-audience.d.ts +70 -0
  64. package/dist/domain/scheduled-tasks.d.ts +61 -18
  65. package/dist/domain/session-connection-accounts.d.ts +53 -0
  66. package/dist/domain/session-file-access.d.ts +4 -0
  67. package/dist/domain/session-native-connection-replacement.d.ts +14 -0
  68. package/dist/domain/session-start-timing.d.ts +7 -0
  69. package/dist/domain/session-tool-policy.d.ts +102 -4
  70. package/dist/domain/sessions.d.ts +179 -15
  71. package/dist/domain/site-audience.d.ts +52 -0
  72. package/dist/domain/skills.d.ts +29 -1
  73. package/dist/domain/slack-bot.d.ts +46 -1
  74. package/dist/domain/slack-publication-secret-safety.d.ts +1 -1
  75. package/dist/domain/vaults/index.d.ts +7 -0
  76. package/dist/domain/vaults/mapping.d.ts +25 -0
  77. package/dist/domain/vaults/provider-registry.d.ts +72 -0
  78. package/dist/domain/vaults/provider-sync.d.ts +176 -0
  79. package/dist/domain/vaults/provider-writes.d.ts +57 -0
  80. package/dist/domain/vaults/secret-resolver.d.ts +34 -0
  81. package/dist/domain/vaults/types.d.ts +24 -0
  82. package/dist/domain/vaults/vault-service.d.ts +35 -0
  83. package/dist/domain/voice-input-billing.d.ts +77 -0
  84. package/dist/domain/web-search-billing.d.ts +51 -0
  85. package/dist/domain/workspace-credits.d.ts +7 -0
  86. package/dist/editable-artifact-live/application.d.ts +2 -1
  87. package/dist/editable-artifact-live/server.d.ts +5 -1
  88. package/dist/editable-artifact-live/ticket.d.ts +2 -1
  89. package/dist/editable-artifact-live/types.d.ts +9 -0
  90. package/dist/editable-artifact-live.js +2 -2
  91. package/dist/editable-artifacts.js +10 -2
  92. package/dist/goal-admission.d.ts +627 -0
  93. package/dist/host-session-usage-limit.d.ts +15 -0
  94. package/dist/index.d.ts +43 -8
  95. package/dist/index.js +22682 -11743
  96. package/dist/index.js.map +1 -1
  97. package/dist/managed-session.d.ts +110 -7
  98. package/dist/model-call.d.ts +58 -0
  99. package/dist/model-catalog.d.ts +29 -3
  100. package/dist/model-provider-credentials.d.ts +43 -0
  101. package/dist/product-usage-metrics.d.ts +30 -0
  102. package/dist/rigs/index.d.ts +1 -13
  103. package/dist/sandbox/fleet.d.ts +58 -4
  104. package/dist/sandbox/routing.d.ts +42 -2
  105. package/dist/sandbox/runtime-settings.d.ts +27 -17
  106. package/dist/session-authorization.d.ts +10 -0
  107. package/dist/session-usage-limit-metrics.d.ts +60 -0
  108. package/dist/session-usage-limits.d.ts +89 -0
  109. package/dist/site-agent-clamp.d.ts +71 -0
  110. package/dist/site-session-origin.d.ts +39 -2
  111. package/dist/transcription.d.ts +120 -3
  112. package/dist/turn-surface.d.ts +20 -0
  113. package/dist/user-presence.d.ts +32 -0
  114. package/dist/workflow-wake-contract.d.ts +13 -1
  115. package/package.json +15 -22
  116. package/src/access/index.ts +961 -38
  117. package/src/access/service-initiator.ts +50 -0
  118. package/src/application/api-integration-servers.ts +9 -0
  119. package/src/application/archived-session-imports.ts +161 -0
  120. package/src/application/composer-submit.ts +3 -2
  121. package/src/application/connect-authority.ts +3 -3
  122. package/src/application/connect-operation.ts +37 -3
  123. package/src/application/external-continuation.ts +20 -3
  124. package/src/application/external-link-work-admission.ts +3 -0
  125. package/src/application/external-workspace-members.ts +51 -3
  126. package/src/application/modal-native-live-declaration-join.ts +196 -0
  127. package/src/application/modal-native-original-configuration.ts +261 -0
  128. package/src/application/modal-native-worker-host-transport.ts +263 -0
  129. package/src/application/new-session-drafts.ts +172 -55
  130. package/src/application/organization-privacy.ts +161 -0
  131. package/src/application/prepared-mcp-connection.ts +133 -0
  132. package/src/application/sandbox-recovery.ts +78 -0
  133. package/src/application/session-commands.ts +73 -1
  134. package/src/application/session-mcp-credential-rotation.ts +61 -18
  135. package/src/application/session-tenancy.ts +262 -44
  136. package/src/application/user-resource-grants.ts +1 -29
  137. package/src/billing/agent-run-admission.ts +117 -0
  138. package/src/billing/limits.ts +138 -14
  139. package/src/billing/model-call-admission.ts +118 -0
  140. package/src/billing/model-usage-settlement.ts +472 -0
  141. package/src/codex-model-availability.ts +264 -0
  142. package/src/default-session-model.ts +692 -0
  143. package/src/dependencies.ts +48 -6
  144. package/src/developer-setup-credential-ceiling.ts +60 -0
  145. package/src/domain/agent-config-resolution.ts +148 -0
  146. package/src/domain/automations.ts +76 -2
  147. package/src/domain/capabilities.ts +830 -181
  148. package/src/domain/connection-account-selection-error.ts +19 -0
  149. package/src/domain/connector-tool-permissions.ts +572 -0
  150. package/src/domain/direct-model-provider.ts +62 -0
  151. package/src/domain/editable-artifacts/agent-application.ts +44 -6
  152. package/src/domain/editable-artifacts/durable-export.ts +100 -5
  153. package/src/domain/editable-artifacts/in-memory.ts +6 -0
  154. package/src/domain/editable-artifacts/ports.ts +11 -0
  155. package/src/domain/editable-artifacts/service.ts +20 -0
  156. package/src/domain/editable-artifacts/types.ts +1 -1
  157. package/src/domain/encryption-key.ts +26 -0
  158. package/src/domain/file-owner.ts +16 -1
  159. package/src/domain/github-action-policies.ts +1 -1
  160. package/src/domain/github-skill-source.ts +3 -1
  161. package/src/domain/insights-timing.ts +95 -0
  162. package/src/domain/insights-usage.ts +67 -0
  163. package/src/domain/insights.ts +167 -43
  164. package/src/domain/knowledge-files.ts +10 -3
  165. package/src/domain/knowledge-messages.ts +1 -0
  166. package/src/domain/knowledge-preparation.test.ts +276 -0
  167. package/src/domain/knowledge-preparation.ts +147 -0
  168. package/src/domain/knowledge-search.test.ts +617 -5
  169. package/src/domain/knowledge-search.ts +240 -23
  170. package/src/domain/mcp-account-bindings.ts +169 -0
  171. package/src/domain/mcp-account-routes.ts +152 -0
  172. package/src/domain/native-mcp-connection-admission.ts +14 -0
  173. package/src/domain/organization-integration-catalog.ts +88 -0
  174. package/src/domain/personal-connection-delegations.ts +399 -257
  175. package/src/domain/pr-review.ts +24 -8
  176. package/src/domain/realtime-voice-billing.ts +425 -0
  177. package/src/domain/resources.ts +50 -4
  178. package/src/domain/scheduled-task-access.ts +1089 -0
  179. package/src/domain/scheduled-task-account-audit.ts +90 -0
  180. package/src/domain/scheduled-task-audience.ts +249 -0
  181. package/src/domain/scheduled-tasks.ts +1313 -374
  182. package/src/domain/session-connection-accounts.ts +239 -0
  183. package/src/domain/session-file-access.ts +47 -0
  184. package/src/domain/session-native-connection-replacement.ts +64 -0
  185. package/src/domain/session-start-timing.ts +60 -0
  186. package/src/domain/session-tool-policy.ts +489 -14
  187. package/src/domain/sessions.ts +2254 -717
  188. package/src/domain/site-audience.ts +130 -0
  189. package/src/domain/skills.ts +9 -0
  190. package/src/domain/slack-bot.ts +141 -4
  191. package/src/domain/slack-publication-secret-safety.ts +1 -1
  192. package/src/domain/vaults/index.ts +19 -0
  193. package/src/domain/vaults/mapping.ts +190 -0
  194. package/src/domain/vaults/provider-registry.ts +201 -0
  195. package/src/domain/vaults/provider-sync.ts +531 -0
  196. package/src/domain/vaults/provider-writes.ts +288 -0
  197. package/src/domain/vaults/secret-resolver.ts +464 -0
  198. package/src/domain/vaults/types.ts +26 -0
  199. package/src/domain/vaults/vault-service.ts +1316 -0
  200. package/src/domain/voice-input-billing.ts +330 -0
  201. package/src/domain/web-search-billing.ts +169 -0
  202. package/src/domain/workspace-credits.ts +13 -0
  203. package/src/editable-artifact-live/application.ts +8 -1
  204. package/src/editable-artifact-live/server.ts +154 -6
  205. package/src/editable-artifact-live/ticket.ts +86 -1
  206. package/src/editable-artifact-live/types.ts +10 -0
  207. package/src/goal-admission.ts +220 -0
  208. package/src/host-session-usage-limit.ts +25 -0
  209. package/src/index.ts +75 -11
  210. package/src/managed-session.ts +137 -6
  211. package/src/model-call.ts +79 -0
  212. package/src/model-catalog.ts +345 -91
  213. package/src/model-provider-credentials.ts +394 -0
  214. package/src/product-usage-metrics.ts +101 -0
  215. package/src/rigs/index.ts +23 -111
  216. package/src/sandbox/fleet.ts +253 -28
  217. package/src/sandbox/routing.ts +204 -43
  218. package/src/sandbox/runtime-settings.ts +159 -85
  219. package/src/session-authorization.ts +172 -23
  220. package/src/session-usage-limit-metrics.ts +95 -0
  221. package/src/session-usage-limits.ts +415 -0
  222. package/src/site-agent-clamp.ts +226 -0
  223. package/src/site-session-origin.ts +84 -5
  224. package/src/transcription.ts +123 -4
  225. package/src/turn-surface.ts +47 -0
  226. package/src/user-presence.ts +134 -0
  227. package/src/workflow-wake-contract.ts +14 -1
  228. package/dist/application/host-mcp-owner.d.ts +0 -6
  229. package/dist/application/host-mcp-resolvers.d.ts +0 -13
  230. package/dist/chunk-5WZYFL45.js +0 -266
  231. package/dist/chunk-5WZYFL45.js.map +0 -1
  232. package/dist/chunk-H7X52RI2.js.map +0 -1
  233. package/dist/chunk-OF65T3PM.js.map +0 -1
  234. package/dist/chunk-VCZFFEPB.js.map +0 -1
  235. package/dist/chunk-ZYCNLYGH.js.map +0 -1
  236. package/dist/domain/connection-authority.d.ts +0 -97
  237. package/dist/domain/connection-authority.js +0 -169
  238. package/dist/domain/connection-authority.js.map +0 -1
  239. package/dist/domain/environments.d.ts +0 -33
  240. package/dist/domain/host-mcp-authority-source-admission.d.ts +0 -10
  241. package/dist/domain/host-mcp-task-admission.d.ts +0 -18
  242. package/dist/domain/packs.d.ts +0 -59
  243. package/dist/domain/product-integration-pack.d.ts +0 -44
  244. package/dist/domain/product-integration-skill.gen.d.ts +0 -5
  245. package/dist/remote-mcp-credentials.d.ts +0 -23
  246. package/dist/remote-mcp-credentials.js +0 -11
  247. package/dist/remote-mcp-credentials.js.map +0 -1
  248. package/src/application/host-mcp-owner.ts +0 -41
  249. package/src/application/host-mcp-resolvers.ts +0 -84
  250. package/src/domain/connection-authority.ts +0 -329
  251. package/src/domain/environments.ts +0 -120
  252. package/src/domain/host-mcp-authority-source-admission.ts +0 -25
  253. package/src/domain/host-mcp-task-admission.ts +0 -121
  254. package/src/domain/packs.ts +0 -699
  255. package/src/domain/product-integration-pack.ts +0 -42
  256. package/src/domain/product-integration-skill.gen.ts +0 -52
  257. package/src/remote-mcp-credentials.ts +0 -361
@@ -0,0 +1,531 @@
1
+ import {
2
+ isValidSecretName,
3
+ VAULT_DEFAULT_PRIMARY_FIELD,
4
+ VAULT_LIMITS,
5
+ type VaultFieldKind,
6
+ type VaultItemType,
7
+ type VaultSyncStatus,
8
+ } from "@opengeni/contracts";
9
+ import {
10
+ getProviderVaultSyncTarget,
11
+ setVaultSyncStatus,
12
+ upsertProviderVaultItems,
13
+ type Database,
14
+ type VaultDbContext,
15
+ } from "@opengeni/db";
16
+ import {
17
+ isVaultProviderError,
18
+ VaultProviderError,
19
+ type ProviderItemMeta,
20
+ type VaultProvider,
21
+ type VaultProviderContext,
22
+ type VaultProviderErrorCode,
23
+ type VaultProviderRegistry,
24
+ } from "@opengeni/vaults";
25
+
26
+ /**
27
+ * Provider vault metadata mirror (design §10.9, OP-1/OP-4). A linked
28
+ * 1Password or host vault keeps a metadata-only mirror (item names, types,
29
+ * websites, field names and kinds); values are always read live. A sync costs
30
+ * one `listVaults` call and skips `listItems` entirely when the provider's
31
+ * `contentVersion` is unchanged, so FAMILY-plan quotas are not burnt on
32
+ * no-op refreshes.
33
+ *
34
+ * The engine is storage-agnostic: the vault service binds `ProviderMirrorStore`
35
+ * to its tables (`vault_items`, `vault_item_fields`, `vaults.sync_status`,
36
+ * `vaults.rate_limited_until`).
37
+ */
38
+
39
+ export interface ProviderMirrorField {
40
+ externalFieldId: string;
41
+ name: string;
42
+ kind: VaultFieldKind;
43
+ position: number;
44
+ }
45
+
46
+ export interface ProviderMirrorItem {
47
+ externalItemId: string;
48
+ /** Valid, vault-unique (case-insensitive) secret name. */
49
+ name: string;
50
+ /** The provider's own name, kept when it had to be sanitized or de-duplicated. */
51
+ providerName: string;
52
+ type: VaultItemType;
53
+ websites: string[];
54
+ fields: ProviderMirrorField[];
55
+ primaryExternalFieldId: string | null;
56
+ hasTotp: boolean;
57
+ version: string | null;
58
+ updatedAt: string | null;
59
+ }
60
+
61
+ export interface ProviderMirrorPlan {
62
+ contentVersion: string | null;
63
+ items: ProviderMirrorItem[];
64
+ /** Items beyond `VAULT_LIMITS.itemsPerVault` that were not mirrored. */
65
+ truncated: number;
66
+ }
67
+
68
+ export interface ProviderSyncStatusUpdate {
69
+ syncStatus: VaultSyncStatus;
70
+ syncErrorCode: VaultProviderErrorCode | null;
71
+ syncErrorMessage: string | null;
72
+ rateLimitedUntil: Date | null;
73
+ /** Set only when the provider was reached and the mirror is current. */
74
+ lastSyncedAt: Date | null;
75
+ }
76
+
77
+ export interface ProviderMirrorStore {
78
+ /** Replace the mirror for this vault in one transaction (upsert by external id, drop missing). */
79
+ replaceMirror(plan: ProviderMirrorPlan): Promise<void>;
80
+ setSyncStatus(update: ProviderSyncStatusUpdate): Promise<void>;
81
+ }
82
+
83
+ export interface ProviderVaultSyncTarget {
84
+ externalVaultId: string;
85
+ /** `contentVersion` recorded by the last successful mirror, if any. */
86
+ mirroredContentVersion: string | null;
87
+ rateLimitedUntil: Date | null;
88
+ }
89
+
90
+ export type ProviderVaultSyncResult =
91
+ | { status: "synced"; contentVersion: string | null; itemCount: number; truncated: number }
92
+ | { status: "unchanged"; contentVersion: string | null }
93
+ | { status: "skipped_rate_limited"; rateLimitedUntil: Date }
94
+ | {
95
+ status: "failed";
96
+ syncStatus: VaultSyncStatus;
97
+ code: VaultProviderErrorCode;
98
+ rateLimitedUntil: Date | null;
99
+ };
100
+
101
+ /** When the provider gives no retry hint, back off this long after a 429. */
102
+ export const PROVIDER_RATE_LIMIT_DEFAULT_BACKOFF_MS = 15 * 60_000;
103
+
104
+ // Same forbidden set as `isValidSecretName` (Q-10).
105
+ // oxlint-disable-next-line no-control-regex
106
+ const FORBIDDEN_NAME_CHARACTERS = /[/{}#\\\u0000-\u001f\u007f-\u009f]/g;
107
+
108
+ function nameKey(name: string): string {
109
+ return name.normalize("NFC").trim().toLowerCase();
110
+ }
111
+
112
+ function truncateChars(value: string, max: number): string {
113
+ const chars = [...value];
114
+ return chars.length > max ? chars.slice(0, max).join("") : value;
115
+ }
116
+
117
+ /** Map a provider item name onto a valid secret name (Q-10). */
118
+ export function providerSecretName(raw: string, fallback = "Untitled"): string {
119
+ let name = truncateChars(
120
+ raw.normalize("NFC").replace(FORBIDDEN_NAME_CHARACTERS, "-").trim(),
121
+ VAULT_LIMITS.nameMaxChars,
122
+ ).trim();
123
+ if (!name) name = fallback;
124
+ if (/^id:/iu.test(name)) name = `id-${name.slice(3)}`.trim() || fallback;
125
+ return isValidSecretName(name) ? name : fallback;
126
+ }
127
+
128
+ /** Appends " (2)", " (3)" … until the name is unique case-insensitively. */
129
+ export function uniqueProviderName(name: string, taken: Set<string>): string {
130
+ let candidate = name;
131
+ for (let n = 2; taken.has(nameKey(candidate)); n += 1) {
132
+ const suffix = ` (${n})`;
133
+ candidate = `${truncateChars(name, VAULT_LIMITS.nameMaxChars - suffix.length).trimEnd()}${suffix}`;
134
+ }
135
+ taken.add(nameKey(candidate));
136
+ return candidate;
137
+ }
138
+
139
+ const PRIMARY_FIELD_FALLBACK = ["password", "value", "credential", "note"] as const;
140
+
141
+ /**
142
+ * The field a bare `vault/item` reference resolves to: the item type's default
143
+ * (§10B.2), then password → value → credential → note by name, then the first
144
+ * concealed field. TOTP fields are never primary (they have `/otp`).
145
+ */
146
+ export function providerPrimaryFieldId(
147
+ item: Pick<ProviderItemMeta, "type" | "fields">,
148
+ ): string | null {
149
+ const candidates = item.fields.filter((field) => field.kind !== "totp");
150
+ const byName = (name: string) =>
151
+ candidates.find((field) => nameKey(field.name) === name)?.externalFieldId ?? null;
152
+ const typeDefault = VAULT_DEFAULT_PRIMARY_FIELD[item.type];
153
+ if (typeDefault) {
154
+ const found = byName(typeDefault.toLowerCase());
155
+ if (found) return found;
156
+ }
157
+ for (const name of PRIMARY_FIELD_FALLBACK) {
158
+ const found = byName(name);
159
+ if (found) return found;
160
+ }
161
+ if (item.type === "file") {
162
+ const file = candidates.find((field) => field.kind === "file");
163
+ if (file) return file.externalFieldId;
164
+ }
165
+ return candidates.find((field) => field.kind === "concealed")?.externalFieldId ?? null;
166
+ }
167
+
168
+ /**
169
+ * Deterministic mirror projection. Items are ordered by provider name then
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.
172
+ */
173
+ export function planProviderMirror(
174
+ items: readonly ProviderItemMeta[],
175
+ contentVersion: string | null,
176
+ ): ProviderMirrorPlan {
177
+ const ordered = [...items].sort(
178
+ (a, b) =>
179
+ nameKey(a.name).localeCompare(nameKey(b.name)) ||
180
+ a.externalItemId.localeCompare(b.externalItemId),
181
+ );
182
+ const kept = ordered.slice(0, VAULT_LIMITS.itemsPerVault);
183
+ const takenItems = new Set<string>();
184
+ const mirrored = kept.map((item): ProviderMirrorItem => {
185
+ const name = uniqueProviderName(providerSecretName(item.name), takenItems);
186
+ const takenFields = new Set<string>();
187
+ const fields = item.fields
188
+ .slice(0, VAULT_LIMITS.fieldsPerItem)
189
+ .map((field, position): ProviderMirrorField => {
190
+ let fieldName = providerSecretName(
191
+ field.name,
192
+ field.kind === "totp" ? "one-time password" : "field",
193
+ );
194
+ if (nameKey(fieldName) === "otp") fieldName = "otp code";
195
+ return {
196
+ externalFieldId: field.externalFieldId,
197
+ name: uniqueProviderName(fieldName, takenFields),
198
+ kind: field.kind,
199
+ position,
200
+ };
201
+ });
202
+ return {
203
+ externalItemId: item.externalItemId,
204
+ name,
205
+ providerName: item.name,
206
+ type: item.type,
207
+ websites: item.websites
208
+ .filter((website) => website.length <= VAULT_LIMITS.websiteMaxChars)
209
+ .slice(0, VAULT_LIMITS.websitesPerItem),
210
+ fields,
211
+ primaryExternalFieldId: providerPrimaryFieldId({ type: item.type, fields: item.fields }),
212
+ hasTotp: item.fields.some((field) => field.kind === "totp"),
213
+ version: item.version,
214
+ updatedAt: item.updatedAt,
215
+ };
216
+ });
217
+ return { contentVersion, items: mirrored, truncated: ordered.length - kept.length };
218
+ }
219
+
220
+ /** Sync status for a provider failure; rate limits persist `rateLimitedUntil`. */
221
+ export function providerSyncFailure(
222
+ error: unknown,
223
+ now: Date,
224
+ ): ProviderSyncStatusUpdate & { syncErrorCode: VaultProviderErrorCode } {
225
+ const code: VaultProviderErrorCode = isVaultProviderError(error)
226
+ ? error.code
227
+ : "provider_unavailable";
228
+ const message = isVaultProviderError(error)
229
+ ? error.message
230
+ : "the vault provider could not be reached";
231
+ if (code === "provider_rate_limited") {
232
+ const retryAfterMs =
233
+ isVaultProviderError(error) && error.retryAfterMs && error.retryAfterMs > 0
234
+ ? error.retryAfterMs
235
+ : PROVIDER_RATE_LIMIT_DEFAULT_BACKOFF_MS;
236
+ return {
237
+ syncStatus: "rate_limited",
238
+ syncErrorCode: code,
239
+ syncErrorMessage: message,
240
+ rateLimitedUntil: new Date(now.getTime() + retryAfterMs),
241
+ lastSyncedAt: null,
242
+ };
243
+ }
244
+ return {
245
+ syncStatus:
246
+ code === "provider_auth_expired" || code === "provider_auth_invalid"
247
+ ? "auth_expired"
248
+ : "error",
249
+ syncErrorCode: code,
250
+ syncErrorMessage: message,
251
+ rateLimitedUntil: null,
252
+ lastSyncedAt: null,
253
+ };
254
+ }
255
+
256
+ /**
257
+ * Refresh one linked vault's mirror. While `rateLimitedUntil` is in the future
258
+ * the provider is not called at all unless `force` is set.
259
+ */
260
+ export async function syncProviderVault(input: {
261
+ provider: VaultProvider;
262
+ ctx: VaultProviderContext;
263
+ target: ProviderVaultSyncTarget;
264
+ store: ProviderMirrorStore;
265
+ now?: () => Date;
266
+ /** Re-list items even when `contentVersion` is unchanged. */
267
+ force?: boolean;
268
+ }): Promise<ProviderVaultSyncResult> {
269
+ const now = input.now ?? (() => new Date());
270
+ const { target } = input;
271
+ if (
272
+ target.rateLimitedUntil &&
273
+ target.rateLimitedUntil.getTime() > now().getTime() &&
274
+ !input.force
275
+ ) {
276
+ return { status: "skipped_rate_limited", rateLimitedUntil: target.rateLimitedUntil };
277
+ }
278
+ try {
279
+ const vaults = await input.provider.listVaults(input.ctx);
280
+ const vault = vaults.find((candidate) => candidate.externalVaultId === target.externalVaultId);
281
+ if (!vault) {
282
+ const failure = providerSyncFailure(
283
+ new VaultProviderError(
284
+ "provider_not_found",
285
+ "the linked provider vault no longer exists or is no longer shared with this connection",
286
+ ),
287
+ now(),
288
+ );
289
+ await input.store.setSyncStatus(failure);
290
+ return {
291
+ status: "failed",
292
+ syncStatus: failure.syncStatus,
293
+ code: failure.syncErrorCode,
294
+ rateLimitedUntil: null,
295
+ };
296
+ }
297
+ if (
298
+ !input.force &&
299
+ vault.contentVersion !== null &&
300
+ vault.contentVersion === target.mirroredContentVersion
301
+ ) {
302
+ await input.store.setSyncStatus({
303
+ syncStatus: "ok",
304
+ syncErrorCode: null,
305
+ syncErrorMessage: null,
306
+ rateLimitedUntil: null,
307
+ lastSyncedAt: now(),
308
+ });
309
+ return { status: "unchanged", contentVersion: vault.contentVersion };
310
+ }
311
+ const items = await input.provider.listItems(input.ctx, target.externalVaultId);
312
+ const plan = planProviderMirror(items, vault.contentVersion);
313
+ await input.store.replaceMirror(plan);
314
+ await input.store.setSyncStatus({
315
+ syncStatus: "ok",
316
+ syncErrorCode: null,
317
+ syncErrorMessage: null,
318
+ rateLimitedUntil: null,
319
+ lastSyncedAt: now(),
320
+ });
321
+ return {
322
+ status: "synced",
323
+ contentVersion: plan.contentVersion,
324
+ itemCount: plan.items.length,
325
+ truncated: plan.truncated,
326
+ };
327
+ } catch (error) {
328
+ if (!isVaultProviderError(error)) throw error;
329
+ const failure = providerSyncFailure(error, now());
330
+ await input.store.setSyncStatus(failure);
331
+ return {
332
+ status: "failed",
333
+ syncStatus: failure.syncStatus,
334
+ code: failure.syncErrorCode,
335
+ rateLimitedUntil: failure.rateLimitedUntil,
336
+ };
337
+ }
338
+ }
339
+
340
+ /** Binds the mirror store to the 0739 routines for one connected vault. */
341
+ export function databaseProviderMirrorStore(
342
+ db: Database,
343
+ context: VaultDbContext,
344
+ vaultId: string,
345
+ ): ProviderMirrorStore {
346
+ return {
347
+ async replaceMirror(plan) {
348
+ await upsertProviderVaultItems(db, context, {
349
+ vaultId,
350
+ contentVersion: plan.contentVersion,
351
+ items: plan.items.map((item) => ({
352
+ providerItemRef: item.externalItemId,
353
+ providerVersion: item.version,
354
+ // Passkeys live only in hosted vaults (D20); providers never mirror them.
355
+ type: item.type === "passkey" ? "login" : item.type,
356
+ // The base name: the routine de-duplicates against the stored mirror
357
+ // so names stay sticky per provider item.
358
+ name: providerSecretName(item.providerName),
359
+ websites: item.websites,
360
+ primaryFieldRef: item.primaryExternalFieldId,
361
+ fields: item.fields.map((field) => ({
362
+ providerFieldRef: field.externalFieldId,
363
+ name: field.name,
364
+ kind: field.kind,
365
+ })),
366
+ })),
367
+ });
368
+ },
369
+ async setSyncStatus(update) {
370
+ await setVaultSyncStatus(db, context, {
371
+ vaultId,
372
+ status: update.syncStatus,
373
+ errorCode: update.syncErrorCode,
374
+ errorMessage: update.syncErrorMessage,
375
+ rateLimitedUntil: update.rateLimitedUntil?.toISOString() ?? null,
376
+ });
377
+ },
378
+ };
379
+ }
380
+
381
+ /**
382
+ * Sync one linked vault the caller reaches: load its provider refs and mirror
383
+ * version, resolve its provider, refresh the mirror and record sync status.
384
+ */
385
+ export async function syncLinkedProviderVault(input: {
386
+ db: Database;
387
+ context: VaultDbContext;
388
+ vaultId: string;
389
+ providers: VaultProviderRegistry;
390
+ budget?: VaultProviderContext["budget"];
391
+ force?: boolean;
392
+ now?: () => Date;
393
+ }): Promise<ProviderVaultSyncResult> {
394
+ const target = await getProviderVaultSyncTarget(input.db, input.context, input.vaultId);
395
+ const store = databaseProviderMirrorStore(input.db, input.context, input.vaultId);
396
+ let provider: VaultProvider;
397
+ try {
398
+ provider = await input.providers.forVault({
399
+ source: target.source,
400
+ providerConnectionId: target.providerConnectionId,
401
+ });
402
+ } catch (error) {
403
+ if (!isVaultProviderError(error)) throw error;
404
+ const failure = providerSyncFailure(error, (input.now ?? (() => new Date()))());
405
+ await store.setSyncStatus(failure);
406
+ return {
407
+ status: "failed",
408
+ syncStatus: failure.syncStatus,
409
+ code: failure.syncErrorCode,
410
+ rateLimitedUntil: failure.rateLimitedUntil,
411
+ };
412
+ }
413
+ return await syncProviderVault({
414
+ provider,
415
+ ctx: {
416
+ connectionId: target.providerConnectionId,
417
+ budget: input.budget ?? { take() {} },
418
+ },
419
+ target: {
420
+ externalVaultId: target.providerVaultRef,
421
+ mirroredContentVersion: target.providerContentVersion,
422
+ rateLimitedUntil: target.rateLimitedUntil ? new Date(target.rateLimitedUntil) : null,
423
+ },
424
+ store,
425
+ ...(input.now ? { now: input.now } : {}),
426
+ ...(input.force ? { force: true } : {}),
427
+ });
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
+ * How long a person opening a linked vault waits for its stale mirror to
438
+ * refresh before the page shows what is mirrored; a slower refresh finishes in
439
+ * the background and the next load shows it.
440
+ */
441
+ export const LINKED_VAULT_OPEN_WAIT_MS = 3_000;
442
+
443
+ /** The vault fields a freshness check needs (a `VaultRow` has them all). */
444
+ export type LinkedVaultFreshness = {
445
+ id: string;
446
+ source: string;
447
+ syncStatus: VaultSyncStatus;
448
+ lastSyncedAt: string | null;
449
+ rateLimitedUntil: string | null;
450
+ updatedAt: string;
451
+ };
452
+
453
+ /**
454
+ * Whether a linked vault's mirror is older than `maxAgeMs`. A vault whose last
455
+ * sync failed counts from that failure (it is recorded as `updatedAt`), so a
456
+ * broken connection is retried once per `maxAgeMs`, not on every call. A vault
457
+ * under a provider rate limit is never stale until the limit lifts.
458
+ */
459
+ export function linkedVaultNeedsRefresh(
460
+ vault: LinkedVaultFreshness,
461
+ maxAgeMs: number,
462
+ now: Date,
463
+ ): boolean {
464
+ if (vault.source === "opengeni") return false;
465
+ if (vault.rateLimitedUntil && new Date(vault.rateLimitedUntil).getTime() > now.getTime()) {
466
+ return false;
467
+ }
468
+ const last =
469
+ vault.syncStatus === "ok" || vault.syncStatus === "syncing"
470
+ ? vault.lastSyncedAt
471
+ : vault.updatedAt;
472
+ if (!last) return true;
473
+ return now.getTime() - new Date(last).getTime() >= maxAgeMs;
474
+ }
475
+
476
+ const linkedVaultRefreshes = new Map<string, Promise<ProviderVaultSyncResult | null>>();
477
+
478
+ /**
479
+ * Bring stale linked vault mirrors up to date, so items added in the provider
480
+ * (for example straight in 1Password) are found without anyone pressing Sync.
481
+ *
482
+ * Each refresh is the ordinary non-forced sync: one `listVaults` call, and an
483
+ * item listing only when the provider says the vault changed. Refreshes of the
484
+ * same vault share one call per process. The caller waits at most `waitMs`;
485
+ * slower refreshes finish in the background and record their own status.
486
+ * Never throws: failures are recorded as the vault's sync status.
487
+ *
488
+ * Returns the ids of the vaults whose mirror changed before the wait ended.
489
+ */
490
+ export async function refreshStaleLinkedVaults(input: {
491
+ db: Database;
492
+ context: VaultDbContext;
493
+ vaults: readonly LinkedVaultFreshness[];
494
+ providers: VaultProviderRegistry;
495
+ maxAgeMs: number;
496
+ waitMs?: number;
497
+ now?: () => Date;
498
+ }): Promise<string[]> {
499
+ const now = (input.now ?? (() => new Date()))();
500
+ const stale = input.vaults.filter((vault) => linkedVaultNeedsRefresh(vault, input.maxAgeMs, now));
501
+ if (stale.length === 0) return [];
502
+ const changed: string[] = [];
503
+ const runs = stale.map((vault) => {
504
+ let run = linkedVaultRefreshes.get(vault.id);
505
+ if (!run) {
506
+ run = syncLinkedProviderVault({
507
+ db: input.db,
508
+ context: input.context,
509
+ vaultId: vault.id,
510
+ providers: input.providers,
511
+ ...(input.now ? { now: input.now } : {}),
512
+ })
513
+ .catch(() => null)
514
+ .finally(() => linkedVaultRefreshes.delete(vault.id));
515
+ linkedVaultRefreshes.set(vault.id, run);
516
+ }
517
+ return run.then((result) => {
518
+ if (result?.status === "synced") changed.push(vault.id);
519
+ });
520
+ });
521
+ let timer: ReturnType<typeof setTimeout> | undefined;
522
+ await Promise.race([
523
+ Promise.all(runs),
524
+ new Promise<void>((resolve) => {
525
+ timer = setTimeout(resolve, input.waitMs ?? LINKED_VAULT_REFRESH_WAIT_MS);
526
+ timer.unref?.();
527
+ }),
528
+ ]);
529
+ if (timer) clearTimeout(timer);
530
+ return [...changed];
531
+ }