@k2b/cloud 0.6.0-rc.0 → 0.7.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.
Files changed (315) hide show
  1. package/README.md +2 -2
  2. package/package.json +30 -17
  3. package/scripts/browser-performance.ts +14 -0
  4. package/scripts/build-pdf-renderer.ts +39 -0
  5. package/scripts/build.ts +15 -35
  6. package/scripts/preload.ts +9 -12
  7. package/scripts/sync-recovery-smoke.ts +2 -1
  8. package/src/_internal/app-presentation.ts +14 -2
  9. package/src/_internal/capabilities.ts +108 -7
  10. package/src/_internal/capability-streams.ts +81 -0
  11. package/src/_internal/define-app.ts +62 -36
  12. package/src/_internal/heartbeat.ts +4 -0
  13. package/src/_internal/help-catalog.ts +27 -172
  14. package/src/_internal/help.ts +13 -76
  15. package/src/_internal/page-responses.ts +2 -0
  16. package/src/_internal/postgres-application-name.ts +30 -0
  17. package/src/_internal/process-identity.ts +15 -0
  18. package/src/_internal/process-sync.ts +1 -1
  19. package/src/_internal/registry-validation.ts +29 -25
  20. package/src/_internal/registry.ts +2 -53
  21. package/src/_internal/runtime-context.ts +5 -16
  22. package/src/_internal/server-timing.ts +13 -0
  23. package/src/_internal/status-preserving-ssr.ts +18 -6
  24. package/src/_internal/web-vitals-asset.ts +10 -0
  25. package/src/access/PermissionEditor.tsx +14 -153
  26. package/src/access/PrincipalPicker.tsx +104 -0
  27. package/src/access/ui.ts +2 -0
  28. package/src/ai/admin.ts +14 -13
  29. package/src/ai/assistant-models.ts +12 -1
  30. package/src/ai/audio-format.ts +34 -0
  31. package/src/ai/audio-tool.ts +90 -0
  32. package/src/ai/browser-code-contracts.ts +79 -0
  33. package/src/ai/browser.ts +2 -0
  34. package/src/ai/capabilities.ts +55 -36
  35. package/src/ai/capability-execution.ts +47 -12
  36. package/src/ai/chat/blocks.tsx +157 -95
  37. package/src/ai/chat/capability-result.ts +15 -0
  38. package/src/ai/chat/capability-table.tsx +2 -10
  39. package/src/ai/chat/composer-adapter.ts +48 -3
  40. package/src/ai/chat/message-actions.tsx +8 -7
  41. package/src/ai/chat/message-utils.ts +12 -1
  42. package/src/ai/chat/messages.ts +2 -0
  43. package/src/ai/chat/presentation.tsx +19 -48
  44. package/src/ai/chat/primitives.tsx +43 -1
  45. package/src/ai/chat/tool-disclosure.tsx +17 -2
  46. package/src/ai/chat/tool-groups.ts +52 -0
  47. package/src/ai/chat-quotas.ts +25 -0
  48. package/src/ai/chat-task-contracts.ts +9 -1
  49. package/src/ai/chat-tasks.ts +276 -43
  50. package/src/ai/client/controller.ts +110 -33
  51. package/src/ai/client/file-source.ts +12 -6
  52. package/src/ai/client/live-connection.ts +1 -0
  53. package/src/ai/client/projection.ts +22 -11
  54. package/src/ai/client/transport.ts +4 -0
  55. package/src/ai/code-capability-routes.ts +110 -0
  56. package/src/ai/code-capability-transport.ts +38 -0
  57. package/src/ai/code-mode-skill.ts +114 -0
  58. package/src/ai/code-runtime-tools.ts +166 -0
  59. package/src/ai/code-source-contracts.ts +202 -0
  60. package/src/ai/code-source-tools.ts +145 -0
  61. package/src/ai/compaction.ts +27 -6
  62. package/src/ai/data-analysis-skill.ts +10 -0
  63. package/src/ai/default-tools.ts +25 -2
  64. package/src/ai/dictation-runtime.ts +171 -0
  65. package/src/ai/dictations.ts +188 -0
  66. package/src/ai/draft-content.ts +34 -0
  67. package/src/ai/enrich.ts +6 -0
  68. package/src/ai/executor.ts +191 -31
  69. package/src/ai/file-content-version.ts +22 -0
  70. package/src/ai/file-context.ts +2 -1
  71. package/src/ai/file-media-type.ts +12 -0
  72. package/src/ai/file-reference-contracts.ts +15 -0
  73. package/src/ai/file-tools.ts +2 -1
  74. package/src/ai/files-store.ts +174 -71
  75. package/src/ai/fixtures/vision-encrypted.pdf +0 -0
  76. package/src/ai/grids-skill.ts +40 -10
  77. package/src/ai/http.ts +15 -8
  78. package/src/ai/index.ts +26 -2
  79. package/src/ai/inference-calls.ts +198 -0
  80. package/src/ai/live-events.ts +5 -1
  81. package/src/ai/memory-learning.ts +60 -28
  82. package/src/ai/message-queue.ts +167 -0
  83. package/src/ai/migrate.ts +187 -11
  84. package/src/ai/model-pricing.ts +43 -0
  85. package/src/ai/pdf-render-worker.ts +74 -0
  86. package/src/ai/pdf-render.fixture.ts +28 -0
  87. package/src/ai/pdf-render.ts +77 -0
  88. package/src/ai/projects-routes.ts +3 -3
  89. package/src/ai/projects.ts +20 -6
  90. package/src/ai/protocol.ts +3 -0
  91. package/src/ai/provider.ts +1 -8
  92. package/src/ai/quota-provider.ts +191 -0
  93. package/src/ai/quota-report.ts +152 -0
  94. package/src/ai/quotas-migrate.ts +45 -0
  95. package/src/ai/quotas.ts +168 -0
  96. package/src/ai/routes.ts +215 -11
  97. package/src/ai/run-timeout.ts +10 -0
  98. package/src/ai/runtime-tools.ts +3 -0
  99. package/src/ai/runtime.ts +55 -16
  100. package/src/ai/settings.ts +36 -8
  101. package/src/ai/short-id.ts +2 -2
  102. package/src/ai/skill-catalog.ts +0 -10
  103. package/src/ai/skill-search.ts +47 -0
  104. package/src/ai/skill-seeds.ts +105 -20
  105. package/src/ai/skill-tool.ts +16 -5
  106. package/src/ai/skills-routes.ts +32 -4
  107. package/src/ai/skills.ts +324 -155
  108. package/src/ai/store.ts +373 -191
  109. package/src/ai/stream.ts +5 -0
  110. package/src/ai/structured-runs.ts +4 -3
  111. package/src/ai/structured.ts +8 -1
  112. package/src/ai/system-prompt.ts +12 -2
  113. package/src/ai/timeline.ts +2 -5
  114. package/src/ai/todo-contracts.ts +20 -0
  115. package/src/ai/todo-tool.ts +16 -0
  116. package/src/ai/tool-audit.ts +30 -40
  117. package/src/ai/tools.ts +6 -0
  118. package/src/ai/transcription.ts +202 -0
  119. package/src/ai/turn-timing.ts +114 -0
  120. package/src/ai/types.ts +58 -10
  121. package/src/ai/ui.tsx +2 -0
  122. package/src/ai/usage.ts +118 -118
  123. package/src/ai/vision-tool.ts +39 -10
  124. package/src/api/admin-ai-projects.ts +2 -2
  125. package/src/api/admin-ai-quotas.ts +74 -0
  126. package/src/api/admin-ai-skills.ts +48 -5
  127. package/src/api/admin-ai-usage.ts +2 -2
  128. package/src/api/admin-core-settings.ts +25 -0
  129. package/src/api/admin-identity.ts +91 -51
  130. package/src/api/admin-rail.ts +58 -0
  131. package/src/api/announcements.ts +14 -0
  132. package/src/api/capabilities.ts +292 -43
  133. package/src/api/capability-streams.ts +262 -0
  134. package/src/api/help.ts +8 -44
  135. package/src/api/index.ts +6 -0
  136. package/src/api/mcp.ts +44 -26
  137. package/src/api/me-web-vitals.ts +35 -0
  138. package/src/api/me.ts +2 -0
  139. package/src/api/search/schemas.ts +26 -15
  140. package/src/api/search.bench.ts +1 -1
  141. package/src/api/search.ts +54 -17
  142. package/src/browser/CloudResourceSearch.tsx +640 -702
  143. package/src/browser/app-approval-vault.ts +19 -7
  144. package/src/browser/command-bridge.ts +109 -0
  145. package/src/browser/command-shortcuts.ts +76 -0
  146. package/src/browser/commands.ts +66 -0
  147. package/src/browser/navigation-search.ts +48 -0
  148. package/src/browser/notifications.ts +1 -1
  149. package/src/browser/resource-picker.tsx +5 -18
  150. package/src/browser/resource-search-dialog.ts +9 -0
  151. package/src/browser/resource-search-input.ts +46 -0
  152. package/src/browser/resource-search-messages.ts +76 -40
  153. package/src/browser/resource-search.ts +7 -0
  154. package/src/browser/search-bridge.ts +102 -0
  155. package/src/browser/search-commands.ts +59 -0
  156. package/src/browser/search.ts +13 -0
  157. package/src/browser/spotlight-position.ts +208 -0
  158. package/src/browser/testing.ts +5 -0
  159. package/src/browser/web-vitals.ts +25 -0
  160. package/src/capabilities/claims.ts +179 -0
  161. package/src/capabilities/client.ts +34 -0
  162. package/src/capabilities/command-link.ts +16 -0
  163. package/src/capabilities/executions.ts +298 -0
  164. package/src/capabilities/migrate.ts +81 -0
  165. package/src/capabilities/server.ts +114 -34
  166. package/src/capabilities/stream-body.ts +16 -0
  167. package/src/capabilities/streams.ts +28 -0
  168. package/src/cli/admin/ai-quotas.ts +161 -0
  169. package/src/cli/admin/ai-skills.ts +64 -0
  170. package/src/cli/admin/ai-usage.ts +53 -30
  171. package/src/cli/admin/app-credentials.ts +79 -0
  172. package/src/cli/admin/data.ts +17 -0
  173. package/src/cli/admin/index.ts +9 -0
  174. package/src/cli/capabilities.ts +29 -0
  175. package/src/cli/capability-streams.ts +25 -0
  176. package/src/cli/index.ts +3 -2
  177. package/src/config/env.ts +11 -8
  178. package/src/contracts/app.ts +23 -3
  179. package/src/contracts/capabilities.ts +68 -5
  180. package/src/contracts/capability-streams.ts +46 -0
  181. package/src/contracts/commands.ts +49 -0
  182. package/src/contracts/index.ts +6 -1
  183. package/src/contracts/rail-admin.ts +38 -0
  184. package/src/contracts/rail-preferences.ts +16 -0
  185. package/src/contracts/registry.ts +3 -27
  186. package/src/contracts/settings-types.ts +1 -1
  187. package/src/contracts/shared.ts +9 -5
  188. package/src/contracts/web-vitals.ts +27 -0
  189. package/src/desktop/solid.tsx +3 -3
  190. package/src/index.ts +0 -4
  191. package/src/server/index.ts +3 -0
  192. package/src/server/middleware/auth.ts +3 -2
  193. package/src/server/middleware/route-template.ts +9 -3
  194. package/src/server/middleware/runtime.ts +2 -1
  195. package/src/server/middleware/settings.ts +17 -11
  196. package/src/server/services/access-revision.ts +10 -0
  197. package/src/server/services/access.ts +1 -1
  198. package/src/server/services/index.ts +1 -0
  199. package/src/services/account-category-policy.ts +9 -0
  200. package/src/services/account-lifecycle/audit.ts +1 -1
  201. package/src/services/accounts/app.ts +12 -2
  202. package/src/services/accounts/email-write.ts +23 -0
  203. package/src/services/accounts/groups.ts +15 -4
  204. package/src/services/accounts/identities.ts +249 -0
  205. package/src/services/accounts/identity-reconciliation.ts +87 -0
  206. package/src/services/accounts/ipa-data.ts +2 -2
  207. package/src/services/accounts/local-groups.ts +2 -2
  208. package/src/services/accounts/posix.ts +45 -29
  209. package/src/services/accounts/users.ts +2 -2
  210. package/src/services/announcements/index.ts +69 -8
  211. package/src/services/audit/index.ts +1 -1
  212. package/src/services/cache-fill.ts +35 -0
  213. package/src/services/help/index.ts +109 -0
  214. package/src/services/help/maintenance.ts +25 -0
  215. package/src/services/help/store.ts +180 -0
  216. package/src/services/help/types.ts +46 -0
  217. package/src/services/identity/invocation-actor.ts +2 -2
  218. package/src/services/identity/key-config.ts +2 -1
  219. package/src/services/identity/key-ring.ts +1 -1
  220. package/src/services/index.ts +15 -0
  221. package/src/services/ipa/users.ts +268 -221
  222. package/src/services/legal-consent.ts +1 -1
  223. package/src/services/logging/index.ts +15 -13
  224. package/src/services/logging/trace.ts +2 -1
  225. package/src/services/logging/web-vitals.ts +81 -0
  226. package/src/services/mandates/index.ts +18 -4
  227. package/src/services/mandates/policy.ts +65 -2
  228. package/src/services/notifications/batches.ts +124 -57
  229. package/src/services/notifications/browser.ts +12 -0
  230. package/src/services/notifications/index.ts +0 -2
  231. package/src/services/notifications/platform.ts +0 -4
  232. package/src/services/oauth-tokens.ts +3 -3
  233. package/src/services/pdf/gotenberg.ts +107 -84
  234. package/src/services/pdf/index.ts +3 -0
  235. package/src/services/providers/local/users.ts +25 -16
  236. package/src/services/public-http.ts +101 -0
  237. package/src/services/rail-shortcuts.ts +95 -0
  238. package/src/services/rail-snapshot.ts +53 -0
  239. package/src/services/request-cache-redis.ts +28 -0
  240. package/src/services/session/index.ts +15 -2
  241. package/src/services/session/user.ts +62 -37
  242. package/src/services/settings/core-settings.ts +21 -28
  243. package/src/services/settings/defaults.ts +5 -0
  244. package/src/services/settings/store.ts +58 -84
  245. package/src/shared/ai-costs.ts +42 -0
  246. package/src/shared/ai-platform-prompt.ts +1 -1
  247. package/src/shared/ai-quotas.ts +136 -0
  248. package/src/shared/ai-usage.ts +6 -16
  249. package/src/shared/app-presentation.ts +21 -15
  250. package/src/shared/capability-messages.ts +14 -0
  251. package/src/shared/help.ts +6 -42
  252. package/src/shared/index.ts +6 -0
  253. package/src/ssr/AdminLayout.tsx +7 -5
  254. package/src/ssr/AdminSidebar.tsx +29 -15
  255. package/src/ssr/AppLaunchpad.island.tsx +24 -137
  256. package/src/ssr/AppLaunchpadPanel.tsx +181 -0
  257. package/src/ssr/BrowserPushRegistration.island.tsx +12 -0
  258. package/src/ssr/GlobalSearchDialog.tsx +167 -20
  259. package/src/ssr/GlobalSearchTrigger.island.tsx +33 -22
  260. package/src/ssr/Layout.tsx +17 -6
  261. package/src/ssr/LayoutBreadcrumbs.island.tsx +3 -5
  262. package/src/ssr/LayoutFooter.tsx +1 -1
  263. package/src/ssr/LayoutHeader.tsx +46 -14
  264. package/src/ssr/LayoutHelp.tsx +23 -22
  265. package/src/ssr/{HotkeysHelpRail.island.tsx → LayoutHelpTrigger.island.tsx} +18 -12
  266. package/src/ssr/LayoutRail.tsx +17 -9
  267. package/src/ssr/MobileNavigation.tsx +71 -0
  268. package/src/ssr/MobileProfileActions.tsx +49 -0
  269. package/src/ssr/ProfilePreferences.island.tsx +3 -36
  270. package/src/ssr/RailApps.island.tsx +2 -2
  271. package/src/ssr/RailEditor.tsx +17 -2
  272. package/src/ssr/WorkspaceNavigation.island.tsx +7 -0
  273. package/src/ssr/WorkspaceNavigationProvider.tsx +16 -0
  274. package/src/ssr/admin-navigation.ts +7 -1
  275. package/src/ssr/help.ts +29 -0
  276. package/src/ssr/islands/SearchBar.tsx +67 -0
  277. package/src/ssr/islands/index.ts +3 -1
  278. package/src/ssr/layout-context.ts +2 -2
  279. package/src/ssr/layout-help-search.ts +15 -0
  280. package/src/ssr/mobile-menu-history.ts +70 -0
  281. package/src/ssr/platform-messages.ts +16 -8
  282. package/src/ssr/profile-actions.ts +42 -0
  283. package/src/ssr/rail-context.ts +8 -4
  284. package/src/ssr/rail-messages.ts +2 -0
  285. package/src/ssr/rail-navigation.ts +3 -3
  286. package/src/ssr/workspace-navigation.ts +75 -0
  287. package/src/styles/effects.css +6 -6
  288. package/src/styles/global.css +54 -0
  289. package/src/styles/input.css +43 -41
  290. package/src/styles/resource-search.css +505 -0
  291. package/src/styles/utilities-feedback.css +13 -0
  292. package/src/styles/utilities-layout.css +1 -5
  293. package/src/styles/utilities-navigation.css +3 -39
  294. package/src/types/ambient.d.ts +5 -0
  295. package/src/workflows/ai/runtime.ts +5 -1
  296. package/src/workflows/ai/store.ts +2 -0
  297. package/src/workflows/ai-actions.ts +16 -13
  298. package/src/workflows/definition.ts +10 -5
  299. package/src/workflows/store/actions.ts +44 -20
  300. package/src/workflows/store/index.ts +2 -0
  301. package/src/workflows/store/runs.ts +36 -0
  302. package/src/workflows/store/worker-pool.ts +55 -0
  303. package/src/workflows/store/worker-runtime.ts +90 -0
  304. package/scripts/app-favicon.test.ts +0 -49
  305. package/src/ai/chat/blocks.render.test.tsx +0 -1108
  306. package/src/ai/kit-skill.ts +0 -22
  307. package/src/ai/usage-migrate.ts +0 -81
  308. package/src/browser/CloudResourceSearch.behavior.test.tsx +0 -74
  309. package/src/browser/notification-target.ts +0 -27
  310. package/src/contracts/notification-live.ts +0 -66
  311. package/src/services/notifications/live.ts +0 -50
  312. package/src/ssr/BrowserNotifications.island.tsx +0 -135
  313. package/src/ssr/GlobalSearchDialog.behavior.test.tsx +0 -96
  314. package/src/ssr/Layout.render.test.tsx +0 -148
  315. package/src/ssr/islands/SearchBar.island.tsx +0 -80
@@ -4,7 +4,7 @@ import type { AccessSubject } from "../../server/services/access";
4
4
  import { isAccountCategoryAllowed } from "../account-category-policy";
5
5
  import { isAccountExpired } from "../account-model";
6
6
  import type { ServiceAccount } from "../service-accounts";
7
- import { buildProjectedUser, loadCurrentUser, userProjectionSql } from "../session/user";
7
+ import { buildProjectedUser, loadCurrentUser, userProjectionJoin, userProjectionSql } from "../session/user";
8
8
  import type { CloudInvocationClaims } from "./invocation-token";
9
9
  import { getIdentityRuntimeConfig } from "./runtime-config";
10
10
 
@@ -92,7 +92,7 @@ export const resolveInvocationAuthority = async (
92
92
  ${userProjectionSql(groupsAdmin)}
93
93
  FROM auth.service_accounts sa
94
94
  LEFT JOIN auth.users u ON u.id = sa.delegated_user_id
95
- LEFT JOIN auth.user_ipa_data ui ON ui.user_id = u.id
95
+ ${userProjectionJoin()}
96
96
  WHERE sa.id = ${claims.sub}::uuid
97
97
  AND sa.status = 'active'
98
98
  `;
@@ -1,4 +1,5 @@
1
1
  import { crypto } from "@k2b/stdlib";
2
+ import { getProcessApplicationId } from "../../_internal/process-identity";
2
3
 
3
4
  const KEY_PATTERN = /^[0-9a-f]{64}$/i;
4
5
 
@@ -23,7 +24,7 @@ const identify = async (key: string): Promise<{ id: string; key: string }> => ({
23
24
  });
24
25
 
25
26
  export const readIdentityKeyEncryptionConfig = async (): Promise<IdentityKeyEncryptionConfig> => {
26
- if ((process.env.APP_ID ?? "").trim() !== "core") {
27
+ if (getProcessApplicationId() !== "core") {
27
28
  throw new Error("Private Cloud identity keys may only be loaded by the Core application");
28
29
  }
29
30
  const current = parseKey("CLOUD_IDENTITY_KEY_ENCRYPTION_KEY", process.env.CLOUD_IDENTITY_KEY_ENCRYPTION_KEY, true)!;
@@ -181,7 +181,7 @@ const insertKey = async (
181
181
  purpose, state, kid, alg, public_jwk, encrypted_private_jwk, encryption_key_id,
182
182
  created_at, activate_at, activated_at, sign_until, verify_until
183
183
  ) VALUES (
184
- ${purpose}, ${state}, ${generated.kid}, ${CLOUD_IDENTITY_ALGORITHM}, ${JSON.stringify(generated.publicJwk)}::jsonb,
184
+ ${purpose}, ${state}, ${generated.kid}, ${CLOUD_IDENTITY_ALGORITHM}, ${JSON.stringify(generated.publicJwk)}::text::jsonb,
185
185
  ${generated.encryptedPrivateJwk}, ${generated.encryptionKeyId}, ${now}, ${activateAt},
186
186
  ${state === "active" ? now : null}, ${signUntil}, ${verifyUntil}
187
187
  )
@@ -4,7 +4,11 @@ export { accounts } from "./accounts";
4
4
  export { accountsAppService } from "./accounts";
5
5
  export { posix as linuxIdentities, PosixError } from "./accounts/posix";
6
6
  export type { PosixCandidate } from "./accounts/posix";
7
+ export { accountIdentities, AccountIdentityError } from "./accounts/identities";
8
+ export type { AccountIdentityUser, AccountIdentityGroup, AccountIdentityPage, AccountIdentityAvailability } from "./accounts/identities";
9
+ export type { AccountIdentityReconciliation } from "./accounts/identity-reconciliation";
7
10
  export { providers } from "./providers";
11
+ export { requestPublicHttps, type PublicHttpInput } from "./public-http";
8
12
  export { authFlows } from "./auth-flows";
9
13
  export { toPgIntArray, toPgTextArray, toPgUuidArray, escapeLikePattern, isUniqueViolation } from "./postgres";
10
14
 
@@ -144,6 +148,10 @@ export {
144
148
  MarkdownPdfError,
145
149
  buildMarkdownPdfHtml,
146
150
  getGotenbergConfig,
151
+ attachPdfFiles,
152
+ attachPdfFilesWithConfig,
153
+ renderFacturXHtmlToPdf,
154
+ renderFacturXHtmlToPdfWithConfig,
147
155
  mergePdfs,
148
156
  mergePdfsWithConfig,
149
157
  renderMarkdownToPdf,
@@ -155,6 +163,8 @@ export {
155
163
  } from "./pdf";
156
164
  export type {
157
165
  GotenbergConfig,
166
+ AttachPdfFilesInput,
167
+ RenderFacturXHtmlToPdfInput,
158
168
  GotenbergRenderErrorCode,
159
169
  MarkdownPdfErrorCode,
160
170
  MarkdownPdfTemplateId,
@@ -185,3 +195,8 @@ export { latestTopicCursor } from "./topic-cursor";
185
195
  export { readAccountCategoryPolicy, isAccountCategoryAllowed } from "./account-category-policy";
186
196
  export { appApproval, type AppDeviceEnrollmentNotice } from "./app-approval";
187
197
  export { legalConsent } from "./legal-consent";
198
+
199
+ /** Core-owned app bar administration; service methods enforce administrator access. */
200
+ export { railShortcuts } from "./rail-shortcuts";
201
+
202
+ export type { WebVitalsOverview, WebVitalsQuery, WebVitalMetric, WebVitalName } from "./logging/web-vitals";
@@ -4,6 +4,7 @@ import type { MutationResult, UserProfile } from "../../contracts/shared";
4
4
  import { freeipa } from "../../server/services";
5
5
  import { writeDeletedAccountAudit } from "../account-lifecycle/audit";
6
6
  import { resolveProviderProfile } from "../accounts/base-user";
7
+ import { emailAlreadyUsed, findAccountsByEmail, lockAccountEmail, normalizeAccountEmail } from "../accounts/email-write";
7
8
  import { logger } from "../logging";
8
9
  import { session } from "../session";
9
10
  import * as settings from "../settings";
@@ -47,34 +48,36 @@ const log = logger("auth:ipa");
47
48
  * profile patches call `patchUserIpaData` — otherwise SSH keys, uid_number,
48
49
  * password expiry, etc. get wiped.
49
50
  */
50
- const upsertUserIpaData = async (params: {
51
- userId: string;
52
- uidNumber?: number | null;
53
- primaryGidNumber?: number | null;
54
- homeDirectory?: string | null;
55
- loginShell?: string | null;
56
- phone?: string | null;
57
- employeeType?: string | null;
58
- mobile?: string | null;
59
- street?: string | null;
60
- postalCode?: string | null;
61
- city?: string | null;
62
- state?: string | null;
63
- passwordExpires?: Date | null;
64
- lastLoginIpa?: Date | null;
65
- syncedAt?: Date | null;
66
- sshPublicKeys?: string[];
67
- sshFingerprints?: string[];
68
- }) => {
69
- await sql.begin(async (tx) => {
70
- await mirrorIpaPosix(tx, {
71
- userId: params.userId,
72
- uidNumber: params.uidNumber ?? null,
73
- primaryGidNumber: params.primaryGidNumber ?? null,
74
- homeDirectory: params.homeDirectory ?? null,
75
- loginShell: params.loginShell ?? null,
76
- });
77
- await tx`
51
+ const upsertUserIpaData = async (
52
+ params: {
53
+ userId: string;
54
+ uidNumber?: number | null;
55
+ primaryGidNumber?: number | null;
56
+ homeDirectory?: string | null;
57
+ loginShell?: string | null;
58
+ phone?: string | null;
59
+ employeeType?: string | null;
60
+ mobile?: string | null;
61
+ street?: string | null;
62
+ postalCode?: string | null;
63
+ city?: string | null;
64
+ state?: string | null;
65
+ passwordExpires?: Date | null;
66
+ lastLoginIpa?: Date | null;
67
+ syncedAt?: Date | null;
68
+ sshPublicKeys?: string[];
69
+ sshFingerprints?: string[];
70
+ },
71
+ tx: typeof sql,
72
+ ) => {
73
+ await mirrorIpaPosix(tx, {
74
+ userId: params.userId,
75
+ uidNumber: params.uidNumber ?? null,
76
+ primaryGidNumber: params.primaryGidNumber ?? null,
77
+ homeDirectory: params.homeDirectory ?? null,
78
+ loginShell: params.loginShell ?? null,
79
+ });
80
+ await tx`
78
81
  INSERT INTO auth.user_ipa_data (
79
82
  user_id, uid_number, phone, employee_type, mobile, addr_street, addr_postal_code,
80
83
  addr_city, addr_state, ipa_password_expires, last_login_ipa, synced_at, ssh_public_keys, ssh_fingerprints
@@ -110,7 +113,6 @@ const upsertUserIpaData = async (params: {
110
113
  ssh_public_keys = EXCLUDED.ssh_public_keys,
111
114
  ssh_fingerprints = EXCLUDED.ssh_fingerprints
112
115
  `;
113
- });
114
116
  };
