Skip to content

Repository files navigation

Inkline

CI

Inkline is a small Medium-style writing app built for learning. It has a vanilla HTML/CSS/JS frontend, a Node HTTP backend, and Postgres persistence through Prisma.

What is included

  • A responsive story feed with search, topic filters, and saved stories.
  • Following authors and topics.
  • Personalized "For you" and "Following" feeds.
  • In-app notifications for follows, new stories, claps, and responses.
  • Writer analytics for story views, reads, followers, and subscribers.
  • Writer subscriptions and publication newsletter subscriptions.
  • Publications with owners, editors, writers, submission review, and email newsletters.
  • Dedicated article URLs like /stories/:id/:slug.
  • Account creation, sign in, sign out, and cookie-based sessions.
  • Author settings with profile and email editing.
  • Story creation, editing, and deletion for the signed-in author.
  • Draft saving before publishing.
  • Paginated feed loading.
  • A contenteditable rich text editor with bold, italic, headings, quotes, lists, and links.
  • Cover image URLs plus local or Supabase Storage uploads.
  • Claps, bookmarks, and reader responses.
  • Response deletion by the response author or story author.
  • Response hiding/showing by story authors and admins.
  • Reporting and blocking controls with block-aware feeds and interactions.
  • Admin moderation queues for reports, responses, stories, and production diagnostics.
  • Postgres full-text search across titles, subtitles, authors, topics, and story bodies.
  • Email verification and password reset flows using expiring single-use tokens plus an email delivery adapter.
  • In-memory fixed-window rate limiting for auth, uploads, and responses.
  • Postgres tables, Prisma schema, and Prisma migrations.
  • API and browser-level tests for the main publishing flow.
  • Admin diagnostics for failed email delivery and image cleanup.

Database setup

Create a .env file from .env.example, then set DATABASE_URL.

For a local Postgres database:

createdb inkline_prisma_dev
DATABASE_URL="postgresql://YOUR_MAC_USER@localhost:5432/inkline_prisma_dev"
DIRECT_URL="postgresql://YOUR_MAC_USER@localhost:5432/inkline_prisma_dev"

For Neon, use the pooled connection string for DATABASE_URL and the direct connection string for DIRECT_URL.

Apply the Prisma migration and seed starter stories:

npm run migrate

If you already have local data in data/db.json, import it into Postgres:

npm run db:import-json

Neon setup

Keep Neon config in a separate local file so you can switch between local Postgres and Neon without editing the same .env over and over:

cp .env.neon.example .env.neon

In .env.neon, paste the pooled Neon connection string into DATABASE_URL and the direct Neon connection string into DIRECT_URL.

Then apply migrations to Neon:

npm run neon:migrate

Optionally import your legacy data/db.json data into Neon:

npm run neon:import-json

Run the app against Neon:

npm run start:neon

Useful Neon commands:

npm run neon:status
NEON_ENV_FILE=.env.neon-preview npm run neon:migrate

Image storage

Uploads use local disk by default and are served from /uploads. To move uploaded cover images to Supabase Storage, create a public bucket such as inkline-uploads, then set these server-side environment variables:

STORAGE_PROVIDER=supabase
SUPABASE_URL="https://YOUR_PROJECT_REF.supabase.co"
SUPABASE_SERVICE_ROLE_KEY="your_server_only_service_role_key"
SUPABASE_STORAGE_BUCKET=inkline-uploads
SUPABASE_STORAGE_PATH_PREFIX=story-covers

SUPABASE_SERVICE_ROLE_KEY must stay on the server and must not be exposed in browser code or committed to Git. SUPABASE_PUBLIC_URL is optional if you later front the bucket with a custom CDN/public base URL.

Run it

npm start

Then open:

http://localhost:4173

Deploy it

Inkline deploys as one hosted Node web service connected to managed Postgres, image storage, and email delivery. The current production-friendly path is:

  • Neon for DATABASE_URL and DIRECT_URL.
  • Supabase Storage for uploaded cover images.
  • Resend for email verification and password reset delivery.
  • A Node host such as Render, Railway, or Fly.io.

Hosted Node settings:

Node version: 24.x
Build command: npm ci
Migration command: npm run db:deploy
Start command: npm start
Fallback start command: npm run start:prod
Health check path: /healthz

Use npm run db:deploy as a separate release, pre-deploy, or migration command when your host supports one. If your host only gives you a build command and a start command, use npm run start:prod for a small single-instance learning deployment; it applies Prisma migrations before starting the server.

Set these environment variables in the host's secret variable UI, not in committed files:

NODE_ENV=production
HOST=0.0.0.0
APP_URL=https://your-inkline-domain.com

DATABASE_URL="your pooled Neon connection string"
DIRECT_URL="your direct Neon connection string"

STORAGE_PROVIDER=supabase
SUPABASE_URL="https://YOUR_PROJECT_REF.supabase.co"
SUPABASE_SERVICE_ROLE_KEY="your_server_only_service_role_key"
SUPABASE_STORAGE_BUCKET=inkline-uploads
SUPABASE_STORAGE_PATH_PREFIX=story-covers

