diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000..e0e90bb
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,334 @@
+# Authenticated Text Sharing Plan
+
+## Overview
+
+Create a dedicated text-sharing feature with:
+
+- A simple `/share` upload page inspired by mclo.gs.
+- Publicly readable pages at `/share/[id]`.
+- Discord login required before web uploads or comments.
+- A private bot endpoint for forwarding Discord `.txt` attachments.
+- PostgreSQL tables `share.text` and `share.comment`.
+- Duplicate-safe synchronization with existing NextAuth accounts.
+
+## System Diagram
+
+### Web upload
+
+```mermaid
+flowchart TD
+ A["Visitor opens /share"] --> B{"Discord session?"}
+ B -- No --> C["LoginDialog"]
+ C --> D["Discord OAuth"]
+ D --> E["Return to /share with session"]
+ E --> F
+ B -- Yes --> F["Upload form
Paste or drop .txt
Description and optional image"]
+ F --> G["Session-protected web handler"]
+ G --> H["Validate text, description, and image"]
+ H --> I["Check 10 shares/hour"]
+ I --> J["Resolve Discord account"]
+ J --> K["Store image and share atomically"]
+ K --> L["Redirect to /share/a1b2c3d4"]
+```
+
+### Bot upload
+
+```mermaid
+flowchart TD
+ A["Discord member sends a .txt file"] --> B["External Discord bot"]
+ B -->|"file + senderDiscordId
Bearer SHARE_BOT_SECRET"| C["POST /api/share"]
+ C --> D["Verify private bot secret"]
+ D --> E["Validate .txt and optional image"]
+ E --> F["Check bot and sender rate limits"]
+ F --> G["Verify sender is a guild member"]
+ G --> H["Fetch global name and avatar from Discord"]
+ H --> I["Ensure one Discord account"]
+ I --> J{"Discord account link exists?"}
+ J -- Yes --> K["Update existing account.user"]
+ J -- No --> L["Create one account.user
and Discord account.account link"]
+ K --> M["Create share.text atomically"]
+ L --> M
+ M --> N["Return /share/id"]
+ N --> O["Bot sends link to Discord"]
+```
+
+### Database relationships
+
+```mermaid
+erDiagram
+ ACCOUNT_USER ||--o{ ACCOUNT_ACCOUNT : "has OAuth links"
+ ACCOUNT_USER o|--o{ SHARE_TEXT : "authors"
+ ACCOUNT_USER o|--o{ SHARE_COMMENT : "writes"
+ SHARE_TEXT ||--o{ SHARE_COMMENT : "contains"
+ PUBLIC_CDN o|--o| SHARE_TEXT : "optional image"
+
+ ACCOUNT_USER {
+ text id PK
+ text discord_id UK
+ text name
+ text image
+ }
+
+ ACCOUNT_ACCOUNT {
+ text userId FK
+ text provider PK
+ text providerAccountId PK
+ }
+
+ SHARE_TEXT {
+ varchar id PK "8 lowercase alphanumeric characters"
+ text content
+ text description "optional"
+ text image_cdn_id FK "optional"
+ text source "web or bot"
+ text author_user_id FK "optional"
+ text author_discord_id
+ text author_name
+ text author_avatar_url "optional"
+ timestamp created_at
+ }
+
+ SHARE_COMMENT {
+ text id PK "UUIDv7"
+ varchar text_id FK
+ text body
+ text author_user_id FK "optional"
+ text author_discord_id
+ text author_name
+ text author_avatar_url "optional"
+ timestamp created_at
+ }
+
+ PUBLIC_CDN {
+ text id PK
+ bytea data
+ text type
+ integer size
+ }
+```
+
+### Public shared page
+
+```mermaid
+flowchart TB
+ subgraph PAGE["/share/id"]
+ direction TB
+ A["Author avatar · Global display name · Bangkok time"]
+ B["Optional description"]
+ C["Optional image"]
+ D["Main shared text
Whitespace-preserving monospace"]
+ E["Copy text
Copies main text only"]
+ F["Copy link
Copies page URL"]
+ G["Comments
Oldest or newest · 50 per page"]
+ H["Public to read
Discord login required to post"]
+
+ A --> B --> C --> D
+ D --> E
+ D --> F
+ E --> G
+ F --> G
+ G --> H
+ end
+```
+
+## Database and Identity
+
+- Add `share` to Drizzle's `schemaFilter`.
+- Define a dedicated `pgSchema("share")`.
+- Create `share.text` with:
+ - Random eight-character lowercase alphanumeric primary key.
+ - Exact main text content.
+ - Optional plain-text description.
+ - Optional CDN image reference.
+ - Source value of `web` or `bot`.
+ - Nullable `account.user` reference.
+ - Discord ID, display-name, and avatar snapshots.
+ - Creation timestamp.
+- Create `share.comment` with:
+ - UUIDv7 primary key.
+ - Parent `share.text` reference with cascade deletion.
+ - Comment body.
+ - Nullable `account.user` reference.
+ - Discord identity snapshots.
+ - Creation timestamp.
+ - An index supporting stable sorting and pagination.
+- Repair the missing Drizzle snapshot for migration `0017` before generating the share migration.
+
+## Duplicate-Safe Discord Accounts
+
+Bot uploads must not create multiple users for the same Discord account.
+
+- Backfill `account.user.discord_id` from existing Discord account links.
+- Add a unique index for non-null Discord IDs.
+- Implement a shared transactional `ensureDiscordUser` operation:
+ 1. Look up `account.account` using provider `discord` and the Discord user ID.
+ 2. If linked, update that existing user's latest profile information.
+ 3. If not linked, upsert `account.user` by unique `discord_id`.
+ 4. Insert the Discord `account.account` link using its existing composite conflict key.
+ 5. Reload and return the database winner after a concurrent conflict.
+- Invoke the same reconciliation during Discord OAuth before NextAuth completes account handling.
+- Bot profile updates must not erase an existing email address.
+- Verified OAuth login may refresh the email, global display name, avatar, and account token fields.
+
+## Authentication
+
+- `/share` checks the NextAuth session on the server before rendering upload controls.
+- Unauthenticated visitors receive a non-dismissible Discord login dialog.
+- Do not render paste, description, image, or upload controls before login.
+- The web upload handler repeats authentication and returns HTTP 401 without a valid session.
+- `/share/[id]` remains publicly readable.
+- Reading comments remains public, but posting comments requires Discord login.
+
+## Web Upload
+
+The authenticated `/share` page should remain visually simple:
+
+- One large `Paste your text here` textarea.
+- Paste, Browse, and Drop support for `.txt` files.
+- A byte counter against the 5 MiB limit.
+- A smaller optional description field.
+- One optional image picker with preview and remove actions.
+- One clear Thai upload button: `อัปโหลดและสร้างลิงก์`.
+- Navigate directly to `/share/[id]` after success.
+
+Validation:
+
+- Main text is required and must contain non-whitespace content.
+- Main text may be at most 5 MiB of UTF-8 data.
+- Description may be at most 5,000 characters.
+- Image is optional and may be PNG, JPEG, WebP, or GIF.
+- Image contents must match their declared type.
+- Image size may be at most 50 MiB.
+
+## Private Bot API
+
+`POST /api/share` is private and reserved for the trusted Discord bot.
+
+Authentication:
+
+```http
+Authorization: Bearer
+```
+
+Multipart fields:
+
+| Field | Required | Description |
+| --- | --- | --- |
+| `file` | Yes | UTF-8 `.txt` attachment, maximum 5 MiB |
+| `senderDiscordId` | Yes | ID of the Discord message author |
+| `description` | No | Plain text, maximum 5,000 characters |
+| `image` | No | Validated image, maximum 50 MiB |
+
+The endpoint must:
+
+1. Verify the bearer secret.
+2. Reject files without a case-insensitive `.txt` extension.
+3. Reject empty, invalid UTF-8, NUL-containing, or oversized files.
+4. Confirm the sender belongs to the configured Discord guild.
+5. Fetch the sender's global Discord profile server-side.
+6. Use `global_name`, falling back to `username`.
+7. Use the global Discord avatar rather than a guild-specific avatar.
+8. Ignore and reject request-provided display-name or avatar fields.
+9. Synchronize the sender with the existing NextAuth account tables.
+10. Store the optional image and share in one transaction.
+
+Successful response:
+
+```json
+{
+ "success": true,
+ "share": {
+ "id": "a1b2c3d4",
+ "url": "https://example.com/share/a1b2c3d4",
+ "author": {
+ "displayName": "Example User",
+ "avatarUrl": "https://cdn.discordapp.com/..."
+ },
+ "imageUrl": null,
+ "createdAt": "2026-08-16T00:00:00.000Z"
+ }
+}
+```
+
+Expected statuses:
+
+- `201` share created.
+- `400` malformed or blank content.
+- `401` invalid bot secret or missing web session.
+- `413` text or image exceeds its limit.
+- `415` unsupported file or image type.
+- `422` invalid sender or sender is not a guild member.
+- `429` rate limit exceeded, including `Retry-After`.
+- `503` Discord profile lookup or rate-limit storage is unavailable.
+
+## Rate Limits
+
+- Combined web and bot uploads: 10 shares/hour per attributed Discord sender.
+- Private bot endpoint: 60 requests/hour globally.
+- Comments: 30 comments/hour per Discord account.
+- Store fixed-window counters in Redis.
+- Return HTTP 429 with `Retry-After` when exceeded.
+- Return HTTP 503 if Redis is unavailable instead of accepting unlimited uploads.
+
+## Shared Page
+
+`/share/[id]` displays:
+
+- Author global display name and avatar snapshot.
+- Bangkok-localized creation timestamp.
+- Optional description in its own plain-text section.
+- Optional image beneath the description.
+- Main content in a whitespace-preserving monospaced panel.
+- `คัดลอกข้อความ` to copy the exact main text.
+- `คัดลอกลิงก์` to copy the page URL.
+- Delete control for the author or configured administrators.
+
+The text copy action must use only `share.text.content`. It must preserve whitespace and must never append or prepend the description.
+
+## Comments
+
+- Everyone may read comments.
+- Discord login is required to post.
+- Comment body maximum is 2,000 characters.
+- Default order is oldest first.
+- Allow switching between oldest and newest.
+- Use 50 comments per page.
+- Use `comments=oldest|newest` and `page` query parameters.
+- Comment authors and configured administrators may delete comments.
+- Deleting a share cascades to all comments.
+
+## Login Dialog Extraction
+
+- Move `LoginDialog` from `app/form/client.tsx` to `components/login-dialog.tsx`.
+- Preserve the form dialog's current behavior and Thai copy as defaults.
+- Add optional `title` and `description` props.
+- Update the form page to import it from `@/components/login-dialog`.
+- Use share-specific Thai copy on `/share` explaining that login is required before uploading.
+- Keep the dialog non-dismissible and return users to the original URL after OAuth.
+- Remove only imports made unused by the extraction.
+
+## Verification
+
+- Verify unauthenticated `/share` requests do not render upload controls.
+- Verify direct unauthenticated web upload requests return HTTP 401.
+- Verify OAuth returns to `/share` and reveals the form.
+- Test existing-user updates, new bot sender creation, concurrent first uploads, and OAuth login after a bot-created account.
+- Assert that each Discord ID resolves to exactly one `account.user` and one Discord `account.account` link.
+- Test `.txt`, UTF-8, NUL, text, description, and image boundaries.
+- Test bot authorization, sender verification, atomic rollback, and response shapes.
+- Test rate limits at 10/11 user shares, 60/61 bot requests, and 30/31 comments.
+- Test that Copy text preserves the content exactly and excludes the description.
+- Regression-test the extracted form login dialog.
+- Run:
+ - `bun test`
+ - `bunx tsc --noEmit`
+ - `bun run lint`
+ - `git diff --check`
+- Confirm Drizzle reports no pending schema difference after migration generation.
+
+## Scope Boundaries
+
+- The Discord bot implementation is outside this repository.
+- The bot supplies `message.author.id` when forwarding a `.txt` attachment.
+- Shared pages remain until deleted.
+- No editing, automatic expiration, public share feed, search, anonymous publishing, or anonymous comments are included.