Skip to content

Instantly share code, notes, and snippets.

@shrumm
Last active January 7, 2026 21:47
Show Gist options
  • Select an option

  • Save shrumm/416fb7aeb8d8b369062ce905e2c4a7ba to your computer and use it in GitHub Desktop.

Select an option

Save shrumm/416fb7aeb8d8b369062ce905e2c4a7ba to your computer and use it in GitHub Desktop.

PromptBin – Product Specification

A minimal prompt sharing platform. Pastebin for prompts.

Core Concept

Anyone can paste a prompt and get a shareable link. Public prompts are discoverable via homepage and search. Unlisted prompts are accessible only by direct URL.

Content Format

Prompts are stored as plain text but rendered as Markdown in the view page. The copy button always copies raw text, preserving portability.

Token count: Display exact token count as "X tokens (OpenAI)" on detail page. Calculated server-side using tiktoken cl100k_base encoding and stored in database (since prompts are immutable).

User Journeys

  1. Publish: Paste content, add title, get a link. Optional: description, custom slug, source attribution, unlisted visibility.
  2. Discover: Browse public prompts on homepage, search by keyword, sort by recent or popular.
  3. View: Open a prompt URL, read content, copy to clipboard.

Non-Goals

  • No authentication or user accounts
  • No editing or deleting prompts (immutable once created)
  • No versioning
  • No comments or ratings
  • No tags or categories

Immutability

Prompts are immutable after creation. There is no UPDATE operation. This simplifies the data model, eliminates conflict resolution, and ensures URLs always return the same content. The FTS index only needs INSERT/DELETE triggers since content never changes.

Technology Stack

  • Go (standard library HTTP server)
  • SQLite with FTS5 for full-text search
  • golang-migrate for database migrations (embedded SQL files)
  • Server-side rendered HTML templates embedded in binary via //go:embed
  • All frontend assets served locally (no external CDN):
    • Theme CSS - Look for macos9.css in backend/handlers/assets/. If that file isn't provided at build time, you must instead embed the official 98.css stylesheet and reference it from the template. Do not invent a custom theme; the fallback must be the canonical 98.css file. Only focus your CSS effort on app-specific layout in app.css.
    • highlight.js for syntax highlighting
    • Custom markdown renderer (server-side)
  • Token counting: server-side using tiktoken-go with cl100k_base encoding (stored in DB on creation)

Project Structure

backend/
├── cmd/server/main.go       # Entry point, signal handling
├── handlers/
│   ├── handlers.go          # HTTP handlers, middleware, router
│   ├── templates/           # Go HTML templates (embedded via go:embed)
│   │   └── base.html        # Main template with all views
│   └── assets/              # Static assets (embedded via go:embed)
│       ├── macos9.css       # Theme CSS (if not present, use 98.css instead)
│       ├── app.css          # App-specific layout styles
│       ├── highlight.min.js # Syntax highlighting
│       └── ChiKareGo2.woff2 # Chicago-style bitmap font (optional)
└── store/
    ├── store.go             # Database operations, validation
    └── migrations/          # SQL migration files (embedded)
e2e/
├── tests/                   # Playwright test files
└── playwright.config.ts
tests/
└── integration_test.go      # Go integration tests (HTTP endpoints)

Data Model

Prompt

Field Type Constraints
id int64 primary key, auto-increment
slug string unique, 3-50 chars, alphanumeric + hyphens
title string required, max 200 chars
description string optional, max 500 chars
content string required, max 100,000 chars
visibility string "public" (default) or "unlisted"
source_url string optional, max 2048 chars, must be valid HTTP/HTTPS URL
source_title string optional, max 200 chars
views int64 default 0, incremented on each view
token_count int calculated on creation using tiktoken cl100k_base
created_at timestamp auto-set on creation

Slug Behavior

  • If provided: use as-is if available, error if taken
  • If empty: auto-generate 6-char random lowercase alphanumeric
  • Reserved slugs (cannot be used): new, api, health, metrics
  • Validation: 3-50 characters, must start with a lowercase letter, followed by lowercase letters, numbers, or hyphens
  • Valid examples: my-prompt, gpt4-system-prompt, a1b2c3
  • Invalid examples: 1-starts-with-number, UPPERCASE, has_underscore, ab (too short)

Visibility Rules

  • Public: appears in homepage listings, appears in search results, accessible by direct URL
  • Unlisted: never appears in listings or search, accessible only by direct URL

Security note: Unlisted prompts are obscured, not secured. The 6-character slug provides ~31 bits of entropy. Determined attackers could enumerate slugs. Do not use unlisted visibility for sensitive content.


API Specification

POST /api/prompts

Create a new prompt.

Request:

{
  "title": "Required title",
  "content": "Required prompt content",
  "description": "Optional description",
  "slug": "optional-custom-slug",
  "visibility": "public",
  "source_url": "https://example.com/original",
  "source_title": "Original Article"
}

Response: 201 Created with full prompt object

Errors:

  • 400 Bad Request: empty title, empty content, invalid slug format, field too long
  • 409 Conflict: slug already exists

GET /api/prompts/search

Search public prompts using full-text search.

Query parameters:

  • q (required, search query)
  • limit (int, default 50, max 100)
  • offset (int, default 0)

Response: 200 OK with array of matching prompt summaries

Errors:

  • 400 Bad Request: empty or missing query

Search behavior: FTS5 search across title, description, and content. User queries are escaped to prevent FTS operator injection. Ordered by relevance. Only public prompts returned.

GET /{slug}.json

Get a single prompt as JSON by appending .json to any prompt URL. Works for both public and unlisted prompts.

Response: 200 OK with full prompt object

Side effect: Increments view count synchronously before returning response.

Errors:

  • 404 Not Found: slug does not exist

GET /health

Health check endpoint.

Response: 200 OK

{"status": "healthy", "database": "connected"}

Error Response: 500 Internal Server Error if database unreachable

GET /metrics

Prometheus metrics in text format.

Metrics:

  • prompts_created_total (counter)
  • prompt_views_total (counter)
  • http_requests_total (counter)
  • http_errors_total (counter)

Frontend Specification

Server-side rendered HTML templates with retro desktop OS theme styling.

Theme CSS

Look for macos9.css in backend/handlers/assets/. If that file is present, use it for the Mac OS 9 Platinum theme. If not provided at build time, embed the official 98.css stylesheet instead. Do not invent a custom theme—use one of these two options. Focus custom CSS effort only on app-specific layout in app.css.

Visual Design

Theme: Classic desktop OS aesthetic (Mac OS 9 or Windows 98 depending on theme CSS) with:

  • Fixed menu bar at top (Apple menu with "About" dialog)
  • Window chrome with title bar, control buttons
  • Finder-style list view with sortable columns
  • Classic system fonts (Chicago-style bitmap font if available, otherwise system default)
  • Beveled buttons, inset fields, raised panels

Color Scheme: Classic system palette

  • Surface gray for window backgrounds
  • White content areas
  • Blue selection highlight
  • Classic border styling (light/dark edges for 3D effect)

URL Structure

  • / – Homepage (browse and search)
  • /new – Create prompt
  • /{slug} – View prompt (HTML)
  • /{slug}.json – View prompt (JSON)

SPA Architecture

The frontend is a single-page application using History API for navigation.

View containers: Three mutually exclusive <div> elements with IDs:

  • view-list – Homepage view (Finder-style list)
  • view-create – Create prompt form (dialog window)
  • view-detail – View prompt detail (document window)

View switching: Use CSS class .hidden (display: none). Only one view visible at a time.

Core JavaScript functions:

  • handleRoute() – Parse window.location.pathname, show appropriate view, fetch data
  • navigate(path) – Update URL via history.pushState(), call handleRoute()
  • Listen for popstate event to handle browser back/forward

State: Store current prompt data in a module-level variable for copy functionality.

Branding

  • Product name: PromptBin
  • Menu bar: Apple menu (left), app name "PromptBin" (right), current time
  • About dialog: "Built by Cleric" with dynamic copyright year
  • No "demo" language

Homepage (/)

Layout: Finder-style window with list view

Components:

  • Window toolbar with "New" button and search box
  • List header with sortable columns: Name, Modified, Tokens
  • List body with alternating row colors
  • Footer showing item count

Empty state:

  • Centered message in list area
  • "New" button remains available

Prompt rows show:

  • Document icon (folded corner style)
  • Title (clickable, selection highlight on hover)
  • Modified date
  • Token count

Mobile: Hide date column to maximize title width (Name + Tokens only)

Create View (/new)

Design: Mac OS 9 dialog window style

Element IDs:

  • createContent – Prompt textarea (monospace, inset field)
  • createTitle – Title input
  • createPublic – Visibility checkbox
  • charCount – Live character count display

Layout:

  1. Content group - Label + textarea with character count
  2. Title input - With label
  3. Source URL input - Optional, with label
  4. Visibility checkbox - "List publicly" (checked by default)
  5. Action buttons - Cancel (left), Save (right, default style)

On success: Navigate to /{slug} view, form resets

View (/{slug})

Design: Document window with metadata bar and action buttons

Element IDs (for testing):

  • detailTitle – Prompt title (in window title bar)
  • detailContent – Rendered markdown content area
  • tokenCount – Token count in metadata bar
  • copyButton – Copy to clipboard button
  • copyText – Text that changes to "copied!" after copy

Layout (top to bottom):

  1. Title bar - Window chrome with prompt title
  2. Metadata bar - Created date, slug link, token count, source (if provided)
  3. Content area - Scrollable, rendered Markdown with syntax highlighting
  4. Action bar - Back button (left), Raw/Formatted toggle, Copy button (right)

JavaScript Requirements

  • History API router (pushState/popstate)
  • Search triggers on Enter key
  • Copy to clipboard using navigator.clipboard.writeText() (copies raw content, not rendered HTML)
  • Markdown rendering: Server-side with highlight.js for code blocks
  • Token count: Display token_count from server (calculated with tiktoken)
  • Live character count on create form
  • Menu bar clock updates every minute

Responsive Design

  • Mobile-first
  • Content textarea comfortable on mobile
  • Date column hidden on narrow screens (<600px)
  • Window fills available height

Technical Constraints

Database

  • prompts table with all prompt fields
  • prompts_fts FTS5 virtual table for full-text search (title, description, content), synced via INSERT/DELETE triggers
  • Unique index on slug
  • Indexes for efficient listing queries (public prompts by recency and popularity)

Observability

Structured Logging

Use log/slog with configurable format (LOG_FORMAT: text or json) and level (LOG_LEVEL: debug, info, warn, error).

Log every:

  • HTTP request: method, path, status, duration_ms
  • Database operation: operation name, duration_ms, relevant context
  • Error: operation context, error message, identifiers

Health Check

GET /health verifies database connectivity by executing a simple query. Returns 200 if healthy, 500 if not.

Metrics

Prometheus format at GET /metrics. Track: prompts created, prompt views, HTTP requests, HTTP errors.


Configuration

Environment variables with defaults:

Variable Default Description
PORT 8080 HTTP server port
DATABASE_PATH ./data/prompts.db SQLite database file path
LOG_FORMAT text Logging format (text or json)
LOG_LEVEL info Logging level
ALLOWED_ORIGIN http://localhost:8080 CORS allowed origin (set to production domain in deployment)

Deployment

Render Configuration

  • Runtime: Go
  • Plan: starter (required for persistent disk)
  • Persistent disk mounted at /data for SQLite
  • Health check: /health
  • Auto-deploy on push to main
  • Build command: make test && make build
  • Start command: ./bin/promptbin

Build Pipeline

  1. Run all tests (make test)
  2. Build binary (make build)
  3. Deploy
  4. Server starts and runs database migrations automatically
  5. Health check must pass before traffic routes

Database Migrations

Migrations use golang-migrate with embedded SQL files:

  • Location: backend/store/migrations/
  • Execution: Migrations run automatically on server startup
  • Behavior:
    • Fresh databases start from version 0 and apply all migrations
    • Existing databases apply only pending migrations
    • Data is preserved during schema changes

Migration files follow the naming convention NNN_description.up.sql and NNN_description.down.sql.


Development

Makefile Targets

The Makefile centralizes build configuration, especially CGO flags required for SQLite FTS5:

Target Description
make run Run the server locally (migrations run automatically)
make test Run all Go tests with verbose output
make test-e2e Run Playwright E2E tests
make build Build the production binary to bin/promptbin
make clean Remove build artifacts
make help Show available targets

CGO Requirement: All Go commands must be run with CGO_CFLAGS="-DSQLITE_ENABLE_FTS5" to enable SQLite full-text search. The Makefile handles this automatically—always use Make targets instead of running go commands directly.


Testing Requirements

Testing Philosophy

First Principle: Each behavior should be tested exactly once, at the appropriate layer.

Tests exist to catch regressions. Testing the same behavior at multiple layers adds maintenance cost without value. Choose the right layer:

Layer Purpose What to Test
Store (unit) Business logic Validation rules, database operations, edge cases
Handler (integration) HTTP wiring Status codes, JSON parsing, middleware, routing
E2E Go Full stack Happy path with real HTTP server
E2E Playwright Browser User journeys requiring a real browser

Anti-pattern to avoid: Testing validation at both store AND handler layers. If Store.CreatePrompt() validates empty titles, don't also test empty titles in TestCreatePromptHandler. The handler just calls the store—test it once.

Store Unit Tests (Go)

Test business logic and database operations. This is where validation lives.

Location: backend/store/store_test.go

What to test:

  • All validation rules (empty title, empty content, field length limits, slug format, URL validation)
  • Database operations (create, get, list, search)
  • Edge cases (duplicate slugs, reserved slugs, FTS operator escaping)
  • Pagination and sorting behavior
  • Visibility filtering (unlisted exclusion)

What NOT to test: HTTP concerns—that's the handler layer's job.

Handler Integration Tests (Go)

Test HTTP-specific behavior using httptest.

Location: backend/handlers/handlers_test.go

What to test:

  • HTTP status codes (one happy path per endpoint)
  • JSON request parsing (malformed JSON, missing Content-Type)
  • Middleware (CORS headers, security headers, body size limits, panic recovery)
  • Response format (correct Content-Type, JSON structure)

What NOT to test: Validation logic that lives in the store—one test proving 400 for store validation errors is enough.

Integration Tests (Go)

Test HTTP endpoint behavior with a real server.

Location: tests/integration_test.go

What to test:

  • HTTP endpoints return correct status codes and content
  • SSR templates render correctly
  • Search API pagination works
  • Error handling (404 for missing prompts)

Keep minimal: These test HTTP wiring, not user flows.

E2E Tests (Playwright)

Test real user journeys through the browser UI.

Location: e2e/tests/

Setup:

  • Start Go server before tests (playwright.config.ts webServer option)
  • Use in-memory database (:memory:) for fast, isolated runs
  • Reuse existing server in development (reuseExistingServer: !process.env.CI)

Test Philosophy:

  • Test actual user flows through the UI (click buttons, fill forms)
  • Never call APIs directly - that's what integration tests are for
  • Test the happy path for each user journey
  • One error case is enough
  • Avoid testing UI details that don't affect functionality
  • Keep the suite small and fast

Core E2E Tests (promptbin.spec.ts, 6 tests)

  1. Create prompt and view it - Full create flow, verify detail page
  2. Create unlisted prompt - Visibility toggle works
  3. Copy to clipboard - Browser clipboard API works
  4. Search works - FTS integration works end-to-end
  5. Validation requires title and content - One error case
  6. Homepage branding - Visual identity correct

Security E2E Tests (security.spec.ts, 5 tests)

  1. Security headers on HTML - CSP, X-Frame-Options, etc.
  2. Security headers on API - Headers present on JSON responses
  3. No CSP violations - Normal usage doesn't trigger CSP errors
  4. XSS escaped - Malicious input displays as text
  5. Large request rejected - 413 for oversized payloads

Test Count Guidelines

A well-structured test suite for this app should have approximately:

Layer Expected Count Rationale
Store ~30 All validation + database operations
Handler ~15 HTTP concerns only, not validation
E2E Go ~4 Prove wiring works
Playwright ~11 User journeys + security
Total ~60

If handler tests exceed ~15, you're likely duplicating store tests. Refactor.


Behavioral Details

View Counting

  • Increment synchronously on every page view (HTML or JSON)
  • Used for "popular" sorting
  • NOT displayed in UI (no vanity metrics)

Slug Generation

When no slug provided:

  1. Generate 6 random lowercase alphanumeric characters
  2. Check for collision
  3. Retry up to 3 times if collision
  4. Error if still colliding (extremely rare)

Error Responses

All errors return JSON with {"error": "message"}. Validation errors include field-specific details in a details object.

CORS

Restrict to configured origin. Set Access-Control-Allow-Origin to the value of ALLOWED_ORIGIN environment variable. Allow Content-Type header. Handle OPTIONS preflight. Reject requests from other origins.

Graceful Shutdown

On SIGINT/SIGTERM: stop accepting new connections, finish in-flight requests, close database, exit cleanly.


Security

This is a simple, public prompt-sharing app. Security measures focus on preventing XSS, clickjacking, and basic abuse—not protecting sensitive data.

Defense in Depth: XSS Prevention

Multiple layers prevent XSS attacks:

  1. Input validation - Reject overly long strings at API layer
  2. Output encoding - All user content is HTML-escaped before rendering (escapeHtml() function)
  3. Content Security Policy - Restricts script sources to self only
  4. No innerHTML with raw user data - Content is escaped, then rendered as markdown

The escapeHtml() function converts <, >, &, ", ' to HTML entities. This happens before any other processing, so even if someone submits <script>alert('xss')</script>, it displays as literal text.

Input Validation

Field constraints are defined in the Data Model section. Additional security considerations:

  • source_url: must be valid HTTP/HTTPS URL (reject javascript:, data:, file: schemes)
  • FTS query escaping: User search queries are escaped to prevent FTS5 operator injection

Security Headers

All HTTP responses include (via middleware):

  • X-Content-Type-Options: nosniff
  • X-Frame-Options: DENY
  • Referrer-Policy: strict-origin-when-cross-origin
  • Content-Security-Policy (see below)

Content Security Policy

The CSP header restricts what resources can load:

Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'

Key restrictions:

  • Scripts and styles only from self (all assets served locally - no external CDN)
  • No external images except data: URIs
  • Cannot be framed (frame-ancestors 'none')
  • Forms can only submit to self

Trade-off: 'unsafe-inline' is required for inline scripts and styles. This is acceptable for this app's threat model.

Why no CDN? All frontend assets (macos9.css, highlight.js) are embedded in the binary and served locally. This provides:

  • No external dependencies or potential supply chain vulnerabilities
  • Works offline / air-gapped environments
  • Simpler, stricter CSP
  • Consistent behavior regardless of CDN availability

Subresource Integrity (SRI)

Note: SRI is not applicable since all assets are served locally from the same origin. The assets are embedded in the Go binary at compile time, ensuring integrity through the build process.

Request Limits

  • Maximum request body size: 150KB (reject before JSON parsing with 413 Payload Too Large)
  • This prevents memory exhaustion from large payloads

Rate Limiting (Optional)

For a simple app, rate limiting is optional. If needed:

  • POST /api/prompts: 10 requests per minute per IP
  • GET endpoints: 100 requests per minute per IP
  • Return 429 Too Many Requests with Retry-After header

CORS

  • Set Access-Control-Allow-Origin to the configured ALLOWED_ORIGIN (not * in production)
  • Allow only GET, POST, OPTIONS methods
  • Allow only Content-Type header
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment