diff --git a/.changeset/pretty-chicken-hang.md b/.changeset/pretty-chicken-hang.md new file mode 100644 index 000000000..e538747de --- /dev/null +++ b/.changeset/pretty-chicken-hang.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +➕ install better auth diff --git a/.changeset/rare-pears-sort.md b/.changeset/rare-pears-sort.md new file mode 100644 index 000000000..384ab4c81 --- /dev/null +++ b/.changeset/rare-pears-sort.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🛂 setup better auth diff --git a/docs/astro.config.ts b/docs/astro.config.ts index 5c83c0ebe..f2fc8ce97 100644 --- a/docs/astro.config.ts +++ b/docs/astro.config.ts @@ -15,7 +15,7 @@ export default defineConfig({ { base: "api", schema: "node_modules/@exactly/server/generated/openapi.json", sidebar: { collapsed: false } }, ]), ], - sidebar: openAPISidebarGroups, + sidebar: [{ label: "Docs", items: ["index", "organization-authentication"] }, ...openAPISidebarGroups], }), mermaid(), ], diff --git a/docs/src/content/docs/organization-authentication.md b/docs/src/content/docs/organization-authentication.md new file mode 100644 index 000000000..68a3adce5 --- /dev/null +++ b/docs/src/content/docs/organization-authentication.md @@ -0,0 +1,143 @@ +--- +title: Organizations, authentication and authorization +sidebar: + label: Organizations and authentication + order: 10 +--- + +Creating organizations is permission-less. Any user can create an organization and will be the owner. +Then the owner can add members with admin role and those admins will be able to add more members with different roles. + +Better auth client and viem are the recommended libraries to use for authentication and signing using SIWE. + +## SIWE Authentication + +Example code to authenticate using SIWE, it will create the user if doesn't exist. +Note: Check viem account to use a private key instead of a mnemonic. + +```typescript +import { createAuthClient } from "better-auth/client"; +import { siweClient, organizationClient } from "better-auth/client/plugins"; +import { mnemonicToAccount } from "viem/accounts"; +import { optimismSepolia } from "viem/chains"; +import { createSiweMessage } from "viem/siwe"; + +const chainId = optimismSepolia.id; + +const authClient = createAuthClient({ + baseURL: "http://localhost:3000", + plugins: [siweClient(), organizationClient()], +}); + +const owner = mnemonicToAccount("test test test test test test test test test test test test"); + +authClient.siwe + .nonce({ + walletAddress: owner.address, + chainId, + }) + .then(async ({ data: nonceResult }) => { + //can be any statement + const statement = "i accept exa terms and conditions"; + const nonce = nonceResult?.nonce ?? ""; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce, + uri: "https://localhost", + address: owner.address, + chainId, + scheme: "https", + version: "1", + domain: "localhost", + }); + const signature = await owner.signMessage({ message }); + + await authClient.siwe.verify( + { + message, + signature, + walletAddress: owner.address, + chainId, + }, + { + onSuccess: async (context) => { + // authentication successful, session cookie is now set + }, + onError: (context) => { + console.log("authorization error", context); + }, + }, + ); + }).catch((error: unknown) => { + console.error("nonce error", error); + }); +``` + +## Creating an organization + +owner account will be the owner of the created organization + +```typescript +const chainId = optimismSepolia.id; + +const authClient = createAuthClient({ + baseURL: "http://localhost:3000", + plugins: [siweClient(), organizationClient()], +}); + +const owner = mnemonicToAccount("test test test test test test test test test test test siwe"); + +authClient.siwe + .nonce({ + walletAddress: owner.address, + chainId, + }) + .then(async ({ data: nonceResult }) => { + const statement = `i accept exa terms and conditions`; + const nonce = nonceResult?.nonce ?? ""; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce, + uri: `https://localhost`, + address: owner.address, + chainId, + scheme: "https", + version: "1", + domain: "localhost", + }); + const signature = await owner.signMessage({ message }); + + await authClient.siwe.verify( + { + message, + signature, + walletAddress: owner.address, + chainId, + }, + { + onSuccess: async (context) => { + const headers = new Headers(); + headers.set("cookie", context.response.headers.get("set-cookie") ?? ""); + const createOrganizationResult = await authClient.organization.create({ + fetchOptions: { headers }, + name: "Uphold", + slug: "uphold", + keepCurrentActiveOrganization: false, + }); + if (createOrganizationResult.data) { + console.log(`organization created id: ${createOrganizationResult.data.id}`); + } else { + console.error("Failed to create organization error:", createOrganizationResult.error); + } + }, + onError: (context) => { + console.log("authorization error", context); + }, + }, + ); + }).catch((error: unknown) => { + console.error("nonce error", error); + }); + ``` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25a0ff669..b661d59c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -772,6 +772,9 @@ importers: async-mutex: specifier: ^0.5.0 version: 0.5.0 + better-auth: + specifier: ^1.4.18 + version: 1.4.18(better-sqlite3@12.6.2)(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.17.1))(pg@8.17.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@4.0.17) bullmq: specifier: ^5.66.5 version: 5.66.5 @@ -780,7 +783,7 @@ importers: version: 4.4.3 drizzle-orm: specifier: ^0.45.1 - version: 0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(pg@8.17.1) + version: 0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.17.1) graphql: specifier: ^16.12.0 version: 16.12.0 @@ -851,6 +854,9 @@ importers: '@wagmi/core': specifier: ^3.4.0 version: 3.4.0(@tanstack/query-core@5.90.19)(@types/react@19.1.17)(ox@0.12.4(typescript@5.9.3)(zod@4.3.5))(react@19.1.0)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.1.0))(viem@2.46.3(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.5)) + better-sqlite3: + specifier: ^12.6.2 + version: 12.6.2 drizzle-kit: specifier: ^0.31.8 version: 0.31.8 @@ -1683,6 +1689,27 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@better-auth/core@1.4.18': + resolution: {integrity: sha512-q+awYgC7nkLEBdx2sW0iJjkzgSHlIxGnOpsN1r/O1+a4m7osJNHtfK2mKJSL1I+GfNyIlxJF8WvD/NLuYMpmcg==} + peerDependencies: + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + better-call: 1.1.8 + jose: ^6.1.0 + kysely: ^0.28.5 + nanostores: ^1.0.1 + + '@better-auth/telemetry@1.4.18': + resolution: {integrity: sha512-e5rDF8S4j3Um/0LIVATL2in9dL4lfO2fr2v1Wio4qTMRbfxqnUDTa+6SZtwdeJrbc4O+a3c+IyIpjG9Q/6GpfQ==} + peerDependencies: + '@better-auth/core': 1.4.18 + + '@better-auth/utils@0.3.0': + resolution: {integrity: sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==} + + '@better-fetch/fetch@1.1.21': + resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} + '@braintree/sanitize-url@7.1.1': resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==} @@ -3681,6 +3708,10 @@ packages: resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} engines: {node: ^14.21.3 || >=16} + '@noble/ciphers@2.1.1': + resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} + engines: {node: '>= 20.19.0'} + '@noble/curves@1.9.1': resolution: {integrity: sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==} engines: {node: ^14.21.3 || >=16} @@ -6769,6 +6800,76 @@ packages: peerDependencies: ajv: ^6.14.0 + better-auth@1.4.18: + resolution: {integrity: sha512-bnyifLWBPcYVltH3RhS7CM62MoelEqC6Q+GnZwfiDWNfepXoQZBjEvn4urcERC7NTKgKq5zNBM8rvPvRBa6xcg==} + peerDependencies: + '@lynx-js/react': '*' + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + '@sveltejs/kit': ^2.0.0 + '@tanstack/react-start': ^1.0.0 + '@tanstack/solid-start': ^1.0.0 + better-sqlite3: ^12.0.0 + drizzle-kit: '>=0.31.4' + drizzle-orm: '>=0.41.0' + mongodb: ^6.0.0 || ^7.0.0 + mysql2: ^3.0.0 + next: ^14.0.0 || ^15.0.0 || ^16.0.0 + pg: ^8.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + solid-js: ^1.0.0 + svelte: ^4.0.0 || ^5.0.0 + vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + '@lynx-js/react': + optional: true + '@prisma/client': + optional: true + '@sveltejs/kit': + optional: true + '@tanstack/react-start': + optional: true + '@tanstack/solid-start': + optional: true + better-sqlite3: + optional: true + drizzle-kit: + optional: true + drizzle-orm: + optional: true + mongodb: + optional: true + mysql2: + optional: true + next: + optional: true + pg: + optional: true + prisma: + optional: true + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vitest: + optional: true + vue: + optional: true + + better-call@1.1.8: + resolution: {integrity: sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw==} + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + better-opn@3.0.2: resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==} engines: {node: '>=12.0.0'} @@ -6777,6 +6878,10 @@ packages: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} + better-sqlite3@12.6.2: + resolution: {integrity: sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} + bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} @@ -6784,6 +6889,9 @@ packages: resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} engines: {node: '>=0.6'} + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + birecord@0.1.1: resolution: {integrity: sha512-VUpsf/qykW0heRlC8LooCq28Kxn3mAqKohhDG/49rrsQ1dT1CXyj/pgXS+5BSRzFTR/3DyIBOqQOrGyZOh71Aw==} @@ -7009,6 +7117,9 @@ packages: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -8436,6 +8547,10 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -8784,6 +8899,9 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + filelist@1.0.4: resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} @@ -9040,6 +9158,9 @@ packages: resolution: {integrity: sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==} engines: {node: '>=6'} + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} @@ -9968,6 +10089,10 @@ packages: resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==} engines: {node: '>= 8'} + kysely@0.28.11: + resolution: {integrity: sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==} + engines: {node: '>=20.0.0'} + lan-network@0.1.7: resolution: {integrity: sha512-mnIlAEMu4OyEvUNdzco9xpuB9YVcPkQec+QsgycBCtPZvEqWPCDPfbAE4OJMdBBWpZWtpCn1xw9jJYlwjWI5zQ==} hasBin: true @@ -10687,6 +10812,9 @@ packages: typescript: optional: true + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} @@ -10747,6 +10875,13 @@ packages: nanospinner@1.2.2: resolution: {integrity: sha512-Zt/AmG6qRU3e+WnzGGLuMCEAO/dAu45stNbHY223tUxldaDAeE+FxSPsd9Q+j+paejmm0ZbrNVs5Sraqy3dRxA==} + nanostores@1.1.0: + resolution: {integrity: sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA==} + engines: {node: ^20.0.0 || >=22.0.0} + + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -11348,6 +11483,12 @@ packages: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -11440,6 +11581,9 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + punycode.js@2.3.1: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} @@ -11990,6 +12134,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rou3@0.7.12: + resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} + roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} @@ -12104,6 +12251,9 @@ packages: set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -12199,6 +12349,12 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + simple-plist@1.3.1: resolution: {integrity: sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==} @@ -12540,6 +12696,9 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + tar-stream@2.2.0: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} @@ -12714,6 +12873,9 @@ packages: resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} engines: {node: '>= 6.0.0'} + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -14640,6 +14802,27 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.5))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)': + dependencies: + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + '@standard-schema/spec': 1.1.0 + better-call: 1.1.8(zod@4.3.5) + jose: 6.1.3 + kysely: 0.28.11 + nanostores: 1.1.0 + zod: 4.3.5 + + '@better-auth/telemetry@1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.5))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))': + dependencies: + '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.5))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + + '@better-auth/utils@0.3.0': {} + + '@better-fetch/fetch@1.1.21': {} + '@braintree/sanitize-url@7.1.1': {} '@bufbuild/buf-darwin-arm64@1.63.0': @@ -16887,6 +17070,8 @@ snapshots: '@noble/ciphers@1.3.0': {} + '@noble/ciphers@2.1.1': {} + '@noble/curves@1.9.1': dependencies: '@noble/hashes': 1.8.0 @@ -21249,6 +21434,38 @@ snapshots: jsonpointer: 5.0.1 leven: 3.1.0 + better-auth@1.4.18(better-sqlite3@12.6.2)(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.17.1))(pg@8.17.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@4.0.17): + dependencies: + '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.5))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) + '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.5))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)) + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + '@noble/ciphers': 2.1.1 + '@noble/hashes': 2.0.1 + better-call: 1.1.8(zod@4.3.5) + defu: 6.1.4 + jose: 6.1.3 + kysely: 0.28.11 + nanostores: 1.1.0 + zod: 4.3.5 + optionalDependencies: + better-sqlite3: 12.6.2 + drizzle-kit: 0.31.8 + drizzle-orm: 0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.17.1) + pg: 8.17.1 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + vitest: 4.0.17(@opentelemetry/api@1.9.0)(@types/node@25.0.9)(@vitest/ui@4.0.17)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + + better-call@1.1.8(zod@4.3.5): + dependencies: + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + rou3: 0.7.12 + set-cookie-parser: 2.7.2 + optionalDependencies: + zod: 4.3.5 + better-opn@3.0.2: dependencies: open: 8.4.2 @@ -21257,12 +21474,21 @@ snapshots: dependencies: is-windows: 1.0.2 + better-sqlite3@12.6.2: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 big-integer@1.6.52: {} + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + birecord@0.1.1: {} bl@4.1.0: @@ -21526,6 +21752,8 @@ snapshots: dependencies: readdirp: 5.0.0 + chownr@1.1.4: {} + chownr@3.0.0: {} chrome-launcher@0.15.2: @@ -22316,10 +22544,12 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(pg@8.17.1): + drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.17.1): optionalDependencies: '@opentelemetry/api': 1.9.0 '@types/pg': 8.16.0 + better-sqlite3: 12.6.2 + kysely: 0.28.11 pg: 8.17.1 dset@3.1.4: {} @@ -23292,6 +23522,8 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 + expand-template@2.0.3: {} + expect-type@1.3.0: {} expo-application@7.0.8(expo@54.0.31): @@ -23726,6 +23958,8 @@ snapshots: dependencies: flat-cache: 4.0.1 + file-uri-to-path@1.0.0: {} + filelist@1.0.4: dependencies: minimatch: 5.1.9 @@ -24004,6 +24238,8 @@ snapshots: getenv@2.0.0: {} + github-from-package@0.0.0: {} + github-slugger@2.0.0: {} gitmojis@3.15.0: {} @@ -25071,6 +25307,8 @@ snapshots: klona@2.0.6: {} + kysely@0.28.11: {} + lan-network@0.1.7: {} langium@4.2.1: @@ -26316,6 +26554,8 @@ snapshots: optionalDependencies: typescript: 5.9.3 + mkdirp-classic@0.5.3: {} + mkdirp@1.0.4: {} mlly@1.8.0: @@ -26379,6 +26619,10 @@ snapshots: dependencies: picocolors: 1.1.1 + nanostores@1.1.0: {} + + napi-build-utils@2.0.0: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} @@ -27045,6 +27289,21 @@ snapshots: dependencies: xtend: 4.0.2 + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.86.0 + pump: 3.0.3 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + prelude-ls@1.2.1: {} prettier-linter-helpers@1.0.1: @@ -27135,6 +27394,11 @@ snapshots: proxy-from-env@1.1.0: {} + pump@3.0.3: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode.js@2.3.1: {} punycode@2.3.1: {} @@ -27848,6 +28112,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.59.0 fsevents: 2.3.3 + rou3@0.7.12: {} + roughjs@4.6.6: dependencies: hachure-fill: 0.5.2 @@ -27996,6 +28262,8 @@ snapshots: set-blocking@2.0.0: {} + set-cookie-parser@2.7.2: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -28161,6 +28429,14 @@ snapshots: signal-exit@4.1.0: {} + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + simple-plist@1.3.1: dependencies: bplist-creator: 0.1.0 @@ -28596,6 +28872,13 @@ snapshots: tapable@2.3.0: {} + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.3 + tar-stream: 2.2.0 + tar-stream@2.2.0: dependencies: bl: 4.1.0 @@ -28758,6 +29041,10 @@ snapshots: dependencies: tslib: 1.14.1 + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 diff --git a/server/api/index.ts b/server/api/index.ts index ec7c91328..6f8d35507 100644 --- a/server/api/index.ts +++ b/server/api/index.ts @@ -11,6 +11,7 @@ import passkey from "./passkey"; import pax from "./pax"; import ramp from "./ramp"; import appOrigin from "../utils/appOrigin"; +import auth from "../utils/auth"; const api = new Hono() .use(cors({ origin: [appOrigin, "http://localhost:8081"], credentials: true, exposeHeaders: ["X-Session-Id"] })) @@ -26,7 +27,8 @@ const api = new Hono() .route("/kyc", kyc) .route("/passkey", passkey) // eslint-disable-line @typescript-eslint/no-deprecated -- // TODO remove .route("/pax", pax) - .route("/ramp", ramp); + .route("/ramp", ramp) + .on(["POST", "GET"], "/auth/*", (c) => auth.handler(c.req.raw)); export default api; export type ExaAPI = typeof api; diff --git a/server/database/index.ts b/server/database/index.ts index d288cb2f3..32f57021e 100644 --- a/server/database/index.ts +++ b/server/database/index.ts @@ -1,3 +1,4 @@ +import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { drizzle } from "drizzle-orm/node-postgres"; import { env } from "node:process"; @@ -5,6 +6,22 @@ import * as schema from "./schema"; if (!env.POSTGRES_URL) throw new Error("missing postgres url"); -export default drizzle(env.POSTGRES_URL, { schema }); +const database = drizzle(env.POSTGRES_URL, { schema }); + +export default database; export * from "./schema"; + +export const authAdapter = drizzleAdapter(database, { + provider: "pg", + schema: { + user: schema.users, + session: schema.sessions, + account: schema.authenticators, + verification: schema.verifications, + walletAddress: schema.walletAddresses, + organization: schema.organizations, + member: schema.members, + invitation: schema.invitations, + }, +}); diff --git a/server/database/schema.ts b/server/database/schema.ts index 1829cc5bb..0e03c9745 100644 --- a/server/database/schema.ts +++ b/server/database/schema.ts @@ -1,6 +1,7 @@ import { relations } from "drizzle-orm"; import { bigint, + boolean, char, customType, index, @@ -12,6 +13,7 @@ import { primaryKey, serial, text, + timestamp, uniqueIndex, } from "drizzle-orm/pg-core"; @@ -108,3 +110,194 @@ export const exaPlugins = substreams.table( }, ({ address, account }) => [primaryKey({ columns: [address, account] })], ); + +export const users = pgTable("users", { + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: boolean("email_verified").default(false).notNull(), + image: text("image"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), +}); + +export const sessions = pgTable( + "sessions", + { + id: text("id").primaryKey(), + expiresAt: timestamp("expires_at").notNull(), + token: text("token").notNull().unique(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + activeOrganizationId: text("active_organization_id"), + }, + (table) => [index("sessions_user_idx").on(table.userId)], +); + +export const authenticators = pgTable( + "authenticators", + { + id: text("id").primaryKey(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: timestamp("access_token_expires_at"), + refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), + scope: text("scope"), + password: text("password"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index("authenticators_user_idx").on(table.userId)], +); + +export const verifications = pgTable( + "verifications", + { + id: text("id").primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index("verifications_identifier_idx").on(table.identifier)], +); + +export const walletAddresses = pgTable( + "wallet_addresses", + { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + address: text("address").notNull(), + chainId: integer("chain_id").notNull(), + isPrimary: boolean("is_primary").default(false), + createdAt: timestamp("created_at").notNull(), + }, + (table) => [index("wallet_addresses_user_idx").on(table.userId)], +); + +export const organizations = pgTable("organizations", { + id: text("id").primaryKey(), + name: text("name").notNull(), + slug: text("slug").notNull().unique(), + logo: text("logo"), + createdAt: timestamp("created_at").notNull(), + metadata: text("metadata"), +}); + +export const members = pgTable( + "members", + { + id: text("id").primaryKey(), + organizationId: text("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + role: text("role").default("member").notNull(), + createdAt: timestamp("created_at").notNull(), + }, + (table) => [index("members_organization_idx").on(table.organizationId), index("members_user_idx").on(table.userId)], +); + +export const invitations = pgTable( + "invitations", + { + id: text("id").primaryKey(), + organizationId: text("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + email: text("email").notNull(), + role: text("role"), + status: text("status").default("pending").notNull(), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + inviterId: text("inviter_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + }, + (table) => [ + index("invitations_organization_idx").on(table.organizationId), + index("invitations_email_idx").on(table.email), + ], +); + +export const usersRelations = relations(users, ({ many }) => ({ + sessions: many(sessions), + authenticators: many(authenticators), + walletAddresses: many(walletAddresses), + members: many(members), + invitations: many(invitations), +})); + +export const sessionsRelations = relations(sessions, ({ one }) => ({ + user: one(users, { + fields: [sessions.userId], + references: [users.id], + }), +})); + +export const authenticatorsRelations = relations(authenticators, ({ one }) => ({ + user: one(users, { + fields: [authenticators.userId], + references: [users.id], + }), +})); + +export const walletAddressesRelations = relations(walletAddresses, ({ one }) => ({ + user: one(users, { + fields: [walletAddresses.userId], + references: [users.id], + }), +})); + +export const organizationsRelations = relations(organizations, ({ many }) => ({ + members: many(members), + invitations: many(invitations), +})); + +export const membersRelations = relations(members, ({ one }) => ({ + organization: one(organizations, { + fields: [members.organizationId], + references: [organizations.id], + }), + user: one(users, { + fields: [members.userId], + references: [users.id], + }), +})); + +export const invitationsRelations = relations(invitations, ({ one }) => ({ + organization: one(organizations, { + fields: [invitations.organizationId], + references: [organizations.id], + }), + user: one(users, { + fields: [invitations.inviterId], + references: [users.id], + }), +})); diff --git a/server/index.ts b/server/index.ts index b3b999717..5c022acec 100644 --- a/server/index.ts +++ b/server/index.ts @@ -26,9 +26,7 @@ import type { UnofficialStatusCode } from "hono/utils/http-status"; const app = new Hono(); app.use(trimTrailingSlash()); - app.route("/api", api); - app.route("/hooks/activity", activityHook); app.route("/hooks/block", block); app.route("/hooks/bridge", bridge); diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index 45c9b5a33..aa423f5cd 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -1,6 +1,7 @@ import { getSignedCookie } from "hono/cookie"; import { createMiddleware } from "hono/factory"; +import betterAuth from "../utils/auth"; import authSecret from "../utils/authSecret"; import type { BlankInput, Env, Input } from "hono/types"; @@ -8,7 +9,14 @@ import type { BlankInput, Env, Input } from "hono/types"; export default function auth() { return createMiddleware(async (c, next) => { const credentialId = await getSignedCookie(c, authSecret, "credential_id"); - if (!credentialId) return c.json({ code: "unauthorized", legacy: "unauthorized" }, 401); + if (!credentialId) { + const session = await betterAuth.api.getSession({ headers: c.req.raw.headers }); + if (session) { + await next(); + return; + } + return c.json({ code: "unauthorized", legacy: "unauthorized" }, 401); + } c.req.addValidatedData("cookie", { credentialId }); await next(); }); diff --git a/server/package.json b/server/package.json index cda9fc932..616e7692e 100644 --- a/server/package.json +++ b/server/package.json @@ -45,6 +45,7 @@ "@types/debug": "^4.1.12", "@valibot/to-json-schema": "^1.5.0", "async-mutex": "^0.5.0", + "better-auth": "^1.4.18", "bullmq": "^5.66.5", "debug": "^4.4.3", "drizzle-orm": "^0.45.1", @@ -73,6 +74,7 @@ "@vitest/coverage-v8": "^4.0.17", "@vitest/ui": "^4.0.17", "@wagmi/core": "catalog:", + "better-sqlite3": "^12.6.2", "drizzle-kit": "^0.31.8", "embedded-postgres": "^18.1.0-beta.15", "eslint": "^9.39.2", diff --git a/server/script/openapi.ts b/server/script/openapi.ts index 124c51240..07ddc0b34 100644 --- a/server/script/openapi.ts +++ b/server/script/openapi.ts @@ -1,12 +1,12 @@ import { generateSpecs } from "hono-openapi"; import { writeFile } from "node:fs/promises"; -import { padHex } from "viem"; +import { padHex, zeroHash } from "viem"; import { version } from "../package.json"; process.env.ALCHEMY_ACTIVITY_ID = "activity"; process.env.ALCHEMY_WEBHOOKS_KEY = "webhooks"; -process.env.AUTH_SECRET = "auth"; +process.env.AUTH_SECRET = zeroHash; process.env.BRIDGE_API_KEY = "bridge"; process.env.BRIDGE_API_URL = "https://bridge.test"; process.env.EXPO_PUBLIC_ALCHEMY_API_KEY = " "; @@ -47,6 +47,7 @@ import("../api") in: "cookie", name: "credential_id", }, + siweAuth: { type: "apiKey", in: "cookie", name: "__Secure-better-auth.session_token" }, }, }, }, diff --git a/server/utils/auth.ts b/server/utils/auth.ts new file mode 100644 index 000000000..2d0d8d940 --- /dev/null +++ b/server/utils/auth.ts @@ -0,0 +1,67 @@ +import { captureException } from "@sentry/core"; +import { betterAuth } from "better-auth"; +import { organization, siwe } from "better-auth/plugins"; +import { createAccessControl } from "better-auth/plugins/access"; +import { adminAc, defaultStatements, memberAc, ownerAc } from "better-auth/plugins/organization/access"; +import { safeParse } from "valibot"; +import { verifyMessage } from "viem"; +import { generateSiweNonce } from "viem/siwe"; + +import domain from "@exactly/common/domain"; +import chain from "@exactly/common/generated/chain"; +import { Address, Hex } from "@exactly/common/validation"; + +import appOrigin from "./appOrigin"; +import authSecret from "./authSecret"; +import { authAdapter } from "../database/index"; +const ac = createAccessControl({ + ...defaultStatements, +}); + +export default betterAuth({ + database: authAdapter, + baseURL: appOrigin, + trustedOrigins: [appOrigin], + secret: authSecret, + user: { changeEmail: { enabled: true } }, + plugins: [ + siwe({ + domain, + emailDomainName: domain === "localhost" ? "localhost.com" : domain, + anonymous: true, + getNonce: () => Promise.resolve(generateSiweNonce()), + verifyMessage: async ({ message, signature, address, chainId }) => { + if (chainId !== chain.id) return false; + + const parsedAddress = safeParse(Address, address); + const parsedSignature = safeParse(Hex, signature); + if (!parsedAddress.success || !parsedSignature.success) return false; + try { + return await verifyMessage({ + address: parsedAddress.output, + message, + signature: parsedSignature.output, + }); + } catch (error) { + captureException(error, { level: "error" }); + return false; + } + }, + }), + organization({ + ac, + roles: { + admin: ac.newRole({ + ...adminAc.statements, + }), + owner: ac.newRole({ + ...ownerAc.statements, + }), + member: ac.newRole({ + ...memberAc.statements, + }), + }, + allowUserToCreateOrganization: () => true, + }), + ], +});