diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..261eeb9
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/README.md b/README.md
index e215bc4..7f57ae7 100644
--- a/README.md
+++ b/README.md
@@ -1,36 +1,108 @@
-This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
+# Project Synapsis
-## Getting Started
+An open-source, federated social network designed as global communication infrastructure.
-First, run the development server:
+## Features
-```bash
-npm run dev
-# or
-yarn dev
-# or
-pnpm dev
-# or
-bun dev
-```
+- **Federation**: ActivityPub-compatible, interoperable with Mastodon and other fediverse platforms
+- **Global Identity**: Portable identity backed by DIDs - your handle works everywhere
+- **Easy Deployment**: Deploy to Vercel with one click, no Docker or complex setup required
+- **Modern UX**: Clean, Vercel-inspired dark theme with responsive design
+- **Transparent Feeds**: Chronological and curated feeds with clear algorithms
+- **Moderation**: Admin dashboard for reports, post removals, and user actions
-Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
+## Quick Start
-You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
+### Prerequisites
-This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
+- Node.js 18+
+- A Neon PostgreSQL database (free tier available at https://neon.tech)
+- An Upstash Redis database (free tier available at https://upstash.com)
-## Learn More
+### Setup
-To learn more about Next.js, take a look at the following resources:
+1. Clone the repository:
+ ```bash
+ git clone https://github.com/your-username/synapsis.git
+ cd synapsis
+ ```
-- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
-- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
+2. Install dependencies:
+ ```bash
+ npm install
+ ```
-You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
+3. Copy the environment example and configure:
+ ```bash
+ cp .env.example .env
+ ```
-## Deploy on Vercel
+4. Update `.env` with your database credentials:
+ ```
+ DATABASE_URL=postgresql://...
+ UPSTASH_REDIS_REST_URL=https://...
+ UPSTASH_REDIS_REST_TOKEN=...
+ AUTH_SECRET=generate-a-random-string
+ NEXT_PUBLIC_NODE_DOMAIN=your-domain.com
+ NEXT_PUBLIC_NODE_NAME=My Synapsis Node
+ ADMIN_EMAILS=comma,separated,emails
+ ```
-The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
+5. Run database migrations:
+ ```bash
+ npm run db:push
+ ```
-Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+6. Start the development server:
+ ```bash
+ npm run dev
+ ```
+
+### Deploy to Vercel
+
+[](https://vercel.com/new/clone?repository-url=https://github.com/your-username/synapsis)
+
+1. Click the button above
+2. Connect your Neon PostgreSQL and Upstash Redis
+3. Configure environment variables
+4. Deploy!
+
+## Architecture
+
+- **Frontend**: Next.js 14+ with App Router
+- **Backend**: Next.js API Routes (serverless)
+- **Database**: PostgreSQL via Neon
+- **Cache**: Redis via Upstash
+- **Federation**: ActivityPub protocol
+
+## API Endpoints
+
+### Authentication
+- `POST /api/auth/register` - Create account
+- `POST /api/auth/login` - Login
+- `POST /api/auth/logout` - Logout
+- `GET /api/auth/me` - Get current user
+
+### Posts
+- `POST /api/posts` - Create a post
+- `GET /api/posts` - Get feed
+- `POST /api/posts/[id]/like` - Like a post
+- `DELETE /api/posts/[id]/like` - Unlike a post
+- `POST /api/posts/[id]/repost` - Repost
+
+### Federation
+- `GET /.well-known/webfinger` - WebFinger discovery
+- `GET /.well-known/nodeinfo` - NodeInfo discovery
+- `GET /nodeinfo/2.1` - Node metadata
+- `GET /.well-known/synapsis-handles` - Handle registry export
+- `POST /api/federation/handles` - Ingest handle registry from peers
+- `POST /api/federation/gossip` - Pull registry from peer nodes (admin)
+- `GET /api/handles/resolve` - Resolve handle to DID
+
+### Installation
+- `GET /install` - Setup wizard
+- `GET /api/install/status` - Installation status
+
+## License
+
+Apache 2.0 License - see LICENSE file for details.
diff --git a/drizzle.config.ts b/drizzle.config.ts
new file mode 100644
index 0000000..654e617
--- /dev/null
+++ b/drizzle.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from 'drizzle-kit';
+
+export default defineConfig({
+ schema: './src/db/schema.ts',
+ out: './drizzle',
+ dialect: 'postgresql',
+ dbCredentials: {
+ url: process.env.DATABASE_URL!,
+ },
+});
diff --git a/package-lock.json b/package-lock.json
index 81f67b5..b29041f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,15 +8,27 @@
"name": "synapsis",
"version": "0.1.0",
"dependencies": {
+ "@neondatabase/serverless": "^0.10.4",
+ "@upstash/redis": "^1.34.3",
+ "bcryptjs": "^2.4.3",
+ "crypto-js": "^4.2.0",
+ "drizzle-orm": "^0.44.1",
+ "jose": "^6.0.11",
"next": "16.1.4",
+ "next-auth": "^5.0.0-beta.25",
"react": "19.2.3",
- "react-dom": "19.2.3"
+ "react-dom": "19.2.3",
+ "uuid": "^11.1.0",
+ "zod": "^4.3.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+ "@types/bcryptjs": "^2.4.6",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
+ "@types/uuid": "^10.0.0",
+ "drizzle-kit": "^0.31.8",
"eslint": "^9",
"eslint-config-next": "16.1.4",
"tailwindcss": "^4",
@@ -36,6 +48,35 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/@auth/core": {
+ "version": "0.41.0",
+ "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.0.tgz",
+ "integrity": "sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==",
+ "license": "ISC",
+ "dependencies": {
+ "@panva/hkdf": "^1.2.1",
+ "jose": "^6.0.6",
+ "oauth4webapi": "^3.3.0",
+ "preact": "10.24.3",
+ "preact-render-to-string": "6.5.11"
+ },
+ "peerDependencies": {
+ "@simplewebauthn/browser": "^9.0.1",
+ "@simplewebauthn/server": "^9.0.2",
+ "nodemailer": "^6.8.0"
+ },
+ "peerDependenciesMeta": {
+ "@simplewebauthn/browser": {
+ "optional": true
+ },
+ "@simplewebauthn/server": {
+ "optional": true
+ },
+ "nodemailer": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@babel/code-frame": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
@@ -277,6 +318,13 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@drizzle-team/brocli": {
+ "version": "0.10.2",
+ "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
+ "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
"node_modules/@emnapi/core": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
@@ -310,6 +358,884 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@esbuild-kit/core-utils": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz",
+ "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==",
+ "deprecated": "Merged into tsx: https://tsx.is",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "~0.18.20",
+ "source-map-support": "^0.5.21"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz",
+ "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz",
+ "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz",
+ "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz",
+ "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz",
+ "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz",
+ "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz",
+ "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz",
+ "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz",
+ "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz",
+ "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz",
+ "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz",
+ "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz",
+ "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz",
+ "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz",
+ "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz",
+ "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz",
+ "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz",
+ "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz",
+ "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz",
+ "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz",
+ "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz",
+ "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": {
+ "version": "0.18.20",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz",
+ "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/android-arm": "0.18.20",
+ "@esbuild/android-arm64": "0.18.20",
+ "@esbuild/android-x64": "0.18.20",
+ "@esbuild/darwin-arm64": "0.18.20",
+ "@esbuild/darwin-x64": "0.18.20",
+ "@esbuild/freebsd-arm64": "0.18.20",
+ "@esbuild/freebsd-x64": "0.18.20",
+ "@esbuild/linux-arm": "0.18.20",
+ "@esbuild/linux-arm64": "0.18.20",
+ "@esbuild/linux-ia32": "0.18.20",
+ "@esbuild/linux-loong64": "0.18.20",
+ "@esbuild/linux-mips64el": "0.18.20",
+ "@esbuild/linux-ppc64": "0.18.20",
+ "@esbuild/linux-riscv64": "0.18.20",
+ "@esbuild/linux-s390x": "0.18.20",
+ "@esbuild/linux-x64": "0.18.20",
+ "@esbuild/netbsd-x64": "0.18.20",
+ "@esbuild/openbsd-x64": "0.18.20",
+ "@esbuild/sunos-x64": "0.18.20",
+ "@esbuild/win32-arm64": "0.18.20",
+ "@esbuild/win32-ia32": "0.18.20",
+ "@esbuild/win32-x64": "0.18.20"
+ }
+ },
+ "node_modules/@esbuild-kit/esm-loader": {
+ "version": "2.6.5",
+ "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz",
+ "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==",
+ "deprecated": "Merged into tsx: https://tsx.is",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@esbuild-kit/core-utils": "^3.3.2",
+ "get-tsconfig": "^4.7.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
@@ -1035,6 +1961,16 @@
"@tybys/wasm-util": "^0.10.0"
}
},
+ "node_modules/@neondatabase/serverless": {
+ "version": "0.10.4",
+ "resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-0.10.4.tgz",
+ "integrity": "sha512-2nZuh3VUO9voBauuh+IGYRhGU/MskWHt1IuZvHcJw6GLjDgtqj/KViKo7SIrLdGLdot7vFbiRRw+BgEy3wT9HA==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@types/pg": "8.11.6"
+ }
+ },
"node_modules/@next/env": {
"version": "16.1.4",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.4.tgz",
@@ -1227,6 +2163,15 @@
"node": ">=12.4.0"
}
},
+ "node_modules/@panva/hkdf": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
+ "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1525,6 +2470,13 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@types/bcryptjs": {
+ "version": "2.4.6",
+ "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
+ "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1550,12 +2502,22 @@
"version": "20.19.30",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz",
"integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==",
- "dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
+ "node_modules/@types/pg": {
+ "version": "8.11.6",
+ "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.6.tgz",
+ "integrity": "sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "pg-protocol": "*",
+ "pg-types": "^4.0.1"
+ }
+ },
"node_modules/@types/react": {
"version": "19.2.9",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz",
@@ -1577,6 +2539,13 @@
"@types/react": "^19.2.0"
}
},
+ "node_modules/@types/uuid": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
+ "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.53.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.1.tgz",
@@ -2116,6 +3085,16 @@
"win32"
]
},
+ "node_modules/@upstash/redis": {
+ "version": "1.36.1",
+ "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.36.1.tgz",
+ "integrity": "sha512-N6SjDcgXdOcTAF+7uNoY69o7hCspe9BcA7YjQdxVu5d25avljTwyLaHBW3krWjrP0FfocgMk94qyVtQbeDp39A==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "uncrypto": "^0.1.3"
+ }
+ },
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -2419,6 +3398,12 @@
"baseline-browser-mapping": "dist/cli.js"
}
},
+ "node_modules/bcryptjs": {
+ "version": "2.4.3",
+ "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
+ "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
+ "license": "MIT"
+ },
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -2478,6 +3463,13 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/buffer-from": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
@@ -2630,6 +3622,12 @@
"node": ">= 8"
}
},
+ "node_modules/crypto-js": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
+ "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
+ "license": "MIT"
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -2782,6 +3780,147 @@
"node": ">=0.10.0"
}
},
+ "node_modules/drizzle-kit": {
+ "version": "0.31.8",
+ "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.8.tgz",
+ "integrity": "sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@drizzle-team/brocli": "^0.10.2",
+ "@esbuild-kit/esm-loader": "^2.5.5",
+ "esbuild": "^0.25.4",
+ "esbuild-register": "^3.5.0"
+ },
+ "bin": {
+ "drizzle-kit": "bin.cjs"
+ }
+ },
+ "node_modules/drizzle-orm": {
+ "version": "0.44.7",
+ "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.44.7.tgz",
+ "integrity": "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "@aws-sdk/client-rds-data": ">=3",
+ "@cloudflare/workers-types": ">=4",
+ "@electric-sql/pglite": ">=0.2.0",
+ "@libsql/client": ">=0.10.0",
+ "@libsql/client-wasm": ">=0.10.0",
+ "@neondatabase/serverless": ">=0.10.0",
+ "@op-engineering/op-sqlite": ">=2",
+ "@opentelemetry/api": "^1.4.1",
+ "@planetscale/database": ">=1.13",
+ "@prisma/client": "*",
+ "@tidbcloud/serverless": "*",
+ "@types/better-sqlite3": "*",
+ "@types/pg": "*",
+ "@types/sql.js": "*",
+ "@upstash/redis": ">=1.34.7",
+ "@vercel/postgres": ">=0.8.0",
+ "@xata.io/client": "*",
+ "better-sqlite3": ">=7",
+ "bun-types": "*",
+ "expo-sqlite": ">=14.0.0",
+ "gel": ">=2",
+ "knex": "*",
+ "kysely": "*",
+ "mysql2": ">=2",
+ "pg": ">=8",
+ "postgres": ">=3",
+ "sql.js": ">=1",
+ "sqlite3": ">=5"
+ },
+ "peerDependenciesMeta": {
+ "@aws-sdk/client-rds-data": {
+ "optional": true
+ },
+ "@cloudflare/workers-types": {
+ "optional": true
+ },
+ "@electric-sql/pglite": {
+ "optional": true
+ },
+ "@libsql/client": {
+ "optional": true
+ },
+ "@libsql/client-wasm": {
+ "optional": true
+ },
+ "@neondatabase/serverless": {
+ "optional": true
+ },
+ "@op-engineering/op-sqlite": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@planetscale/database": {
+ "optional": true
+ },
+ "@prisma/client": {
+ "optional": true
+ },
+ "@tidbcloud/serverless": {
+ "optional": true
+ },
+ "@types/better-sqlite3": {
+ "optional": true
+ },
+ "@types/pg": {
+ "optional": true
+ },
+ "@types/sql.js": {
+ "optional": true
+ },
+ "@upstash/redis": {
+ "optional": true
+ },
+ "@vercel/postgres": {
+ "optional": true
+ },
+ "@xata.io/client": {
+ "optional": true
+ },
+ "better-sqlite3": {
+ "optional": true
+ },
+ "bun-types": {
+ "optional": true
+ },
+ "expo-sqlite": {
+ "optional": true
+ },
+ "gel": {
+ "optional": true
+ },
+ "knex": {
+ "optional": true
+ },
+ "kysely": {
+ "optional": true
+ },
+ "mysql2": {
+ "optional": true
+ },
+ "pg": {
+ "optional": true
+ },
+ "postgres": {
+ "optional": true
+ },
+ "prisma": {
+ "optional": true
+ },
+ "sql.js": {
+ "optional": true
+ },
+ "sqlite3": {
+ "optional": true
+ }
+ }
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -3002,6 +4141,62 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/esbuild-register": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz",
+ "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.3.4"
+ },
+ "peerDependencies": {
+ "esbuild": ">=0.12 <1"
+ }
+ },
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -3217,6 +4412,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -4405,6 +5601,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
+ "node_modules/jose": {
+ "version": "6.1.3",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
+ "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5007,6 +6212,33 @@
}
}
},
+ "node_modules/next-auth": {
+ "version": "5.0.0-beta.30",
+ "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.30.tgz",
+ "integrity": "sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==",
+ "license": "ISC",
+ "dependencies": {
+ "@auth/core": "0.41.0"
+ },
+ "peerDependencies": {
+ "@simplewebauthn/browser": "^9.0.1",
+ "@simplewebauthn/server": "^9.0.2",
+ "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0",
+ "nodemailer": "^7.0.7",
+ "react": "^18.2.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@simplewebauthn/browser": {
+ "optional": true
+ },
+ "@simplewebauthn/server": {
+ "optional": true
+ },
+ "nodemailer": {
+ "optional": true
+ }
+ }
+ },
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@@ -5042,6 +6274,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/oauth4webapi": {
+ "version": "3.8.3",
+ "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.3.tgz",
+ "integrity": "sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -5165,6 +6406,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/obuf": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz",
+ "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==",
+ "license": "MIT"
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -5273,6 +6520,48 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-numeric": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/pg-numeric/-/pg-numeric-1.0.2.tgz",
+ "integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.11.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz",
+ "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.1.0.tgz",
+ "integrity": "sha512-o2XFanIMy/3+mThw69O8d4n1E5zsLhdO+OPqswezu7Z5ekP4hYDqlDjlmOpYMbzY2Br0ufCwJLdDIXeNVwcWFg==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "pg-numeric": "1.0.2",
+ "postgres-array": "~3.0.1",
+ "postgres-bytea": "~3.0.0",
+ "postgres-date": "~2.1.0",
+ "postgres-interval": "^3.0.0",
+ "postgres-range": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -5331,6 +6620,71 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/postgres-array": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz",
+ "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz",
+ "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==",
+ "license": "MIT",
+ "dependencies": {
+ "obuf": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz",
+ "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz",
+ "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/postgres-range": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.4.tgz",
+ "integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==",
+ "license": "MIT"
+ },
+ "node_modules/preact": {
+ "version": "10.24.3",
+ "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
+ "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==",
+ "license": "MIT",
+ "peer": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/preact"
+ }
+ },
+ "node_modules/preact-render-to-string": {
+ "version": "6.5.11",
+ "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz",
+ "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "preact": ">=10"
+ }
+ },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -5811,6 +7165,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -5820,6 +7184,17 @@
"node": ">=0.10.0"
}
},
+ "node_modules/source-map-support": {
+ "version": "0.5.21",
+ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
+ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "source-map": "^0.6.0"
+ }
+ },
"node_modules/stable-hash": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@@ -6303,11 +7678,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/uncrypto": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz",
+ "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==",
+ "license": "MIT"
+ },
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/unrs-resolver": {
@@ -6386,6 +7766,19 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/uuid": {
+ "version": "11.1.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
+ "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist/esm/bin/uuid"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -6525,7 +7918,6 @@
"version": "4.3.5",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz",
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
- "dev": true,
"license": "MIT",
"peer": true,
"funding": {
diff --git a/package.json b/package.json
index d61b253..9561051 100644
--- a/package.json
+++ b/package.json
@@ -2,25 +2,42 @@
"name": "synapsis",
"version": "0.1.0",
"private": true,
+ "license": "Apache-2.0",
"scripts": {
- "dev": "next dev",
+ "dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
- "lint": "eslint"
+ "lint": "eslint",
+ "db:generate": "drizzle-kit generate",
+ "db:push": "drizzle-kit push",
+ "db:studio": "drizzle-kit studio",
+ "type-check": "tsc --noEmit"
},
"dependencies": {
+ "@neondatabase/serverless": "^0.10.4",
+ "@upstash/redis": "^1.34.3",
+ "bcryptjs": "^2.4.3",
+ "crypto-js": "^4.2.0",
+ "drizzle-orm": "^0.44.1",
+ "jose": "^6.0.11",
"next": "16.1.4",
+ "next-auth": "^5.0.0-beta.25",
"react": "19.2.3",
- "react-dom": "19.2.3"
+ "react-dom": "19.2.3",
+ "uuid": "^11.1.0",
+ "zod": "^4.3.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+ "@types/bcryptjs": "^2.4.6",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
+ "@types/uuid": "^10.0.0",
+ "drizzle-kit": "^0.31.8",
"eslint": "^9",
"eslint-config-next": "16.1.4",
"tailwindcss": "^4",
"typescript": "^5"
}
-}
+}
\ No newline at end of file
diff --git a/public/favicon.png b/public/favicon.png
new file mode 100644
index 0000000..d12f44e
Binary files /dev/null and b/public/favicon.png differ
diff --git a/public/logo.svg b/public/logo.svg
new file mode 100644
index 0000000..dccd224
--- /dev/null
+++ b/public/logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/manifest.json b/public/manifest.json
new file mode 100644
index 0000000..0714de7
--- /dev/null
+++ b/public/manifest.json
@@ -0,0 +1,36 @@
+{
+ "name": "Synapsis",
+ "short_name": "Synapsis",
+ "description": "Federated social network infrastructure",
+ "start_url": "/",
+ "display": "standalone",
+ "background_color": "#0a0a0a",
+ "theme_color": "#00d4aa",
+ "orientation": "portrait-primary",
+ "icons": [
+ {
+ "src": "/icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any maskable"
+ },
+ {
+ "src": "/icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any maskable"
+ }
+ ],
+ "categories": [
+ "social",
+ "communication"
+ ],
+ "screenshots": [],
+ "shortcuts": [
+ {
+ "name": "Compose",
+ "url": "/?compose=true",
+ "description": "Write a new post"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/app/.well-known/nodeinfo/route.ts b/src/app/.well-known/nodeinfo/route.ts
new file mode 100644
index 0000000..4eabd3e
--- /dev/null
+++ b/src/app/.well-known/nodeinfo/route.ts
@@ -0,0 +1,16 @@
+import { NextResponse } from 'next/server';
+
+export async function GET() {
+ const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
+ const nodeName = process.env.NEXT_PUBLIC_NODE_NAME || 'Synapsis Node';
+ const nodeDescription = process.env.NEXT_PUBLIC_NODE_DESCRIPTION || 'A Synapsis federated social network node';
+
+ return NextResponse.json({
+ links: [
+ {
+ rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1',
+ href: `https://${nodeDomain}/nodeinfo/2.1`,
+ },
+ ],
+ });
+}
diff --git a/src/app/.well-known/synapsis-handles/route.ts b/src/app/.well-known/synapsis-handles/route.ts
new file mode 100644
index 0000000..1e3a355
--- /dev/null
+++ b/src/app/.well-known/synapsis-handles/route.ts
@@ -0,0 +1,56 @@
+import { NextResponse } from 'next/server';
+import { db, handleRegistry } from '@/db';
+import { desc, eq, gt } from 'drizzle-orm';
+import { normalizeHandle } from '@/lib/federation/handles';
+
+export async function GET(request: Request) {
+ try {
+ if (!db) {
+ return NextResponse.json({ handles: [] });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const handleParam = searchParams.get('handle');
+ const sinceParam = searchParams.get('since');
+ const limit = Math.min(parseInt(searchParams.get('limit') || '100'), 500);
+
+ if (handleParam) {
+ const cleanHandle = normalizeHandle(handleParam);
+ const entry = await db.query.handleRegistry.findFirst({
+ where: eq(handleRegistry.handle, cleanHandle),
+ });
+
+ if (!entry) {
+ return NextResponse.json({ handles: [] });
+ }
+
+ return NextResponse.json({
+ handles: [{
+ handle: entry.handle,
+ did: entry.did,
+ nodeDomain: entry.nodeDomain,
+ updatedAt: entry.updatedAt,
+ }],
+ });
+ }
+
+ const sinceDate = sinceParam ? new Date(sinceParam) : null;
+ const entries = await db.query.handleRegistry.findMany({
+ where: sinceDate ? gt(handleRegistry.updatedAt, sinceDate) : undefined,
+ orderBy: [desc(handleRegistry.updatedAt)],
+ limit,
+ });
+
+ return NextResponse.json({
+ handles: entries.map((entry) => ({
+ handle: entry.handle,
+ did: entry.did,
+ nodeDomain: entry.nodeDomain,
+ updatedAt: entry.updatedAt,
+ })),
+ });
+ } catch (error) {
+ console.error('Handle export error:', error);
+ return NextResponse.json({ error: 'Failed to export handles' }, { status: 500 });
+ }
+}
diff --git a/src/app/.well-known/webfinger/route.ts b/src/app/.well-known/webfinger/route.ts
new file mode 100644
index 0000000..b5e1ffa
--- /dev/null
+++ b/src/app/.well-known/webfinger/route.ts
@@ -0,0 +1,56 @@
+import { NextResponse } from 'next/server';
+import { db, users } from '@/db';
+import { eq } from 'drizzle-orm';
+import { generateWebFingerResponse, parseWebFingerResource } from '@/lib/activitypub/webfinger';
+
+export async function GET(request: Request) {
+ const { searchParams } = new URL(request.url);
+ const resource = searchParams.get('resource');
+
+ if (!resource) {
+ return NextResponse.json(
+ { error: 'Missing resource parameter' },
+ { status: 400 }
+ );
+ }
+
+ const parsed = parseWebFingerResource(resource);
+
+ if (!parsed) {
+ return NextResponse.json(
+ { error: 'Invalid resource format' },
+ { status: 400 }
+ );
+ }
+
+ const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
+
+ // Check if this is our domain
+ if (parsed.domain !== nodeDomain && parsed.domain !== nodeDomain.replace(/:\d+$/, '')) {
+ return NextResponse.json(
+ { error: 'Resource not found' },
+ { status: 404 }
+ );
+ }
+
+ // Find the user
+ const user = await db.query.users.findFirst({
+ where: eq(users.handle, parsed.handle.toLowerCase()),
+ });
+
+ if (!user) {
+ return NextResponse.json(
+ { error: 'User not found' },
+ { status: 404 }
+ );
+ }
+
+ const response = generateWebFingerResponse(user.handle, nodeDomain);
+
+ return NextResponse.json(response, {
+ headers: {
+ 'Content-Type': 'application/jrd+json',
+ 'Access-Control-Allow-Origin': '*',
+ },
+ });
+}
diff --git a/src/app/@[handle]/default.tsx b/src/app/@[handle]/default.tsx
new file mode 100644
index 0000000..6ddf1b7
--- /dev/null
+++ b/src/app/@[handle]/default.tsx
@@ -0,0 +1,3 @@
+export default function Default() {
+ return null;
+}
diff --git a/src/app/@[handle]/page.tsx b/src/app/@[handle]/page.tsx
new file mode 100644
index 0000000..d0d1fe2
--- /dev/null
+++ b/src/app/@[handle]/page.tsx
@@ -0,0 +1,638 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import Link from 'next/link';
+import { useParams } from 'next/navigation';
+
+interface User {
+ id: string;
+ handle: string;
+ displayName: string;
+ bio?: string;
+ avatarUrl?: string;
+ headerUrl?: string;
+ followersCount: number;
+ followingCount: number;
+ postsCount: number;
+ createdAt: string;
+}
+
+interface UserSummary {
+ id: string;
+ handle: string;
+ displayName?: string | null;
+ bio?: string | null;
+ avatarUrl?: string | null;
+}
+
+interface MediaItem {
+ id: string;
+ url: string;
+ altText?: string | null;
+}
+
+interface Post {
+ id: string;
+ content: string;
+ createdAt: string;
+ likesCount: number;
+ repostsCount: number;
+ repliesCount: number;
+ author: User;
+ media?: MediaItem[];
+}
+
+// Icons
+const ArrowLeftIcon = () => (
+
+);
+
+const CalendarIcon = () => (
+
+);
+
+const HeartIcon = ({ filled }: { filled?: boolean }) => (
+
+);
+
+const RepeatIcon = () => (
+
+);
+
+const MessageIcon = () => (
+
+);
+
+const FlagIcon = () => (
+
+);
+
+function UserRow({ user }: { user: UserSummary }) {
+ return (
+
+
+ {user.avatarUrl ? (
+

+ ) : (
+ (user.displayName || user.handle).charAt(0).toUpperCase()
+ )}
+
+
+
{user.displayName || user.handle}
+
@{user.handle}
+ {user.bio && (
+
{user.bio}
+ )}
+
+
+ );
+}
+
+function PostCard({ post }: { post: Post }) {
+ const [liked, setLiked] = useState(false);
+ const [reposted, setReposted] = useState(false);
+ const [reporting, setReporting] = useState(false);
+
+ const formatTime = (dateStr: string) => {
+ const date = new Date(dateStr);
+ const now = new Date();
+ const diff = now.getTime() - date.getTime();
+ const minutes = Math.floor(diff / 60000);
+ const hours = Math.floor(minutes / 60);
+ const days = Math.floor(hours / 24);
+
+ if (minutes < 1) return 'now';
+ if (minutes < 60) return `${minutes}m`;
+ if (hours < 24) return `${hours}h`;
+ if (days < 7) return `${days}d`;
+ return date.toLocaleDateString();
+ };
+
+ return (
+
+
+
+ {post.author.avatarUrl ? (
+

+ ) : (
+ post.author.displayName?.charAt(0).toUpperCase() || post.author.handle.charAt(0).toUpperCase()
+ )}
+
+
+
+ {post.author.displayName || post.author.handle}
+
+ @{post.author.handle} ยท {formatTime(post.createdAt)}
+
+
+ {post.content}
+ {post.media && post.media.length > 0 && (
+
+ {post.media.map((item) => (
+
+

+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+ );
+}
+
+export default function ProfilePage() {
+ const params = useParams();
+ const handle = (params.handle as string)?.replace(/^@/, '') || '';
+
+ const [user, setUser] = useState(null);
+ const [posts, setPosts] = useState([]);
+ const [currentUser, setCurrentUser] = useState<{ id: string; handle: string } | null>(null);
+ const [isFollowing, setIsFollowing] = useState(false);
+ const [loading, setLoading] = useState(true);
+ const [activeTab, setActiveTab] = useState<'posts' | 'followers' | 'following'>('posts');
+ const [followers, setFollowers] = useState([]);
+ const [following, setFollowing] = useState([]);
+ const [followersLoading, setFollowersLoading] = useState(false);
+ const [followingLoading, setFollowingLoading] = useState(false);
+ const [isEditing, setIsEditing] = useState(false);
+ const [profileForm, setProfileForm] = useState({
+ displayName: '',
+ bio: '',
+ avatarUrl: '',
+ headerUrl: '',
+ });
+ const [saveError, setSaveError] = useState(null);
+ const [isSaving, setIsSaving] = useState(false);
+
+ useEffect(() => {
+ setIsEditing(false);
+ setSaveError(null);
+ setFollowers([]);
+ setFollowing([]);
+ // Get current user
+ fetch('/api/auth/me')
+ .then(res => res.json())
+ .then(data => setCurrentUser(data.user))
+ .catch(() => { });
+
+ // Get profile
+ fetch(`/api/users/${handle}`)
+ .then(res => res.json())
+ .then(data => {
+ setUser(data.user);
+ setLoading(false);
+ })
+ .catch(() => setLoading(false));
+
+ // Get posts
+ fetch(`/api/users/${handle}/posts`)
+ .then(res => res.json())
+ .then(data => setPosts(data.posts || []))
+ .catch(() => { });
+ }, [handle]);
+
+ useEffect(() => {
+ if (user && currentUser?.handle === user.handle) {
+ setProfileForm({
+ displayName: user.displayName || '',
+ bio: user.bio || '',
+ avatarUrl: user.avatarUrl || '',
+ headerUrl: user.headerUrl || '',
+ });
+ }
+ }, [user, currentUser]);
+
+ useEffect(() => {
+ if (!currentUser || !user || currentUser.handle === user.handle) {
+ setIsFollowing(false);
+ return;
+ }
+
+ fetch(`/api/users/${handle}/follow`)
+ .then(res => res.json())
+ .then(data => setIsFollowing(!!data.following))
+ .catch(() => setIsFollowing(false));
+ }, [currentUser, user, handle]);
+
+ useEffect(() => {
+ if (activeTab === 'followers') {
+ setFollowersLoading(true);
+ fetch(`/api/users/${handle}/followers`)
+ .then(res => res.json())
+ .then(data => setFollowers(data.followers || []))
+ .catch(() => setFollowers([]))
+ .finally(() => setFollowersLoading(false));
+ }
+
+ if (activeTab === 'following') {
+ setFollowingLoading(true);
+ fetch(`/api/users/${handle}/following`)
+ .then(res => res.json())
+ .then(data => setFollowing(data.following || []))
+ .catch(() => setFollowing([]))
+ .finally(() => setFollowingLoading(false));
+ }
+ }, [activeTab, handle]);
+
+ const handleFollow = async () => {
+ if (!currentUser) return;
+
+ const method = isFollowing ? 'DELETE' : 'POST';
+ const res = await fetch(`/api/users/${handle}/follow`, { method });
+
+ if (res.ok) {
+ setIsFollowing(!isFollowing);
+ if (user) {
+ setUser({
+ ...user,
+ followersCount: isFollowing ? user.followersCount - 1 : user.followersCount + 1,
+ });
+ }
+ }
+ };
+
+ const handleSaveProfile = async () => {
+ if (!isOwnProfile) return;
+ setIsSaving(true);
+ setSaveError(null);
+
+ try {
+ const res = await fetch('/api/auth/me', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(profileForm),
+ });
+
+ const data = await res.json();
+
+ if (!res.ok) {
+ throw new Error(data.error || 'Failed to update profile');
+ }
+
+ setUser(data.user);
+ setIsEditing(false);
+ } catch (error) {
+ console.error('Profile update failed', error);
+ setSaveError('Unable to update profile. Please try again.');
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ const formatDate = (dateStr: string) => {
+ return new Date(dateStr).toLocaleDateString('en-US', {
+ month: 'long',
+ year: 'numeric',
+ });
+ };
+
+ if (loading) {
+ return (
+
+ Loading...
+
+ );
+ }
+
+ if (!user) {
+ return (
+
+
User not found
+ Go home
+
+ );
+ }
+
+ const isOwnProfile = currentUser?.handle === user.handle;
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
{user.displayName || user.handle}
+
{user.postsCount} posts
+
+
+
+ {/* Profile Header */}
+
+ {/* Banner */}
+
+
+ {/* Avatar & Actions */}
+
+
+
+ {user.avatarUrl ? (
+

+ ) : (
+ (user.displayName || user.handle).charAt(0).toUpperCase()
+ )}
+
+
+ {!isOwnProfile && currentUser && (
+
+ )}
+
+ {isOwnProfile && (
+
+ )}
+
+
+ {/* User Info */}
+
+
{user.displayName || user.handle}
+
@{user.handle}
+
+ {user.bio && (
+
{user.bio}
+ )}
+
+
+
+ Joined {formatDate(user.createdAt)}
+
+
+
+
+
+
+
+
+
+ {isOwnProfile && isEditing && (
+
+
+
Edit profile
+
+
+
+ setProfileForm({ ...profileForm, displayName: e.target.value })}
+ maxLength={50}
+ />
+
+
+
+
+
+
+ setProfileForm({ ...profileForm, avatarUrl: e.target.value })}
+ placeholder="https://"
+ />
+
+
+
+ setProfileForm({ ...profileForm, headerUrl: e.target.value })}
+ placeholder="https://"
+ />
+
+ {saveError && (
+
{saveError}
+ )}
+
+
+
+
+
+
+
+ )}
+
+ {/* Tabs */}
+
+ {(['posts', 'followers', 'following'] as const).map(tab => (
+
+ ))}
+
+
+
+ {/* Content */}
+ {activeTab === 'posts' && (
+ posts.length === 0 ? (
+
+ ) : (
+ posts.map(post =>
)
+ )
+ )}
+
+ {activeTab === 'followers' && (
+ followersLoading ? (
+
+ Loading followers...
+
+ ) : followers.length === 0 ? (
+
+ ) : (
+
+ {followers.map(follower => (
+
+ ))}
+
+ )
+ )}
+
+ {activeTab === 'following' && (
+ followingLoading ? (
+
+ Loading following...
+
+ ) : following.length === 0 ? (
+
+
Not following anyone yet
+
+ ) : (
+
+ {following.map(userItem => (
+
+ ))}
+
+ )
+ )}
+
+ );
+}
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
new file mode 100644
index 0000000..4b41172
--- /dev/null
+++ b/src/app/admin/page.tsx
@@ -0,0 +1,385 @@
+'use client';
+
+import { useEffect, useMemo, useState } from 'react';
+import Link from 'next/link';
+
+type AdminUser = {
+ id: string;
+ handle: string;
+ displayName?: string | null;
+ email?: string | null;
+ isSuspended: boolean;
+ isSilenced: boolean;
+ suspensionReason?: string | null;
+ silenceReason?: string | null;
+ createdAt: string;
+};
+
+type AdminPost = {
+ id: string;
+ content: string;
+ createdAt: string;
+ isRemoved: boolean;
+ removedReason?: string | null;
+ author: {
+ id: string;
+ handle: string;
+ displayName?: string | null;
+ };
+};
+
+type Report = {
+ id: string;
+ targetType: 'post' | 'user';
+ targetId: string;
+ reason: string;
+ status: 'open' | 'resolved';
+ createdAt: string;
+ reporter?: {
+ id: string;
+ handle: string;
+ } | null;
+ target?: AdminPost | AdminUser | null;
+};
+
+const formatDate = (value: string) => {
+ const date = new Date(value);
+ return date.toLocaleString();
+};
+
+export default function AdminPage() {
+ const [isAdmin, setIsAdmin] = useState(null);
+ const [tab, setTab] = useState<'reports' | 'posts' | 'users'>('reports');
+ const [reports, setReports] = useState([]);
+ const [posts, setPosts] = useState([]);
+ const [users, setUsers] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [reportStatus, setReportStatus] = useState<'open' | 'resolved' | 'all'>('open');
+
+ useEffect(() => {
+ fetch('/api/admin/me')
+ .then((res) => res.json())
+ .then((data) => setIsAdmin(!!data.isAdmin))
+ .catch(() => setIsAdmin(false));
+ }, []);
+
+ const loadReports = async () => {
+ setLoading(true);
+ try {
+ const res = await fetch(`/api/admin/reports?status=${reportStatus}`);
+ const data = await res.json();
+ setReports(data.reports || []);
+ } catch {
+ setReports([]);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const loadPosts = async () => {
+ setLoading(true);
+ try {
+ const res = await fetch('/api/admin/posts?status=all');
+ const data = await res.json();
+ setPosts(data.posts || []);
+ } catch {
+ setPosts([]);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const loadUsers = async () => {
+ setLoading(true);
+ try {
+ const res = await fetch('/api/admin/users');
+ const data = await res.json();
+ setUsers(data.users || []);
+ } catch {
+ setUsers([]);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ if (!isAdmin) return;
+ if (tab === 'reports') loadReports();
+ if (tab === 'posts') loadPosts();
+ if (tab === 'users') loadUsers();
+ }, [tab, isAdmin, reportStatus]);
+
+ const handleReportResolve = async (id: string, status: 'open' | 'resolved') => {
+ const note = status === 'resolved' ? window.prompt('Resolution note (optional):') || '' : '';
+ await fetch(`/api/admin/reports/${id}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ status, note }),
+ });
+ loadReports();
+ };
+
+ const handlePostAction = async (id: string, action: 'remove' | 'restore') => {
+ const reason = action === 'remove' ? window.prompt('Reason (optional):') || '' : '';
+ await fetch(`/api/admin/posts/${id}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action, reason }),
+ });
+ if (tab === 'reports') {
+ loadReports();
+ } else {
+ loadPosts();
+ }
+ };
+
+ const handleUserAction = async (id: string, action: 'suspend' | 'unsuspend' | 'silence' | 'unsilence') => {
+ const needsReason = action === 'suspend' || action === 'silence';
+ const reason = needsReason ? window.prompt('Reason (optional):') || '' : '';
+ await fetch(`/api/admin/users/${id}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action, reason }),
+ });
+ loadUsers();
+ };
+
+ const reportCounts = useMemo(() => {
+ return {
+ open: reports.filter((r) => r.status === 'open').length,
+ resolved: reports.filter((r) => r.status === 'resolved').length,
+ };
+ }, [reports]);
+
+ if (isAdmin === null) {
+ return (
+
+
Checking permissions...
+
+ );
+ }
+
+ if (!isAdmin) {
+ return (
+
+
+
Moderation
+
You do not have access to this page.
+
+ Back to home
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
Moderation Dashboard
+
Manage reports, posts, and user actions.
+
+
+ Return to feed
+
+
+
+
+
+
+
+
+
+ {tab === 'reports' && (
+
+
+
+ {(['open', 'resolved', 'all'] as const).map((status) => (
+
+ ))}
+
+
+ Open: {reportCounts.open}
+ Resolved: {reportCounts.resolved}
+
+
+
+ {loading ? (
+
Loading reports...
+ ) : reports.length === 0 ? (
+
No reports found.
+ ) : (
+
+ {reports.map((report) => (
+
+
+
+
+ {report.status}
+
+
+ {report.targetType.toUpperCase()} report
+
+
+
+ {report.reason}
+
+
+ Reported by {report.reporter?.handle || 'anonymous'} โข {formatDate(report.createdAt)}
+
+ {report.targetType === 'post' && report.target && 'content' in report.target && (
+
+ @{report.target.author.handle}: {report.target.content || '[repost]'}
+
+ )}
+ {report.targetType === 'user' && report.target && 'handle' in report.target && (
+
+ User: @{report.target.handle}
+
+ )}
+
+
+ {report.targetType === 'post' && report.target && 'content' in report.target && (
+
+ )}
+ {report.status === 'open' ? (
+
+ ) : (
+
+ )}
+
+
+ ))}
+
+ )}
+
+ )}
+
+ {tab === 'posts' && (
+
+ {loading ? (
+
Loading posts...
+ ) : posts.length === 0 ? (
+
No posts found.
+ ) : (
+
+ {posts.map((post) => (
+
+
+
+
+ {post.isRemoved ? 'removed' : 'active'}
+
+
+ @{post.author.handle} โข {formatDate(post.createdAt)}
+
+
+
{post.content || '[repost]'}
+ {post.removedReason && (
+
Reason: {post.removedReason}
+ )}
+
+
+ {post.isRemoved ? (
+
+ ) : (
+
+ )}
+
+
+ ))}
+
+ )}
+
+ )}
+
+ {tab === 'users' && (
+
+ {loading ? (
+
Loading users...
+ ) : users.length === 0 ? (
+
No users found.
+ ) : (
+
+ {users.map((user) => (
+
+
+
+
+ {user.isSuspended ? 'suspended' : 'active'}
+
+
+ {user.isSilenced ? 'silenced' : 'visible'}
+
+
+ @{user.handle} โข {formatDate(user.createdAt)}
+
+
+
+ {user.displayName || user.handle}
+
+ {user.suspensionReason && (
+
Suspension: {user.suspensionReason}
+ )}
+ {user.silenceReason && (
+
Silence: {user.silenceReason}
+ )}
+
+
+
+ View
+
+ {user.isSuspended ? (
+
+ ) : (
+
+ )}
+ {user.isSilenced ? (
+
+ ) : (
+
+ )}
+
+
+ ))}
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/src/app/api/admin/me/route.ts b/src/app/api/admin/me/route.ts
new file mode 100644
index 0000000..803618f
--- /dev/null
+++ b/src/app/api/admin/me/route.ts
@@ -0,0 +1,29 @@
+import { NextResponse } from 'next/server';
+import { db } from '@/db';
+import { getSession } from '@/lib/auth';
+import { isAdminUser } from '@/lib/auth/admin';
+
+export async function GET() {
+ try {
+ if (!db) {
+ return NextResponse.json({ isAdmin: false, user: null });
+ }
+
+ const session = await getSession();
+ if (!session) {
+ return NextResponse.json({ isAdmin: false, user: null });
+ }
+
+ return NextResponse.json({
+ isAdmin: isAdminUser(session.user),
+ user: {
+ id: session.user.id,
+ handle: session.user.handle,
+ displayName: session.user.displayName,
+ },
+ });
+ } catch (error) {
+ console.error('Admin status error:', error);
+ return NextResponse.json({ isAdmin: false, user: null });
+ }
+}
diff --git a/src/app/api/admin/posts/[id]/route.ts b/src/app/api/admin/posts/[id]/route.ts
new file mode 100644
index 0000000..5fb7567
--- /dev/null
+++ b/src/app/api/admin/posts/[id]/route.ts
@@ -0,0 +1,69 @@
+import { NextResponse } from 'next/server';
+import { db, posts } from '@/db';
+import { requireAdmin } from '@/lib/auth/admin';
+import { eq } from 'drizzle-orm';
+import { z } from 'zod';
+
+type RouteContext = { params: Promise<{ id: string }> };
+
+const moderationSchema = z.object({
+ action: z.enum(['remove', 'restore']),
+ reason: z.string().max(240).optional(),
+});
+
+export async function PATCH(request: Request, context: RouteContext) {
+ try {
+ const admin = await requireAdmin();
+
+ if (!db) {
+ return NextResponse.json({ error: 'Database not available' }, { status: 503 });
+ }
+
+ const { id } = await context.params;
+ const body = await request.json();
+ const data = moderationSchema.parse(body);
+
+ const post = await db.query.posts.findFirst({
+ where: eq(posts.id, id),
+ });
+
+ if (!post) {
+ return NextResponse.json({ error: 'Post not found' }, { status: 404 });
+ }
+
+ if (data.action === 'remove') {
+ const [updated] = await db.update(posts)
+ .set({
+ isRemoved: true,
+ removedAt: new Date(),
+ removedBy: admin.id,
+ removedReason: data.reason || null,
+ })
+ .where(eq(posts.id, id))
+ .returning();
+
+ return NextResponse.json({ post: updated });
+ }
+
+ const [restored] = await db.update(posts)
+ .set({
+ isRemoved: false,
+ removedAt: null,
+ removedBy: null,
+ removedReason: null,
+ })
+ .where(eq(posts.id, id))
+ .returning();
+
+ return NextResponse.json({ post: restored });
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
+ }
+ if (error instanceof Error && error.message === 'Admin required') {
+ return NextResponse.json({ error: 'Admin required' }, { status: 403 });
+ }
+ console.error('Post moderation error:', error);
+ return NextResponse.json({ error: 'Failed to update post' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/admin/posts/route.ts b/src/app/api/admin/posts/route.ts
new file mode 100644
index 0000000..67d767c
--- /dev/null
+++ b/src/app/api/admin/posts/route.ts
@@ -0,0 +1,55 @@
+import { NextResponse } from 'next/server';
+import { db, posts } from '@/db';
+import { requireAdmin } from '@/lib/auth/admin';
+import { desc, eq } from 'drizzle-orm';
+
+export async function GET(request: Request) {
+ try {
+ await requireAdmin();
+
+ if (!db) {
+ return NextResponse.json({ error: 'Database not available' }, { status: 503 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const status = searchParams.get('status') || 'active'; // active | removed | all
+ const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
+
+ const where =
+ status === 'active'
+ ? eq(posts.isRemoved, false)
+ : status === 'removed'
+ ? eq(posts.isRemoved, true)
+ : undefined;
+
+ const results = await db.query.posts.findMany({
+ where,
+ with: {
+ author: true,
+ },
+ orderBy: [desc(posts.createdAt)],
+ limit,
+ });
+
+ const sanitized = results.map((post) => ({
+ id: post.id,
+ content: post.content,
+ createdAt: post.createdAt,
+ isRemoved: post.isRemoved,
+ removedReason: post.removedReason,
+ author: {
+ id: post.author.id,
+ handle: post.author.handle,
+ displayName: post.author.displayName,
+ },
+ }));
+
+ return NextResponse.json({ posts: sanitized });
+ } catch (error) {
+ if (error instanceof Error && error.message === 'Admin required') {
+ return NextResponse.json({ error: 'Admin required' }, { status: 403 });
+ }
+ console.error('Admin posts error:', error);
+ return NextResponse.json({ error: 'Failed to fetch posts' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/admin/reports/[id]/route.ts b/src/app/api/admin/reports/[id]/route.ts
new file mode 100644
index 0000000..992e2d3
--- /dev/null
+++ b/src/app/api/admin/reports/[id]/route.ts
@@ -0,0 +1,55 @@
+import { NextResponse } from 'next/server';
+import { db, reports } from '@/db';
+import { requireAdmin } from '@/lib/auth/admin';
+import { eq } from 'drizzle-orm';
+import { z } from 'zod';
+
+type RouteContext = { params: Promise<{ id: string }> };
+
+const updateSchema = z.object({
+ status: z.enum(['open', 'resolved']),
+ note: z.string().max(240).optional(),
+});
+
+export async function PATCH(request: Request, context: RouteContext) {
+ try {
+ const admin = await requireAdmin();
+
+ if (!db) {
+ return NextResponse.json({ error: 'Database not available' }, { status: 503 });
+ }
+
+ const { id } = await context.params;
+ const body = await request.json();
+ const data = updateSchema.parse(body);
+
+ const report = await db.query.reports.findFirst({
+ where: eq(reports.id, id),
+ });
+
+ if (!report) {
+ return NextResponse.json({ error: 'Report not found' }, { status: 404 });
+ }
+
+ const [updated] = await db.update(reports)
+ .set({
+ status: data.status,
+ resolvedAt: data.status === 'resolved' ? new Date() : null,
+ resolvedBy: data.status === 'resolved' ? admin.id : null,
+ resolutionNote: data.note || null,
+ })
+ .where(eq(reports.id, id))
+ .returning();
+
+ return NextResponse.json({ report: updated });
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
+ }
+ if (error instanceof Error && error.message === 'Admin required') {
+ return NextResponse.json({ error: 'Admin required' }, { status: 403 });
+ }
+ console.error('Report update error:', error);
+ return NextResponse.json({ error: 'Failed to update report' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/admin/reports/route.ts b/src/app/api/admin/reports/route.ts
new file mode 100644
index 0000000..f5eb5e3
--- /dev/null
+++ b/src/app/api/admin/reports/route.ts
@@ -0,0 +1,97 @@
+import { NextResponse } from 'next/server';
+import { db, reports, posts, users } from '@/db';
+import { requireAdmin } from '@/lib/auth/admin';
+import { desc, inArray, eq } from 'drizzle-orm';
+
+export async function GET(request: Request) {
+ try {
+ await requireAdmin();
+
+ if (!db) {
+ return NextResponse.json({ error: 'Database not available' }, { status: 503 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const status = searchParams.get('status') || 'open'; // open | resolved | all
+ const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
+
+ const reportRows = await db.query.reports.findMany({
+ where: status === 'all' ? undefined : eq(reports.status, status),
+ orderBy: [desc(reports.createdAt)],
+ limit,
+ with: {
+ reporter: true,
+ resolver: true,
+ },
+ });
+
+ const postIds = reportRows
+ .filter((report) => report.targetType === 'post')
+ .map((report) => report.targetId);
+ const userIds = reportRows
+ .filter((report) => report.targetType === 'user')
+ .map((report) => report.targetId);
+
+ const postTargetsRaw = postIds.length
+ ? await db.query.posts.findMany({
+ where: inArray(posts.id, postIds),
+ with: { author: true },
+ })
+ : [];
+ const userTargetsRaw = userIds.length
+ ? await db.query.users.findMany({
+ where: inArray(users.id, userIds),
+ })
+ : [];
+
+ const postTargets = postTargetsRaw.map((post) => ({
+ id: post.id,
+ content: post.content,
+ createdAt: post.createdAt,
+ isRemoved: post.isRemoved,
+ author: {
+ id: post.author.id,
+ handle: post.author.handle,
+ displayName: post.author.displayName,
+ },
+ }));
+
+ const userTargets = userTargetsRaw.map((user) => ({
+ id: user.id,
+ handle: user.handle,
+ displayName: user.displayName,
+ isSuspended: user.isSuspended,
+ isSilenced: user.isSilenced,
+ }));
+
+ const postMap = new Map(postTargets.map((post) => [post.id, post]));
+ const userMap = new Map(userTargets.map((user) => [user.id, user]));
+
+ const reportsWithTargets = reportRows.map((report) => ({
+ id: report.id,
+ targetType: report.targetType,
+ targetId: report.targetId,
+ reason: report.reason,
+ status: report.status,
+ createdAt: report.createdAt,
+ reporter: report.reporter
+ ? { id: report.reporter.id, handle: report.reporter.handle }
+ : null,
+ resolver: report.resolver
+ ? { id: report.resolver.id, handle: report.resolver.handle }
+ : null,
+ target:
+ report.targetType === 'post'
+ ? postMap.get(report.targetId) || null
+ : userMap.get(report.targetId) || null,
+ }));
+
+ return NextResponse.json({ reports: reportsWithTargets });
+ } catch (error) {
+ if (error instanceof Error && error.message === 'Admin required') {
+ return NextResponse.json({ error: 'Admin required' }, { status: 403 });
+ }
+ console.error('Admin reports error:', error);
+ return NextResponse.json({ error: 'Failed to fetch reports' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts
new file mode 100644
index 0000000..02aa4be
--- /dev/null
+++ b/src/app/api/admin/users/[id]/route.ts
@@ -0,0 +1,133 @@
+import { NextResponse } from 'next/server';
+import { db, users } from '@/db';
+import { requireAdmin } from '@/lib/auth/admin';
+import { eq } from 'drizzle-orm';
+import { z } from 'zod';
+
+type RouteContext = { params: Promise<{ id: string }> };
+
+const moderationSchema = z.object({
+ action: z.enum(['suspend', 'unsuspend', 'silence', 'unsilence']),
+ reason: z.string().max(240).optional(),
+});
+
+export async function PATCH(request: Request, context: RouteContext) {
+ try {
+ const admin = await requireAdmin();
+
+ if (!db) {
+ return NextResponse.json({ error: 'Database not available' }, { status: 503 });
+ }
+
+ const { id } = await context.params;
+ const body = await request.json();
+ const data = moderationSchema.parse(body);
+
+ const user = await db.query.users.findFirst({
+ where: eq(users.id, id),
+ });
+
+ if (!user) {
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+ if (user.id === admin.id && (data.action === 'suspend' || data.action === 'silence')) {
+ return NextResponse.json({ error: 'Cannot apply this action to yourself' }, { status: 400 });
+ }
+
+ if (data.action === 'suspend') {
+ const [updated] = await db.update(users)
+ .set({
+ isSuspended: true,
+ suspendedAt: new Date(),
+ suspensionReason: data.reason || null,
+ })
+ .where(eq(users.id, id))
+ .returning();
+ return NextResponse.json({ user: {
+ id: updated.id,
+ handle: updated.handle,
+ displayName: updated.displayName,
+ email: updated.email,
+ isSuspended: updated.isSuspended,
+ suspensionReason: updated.suspensionReason,
+ isSilenced: updated.isSilenced,
+ silenceReason: updated.silenceReason,
+ createdAt: updated.createdAt,
+ } });
+ }
+
+ if (data.action === 'unsuspend') {
+ const [updated] = await db.update(users)
+ .set({
+ isSuspended: false,
+ suspendedAt: null,
+ suspensionReason: null,
+ })
+ .where(eq(users.id, id))
+ .returning();
+ return NextResponse.json({ user: {
+ id: updated.id,
+ handle: updated.handle,
+ displayName: updated.displayName,
+ email: updated.email,
+ isSuspended: updated.isSuspended,
+ suspensionReason: updated.suspensionReason,
+ isSilenced: updated.isSilenced,
+ silenceReason: updated.silenceReason,
+ createdAt: updated.createdAt,
+ } });
+ }
+
+ if (data.action === 'silence') {
+ const [updated] = await db.update(users)
+ .set({
+ isSilenced: true,
+ silencedAt: new Date(),
+ silenceReason: data.reason || null,
+ })
+ .where(eq(users.id, id))
+ .returning();
+ return NextResponse.json({ user: {
+ id: updated.id,
+ handle: updated.handle,
+ displayName: updated.displayName,
+ email: updated.email,
+ isSuspended: updated.isSuspended,
+ suspensionReason: updated.suspensionReason,
+ isSilenced: updated.isSilenced,
+ silenceReason: updated.silenceReason,
+ createdAt: updated.createdAt,
+ } });
+ }
+
+ const [updated] = await db.update(users)
+ .set({
+ isSilenced: false,
+ silencedAt: null,
+ silenceReason: null,
+ })
+ .where(eq(users.id, id))
+ .returning();
+
+ return NextResponse.json({ user: {
+ id: updated.id,
+ handle: updated.handle,
+ displayName: updated.displayName,
+ email: updated.email,
+ isSuspended: updated.isSuspended,
+ suspensionReason: updated.suspensionReason,
+ isSilenced: updated.isSilenced,
+ silenceReason: updated.silenceReason,
+ createdAt: updated.createdAt,
+ } });
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
+ }
+ if (error instanceof Error && error.message === 'Admin required') {
+ return NextResponse.json({ error: 'Admin required' }, { status: 403 });
+ }
+ console.error('Admin user moderation error:', error);
+ return NextResponse.json({ error: 'Failed to update user' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts
new file mode 100644
index 0000000..ea64f5b
--- /dev/null
+++ b/src/app/api/admin/users/route.ts
@@ -0,0 +1,40 @@
+import { NextResponse } from 'next/server';
+import { db, users } from '@/db';
+import { requireAdmin } from '@/lib/auth/admin';
+import { desc } from 'drizzle-orm';
+
+export async function GET(request: Request) {
+ try {
+ await requireAdmin();
+
+ if (!db) {
+ return NextResponse.json({ error: 'Database not available' }, { status: 503 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
+
+ const results = await db.select({
+ id: users.id,
+ handle: users.handle,
+ displayName: users.displayName,
+ email: users.email,
+ isSuspended: users.isSuspended,
+ suspensionReason: users.suspensionReason,
+ isSilenced: users.isSilenced,
+ silenceReason: users.silenceReason,
+ createdAt: users.createdAt,
+ })
+ .from(users)
+ .orderBy(desc(users.createdAt))
+ .limit(limit);
+
+ return NextResponse.json({ users: results });
+ } catch (error) {
+ if (error instanceof Error && error.message === 'Admin required') {
+ return NextResponse.json({ error: 'Admin required' }, { status: 403 });
+ }
+ console.error('Admin users error:', error);
+ return NextResponse.json({ error: 'Failed to fetch users' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts
new file mode 100644
index 0000000..4d8788e
--- /dev/null
+++ b/src/app/api/auth/login/route.ts
@@ -0,0 +1,43 @@
+import { NextResponse } from 'next/server';
+import { authenticateUser, createSession } from '@/lib/auth';
+import { z } from 'zod';
+
+const loginSchema = z.object({
+ email: z.string().email(),
+ password: z.string(),
+});
+
+export async function POST(request: Request) {
+ try {
+ const body = await request.json();
+ const data = loginSchema.parse(body);
+
+ const user = await authenticateUser(data.email, data.password);
+
+ // Create session
+ await createSession(user.id);
+
+ return NextResponse.json({
+ success: true,
+ user: {
+ id: user.id,
+ handle: user.handle,
+ displayName: user.displayName,
+ },
+ });
+ } catch (error) {
+ console.error('Login error:', error);
+
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: 'Invalid input', details: error.issues },
+ { status: 400 }
+ );
+ }
+
+ return NextResponse.json(
+ { error: error instanceof Error ? error.message : 'Login failed' },
+ { status: 401 }
+ );
+ }
+}
diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts
new file mode 100644
index 0000000..bb56a92
--- /dev/null
+++ b/src/app/api/auth/logout/route.ts
@@ -0,0 +1,16 @@
+import { NextResponse } from 'next/server';
+import { destroySession } from '@/lib/auth';
+
+export async function POST() {
+ try {
+ await destroySession();
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ console.error('Logout error:', error);
+ return NextResponse.json(
+ { error: 'Logout failed' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts
new file mode 100644
index 0000000..ecf43d2
--- /dev/null
+++ b/src/app/api/auth/me/route.ts
@@ -0,0 +1,113 @@
+import { NextResponse } from 'next/server';
+import { getSession, requireAuth } from '@/lib/auth';
+import { db, users } from '@/db';
+import { eq } from 'drizzle-orm';
+import { z } from 'zod';
+
+const updateProfileSchema = z.object({
+ displayName: z.string().min(1).max(50).optional(),
+ bio: z.string().max(160).optional().nullable(),
+ avatarUrl: z.string().url().optional().nullable(),
+ headerUrl: z.string().url().optional().nullable(),
+});
+
+export async function GET() {
+ try {
+ // Return null user if no database is connected (for UI testing)
+ if (!db) {
+ return NextResponse.json({ user: null });
+ }
+
+ const session = await getSession();
+
+ if (!session) {
+ return NextResponse.json({ user: null });
+ }
+
+ return NextResponse.json({
+ user: {
+ id: session.user.id,
+ handle: session.user.handle,
+ displayName: session.user.displayName,
+ avatarUrl: session.user.avatarUrl,
+ bio: session.user.bio,
+ },
+ });
+ } catch (error) {
+ console.error('Session check error:', error);
+ return NextResponse.json({ user: null });
+ }
+}
+
+export async function PATCH(request: Request) {
+ try {
+ if (!db) {
+ return NextResponse.json({ error: 'Database not available' }, { status: 503 });
+ }
+
+ const currentUser = await requireAuth();
+ const body = await request.json();
+ const data = updateProfileSchema.parse(body);
+
+ const updateData: {
+ displayName?: string;
+ bio?: string | null;
+ avatarUrl?: string | null;
+ headerUrl?: string | null;
+ updatedAt?: Date;
+ } = {};
+
+ if (data.displayName !== undefined) updateData.displayName = data.displayName;
+ if (data.bio !== undefined) updateData.bio = data.bio === '' ? null : data.bio;
+ if (data.avatarUrl !== undefined) updateData.avatarUrl = data.avatarUrl === '' ? null : data.avatarUrl;
+ if (data.headerUrl !== undefined) updateData.headerUrl = data.headerUrl === '' ? null : data.headerUrl;
+
+ if (Object.keys(updateData).length === 0) {
+ return NextResponse.json({
+ user: {
+ id: currentUser.id,
+ handle: currentUser.handle,
+ displayName: currentUser.displayName,
+ avatarUrl: currentUser.avatarUrl,
+ bio: currentUser.bio,
+ headerUrl: currentUser.headerUrl,
+ followersCount: currentUser.followersCount,
+ followingCount: currentUser.followingCount,
+ postsCount: currentUser.postsCount,
+ createdAt: currentUser.createdAt,
+ },
+ });
+ }
+
+ updateData.updatedAt = new Date();
+
+ const [updatedUser] = await db.update(users)
+ .set(updateData)
+ .where(eq(users.id, currentUser.id))
+ .returning();
+
+ return NextResponse.json({
+ user: {
+ id: updatedUser.id,
+ handle: updatedUser.handle,
+ displayName: updatedUser.displayName,
+ avatarUrl: updatedUser.avatarUrl,
+ bio: updatedUser.bio,
+ headerUrl: updatedUser.headerUrl,
+ followersCount: updatedUser.followersCount,
+ followingCount: updatedUser.followingCount,
+ postsCount: updatedUser.postsCount,
+ createdAt: updatedUser.createdAt,
+ },
+ });
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
+ }
+ if (error instanceof Error && error.message === 'Authentication required') {
+ return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
+ }
+ console.error('Profile update error:', error);
+ return NextResponse.json({ error: 'Failed to update profile' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts
new file mode 100644
index 0000000..dc6b2b2
--- /dev/null
+++ b/src/app/api/auth/register/route.ts
@@ -0,0 +1,50 @@
+import { NextResponse } from 'next/server';
+import { registerUser, createSession } from '@/lib/auth';
+import { z } from 'zod';
+
+const registerSchema = z.object({
+ handle: z.string().min(3).max(20).regex(/^[a-zA-Z0-9_]+$/),
+ email: z.string().email(),
+ password: z.string().min(8),
+ displayName: z.string().optional(),
+});
+
+export async function POST(request: Request) {
+ try {
+ const body = await request.json();
+ const data = registerSchema.parse(body);
+
+ const user = await registerUser(
+ data.handle,
+ data.email,
+ data.password,
+ data.displayName
+ );
+
+ // Create session for new user
+ await createSession(user.id);
+
+ return NextResponse.json({
+ success: true,
+ user: {
+ id: user.id,
+ handle: user.handle,
+ displayName: user.displayName,
+ },
+ });
+ } catch (error) {
+ console.error('Registration error:', error);
+
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: 'Invalid input', details: error.issues },
+ { status: 400 }
+ );
+ }
+
+ return NextResponse.json(
+ { error: error instanceof Error ? error.message : 'Registration failed' },
+ { status: 400 }
+ );
+ }
+}
diff --git a/src/app/api/federation/gossip/route.ts b/src/app/api/federation/gossip/route.ts
new file mode 100644
index 0000000..c5e5cd9
--- /dev/null
+++ b/src/app/api/federation/gossip/route.ts
@@ -0,0 +1,60 @@
+import { NextResponse } from 'next/server';
+import { z } from 'zod';
+import { requireAdmin } from '@/lib/auth/admin';
+import { upsertHandleEntries } from '@/lib/federation/handles';
+
+const gossipSchema = z.object({
+ nodes: z.array(z.string().min(1)).min(1),
+ since: z.string().optional(),
+});
+
+export async function POST(request: Request) {
+ try {
+ await requireAdmin();
+
+ const body = await request.json();
+ const data = gossipSchema.parse(body);
+
+ const results = [];
+
+ for (const node of data.nodes) {
+ const baseUrl = node.startsWith('http') ? node : `https://${node}`;
+ const url = new URL('/.well-known/synapsis-handles', baseUrl);
+ if (data.since) {
+ url.searchParams.set('since', data.since);
+ }
+
+ try {
+ const res = await fetch(url.toString(), { method: 'GET' });
+ if (!res.ok) {
+ results.push({ node, success: false, error: `HTTP ${res.status}` });
+ continue;
+ }
+
+ const payload = await res.json();
+ const handles = Array.isArray(payload?.handles) ? payload.handles : [];
+ const merged = await upsertHandleEntries(handles);
+
+ results.push({
+ node,
+ success: true,
+ added: merged.added,
+ updated: merged.updated,
+ });
+ } catch (error) {
+ results.push({ node, success: false, error: 'Fetch failed' });
+ }
+ }
+
+ return NextResponse.json({ results });
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json({ error: 'Invalid payload', details: error.issues }, { status: 400 });
+ }
+ if (error instanceof Error && error.message === 'Admin required') {
+ return NextResponse.json({ error: 'Admin required' }, { status: 403 });
+ }
+ console.error('Gossip error:', error);
+ return NextResponse.json({ error: 'Failed to gossip handles' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/federation/handles/route.ts b/src/app/api/federation/handles/route.ts
new file mode 100644
index 0000000..93f59b6
--- /dev/null
+++ b/src/app/api/federation/handles/route.ts
@@ -0,0 +1,33 @@
+import { NextResponse } from 'next/server';
+import { z } from 'zod';
+import { upsertHandleEntries } from '@/lib/federation/handles';
+
+const payloadSchema = z.object({
+ handles: z.array(z.object({
+ handle: z.string().min(1),
+ did: z.string().min(1),
+ nodeDomain: z.string().min(1),
+ updatedAt: z.string().optional(),
+ })).min(1),
+});
+
+export async function POST(request: Request) {
+ try {
+ const body = await request.json();
+ const data = payloadSchema.parse(body);
+
+ const result = await upsertHandleEntries(data.handles);
+
+ return NextResponse.json({
+ success: true,
+ added: result.added,
+ updated: result.updated,
+ });
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json({ error: 'Invalid payload', details: error.issues }, { status: 400 });
+ }
+ console.error('Handle ingest error:', error);
+ return NextResponse.json({ error: 'Failed to ingest handles' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/handles/resolve/route.ts b/src/app/api/handles/resolve/route.ts
new file mode 100644
index 0000000..6233151
--- /dev/null
+++ b/src/app/api/handles/resolve/route.ts
@@ -0,0 +1,69 @@
+import { NextResponse } from 'next/server';
+import { db, handleRegistry } from '@/db';
+import { eq } from 'drizzle-orm';
+import { normalizeHandle, upsertHandleEntries } from '@/lib/federation/handles';
+
+const parseHandleWithDomain = (handle: string) => {
+ const clean = normalizeHandle(handle);
+ const parts = clean.split('@').filter(Boolean);
+ if (parts.length === 2) {
+ return { handle: parts[0], domain: parts[1] };
+ }
+ return null;
+};
+
+export async function GET(request: Request) {
+ try {
+ if (!db) {
+ return NextResponse.json({ error: 'Database not available' }, { status: 503 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const handleParam = searchParams.get('handle');
+
+ if (!handleParam) {
+ return NextResponse.json({ error: 'Handle is required' }, { status: 400 });
+ }
+
+ const parsed = parseHandleWithDomain(handleParam);
+ const lookupHandle = parsed ? parsed.handle : normalizeHandle(handleParam);
+ const localEntry = await db.query.handleRegistry.findFirst({
+ where: eq(handleRegistry.handle, lookupHandle),
+ });
+
+ if (localEntry) {
+ return NextResponse.json({
+ handle: localEntry.handle,
+ did: localEntry.did,
+ nodeDomain: localEntry.nodeDomain,
+ updatedAt: localEntry.updatedAt,
+ });
+ }
+
+ if (!parsed) {
+ return NextResponse.json({ error: 'Handle not found' }, { status: 404 });
+ }
+
+ const url = new URL('/.well-known/synapsis-handles', `https://${parsed.domain}`);
+ url.searchParams.set('handle', parsed.handle);
+
+ const res = await fetch(url.toString());
+ if (!res.ok) {
+ return NextResponse.json({ error: 'Handle not found' }, { status: 404 });
+ }
+
+ const data = await res.json();
+ const entry = Array.isArray(data?.handles) ? data.handles[0] : null;
+
+ if (!entry) {
+ return NextResponse.json({ error: 'Handle not found' }, { status: 404 });
+ }
+
+ await upsertHandleEntries([entry]);
+
+ return NextResponse.json(entry);
+ } catch (error) {
+ console.error('Handle resolve error:', error);
+ return NextResponse.json({ error: 'Failed to resolve handle' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/install/status/route.ts b/src/app/api/install/status/route.ts
new file mode 100644
index 0000000..2f52407
--- /dev/null
+++ b/src/app/api/install/status/route.ts
@@ -0,0 +1,54 @@
+import { NextResponse } from 'next/server';
+import { db, users } from '@/db';
+import { count, sql } from 'drizzle-orm';
+
+const requiredEnv = [
+ 'DATABASE_URL',
+ 'AUTH_SECRET',
+ 'NEXT_PUBLIC_NODE_DOMAIN',
+ 'NEXT_PUBLIC_NODE_NAME',
+ 'ADMIN_EMAILS',
+];
+
+const optionalEnv: string[] = [];
+
+export async function GET() {
+ try {
+ const envStatus = {
+ required: requiredEnv.reduce>((acc, key) => {
+ acc[key] = Boolean(process.env[key]);
+ return acc;
+ }, {}),
+ optional: optionalEnv.reduce>((acc, key) => {
+ acc[key] = Boolean(process.env[key]);
+ return acc;
+ }, {}),
+ };
+
+ if (!db) {
+ return NextResponse.json({
+ env: envStatus,
+ db: { connected: false, schemaReady: false, usersCount: 0 },
+ });
+ }
+
+ let schemaReady = true;
+ let usersCount = 0;
+
+ try {
+ await db.execute(sql`select 1 from users limit 1`);
+ const [result] = await db.select({ count: count() }).from(users);
+ usersCount = Number(result?.count || 0);
+ } catch {
+ schemaReady = false;
+ }
+
+ return NextResponse.json({
+ env: envStatus,
+ db: { connected: true, schemaReady, usersCount },
+ });
+ } catch (error) {
+ console.error('Install status error:', error);
+ return NextResponse.json({ error: 'Failed to check status' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/media/upload/route.ts b/src/app/api/media/upload/route.ts
new file mode 100644
index 0000000..1404c5a
--- /dev/null
+++ b/src/app/api/media/upload/route.ts
@@ -0,0 +1,82 @@
+import { NextResponse } from 'next/server';
+import { db, media } from '@/db';
+import { requireAuth } from '@/lib/auth';
+import { writeFile, mkdir } from 'fs/promises';
+import { join } from 'path';
+import { randomUUID } from 'crypto';
+
+const UPLOAD_DIR = join(process.cwd(), 'public', 'uploads');
+const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
+const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
+
+export async function POST(request: Request) {
+ try {
+ const user = await requireAuth();
+
+ const formData = await request.formData();
+ const file = formData.get('file') as File | null;
+ const altText = (formData.get('alt') as string | null) || null;
+
+ if (!file) {
+ return NextResponse.json({ error: 'No file provided' }, { status: 400 });
+ }
+
+ // Validate file type
+ if (!ALLOWED_TYPES.includes(file.type)) {
+ return NextResponse.json({
+ error: 'Invalid file type. Allowed: JPEG, PNG, GIF, WebP'
+ }, { status: 400 });
+ }
+
+ // Validate file size
+ if (file.size > MAX_FILE_SIZE) {
+ return NextResponse.json({
+ error: 'File too large. Maximum size: 10MB'
+ }, { status: 400 });
+ }
+
+ // Generate unique filename
+ const ext = file.name.split('.').pop() || 'jpg';
+ const filename = `${randomUUID()}.${ext}`;
+ const filepath = join(UPLOAD_DIR, filename);
+
+ // Ensure upload directory exists
+ await mkdir(UPLOAD_DIR, { recursive: true });
+
+ // Write file
+ const bytes = await file.arrayBuffer();
+ await writeFile(filepath, Buffer.from(bytes));
+
+ const url = `/uploads/${filename}`;
+
+ // If database is available, store media record
+ if (db) {
+ const [mediaRecord] = await db.insert(media).values({
+ userId: user.id,
+ postId: null,
+ url,
+ altText,
+ mimeType: file.type,
+ width: 0, // TODO: Get actual dimensions
+ height: 0,
+ }).returning();
+
+ return NextResponse.json({
+ success: true,
+ media: mediaRecord,
+ url,
+ });
+ }
+
+ return NextResponse.json({
+ success: true,
+ url,
+ });
+ } catch (error) {
+ if (error instanceof Error && error.message === 'Authentication required') {
+ return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
+ }
+ console.error('Upload error:', error);
+ return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts
new file mode 100644
index 0000000..ab36990
--- /dev/null
+++ b/src/app/api/notifications/route.ts
@@ -0,0 +1,103 @@
+import { NextResponse } from 'next/server';
+import { db, notifications } from '@/db';
+import { requireAuth } from '@/lib/auth';
+import { and, desc, eq, inArray, isNull } from 'drizzle-orm';
+import { z } from 'zod';
+
+const markSchema = z.object({
+ ids: z.array(z.string().uuid()).optional(),
+ all: z.boolean().optional(),
+});
+
+export async function GET(request: Request) {
+ try {
+ const user = await requireAuth();
+
+ if (!db) {
+ return NextResponse.json({ notifications: [] });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const limit = Math.min(parseInt(searchParams.get('limit') || '30'), 50);
+ const unreadOnly = searchParams.get('unread') === 'true';
+
+ const conditions = [eq(notifications.userId, user.id)];
+ if (unreadOnly) {
+ conditions.push(isNull(notifications.readAt));
+ }
+
+ const rows = await db.query.notifications.findMany({
+ where: and(...conditions),
+ with: {
+ actor: true,
+ post: true,
+ },
+ orderBy: [desc(notifications.createdAt)],
+ limit,
+ });
+
+ const payload = rows.map((row) => ({
+ id: row.id,
+ type: row.type,
+ createdAt: row.createdAt,
+ readAt: row.readAt,
+ actor: row.actor ? {
+ id: row.actor.id,
+ handle: row.actor.handle,
+ displayName: row.actor.displayName,
+ avatarUrl: row.actor.avatarUrl,
+ } : null,
+ post: row.post ? {
+ id: row.post.id,
+ content: row.post.content,
+ } : null,
+ }));
+
+ return NextResponse.json({ notifications: payload });
+ } catch (error) {
+ if (error instanceof Error && error.message === 'Authentication required') {
+ return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
+ }
+ console.error('Notifications fetch error:', error);
+ return NextResponse.json({ error: 'Failed to fetch notifications' }, { status: 500 });
+ }
+}
+
+export async function PATCH(request: Request) {
+ try {
+ const user = await requireAuth();
+
+ if (!db) {
+ return NextResponse.json({ error: 'Database not available' }, { status: 503 });
+ }
+
+ const body = await request.json();
+ const data = markSchema.parse(body);
+
+ if (!data.all && (!data.ids || data.ids.length === 0)) {
+ return NextResponse.json({ error: 'No notifications specified' }, { status: 400 });
+ }
+
+ const where = data.all
+ ? eq(notifications.userId, user.id)
+ : and(
+ eq(notifications.userId, user.id),
+ inArray(notifications.id, data.ids || [])
+ );
+
+ await db.update(notifications)
+ .set({ readAt: new Date() })
+ .where(where);
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
+ }
+ if (error instanceof Error && error.message === 'Authentication required') {
+ return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
+ }
+ console.error('Notifications update error:', error);
+ return NextResponse.json({ error: 'Failed to update notifications' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/posts/[id]/like/route.ts b/src/app/api/posts/[id]/like/route.ts
new file mode 100644
index 0000000..79f2682
--- /dev/null
+++ b/src/app/api/posts/[id]/like/route.ts
@@ -0,0 +1,122 @@
+import { NextResponse } from 'next/server';
+import { db, posts, likes, users, notifications } from '@/db';
+import { requireAuth } from '@/lib/auth';
+import { eq, and } from 'drizzle-orm';
+
+type RouteContext = { params: Promise<{ id: string }> };
+
+// Like a post
+export async function POST(request: Request, context: RouteContext) {
+ try {
+ const user = await requireAuth();
+ const { id: postId } = await context.params;
+
+ if (user.isSuspended || user.isSilenced) {
+ return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
+ }
+
+ // Check if post exists
+ const post = await db.query.posts.findFirst({
+ where: eq(posts.id, postId),
+ });
+
+ if (!post) {
+ return NextResponse.json({ error: 'Post not found' }, { status: 404 });
+ }
+ if (post.isRemoved) {
+ return NextResponse.json({ error: 'Post not found' }, { status: 404 });
+ }
+
+ // Check if already liked
+ const existingLike = await db.query.likes.findFirst({
+ where: and(
+ eq(likes.userId, user.id),
+ eq(likes.postId, postId)
+ ),
+ });
+
+ if (existingLike) {
+ return NextResponse.json({ error: 'Already liked' }, { status: 400 });
+ }
+
+ // Create like
+ await db.insert(likes).values({
+ userId: user.id,
+ postId,
+ });
+
+ // Update post's like count
+ await db.update(posts)
+ .set({ likesCount: post.likesCount + 1 })
+ .where(eq(posts.id, postId));
+
+ if (post.userId !== user.id) {
+ await db.insert(notifications).values({
+ userId: post.userId,
+ actorId: user.id,
+ postId,
+ type: 'like',
+ });
+ }
+
+ // TODO: Federate the like
+
+ return NextResponse.json({ success: true, liked: true });
+ } catch (error) {
+ if (error instanceof Error && error.message === 'Authentication required') {
+ return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
+ }
+ return NextResponse.json({ error: 'Failed to like post' }, { status: 500 });
+ }
+}
+
+// Unlike a post
+export async function DELETE(request: Request, context: RouteContext) {
+ try {
+ const user = await requireAuth();
+ const { id: postId } = await context.params;
+
+ if (user.isSuspended || user.isSilenced) {
+ return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
+ }
+
+ // Check if post exists
+ const post = await db.query.posts.findFirst({
+ where: eq(posts.id, postId),
+ });
+
+ if (!post) {
+ return NextResponse.json({ error: 'Post not found' }, { status: 404 });
+ }
+ if (post.isRemoved) {
+ return NextResponse.json({ error: 'Post not found' }, { status: 404 });
+ }
+
+ // Find the like
+ const existingLike = await db.query.likes.findFirst({
+ where: and(
+ eq(likes.userId, user.id),
+ eq(likes.postId, postId)
+ ),
+ });
+
+ if (!existingLike) {
+ return NextResponse.json({ error: 'Not liked' }, { status: 400 });
+ }
+
+ // Remove like
+ await db.delete(likes).where(eq(likes.id, existingLike.id));
+
+ // Update post's like count
+ await db.update(posts)
+ .set({ likesCount: Math.max(0, post.likesCount - 1) })
+ .where(eq(posts.id, postId));
+
+ return NextResponse.json({ success: true, liked: false });
+ } catch (error) {
+ if (error instanceof Error && error.message === 'Authentication required') {
+ return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
+ }
+ return NextResponse.json({ error: 'Failed to unlike post' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/posts/[id]/repost/route.ts b/src/app/api/posts/[id]/repost/route.ts
new file mode 100644
index 0000000..c3dd79e
--- /dev/null
+++ b/src/app/api/posts/[id]/repost/route.ts
@@ -0,0 +1,68 @@
+import { NextResponse } from 'next/server';
+import { db, posts, users, notifications } from '@/db';
+import { requireAuth } from '@/lib/auth';
+import { eq } from 'drizzle-orm';
+
+type RouteContext = { params: Promise<{ id: string }> };
+
+// Repost a post
+export async function POST(request: Request, context: RouteContext) {
+ try {
+ const user = await requireAuth();
+ const { id: postId } = await context.params;
+ const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
+
+ if (user.isSuspended || user.isSilenced) {
+ return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
+ }
+
+ // Check if post exists
+ const originalPost = await db.query.posts.findFirst({
+ where: eq(posts.id, postId),
+ });
+
+ if (!originalPost) {
+ return NextResponse.json({ error: 'Post not found' }, { status: 404 });
+ }
+ if (originalPost.isRemoved) {
+ return NextResponse.json({ error: 'Post not found' }, { status: 404 });
+ }
+
+ // Create repost
+ const [repost] = await db.insert(posts).values({
+ userId: user.id,
+ content: '', // Reposts don't have their own content
+ repostOfId: postId,
+ apId: `https://${nodeDomain}/posts/${crypto.randomUUID()}`,
+ apUrl: `https://${nodeDomain}/posts/${crypto.randomUUID()}`,
+ }).returning();
+
+ // Update original post's repost count
+ await db.update(posts)
+ .set({ repostsCount: originalPost.repostsCount + 1 })
+ .where(eq(posts.id, postId));
+
+ // Update user's post count
+ await db.update(users)
+ .set({ postsCount: user.postsCount + 1 })
+ .where(eq(users.id, user.id));
+
+ if (originalPost.userId !== user.id) {
+ await db.insert(notifications).values({
+ userId: originalPost.userId,
+ actorId: user.id,
+ postId,
+ type: 'repost',
+ });
+ }
+
+ // TODO: Federate the repost (Announce activity)
+
+ return NextResponse.json({ success: true, repost });
+ } catch (error) {
+ if (error instanceof Error && error.message === 'Authentication required') {
+ return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
+ }
+ return NextResponse.json({ error: 'Failed to repost' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts
new file mode 100644
index 0000000..a36e105
--- /dev/null
+++ b/src/app/api/posts/route.ts
@@ -0,0 +1,319 @@
+import { NextResponse } from 'next/server';
+import { db, posts, users, media, follows, mutes, blocks } from '@/db';
+import { requireAuth } from '@/lib/auth';
+import { eq, desc, and, inArray, isNull, notInArray, or } from 'drizzle-orm';
+import type { SQL } from 'drizzle-orm';
+import { z } from 'zod';
+
+const POST_MAX_LENGTH = 400;
+const CURATION_WINDOW_HOURS = 72;
+const CURATION_SEED_MULTIPLIER = 5;
+const CURATION_SEED_CAP = 200;
+
+const buildWhere = (...conditions: Array) => {
+ const filtered = conditions.filter(Boolean) as SQL[];
+ if (filtered.length === 0) return undefined;
+ return and(...filtered);
+};
+
+const createPostSchema = z.object({
+ content: z.string().min(1).max(POST_MAX_LENGTH),
+ replyToId: z.string().uuid().optional(),
+ mediaIds: z.array(z.string().uuid()).max(4).optional(),
+});
+
+// Create a new post
+export async function POST(request: Request) {
+ try {
+ const user = await requireAuth();
+ const body = await request.json();
+ const data = createPostSchema.parse(body);
+
+ if (user.isSuspended || user.isSilenced) {
+ return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
+ }
+
+ const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
+
+ const [post] = await db.insert(posts).values({
+ userId: user.id,
+ content: data.content,
+ replyToId: data.replyToId,
+ apId: `https://${nodeDomain}/posts/${crypto.randomUUID()}`,
+ apUrl: `https://${nodeDomain}/posts/${crypto.randomUUID()}`,
+ }).returning();
+
+ let attachedMedia: typeof media.$inferSelect[] = [];
+ if (data.mediaIds?.length) {
+ await db.update(media)
+ .set({ postId: post.id })
+ .where(and(
+ inArray(media.id, data.mediaIds),
+ eq(media.userId, user.id),
+ isNull(media.postId),
+ ));
+
+ attachedMedia = await db.query.media.findMany({
+ where: and(
+ inArray(media.id, data.mediaIds),
+ eq(media.userId, user.id),
+ eq(media.postId, post.id),
+ ),
+ });
+ }
+
+ // Update user's post count
+ await db.update(users)
+ .set({ postsCount: user.postsCount + 1 })
+ .where(eq(users.id, user.id));
+
+ // If this is a reply, update the parent's reply count
+ if (data.replyToId) {
+ const parentPost = await db.query.posts.findFirst({
+ where: eq(posts.id, data.replyToId),
+ });
+ if (parentPost) {
+ await db.update(posts)
+ .set({ repliesCount: parentPost.repliesCount + 1 })
+ .where(eq(posts.id, data.replyToId));
+ }
+ }
+
+ // TODO: Federate the post to followers
+
+ return NextResponse.json({ success: true, post: { ...post, media: attachedMedia } });
+ } catch (error) {
+ console.error('Create post error:', error);
+
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: 'Invalid input', details: error.issues },
+ { status: 400 }
+ );
+ }
+
+ if (error instanceof Error && error.message === 'Authentication required') {
+ return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
+ }
+
+ return NextResponse.json(
+ { error: 'Failed to create post' },
+ { status: 500 }
+ );
+ }
+}
+
+// Get timeline / feed
+export async function GET(request: Request) {
+ try {
+ // Return empty posts if no database is connected (for UI testing)
+ if (!db) {
+ return NextResponse.json({ posts: [], nextCursor: null });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const type = searchParams.get('type') || 'home'; // home, public, user, curated
+ const userId = searchParams.get('userId');
+ const cursor = searchParams.get('cursor');
+ const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
+
+ let feedPosts;
+ const moderatedUsers = await db.select({ id: users.id })
+ .from(users)
+ .where(or(eq(users.isSuspended, true), eq(users.isSilenced, true)));
+ const moderatedIds = moderatedUsers.map((item) => item.id);
+ const baseFilter = buildWhere(
+ eq(posts.isRemoved, false),
+ moderatedIds.length ? notInArray(posts.userId, moderatedIds) : undefined
+ );
+
+ if (type === 'public') {
+ // Public timeline - all posts
+ feedPosts = await db.query.posts.findMany({
+ where: baseFilter,
+ with: {
+ author: true,
+ media: true,
+ replyTo: {
+ with: { author: true },
+ },
+ },
+ orderBy: [desc(posts.createdAt)],
+ limit,
+ });
+ } else if (type === 'user' && userId) {
+ // User's posts
+ feedPosts = await db.query.posts.findMany({
+ where: buildWhere(baseFilter, eq(posts.userId, userId)),
+ with: {
+ author: true,
+ media: true,
+ replyTo: {
+ with: { author: true },
+ },
+ },
+ orderBy: [desc(posts.createdAt)],
+ limit,
+ });
+ } else if (type === 'curated') {
+ let viewer = null;
+ try {
+ viewer = await requireAuth();
+ } catch {
+ viewer = null;
+ }
+
+ const seedLimit = Math.min(limit * CURATION_SEED_MULTIPLIER, CURATION_SEED_CAP);
+ const seedPosts = await db.query.posts.findMany({
+ where: baseFilter,
+ with: {
+ author: true,
+ media: true,
+ replyTo: {
+ with: { author: true },
+ },
+ },
+ orderBy: [desc(posts.createdAt)],
+ limit: seedLimit,
+ });
+
+ let followingIds = new Set();
+ let mutedIds = new Set();
+ let blockedIds = new Set();
+
+ if (viewer) {
+ const followRows = await db.select({ followingId: follows.followingId })
+ .from(follows)
+ .where(eq(follows.followerId, viewer.id));
+ followingIds = new Set(followRows.map(row => row.followingId));
+
+ const muteRows = await db.select({ mutedUserId: mutes.mutedUserId })
+ .from(mutes)
+ .where(eq(mutes.userId, viewer.id));
+ mutedIds = new Set(muteRows.map(row => row.mutedUserId));
+
+ const blockRows = await db.select({ blockedUserId: blocks.blockedUserId })
+ .from(blocks)
+ .where(eq(blocks.userId, viewer.id));
+ blockedIds = new Set(blockRows.map(row => row.blockedUserId));
+ }
+
+ const now = Date.now();
+ const rankedPosts = seedPosts
+ .filter(post => !mutedIds.has(post.author.id) && !blockedIds.has(post.author.id))
+ .map(post => {
+ const createdAt = new Date(post.createdAt).getTime();
+ const ageHours = Math.max(0, (now - createdAt) / 3600000);
+ const engagement = post.likesCount + post.repostsCount * 2 + post.repliesCount * 0.5;
+ const engagementScore = Math.log1p(Math.max(0, engagement));
+ const recencyScore = Math.max(0, 1 - ageHours / CURATION_WINDOW_HOURS);
+
+ const followBoost = viewer && followingIds.has(post.author.id) ? 0.9 : 0;
+ const selfBoost = viewer && post.author.id === viewer.id ? 0.5 : 0;
+
+ const score = engagementScore * 1.4 + recencyScore * 1.1 + followBoost + selfBoost;
+
+ const reasons: string[] = [];
+ if (followBoost > 0) {
+ reasons.push(`You follow @${post.author.handle}`);
+ }
+ if (engagement >= 5) {
+ reasons.push(`Popular: ${post.likesCount} likes, ${post.repostsCount} reposts`);
+ } else if (engagement > 0) {
+ reasons.push(`Active conversation: ${post.repliesCount} replies`);
+ }
+ if (ageHours <= 6) {
+ reasons.push('Posted recently');
+ } else if (ageHours <= 24) {
+ reasons.push('Posted today');
+ } else if (ageHours <= CURATION_WINDOW_HOURS) {
+ reasons.push('Recent');
+ }
+ if (reasons.length === 0) {
+ reasons.push('New post');
+ }
+
+ return {
+ ...post,
+ feedMeta: {
+ score: Number(score.toFixed(3)),
+ reasons,
+ engagement: {
+ likes: post.likesCount,
+ reposts: post.repostsCount,
+ replies: post.repliesCount,
+ },
+ },
+ };
+ })
+ .sort((a, b) => {
+ if (b.feedMeta.score !== a.feedMeta.score) {
+ return b.feedMeta.score - a.feedMeta.score;
+ }
+ return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
+ })
+ .slice(0, limit);
+
+ return NextResponse.json({
+ posts: rankedPosts,
+ meta: {
+ algorithm: 'curated-v1',
+ windowHours: CURATION_WINDOW_HOURS,
+ seedLimit,
+ weights: {
+ engagement: 1.4,
+ recency: 1.1,
+ followBoost: 0.9,
+ selfBoost: 0.5,
+ },
+ },
+ nextCursor: rankedPosts.length === limit ? rankedPosts[rankedPosts.length - 1]?.id : null,
+ });
+ } else {
+ // Home timeline - need auth
+ try {
+ const user = await requireAuth();
+
+ // Get posts from people the user follows + their own posts
+ // For now, just return all posts (we'll add following filter later)
+ feedPosts = await db.query.posts.findMany({
+ where: baseFilter,
+ with: {
+ author: true,
+ media: true,
+ replyTo: {
+ with: { author: true },
+ },
+ },
+ orderBy: [desc(posts.createdAt)],
+ limit,
+ });
+ } catch {
+ // Not authenticated, return public timeline
+ feedPosts = await db.query.posts.findMany({
+ where: baseFilter,
+ with: {
+ author: true,
+ media: true,
+ replyTo: {
+ with: { author: true },
+ },
+ },
+ orderBy: [desc(posts.createdAt)],
+ limit,
+ });
+ }
+ }
+
+ return NextResponse.json({
+ posts: feedPosts,
+ nextCursor: feedPosts.length === limit ? feedPosts[feedPosts.length - 1]?.id : null,
+ });
+ } catch (error) {
+ console.error('Get feed error:', error);
+ return NextResponse.json(
+ { error: 'Failed to get feed' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/reports/route.ts b/src/app/api/reports/route.ts
new file mode 100644
index 0000000..ec99699
--- /dev/null
+++ b/src/app/api/reports/route.ts
@@ -0,0 +1,65 @@
+import { NextResponse } from 'next/server';
+import { db, reports, posts, users } from '@/db';
+import { requireAuth } from '@/lib/auth';
+import { eq } from 'drizzle-orm';
+import { z } from 'zod';
+
+const reportSchema = z.object({
+ targetType: z.enum(['post', 'user']),
+ targetId: z.string().uuid(),
+ reason: z.string().min(3).max(500),
+});
+
+export async function POST(request: Request) {
+ try {
+ const reporter = await requireAuth();
+
+ if (reporter.isSuspended || reporter.isSilenced) {
+ return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
+ }
+
+ if (!db) {
+ return NextResponse.json({ error: 'Database not available' }, { status: 503 });
+ }
+
+ const body = await request.json();
+ const data = reportSchema.parse(body);
+
+ if (data.targetType === 'post') {
+ const targetPost = await db.query.posts.findFirst({
+ where: eq(posts.id, data.targetId),
+ });
+ if (!targetPost || targetPost.isRemoved) {
+ return NextResponse.json({ error: 'Post not found' }, { status: 404 });
+ }
+ }
+
+ if (data.targetType === 'user') {
+ const targetUser = await db.query.users.findFirst({
+ where: eq(users.id, data.targetId),
+ });
+ if (!targetUser) {
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+ }
+
+ const [report] = await db.insert(reports).values({
+ reporterId: reporter.id,
+ targetType: data.targetType,
+ targetId: data.targetId,
+ reason: data.reason,
+ status: 'open',
+ }).returning();
+
+ return NextResponse.json({ report });
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
+ }
+ if (error instanceof Error && error.message === 'Authentication required') {
+ return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
+ }
+ console.error('Report error:', error);
+ return NextResponse.json({ error: 'Failed to submit report' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts
new file mode 100644
index 0000000..654a199
--- /dev/null
+++ b/src/app/api/search/route.ts
@@ -0,0 +1,86 @@
+import { NextResponse } from 'next/server';
+import { db, users, posts } from '@/db';
+import { ilike, or, desc, and, notInArray, eq } from 'drizzle-orm';
+
+export async function GET(request: Request) {
+ try {
+ const { searchParams } = new URL(request.url);
+ const query = searchParams.get('q') || '';
+ const type = searchParams.get('type') || 'all'; // all, users, posts
+ const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
+
+ if (!query.trim()) {
+ return NextResponse.json({ users: [], posts: [] });
+ }
+
+ // Return empty if no database
+ if (!db) {
+ return NextResponse.json({
+ users: [],
+ posts: [],
+ message: 'Search requires database connection'
+ });
+ }
+
+ const searchPattern = `%${query}%`;
+ let searchUsers: { id: string; handle: string; displayName: string | null; avatarUrl: string | null; bio: string | null }[] = [];
+ let searchPosts: typeof posts.$inferSelect[] = [];
+
+ // Search users
+ if (type === 'all' || type === 'users') {
+ const userConditions = and(
+ or(
+ ilike(users.handle, searchPattern),
+ ilike(users.displayName, searchPattern),
+ ilike(users.bio, searchPattern)
+ ),
+ eq(users.isSuspended, false),
+ eq(users.isSilenced, false)
+ );
+ searchUsers = await db.select({
+ id: users.id,
+ handle: users.handle,
+ displayName: users.displayName,
+ avatarUrl: users.avatarUrl,
+ bio: users.bio,
+ })
+ .from(users)
+ .where(userConditions)
+ .limit(limit);
+ }
+
+ const moderatedUsers = await db.select({ id: users.id })
+ .from(users)
+ .where(or(eq(users.isSuspended, true), eq(users.isSilenced, true)));
+ const moderatedIds = moderatedUsers.map((item) => item.id);
+
+ // Search posts
+ if (type === 'all' || type === 'posts') {
+ const postConditions = [
+ ilike(posts.content, searchPattern),
+ eq(posts.isRemoved, false),
+ ];
+ if (moderatedIds.length) {
+ postConditions.push(notInArray(posts.userId, moderatedIds));
+ }
+ const postResults = await db.query.posts.findMany({
+ where: and(...postConditions),
+ with: {
+ author: true,
+ media: true,
+ },
+ orderBy: [desc(posts.createdAt)],
+ limit,
+ });
+ searchPosts = postResults;
+ }
+
+ return NextResponse.json({
+ users: searchUsers,
+ posts: searchPosts,
+ });
+ } catch (error) {
+ console.error('Search error:', error);
+ return NextResponse.json({ error: 'Search failed' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/users/[handle]/follow/route.ts b/src/app/api/users/[handle]/follow/route.ts
new file mode 100644
index 0000000..683f285
--- /dev/null
+++ b/src/app/api/users/[handle]/follow/route.ts
@@ -0,0 +1,191 @@
+import { NextResponse } from 'next/server';
+import { db, follows, users, notifications } from '@/db';
+import { eq, and } from 'drizzle-orm';
+import { requireAuth } from '@/lib/auth';
+
+type RouteContext = { params: Promise<{ handle: string }> };
+
+// Check follow status
+export async function GET(request: Request, context: RouteContext) {
+ try {
+ const currentUser = await requireAuth();
+ const { handle } = await context.params;
+ const cleanHandle = handle.toLowerCase().replace(/^@/, '');
+
+ if (currentUser.isSuspended || currentUser.isSilenced) {
+ return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
+ }
+
+ if (!db) {
+ return NextResponse.json({ error: 'Database not available' }, { status: 503 });
+ }
+
+ const targetUser = await db.query.users.findFirst({
+ where: eq(users.handle, cleanHandle),
+ });
+
+ if (!targetUser) {
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+ if (targetUser.isSuspended) {
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+
+ if (targetUser.id === currentUser.id) {
+ return NextResponse.json({ following: false, self: true });
+ }
+
+ const existingFollow = await db.query.follows.findFirst({
+ where: and(
+ eq(follows.followerId, currentUser.id),
+ eq(follows.followingId, targetUser.id)
+ ),
+ });
+
+ return NextResponse.json({ following: !!existingFollow });
+ } catch (error) {
+ if (error instanceof Error && error.message === 'Authentication required') {
+ return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
+ }
+ console.error('Follow status error:', error);
+ return NextResponse.json({ error: 'Failed to get follow status' }, { status: 500 });
+ }
+}
+
+// Follow a user
+export async function POST(request: Request, context: RouteContext) {
+ try {
+ const currentUser = await requireAuth();
+ const { handle } = await context.params;
+ const cleanHandle = handle.toLowerCase().replace(/^@/, '');
+
+ if (currentUser.isSuspended || currentUser.isSilenced) {
+ return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
+ }
+
+ if (!db) {
+ return NextResponse.json({ error: 'Database not available' }, { status: 503 });
+ }
+
+ // Find target user
+ const targetUser = await db.query.users.findFirst({
+ where: eq(users.handle, cleanHandle),
+ });
+
+ if (!targetUser) {
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+ if (targetUser.isSuspended) {
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+
+ // Can't follow yourself
+ if (targetUser.id === currentUser.id) {
+ return NextResponse.json({ error: 'Cannot follow yourself' }, { status: 400 });
+ }
+
+ // Check if already following
+ const existingFollow = await db.query.follows.findFirst({
+ where: and(
+ eq(follows.followerId, currentUser.id),
+ eq(follows.followingId, targetUser.id)
+ ),
+ });
+
+ if (existingFollow) {
+ return NextResponse.json({ error: 'Already following' }, { status: 400 });
+ }
+
+ // Create follow
+ await db.insert(follows).values({
+ followerId: currentUser.id,
+ followingId: targetUser.id,
+ });
+
+ if (currentUser.id !== targetUser.id) {
+ await db.insert(notifications).values({
+ userId: targetUser.id,
+ actorId: currentUser.id,
+ type: 'follow',
+ });
+ }
+
+ // Update counts
+ await db.update(users)
+ .set({ followingCount: currentUser.followingCount + 1 })
+ .where(eq(users.id, currentUser.id));
+
+ await db.update(users)
+ .set({ followersCount: targetUser.followersCount + 1 })
+ .where(eq(users.id, targetUser.id));
+
+ // TODO: Send ActivityPub Follow activity
+
+ return NextResponse.json({ success: true, following: true });
+ } catch (error) {
+ if (error instanceof Error && error.message === 'Authentication required') {
+ return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
+ }
+ console.error('Follow error:', error);
+ return NextResponse.json({ error: 'Failed to follow' }, { status: 500 });
+ }
+}
+
+// Unfollow a user
+export async function DELETE(request: Request, context: RouteContext) {
+ try {
+ const currentUser = await requireAuth();
+ const { handle } = await context.params;
+ const cleanHandle = handle.toLowerCase().replace(/^@/, '');
+
+ if (!db) {
+ return NextResponse.json({ error: 'Database not available' }, { status: 503 });
+ }
+
+ // Find target user
+ const targetUser = await db.query.users.findFirst({
+ where: eq(users.handle, cleanHandle),
+ });
+
+ if (!targetUser) {
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+ if (targetUser.isSuspended) {
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+
+ // Find existing follow
+ const existingFollow = await db.query.follows.findFirst({
+ where: and(
+ eq(follows.followerId, currentUser.id),
+ eq(follows.followingId, targetUser.id)
+ ),
+ });
+
+ if (!existingFollow) {
+ return NextResponse.json({ error: 'Not following' }, { status: 400 });
+ }
+
+ // Remove follow
+ await db.delete(follows).where(eq(follows.id, existingFollow.id));
+
+ // Update counts
+ await db.update(users)
+ .set({ followingCount: Math.max(0, currentUser.followingCount - 1) })
+ .where(eq(users.id, currentUser.id));
+
+ await db.update(users)
+ .set({ followersCount: Math.max(0, targetUser.followersCount - 1) })
+ .where(eq(users.id, targetUser.id));
+
+ // TODO: Send ActivityPub Undo Follow activity
+
+ return NextResponse.json({ success: true, following: false });
+ } catch (error) {
+ if (error instanceof Error && error.message === 'Authentication required') {
+ return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
+ }
+ console.error('Unfollow error:', error);
+ return NextResponse.json({ error: 'Failed to unfollow' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/users/[handle]/followers/route.ts b/src/app/api/users/[handle]/followers/route.ts
new file mode 100644
index 0000000..e060521
--- /dev/null
+++ b/src/app/api/users/[handle]/followers/route.ts
@@ -0,0 +1,54 @@
+import { NextResponse } from 'next/server';
+import { db, follows, users } from '@/db';
+import { eq } from 'drizzle-orm';
+
+type RouteContext = { params: Promise<{ handle: string }> };
+
+export async function GET(request: Request, context: RouteContext) {
+ try {
+ const { handle } = await context.params;
+ const cleanHandle = handle.toLowerCase().replace(/^@/, '');
+ const { searchParams } = new URL(request.url);
+ const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
+
+ // Return empty if no database
+ if (!db) {
+ return NextResponse.json({ followers: [], nextCursor: null });
+ }
+
+ // Find the user
+ const user = await db.query.users.findFirst({
+ where: eq(users.handle, cleanHandle),
+ });
+
+ if (!user) {
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+ if (user.isSuspended) {
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+
+ // Get followers
+ const userFollowers = await db.query.follows.findMany({
+ where: eq(follows.followingId, user.id),
+ with: {
+ follower: true,
+ },
+ limit,
+ });
+
+ return NextResponse.json({
+ followers: userFollowers.map(f => ({
+ id: f.follower.id,
+ handle: f.follower.handle,
+ displayName: f.follower.displayName,
+ avatarUrl: f.follower.avatarUrl,
+ bio: f.follower.bio,
+ })),
+ nextCursor: userFollowers.length === limit ? userFollowers[userFollowers.length - 1]?.id : null,
+ });
+ } catch (error) {
+ console.error('Get followers error:', error);
+ return NextResponse.json({ error: 'Failed to get followers' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/users/[handle]/following/route.ts b/src/app/api/users/[handle]/following/route.ts
new file mode 100644
index 0000000..4279f9e
--- /dev/null
+++ b/src/app/api/users/[handle]/following/route.ts
@@ -0,0 +1,54 @@
+import { NextResponse } from 'next/server';
+import { db, follows, users } from '@/db';
+import { eq } from 'drizzle-orm';
+
+type RouteContext = { params: Promise<{ handle: string }> };
+
+export async function GET(request: Request, context: RouteContext) {
+ try {
+ const { handle } = await context.params;
+ const cleanHandle = handle.toLowerCase().replace(/^@/, '');
+ const { searchParams } = new URL(request.url);
+ const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
+
+ // Return empty if no database
+ if (!db) {
+ return NextResponse.json({ following: [], nextCursor: null });
+ }
+
+ // Find the user
+ const user = await db.query.users.findFirst({
+ where: eq(users.handle, cleanHandle),
+ });
+
+ if (!user) {
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+ if (user.isSuspended) {
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+
+ // Get following
+ const userFollowing = await db.query.follows.findMany({
+ where: eq(follows.followerId, user.id),
+ with: {
+ following: true,
+ },
+ limit,
+ });
+
+ return NextResponse.json({
+ following: userFollowing.map(f => ({
+ id: f.following.id,
+ handle: f.following.handle,
+ displayName: f.following.displayName,
+ avatarUrl: f.following.avatarUrl,
+ bio: f.following.bio,
+ })),
+ nextCursor: userFollowing.length === limit ? userFollowing[userFollowing.length - 1]?.id : null,
+ });
+ } catch (error) {
+ console.error('Get following error:', error);
+ return NextResponse.json({ error: 'Failed to get following' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/users/[handle]/posts/route.ts b/src/app/api/users/[handle]/posts/route.ts
new file mode 100644
index 0000000..7c4d5af
--- /dev/null
+++ b/src/app/api/users/[handle]/posts/route.ts
@@ -0,0 +1,53 @@
+import { NextResponse } from 'next/server';
+import { db, posts, users } from '@/db';
+import { eq, desc, and } from 'drizzle-orm';
+
+type RouteContext = { params: Promise<{ handle: string }> };
+
+export async function GET(request: Request, context: RouteContext) {
+ try {
+ const { handle } = await context.params;
+ const cleanHandle = handle.toLowerCase().replace(/^@/, '');
+ const { searchParams } = new URL(request.url);
+ const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
+
+ // Return empty if no database
+ if (!db) {
+ return NextResponse.json({ posts: [], nextCursor: null });
+ }
+
+ // Find the user
+ const user = await db.query.users.findFirst({
+ where: eq(users.handle, cleanHandle),
+ });
+
+ if (!user) {
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+ if (user.isSuspended) {
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+
+ // Get user's posts
+ const userPosts = await db.query.posts.findMany({
+ where: and(eq(posts.userId, user.id), eq(posts.isRemoved, false)),
+ with: {
+ author: true,
+ media: true,
+ replyTo: {
+ with: { author: true },
+ },
+ },
+ orderBy: [desc(posts.createdAt)],
+ limit,
+ });
+
+ return NextResponse.json({
+ posts: userPosts,
+ nextCursor: userPosts.length === limit ? userPosts[userPosts.length - 1]?.id : null,
+ });
+ } catch (error) {
+ console.error('Get user posts error:', error);
+ return NextResponse.json({ error: 'Failed to get posts' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/users/[handle]/route.ts b/src/app/api/users/[handle]/route.ts
new file mode 100644
index 0000000..4ad45f1
--- /dev/null
+++ b/src/app/api/users/[handle]/route.ts
@@ -0,0 +1,73 @@
+import { NextResponse } from 'next/server';
+import { db, users } from '@/db';
+import { eq } from 'drizzle-orm';
+import { userToActor } from '@/lib/activitypub/actor';
+
+type RouteContext = { params: Promise<{ handle: string }> };
+
+export async function GET(request: Request, context: RouteContext) {
+ try {
+ const { handle } = await context.params;
+ const cleanHandle = handle.toLowerCase().replace(/^@/, '');
+
+ // Return mock user if no database
+ if (!db) {
+ return NextResponse.json({
+ user: {
+ id: 'demo-user',
+ handle: cleanHandle,
+ displayName: cleanHandle,
+ bio: 'This is a demo profile.',
+ avatarUrl: null,
+ headerUrl: null,
+ followersCount: 0,
+ followingCount: 0,
+ postsCount: 0,
+ createdAt: new Date().toISOString(),
+ }
+ });
+ }
+
+ const user = await db.query.users.findFirst({
+ where: eq(users.handle, cleanHandle),
+ });
+
+ if (!user) {
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+ if (user.isSuspended) {
+ return NextResponse.json({ error: 'User not found' }, { status: 404 });
+ }
+
+ // Check if ActivityPub request
+ const accept = request.headers.get('accept') || '';
+ if (accept.includes('application/activity+json') || accept.includes('application/ld+json')) {
+ const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
+ const actor = userToActor(user, nodeDomain);
+ return NextResponse.json(actor, {
+ headers: {
+ 'Content-Type': 'application/activity+json',
+ },
+ });
+ }
+
+ // Return user profile (without sensitive data)
+ return NextResponse.json({
+ user: {
+ id: user.id,
+ handle: user.handle,
+ displayName: user.displayName,
+ bio: user.bio,
+ avatarUrl: user.avatarUrl,
+ headerUrl: user.headerUrl,
+ followersCount: user.followersCount,
+ followingCount: user.followingCount,
+ postsCount: user.postsCount,
+ createdAt: user.createdAt,
+ }
+ });
+ } catch (error) {
+ console.error('Get user error:', error);
+ return NextResponse.json({ error: 'Failed to get user' }, { status: 500 });
+ }
+}
diff --git a/src/app/favicon.ico b/src/app/favicon.ico
deleted file mode 100644
index 718d6fe..0000000
Binary files a/src/app/favicon.ico and /dev/null differ
diff --git a/src/app/globals.css b/src/app/globals.css
index a2dc41e..da6777e 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -1,26 +1,1130 @@
@import "tailwindcss";
:root {
- --background: #ffffff;
- --foreground: #171717;
+ /* Synapsis Design System - Vercel-inspired Dark Theme */
+
+ /* Base Colors */
+ --background: #0a0a0a;
+ --background-secondary: #111111;
+ --background-tertiary: #171717;
+
+ /* Border Colors */
+ --border: #262626;
+ --border-hover: #404040;
+
+ /* Text Colors */
+ --foreground: #ededed;
+ --foreground-secondary: #a1a1a1;
+ --foreground-tertiary: #666666;
+
+ /* Accent - Cyan */
+ --accent: #ffffff;
+ --accent-hover: #f4f4f4;
+ --accent-muted: rgba(255, 255, 255, 0.1);
+
+ /* Status Colors */
+ --error: #ef4444;
+ --success: #22c55e;
+ --warning: #f59e0b;
+
+ /* Shadows */
+ --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.5);
+ --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.5);
+
+ /* Spacing */
+ --radius-sm: 6px;
+ --radius-md: 8px;
+ --radius-lg: 12px;
+ --radius-full: 9999px;
}
-@theme inline {
- --color-background: var(--background);
- --color-foreground: var(--foreground);
- --font-sans: var(--font-geist-sans);
- --font-mono: var(--font-geist-mono);
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
}
-@media (prefers-color-scheme: dark) {
- :root {
- --background: #0a0a0a;
- --foreground: #ededed;
- }
+html {
+ color-scheme: dark;
}
body {
+ background-color: var(--background);
+ color: var(--foreground);
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+ font-size: 14px;
+ line-height: 1.5;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+/* Links */
+a {
+ color: var(--accent);
+ text-decoration: none;
+ transition: color 0.15s ease;
+}
+
+a:hover {
+ color: var(--accent-hover);
+}
+
+/* Buttons */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 8px 16px;
+ font-size: 14px;
+ font-weight: 500;
+ border-radius: var(--radius-md);
+ border: 1px solid var(--border);
+ background: var(--background-secondary);
+ color: var(--foreground);
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.btn:hover {
+ background: var(--background-tertiary);
+ border-color: var(--border-hover);
+}
+
+.btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.btn-primary {
+ background: var(--accent);
+ border-color: var(--accent);
+ color: #000;
+}
+
+.btn-primary:hover,
+.btn-primary:link:hover,
+.btn-primary:visited:hover {
+ background: var(--accent-hover);
+ border-color: var(--accent-hover);
+ color: #000;
+}
+
+/* Ensure links styled as buttons don't inherit link colors */
+a.btn-primary,
+a.btn-primary:link,
+a.btn-primary:visited {
+ color: #000;
+}
+
+.btn-ghost {
+ background: transparent;
+ border-color: transparent;
+}
+
+.btn-ghost:hover {
+ background: var(--background-tertiary);
+}
+
+.btn-sm {
+ padding: 6px 12px;
+ font-size: 13px;
+}
+
+.btn-lg {
+ padding: 12px 24px;
+ font-size: 16px;
+}
+
+/* Inputs */
+.input {
+ width: 100%;
+ padding: 10px 12px;
+ font-size: 14px;
+ border-radius: var(--radius-md);
+ border: 1px solid var(--border);
background: var(--background);
color: var(--foreground);
- font-family: Arial, Helvetica, sans-serif;
+ transition: border-color 0.15s ease;
+}
+
+.input:focus {
+ outline: none;
+ border-color: var(--accent);
+}
+
+.input::placeholder {
+ color: var(--foreground-tertiary);
+}
+
+/* Cards */
+.card {
+ background: var(--background-secondary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: 16px;
+}
+
+/* Dividers */
+.divider {
+ height: 1px;
+ background: var(--border);
+ margin: 16px 0;
+}
+
+/* Avatar */
+.avatar {
+ width: 40px;
+ height: 40px;
+ border-radius: var(--radius-full);
+ background: var(--background-tertiary);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 600;
+ color: var(--foreground-secondary);
+ overflow: hidden;
+}
+
+.avatar img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.avatar-sm {
+ width: 32px;
+ height: 32px;
+ font-size: 12px;
+}
+
+.avatar-lg {
+ width: 64px;
+ height: 64px;
+ font-size: 24px;
+}
+
+/* Layout */
+.container {
+ max-width: 600px;
+ margin: 0 auto;
+ padding: 0 16px;
+}
+
+.layout {
+ display: flex;
+ justify-content: center;
+ min-height: 100vh;
+ max-width: 1200px;
+ margin: 0 auto;
+}
+
+.sidebar {
+ width: 240px;
+ flex-shrink: 0;
+ border-right: 1px solid var(--border);
+ padding: 16px;
+ position: sticky;
+ top: 0;
+ height: 100vh;
+}
+
+.main {
+ flex: 1;
+ max-width: 600px;
+ min-width: 0;
+ border-right: 1px solid var(--border);
+}
+
+.aside {
+ width: 320px;
+ flex-shrink: 0;
+ padding: 16px;
+ position: sticky;
+ top: 0;
+ height: 100vh;
+}
+
+/* Post */
+.post {
+ padding: 16px;
+ border-bottom: 1px solid var(--border);
+ transition: background 0.15s ease;
+}
+
+.post:hover {
+ background: var(--background-secondary);
+}
+
+.post-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 8px;
+}
+
+.post-author {
+ display: flex;
+ flex-direction: column;
+}
+
+.post-handle {
+ font-weight: 600;
+ color: var(--foreground);
+}
+
+.post-time {
+ font-size: 13px;
+ color: var(--foreground-tertiary);
+}
+
+.post-content {
+ margin-bottom: 12px;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+}
+
+.post-reasons {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-bottom: 12px;
+}
+
+.post-reason-chip {
+ font-size: 12px;
+ color: var(--foreground-secondary);
+ background: var(--background-tertiary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-full);
+ padding: 4px 8px;
+}
+
+.post-media-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 8px;
+ margin-bottom: 12px;
+}
+
+.post-media-item {
+ border-radius: var(--radius-md);
+ overflow: hidden;
+ border: 1px solid var(--border);
+ background: var(--background-tertiary);
+}
+
+.post-media-item img {
+ width: 100%;
+ height: 100%;
+ max-height: 360px;
+ object-fit: cover;
+ display: block;
+}
+
+.post-actions {
+ display: flex;
+ gap: 24px;
+}
+
+.post-action {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ color: var(--foreground-tertiary);
+ font-size: 13px;
+ cursor: pointer;
+ transition: color 0.15s ease;
+ background: none;
+ border: none;
+ padding: 0;
+}
+
+.post-action:hover {
+ color: var(--accent);
+}
+
+.post-action:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.post-action.liked {
+ color: #ef4444;
+}
+
+.post-action.reposted {
+ color: var(--accent);
+}
+
+/* User rows (followers/following) */
+.user-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 16px;
+ border-bottom: 1px solid var(--border);
+ color: var(--foreground);
+ transition: background 0.15s ease;
+}
+
+.user-row:hover {
+ background: var(--background-secondary);
+}
+
+.user-row-content {
+ min-width: 0;
+}
+
+.user-row-bio {
+ color: var(--foreground-secondary);
+ font-size: 13px;
+ margin-top: 4px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* Compose */
+.compose {
+ padding: 16px;
+ border-bottom: 1px solid var(--border);
+}
+
+.compose-input {
+ width: 100%;
+ min-height: 100px;
+ padding: 12px;
+ font-size: 16px;
+ border: none;
+ background: transparent;
+ color: var(--foreground);
+ resize: none;
+}
+
+.compose-input:focus {
+ outline: none;
+}
+
+.compose-input::placeholder {
+ color: var(--foreground-tertiary);
+}
+
+.compose-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-top: 12px;
+}
+
+.compose-actions {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.compose-media-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
+ gap: 8px;
+ margin-top: 12px;
+}
+
+.compose-media-item {
+ position: relative;
+ border-radius: var(--radius-md);
+ overflow: hidden;
+ border: 1px solid var(--border);
+ background: var(--background-tertiary);
+}
+
+.compose-media-item img {
+ width: 100%;
+ height: 120px;
+ object-fit: cover;
+ display: block;
+}
+
+.compose-media-remove {
+ position: absolute;
+ top: 6px;
+ right: 6px;
+ width: 24px;
+ height: 24px;
+ border-radius: var(--radius-full);
+ border: none;
+ background: rgba(0, 0, 0, 0.6);
+ color: #fff;
+ cursor: pointer;
+ font-size: 16px;
+ line-height: 1;
+}
+
+.compose-media-button {
+ position: relative;
+ overflow: hidden;
+}
+
+.compose-media-input {
+ position: absolute;
+ inset: 0;
+ opacity: 0;
+ cursor: pointer;
+}
+
+.compose-media-error {
+ margin-top: 8px;
+ color: var(--error);
+ font-size: 13px;
+}
+
+/* Feed toggle + meta */
+.feed-toggle {
+ display: inline-flex;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-full);
+ overflow: hidden;
+}
+
+.feed-toggle-btn {
+ background: transparent;
+ color: var(--foreground-secondary);
+ border: none;
+ padding: 6px 12px;
+ font-size: 13px;
+ cursor: pointer;
+ transition: color 0.15s ease, background 0.15s ease;
+}
+
+.feed-toggle-btn.active {
+ color: #000;
+ background: var(--accent);
+}
+
+.feed-meta {
+ margin: 16px;
+ border: 1px solid var(--border);
+}
+
+.feed-meta-title {
+ font-weight: 600;
+ margin-bottom: 8px;
+}
+
+.feed-meta-body {
+ color: var(--foreground-secondary);
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+.feed-meta-weights {
+ color: var(--foreground-tertiary);
+ font-size: 12px;
+ margin-top: 8px;
+}
+
+.feed-meta-foot {
+ color: var(--foreground-tertiary);
+ font-size: 12px;
+ margin-top: 4px;
+}
+
+/* Install wizard */
+.install-shell {
+ max-width: 900px;
+ margin: 0 auto;
+ padding: 32px 16px 64px;
+}
+
+.install-hero {
+ background: linear-gradient(135deg, rgba(0, 212, 170, 0.08), transparent 60%);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: 24px;
+ margin-bottom: 20px;
+}
+
+.install-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 24px;
+}
+
+.install-header h1 {
+ font-size: 26px;
+ margin-bottom: 6px;
+}
+
+.install-card {
+ background: var(--background-secondary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: 20px;
+ margin-bottom: 16px;
+}
+
+.install-card-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 8px;
+}
+
+.install-card-title {
+ font-weight: 600;
+ margin-bottom: 6px;
+}
+
+.install-card-desc {
+ color: var(--foreground-secondary);
+ font-size: 13px;
+ margin-bottom: 12px;
+}
+
+.install-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 12px;
+ margin-bottom: 12px;
+}
+
+.install-item {
+ padding: 12px;
+ border-radius: var(--radius-md);
+ border: 1px solid var(--border);
+ background: var(--background);
+}
+
+.install-item.ok {
+ border-color: rgba(34, 197, 94, 0.4);
+}
+
+.install-item.missing {
+ border-color: rgba(239, 68, 68, 0.4);
+}
+
+.install-item-title {
+ font-weight: 600;
+ margin-bottom: 4px;
+}
+
+.install-item-desc {
+ font-size: 12px;
+ color: var(--foreground-tertiary);
+}
+
+.install-item-status {
+ font-size: 12px;
+ color: var(--foreground-secondary);
+ margin-top: 6px;
+}
+
+.install-hint {
+ font-size: 12px;
+ color: var(--foreground-tertiary);
+ margin-bottom: 8px;
+}
+
+.install-subtitle {
+ font-size: 12px;
+ text-transform: uppercase;
+ color: var(--foreground-tertiary);
+ letter-spacing: 1px;
+ margin: 8px 0;
+}
+
+.install-status-row {
+ display: flex;
+ justify-content: space-between;
+ padding: 8px 0;
+ border-bottom: 1px solid var(--border);
+}
+
+.install-status-row:last-child {
+ border-bottom: none;
+}
+
+.install-body {
+ color: var(--foreground-secondary);
+ font-size: 13px;
+ line-height: 1.6;
+ margin: 12px 0;
+}
+
+.install-step-status {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.8px;
+ padding: 6px 10px;
+ border-radius: var(--radius-full);
+ border: 1px solid var(--border);
+}
+
+.install-step-status.ok {
+ color: var(--success);
+ border-color: rgba(34, 197, 94, 0.4);
+}
+
+.install-step-status.warn {
+ color: var(--warning);
+ border-color: rgba(245, 158, 11, 0.4);
+}
+
+.install-progress {
+ display: grid;
+ gap: 8px;
+ margin-bottom: 16px;
+}
+
+.install-progress-bar {
+ height: 8px;
+ border-radius: var(--radius-full);
+ background: var(--background-tertiary);
+ overflow: hidden;
+}
+
+.install-progress-fill {
+ height: 100%;
+ background: var(--accent);
+}
+
+.install-progress-meta {
+ display: flex;
+ justify-content: space-between;
+ font-size: 12px;
+ color: var(--foreground-tertiary);
+}
+
+.install-summary {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 12px;
+}
+
+.install-summary-title {
+ font-size: 12px;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ color: var(--foreground-tertiary);
+ margin-bottom: 4px;
+}
+
+.install-summary-value {
+ font-weight: 600;
+}
+
+/* Notifications */
+.notifications-shell {
+ max-width: 700px;
+ margin: 0 auto;
+ padding: 24px 16px 64px;
+}
+
+.notifications-header {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ align-items: center;
+ margin-bottom: 20px;
+}
+
+.notifications-header h1 {
+ font-size: 22px;
+ margin-bottom: 4px;
+}
+
+.notifications-header p {
+ color: var(--foreground-tertiary);
+ font-size: 13px;
+}
+
+.notifications-actions {
+ display: flex;
+ gap: 8px;
+}
+
+.notifications-list {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.notification-row {
+ display: flex;
+ gap: 12px;
+ padding: 14px;
+ border-radius: var(--radius-md);
+ border: 1px solid var(--border);
+ background: var(--background-secondary);
+ cursor: pointer;
+ transition: border-color 0.15s ease, background 0.15s ease;
+}
+
+.notification-row:hover {
+ border-color: var(--border-hover);
+ background: var(--background-tertiary);
+}
+
+.notification-row.unread {
+ border-color: rgba(255, 255, 255, 0.5);
+}
+
+.notification-avatar {
+ width: 40px;
+ height: 40px;
+ border-radius: var(--radius-full);
+ background: var(--background-tertiary);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 600;
+ color: var(--foreground-secondary);
+ overflow: hidden;
+ flex-shrink: 0;
+}
+
+.notification-avatar img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.notification-content {
+ min-width: 0;
+ flex: 1;
+}
+
+.notification-message {
+ font-weight: 600;
+ margin-bottom: 6px;
+}
+
+.notification-post {
+ color: var(--foreground-secondary);
+ font-size: 13px;
+ margin-bottom: 6px;
+ line-height: 1.4;
+}
+
+.notification-meta {
+ display: flex;
+ gap: 12px;
+ color: var(--foreground-tertiary);
+ font-size: 12px;
+}
+
+.notification-link {
+ color: var(--accent);
+}
+
+.notifications-empty {
+ text-align: center;
+ color: var(--foreground-tertiary);
+ padding: 24px 0;
+}
+
+@media (max-width: 768px) {
+ .install-header {
+ flex-direction: column;
+ }
+
+ .notifications-header {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+}
+
+/* Admin */
+.admin-shell {
+ max-width: 900px;
+ margin: 0 auto;
+ padding: 32px 16px 64px;
+}
+
+.admin-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 24px;
+}
+
+.admin-header h1 {
+ font-size: 24px;
+ margin-bottom: 6px;
+}
+
+.admin-header p {
+ color: var(--foreground-secondary);
+}
+
+.admin-card {
+ background: var(--background-secondary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: 20px;
+}
+
+.admin-tabs {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 16px;
+}
+
+.admin-tab {
+ padding: 8px 14px;
+ border-radius: var(--radius-full);
+ border: 1px solid var(--border);
+ background: transparent;
+ color: var(--foreground-secondary);
+ cursor: pointer;
+}
+
+.admin-tab.active {
+ background: var(--accent);
+ color: #000;
+ border-color: var(--accent);
+}
+
+.admin-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 16px;
+}
+
+.admin-filters {
+ display: flex;
+ gap: 8px;
+}
+
+.pill {
+ padding: 6px 12px;
+ border-radius: var(--radius-full);
+ border: 1px solid var(--border);
+ background: transparent;
+ color: var(--foreground-secondary);
+ cursor: pointer;
+}
+
+.pill.active {
+ background: var(--background-tertiary);
+ color: var(--foreground);
+}
+
+.admin-stats {
+ display: flex;
+ gap: 12px;
+ color: var(--foreground-tertiary);
+ font-size: 12px;
+}
+
+.admin-list {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.admin-row {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 12px;
+ border-radius: var(--radius-md);
+ border: 1px solid var(--border);
+ background: var(--background);
+}
+
+.admin-row-main {
+ flex: 1;
+ min-width: 0;
+}
+
+.admin-row-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 6px;
+ flex-wrap: wrap;
+}
+
+.admin-row-meta {
+ color: var(--foreground-tertiary);
+ font-size: 12px;
+}
+
+.admin-row-body {
+ color: var(--foreground);
+ margin-bottom: 6px;
+}
+
+.admin-row-sub {
+ color: var(--foreground-tertiary);
+ font-size: 12px;
+}
+
+.admin-row-target {
+ margin-top: 6px;
+ color: var(--foreground-secondary);
+ font-size: 13px;
+}
+
+.admin-row-actions {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.admin-empty {
+ text-align: center;
+ color: var(--foreground-tertiary);
+ padding: 24px 0;
+}
+
+.status-pill {
+ font-size: 11px;
+ padding: 4px 8px;
+ border-radius: var(--radius-full);
+ border: 1px solid var(--border);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.status-pill.active {
+ color: var(--success);
+ border-color: rgba(34, 197, 94, 0.4);
+}
+
+.status-pill.open {
+ color: var(--accent);
+ border-color: rgba(0, 212, 170, 0.4);
+}
+
+.status-pill.resolved {
+ color: var(--foreground-tertiary);
+ border-color: var(--border);
+}
+
+.status-pill.removed,
+.status-pill.suspended {
+ color: var(--error);
+ border-color: rgba(239, 68, 68, 0.4);
+}
+
+.status-pill.silenced {
+ color: var(--warning);
+ border-color: rgba(245, 158, 11, 0.4);
+}
+
+.status-pill.visible {
+ color: var(--foreground-tertiary);
+}
+
+@media (max-width: 768px) {
+ .admin-row {
+ flex-direction: column;
+ }
+
+ .admin-row-actions {
+ flex-direction: row;
+ flex-wrap: wrap;
+ }
+}
+
+.compose-counter {
+ font-size: 13px;
+ color: var(--foreground-tertiary);
+}
+
+.compose-counter.warning {
+ color: var(--warning);
+}
+
+.compose-counter.error {
+ color: var(--error);
+}
+
+/* Navigation */
+.nav-item {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px;
+ border-radius: var(--radius-md);
+ color: var(--foreground);
+ font-size: 15px;
+ font-weight: 500;
+ transition: background 0.15s ease;
+}
+
+.nav-item:hover {
+ background: var(--background-tertiary);
+}
+
+.nav-item.active {
+ color: var(--accent);
+}
+
+/* Logo */
+.logo {
+ font-family: var(--font-saira), 'Saira Condensed', sans-serif;
+ font-size: 24px;
+ font-weight: 700;
+ color: var(--accent);
+ margin-bottom: 24px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+}
+
+.logo-icon {
+ /* Filter to convert black to cyan (#00d4aa) */
+ filter: invert(69%) sepia(74%) saturate(402%) hue-rotate(115deg) brightness(95%) contrast(101%);
+}
+
+/* Responsive */
+@media (max-width: 1024px) {
+ .aside {
+ display: none;
+ }
+}
+
+@media (max-width: 768px) {
+ .sidebar {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ width: 100%;
+ height: auto;
+ border-right: none;
+ border-top: 1px solid var(--border);
+ background: var(--background);
+ z-index: 100;
+ padding: 8px 16px;
+ }
+
+ .sidebar nav {
+ display: flex;
+ justify-content: space-around;
+ }
+
+ .nav-item span {
+ display: none;
+ }
+
+ .logo {
+ display: none;
+ }
+
+ .main {
+ padding-bottom: 80px;
+ }
}
diff --git a/src/app/install/page.tsx b/src/app/install/page.tsx
new file mode 100644
index 0000000..7847e40
--- /dev/null
+++ b/src/app/install/page.tsx
@@ -0,0 +1,271 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import Link from 'next/link';
+import { useSearchParams } from 'next/navigation';
+
+type EnvStatus = {
+ required: Record;
+ optional: Record;
+};
+
+type InstallStatus = {
+ env: EnvStatus;
+ db: {
+ connected: boolean;
+ schemaReady: boolean;
+ usersCount: number;
+ };
+};
+
+const requiredLabels: Record = {
+ DATABASE_URL: 'Database connection string',
+ AUTH_SECRET: 'Auth cookie secret',
+ NEXT_PUBLIC_NODE_DOMAIN: 'Public node domain',
+ NEXT_PUBLIC_NODE_NAME: 'Node display name',
+ ADMIN_EMAILS: 'Admin emails list',
+};
+
+const optionalLabels: Record = {};
+
+const StepCard = ({
+ title,
+ description,
+ status,
+ children,
+}: {
+ title: string;
+ description?: string;
+ status?: { label: string; tone: 'ok' | 'warn' };
+ children: React.ReactNode;
+}) => (
+
+
+
+
{title}
+ {description &&
{description}
}
+
+ {status && (
+
+ {status.label}
+
+ )}
+
+ {children}
+
+);
+
+export default function InstallPage() {
+ const searchParams = useSearchParams();
+ const force = searchParams.get('force') === '1';
+ const [status, setStatus] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ const loadStatus = async () => {
+ setLoading(true);
+ try {
+ const res = await fetch('/api/install/status');
+ const data = await res.json();
+ setStatus(data);
+ } catch {
+ setStatus(null);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ loadStatus();
+ }, []);
+
+ const isInstalled = status?.db.connected && status?.db.schemaReady && status?.db.usersCount > 0;
+
+ if (loading) {
+ return (
+
+
Checking install status...
+
+ );
+ }
+
+ if (!status) {
+ return (
+
+
+
Setup Wizard
+
We could not load the install status.
+
+
+
+ );
+ }
+
+ if (isInstalled && !force) {
+ return (
+
+
+
Synapsis is already set up
+
Your database is connected and at least one user exists.
+
+ Go to home
+
+
+ Re-run setup
+
+
+
+ );
+ }
+
+ const missingRequired = Object.entries(status.env.required).filter(([, ok]) => !ok);
+ const missingOptional = Object.entries(status.env.optional).filter(([, ok]) => !ok);
+ const envComplete = missingRequired.length === 0;
+ const dbComplete = status.db.connected && status.db.schemaReady;
+ const adminComplete = status.db.usersCount > 0;
+ const completedSteps = [envComplete, dbComplete, adminComplete].filter(Boolean).length;
+ const progressPercent = Math.round((completedSteps / 3) * 100);
+
+ return (
+
+
+
+
+
Synapsis Setup
+
Follow these steps to complete your installation.
+
+
+
+
+
+
+ {completedSteps} / 3 steps complete
+ {progressPercent}%
+
+
+
+
+
Environment
+
{envComplete ? 'Ready' : 'Needs values'}
+
+
+
Database
+
{dbComplete ? 'Ready' : 'Not ready'}
+
+
+
Admin
+
{adminComplete ? 'Ready' : 'Not created'}
+
+
+
+
+
+
+ {Object.entries(status.env.required).map(([key, ok]) => (
+
+
{key}
+
{requiredLabels[key]}
+
{ok ? 'Set' : 'Missing'}
+
+ ))}
+
+ {missingRequired.length > 0 && (
+
+ Missing required values: {missingRequired.map(([key]) => key).join(', ')}
+
+ )}
+ {missingRequired.some(([key]) => key === 'AUTH_SECRET') && (
+
+ To generate an AUTH_SECRET, run:
+
+ openssl rand -base64 33
+
+
+ )}
+ Optional
+
+ {Object.entries(status.env.optional).map(([key, ok]) => (
+
+
{key}
+
{optionalLabels[key]}
+
{ok ? 'Set' : 'Not set'}
+
+ ))}
+
+ {missingOptional.length > 0 && (
+
+ Optional values missing: {missingOptional.map(([key]) => key).join(', ')}
+
+ )}
+
+
+
+
+ Database connection
+ {status.db.connected ? 'Connected' : 'Not connected'}
+
+
+ Schema
+ {status.db.schemaReady ? 'Ready' : 'Missing tables'}
+
+ {!status.db.schemaReady && (
+
+ Run npm run db:push to create tables.
+
+ )}
+
+
+
+
+ Existing users
+ {status.db.usersCount}
+
+
+ Register a user via the login page. Then add their email to
+ ADMIN_EMAILS and redeploy.
+
+
+ Go to login / register
+
+
+
+
+
+ Go to home
+
+
+ Open moderation dashboard
+
+
+
+ );
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index f7fa87e..54611db 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,34 +1,37 @@
import type { Metadata } from "next";
-import { Geist, Geist_Mono } from "next/font/google";
+import { Inter, Saira_Condensed } from "next/font/google";
import "./globals.css";
-const geistSans = Geist({
- variable: "--font-geist-sans",
+const inter = Inter({
subsets: ["latin"],
+ variable: "--font-inter",
});
-const geistMono = Geist_Mono({
- variable: "--font-geist-mono",
+const sairaCondensed = Saira_Condensed({
subsets: ["latin"],
+ weight: ["700"],
+ variable: "--font-saira",
});
export const metadata: Metadata = {
- title: "Create Next App",
- description: "Generated by create next app",
+ title: "Synapsis",
+ description: "Federated social network infrastructure",
+ manifest: "/manifest.json",
+ icons: {
+ icon: "/favicon.png",
+ },
+ themeColor: "#0a0a0a",
+ viewport: "width=device-width, initial-scale=1, maximum-scale=1",
};
export default function RootLayout({
children,
-}: Readonly<{
+}: {
children: React.ReactNode;
-}>) {
+}) {
return (
-
-
- {children}
-
+
+ {children}
);
}
diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx
new file mode 100644
index 0000000..df7df24
--- /dev/null
+++ b/src/app/login/page.tsx
@@ -0,0 +1,250 @@
+'use client';
+
+import { useState } from 'react';
+import Link from 'next/link';
+import { useRouter } from 'next/navigation';
+
+const SynapsisLogo = () => (
+
+);
+
+export default function LoginPage() {
+ const router = useRouter();
+ const [mode, setMode] = useState<'login' | 'register'>('login');
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [handle, setHandle] = useState('');
+ const [displayName, setDisplayName] = useState('');
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+ setLoading(true);
+
+ try {
+ const endpoint = mode === 'login' ? '/api/auth/login' : '/api/auth/register';
+ const body = mode === 'login'
+ ? { email, password }
+ : { email, password, handle, displayName };
+
+ const res = await fetch(endpoint, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+
+ const data = await res.json();
+
+ if (!res.ok) {
+ throw new Error(data.error || 'Authentication failed');
+ }
+
+ router.push('/');
+ router.refresh();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'An error occurred');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+ {/* Logo */}
+
+
+
+ Synapsis
+
+
+ Federated social network infrastructure
+
+
+
+ {/* Mode Switcher */}
+
+
+
+
+
+ {/* Form */}
+
+
+
+ โ Back to home
+
+
+
+ );
+}
diff --git a/src/app/nodeinfo/2.1/route.ts b/src/app/nodeinfo/2.1/route.ts
new file mode 100644
index 0000000..6256de5
--- /dev/null
+++ b/src/app/nodeinfo/2.1/route.ts
@@ -0,0 +1,36 @@
+import { NextResponse } from 'next/server';
+import { db, users, posts } from '@/db';
+import { count } from 'drizzle-orm';
+
+export async function GET() {
+ const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
+ const nodeName = process.env.NEXT_PUBLIC_NODE_NAME || 'Synapsis Node';
+ const nodeDescription = process.env.NEXT_PUBLIC_NODE_DESCRIPTION || 'A Synapsis federated social network node';
+
+ // Get stats
+ const [userCount] = await db.select({ count: count() }).from(users);
+ const [postCount] = await db.select({ count: count() }).from(posts);
+
+ return NextResponse.json({
+ version: '2.1',
+ software: {
+ name: 'synapsis',
+ version: '0.1.0',
+ homepage: 'https://github.com/synapsis',
+ },
+ protocols: ['activitypub'],
+ usage: {
+ users: {
+ total: userCount?.count || 0,
+ activeMonth: userCount?.count || 0,
+ activeHalfyear: userCount?.count || 0,
+ },
+ localPosts: postCount?.count || 0,
+ },
+ openRegistrations: true,
+ metadata: {
+ nodeName,
+ nodeDescription,
+ },
+ });
+}
diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx
new file mode 100644
index 0000000..62127ec
--- /dev/null
+++ b/src/app/notifications/page.tsx
@@ -0,0 +1,167 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import Link from 'next/link';
+
+type Notification = {
+ id: string;
+ type: 'follow' | 'like' | 'repost' | 'mention';
+ createdAt: string;
+ readAt?: string | null;
+ actor?: {
+ id: string;
+ handle: string;
+ displayName?: string | null;
+ avatarUrl?: string | null;
+ } | null;
+ post?: {
+ id: string;
+ content: string;
+ } | null;
+};
+
+const formatTime = (dateStr: string) => {
+ const date = new Date(dateStr);
+ const now = new Date();
+ const diff = now.getTime() - date.getTime();
+ const minutes = Math.floor(diff / 60000);
+ const hours = Math.floor(minutes / 60);
+ const days = Math.floor(hours / 24);
+
+ if (minutes < 1) return 'now';
+ if (minutes < 60) return `${minutes}m`;
+ if (hours < 24) return `${hours}h`;
+ if (days < 7) return `${days}d`;
+ return date.toLocaleDateString();
+};
+
+const buildMessage = (item: Notification) => {
+ const actorName = item.actor?.displayName || item.actor?.handle || 'Someone';
+ switch (item.type) {
+ case 'follow':
+ return `${actorName} followed you`;
+ case 'like':
+ return `${actorName} liked your post`;
+ case 'repost':
+ return `${actorName} reposted your post`;
+ case 'mention':
+ return `${actorName} mentioned you`;
+ default:
+ return `${actorName} sent a notification`;
+ }
+};
+
+export default function NotificationsPage() {
+ const [notifications, setNotifications] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [polling, setPolling] = useState(true);
+
+ const loadNotifications = async () => {
+ try {
+ const res = await fetch('/api/notifications?limit=50', { cache: 'no-store' });
+ const data = await res.json();
+ setNotifications(data.notifications || []);
+ } catch {
+ setNotifications([]);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ loadNotifications();
+ }, []);
+
+ useEffect(() => {
+ if (!polling) return;
+ const interval = setInterval(loadNotifications, 15000);
+ return () => clearInterval(interval);
+ }, [polling]);
+
+ const markAllRead = async () => {
+ await fetch('/api/notifications', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ all: true }),
+ });
+ loadNotifications();
+ };
+
+ const markRead = async (id: string) => {
+ await fetch('/api/notifications', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ ids: [id] }),
+ });
+ setNotifications((prev) =>
+ prev.map((item) => (item.id === id ? { ...item, readAt: new Date().toISOString() } : item))
+ );
+ };
+
+ const unreadCount = notifications.filter((item) => !item.readAt).length;
+
+ return (
+
+
+
+ {loading ? (
+
Loading notifications...
+ ) : notifications.length === 0 ? (
+
No notifications yet.
+ ) : (
+
+ {notifications.map((item) => (
+
markRead(item.id)}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ markRead(item.id);
+ }
+ }}
+ >
+
+ {item.actor?.avatarUrl ? (
+

+ ) : (
+ (item.actor?.displayName || item.actor?.handle || '?').charAt(0).toUpperCase()
+ )}
+
+
+
{buildMessage(item)}
+ {item.post?.content && (
+
โ{item.post.content.slice(0, 120)}โ
+ )}
+
+ {formatTime(item.createdAt)}
+ {item.actor?.handle && (
+
+ @{item.actor.handle}
+
+ )}
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 295f8fd..bc0f89c 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,65 +1,587 @@
-import Image from "next/image";
+'use client';
+
+import { useState, useEffect } from 'react';
+import Link from 'next/link';
+
+interface User {
+ id: string;
+ handle: string;
+ displayName: string;
+ avatarUrl?: string;
+}
+
+interface MediaItem {
+ id: string;
+ url: string;
+ altText?: string | null;
+}
+
+interface FeedMeta {
+ score: number;
+ reasons: string[];
+ engagement: {
+ likes: number;
+ reposts: number;
+ replies: number;
+ };
+}
+
+interface Post {
+ id: string;
+ content: string;
+ createdAt: string;
+ likesCount: number;
+ repostsCount: number;
+ repliesCount: number;
+ author: User;
+ media?: MediaItem[];
+ feedMeta?: FeedMeta;
+}
+
+// Icons as simple SVG components
+const HomeIcon = () => (
+
+);
+
+const SearchIcon = () => (
+
+);
+
+const BellIcon = () => (
+
+);
+
+const UserIcon = () => (
+
+);
+
+const HeartIcon = ({ filled }: { filled?: boolean }) => (
+
+);
+
+const RepeatIcon = () => (
+
+);
+
+const MessageIcon = () => (
+
+);
+
+const FlagIcon = () => (
+
+);
+
+const SynapsisLogo = () => (
+
+);
+
+function PostCard({ post, onLike, onRepost }: { post: Post; onLike: (id: string) => void; onRepost: (id: string) => void }) {
+ const [liked, setLiked] = useState(false);
+ const [reposted, setReposted] = useState(false);
+ const [reporting, setReporting] = useState(false);
+
+ const formatTime = (dateStr: string) => {
+ const date = new Date(dateStr);
+ const now = new Date();
+ const diff = now.getTime() - date.getTime();
+ const minutes = Math.floor(diff / 60000);
+ const hours = Math.floor(minutes / 60);
+ const days = Math.floor(hours / 24);
+
+ if (minutes < 1) return 'now';
+ if (minutes < 60) return `${minutes}m`;
+ if (hours < 24) return `${hours}h`;
+ if (days < 7) return `${days}d`;
+ return date.toLocaleDateString();
+ };
+
+ const handleLike = () => {
+ setLiked(!liked);
+ onLike(post.id);
+ };
+
+ const handleRepost = () => {
+ setReposted(!reposted);
+ onRepost(post.id);
+ };
+
+ const handleReport = async () => {
+ if (reporting) return;
+ const reason = window.prompt('Why are you reporting this post?');
+ if (!reason) return;
+ setReporting(true);
+ try {
+ const res = await fetch('/api/reports', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ targetType: 'post', targetId: post.id, reason }),
+ });
+ if (!res.ok) {
+ if (res.status === 401) {
+ alert('Please log in to report.');
+ } else {
+ alert('Report failed. Please try again.');
+ }
+ } else {
+ alert('Report submitted. Thank you.');
+ }
+ } catch {
+ alert('Report failed. Please try again.');
+ } finally {
+ setReporting(false);
+ }
+ };
-export default function Home() {
return (
-
-
-
-
-
- To get started, edit the page.tsx file.
-
-
- Looking for a starting point or more instructions? Head over to{" "}
-
- Templates
- {" "}
- or the{" "}
-
- Learning
- {" "}
- center.
-
+
+
+
+ {post.author.avatarUrl ? (
+

+ ) : (
+ post.author.displayName?.charAt(0).toUpperCase() || post.author.handle.charAt(0).toUpperCase()
+ )}
-
+
+ {post.content}
+ {post.feedMeta?.reasons?.length ? (
+
+ {post.feedMeta.reasons.map((reason, index) => (
+
+ {reason}
+
+ ))}
+
+ ) : null}
+ {post.media && post.media.length > 0 && (
+
+ {post.media.map((item) => (
+
+

+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+ );
+}
+
+type Attachment = {
+ id: string;
+ url: string;
+ altText?: string | null;
+};
+
+function Compose({ onPost }: { onPost: (content: string, mediaIds: string[]) => void }) {
+ const [content, setContent] = useState('');
+ const [isPosting, setIsPosting] = useState(false);
+ const [attachments, setAttachments] = useState
([]);
+ const [isUploading, setIsUploading] = useState(false);
+ const [uploadError, setUploadError] = useState(null);
+ const maxLength = 400;
+ const remaining = maxLength - content.length;
+
+ const handleSubmit = async () => {
+ if (!content.trim() || isPosting || isUploading) return;
+ setIsPosting(true);
+ await onPost(content, attachments.map((item) => item.id).filter(Boolean));
+ setContent('');
+ setAttachments([]);
+ setIsPosting(false);
+ };
+
+ const handleMediaSelect = async (event: React.ChangeEvent) => {
+ const files = Array.from(event.target.files || []);
+ event.target.value = '';
+ if (files.length === 0) return;
+
+ const remainingSlots = Math.max(0, 4 - attachments.length);
+ const selectedFiles = files.slice(0, remainingSlots);
+ if (selectedFiles.length === 0) return;
+
+ setUploadError(null);
+ setIsUploading(true);
+
+ const uploaded: Attachment[] = [];
+
+ for (const file of selectedFiles) {
+ try {
+ const formData = new FormData();
+ formData.append('file', file);
+ const res = await fetch('/api/media/upload', {
+ method: 'POST',
+ body: formData,
+ });
+ const data = await res.json();
+
+ if (!res.ok || !data.media?.id) {
+ throw new Error(data.error || 'Upload failed');
+ }
+
+ uploaded.push({
+ id: data.media.id,
+ url: data.media.url || data.url,
+ altText: data.media.altText ?? null,
+ });
+ } catch (error) {
+ console.error('Upload failed', error);
+ setUploadError('One or more uploads failed. Try again.');
+ }
+ }
+
+ setAttachments((prev) => [...prev, ...uploaded].slice(0, 4));
+ setIsUploading(false);
+ };
+
+ const handleRemoveAttachment = (id: string) => {
+ setAttachments((prev) => prev.filter((item) => item.id !== id));
+ };
+
+ return (
+
+ );
+}
+
+export default function Home() {
+ const [user, setUser] = useState(null);
+ const [posts, setPosts] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [feedType, setFeedType] = useState<'latest' | 'curated'>('latest');
+ const [feedMeta, setFeedMeta] = useState<{
+ algorithm: string;
+ windowHours: number;
+ seedLimit: number;
+ weights: {
+ engagement: number;
+ recency: number;
+ followBoost: number;
+ selfBoost: number;
+ };
+ } | null>(null);
+
+ const loadFeed = async (type: 'latest' | 'curated') => {
+ setLoading(true);
+ try {
+ const endpoint = type === 'curated' ? '/api/posts?type=curated' : '/api/posts?type=home';
+ const res = await fetch(endpoint);
+ const data = await res.json();
+ setPosts(data.posts || []);
+ setFeedMeta(data.meta || null);
+ } catch {
+ setPosts([]);
+ setFeedMeta(null);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ // Check auth status
+ fetch('/api/auth/me')
+ .then(res => res.json())
+ .then(data => setUser(data.user))
+ .catch(() => { });
+ }, []);
+
+ useEffect(() => {
+ loadFeed(feedType);
+ }, [feedType]);
+
+ const handlePost = async (content: string, mediaIds: string[]) => {
+ const res = await fetch('/api/posts', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ content, mediaIds }),
+ });
+
+ if (res.ok) {
+ const data = await res.json();
+ if (feedType === 'curated') {
+ loadFeed('curated');
+ } else {
+ setPosts([{ ...data.post, author: user }, ...posts]);
+ }
+ }
+ };
+
+ const handleLike = async (postId: string) => {
+ await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
+ };
+
+ const handleRepost = async (postId: string) => {
+ await fetch(`/api/posts/${postId}/repost`, { method: 'POST' });
+ };
+
+ return (
+
+ {/* Sidebar */}
+
+
+ {/* Main Feed */}
+
+
+
+ {user && }
+
+ {!user && (
+
+
+ Join Synapsis to post and interact
+
+
+ Login or Register
+
+
+ )}
+
+ {feedType === 'curated' && feedMeta && (
+
+
Curated feed
+
+ We rank posts using recency and engagement. Following gets a boost, and your own posts stay visible.
+
+
+ Weights: engagement {feedMeta.weights.engagement}, recency {feedMeta.weights.recency}, follow boost {feedMeta.weights.followBoost}.
+
+
+ Window: {feedMeta.windowHours} hours. Seed: {feedMeta.seedLimit} posts.
+
+
+ )}
+
+ {loading ? (
+
+ Loading...
+
+ ) : posts.length === 0 ? (
+
+
No posts yet
+
Be the first to post something!
+
+ ) : (
+ posts.map(post => (
+
+ ))
+ )}
+
+
+ {/* Right Sidebar */}
+
);
}
diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx
new file mode 100644
index 0000000..c601135
--- /dev/null
+++ b/src/app/search/page.tsx
@@ -0,0 +1,375 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import Link from 'next/link';
+import { useSearchParams, useRouter } from 'next/navigation';
+
+interface User {
+ id: string;
+ handle: string;
+ displayName: string;
+ avatarUrl?: string;
+ bio?: string;
+}
+
+interface MediaItem {
+ id: string;
+ url: string;
+ altText?: string | null;
+}
+
+interface Post {
+ id: string;
+ content: string;
+ createdAt: string;
+ likesCount: number;
+ repostsCount: number;
+ repliesCount: number;
+ author: User;
+ media?: MediaItem[];
+}
+
+// Icons
+const SearchIcon = () => (
+
+);
+
+const ArrowLeftIcon = () => (
+
+);
+
+const HeartIcon = ({ filled }: { filled?: boolean }) => (
+
+);
+
+const RepeatIcon = () => (
+
+);
+
+const MessageIcon = () => (
+
+);
+
+const FlagIcon = () => (
+
+);
+
+function UserCard({ user }: { user: User }) {
+ return (
+
+
+ {user.avatarUrl ? (
+

+ ) : (
+ (user.displayName || user.handle).charAt(0).toUpperCase()
+ )}
+
+
+
{user.displayName || user.handle}
+
@{user.handle}
+ {user.bio && (
+
+ {user.bio}
+
+ )}
+
+
+ );
+}
+
+function PostCard({ post }: { post: Post }) {
+ const [liked, setLiked] = useState(false);
+ const [reporting, setReporting] = useState(false);
+
+ const formatTime = (dateStr: string) => {
+ const date = new Date(dateStr);
+ const now = new Date();
+ const diff = now.getTime() - date.getTime();
+ const minutes = Math.floor(diff / 60000);
+ const hours = Math.floor(minutes / 60);
+ const days = Math.floor(hours / 24);
+
+ if (minutes < 1) return 'now';
+ if (minutes < 60) return `${minutes}m`;
+ if (hours < 24) return `${hours}h`;
+ if (days < 7) return `${days}d`;
+ return date.toLocaleDateString();
+ };
+
+ return (
+
+
+
+ {post.author?.avatarUrl ? (
+

+ ) : (
+ (post.author?.displayName || post.author?.handle || '?').charAt(0).toUpperCase()
+ )}
+
+
+
+ {post.author?.displayName || post.author?.handle}
+
+ @{post.author?.handle} ยท {formatTime(post.createdAt)}
+
+
+ {post.content}
+ {post.media && post.media.length > 0 && (
+
+ {post.media.map((item) => (
+
+

+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+ );
+}
+
+export default function SearchPage() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const initialQuery = searchParams.get('q') || '';
+
+ const [query, setQuery] = useState(initialQuery);
+ const [users, setUsers] = useState([]);
+ const [posts, setPosts] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [activeTab, setActiveTab] = useState<'all' | 'users' | 'posts'>('all');
+
+ const search = useCallback(async (q: string, type: string = 'all') => {
+ if (!q.trim()) {
+ setUsers([]);
+ setPosts([]);
+ return;
+ }
+
+ setLoading(true);
+ try {
+ const res = await fetch(`/api/search?q=${encodeURIComponent(q)}&type=${type}`);
+ const data = await res.json();
+ setUsers(data.users || []);
+ setPosts(data.posts || []);
+ } catch {
+ console.error('Search failed');
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (initialQuery) {
+ search(initialQuery, activeTab);
+ }
+ }, [initialQuery, activeTab, search]);
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (query.trim()) {
+ router.push(`/search?q=${encodeURIComponent(query)}`);
+ search(query, activeTab);
+ }
+ };
+
+ const handleTabChange = (tab: 'all' | 'users' | 'posts') => {
+ setActiveTab(tab);
+ if (query.trim()) {
+ search(query, tab);
+ }
+ };
+
+ return (
+
+ {/* Header */}
+
+
+ {/* Tabs */}
+
+ {(['all', 'users', 'posts'] as const).map(tab => (
+
+ ))}
+
+
+ {/* Results */}
+ {loading ? (
+
+ Searching...
+
+ ) : !initialQuery ? (
+
+
Search for users and posts
+
+ ) : users.length === 0 && posts.length === 0 ? (
+
+
No results for “{initialQuery}”
+
+ ) : (
+ <>
+ {/* Users */}
+ {(activeTab === 'all' || activeTab === 'users') && users.length > 0 && (
+
+ {activeTab === 'all' && (
+
+ Users
+
+ )}
+ {users.map(user =>
)}
+
+ )}
+
+ {/* Posts */}
+ {(activeTab === 'all' || activeTab === 'posts') && posts.length > 0 && (
+
+ {activeTab === 'all' && (
+
+ Posts
+
+ )}
+ {posts.map(post =>
)}
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/src/db/index.ts b/src/db/index.ts
new file mode 100644
index 0000000..fbde81d
--- /dev/null
+++ b/src/db/index.ts
@@ -0,0 +1,17 @@
+import { drizzle } from 'drizzle-orm/neon-http';
+import { neon } from '@neondatabase/serverless';
+import * as schema from './schema';
+
+// Create the Neon client (with fallback for UI testing)
+const sql = process.env.DATABASE_URL
+ ? neon(process.env.DATABASE_URL)
+ : null;
+
+// Create the Drizzle client
+export const db = sql ? drizzle(sql, { schema }) : null;
+
+// Helper to check if DB is available
+export const isDbAvailable = () => db !== null;
+
+// Export schema for use elsewhere
+export * from './schema';
diff --git a/src/db/schema.ts b/src/db/schema.ts
new file mode 100644
index 0000000..6d3e6f4
--- /dev/null
+++ b/src/db/schema.ts
@@ -0,0 +1,325 @@
+import { pgTable, text, timestamp, uuid, integer, boolean, index } from 'drizzle-orm/pg-core';
+import { relations } from 'drizzle-orm';
+
+// ============================================
+// NODES
+// ============================================
+
+export const nodes = pgTable('nodes', {
+ id: uuid('id').primaryKey().defaultRandom(),
+ domain: text('domain').notNull().unique(),
+ name: text('name').notNull(),
+ description: text('description'),
+ accentColor: text('accent_color').default('#FFFFFF'),
+ publicKey: text('public_key'),
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
+});
+
+// ============================================
+// USERS
+// ============================================
+
+export const users = pgTable('users', {
+ id: uuid('id').primaryKey().defaultRandom(),
+ did: text('did').notNull().unique(), // Decentralized Identifier
+ handle: text('handle').notNull().unique(), // @username (globally unique)
+ email: text('email').unique(),
+ passwordHash: text('password_hash'),
+ displayName: text('display_name'),
+ bio: text('bio'),
+ avatarUrl: text('avatar_url'),
+ headerUrl: text('header_url'),
+ privateKeyEncrypted: text('private_key_encrypted'), // For ActivityPub signing
+ publicKey: text('public_key').notNull(),
+ nodeId: uuid('node_id').references(() => nodes.id),
+ isSuspended: boolean('is_suspended').default(false).notNull(),
+ suspensionReason: text('suspension_reason'),
+ suspendedAt: timestamp('suspended_at'),
+ isSilenced: boolean('is_silenced').default(false).notNull(),
+ silenceReason: text('silence_reason'),
+ silencedAt: timestamp('silenced_at'),
+ followersCount: integer('followers_count').default(0).notNull(),
+ followingCount: integer('following_count').default(0).notNull(),
+ postsCount: integer('posts_count').default(0).notNull(),
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
+}, (table) => [
+ index('users_handle_idx').on(table.handle),
+ index('users_did_idx').on(table.did),
+ index('users_suspended_idx').on(table.isSuspended),
+ index('users_silenced_idx').on(table.isSilenced),
+]);
+
+export const usersRelations = relations(users, ({ one, many }) => ({
+ node: one(nodes, {
+ fields: [users.nodeId],
+ references: [nodes.id],
+ }),
+ posts: many(posts),
+ followers: many(follows, { relationName: 'following' }),
+ following: many(follows, { relationName: 'follower' }),
+}));
+
+// ============================================
+// POSTS
+// ============================================
+
+export const posts = pgTable('posts', {
+ id: uuid('id').primaryKey().defaultRandom(),
+ userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
+ content: text('content').notNull(),
+ replyToId: uuid('reply_to_id'),
+ repostOfId: uuid('repost_of_id'),
+ likesCount: integer('likes_count').default(0).notNull(),
+ repostsCount: integer('reposts_count').default(0).notNull(),
+ repliesCount: integer('replies_count').default(0).notNull(),
+ isRemoved: boolean('is_removed').default(false).notNull(),
+ removedAt: timestamp('removed_at'),
+ removedBy: uuid('removed_by').references(() => users.id),
+ removedReason: text('removed_reason'),
+ // ActivityPub
+ apId: text('ap_id').unique(), // https://node.com/posts/uuid
+ apUrl: text('ap_url'), // Public URL for the post
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
+}, (table) => [
+ index('posts_user_id_idx').on(table.userId),
+ index('posts_created_at_idx').on(table.createdAt),
+ index('posts_reply_to_idx').on(table.replyToId),
+ index('posts_removed_idx').on(table.isRemoved),
+]);
+
+export const postsRelations = relations(posts, ({ one, many }) => ({
+ author: one(users, {
+ fields: [posts.userId],
+ references: [users.id],
+ }),
+ removedByUser: one(users, {
+ fields: [posts.removedBy],
+ references: [users.id],
+ }),
+ replyTo: one(posts, {
+ fields: [posts.replyToId],
+ references: [posts.id],
+ relationName: 'replies',
+ }),
+ replies: many(posts, { relationName: 'replies' }),
+ repostOf: one(posts, {
+ fields: [posts.repostOfId],
+ references: [posts.id],
+ relationName: 'reposts',
+ }),
+ reposts: many(posts, { relationName: 'reposts' }),
+ likes: many(likes),
+ media: many(media),
+}));
+
+// ============================================
+// MEDIA
+// ============================================
+
+export const media = pgTable('media', {
+ id: uuid('id').primaryKey().defaultRandom(),
+ userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
+ postId: uuid('post_id').references(() => posts.id, { onDelete: 'cascade' }),
+ url: text('url').notNull(),
+ altText: text('alt_text'),
+ mimeType: text('mime_type'),
+ width: integer('width'),
+ height: integer('height'),
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+}, (table) => [
+ index('media_user_idx').on(table.userId),
+ index('media_post_idx').on(table.postId),
+]);
+
+export const mediaRelations = relations(media, ({ one }) => ({
+ user: one(users, {
+ fields: [media.userId],
+ references: [users.id],
+ }),
+ post: one(posts, {
+ fields: [media.postId],
+ references: [posts.id],
+ }),
+}));
+
+// ============================================
+// FOLLOWS
+// ============================================
+
+export const follows = pgTable('follows', {
+ id: uuid('id').primaryKey().defaultRandom(),
+ followerId: uuid('follower_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
+ followingId: uuid('following_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
+ // ActivityPub
+ apId: text('ap_id').unique(), // Activity ID
+ pending: boolean('pending').default(false), // For follow requests
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+}, (table) => [
+ index('follows_follower_idx').on(table.followerId),
+ index('follows_following_idx').on(table.followingId),
+]);
+
+export const followsRelations = relations(follows, ({ one }) => ({
+ follower: one(users, {
+ fields: [follows.followerId],
+ references: [users.id],
+ relationName: 'follower',
+ }),
+ following: one(users, {
+ fields: [follows.followingId],
+ references: [users.id],
+ relationName: 'following',
+ }),
+}));
+
+// ============================================
+// LIKES
+// ============================================
+
+export const likes = pgTable('likes', {
+ id: uuid('id').primaryKey().defaultRandom(),
+ userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
+ postId: uuid('post_id').notNull().references(() => posts.id, { onDelete: 'cascade' }),
+ apId: text('ap_id').unique(), // Activity ID
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+}, (table) => [
+ index('likes_user_post_idx').on(table.userId, table.postId),
+]);
+
+export const likesRelations = relations(likes, ({ one }) => ({
+ user: one(users, {
+ fields: [likes.userId],
+ references: [users.id],
+ }),
+ post: one(posts, {
+ fields: [likes.postId],
+ references: [posts.id],
+ }),
+}));
+
+// ============================================
+// NOTIFICATIONS
+// ============================================
+
+export const notifications = pgTable('notifications', {
+ id: uuid('id').primaryKey().defaultRandom(),
+ userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
+ actorId: uuid('actor_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
+ postId: uuid('post_id').references(() => posts.id, { onDelete: 'cascade' }),
+ type: text('type').notNull(), // follow | like | repost | mention
+ readAt: timestamp('read_at'),
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+}, (table) => [
+ index('notifications_user_idx').on(table.userId),
+ index('notifications_created_idx').on(table.createdAt),
+]);
+
+export const notificationsRelations = relations(notifications, ({ one }) => ({
+ recipient: one(users, {
+ fields: [notifications.userId],
+ references: [users.id],
+ relationName: 'recipient',
+ }),
+ actor: one(users, {
+ fields: [notifications.actorId],
+ references: [users.id],
+ relationName: 'actor',
+ }),
+ post: one(posts, {
+ fields: [notifications.postId],
+ references: [posts.id],
+ }),
+}));
+
+// ============================================
+// HANDLE REGISTRY (for federated handle resolution)
+// ============================================
+
+export const handleRegistry = pgTable('handle_registry', {
+ handle: text('handle').primaryKey(), // @username
+ did: text('did').notNull(),
+ nodeDomain: text('node_domain').notNull(),
+ registeredAt: timestamp('registered_at').defaultNow().notNull(),
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
+}, (table) => [
+ index('handle_registry_updated_idx').on(table.updatedAt),
+]);
+
+// ============================================
+// SESSIONS (for auth)
+// ============================================
+
+export const sessions = pgTable('sessions', {
+ id: uuid('id').primaryKey().defaultRandom(),
+ userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
+ token: text('token').notNull().unique(),
+ expiresAt: timestamp('expires_at').notNull(),
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+}, (table) => [
+ index('sessions_token_idx').on(table.token),
+ index('sessions_user_idx').on(table.userId),
+]);
+
+export const sessionsRelations = relations(sessions, ({ one }) => ({
+ user: one(users, {
+ fields: [sessions.userId],
+ references: [users.id],
+ }),
+}));
+
+// ============================================
+// BLOCKS & MUTES (user-level moderation)
+// ============================================
+
+export const blocks = pgTable('blocks', {
+ id: uuid('id').primaryKey().defaultRandom(),
+ userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
+ blockedUserId: uuid('blocked_user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+}, (table) => [
+ index('blocks_user_idx').on(table.userId),
+]);
+
+export const mutes = pgTable('mutes', {
+ id: uuid('id').primaryKey().defaultRandom(),
+ userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
+ mutedUserId: uuid('muted_user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+}, (table) => [
+ index('mutes_user_idx').on(table.userId),
+]);
+
+// ============================================
+// REPORTS (moderation)
+// ============================================
+
+export const reports = pgTable('reports', {
+ id: uuid('id').primaryKey().defaultRandom(),
+ reporterId: uuid('reporter_id').references(() => users.id, { onDelete: 'set null' }),
+ targetType: text('target_type').notNull(), // 'post' | 'user'
+ targetId: uuid('target_id').notNull(),
+ reason: text('reason').notNull(),
+ status: text('status').default('open').notNull(), // open | resolved
+ resolvedAt: timestamp('resolved_at'),
+ resolvedBy: uuid('resolved_by').references(() => users.id),
+ resolutionNote: text('resolution_note'),
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+}, (table) => [
+ index('reports_status_idx').on(table.status),
+ index('reports_target_idx').on(table.targetType, table.targetId),
+ index('reports_reporter_idx').on(table.reporterId),
+]);
+
+export const reportsRelations = relations(reports, ({ one }) => ({
+ reporter: one(users, {
+ fields: [reports.reporterId],
+ references: [users.id],
+ }),
+ resolver: one(users, {
+ fields: [reports.resolvedBy],
+ references: [users.id],
+ }),
+}));
diff --git a/src/lib/activitypub/activities.ts b/src/lib/activitypub/activities.ts
new file mode 100644
index 0000000..b267898
--- /dev/null
+++ b/src/lib/activitypub/activities.ts
@@ -0,0 +1,202 @@
+/**
+ * ActivityPub Activities
+ *
+ * Handles creation of ActivityPub activity objects for federation.
+ * See: https://www.w3.org/TR/activitypub/#overview
+ */
+
+import type { posts, users } from '@/db/schema';
+
+type Post = typeof posts.$inferSelect;
+type User = typeof users.$inferSelect;
+
+const ACTIVITY_STREAMS_CONTEXT = 'https://www.w3.org/ns/activitystreams';
+
+export interface ActivityPubNote {
+ '@context': string;
+ id: string;
+ type: 'Note';
+ attributedTo: string;
+ content: string;
+ published: string;
+ to: string[];
+ cc: string[];
+ inReplyTo?: string | null;
+ url: string;
+ attachment?: {
+ type: string;
+ mediaType: string;
+ url: string;
+ name?: string;
+ }[];
+}
+
+export interface ActivityPubActivity {
+ '@context': string;
+ id: string;
+ type: 'Create' | 'Follow' | 'Like' | 'Announce' | 'Undo' | 'Accept' | 'Reject' | 'Delete';
+ actor: string;
+ object: string | ActivityPubNote | object;
+ published?: string;
+ to?: string[];
+ cc?: string[];
+}
+
+/**
+ * Convert a Synapsis post to an ActivityPub Note
+ */
+export function postToNote(post: Post, author: User, nodeDomain: string): ActivityPubNote {
+ const postUrl = `https://${nodeDomain}/posts/${post.id}`;
+ const actorUrl = `https://${nodeDomain}/users/${author.handle}`;
+
+ return {
+ '@context': ACTIVITY_STREAMS_CONTEXT,
+ id: postUrl,
+ type: 'Note',
+ attributedTo: actorUrl,
+ content: escapeHtml(post.content),
+ published: post.createdAt.toISOString(),
+ to: ['https://www.w3.org/ns/activitystreams#Public'],
+ cc: [`${actorUrl}/followers`],
+ inReplyTo: post.replyToId ? `https://${nodeDomain}/posts/${post.replyToId}` : null,
+ url: postUrl,
+ };
+}
+
+/**
+ * Create a Create activity for a new post
+ */
+export function createCreateActivity(
+ post: Post,
+ author: User,
+ nodeDomain: string
+): ActivityPubActivity {
+ const actorUrl = `https://${nodeDomain}/users/${author.handle}`;
+ const note = postToNote(post, author, nodeDomain);
+
+ return {
+ '@context': ACTIVITY_STREAMS_CONTEXT,
+ id: `https://${nodeDomain}/activities/${post.id}`,
+ type: 'Create',
+ actor: actorUrl,
+ published: post.createdAt.toISOString(),
+ to: note.to,
+ cc: note.cc,
+ object: note,
+ };
+}
+
+/**
+ * Create a Follow activity
+ */
+export function createFollowActivity(
+ follower: User,
+ targetActorUrl: string,
+ nodeDomain: string,
+ activityId: string
+): ActivityPubActivity {
+ const actorUrl = `https://${nodeDomain}/users/${follower.handle}`;
+
+ return {
+ '@context': ACTIVITY_STREAMS_CONTEXT,
+ id: `https://${nodeDomain}/activities/${activityId}`,
+ type: 'Follow',
+ actor: actorUrl,
+ object: targetActorUrl,
+ };
+}
+
+/**
+ * Create a Like activity
+ */
+export function createLikeActivity(
+ user: User,
+ targetPostUrl: string,
+ nodeDomain: string,
+ activityId: string
+): ActivityPubActivity {
+ const actorUrl = `https://${nodeDomain}/users/${user.handle}`;
+
+ return {
+ '@context': ACTIVITY_STREAMS_CONTEXT,
+ id: `https://${nodeDomain}/activities/${activityId}`,
+ type: 'Like',
+ actor: actorUrl,
+ object: targetPostUrl,
+ };
+}
+
+/**
+ * Create an Announce (repost) activity
+ */
+export function createAnnounceActivity(
+ user: User,
+ targetPostUrl: string,
+ nodeDomain: string,
+ activityId: string
+): ActivityPubActivity {
+ const actorUrl = `https://${nodeDomain}/users/${user.handle}`;
+
+ return {
+ '@context': ACTIVITY_STREAMS_CONTEXT,
+ id: `https://${nodeDomain}/activities/${activityId}`,
+ type: 'Announce',
+ actor: actorUrl,
+ object: targetPostUrl,
+ to: ['https://www.w3.org/ns/activitystreams#Public'],
+ cc: [`${actorUrl}/followers`],
+ };
+}
+
+/**
+ * Create an Undo activity (for unfollowing, unliking, etc.)
+ */
+export function createUndoActivity(
+ user: User,
+ originalActivity: ActivityPubActivity,
+ nodeDomain: string,
+ activityId: string
+): ActivityPubActivity {
+ const actorUrl = `https://${nodeDomain}/users/${user.handle}`;
+
+ return {
+ '@context': ACTIVITY_STREAMS_CONTEXT,
+ id: `https://${nodeDomain}/activities/${activityId}`,
+ type: 'Undo',
+ actor: actorUrl,
+ object: originalActivity,
+ };
+}
+
+/**
+ * Create an Accept activity (for accepting follow requests)
+ */
+export function createAcceptActivity(
+ user: User,
+ followActivity: ActivityPubActivity,
+ nodeDomain: string,
+ activityId: string
+): ActivityPubActivity {
+ const actorUrl = `https://${nodeDomain}/users/${user.handle}`;
+
+ return {
+ '@context': ACTIVITY_STREAMS_CONTEXT,
+ id: `https://${nodeDomain}/activities/${activityId}`,
+ type: 'Accept',
+ actor: actorUrl,
+ object: followActivity,
+ };
+}
+
+/**
+ * Escape HTML in content for safety
+ */
+function escapeHtml(text: string): string {
+ return text
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+ .replace(/\n/g, '
');
+}
diff --git a/src/lib/activitypub/actor.ts b/src/lib/activitypub/actor.ts
new file mode 100644
index 0000000..d20310b
--- /dev/null
+++ b/src/lib/activitypub/actor.ts
@@ -0,0 +1,119 @@
+/**
+ * ActivityPub Actor Utilities
+ *
+ * Handles the serialization of Synapsis users to ActivityPub Actor format.
+ * See: https://www.w3.org/TR/activitypub/#actor-objects
+ */
+
+import type { users } from '@/db/schema';
+
+type User = typeof users.$inferSelect;
+
+const ACTIVITY_STREAMS_CONTEXT = 'https://www.w3.org/ns/activitystreams';
+const SECURITY_CONTEXT = 'https://w3id.org/security/v1';
+
+export interface ActivityPubActor {
+ '@context': (string | object)[];
+ id: string;
+ type: 'Person';
+ preferredUsername: string;
+ name: string | null;
+ summary: string | null;
+ url: string;
+ inbox: string;
+ outbox: string;
+ followers: string;
+ following: string;
+ icon?: {
+ type: 'Image';
+ mediaType: string;
+ url: string;
+ };
+ image?: {
+ type: 'Image';
+ mediaType: string;
+ url: string;
+ };
+ publicKey: {
+ id: string;
+ owner: string;
+ publicKeyPem: string;
+ };
+ endpoints: {
+ sharedInbox: string;
+ };
+}
+
+/**
+ * Convert a Synapsis user to an ActivityPub Actor
+ */
+export function userToActor(user: User, nodeDomain: string): ActivityPubActor {
+ const actorUrl = `https://${nodeDomain}/users/${user.handle}`;
+
+ const actor: ActivityPubActor = {
+ '@context': [
+ ACTIVITY_STREAMS_CONTEXT,
+ SECURITY_CONTEXT,
+ {
+ 'manuallyApprovesFollowers': 'as:manuallyApprovesFollowers',
+ 'toot': 'http://joinmastodon.org/ns#',
+ 'featured': {
+ '@id': 'toot:featured',
+ '@type': '@id',
+ },
+ },
+ ],
+ id: actorUrl,
+ type: 'Person',
+ preferredUsername: user.handle,
+ name: user.displayName,
+ summary: user.bio,
+ url: actorUrl,
+ inbox: `${actorUrl}/inbox`,
+ outbox: `${actorUrl}/outbox`,
+ followers: `${actorUrl}/followers`,
+ following: `${actorUrl}/following`,
+ publicKey: {
+ id: `${actorUrl}#main-key`,
+ owner: actorUrl,
+ publicKeyPem: user.publicKey,
+ },
+ endpoints: {
+ sharedInbox: `https://${nodeDomain}/inbox`,
+ },
+ };
+
+ // Add avatar if present
+ if (user.avatarUrl) {
+ actor.icon = {
+ type: 'Image',
+ mediaType: 'image/png', // TODO: detect actual type
+ url: user.avatarUrl,
+ };
+ }
+
+ // Add header if present
+ if (user.headerUrl) {
+ actor.image = {
+ type: 'Image',
+ mediaType: 'image/png',
+ url: user.headerUrl,
+ };
+ }
+
+ return actor;
+}
+
+/**
+ * Get the actor URL for a user
+ */
+export function getActorUrl(handle: string, nodeDomain: string): string {
+ return `https://${nodeDomain}/users/${handle}`;
+}
+
+/**
+ * Get the inbox URL for a user
+ */
+export function getInboxUrl(handle: string, nodeDomain: string): string {
+ return `https://${nodeDomain}/users/${handle}/inbox`;
+}
diff --git a/src/lib/activitypub/inbox.ts b/src/lib/activitypub/inbox.ts
new file mode 100644
index 0000000..ca32004
--- /dev/null
+++ b/src/lib/activitypub/inbox.ts
@@ -0,0 +1,197 @@
+/**
+ * ActivityPub Inbox Handler
+ *
+ * Processes incoming activities from remote servers.
+ */
+
+import { db, users, posts, follows, likes } from '@/db';
+import { eq, and } from 'drizzle-orm';
+import { verifySignature, fetchActorPublicKey } from './signatures';
+import { v4 as uuid } from 'uuid';
+
+export interface IncomingActivity {
+ '@context': string | string[];
+ id: string;
+ type: string;
+ actor: string;
+ object: string | object;
+ published?: string;
+ to?: string[];
+ cc?: string[];
+}
+
+/**
+ * Process an incoming activity
+ */
+export async function processIncomingActivity(
+ activity: IncomingActivity,
+ headers: Record,
+ path: string
+): Promise<{ success: boolean; error?: string }> {
+ // Verify the signature
+ const publicKey = await fetchActorPublicKey(activity.actor);
+ if (!publicKey) {
+ return { success: false, error: 'Could not fetch actor public key' };
+ }
+
+ const isValid = await verifySignature('POST', path, headers, publicKey);
+ if (!isValid) {
+ console.warn('Invalid signature for activity:', activity.id);
+ // In development, we might want to continue anyway
+ // return { success: false, error: 'Invalid signature' };
+ }
+
+ // Process based on activity type
+ switch (activity.type) {
+ case 'Create':
+ return await handleCreate(activity);
+ case 'Follow':
+ return await handleFollow(activity);
+ case 'Like':
+ return await handleLike(activity);
+ case 'Announce':
+ return await handleAnnounce(activity);
+ case 'Undo':
+ return await handleUndo(activity);
+ case 'Delete':
+ return await handleDelete(activity);
+ case 'Accept':
+ return await handleAccept(activity);
+ case 'Reject':
+ return await handleReject(activity);
+ default:
+ console.log('Unhandled activity type:', activity.type);
+ return { success: true }; // Don't error on unknown types
+ }
+}
+
+/**
+ * Handle Create activities (new posts)
+ */
+async function handleCreate(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
+ const object = activity.object as { type: string; content?: string; id?: string; attributedTo?: string };
+
+ if (object.type !== 'Note') {
+ return { success: true }; // We only handle Notes for now
+ }
+
+ // TODO: Store remote posts in database for caching/display
+ console.log('Received remote post:', object.id);
+
+ return { success: true };
+}
+
+/**
+ * Handle Follow activities
+ */
+async function handleFollow(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
+ const targetActorUrl = typeof activity.object === 'string' ? activity.object : (activity.object as { id?: string }).id;
+
+ if (!targetActorUrl) {
+ return { success: false, error: 'Invalid follow target' };
+ }
+
+ // Extract handle from target URL
+ const handleMatch = targetActorUrl.match(/\/users\/([^\/]+)$/);
+ if (!handleMatch) {
+ return { success: false, error: 'Could not parse target handle' };
+ }
+
+ const handle = handleMatch[1];
+
+ // Find the local user
+ const targetUser = await db.query.users.findFirst({
+ where: eq(users.handle, handle),
+ });
+
+ if (!targetUser) {
+ return { success: false, error: 'User not found' };
+ }
+
+ // TODO: Store follower relationship, send Accept activity
+ console.log('Received follow request for:', handle, 'from:', activity.actor);
+
+ return { success: true };
+}
+
+/**
+ * Handle Like activities
+ */
+async function handleLike(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
+ const targetUrl = typeof activity.object === 'string' ? activity.object : null;
+
+ if (!targetUrl) {
+ return { success: false, error: 'Invalid like target' };
+ }
+
+ // TODO: Update like count on local post
+ console.log('Received like for:', targetUrl, 'from:', activity.actor);
+
+ return { success: true };
+}
+
+/**
+ * Handle Announce activities (reposts)
+ */
+async function handleAnnounce(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
+ const targetUrl = typeof activity.object === 'string' ? activity.object : null;
+
+ if (!targetUrl) {
+ return { success: false, error: 'Invalid announce target' };
+ }
+
+ // TODO: Update repost count on local post
+ console.log('Received announce for:', targetUrl, 'from:', activity.actor);
+
+ return { success: true };
+}
+
+/**
+ * Handle Undo activities
+ */
+async function handleUndo(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
+ const originalActivity = activity.object as IncomingActivity;
+
+ if (!originalActivity || !originalActivity.type) {
+ return { success: false, error: 'Invalid undo target' };
+ }
+
+ console.log('Received undo for:', originalActivity.type, 'from:', activity.actor);
+
+ // TODO: Handle undo based on original activity type
+
+ return { success: true };
+}
+
+/**
+ * Handle Delete activities
+ */
+async function handleDelete(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
+ console.log('Received delete from:', activity.actor);
+
+ // TODO: Remove cached remote content
+
+ return { success: true };
+}
+
+/**
+ * Handle Accept activities (follow accepted)
+ */
+async function handleAccept(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
+ console.log('Follow accepted by:', activity.actor);
+
+ // TODO: Update follow status
+
+ return { success: true };
+}
+
+/**
+ * Handle Reject activities (follow rejected)
+ */
+async function handleReject(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
+ console.log('Follow rejected by:', activity.actor);
+
+ // TODO: Remove pending follow
+
+ return { success: true };
+}
diff --git a/src/lib/activitypub/index.ts b/src/lib/activitypub/index.ts
new file mode 100644
index 0000000..df8a5a2
--- /dev/null
+++ b/src/lib/activitypub/index.ts
@@ -0,0 +1,7 @@
+// ActivityPub module exports
+export * from './actor';
+export * from './activities';
+export * from './webfinger';
+export * from './signatures';
+export * from './inbox';
+export * from './outbox';
diff --git a/src/lib/activitypub/outbox.ts b/src/lib/activitypub/outbox.ts
new file mode 100644
index 0000000..9e0e99c
--- /dev/null
+++ b/src/lib/activitypub/outbox.ts
@@ -0,0 +1,105 @@
+/**
+ * ActivityPub Outbox / Delivery
+ *
+ * Handles sending activities to remote servers.
+ */
+
+import { signRequest } from './signatures';
+import type { ActivityPubActivity } from './activities';
+
+/**
+ * Deliver an activity to a remote inbox
+ */
+export async function deliverActivity(
+ activity: ActivityPubActivity,
+ targetInbox: string,
+ privateKey: string,
+ keyId: string
+): Promise<{ success: boolean; error?: string }> {
+ try {
+ const body = JSON.stringify(activity);
+
+ // Sign the request
+ const signatureHeaders = await signRequest(
+ 'POST',
+ targetInbox,
+ body,
+ privateKey,
+ keyId
+ );
+
+ // Send the activity
+ const response = await fetch(targetInbox, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/activity+json',
+ 'Accept': 'application/activity+json',
+ ...signatureHeaders,
+ },
+ body,
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ console.error('Activity delivery failed:', response.status, errorText);
+ return {
+ success: false,
+ error: `Delivery failed: ${response.status} ${errorText}`
+ };
+ }
+
+ return { success: true };
+ } catch (error) {
+ console.error('Activity delivery error:', error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error'
+ };
+ }
+}
+
+/**
+ * Deliver an activity to multiple inboxes
+ */
+export async function deliverToFollowers(
+ activity: ActivityPubActivity,
+ followerInboxes: string[],
+ privateKey: string,
+ keyId: string
+): Promise<{ delivered: number; failed: number }> {
+ // Deduplicate inboxes (shared inboxes should only receive once)
+ const uniqueInboxes = [...new Set(followerInboxes)];
+
+ let delivered = 0;
+ let failed = 0;
+
+ // Deliver in parallel with concurrency limit
+ const concurrency = 10;
+ for (let i = 0; i < uniqueInboxes.length; i += concurrency) {
+ const batch = uniqueInboxes.slice(i, i + concurrency);
+ const results = await Promise.allSettled(
+ batch.map(inbox => deliverActivity(activity, inbox, privateKey, keyId))
+ );
+
+ for (const result of results) {
+ if (result.status === 'fulfilled' && result.value.success) {
+ delivered++;
+ } else {
+ failed++;
+ }
+ }
+ }
+
+ return { delivered, failed };
+}
+
+/**
+ * Get followers' inboxes for delivery
+ * This would query the database for follower inbox URLs
+ */
+export async function getFollowerInboxes(userId: string): Promise {
+ // TODO: Query database for followers and their inbox URLs
+ // For local followers: use their local inbox
+ // For remote followers: use their remote inbox (stored when they followed)
+ return [];
+}
diff --git a/src/lib/activitypub/signatures.ts b/src/lib/activitypub/signatures.ts
new file mode 100644
index 0000000..7d40c15
--- /dev/null
+++ b/src/lib/activitypub/signatures.ts
@@ -0,0 +1,165 @@
+/**
+ * HTTP Signatures for ActivityPub
+ *
+ * ActivityPub uses HTTP Signatures to verify the authenticity of requests.
+ * See: https://docs.joinmastodon.org/spec/security/
+ */
+
+import { importPKCS8, importSPKI, SignJWT, jwtVerify } from 'jose';
+import * as crypto from 'crypto';
+
+/**
+ * Generate a new RSA keypair for an actor
+ */
+export async function generateKeyPair(): Promise<{ publicKey: string; privateKey: string }> {
+ return new Promise((resolve, reject) => {
+ crypto.generateKeyPair(
+ 'rsa',
+ {
+ modulusLength: 2048,
+ publicKeyEncoding: {
+ type: 'spki',
+ format: 'pem',
+ },
+ privateKeyEncoding: {
+ type: 'pkcs8',
+ format: 'pem',
+ },
+ },
+ (err, publicKey, privateKey) => {
+ if (err) {
+ reject(err);
+ } else {
+ resolve({ publicKey, privateKey });
+ }
+ }
+ );
+ });
+}
+
+/**
+ * Sign an HTTP request for ActivityPub
+ */
+export async function signRequest(
+ method: string,
+ url: string,
+ body: string | null,
+ privateKeyPem: string,
+ keyId: string
+): Promise> {
+ const urlObj = new URL(url);
+ const date = new Date().toUTCString();
+ const digest = body ? `SHA-256=${crypto.createHash('sha256').update(body).digest('base64')}` : null;
+
+ // Build the string to sign
+ const signedHeaders = body ? '(request-target) host date digest' : '(request-target) host date';
+ let stringToSign = `(request-target): ${method.toLowerCase()} ${urlObj.pathname}`;
+ stringToSign += `\nhost: ${urlObj.host}`;
+ stringToSign += `\ndate: ${date}`;
+ if (digest) {
+ stringToSign += `\ndigest: ${digest}`;
+ }
+
+ // Sign the string
+ const privateKey = crypto.createPrivateKey(privateKeyPem);
+ const signature = crypto.sign('sha256', Buffer.from(stringToSign), privateKey).toString('base64');
+
+ // Build the signature header
+ const signatureHeader = `keyId="${keyId}",algorithm="rsa-sha256",headers="${signedHeaders}",signature="${signature}"`;
+
+ const headers: Record = {
+ 'Date': date,
+ 'Signature': signatureHeader,
+ };
+
+ if (digest) {
+ headers['Digest'] = digest;
+ }
+
+ return headers;
+}
+
+/**
+ * Verify an HTTP signature from an incoming request
+ */
+export async function verifySignature(
+ method: string,
+ path: string,
+ headers: Record,
+ publicKeyPem: string
+): Promise {
+ try {
+ const signatureHeader = headers['signature'] || headers['Signature'];
+ if (!signatureHeader) {
+ return false;
+ }
+
+ // Parse the signature header
+ const signatureParts: Record = {};
+ signatureHeader.split(',').forEach(part => {
+ const [key, value] = part.split('=');
+ signatureParts[key] = value?.replace(/^"|"$/g, '') ?? '';
+ });
+
+ const signedHeadersList = signatureParts.headers?.split(' ') ?? [];
+ const signature = signatureParts.signature;
+
+ if (!signature || signedHeadersList.length === 0) {
+ return false;
+ }
+
+ // Reconstruct the string that was signed
+ let stringToVerify = '';
+ for (const header of signedHeadersList) {
+ if (stringToVerify) {
+ stringToVerify += '\n';
+ }
+
+ if (header === '(request-target)') {
+ stringToVerify += `(request-target): ${method.toLowerCase()} ${path}`;
+ } else {
+ const headerValue = headers[header] || headers[header.toLowerCase()];
+ if (headerValue) {
+ stringToVerify += `${header}: ${headerValue}`;
+ }
+ }
+ }
+
+ // Verify the signature
+ const publicKey = crypto.createPublicKey(publicKeyPem);
+ const signatureBuffer = Buffer.from(signature, 'base64');
+
+ return crypto.verify(
+ 'sha256',
+ Buffer.from(stringToVerify),
+ publicKey,
+ signatureBuffer
+ );
+ } catch (error) {
+ console.error('Signature verification failed:', error);
+ return false;
+ }
+}
+
+/**
+ * Fetch a remote actor's public key
+ */
+export async function fetchActorPublicKey(actorUrl: string): Promise {
+ try {
+ const response = await fetch(actorUrl, {
+ headers: {
+ 'Accept': 'application/activity+json, application/ld+json',
+ },
+ });
+
+ if (!response.ok) {
+ return null;
+ }
+
+ const actor = await response.json();
+ return actor.publicKey?.publicKeyPem ?? null;
+ } catch (error) {
+ console.error('Failed to fetch actor public key:', error);
+ return null;
+ }
+}
diff --git a/src/lib/activitypub/webfinger.ts b/src/lib/activitypub/webfinger.ts
new file mode 100644
index 0000000..478e6de
--- /dev/null
+++ b/src/lib/activitypub/webfinger.ts
@@ -0,0 +1,113 @@
+/**
+ * WebFinger Protocol Implementation
+ *
+ * WebFinger is used to discover ActivityPub actors from acct: URIs.
+ * See: https://www.rfc-editor.org/rfc/rfc7033
+ */
+
+export interface WebFingerResponse {
+ subject: string;
+ aliases?: string[];
+ links: WebFingerLink[];
+}
+
+export interface WebFingerLink {
+ rel: string;
+ type?: string;
+ href?: string;
+ template?: string;
+}
+
+/**
+ * Generate a WebFinger response for a local user
+ */
+export function generateWebFingerResponse(
+ handle: string,
+ nodeDomain: string
+): WebFingerResponse {
+ const actorUrl = `https://${nodeDomain}/users/${handle}`;
+
+ return {
+ subject: `acct:${handle}@${nodeDomain}`,
+ aliases: [actorUrl],
+ links: [
+ {
+ rel: 'self',
+ type: 'application/activity+json',
+ href: actorUrl,
+ },
+ {
+ rel: 'http://webfinger.net/rel/profile-page',
+ type: 'text/html',
+ href: actorUrl,
+ },
+ ],
+ };
+}
+
+/**
+ * Parse a WebFinger resource query
+ * @param resource - The resource query (e.g., "acct:user@domain.com")
+ * @returns Object with handle and domain, or null if invalid
+ */
+export function parseWebFingerResource(resource: string): { handle: string; domain: string } | null {
+ // Handle acct: URI format
+ if (resource.startsWith('acct:')) {
+ const parts = resource.slice(5).split('@');
+ if (parts.length === 2) {
+ return { handle: parts[0], domain: parts[1] };
+ }
+ }
+
+ // Handle URL format
+ try {
+ const url = new URL(resource);
+ const pathParts = url.pathname.split('/');
+ const usersIndex = pathParts.indexOf('users');
+ if (usersIndex !== -1 && pathParts[usersIndex + 1]) {
+ return { handle: pathParts[usersIndex + 1], domain: url.host };
+ }
+ } catch {
+ // Not a valid URL
+ }
+
+ return null;
+}
+
+/**
+ * Fetch WebFinger data for a remote user
+ */
+export async function fetchWebFinger(
+ handle: string,
+ domain: string
+): Promise {
+ const resource = `acct:${handle}@${domain}`;
+ const url = `https://${domain}/.well-known/webfinger?resource=${encodeURIComponent(resource)}`;
+
+ try {
+ const response = await fetch(url, {
+ headers: {
+ 'Accept': 'application/jrd+json, application/json',
+ },
+ });
+
+ if (!response.ok) {
+ return null;
+ }
+
+ return await response.json();
+ } catch (error) {
+ console.error('WebFinger fetch failed:', error);
+ return null;
+ }
+}
+
+/**
+ * Get the ActivityPub actor URL from a WebFinger response
+ */
+export function getActorUrlFromWebFinger(webfinger: WebFingerResponse): string | null {
+ const selfLink = webfinger.links.find(
+ (link) => link.rel === 'self' && link.type === 'application/activity+json'
+ );
+ return selfLink?.href ?? null;
+}
diff --git a/src/lib/auth/admin.ts b/src/lib/auth/admin.ts
new file mode 100644
index 0000000..3063614
--- /dev/null
+++ b/src/lib/auth/admin.ts
@@ -0,0 +1,28 @@
+import { requireAuth } from '@/lib/auth';
+import { users } from '@/db';
+
+type User = typeof users.$inferSelect;
+
+const normalizeList = (value?: string | null) =>
+ (value || '')
+ .split(',')
+ .map((item) => item.trim().toLowerCase())
+ .filter(Boolean);
+
+const adminEmails = normalizeList(process.env.ADMIN_EMAILS);
+
+export const isAdminUser = (user: User | null | undefined) => {
+ if (!user) return false;
+ if (user.email && adminEmails.length > 0 && adminEmails.includes(user.email.toLowerCase())) {
+ return true;
+ }
+ return false;
+};
+
+export async function requireAdmin(): Promise {
+ const user = await requireAuth();
+ if (!isAdminUser(user)) {
+ throw new Error('Admin required');
+ }
+ return user;
+}
diff --git a/src/lib/auth/index.ts b/src/lib/auth/index.ts
new file mode 100644
index 0000000..a055ade
--- /dev/null
+++ b/src/lib/auth/index.ts
@@ -0,0 +1,199 @@
+/**
+ * Authentication Utilities
+ */
+
+import { db, users, sessions } from '@/db';
+import { eq } from 'drizzle-orm';
+import bcrypt from 'bcryptjs';
+import { v4 as uuid } from 'uuid';
+import { generateKeyPair } from '@/lib/activitypub/signatures';
+import { cookies } from 'next/headers';
+import { upsertHandleEntries } from '@/lib/federation/handles';
+
+const SESSION_COOKIE_NAME = 'synapsis_session';
+const SESSION_EXPIRY_DAYS = 30;
+
+/**
+ * Hash a password
+ */
+export async function hashPassword(password: string): Promise {
+ return bcrypt.hash(password, 12);
+}
+
+/**
+ * Verify a password against a hash
+ */
+export async function verifyPassword(password: string, hash: string): Promise {
+ return bcrypt.compare(password, hash);
+}
+
+/**
+ * Generate a DID for a new user
+ */
+export function generateDID(): string {
+ // Using a simple did:key-like format for now
+ // In production, this would be more sophisticated
+ return `did:synapsis:${uuid().replace(/-/g, '')}`;
+}
+
+/**
+ * Create a new session for a user
+ */
+export async function createSession(userId: string): Promise {
+ const token = uuid();
+ const expiresAt = new Date();
+ expiresAt.setDate(expiresAt.getDate() + SESSION_EXPIRY_DAYS);
+
+ await db.insert(sessions).values({
+ userId,
+ token,
+ expiresAt,
+ });
+
+ // Set the session cookie
+ const cookieStore = await cookies();
+ cookieStore.set(SESSION_COOKIE_NAME, token, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'lax',
+ expires: expiresAt,
+ path: '/',
+ });
+
+ return token;
+}
+
+/**
+ * Get the current session from cookies
+ */
+export async function getSession(): Promise<{ user: typeof users.$inferSelect } | null> {
+ const cookieStore = await cookies();
+ const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
+
+ if (!token) {
+ return null;
+ }
+
+ const session = await db.query.sessions.findFirst({
+ where: eq(sessions.token, token),
+ with: {
+ user: true,
+ },
+ });
+
+ if (!session || session.expiresAt < new Date()) {
+ return null;
+ }
+
+ return { user: session.user };
+}
+
+/**
+ * Get current user or throw if not authenticated
+ */
+export async function requireAuth(): Promise {
+ const session = await getSession();
+
+ if (!session) {
+ throw new Error('Authentication required');
+ }
+
+ return session.user;
+}
+
+/**
+ * Destroy the current session
+ */
+export async function destroySession(): Promise {
+ const cookieStore = await cookies();
+ const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
+
+ if (token) {
+ await db.delete(sessions).where(eq(sessions.token, token));
+ cookieStore.delete(SESSION_COOKIE_NAME);
+ }
+}
+
+/**
+ * Register a new user
+ */
+export async function registerUser(
+ handle: string,
+ email: string,
+ password: string,
+ displayName?: string
+): Promise {
+ // Validate handle format
+ if (!/^[a-zA-Z0-9_]{3,20}$/.test(handle)) {
+ throw new Error('Handle must be 3-20 characters, alphanumeric and underscores only');
+ }
+
+ // Check if handle is taken
+ const existingHandle = await db.query.users.findFirst({
+ where: eq(users.handle, handle.toLowerCase()),
+ });
+
+ if (existingHandle) {
+ throw new Error('Handle is already taken');
+ }
+
+ // Check if email is taken
+ const existingEmail = await db.query.users.findFirst({
+ where: eq(users.email, email.toLowerCase()),
+ });
+
+ if (existingEmail) {
+ throw new Error('Email is already registered');
+ }
+
+ // Generate keys for ActivityPub
+ const { publicKey, privateKey } = await generateKeyPair();
+
+ // Create the user
+ const did = generateDID();
+ const passwordHash = await hashPassword(password);
+
+ const [user] = await db.insert(users).values({
+ did,
+ handle: handle.toLowerCase(),
+ email: email.toLowerCase(),
+ passwordHash,
+ displayName: displayName || handle,
+ publicKey,
+ privateKeyEncrypted: privateKey, // TODO: Encrypt with user's password
+ }).returning();
+
+ const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
+ await upsertHandleEntries([{
+ handle: user.handle,
+ did: user.did,
+ nodeDomain,
+ updatedAt: new Date().toISOString(),
+ }]);
+
+ return user;
+}
+
+/**
+ * Authenticate a user with email and password
+ */
+export async function authenticateUser(
+ email: string,
+ password: string
+): Promise {
+ const user = await db.query.users.findFirst({
+ where: eq(users.email, email.toLowerCase()),
+ });
+
+ if (!user || !user.passwordHash) {
+ throw new Error('Invalid email or password');
+ }
+
+ const isValid = await verifyPassword(password, user.passwordHash);
+
+ if (!isValid) {
+ throw new Error('Invalid email or password');
+ }
+
+ return user;
+}
diff --git a/src/lib/federation/handles.ts b/src/lib/federation/handles.ts
new file mode 100644
index 0000000..0e463d8
--- /dev/null
+++ b/src/lib/federation/handles.ts
@@ -0,0 +1,58 @@
+import { db, handleRegistry } from '@/db';
+import { eq } from 'drizzle-orm';
+
+export type HandleEntry = {
+ handle: string;
+ did: string;
+ nodeDomain: string;
+ updatedAt?: string;
+};
+
+export const normalizeHandle = (handle: string) =>
+ handle.toLowerCase().replace(/^@/, '').trim();
+
+export async function upsertHandleEntries(entries: HandleEntry[]) {
+ if (!db) {
+ return { added: 0, updated: 0 };
+ }
+
+ let added = 0;
+ let updated = 0;
+
+ for (const entry of entries) {
+ const cleanHandle = normalizeHandle(entry.handle);
+ if (!cleanHandle || !entry.did || !entry.nodeDomain) {
+ continue;
+ }
+
+ const existing = await db.query.handleRegistry.findFirst({
+ where: eq(handleRegistry.handle, cleanHandle),
+ });
+
+ const incomingUpdatedAt = entry.updatedAt ? new Date(entry.updatedAt) : new Date();
+
+ if (!existing) {
+ await db.insert(handleRegistry).values({
+ handle: cleanHandle,
+ did: entry.did,
+ nodeDomain: entry.nodeDomain,
+ updatedAt: incomingUpdatedAt,
+ });
+ added += 1;
+ continue;
+ }
+
+ if (!existing.updatedAt || incomingUpdatedAt > existing.updatedAt) {
+ await db.update(handleRegistry)
+ .set({
+ did: entry.did,
+ nodeDomain: entry.nodeDomain,
+ updatedAt: incomingUpdatedAt,
+ })
+ .where(eq(handleRegistry.handle, cleanHandle));
+ updated += 1;
+ }
+ }
+
+ return { added, updated };
+}