EMAIL_PROVIDER=resend
RESEND_API_KEY=your_resend_key
EMAIL_FROM="Inkline <hello@yourdomain.com>"
ADMIN_EMAILS=you@yourdomain.com

Most hosts inject PORT automatically, so do not hard-code it unless the host asks you to. Keep HOST=0.0.0.0 in production so the platform proxy can reach the Node process. Use Supabase Storage in production because local uploaded files are not durable on many hosted runtimes.

Platform notes:

  • Render: create a Web Service from the GitHub repo. Render's Node guide uses build and start commands, and Render's deploy docs describe pre-deploy commands for tasks like database migrations. Use npm ci, npm run db:deploy, and npm start when pre-deploy is available; otherwise use npm run start:prod.
  • Railway: add variables from the service's Variables tab or RAW editor, then deploy from the GitHub repo. Use the same hosted Node settings above.
  • Fly.io: make sure the app listens on 0.0.0.0 and that Fly's internal port matches the app's PORT. Fly's troubleshooting docs call this out as a common cause of unreachable deployments.

Production smoke test:

curl https://your-inkline-domain.com/healthz

Then open the app in the browser and check:

  • Register a new account and verify the email link.
  • Publish a story with an uploaded cover image.
  • Reload the dedicated story URL.
  • Add a response, clap, and bookmark.
  • Request a password reset and confirm the reset link uses APP_URL.
  • Confirm the host logs do not show migration, email, storage, or Prisma errors.
  • Open Admin tools and confirm Diagnostics is empty after the smoke test.

Test it

The API test suite creates a temporary local Postgres database by default, applies Prisma migrations, starts the server on a random port, runs the main API flows, and drops the database afterward. The Playwright suite starts the app in a browser and clicks through sign up, writing, publishing, reading, responding, bookmarking, clapping, and editing.

Install the Playwright browser once before running browser tests locally:

npx playwright install chromium

If that download is slow and you already have Google Chrome installed, run the browser suite with:

PLAYWRIGHT_USE_SYSTEM_CHROME=1 npm run test:e2e

Run all tests:

npm test

Run only one layer:

npm run test:api
npm run test:e2e

Use TEST_DATABASE_URL if you want to point the tests at your own disposable database instead:

TEST_DATABASE_URL="postgresql://YOUR_MAC_USER@localhost:5432/inkline_test" npm test

GitHub Actions installs Chromium, then runs npm run check and npm test against Postgres on every push and pull request to main.

Useful database commands:

npm run db:generate
npm run db:migrate
npm run db:deploy
npm run db:seed
npm run db:studio

Files to study

  • index.html holds the page structure and dialogs.
  • styles.css controls layout, responsive behavior, editor styling, and dialog styling.
  • app.js holds frontend state, rendering, routing, and API calls.
  • server.js holds the HTTP server, API routes, sanitization, direct Prisma story/auth/comment/upload/profile/admin flows, image storage adapters, and the JSON import path for old local data.
  • prisma/schema.prisma defines the Postgres tables and relationships.
  • prisma/migrations/ contains SQL migrations generated from the Prisma schema.
  • data/db.json is now legacy local data that can be imported with npm run db:import-json.
  • uploads/ is created automatically when local image storage is enabled.
  • Admin diagnostics are stored in the SystemEvent table and shown in Admin tools.

API map

  • GET /api/session
  • GET /healthz
  • POST /api/auth/register
  • POST /api/auth/login
  • POST /api/auth/logout
  • POST /api/auth/request-verification
  • POST /api/auth/verify-email
  • POST /api/auth/request-reset
  • POST /api/auth/reset-password
  • GET /api/follows
  • POST /api/follows/authors
  • POST /api/follows/topics
  • GET /api/notifications
  • POST /api/notifications/read
  • PUT /api/me
  • GET /api/me/drafts
  • GET /api/me/analytics
  • GET /api/writers/:authorId/subscription
  • POST /api/writers/:authorId/subscription
  • GET /api/publications
  • POST /api/publications
  • GET /api/publications/:id
  • POST /api/publications/:id/members
  • POST /api/publications/:id/submissions
  • POST /api/publications/:id/submissions/:submissionId
  • POST /api/publications/:id/subscribe
  • POST /api/publications/:id/newsletters
  • GET /api/blocks
  • POST /api/blocks
  • POST /api/reports
  • POST /api/uploads
  • GET /api/admin/moderation
  • POST /api/admin/reports/:reportId
  • POST /api/admin/responses/:responseId/moderate
  • DELETE /api/admin/responses/:responseId
  • DELETE /api/admin/stories/:id
  • GET /api/stories
  • GET /api/stories/:id
  • POST /api/stories
  • PUT /api/stories/:id
  • DELETE /api/stories/:id
  • POST /api/stories/:id/clap
  • POST /api/stories/:id/bookmark
  • POST /api/stories/:id/view
  • POST /api/stories/:id/read
  • POST /api/stories/:id/responses
  • DELETE /api/stories/:id/responses/:responseId
  • POST /api/stories/:id/responses/:responseId/moderate

