diff --git a/TODOS.md b/TODOS.md
new file mode 100644
index 0000000..2f2727b
--- /dev/null
+++ b/TODOS.md
@@ -0,0 +1,7 @@
+# TODOS
+
+## Add philosophy/workflow guidance to MCP tool descriptions
+**What:** Enhance MCP tool descriptions in `routes/mcp.py` to include behavioral guidance (when to create pages, visibility recommendations, titling conventions) alongside the current functional descriptions.
+**Why:** Claude Code (the primary agent path) reads MCP tool descriptions, not `/api/v1/agent-setup`. This is where philosophy guidance has the highest impact on actual agent behavior. Codex review flagged this as higher leverage than the agent-setup endpoint changes.
+**Context:** Currently MCP tool descriptions are functional only (e.g., "Create a new page. Content should be markdown starting with `# Title`."). Philosophy guidance lives in `agent-setup` (for the copy-paste prompt path) but not in MCP (for the native Claude Code path). The `list_pages` MCP tool already returns conventions with AGENTS content, but the tool descriptions themselves don't prime the agent's behavior.
+**Depends on:** Nothing. Can be done independently of the agent-setup philosophy changes.
diff --git a/app.py b/app.py
index 9ee12f4..34074d5 100644
--- a/app.py
+++ b/app.py
@@ -83,7 +83,7 @@ def make_session_permanent():
_CSP = (
"default-src 'self'; script-src 'self' https://static.cloudflareinsights.com;"
" connect-src 'self' https://cloudflareinsights.com; style-src 'self';"
- " img-src 'self' https://*.fly.storage.tigris.dev"
+ " img-src 'self' blob: https://*.fly.storage.tigris.dev"
)
diff --git a/cli/jottit_cli/commands/publish.py b/cli/jottit_cli/commands/publish.py
index 9d0507c..7d388cb 100644
--- a/cli/jottit_cli/commands/publish.py
+++ b/cli/jottit_cli/commands/publish.py
@@ -25,9 +25,9 @@ def _read_content(file, title):
@click.option("--slug", help="Custom slug for the page")
@click.option(
"--visibility",
- type=click.Choice(["unlisted", "listed", "pinned"]),
+ type=click.Choice(["private", "unlisted", "listed", "pinned"]),
default=None,
- help="Page visibility (default: unlisted)",
+ help="Page visibility (default: private)",
)
@click.option("--title", help="Page title (prepended as # heading if missing)")
@click.option(
diff --git a/db.py b/db.py
index 11edbf3..6b45346 100644
--- a/db.py
+++ b/db.py
@@ -1,5 +1,6 @@
import hashlib
import importlib.util
+import json
import os
import secrets
import threading
@@ -577,6 +578,53 @@ def create_api_token(user_id, name):
return token, row["id"]
+def get_or_create_api_token(user_id, name):
+ """Return an existing token by name, or create one. Returns (token_str, token_id).
+ token_str is None if the token already existed (value not stored)."""
+ with get_db() as conn:
+ existing = conn.execute(
+ "SELECT id FROM api_tokens WHERE user_id = %s AND name = %s LIMIT 1",
+ (user_id, name),
+ ).fetchone()
+ if existing:
+ return None, existing["id"]
+ return create_api_token(user_id, name)
+
+
+def get_setup_checklist(user_id):
+ """Return checklist status for the getting-started flow."""
+ with get_db() as conn:
+ page_count = conn.execute(
+ "SELECT COUNT(*) AS c FROM pages WHERE user_id = %s",
+ (user_id,),
+ ).fetchone()["c"]
+ token_count = conn.execute(
+ "SELECT COUNT(*) AS c FROM api_tokens WHERE user_id = %s",
+ (user_id,),
+ ).fetchone()["c"]
+ mcp_used = conn.execute(
+ "SELECT EXISTS(SELECT 1 FROM revisions r JOIN pages p ON r.page_id = p.id WHERE p.user_id = %s AND r.source = 'mcp') AS e",
+ (user_id,),
+ ).fetchone()["e"]
+ return {
+ "has_pages": page_count > 0,
+ "has_token": token_count > 0,
+ "has_mcp": bool(mcp_used),
+ }
+
+
+def log_event(user_id, event, metadata=None):
+ try:
+ with get_db() as conn:
+ conn.execute(
+ "INSERT INTO user_events (user_id, event, metadata) VALUES (%s, %s, %s)",
+ (user_id, event, json.dumps(metadata) if metadata else None),
+ )
+ conn.commit()
+ except Exception:
+ pass
+
+
def get_api_tokens(user_id):
with get_db() as conn:
return conn.execute(
diff --git a/migrations/026_add_private_visibility.sql b/migrations/026_add_private_visibility.sql
new file mode 100644
index 0000000..0a3d9dc
--- /dev/null
+++ b/migrations/026_add_private_visibility.sql
@@ -0,0 +1,4 @@
+-- Re-add 'private' as a valid visibility state.
+-- Private pages are only visible to their owner.
+-- Lifecycle: private → unlisted → listed → pinned
+-- (Previously removed in migration 018.)
diff --git a/migrations/027_add_user_events.sql b/migrations/027_add_user_events.sql
new file mode 100644
index 0000000..f42be93
--- /dev/null
+++ b/migrations/027_add_user_events.sql
@@ -0,0 +1,10 @@
+CREATE TABLE IF NOT EXISTS user_events (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
+ event TEXT NOT NULL,
+ metadata JSONB,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS user_events_user_id_idx ON user_events (user_id);
+CREATE INDEX IF NOT EXISTS user_events_event_idx ON user_events (event, created_at DESC);
diff --git a/routes/__init__.py b/routes/__init__.py
index c697ae1..25cbfcd 100644
--- a/routes/__init__.py
+++ b/routes/__init__.py
@@ -43,7 +43,7 @@
},
}
-VISIBILITY_OPTIONS = ("unlisted", "listed", "pinned")
+VISIBILITY_OPTIONS = ("private", "unlisted", "listed", "pinned")
def _get_subdomain():
@@ -106,6 +106,15 @@ def can_edit(page_meta):
return is_creator(page_meta)
+def check_page_visibility(page_meta):
+ """Abort 404 if page is private and current user is not the owner."""
+ if not page_meta:
+ return
+ if page_meta.get("visibility") == "private":
+ if session.get("user_id") != page_meta["user_id"]:
+ abort(404)
+
+
def send_verification(email, purpose):
code = create_verification_code(email, purpose)
send_verification_email(email, code)
diff --git a/routes/admin.py b/routes/admin.py
index e13763e..03033ac 100644
--- a/routes/admin.py
+++ b/routes/admin.py
@@ -1,6 +1,6 @@
import re
-from flask import flash, redirect, render_template, request, session
+from flask import flash, jsonify, redirect, render_template, request, session
from db import (
check_username_available,
@@ -8,6 +8,9 @@
delete_api_token,
delete_user,
find_or_create_user,
+ get_or_create_api_token,
+ log_event,
+ set_user_username,
get_api_tokens,
get_pages_for_user,
get_user,
@@ -50,9 +53,6 @@ def signin():
"signin.html", error="Please enter a valid email address."
)
- if not user_exists(email):
- return render_template("signin.html", not_found=True)
-
send_verification(email, "signin")
return redirect("/signin/verify")
@@ -83,8 +83,10 @@ def signin_verify():
session["user_id"] = user_id
user = get_user(user_id)
if user and user.get("username"):
+ log_event(user_id, "signin")
return redirect(profile_url(user["username"]))
- return redirect("/")
+ log_event(user_id, "signup")
+ return redirect("/setup/username")
@bp.route("/signout", methods=["POST"])
@@ -93,6 +95,108 @@ def signout():
return redirect("/")
+@bp.route("/setup/username", methods=["GET", "POST"])
+def setup_username():
+ user_id = session.get("user_id")
+ if not user_id:
+ return redirect("/signin")
+ user = get_user(user_id)
+ if user and user.get("username"):
+ return redirect(profile_url(user["username"]))
+
+ if request.method == "GET":
+ return render_template("setup_username.html")
+
+ username = request.form.get("username", "").strip().lower()
+ if not username:
+ return render_template("setup_username.html", error="Username is required.")
+ if not valid_username(username):
+ return render_template(
+ "setup_username.html",
+ error="Username must be lowercase letters, numbers, and hyphens only.",
+ username=username,
+ )
+ if username in RESERVED_USERNAMES:
+ return render_template(
+ "setup_username.html",
+ error="That username is reserved.",
+ username=username,
+ )
+ if not check_username_available(username):
+ return render_template(
+ "setup_username.html",
+ error="That username is already taken.",
+ username=username,
+ )
+
+ set_user_username(user_id, username)
+ update_user_settings(user_id, name="", username=username, bio="", license=None)
+ return redirect("/setup/profile")
+
+
+@bp.route("/setup/profile", methods=["GET", "POST"])
+def setup_profile():
+ user_id = session.get("user_id")
+ if not user_id:
+ return redirect("/signin")
+ user = get_user(user_id)
+ if not user or not user.get("username"):
+ return redirect("/setup/username")
+
+ if request.method == "GET":
+ return render_template("setup_profile.html", username=user["username"])
+
+ name = request.form.get("name", "").strip()
+ if name:
+ update_user_settings(
+ user_id, name=name, username=user["username"], bio="", license=None
+ )
+
+ file = request.files.get("avatar")
+ if file and file.filename:
+ error = validate_image(file)
+ if not error:
+ ext = ALLOWED_IMAGE_TYPES[file.content_type]
+ fmt = file.content_type.split("/")[-1].upper()
+ if fmt == "JPG":
+ fmt = "JPEG"
+ cropped = crop_square(file, fmt)
+ key = f"{user_id}/avatar.{ext}"
+ url = upload_image(key, cropped, file.content_type)
+ update_user_avatar(user_id, url)
+
+ return redirect(profile_url(user["username"]))
+
+
+@bp.route("/setup/mcp-config", methods=["POST"])
+def setup_mcp_config():
+ user_id = session.get("user_id")
+ if not user_id:
+ return jsonify({"error": "Unauthorized"}), 401
+ user = get_user(user_id)
+ if not user:
+ return jsonify({"error": "Unauthorized"}), 401
+
+ token, token_id = get_or_create_api_token(user_id, "mcp-default")
+ if token is None:
+ # Token already exists but we can't show the stored value.
+ # Rotate: delete old, create new.
+ delete_api_token(token_id, user_id)
+ token, _ = create_api_token(user_id, "mcp-default")
+
+ base_url = request.url_root.rstrip("/")
+ prompt = (
+ f"You are connected to my Jottit, a place where ideas become "
+ f"beautiful pages on the web. When I share an insight worth keeping, "
+ f"capture it as a page. "
+ f"Fetch {base_url}/api/v1/agent-setup with the header "
+ f"'Authorization: Bearer {token}' for API details and my conventions. "
+ f"Then list my pages to understand what's already there."
+ )
+ log_event(user_id, "token_generated")
+ return jsonify({"config_text": prompt})
+
+
@bp.route("/pages")
def user_pages():
from routes.public import _build_page_item, sidebar_vars, compute_initials
@@ -362,6 +466,15 @@ def settings_delete():
return redirect("/")
+@bp.route("/settings/mcp")
+def settings_mcp():
+ user_id, user = require_user()
+ if not user:
+ return redirect("/signin")
+ base_url = request.url_root.rstrip("/")
+ return render_template("settings_mcp.html", user=user, base_url=base_url)
+
+
@bp.route("/settings/tokens", methods=["GET", "POST"])
@limiter.limit("5 per 5 minutes", methods=["POST"])
def settings_tokens():
diff --git a/routes/api.py b/routes/api.py
index d5b12d7..9683133 100644
--- a/routes/api.py
+++ b/routes/api.py
@@ -59,6 +59,89 @@ def _serialize_page(meta, page_data):
}
+@api_bp.route("/agent-setup")
+def agent_setup():
+ user = _require_auth()
+ if not user:
+ return _error(
+ "Unauthorized — include your API token as: Authorization: Bearer YOUR_TOKEN",
+ 401,
+ )
+ base_url = request.url_root.rstrip("/")
+ username = user.get("username", "")
+ pages = get_pages_for_user(user["id"])
+ return jsonify(
+ {
+ "welcome": f"You are connected to Jottit as @{username}.",
+ "philosophy": (
+ "Jottit turns markdown into beautiful, shareable web pages. "
+ "When someone shares an insight worth keeping, your job is to "
+ "capture it as a well-structured page. Think of yourself as a "
+ "writing partner, not an API client."
+ ),
+ "workflow": {
+ "when_to_create": (
+ "When the user shares an insight, idea, or piece of thinking "
+ "that deserves to exist as its own page. Don't create pages "
+ "for trivial exchanges."
+ ),
+ "when_to_update": (
+ "When the user refines or adds to an existing topic. Check "
+ "list_pages first to avoid duplicates."
+ ),
+ "default_visibility": "private",
+ "titling": (
+ "Every page starts with a markdown H1 (# Title). Use clear, "
+ "descriptive titles. The title becomes the page's identity."
+ ),
+ "content_style": (
+ "Write in clean markdown. The output will be rendered with "
+ "beautiful typography. No need for HTML."
+ ),
+ },
+ "conventions": build_conventions(pages),
+ "about": "Jottit is a markdown publishing tool. Pages are written in markdown and get a beautiful URL.",
+ "api_base": f"{base_url}/api/v1",
+ "your_profile": f"{base_url}/@{username}",
+ "endpoints": {
+ "list_pages": {
+ "method": "GET",
+ "path": "/pages",
+ "description": "List all your pages. Returns slug, title, visibility, and updated_at.",
+ },
+ "create_page": {
+ "method": "POST",
+ "path": "/pages",
+ "description": "Create a new page.",
+ "body": {
+ "content": "# Title\\n\\nMarkdown content here.",
+ "slug": "(optional)",
+ "visibility": "(optional: private, unlisted, listed, pinned)",
+ },
+ },
+ "get_page": {
+ "method": "GET",
+ "path": "/pages/{slug}",
+ "description": "Get a page by slug. Returns title, content (markdown), visibility, and updated_at.",
+ },
+ "update_page": {
+ "method": "PUT",
+ "path": "/pages/{slug}",
+ "description": "Update a page. Send content and/or visibility.",
+ "body": {
+ "content": "(optional) full markdown",
+ "visibility": "(optional)",
+ },
+ },
+ },
+ "auth": "Include your token in every request: Authorization: Bearer YOUR_TOKEN",
+ "content_format": "All content is markdown. Start with '# Title' on the first line.",
+ "default_visibility": "New pages are private by default. Change to 'listed' to show on your profile.",
+ "try_it": f"To verify this works, call GET {base_url}/api/v1/pages to list your pages.",
+ }
+ )
+
+
@api_bp.route("/user")
def get_current_user():
user = _require_auth()
@@ -155,7 +238,7 @@ def create_page():
slug = slugify(data.get("slug", "")) or generate_slug()
if user:
- visibility = data.get("visibility", "unlisted")
+ visibility = data.get("visibility", "private")
if visibility not in VISIBILITY_OPTIONS:
return _error(
f"Visibility must be one of: {', '.join(VISIBILITY_OPTIONS)}", 400
diff --git a/routes/mcp.py b/routes/mcp.py
index 93f38e2..ba72947 100644
--- a/routes/mcp.py
+++ b/routes/mcp.py
@@ -59,7 +59,7 @@
},
"visibility": {
"type": "string",
- "enum": ["unlisted", "listed", "pinned"],
+ "enum": ["private", "unlisted", "listed", "pinned"],
"default": "unlisted",
},
},
@@ -76,7 +76,7 @@
"content": {"type": "string", "description": "Markdown content"},
"visibility": {
"type": "string",
- "enum": ["unlisted", "listed", "pinned"],
+ "enum": ["private", "unlisted", "listed", "pinned"],
},
},
"required": ["slug"],
diff --git a/routes/public.py b/routes/public.py
index 25daee2..6017c7f 100644
--- a/routes/public.py
+++ b/routes/public.py
@@ -1,6 +1,5 @@
import json
import os
-from collections import Counter
from email.utils import format_datetime
from xml.sax.saxutils import escape as xml_escape
@@ -26,6 +25,7 @@
get_page_meta,
get_pages_for_user,
get_public_pages,
+ get_setup_checklist,
get_revision,
get_revision_count,
get_revisions_paginated,
@@ -51,6 +51,7 @@
LICENSES,
_set_profile_user,
account_link_vars,
+ check_page_visibility,
compute_initials,
find_page,
is_creator,
@@ -71,40 +72,9 @@ def install_cli():
return send_from_directory("static", "install-cli.sh", mimetype="text/plain")
-_VISIBILITY_TABS = ("all", "unlisted", "listed", "pinned")
-
-
@bp.route("/")
def home():
- if "user_id" not in session:
- return render_template("home.html", **account_link_vars())
-
- pages = get_pages_for_user(session["user_id"])
- all_items = [_build_page_item(p) for p in pages if not is_special_slug(p["slug"])]
-
- counts = Counter(i["visibility"] for i in all_items)
- counts["all"] = len(all_items)
-
- tab = request.args.get("tab", "all")
- if tab not in _VISIBILITY_TABS:
- tab = "all"
-
- if tab == "all":
- page_list = all_items
- else:
- page_list = [i for i in all_items if i["visibility"] == tab]
-
- user = g.current_user
- display_name = (user.get("name") or user.get("username")) if user else None
-
- return render_template(
- "home.html",
- pages=page_list,
- tab=tab,
- counts=counts,
- display_name=display_name,
- **account_link_vars(),
- )
+ return render_template("home.html", **account_link_vars())
def _build_page_item(p):
@@ -121,6 +91,8 @@ def _build_page_item(p):
def sidebar_vars(user_id, username, active_slug=None, is_owner=False):
+ from db import get_api_tokens
+
all_pages = get_pages_for_user(user_id)
pages = [p for p in all_pages if not is_special_slug(p["slug"])]
special = [
@@ -142,6 +114,7 @@ def sidebar_vars(user_id, username, active_slug=None, is_owner=False):
recent.append({"slug": p["slug"], "title": title or "Untitled"})
if len(recent) >= 5:
break
+ has_token = bool(get_api_tokens(user_id)) if is_owner else False
return {
"sidebar_pinned": pinned,
"sidebar_recent": recent,
@@ -149,17 +122,30 @@ def sidebar_vars(user_id, username, active_slug=None, is_owner=False):
"sidebar_total": len(pages),
"sidebar_active_slug": active_slug,
"sidebar_username": username,
+ "sidebar_has_token": has_token,
}
def subdomain_home(user):
pages = get_pages_for_user(user["id"])
+ is_owner = session.get("user_id") == user["id"]
+
+ # INDEX.md: if user has a page with slug "index", render it on the profile
+ index_html = None
+ for p in pages:
+ if p["slug"] == "index":
+ # Owner sees their index regardless of visibility;
+ # visitors only see it if listed or unlisted
+ if is_owner or p["visibility"] in ("listed", "unlisted", "pinned"):
+ index_html = render_markdown(p["content"])
+ break
+
pinned = []
listed = []
for p in pages:
- if is_special_slug(p["slug"]):
+ if is_special_slug(p["slug"]) or p["slug"] == "index":
continue
- if p["visibility"] not in ("listed", "pinned"):
+ if not is_owner and p["visibility"] not in ("listed", "pinned"):
continue
item = _build_page_item(p)
if p["visibility"] == "pinned":
@@ -169,7 +155,6 @@ def subdomain_home(user):
page_list = pinned + listed
site_title = user.get("name") or user.get("username")
- is_owner = session.get("user_id") == user["id"]
owner_initials = None
owner_avatar_url = None
profile_incomplete = False
@@ -178,12 +163,19 @@ def subdomain_home(user):
owner_avatar_url = user.get("avatar")
if not user.get("avatar") and not user.get("bio"):
profile_incomplete = True
+ checklist = (
+ get_setup_checklist(user["id"])
+ if is_owner and not pages and not index_html
+ else None
+ )
bio = user.get("bio")
bio_html = render_bio(bio, g.url_prefix) if bio else ""
return render_template(
"profile.html",
user=user,
pages=page_list,
+ index_html=index_html,
+ checklist=checklist,
site_title=site_title,
site_username=user.get("username", ""),
is_owner=is_owner,
@@ -356,6 +348,8 @@ def _resolve_page(slug, suffix=""):
if not row:
abort(404)
+ check_page_visibility(page_meta)
+
return page_meta, row
@@ -468,7 +462,9 @@ def view_page(
revision_count = get_revision_count(page_meta["id"])
- comments_enabled = row.get("comments_enabled", True)
+ comments_enabled = (
+ row.get("comments_enabled", True) and page_meta.get("visibility") != "private"
+ )
comments = []
reply_to = None
if comments_enabled:
@@ -546,6 +542,7 @@ def page_history(slug):
page_meta = find_page(slug)
if not page_meta:
abort(404)
+ check_page_visibility(page_meta)
total = get_revision_count(page_meta["id"])
if total == 0:
@@ -590,6 +587,7 @@ def view_revision(slug, revision):
page_meta = find_page(slug)
if not page_meta:
abort(404)
+ check_page_visibility(page_meta)
row = get_revision(page_meta["id"], revision)
if not row:
diff --git a/routes/site.py b/routes/site.py
index e538014..2462189 100644
--- a/routes/site.py
+++ b/routes/site.py
@@ -25,6 +25,7 @@
delete_comment,
delete_page,
find_or_create_user,
+ log_event,
find_page_owner_for_redirect,
get_comment,
get_export_pages,
@@ -166,8 +167,10 @@ def new_page():
if is_special_slug(slug):
visibility = "unlisted"
+ elif owner_id or session.get("user_id"):
+ visibility = "private"
else:
- visibility = "listed" if owner_id else "unlisted"
+ visibility = "listed"
slug = save_page(slug, content, visibility, subdomain_user_id)
new_page_meta = get_page_meta(slug, subdomain_user_id)
@@ -254,14 +257,20 @@ def edit_page(slug):
is_new = page_meta is None
subdomain_user_id = subdomain_user["id"] if subdomain_user else None
- # Preserve existing visibility on edit; default to "listed" for new pages
- # Special slugs are always unlisted
+ # Preserve existing visibility on edit; default to "private" for signed-in
+ # users, "listed" for anonymous. Special slugs are always unlisted.
if is_special_slug(slug):
visibility = "unlisted"
+ elif page_meta:
+ visibility = page_meta["visibility"]
+ elif session.get("user_id") or subdomain_user:
+ visibility = "private"
else:
- visibility = page_meta["visibility"] if page_meta else "listed"
+ visibility = "listed"
slug = save_page(slug, content, visibility, subdomain_user_id)
+ if is_new and session.get("user_id"):
+ log_event(session["user_id"], "page_create", {"slug": slug})
if is_new:
new_page_meta = get_page_meta(slug, subdomain_user_id)
if new_page_meta and session.get("user_id") and not subdomain_user_id:
diff --git a/schema.sql b/schema.sql
index 650e7af..d4bd08b 100644
--- a/schema.sql
+++ b/schema.sql
@@ -94,6 +94,14 @@ CREATE TABLE IF NOT EXISTS slug_redirects (
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
+CREATE TABLE IF NOT EXISTS user_events (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
+ event TEXT NOT NULL,
+ metadata JSONB,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
CREATE TABLE IF NOT EXISTS schema_migrations (
filename TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
@@ -119,6 +127,8 @@ CREATE UNIQUE INDEX IF NOT EXISTS slug_redirects_lookup_unclaimed ON slug_redire
CREATE INDEX IF NOT EXISTS comments_page_id_idx ON comments (page_id);
CREATE INDEX IF NOT EXISTS comments_ip_created_idx ON comments (ip_hash, created_at);
CREATE INDEX IF NOT EXISTS comments_user_id_idx ON comments (user_id);
+CREATE INDEX IF NOT EXISTS user_events_user_id_idx ON user_events (user_id);
+CREATE INDEX IF NOT EXISTS user_events_event_idx ON user_events (event, created_at DESC);
-- On fresh DBs (no sites table), seed historical migrations as already applied
DO $$ BEGIN
@@ -147,7 +157,9 @@ DO $$ BEGIN
('021_add_comments.sql'),
('022_add_comment_user_id.sql'),
('024_drop_comment_author_fields.sql'),
- ('025_drop_comment_is_pinned.sql')
+ ('025_drop_comment_is_pinned.sql'),
+ ('026_add_private_visibility.sql'),
+ ('027_add_user_events.sql')
ON CONFLICT DO NOTHING;
END IF;
END $$;
diff --git a/static/setup-mcp.js b/static/setup-mcp.js
new file mode 100644
index 0000000..1616121
--- /dev/null
+++ b/static/setup-mcp.js
@@ -0,0 +1,24 @@
+document.addEventListener('DOMContentLoaded', function() {
+ var btn = document.getElementById('mcp-config-btn');
+ var output = document.getElementById('mcp-config-output');
+ var text = document.getElementById('mcp-config-text');
+ var copyBtn = document.getElementById('mcp-copy-btn');
+ if (!btn) return;
+ var csrf = btn.getAttribute('data-csrf');
+ btn.addEventListener('click', function() {
+ btn.textContent = 'Loading...';
+ fetch('/setup/mcp-config', {method: 'POST', headers: {'X-CSRFToken': csrf}})
+ .then(function(r) { return r.json(); })
+ .then(function(data) {
+ text.textContent = data.config_text;
+ output.classList.remove('setup-hidden');
+ btn.style.display = 'none';
+ });
+ });
+ copyBtn.addEventListener('click', function() {
+ navigator.clipboard.writeText(text.textContent).then(function() {
+ copyBtn.textContent = 'Copied!';
+ setTimeout(function() { copyBtn.textContent = 'Copy prompt'; }, 2000);
+ });
+ });
+});
diff --git a/static/setup-profile.js b/static/setup-profile.js
new file mode 100644
index 0000000..80870fe
--- /dev/null
+++ b/static/setup-profile.js
@@ -0,0 +1,19 @@
+document.addEventListener('DOMContentLoaded', function() {
+ var input = document.getElementById('avatar-file');
+ var preview = document.getElementById('avatar-preview');
+ if (!input || !preview) return;
+
+ preview.addEventListener('click', function() {
+ input.click();
+ });
+
+ input.addEventListener('change', function() {
+ if (!input.files || !input.files[0]) return;
+ var url = URL.createObjectURL(input.files[0]);
+ var img = document.createElement('img');
+ img.src = url;
+ img.className = 'setup-avatar-img';
+ preview.textContent = '';
+ preview.appendChild(img);
+ });
+});
diff --git a/static/theme.css b/static/theme.css
index 733dece..4c25a0d 100644
--- a/static/theme.css
+++ b/static/theme.css
@@ -363,6 +363,12 @@
color: var(--site-color-muted);
}
+.page-listing-meta {
+ display: flex;
+ align-items: center;
+ gap: var(--space-1);
+}
+
.page-listing-date {
font-size: var(--font-size-xs);
font-family: var(--site-font-heading);
diff --git a/static/ui.css b/static/ui.css
index 978e22d..91e7c5a 100644
--- a/static/ui.css
+++ b/static/ui.css
@@ -139,16 +139,28 @@
flex-direction: column;
}
+.home-hero-wrapper {
+ min-height: auto;
+ padding-top: 200px;
+ padding-bottom: var(--space-12);
+}
+
.home-main {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
- gap: var(--space-1);
- max-width: var(--content-max-width);
+ gap: var(--space-2);
+ max-width: 680px;
margin: 0 auto;
padding: 0 var(--space-5);
+ padding-bottom: 10vh;
+ text-align: center;
+}
+
+.home-hero-wrapper .home-main {
+ padding-bottom: 0;
}
.logo {
@@ -171,11 +183,531 @@
margin-bottom: var(--space-3);
}
+.home-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--space-5) var(--space-5);
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+}
+
+@media (min-width: 640px) {
+ .home-header {
+ padding: var(--space-5) var(--space-10);
+ }
+}
+
+.home-header-logo {
+ font-size: 1.5rem;
+ font-weight: 800;
+ text-decoration: none;
+ letter-spacing: -0.02em;
+}
+
+.home-header .signin-link {
+ position: static;
+}
+
+.home-header-avatar-link {
+ display: flex;
+ align-items: center;
+}
+
+.home-header-avatar {
+ width: 35px;
+ height: 35px;
+ border-radius: 50%;
+ object-fit: cover;
+}
+
+.home-header-initials {
+ width: 35px;
+ height: 35px;
+ border-radius: 50%;
+ background: var(--subtle);
+ color: var(--text-secondary);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: var(--font-size-xs);
+ font-weight: 600;
+}
+
+.home-headline {
+ font-size: clamp(2.2rem, 5vw, 3.2rem);
+ font-weight: 800;
+ letter-spacing: -0.03em;
+ line-height: 1.15;
+ color: var(--text-primary);
+ margin: 0 0 var(--space-2) 0;
+}
+
+.home-subtitle {
+ font-size: var(--font-size-lg);
+ color: var(--text-secondary);
+ line-height: 1.6;
+ max-width: 480px;
+ margin: 0 auto var(--space-5) auto;
+}
+
+.home-signup-form {
+ display: flex;
+ gap: 0;
+ align-items: stretch;
+ justify-content: center;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-pill);
+ overflow: hidden;
+ background: var(--bg-primary);
+ max-width: 440px;
+ width: 100%;
+}
+
+.home-email-input {
+ flex: 1;
+ padding: 14px 16px;
+ font-size: var(--font-size-base);
+ border: none;
+ background: transparent;
+ color: var(--text-primary);
+ outline: none;
+ min-width: 0;
+}
+
+.home-email-input::placeholder {
+ color: var(--muted);
+}
+
+.home-signup-form .btn {
+ border-radius: 0;
+ white-space: nowrap;
+ padding: 14px 20px;
+ font-weight: 600;
+ border: none;
+ border-left: 1px solid var(--border);
+ background: var(--brand-1);
+ color: #fff;
+}
+
+.home-signup-form .btn:hover {
+ opacity: 0.9;
+}
+
+.home-create-link {
+ font-size: var(--font-size-sm);
+ color: var(--muted);
+ margin-top: var(--space-3);
+}
+
+.home-create-link:hover {
+ color: var(--text-secondary);
+}
+
.home-hint {
font-size: var(--font-size-xs);
color: var(--muted);
}
+/* Landing page below-fold sections */
+
+.landing-section {
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 80px var(--space-5);
+}
+
+.landing-label {
+ display: block;
+ font-size: var(--font-size-xs);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--brand-1);
+ font-weight: 600;
+ margin-bottom: var(--space-3);
+}
+
+.landing-heading {
+ font-size: 1.625rem;
+ font-weight: 700;
+ letter-spacing: -0.015em;
+ line-height: 1.25;
+ color: var(--text);
+ margin-bottom: var(--space-8);
+}
+
+.landing-divider {
+ border: none;
+ border-top: 1px solid var(--border);
+ max-width: 800px;
+ margin: 0 auto;
+}
+
+/* Section 1: How it works */
+
+.landing-steps {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: var(--space-10);
+}
+
+.landing-step {
+ text-align: center;
+}
+
+.landing-step-number {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ background: var(--bg-raised);
+ color: var(--brand-1);
+ font-weight: 700;
+ font-size: var(--font-size-sm);
+ margin-bottom: var(--space-3);
+}
+
+.landing-step-title {
+ font-size: var(--font-size-md);
+ font-weight: 600;
+ color: var(--text);
+ margin-bottom: var(--space-2);
+}
+
+.landing-step-body {
+ font-size: var(--font-size-sm);
+ color: var(--text-secondary);
+ line-height: 1.5;
+}
+
+@media (max-width: 640px) {
+ .landing-steps {
+ grid-template-columns: 1fr;
+ gap: var(--space-6);
+ }
+}
+
+/* Section 2: Agent angle */
+
+.landing-agent-card {
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: var(--space-8);
+}
+
+.landing-agent-intro {
+ font-size: var(--font-size-base);
+ color: var(--text-secondary);
+ margin-bottom: var(--space-5);
+}
+
+.landing-code {
+ background: var(--code-bg);
+ padding: var(--space-5);
+ border-radius: var(--radius-sm);
+ font-family: var(--font-mono);
+ font-size: var(--font-size-sm);
+ line-height: 1.7;
+ overflow-x: auto;
+}
+
+.landing-code code {
+ background: none;
+}
+
+.landing-code-comment {
+ color: var(--muted);
+}
+
+.landing-code-string {
+ color: var(--brand-1);
+}
+
+/* Bottom CTA */
+
+.landing-bottom-cta {
+ text-align: center;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+.landing-cta-heading {
+ font-size: var(--font-size-xl);
+ font-weight: 700;
+ color: var(--text);
+ margin-bottom: var(--space-5);
+}
+
+.home-greeting {
+ font-size: var(--font-size-xl);
+ font-weight: 700;
+ margin-bottom: var(--space-3);
+}
+
+.home-tabs {
+ display: flex;
+ gap: var(--space-2);
+ margin-bottom: var(--space-4);
+ flex-wrap: wrap;
+}
+
+.home-tab {
+ font-size: var(--font-size-sm);
+ color: var(--text-secondary);
+ padding: var(--space-1) var(--space-2);
+ border-radius: var(--radius);
+ text-decoration: none;
+}
+
+.home-tab:hover {
+ background: var(--bg-secondary);
+}
+
+.home-tab-active {
+ color: var(--text-primary);
+ background: var(--bg-secondary);
+ font-weight: 600;
+}
+
+.home-pages {
+ width: 100%;
+ max-width: 600px;
+}
+
+.home-empty {
+ color: var(--text-secondary);
+}
+
+.vis-badge {
+ font-size: var(--font-size-xs);
+ padding: 1px 6px;
+ border-radius: 3px;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+}
+
+.vis-private {
+ background: var(--bg-secondary);
+ color: var(--text-secondary);
+}
+
+.vis-unlisted {
+ background: color-mix(in srgb, var(--brand-1) 10%, transparent);
+ color: var(--brand-1);
+}
+
+.vis-pinned {
+ background: color-mix(in srgb, var(--brand-2) 10%, transparent);
+ color: var(--brand-2);
+}
+
+.setup-quickstart {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+}
+
+.setup-section {
+ padding: var(--space-4) 0;
+ border-bottom: 1px solid var(--border);
+}
+
+.setup-section:last-child {
+ border-bottom: none;
+}
+
+.setup-section-highlight {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: var(--space-4);
+ margin-bottom: var(--space-2);
+}
+
+.setup-welcome {
+ font-size: var(--font-size-xl);
+ font-weight: 700;
+ margin: 0 0 var(--space-2) 0;
+}
+
+.setup-section-title {
+ font-size: var(--font-size-base);
+ font-weight: 600;
+ margin: 0 0 var(--space-1) 0;
+}
+
+.setup-section-desc {
+ font-size: var(--font-size-sm);
+ color: var(--text-secondary);
+ margin: 0;
+}
+
+.setup-section-desc a {
+ font-weight: 600;
+}
+
+.setup-hidden {
+ display: none !important;
+}
+
+.setup-code {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: var(--space-3);
+ margin-top: var(--space-2);
+ position: relative;
+ overflow-x: auto;
+}
+
+.setup-code pre {
+ margin: 0;
+ font-size: var(--font-size-sm);
+ line-height: 1.6;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.setup-code-copy {
+ position: absolute;
+ top: var(--space-2);
+ right: var(--space-2);
+ background: var(--bg-primary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 2px 8px;
+ cursor: pointer;
+ font-size: var(--font-size-sm);
+ color: var(--text-secondary);
+ line-height: 1;
+}
+
+.setup-code-copy:hover {
+ color: var(--text-primary);
+}
+
+.setup-profile-card {
+ display: flex;
+ align-items: center;
+ gap: var(--space-4);
+ padding: var(--space-5);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: var(--bg-raised);
+ width: 100%;
+ max-width: 420px;
+ margin-bottom: var(--space-4);
+}
+
+.setup-avatar-upload {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-2);
+ flex-shrink: 0;
+}
+
+.setup-avatar-preview {
+ width: 72px;
+ height: 72px;
+ border-radius: 50%;
+ background: var(--bg-surface);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+ cursor: pointer;
+}
+
+.setup-avatar-initials {
+ font-size: var(--font-size-xl);
+ font-weight: 700;
+ color: var(--muted);
+}
+
+.setup-avatar-img {
+ width: 72px;
+ height: 72px;
+ object-fit: cover;
+ display: block;
+ border-radius: 50%;
+}
+
+.setup-avatar-label {
+ font-size: var(--font-size-xs);
+ color: var(--brand-1);
+ cursor: pointer;
+ font-weight: 600;
+}
+
+.setup-avatar-label:hover {
+ text-decoration: underline;
+}
+
+.setup-profile-fields {
+ flex: 1;
+ min-width: 0;
+}
+
+.setup-name-input {
+ width: 100%;
+ padding: var(--space-2);
+ font-size: var(--font-size-lg);
+ font-weight: 600;
+ border: none;
+ border-bottom: 1px solid var(--border);
+ background: transparent;
+ color: var(--text-primary);
+ outline: none;
+}
+
+.setup-name-input::placeholder {
+ color: var(--muted);
+ font-weight: 400;
+}
+
+.setup-name-input:focus {
+ border-color: var(--brand-1);
+}
+
+.setup-username-display {
+ font-size: var(--font-size-sm);
+ color: var(--muted);
+ margin-top: var(--space-1);
+ padding-left: var(--space-2);
+ text-align: left;
+}
+
+.setup-profile-actions {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+.setup-skip-link {
+ font-size: var(--font-size-sm);
+ color: var(--muted);
+}
+
+.setup-skip-link:hover {
+ color: var(--text-secondary);
+}
+
+.btn-sm {
+ font-size: var(--font-size-sm);
+ padding: 6px 14px;
+ margin-top: var(--space-2);
+}
+
.user-header {
display: flex;
align-items: center;
@@ -673,6 +1205,17 @@
margin: 0;
}
+.action-link {
+ font-size: var(--font-size-sm);
+ font-weight: 600;
+ color: var(--brand-1);
+}
+
+.action-link:hover {
+ text-decoration: underline;
+}
+
+
.profile-header--avatar .profile-bio,
.profile-header--full .profile-bio {
margin: var(--space-1) 0 0;
@@ -832,6 +1375,7 @@
}
.site-header {
+ background: var(--bg);
border-bottom: 1px solid var(--border);
}
@@ -1079,7 +1623,7 @@
font-size: var(--font-size-sm);
color: var(--text-secondary);
text-decoration: none;
- padding: 5px 0;
+ padding: 5px var(--space-2);
border-radius: var(--radius-sm);
overflow: hidden;
min-width: 0;
@@ -1102,9 +1646,24 @@
background: var(--subtle);
}
-.sidebar-link--special {
+.sidebar-meta {
+ padding: var(--space-3) 0;
+ border-top: 1px solid var(--border);
+ margin-top: var(--space-4);
+}
+
+.sidebar-meta-link {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
font-family: var(--font-mono);
- font-size: var(--font-size-sm);
+ font-size: var(--font-size-xs);
+ color: var(--muted);
+ padding: 3px 0;
+}
+
+.sidebar-meta-link:hover {
+ color: var(--text-secondary);
}
.sidebar-create {
@@ -1120,7 +1679,7 @@
/* Profile header */
.profile-header {
- padding-top: var(--space-8);
+ padding-top: var(--space-10);
margin-bottom: var(--space-8);
}
@@ -1194,6 +1753,68 @@
color: var(--text);
}
+.settings-agent-section {
+ padding: var(--space-4) var(--space-5);
+}
+
+.settings-agent-section p {
+ font-size: var(--font-size-sm);
+ color: var(--text-secondary);
+ margin: 0 0 var(--space-3) 0;
+}
+
+.settings-agent-section h3 {
+ font-size: var(--font-size-base);
+ font-weight: 600;
+ margin: 0 0 var(--space-1) 0;
+}
+
+.settings-agent-divider {
+ border-top: 1px solid var(--border);
+}
+
+.settings-agent-footer p {
+ margin: 0;
+ color: var(--muted);
+}
+
+.setup-prompt {
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: var(--space-4);
+ margin-bottom: var(--space-3);
+}
+
+.setup-prompt p {
+ margin: 0 0 var(--space-3) 0;
+ font-size: var(--font-size-sm);
+ line-height: 1.6;
+ color: var(--text-primary);
+}
+
+.setup-prompt-copy {
+ font-size: var(--font-size-sm);
+ font-weight: 600;
+ padding: 6px 16px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-pill);
+ background: var(--bg-primary);
+ color: var(--text-secondary);
+ cursor: pointer;
+}
+
+.setup-prompt-copy:hover {
+ color: var(--text-primary);
+ border-color: var(--text-secondary);
+}
+
+.sidebar-empty {
+ font-size: var(--font-size-sm);
+ color: var(--muted);
+ padding: 0 var(--space-2);
+}
+
@media (max-width: 639px) {
.layout-with-sidebar {
grid-template-columns: 1fr;
@@ -1408,6 +2029,16 @@
gap: var(--space-3);
}
+.verify-hint {
+ font-size: var(--font-size-sm);
+ color: var(--muted);
+ margin-top: var(--space-4);
+}
+
+.verify-hint a {
+ color: var(--text-secondary);
+}
+
.shell-centered .auth-form {
flex-direction: column;
align-items: center;
diff --git a/templates/_sidebar.html b/templates/_sidebar.html
index b21377f..98a4bf2 100644
--- a/templates/_sidebar.html
+++ b/templates/_sidebar.html
@@ -12,6 +12,7 @@
{% endif %}
{% if sidebar_special %}
+
+ {% endif %}
+ {% if not sidebar_has_token %}
{% endif %}
{% endif %}
diff --git a/templates/home.html b/templates/home.html
index 8ae64a0..174acbe 100644
--- a/templates/home.html
+++ b/templates/home.html
@@ -1,18 +1,74 @@
{% extends "base.html" %} {% block body %}
-
- {% include "_account_link.html" %}
-
- Jottit
- Put your ideas on the web
- Create a page
- {% if not signed_in %}
- no sign-up required
+
+
+
+
+ How it works
+ Write. Publish. Share.
+
+
+
1
+
Write in markdown
+
A clean split editor. Your markdown on the left, live preview on the right.
+
+
+
2
+
Hit publish
+
One click. Your page is live at jottit.org/your-slug with beautiful typography.
+
+
+
3
+
Share the URL
+
That's it. No setup, no config. Just a URL that looks great on any device.
+
+
+
+
+
+
+
+ For AI agents
+ Your agent can write too
+
+
Connect Claude, ChatGPT, or any MCP-compatible agent. They create and edit pages directly.
+
# In Claude Desktop, add Jottit as an MCP server
+# Then just ask:
+
+"Save my insights about distributed systems to Jottit"
+
+# Your agent creates a page, you refine it, share when ready.
+
+
+
+
{% include "_footer.html" %}
{% endblock %}
-{% block head %}
-
-{% endblock %}
diff --git a/templates/page.html b/templates/page.html
index 4cc92aa..9007f12 100644
--- a/templates/page.html
+++ b/templates/page.html
@@ -39,6 +39,7 @@
+ Private
Unlisted
Listed
Pinned
diff --git a/templates/profile.html b/templates/profile.html
index 7c181f2..dc1f3bf 100644
--- a/templates/profile.html
+++ b/templates/profile.html
@@ -9,6 +9,7 @@
{% block body %}
{% include "_site_header.html" %}
+{% if not checklist %}
-
{% endblock %}
diff --git a/tests/test_api.py b/tests/test_api.py
index 9b43e53..4a67e20 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -62,6 +62,62 @@ def test_get_user_profile_not_found(client):
assert r.status_code == 404
+def test_agent_setup(client):
+ _, token = _setup_user_with_token()
+ r = client.get("/api/v1/agent-setup", headers=_auth(token))
+ assert r.status_code == 200
+ data = r.get_json()
+ assert "welcome" in data
+ assert "endpoints" in data
+ assert "create_page" in data["endpoints"]
+ assert "@" in data["welcome"]
+
+
+def test_agent_setup_includes_philosophy_and_workflow(client):
+ _, token = _setup_user_with_token()
+ r = client.get("/api/v1/agent-setup", headers=_auth(token))
+ data = r.get_json()
+ assert "philosophy" in data
+ assert "workflow" in data
+ assert "when_to_create" in data["workflow"]
+ assert "when_to_update" in data["workflow"]
+ assert data["workflow"]["default_visibility"] == "private"
+ assert "conventions" in data
+
+
+def test_agent_setup_conventions_include_agents_content(client):
+ user_id, token = _setup_user_with_token()
+ save_page("AGENTS", "# Agents\n\nBe concise.", "unlisted", user_id)
+ r = client.get("/api/v1/agent-setup", headers=_auth(token))
+ data = r.get_json()
+ agents = data["conventions"]["special_pages"]["AGENTS"]
+ assert agents["content"] == "# Agents\n\nBe concise."
+
+
+def test_agent_setup_conventions_empty_without_agents(client):
+ _, token = _setup_user_with_token()
+ r = client.get("/api/v1/agent-setup", headers=_auth(token))
+ data = r.get_json()
+ assert "AGENTS" in data["conventions"]["special_pages"]
+ assert "content" not in data["conventions"]["special_pages"]["AGENTS"]
+
+
+def test_agent_setup_visibility_states_correct(client):
+ _, token = _setup_user_with_token()
+ r = client.get("/api/v1/agent-setup", headers=_auth(token))
+ data = r.get_json()
+ body = data["endpoints"]["create_page"]["body"]
+ assert "private" in body["visibility"]
+ assert "unlisted" in body["visibility"]
+ assert "listed" in body["visibility"]
+ assert "pinned" in body["visibility"]
+
+
+def test_agent_setup_requires_auth(client):
+ r = client.get("/api/v1/agent-setup")
+ assert r.status_code == 401
+
+
def test_create_page(client):
_, token = _setup_user_with_token()
r = client.post(
@@ -73,7 +129,7 @@ def test_create_page(client):
data = r.get_json()
assert data["title"] == "My Page"
assert data["content"] == "# My Page\n\nHello world"
- assert data["visibility"] == "unlisted"
+ assert data["visibility"] == "private"
def test_create_page_with_custom_slug(client):
diff --git a/tests/test_auth.py b/tests/test_auth.py
index e27f980..a05369a 100644
--- a/tests/test_auth.py
+++ b/tests/test_auth.py
@@ -390,12 +390,10 @@ def test_signin_page(client):
# Sign-in with unknown email is rejected
-def test_signin_rejects_unknown_email(client):
+def test_signin_accepts_unknown_email_for_signup(client):
r = client.post("/signin", data={"email": "nobody@example.com"})
- assert r.status_code == 200
- assert b"Email not found" in r.data
- assert b"Create a page" in r.data
- assert b"Try another email" in r.data
+ assert r.status_code == 302
+ assert "/signin/verify" in r.headers["Location"]
# Full sign-in: submit email, verify code, session contains user_id
@@ -414,7 +412,7 @@ def test_signin_full_flow(client):
r = client.post("/signin/verify", data={"code": code, "email": "user@example.com"})
assert r.status_code == 302
- assert r.headers["Location"] == "/"
+ assert r.headers["Location"] == "/setup/username"
with client.session_transaction() as sess:
assert "user_id" in sess
@@ -434,6 +432,73 @@ def test_signin_with_username_redirects_to_profile(client):
assert r.headers["Location"] == "/@signinuser"
+# Full signup: new email, verify code, pick username, land on profile
+def test_signup_full_flow(client):
+ r = client.post("/signin", data={"email": "newuser@example.com"})
+ assert r.status_code == 302
+
+ code = create_verification_code("newuser@example.com", "signin")
+ r = client.post(
+ "/signin/verify", data={"code": code, "email": "newuser@example.com"}
+ )
+ assert r.status_code == 302
+ assert r.headers["Location"] == "/setup/username"
+
+ r = client.get("/setup/username")
+ assert r.status_code == 200
+
+ r = client.post("/setup/username", data={"username": "newuser"})
+ assert r.status_code == 302
+ assert r.headers["Location"] == "/setup/profile"
+
+ r = client.get("/setup/profile")
+ assert r.status_code == 200
+
+ r = client.post("/setup/profile", data={"name": "New User"})
+ assert r.status_code == 302
+ assert r.headers["Location"] == "/@newuser"
+
+
+def test_setup_profile_skip(client):
+ user_id = find_or_create_user("skipper@example.com")
+ set_user_username(user_id, "skipper")
+ with client.session_transaction() as sess:
+ sess["user_id"] = user_id
+
+ r = client.get("/@skipper")
+ assert r.status_code == 200
+
+
+def test_setup_username_requires_signin(client):
+ r = client.get("/setup/username")
+ assert r.status_code == 302
+ assert "/signin" in r.headers["Location"]
+
+
+def test_setup_username_rejects_taken(client):
+ user_id = find_or_create_user("existing@example.com")
+ set_user_username(user_id, "taken")
+
+ new_id = find_or_create_user("newperson@example.com")
+ with client.session_transaction() as sess:
+ sess["user_id"] = new_id
+
+ r = client.post("/setup/username", data={"username": "taken"})
+ assert r.status_code == 200
+ assert b"already taken" in r.data
+
+
+def test_setup_username_skips_if_already_set(client):
+ user_id = find_or_create_user("hasname@example.com")
+ set_user_username(user_id, "hasname")
+ with client.session_transaction() as sess:
+ sess["user_id"] = user_id
+
+ r = client.get("/setup/username")
+ assert r.status_code == 302
+ assert "/@hasname" in r.headers["Location"]
+
+
# Invalid sign-in code shows an error
def test_signin_invalid_code(client):
find_or_create_user("user@example.com")
@@ -471,28 +536,16 @@ def test_homepage_shows_signin_when_logged_out(client):
assert b"Sign in" in r.data
-# Logged-in homepage shows settings link instead of sign in
+# Logged-in homepage shows avatar instead of sign in
def test_homepage_shows_avatar_when_logged_in(client):
user_id = find_or_create_user("logged@example.com")
with client.session_transaction() as sess:
sess["user_id"] = user_id
r = client.get("/")
- assert b"/settings" in r.data
+ assert b"Markdown pages for humans and agents" in r.data
assert b"Sign in" not in r.data
-
-
-# -- Homepage pages list --
-
-
-# Homepage doesn't show a "My pages" link
-def test_homepage_no_my_pages_link(client):
- user_id = find_or_create_user("pages@example.com")
- with client.session_transaction() as sess:
- sess["user_id"] = user_id
-
- r = client.get("/")
- assert b"My pages" not in r.data
+ assert b"home-header-avatar-link" in r.data
# -- Auto-claim for signed-in users --
diff --git a/tests/test_pages.py b/tests/test_pages.py
index 647b306..c3ea290 100644
--- a/tests/test_pages.py
+++ b/tests/test_pages.py
@@ -266,7 +266,7 @@ def test_homepage_logged_out_shows_landing(client):
r = client.get("/")
body = r.data.decode()
assert "tab--active" not in body
- assert "Create a page" in body
+ assert "Sign up for Jottit" in body
# /pages requires sign-in
diff --git a/tests/test_revisions.py b/tests/test_revisions.py
index e73cd88..f006aa4 100644
--- a/tests/test_revisions.py
+++ b/tests/test_revisions.py
@@ -62,6 +62,19 @@ def test_view_revision_nonexistent(client):
assert r.status_code == 404
+# Private page revision is not accessible to non-owners
+def test_view_revision_private_blocked(client):
+ from db import find_or_create_user, set_user_username
+
+ user_id = find_or_create_user("revpriv@test.com")
+ set_user_username(user_id, "revprivuser")
+ save_page("secretrev", "# Secret\n\nHidden content", "private", user_id)
+ with client.session_transaction() as sess:
+ sess.clear()
+ r = client.get("/@revprivuser/secretrev/history/1")
+ assert r.status_code == 404
+
+
# -- Change descriptions --
diff --git a/tests/test_subdomain.py b/tests/test_subdomain.py
index 760ef64..9cd1b28 100644
--- a/tests/test_subdomain.py
+++ b/tests/test_subdomain.py
@@ -116,11 +116,21 @@ def test_old_slug_redirects_unclaimed_page(client):
# -- Visibility --
-# New pages default to "listed" visibility (via create_user_with_username)
-def test_visibility_default_is_listed(client):
- user_id = create_user_with_username(client, "list1@example.com", "listuser1", "lp1")
- page_meta = get_page_meta("lp1", user_id)
- assert page_meta["visibility"] == "listed"
+# New pages by signed-in users default to "private" visibility
+def test_visibility_default_is_private(client):
+ from db import find_or_create_user, set_user_username
+
+ user_id = find_or_create_user("list1@example.com")
+ set_user_username(user_id, "listuser1")
+ with client.session_transaction() as sess:
+ sess["user_id"] = user_id
+ client.post(
+ "/@listuser1/newpage/edit",
+ data={"content": "# Test\n\nContent"},
+ )
+ page_meta = get_page_meta("newpage", user_id)
+ assert page_meta is not None
+ assert page_meta["visibility"] == "private"
# Owner can change a page's visibility to "unlisted"
@@ -134,6 +144,51 @@ def test_update_visibility(client):
assert page_meta["visibility"] == "unlisted"
+# Private pages are blocked for non-owners
+def test_private_page_blocked_for_non_owner(client):
+ from db import update_page_visibility
+
+ user_id = create_user_with_username(
+ client, "priv@example.com", "privuser", "privpage"
+ )
+ page_meta = get_page_meta("privpage", user_id)
+ update_page_visibility(page_meta["id"], "private")
+ with client.session_transaction() as sess:
+ sess.clear()
+ r = client.get("/@privuser/privpage")
+ assert r.status_code == 404
+
+
+# Private pages are accessible to the owner
+def test_private_page_accessible_for_owner(client):
+ from db import update_page_visibility
+
+ user_id = create_user_with_username(
+ client, "privo@example.com", "privowner", "privownpage"
+ )
+ page_meta = get_page_meta("privownpage", user_id)
+ update_page_visibility(page_meta["id"], "private")
+ with client.session_transaction() as sess:
+ sess["user_id"] = user_id
+ r = client.get("/@privowner/privownpage")
+ assert r.status_code == 200
+
+
+# Private pages don't appear on the profile for visitors
+def test_private_page_hidden_from_profile(client):
+ from db import update_page_visibility
+
+ user_id = create_user_with_username(
+ client, "privh@example.com", "privhidden", "privhpage"
+ )
+ page_meta = get_page_meta("privhpage", user_id)
+ update_page_visibility(page_meta["id"], "private")
+ with client.session_transaction() as sess:
+ sess.clear()
+ r = client.get("/@privhidden")
+ assert b"privhpage" not in r.data
+
+
# Unlisted pages don't appear on the profile homepage
def test_unlisted_page_hidden_from_profile(client):
user_id = create_user_with_username(client, "list3@example.com", "listuser3", "lp3")
@@ -286,3 +341,47 @@ def test_subdomain_redirects_to_profile(client):
r = client.get("/lp1", headers={"Host": "legacyuser.jottit.localhost:8000"})
assert r.status_code == 301
assert "/@legacyuser" in r.headers["Location"]
+
+
+# INDEX.md: page with slug "index" renders as profile content
+def test_index_page_renders_on_profile(client):
+ user_id = find_or_create_user("idx@example.com")
+ set_user_username(user_id, "idxuser")
+ save_page("index", "# Welcome\n\nThis is my profile.", "listed")
+ page_meta = get_page_meta("index")
+ claim_page(page_meta["id"], user_id)
+ r = client.get("/@idxuser")
+ assert r.status_code == 200
+ assert b"This is my profile" in r.data
+
+
+# INDEX.md: private index page visible to owner but not visitors
+def test_index_page_private_hidden_from_visitors(client):
+ from db import update_page_visibility
+
+ user_id = find_or_create_user("idxp@example.com")
+ set_user_username(user_id, "idxpriv")
+ save_page("index", "# Secret\n\nPrivate index.", "listed")
+ page_meta = get_page_meta("index")
+ claim_page(page_meta["id"], user_id)
+ update_page_visibility(page_meta["id"], "private")
+ with client.session_transaction() as sess:
+ sess.clear()
+ r = client.get("/@idxpriv")
+ assert b"Private index" not in r.data
+
+
+# INDEX.md: private index page visible to owner
+def test_index_page_private_visible_to_owner(client):
+ from db import update_page_visibility
+
+ user_id = find_or_create_user("idxo@example.com")
+ set_user_username(user_id, "idxown")
+ save_page("index", "# My Index\n\nOwner sees this.", "listed")
+ page_meta = get_page_meta("index")
+ claim_page(page_meta["id"], user_id)
+ update_page_visibility(page_meta["id"], "private")
+ with client.session_transaction() as sess:
+ sess["user_id"] = user_id
+ r = client.get("/@idxown")
+ assert b"Owner sees this" in r.data