115
117
 
116
118
  /**
@@ -119,19 +121,22 @@ const upsertUserIpaData = async (params: {
119
121
  * If no row exists yet the UPDATE is a no-op; callers that need to guarantee a
120
122
  * row must use `upsertUserIpaData` first.
121
123
  */
122
- const patchUserIpaData = async (params: {
123
- userId: string;
124
- phone?: string | null;
125
- street?: string | null;
126
- postalCode?: string | null;
127
- city?: string | null;
128
- state?: string | null;
129
- sshPublicKeys?: string[];
130
- sshFingerprints?: string[];
131
- syncedAt?: Date | null;
132
- }) => {
124
+ const patchUserIpaData = async (
125
+ params: {
126
+ userId: string;
127
+ phone?: string | null;
128
+ street?: string | null;
129
+ postalCode?: string | null;
130
+ city?: string | null;
131
+ state?: string | null;
132
+ sshPublicKeys?: string[];
133
+ sshFingerprints?: string[];
134
+ syncedAt?: Date | null;
135
+ },
136
+ tx: typeof sql,
137
+ ) => {
133
138
  const has = (v: unknown) => v !== undefined;
134
- await sql`
139
+ await tx`
135
140
  UPDATE auth.user_ipa_data SET
136
141
  phone = CASE WHEN ${has(params.phone)} THEN ${params.phone ?? null} ELSE phone END,
137
142
  addr_street = CASE WHEN ${has(params.street)} THEN ${params.street ?? null} ELSE addr_street END,
@@ -236,85 +241,91 @@ export const addIpa = async (params: {
236
241
  const unavailable = await ensureFreeIpaMutationAvailable();
237
242
  if (unavailable) return unavailable;
238
243
  const { ipaSession, data } = params;
239
- const { email, givenname, sn } = data;
240
- const targetProfile = params.profile ?? "user";
241
-
242
- const displayName = data.displayName || `${givenname} ${sn}`;
243
-
244
- // Check if a local account with this email already exists (provider switch case)
245
- const existingLocalRows: DbRow[] = await sql`
246
- SELECT id, uid FROM auth.users WHERE mail = ${email} AND provider = 'local'
247
- `;
248
-
249
- // Use existing guest UID or generate a new one
250
- let uid: string;
251
- if (existingLocalRows.length > 0) {
252
- uid = existingLocalRows[0]!.uid as string;
253
- } else {
254
- try {
255
- const abbrLen = await settings.get<number>("user.abbr_length");
256
- uid = await generateUniqueAbbreviation(abbrLen);
257
- } catch (e) {
258
- return {
259
- ok: false,
260
- error: e instanceof Error ? e.message : "Failed to generate UID",
261
- status: 500,
262
- };
244
+ const { givenname, sn } = data;
245
+ let promotedUserId: string | undefined;
246
+ const result = await sql.begin(async (tx): Promise<MutationResult<AddIpaResult>> => {
247
+ await lockAccountEmail(tx, data.email);
248
+ const targetProfile = params.profile ?? "user";
249
+
250
+ const displayName = data.displayName || `${givenname} ${sn}`;
251
+
252
+ const matches = await findAccountsByEmail(tx, data.email);
253
+ const locals = matches.filter((row) => row.provider === "local");
254
+ // Preserve an exact legacy match; otherwise promotion must identify one account.
255
+ const local = locals.find((row) => row.mail === data.email) ?? (locals.length === 1 ? locals[0] : undefined);
256
+ if (!local && matches.length > 0) return emailAlreadyUsed();
257
+ const existingLocalRows = local ? [local] : [];
258
+ const email = local?.mail ?? normalizeAccountEmail(data.email);
259
+
260
+ // Use existing guest UID or generate a new one
261
+ let uid: string;
262
+ if (existingLocalRows.length > 0) {
263
+ uid = existingLocalRows[0]!.uid as string;
264
+ } else {
265
+ try {
266
+ const abbrLen = await settings.get<number>("user.abbr_length");
267
+ uid = await generateUniqueAbbreviation(abbrLen);
268
+ } catch (e) {
269
+ return {
270
+ ok: false,
271
+ error: e instanceof Error ? e.message : "Failed to generate UID",
272
+ status: 500,
273
+ };
274
+ }
263
275
  }
264
- }
265
276
 
266
- if ((await uidExists(uid)) && existingLocalRows.length === 0) {
267
- return { ok: false, error: `UID '${uid}' already exists`, status: 400 };
268
- }
277
+ if ((await uidExists(uid)) && existingLocalRows.length === 0) {
278
+ return { ok: false, error: `UID '${uid}' already exists`, status: 400 };
279
+ }
269
280
 
270
- const temporaryPassword = generateFreeIpaPassword();
271
- const accountExpiry = params.accountExpires === undefined ? await calculateAccountExpiration() : params.accountExpires;
272
- const now = new Date();
273
-
274
- const ipaOpts: Record<string, unknown> = {
275
- givenname,
276
- sn,
277
- cn: displayName,
278
- displayname: displayName,
279
- mail: email,
280
- userpassword: temporaryPassword,
281
- };
282
- if (accountExpiry) {
283
- ipaOpts.krbprincipalexpiration = freeipa.util.toGeneralizedTime(accountExpiry);
284
- }
281
+ const temporaryPassword = generateFreeIpaPassword();
282
+ const accountExpiry = params.accountExpires === undefined ? await calculateAccountExpiration() : params.accountExpires;
283
+ const now = new Date();
284
+
285
+ const ipaOpts: Record<string, unknown> = {
286
+ givenname,
287
+ sn,
288
+ cn: displayName,
289
+ displayname: displayName,
290
+ mail: email,
291
+ userpassword: temporaryPassword,
292
+ };
293
+ if (accountExpiry) {
294
+ ipaOpts.krbprincipalexpiration = freeipa.util.toGeneralizedTime(accountExpiry);
295
+ }
285
296
 
286
- const response = await freeipa.client.call({ url: await getIpaUrl(), ipaSession, method: "user_add", args: [uid], options: ipaOpts });
287
- if (response.error) {
288
- const code = response.error.code;
289
- if (code === 4001)
290
- return {
291
- ok: false,
292
- error: "IPA session expired. Please log in again.",
293
- status: 401,
294
- };
295
- if (code === 4301)
297
+ const response = await freeipa.client.call({ url: await getIpaUrl(), ipaSession, method: "user_add", args: [uid], options: ipaOpts });
298
+ if (response.error) {
299
+ const code = response.error.code;
300
+ if (code === 4001)
301
+ return {
302
+ ok: false,
303
+ error: "IPA session expired. Please log in again.",
304
+ status: 401,
305
+ };
306
+ if (code === 4301)
307
+ return {
308
+ ok: false,
309
+ error: "You don't have permission to create users.",
310
+ status: 403,
311
+ };
296
312
  return {
297
313
  ok: false,
298
- error: "You don't have permission to create users.",
299
- status: 403,
314
+ error: response.error.message || "Failed to create user.",
315
+ status: 400,
300
316
  };
301
- return {
302
- ok: false,
303
- error: response.error.message || "Failed to create user.",
304
- status: 400,
305
- };
306
- }
317
+ }
307
318
 
308
- // Extract uidNumber from IPA response
309
- const ipaResult = response.result?.result as Record<string, unknown> | undefined;
310
- const uidNumber = ipaResult ? freeipa.util.num(ipaResult.uidnumber) : null;
319
+ // Extract uidNumber from IPA response
320
+ const ipaResult = response.result?.result as Record<string, unknown> | undefined;
321
+ const uidNumber = ipaResult ? freeipa.util.num(ipaResult.uidnumber) : null;
311
322
 
312
- let id: string;
313
- try {
314
- if (existingLocalRows.length > 0) {
315
- // Provider switch: update existing local account to IPA user
316
- const guestId = existingLocalRows[0]!.id as string;
317
- const updateRows: DbRow[] = await sql`
323
+ let id: string;
324
+ try {
325
+ if (existingLocalRows.length > 0) {
326
+ // Provider switch: update existing local account to IPA user
327
+ const guestId = existingLocalRows[0]!.id as string;
328
+ const updateRows: DbRow[] = await tx`
318
329
  UPDATE auth.users SET
319
330
  provider = 'ipa',
320
331
  profile = ${targetProfile},
@@ -327,20 +338,23 @@ export const addIpa = async (params: {
327
338
  WHERE id = ${guestId}
328
339
  RETURNING id
329
340
  `;
330
- id = updateRows[0]!.id as string;
331
- await upsertUserIpaData({
332
- userId: id,
333
- uidNumber,
334
- primaryGidNumber: ipaResult ? freeipa.util.num(ipaResult.gidnumber) : null,
335
- homeDirectory: ipaResult ? freeipa.util.str(ipaResult.homedirectory) || null : null,
336
- loginShell: ipaResult ? freeipa.util.str(ipaResult.loginshell) || null : null,
337
- passwordExpires: now,
338
- syncedAt: now,
339
- });
340
- await session.revokeAllForUser(guestId);
341
- } else {
342
- // New user: insert
343
- const insertRows: DbRow[] = await sql`
341
+ id = updateRows[0]!.id as string;
342
+ await upsertUserIpaData(
343
+ {
344
+ userId: id,
345
+ uidNumber,
346
+ primaryGidNumber: ipaResult ? freeipa.util.num(ipaResult.gidnumber) : null,
347
+ homeDirectory: ipaResult ? freeipa.util.str(ipaResult.homedirectory) || null : null,
348
+ loginShell: ipaResult ? freeipa.util.str(ipaResult.loginshell) || null : null,
349
+ passwordExpires: now,
350
+ syncedAt: now,
351
+ },
352
+ tx,
353
+ );
354
+ promotedUserId = guestId;
355
+ } else {
356
+ // New user: insert
357
+ const insertRows: DbRow[] = await tx`
344
358
  INSERT INTO auth.users (uid, provider, profile, admin, given_name, sn, display_name, mail, account_expires)
345
359
  VALUES (${uid}, 'ipa', ${targetProfile}, false, ${givenname}, ${sn}, ${displayName}, ${email}, ${accountExpiry})
346
360
  ON CONFLICT (uid) DO UPDATE SET
@@ -354,38 +368,44 @@ export const addIpa = async (params: {
354
368
  account_expires = EXCLUDED.account_expires
355
369
  RETURNING id
356
370
  `;
357
- id = insertRows[0]!.id as string;
358
- await upsertUserIpaData({
359
- userId: id,
371
+ id = insertRows[0]!.id as string;
372
+ await upsertUserIpaData(
373
+ {
374
+ userId: id,
375
+ uidNumber,
376
+ primaryGidNumber: ipaResult ? freeipa.util.num(ipaResult.gidnumber) : null,
377
+ homeDirectory: ipaResult ? freeipa.util.str(ipaResult.homedirectory) || null : null,
378
+ loginShell: ipaResult ? freeipa.util.str(ipaResult.loginshell) || null : null,
379
+ passwordExpires: now,
380
+ syncedAt: now,
381
+ },
382
+ tx,
383
+ );
384
+ }
385
+ } catch (dbError) {
386
+ log.error("CRITICAL: FreeIPA user created but local DB update failed. Manual reconciliation needed.", {
387
+ uid,
388
+ email,
389
+ userId: existingLocalRows.length > 0 ? (existingLocalRows[0]!.id as string) : null,
360
390
  uidNumber,
361
- primaryGidNumber: ipaResult ? freeipa.util.num(ipaResult.gidnumber) : null,
362
- homeDirectory: ipaResult ? freeipa.util.str(ipaResult.homedirectory) || null : null,
363
- loginShell: ipaResult ? freeipa.util.str(ipaResult.loginshell) || null : null,
364
- passwordExpires: now,
365
- syncedAt: now,
391
+ targetProfile,
392
+ error: dbError instanceof Error ? dbError.message : String(dbError),
366
393
  });
394
+ throw dbError;
367
395
  }
368
- } catch (dbError) {
369
- log.error("CRITICAL: FreeIPA user created but local DB update failed. Manual reconciliation needed.", {
370
- uid,
371
- email,
372
- userId: existingLocalRows.length > 0 ? (existingLocalRows[0]!.id as string) : null,
373
- uidNumber,
374
- targetProfile,
375
- error: dbError instanceof Error ? dbError.message : String(dbError),
376
- });
377
- throw dbError;
378
- }
379
396
 
380
- return {
381
- ok: true,
382
- data: {
383
- id,
384
- uid,
385
- accountExpires: accountExpiry ? accountExpiry.toISOString() : null,
386
- _temporaryPassword: temporaryPassword,
387
- },
388
- };
397
+ return {
398
+ ok: true,
399
+ data: {
400
+ id,
401
+ uid,
402
+ accountExpires: accountExpiry ? accountExpiry.toISOString() : null,
403
+ _temporaryPassword: temporaryPassword,
404
+ },
405
+ };
406
+ });
407
+ if (result.ok && promotedUserId) await session.revokeAllForUser(promotedUserId);
408
+ return result;
389
409
  };
390
410
 
391
411
  // ==========================
@@ -400,74 +420,100 @@ export const updateProfile = async (params: {
400
420
  id: string;
401
421
  data: IpaPatchData;
402
422
  }): Promise<MutationResult<void>> => {
403
- const { ipaSession, id, data } = params;
404
-
405
- const userRows: DbRow[] = await sql`SELECT uid, provider, profile FROM auth.users WHERE id = ${id}`;
406
- if (userRows.length === 0) {
407
- return { ok: false, error: "User not found", status: 404 };
408
- }
409
- const uid = userRows[0]!.uid as string;
410
- const { provider } = resolveProviderProfile(userRows[0]!);
411
-
412
- if (provider === "ipa") {
413
- const unavailable = await ensureFreeIpaMutationAvailable();
414
- if (unavailable) return unavailable;
415
- if (!ipaSession) {
416
- return {
417
- ok: false,
418
- error: "IPA session required to update IPA user",
419
- status: 400,
420
- };
423
+ const { ipaSession, id } = params;
424
+ const data = { ...params.data };
425
+ return sql.begin(async (tx): Promise<MutationResult<void>> => {
426
+ if (data.mail !== undefined) await lockAccountEmail(tx, data.mail);
427
+ else await tx`LOCK TABLE auth.users IN ROW EXCLUSIVE MODE`;
428
+
429
+ const userRows: DbRow[] = await tx`SELECT uid, provider, profile, mail FROM auth.users WHERE id = ${id} FOR UPDATE`;
430
+ if (userRows.length === 0) {
431
+ return { ok: false, error: "User not found", status: 404 };
421
432
  }
422
-
423
- const ipaOptions: Record<string, unknown> = {};
424
- if (data.givenname !== undefined) ipaOptions.givenname = data.givenname;
425
- if (data.sn !== undefined) ipaOptions.sn = data.sn;
426
- if (data.displayName !== undefined) ipaOptions.displayname = data.displayName;
427
- if (data.mail !== undefined) ipaOptions.mail = data.mail || "";
428
- if (data.ipa?.phone !== undefined) ipaOptions.telephonenumber = data.ipa.phone || "";
429
- if (data.ipa?.address?.street !== undefined) ipaOptions.street = data.ipa.address.street || "";
430
- if (data.ipa?.address?.postalCode !== undefined) ipaOptions.postalcode = data.ipa.address.postalCode || "";
431
- if (data.ipa?.address?.city !== undefined) ipaOptions.l = data.ipa.address.city || "";
432
- if (data.ipa?.address?.state !== undefined) ipaOptions.st = data.ipa.address.state || "";
433
- if (data.ipa?.sshPublicKeys !== undefined) {
434
- ipaOptions.ipasshpubkey = data.ipa.sshPublicKeys.length > 0 ? data.ipa.sshPublicKeys : "";
433
+ if (data.mail !== undefined) {
434
+ const email = normalizeAccountEmail(data.mail);
435
+ const existingEmail = (userRows[0]!.mail as string | null) ?? "";
436
+ if (email === normalizeAccountEmail(existingEmail)) {
437
+ // Keep the stored spelling: legacy provider/mail uniqueness is case-sensitive.
438
+ delete data.mail;
439
+ } else {
440
+ if (email && (await findAccountsByEmail(tx, email)).some((row) => row.id !== id)) return emailAlreadyUsed();
441
+ data.mail = email;
442
+ }
435
443
  }
436
-
437
- const response = await freeipa.client.call({
438
- url: await getIpaUrl(),
439
- ipaSession,
440
- method: "user_mod",
441
- args: [uid],
442
- options: ipaOptions,
443
- });
444
- if (response.error) {
445
- return {
446
- ok: false,
447
- error: response.error.message ?? "Failed to update user in FreeIPA",
448
- status: freeipa.util.mapIpaErrorCode(response.error.code),
449
- };
444
+ const uid = userRows[0]!.uid as string;
445
+ const { provider } = resolveProviderProfile(userRows[0]!);
446
+
447
+ if (provider === "ipa") {
448
+ const unavailable = await ensureFreeIpaMutationAvailable();
449
+ if (unavailable) return unavailable;
450
+ if (!ipaSession) {
451
+ return {
452
+ ok: false,
453
+ error: "IPA session required to update IPA user",
454
+ status: 400,
455
+ };
456
+ }
457
+
458
+ const ipaOptions: Record<string, unknown> = {};
459
+ if (data.givenname !== undefined) ipaOptions.givenname = data.givenname;
460
+ if (data.sn !== undefined) ipaOptions.sn = data.sn;
461
+ if (data.displayName !== undefined) ipaOptions.displayname = data.displayName;
462
+ if (data.mail !== undefined) ipaOptions.mail = data.mail || "";
463
+ if (data.ipa?.phone !== undefined) ipaOptions.telephonenumber = data.ipa.phone || "";
464
+ if (data.ipa?.address?.street !== undefined) ipaOptions.street = data.ipa.address.street || "";
465
+ if (data.ipa?.address?.postalCode !== undefined) ipaOptions.postalcode = data.ipa.address.postalCode || "";
466
+ if (data.ipa?.address?.city !== undefined) ipaOptions.l = data.ipa.address.city || "";
467
+ if (data.ipa?.address?.state !== undefined) ipaOptions.st = data.ipa.address.state || "";
468
+ if (data.ipa?.sshPublicKeys !== undefined) {
469
+ ipaOptions.ipasshpubkey = data.ipa.sshPublicKeys.length > 0 ? data.ipa.sshPublicKeys : "";
470
+ }
471
+
472
+ const response = await freeipa.client.call({
473
+ url: await getIpaUrl(),
474
+ ipaSession,
475
+ method: "user_mod",
476
+ args: [uid],
477
+ options: ipaOptions,
478
+ });
479
+ if (response.error) {
480
+ return {
481
+ ok: false,
482
+ error: response.error.message ?? "Failed to update user in FreeIPA",
483
+ status: freeipa.util.mapIpaErrorCode(response.error.code),
484
+ };
485
+ }
486
+
487
+ const result = response.result?.result as Record<string, unknown> | undefined;
488
+ // Partial patch: never touch uid_number, password expiry, last-login, or
489
+ // other full-sync fields when the user is only editing profile attributes.
490
+ await patchUserIpaData(
491
+ {
492
+ userId: id,
493
+ phone: data.ipa?.phone,
494
+ street: data.ipa?.address?.street,
495
+ postalCode: data.ipa?.address?.postalCode,
496
+ city: data.ipa?.address?.city,
497
+ state: data.ipa?.address?.state,
498
+ sshPublicKeys:
499
+ data.ipa?.sshPublicKeys !== undefined
500
+ ? Array.isArray(result?.ipasshpubkey)
501
+ ? (result?.ipasshpubkey as string[])
502
+ : []
503
+ : undefined,
504
+ sshFingerprints:
505
+ data.ipa?.sshPublicKeys !== undefined
506
+ ? Array.isArray(result?.sshpubkeyfp)
507
+ ? (result?.sshpubkeyfp as string[])
508
+ : []
509
+ : undefined,
510
+ syncedAt: new Date(),
511
+ },
512
+ tx,
513
+ );
450
514
  }
451
515
 
452
- const result = response.result?.result as Record<string, unknown> | undefined;
453
- // Partial patch: never touch uid_number, password expiry, last-login, or
454
- // other full-sync fields when the user is only editing profile attributes.
455
- await patchUserIpaData({
456
- userId: id,
457
- phone: data.ipa?.phone,
458
- street: data.ipa?.address?.street,
459
- postalCode: data.ipa?.address?.postalCode,
460
- city: data.ipa?.address?.city,
461
- state: data.ipa?.address?.state,
462
- sshPublicKeys:
463
- data.ipa?.sshPublicKeys !== undefined ? (Array.isArray(result?.ipasshpubkey) ? (result?.ipasshpubkey as string[]) : []) : undefined,
464
- sshFingerprints:
465
- data.ipa?.sshPublicKeys !== undefined ? (Array.isArray(result?.sshpubkeyfp) ? (result?.sshpubkeyfp as string[]) : []) : undefined,
466
- syncedAt: new Date(),
467
- });
468
- }
469
-
470
- await sql`
516
+ await tx`
471
517
  UPDATE auth.users
472
518
  SET given_name = CASE WHEN ${data.givenname !== undefined} THEN ${data.givenname ?? ""} ELSE given_name END,
473
519
  sn = CASE WHEN ${data.sn !== undefined} THEN ${data.sn ?? ""} ELSE sn END,
@@ -475,11 +521,12 @@ export const updateProfile = async (params: {
475
521
  WHERE id = ${id}
476
522
  `;
477
523
 
478
- if (data.mail !== undefined) {
479
- await sql`UPDATE auth.users SET mail = ${data.mail || null} WHERE id = ${id}`;
480
- }
524
+ if (data.mail !== undefined) {
525
+ await tx`UPDATE auth.users SET mail = ${data.mail || null} WHERE id = ${id}`;
526
+ }
481
527
 
482
- return { ok: true, data: undefined };
528
+ return { ok: true, data: undefined };
529
+ });
483
530
  };
484
531
 
485
532
  // ==========================