Email delivery

Verification and reset links are generated with production-style token rules: random tokens, hashed storage, expiry times, and single use after success. By default, the app uses the development email adapter and shows generated links in the UI.

For production delivery, Inkline can send through Resend's Email API. Resend expects a verified sending domain and sends messages through POST /emails; see the official Send Email and Managing Domains docs.

Set these server-side environment variables before starting the server:

APP_URL=https://your-inkline-domain.com
EMAIL_PROVIDER=resend
RESEND_API_KEY=your_resend_key
EMAIL_FROM="Inkline <hello@yourdomain.com>"

APP_URL is used inside verification and password reset links. Set it to the real HTTPS origin users will open in the browser. RESEND_API_KEY must stay on the server and must not be exposed in browser code or committed to Git. If EMAIL_PROVIDER=resend is configured incorrectly, Inkline records an admin diagnostic and does not show a development link to users.

Production email setup checklist:

  • Add your sending domain in Resend and wait until the domain status is verified.
  • Use a sender address on that verified domain, such as Inkline <hello@yourdomain.com>.
  • Store RESEND_API_KEY, EMAIL_PROVIDER=resend, EMAIL_FROM, and APP_URL in the host's secret environment settings.
  • Register a test account and confirm the verification email arrives in the inbox.
  • Click the verification link and confirm the app marks the account as verified.
  • Request a password reset and confirm the reset link opens the app on the production domain.
  • Confirm Resend logs show the messages as accepted or delivered.
  • Confirm no production secrets are present in .env, .env.neon, shell history, screenshots, or committed files.

Set ADMIN_EMAILS to a comma-separated list to make known accounts admins. The first registered user is also made an admin locally so you can reach the moderation tools.

Rate limiting

The server includes in-memory fixed-window limits for noisy write paths:

AUTH_RATE_LIMIT_MAX=30
AUTH_RATE_LIMIT_WINDOW_MS=900000
UPLOAD_RATE_LIMIT_MAX=20
UPLOAD_RATE_LIMIT_WINDOW_MS=3600000
RESPONSE_RATE_LIMIT_MAX=12
RESPONSE_RATE_LIMIT_WINDOW_MS=300000

Auth limits are keyed by client IP. Upload and response limits are keyed by signed-in user. This is enough for a single Node server; for multi-instance production, move counters to Redis or another shared store.

Learning path

  1. Trace boot() in app.js to see how the page loads session data and stories.
  2. Follow submitAuth() into server.js to learn how sessions are created.
  3. Read prisma/schema.prisma to see how users, stories, publications, subscriptions, analytics, reports, blocks, and notifications map to tables.
  4. Read handleStoryIndexPrisma(), handleStoryDetailPrisma(), and handleMyDraftsPrisma() in server.js to see direct Prisma story reads.
  5. Read handleRegisterPrisma(), handleLoginPrisma(), and handleResetPasswordPrisma() to see direct Prisma auth/session writes.
  6. Follow submitStory() into handleCreateStoryPrisma() and handleUpdateStoryPrisma() to see frontend data become persisted Postgres data.
  7. Read handleCreateResponsePrisma(), handleDeleteResponsePrisma(), and handleModerateResponsePrisma() to see direct Prisma comment writes.
  8. Read handleUploadPrisma(), handleUpdateMePrisma(), and handleAdminModerationPrisma() to see direct Prisma file metadata, profile, diagnostics, and admin flows.
  9. Read importJsonDatabase() and writeDb() in server.js to understand how old data/db.json records are imported into Postgres tables.
  10. Read getFollowStatePrisma(), recommendationScore(), and notifyStoryFollowersPrisma() to see how follows power feeds and notifications.
  11. Read findPublishedStoryIdsBySearch() and the full-text search migration to see how Postgres ranks matching stories.
  12. Read applyRateLimit() to see how fixed-window rate limiting protects auth, uploads, and responses.
  13. Read saveLocalImageUpload() and saveSupabaseImageUpload() to see how upload storage is swapped by environment.
  14. Read test/api.test.js to see how publishing, analytics, publications, newsletters, reports, blocking, and moderation are tested through HTTP.
  15. Read e2e/writing.spec.js to see how Playwright tests the same flow through the browser UI.
  16. Read validateStoryInput() to see how drafts and published stories use different validation rules.
  17. Use npm run db:studio to inspect the database visually while you create stories in the app.

Next useful features

  • Add a Redis-backed rate limiter for multi-instance production deploys.
  • Add a retry or resolve workflow for stored production diagnostics.
  • Add time-series analytics and referrer breakdowns instead of lifetime counters only.
  • Add publication invitations, member removal, and scheduled newsletter delivery.
  • Add moderator notes, report appeals, and account-level suspension tools.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages