# Facet Design Document **Version:** 1.2 **Status:** Active **Last Updated:** 2026-06-23 **Applies to:** Facet v2.22.1 (self-hosted) --- ## Table of Contents 1. [Vision & Principles](#1-vision--principles) 2. [Core Concepts](#2-core-concepts) 3. [Data Model](#3-data-model) 4. [View System Design](#4-view-system-design) 5. [Routing & URL Philosophy](#5-routing--url-philosophy) 6. [Sharing & Privacy Model](#6-sharing--privacy-model) 7. [Import & Enrichment Architecture](#7-import--enrichment-architecture) 8. [Admin UX Philosophy](#8-admin-ux-philosophy) 9. [Public UX Philosophy](#9-public-ux-philosophy) 10. [Security Invariants](#10-security-invariants) 11. [Extensibility Boundaries](#11-extensibility-boundaries) 12. [Known Tradeoffs](#12-known-tradeoffs) 13. [Feature Gaps & Work](#13-feature-gaps--work) 14. [Print & Export System](#14-print--export-system) --- ## 1. Vision & Principles ### 1.1 What Facet Is Facet is a **self-hosted, privacy-respecting, single-owner personal profile platform**. It is best understood as: > "A self-hosted, fully owned alternative to LinkedIn profiles — with views, versions, and sharing — built for individuals, not networks." ### 1.2 Core Philosophy | Principle | Description | |-----------|-------------| | **Single-owner by design** | One person owns this instance. There are no users, no teams, no multi-tenancy. | | **Self-hosted** | Runs on your infrastructure. One container, one port, one volume to backup. | | **Fully owned data** | All data lives in SQLite. Export, backup, delete at will. | | **Quiet and intentional** | No notifications, no feeds, no engagement metrics. | | **Powerful but not loud** | Rich features that stay out of the way until needed. | | **Not social** | No feeds, likes, or follows. Comments exist only as an opt-in, per-item, fully moderated feature — off by default. | | **Privacy first** | Explicit sharing, no accidental disclosure, zero tracking. | ### 1.3 Mental Model Think of Facet as: - **LinkedIn profile** (the content part, not the network) - **Resume + portfolio + personal site** combined - **With versions ("views")** for different audiences: - Recruiter view (focused on employment history) - Conference view (speaking and projects) - Consulting view (skills and case studies) - Internal view (team-specific context) - Personal view (friends and family) - **With strong privacy controls** and explicit sharing - **With zero accidental exposure** — unlisted and private content requires explicit access This is something a thoughtful adult would host for themselves, not a SaaS product. ### 1.4 Explicit Non-Goals Facet will **never** include: - Engagement-driven social features (feeds, likes, follows, infinite scroll) - Messaging or DMs (email — testimonial verification, newsletter, webhook delivery — is functional, not social) - Multi-tenant SaaS features - Advertising or growth loops - User tracking beyond basic access logging > **Note:** Threaded comments do exist, but as an opt-in, per-item moderated feature (off by default, with an approval queue and spam reporting) — not as an open social network. Likewise, lightweight analytics/access logs exist for the owner's own use; there are no third-party trackers. --- ## 2. Core Concepts ### 2.1 Profile The **Profile** is the singleton identity record. There is exactly one profile per Facet instance. It contains: - **Identity**: Name, headline, location, avatar - **Narrative**: Summary (Markdown) - **Contact**: Email, links (GitHub, LinkedIn, etc.) - **Visibility**: Public, unlisted, or private The profile is the "global" identity that views (facets) inherit from and can override. ### 2.2 Content Collections Content is organized into typed collections: | Collection | Purpose | Key Fields | |------------|---------|------------| | **Experience** | Work history | Company, title, dates, description, bullets | | **Projects** | Portfolio items | Title, summary, tech stack, links, cover image | | **Education** | Academic background | Institution, degree, field, dates | | **Certifications** | Professional certs | Name, issuer, dates, credential ID | | **Awards** | Recognition and achievements | Title, issuer, date, description | | **Skills** | Categorized skills | Name, category, proficiency level | | **Posts** | Blog/writing | Title, content (Markdown), published date | | **Talks** | Speaking engagements | Title, event, date, slides/video URLs | | **Custom Content** | User-defined sections | Title, content (Markdown), icon | | **Contact Methods** | Protected contact info | Type, value, protection level | | **Testimonials** | Social proof from others | Name, company, relationship, content | Each content item has: - `visibility`: public / unlisted / private - `is_draft`: Draft items are never shown publicly - `sort_order`: Manual ordering within collections ### 2.3 Views **Views** are curated versions of your profile for different audiences. A view defines: 1. **What to show**: Which sections and items to include 2. **How to order**: Custom ordering of sections and items 3. **What to override**: Per-view headline, summary, CTA 4. **Who can access**: Visibility level and authentication Views are the core differentiator of Facet. They enable: - Showing different content to recruiters vs. conference attendees - Tailoring messaging for different contexts - Sharing private content with specific people via tokens ### 2.4 Share Tokens **Share tokens** enable controlled access to unlisted views: - Generated by admin, returned exactly once - Stored as HMAC hash (raw token never stored) - Optional expiration date and usage limits - Revocable at any time - Accessed via `/s/` URLs that redirect to clean canonical URLs ### 2.5 Sources **Sources** represent external origins of content (currently GitHub repos): - Track import origin for refresh capability - Enable field-level locking (user customizations preserved) - Generate **Import Proposals** for human review ### 2.6 AI Providers (Optional) **AI Providers** are user-supplied credentials for optional enrichment. Self-hosted Facet is **bring-your-own-key only** — there are no managed AI credits; you connect your own OpenAI, Anthropic, or Ollama account. - Support for OpenAI, Anthropic, Ollama, custom endpoints - API keys encrypted at rest (AES-256-GCM) - Never auto-publish — all AI output goes through review - Privacy modes: full README, summary only, none AI powers several optional features beyond GitHub enrichment: resume parsing (PDF/DOCX upload → structured data), AI resume generation (export to PDF/DOCX), and a writing assistant (five tones plus a critique mode). All of these require a configured provider and route their output through review. --- ## 3. Data Model ### 3.1 Current Schema The current implementation uses PocketBase collections with the following structure: ``` profile (singleton) ├── name, headline, location ├── summary (Markdown) ├── hero_image, avatar (files) ├── contact_email, contact_links (JSON) └── visibility (public|unlisted|private) experience ├── company, title, location ├── start_date, end_date ├── description (Markdown), bullets (JSON), skills (JSON) ├── media (files) ├── visibility, is_draft, sort_order └── (password_hash - for item-level protection) projects ├── title, slug (unique), summary ├── description (Markdown), tech_stack (JSON), links (JSON) ├── media, cover_image (files) ├── categories (JSON) ├── visibility, is_draft, is_featured, sort_order ├── source_id (FK→sources), field_locks (JSON), last_sync └── (password_hash) education ├── institution, degree, field ├── start_date, end_date, description └── visibility, is_draft, sort_order certifications ├── name, issuer, issue_date, expiry_date ├── credential_id, credential_url └── visibility, is_draft, sort_order skills ├── name, category, proficiency (expert|proficient|familiar) └── visibility, sort_order posts ├── title, slug (unique), excerpt ├── content (Markdown), cover_image ├── tags (JSON), published_at └── visibility, is_draft talks ├── title, event, event_url, date, location ├── description, slides_url, video_url └── visibility, is_draft, sort_order views ├── name, slug (unique, required) ├── description (internal note) ├── visibility (public|unlisted|password|private) ├── password_hash (for password visibility) ├── hero_headline, hero_summary (overrides) ├── cta_text, cta_url ├── sections (JSON: section configs with items) ├── is_active, is_default └── (theme, custom CSS) share_tokens ├── view_id (FK→views) ├── token_prefix (12 chars, indexed) ├── token_hash (HMAC-SHA256) ├── name (label) ├── expires_at, max_uses, use_count ├── is_active, last_used_at sources ├── type (github) ├── identifier (owner/repo) ├── project_id (FK→projects, optional) ├── last_sync, sync_status, sync_log ai_providers ├── name, type (openai|anthropic|ollama|custom) ├── api_key, api_key_encrypted ├── base_url, model ├── is_default, is_active ├── last_test, test_status import_proposals ├── source_id (FK→sources) ├── project_id (FK→projects, optional for new) ├── proposed_data (JSON) ├── diff (JSON) ├── ai_enriched (boolean) ├── status (pending|applied|rejected) └── applied_fields (JSON) settings (key-value) ├── key (unique) └── value (JSON) testimonials ├── author_name, author_title, author_company ├── content (text), relationship ├── verification_method, verification_identifier ├── verified_at ├── status (pending|approved|rejected) ├── request_id (FK→testimonial_requests) ├── featured, sort_order └── visibility, is_draft testimonial_requests ├── token_hash (HMAC-SHA256) ├── token_prefix (12 chars, indexed) ├── label, custom_message ├── expires_at, max_uses, use_count └── is_active email_verification_tokens ├── testimonial_id (FK→testimonials) ├── email, token_hash ├── expires_at, verified_at comments ├── content_type, content_id (target item) ├── parent_id (threaded replies) ├── author_name, author_email, body ├── status (pending|approved|spam|rejected) ├── is_pinned, is_admin_reply └── ip_hash, user_agent (comment_reports: comment FK, reason, detail, status) api_keys ├── label, key_prefix (indexed), key_hash (HMAC) ├── scopes (JSON: read:*/write:* for profile,posts,projects,skills,experience) ├── allowed_origins, expires_at, last_used_at └── is_active webhooks ├── label, url (SSRF-checked) ├── secret (HMAC-SHA256, auto-generated), events (JSON) ├── is_active, failure_count (auto-disable) └── last_delivered_at, last_failure_at, last_error (webhook_deliveries: webhook_id FK, event_name, request/response, succeeded) system_alerts ├── severity, event, title ├── details, metadata └── acknowledged_at newsletter_lists ├── name, slug, description ├── sender_name, reply_to, welcome_subject, welcome_html ├── is_default, is_active, subscriber_count (subscribers + subscriber_list_memberships for multi-list/segments) audit_logs / access_logs ├── event, metadata, created └── (owner-facing access history, not third-party tracking) external_media ├── url, provider (youtube|vimeo|loom|soundcloud| │ spotify|codepen|figma|immich|image|...) └── title, mime_type, thumbnail_url ``` > Self-hosted Facet runs **automigrate on boot**, so every collection above is provisioned automatically on first start of a fresh container. ### 3.2 Shipped & Proposed Schema Extensions **Shipped** (already in the schema): - ✅ `awards` collection: title, issuer, date, description, visibility - ✅ `custom_content` collection: title, slug, content (Markdown), icon, sort_order, visibility — arbitrary content sections without schema changes - ✅ `experience.company_logo`: file upload for logos - ✅ `contact_methods` with tiered protection (none / obfuscated / click-reveal / captcha) **Still proposed:** Add to `profile`: - `pronouns`: Optional, displayed with name - `tagline`: Short one-liner (distinct from headline) Add to `experience`: - `employment_type`: Full-time, part-time, contract, etc. Add to `projects`: - `status`: Active, completed, archived, abandoned - `year_started`, `year_ended`: Simplified date display - `collaborators`: JSON array of names New collection: - `publications`: title, venue, date, url, type (paper|book|article), visibility --- ## 4. View System Design ### 4.1 Why Views Exist Views solve the **audience problem**: different people need to see different things. A recruiter cares about employment history and skills. A conference organizer cares about talks and projects. A potential client cares about case studies and testimonials. Your family wants the personal touch. Without views, you either: 1. Show everything to everyone (privacy loss) 2. Manually maintain multiple profiles (synchronization hell) 3. Use visibility toggles (loses context, awkward) Views provide **curated lenses** onto a single source of truth. ### 4.2 What Views Control | Aspect | Default Behavior | View Override | |--------|------------------|---------------| | **Sections** | All visible sections | Explicit include/exclude | | **Items** | All visible items in section | Explicit item selection | | **Section Order** | Default order | Custom per-view section ordering | | **Item Order** | sort_order field | Custom per-view item ordering | | **Item Content** | Source record values | Per-item field overrides | | **Headline** | Profile headline | hero_headline override | | **Summary** | Profile summary | hero_summary override | | **CTA** | None | cta_text + cta_url | | **Theme** | System default | (Future: per-view theme) | ### 4.3 Section Configuration Views store section configuration as JSON: ```json { "sections": [ { "section": "experience", "enabled": true, "items": ["id1", "id2", "id3"], "order": 1, "itemConfig": { "id1": { "order": 0, "overrides": { "title": "Senior UX Designer", "description": "Led user research initiatives..." } }, "id2": { "order": 1 }, "id3": { "order": 2 } } }, { "section": "projects", "enabled": true, "items": [], "order": 2 }, { "section": "education", "enabled": false } ] } ``` **Design Decision**: Empty `items` array means "include all visible items" (filtered by visibility and draft status). This avoids manual updates when adding new content. ### 4.4 Item-Level Overrides Views can override specific fields on individual items without modifying the source record. This enables **audience-specific framing** of the same experience. #### Use Case: Career Pivot A product designer who straddles UX and Instructional Design can create two views from the same job history: ``` Source Record: "Product Designer @ Acme Corp" ├── title: "Product Designer" ├── description: "Designed products and training materials" ├── bullets: ["Led design system", "Created onboarding", "User research"] View: "UX Designer" └── Overrides: ├── title: "Senior UX Designer" ├── description: "Led user-centered design initiatives..." └── bullets: ["Led design system", "Conducted 50+ user interviews", ...] View: "Instructional Designer" └── Overrides: ├── title: "Learning Experience Designer" ├── description: "Created scalable training programs..." └── bullets: ["Developed onboarding curriculum", "Built LMS integrations", ...] ``` #### Overridable Fields by Collection | Collection | Overridable Fields | |------------|-------------------| | **Experience** | title, description, bullets | | **Projects** | title, summary, description | | **Education** | degree, field, description | | **Skills** | (none - include/exclude only) | | **Certifications** | (none - include/exclude only) | | **Talks** | title, description | | **Posts** | (none - include/exclude only) | **Not Overridable**: Company names, dates, institutions, issuers, URLs. These are factual and should remain consistent across views. #### Override Inheritance ``` Render Priority (highest wins): 1. View itemConfig.overrides → If field has override, use it 2. Source record value → Otherwise, use original ``` #### UI Indicators The admin UI should clearly show: - Which items have overrides (visual badge) - Which specific fields are overridden (inline indicator) - "Reset to original" action to clear overrides - Side-by-side comparison of original vs. override ### 4.5 Default View Behavior The homepage (`/`) renders the **default view**: 1. Find view where `is_default=true` AND `is_active=true` AND `visibility='public'` 2. Fallback: First public active view by creation date 3. Fallback: Legacy homepage (all public content aggregated) **Invariant**: Only ONE view can have `is_default=true`. This is enforced by backend hooks that clear other defaults when setting a new one. ### 4.6 View Inheritance Model Views **inherit** from the profile and **override** selectively: ``` Profile (global identity) ├── name: "Jane Smith" ← Always used ├── headline: "Software Engineer" ← Can be overridden per view ├── summary: "I build things..." ← Can be overridden per view └── contact_links: [...] ← Always used View: "Recruiter" ├── hero_headline: "Senior Engineer | 10+ Years Experience" ← Override ├── hero_summary: "Looking for backend roles..." ← Override └── cta_text: "Download Resume" → cta_url: "/resume.pdf" ← Addition ``` **Not Overridable**: Name, avatar, contact links. These are identity, not messaging. --- ## 5. Routing & URL Philosophy ### 5.1 URL Model Facet uses **LinkedIn-style canonical URLs**: | Route | Purpose | Example | |-------|---------|---------| | `/` | Default public view | Homepage | | `/` | Named view (canonical) | `/recruiter`, `/conference` | | `/s/` | Share link entry | Validates, sets cookie, redirects | | `/v/` | Legacy route | 301 redirects to `/` | ### 5.2 Design Rationale **Root-level slugs** (`/recruiter` not `/v/recruiter`): - Cleaner URLs for sharing - More professional appearance - LinkedIn uses this pattern (`linkedin.com/in/username`) **Share link redirect** (`/s/` → `/`): - Token never appears in final URL - Token not leaked via browser history - Token not leaked via Referer headers - Cookie enables subsequent requests ### 5.3 Reserved Slugs These slugs cannot be used for views (enforced at frontend and backend): ``` admin, api, s, v, projects, posts, talks, _app, _, assets, static, favicon.ico, robots.txt, sitemap.xml, health, healthz, ready, login, logout, auth, oauth, callback, home, index, default, profile ``` **Protection Layers**: 1. Frontend param matcher: Invalid slugs don't match `[slug=slug]` route 2. Backend hook: Returns 400 when creating views with reserved slugs ### 5.4 Slug Validation Rules A valid view slug must: - Not be empty - Not be in the reserved list (case-insensitive) - Match pattern: `^[a-zA-Z0-9][a-zA-Z0-9_-]*$` - Not start with underscore or hyphen - Be at most 100 characters --- ## 6. Sharing & Privacy Model ### 6.1 Visibility Levels | Level | Access | HTTP Behavior | Use Case | |-------|--------|---------------|----------| | **public** | Anyone | 200 OK | General audience | | **unlisted** | Share token required | 200 with token, 404 without | Specific people | | **password** | Password JWT required | Password prompt, then 200 | Protected content | | **private** | Admin only | 404 (never 401/403) | Internal/draft | **Security Principle**: Private and unlisted content returns 404, not 401/403, to prevent discovery of existence. ### 6.2 Share Token Flow ``` 1. Admin generates token for unlisted view POST /api/share/generate → Returns raw token ONCE (never stored) 2. Admin shares URL: https://example.com/s/ 3. User visits /s/: - Server validates token (HMAC verification) - Sets httpOnly cookie (me_share_token) - 302 redirect to / - Token NOT in final URL 4. Subsequent requests to /: - Token read from cookie - Sent via X-Share-Token header - View data returned with sections ``` ### 6.3 Token Security Properties | Property | Implementation | |----------|----------------| | **Storage** | HMAC-SHA256 hash, not raw token | | **Lookup** | Token prefix (12 chars) for O(1) indexed query | | **Comparison** | Constant-time HMAC verification | | **View-bound** | Each token tied to specific view_id | | **Expiration** | Optional expires_at timestamp | | **Usage limits** | Optional max_uses with use_count | | **Revocation** | is_active flag for instant deactivation | | **Error responses** | Uniform "invalid token" for all failure modes | ### 6.4 Password Protection Flow ``` 1. User visits /protected-view (no JWT cookie): - Server returns { requiresPassword: true } - Client shows password prompt 2. User submits password: POST /api/password/check - bcrypt comparison against password_hash - Returns signed JWT (1-hour expiry) 3. Client stores JWT in httpOnly cookie 4. Subsequent requests include JWT: - Authorization: Bearer - Server validates signature, expiry, view_id - Returns view data ``` ### 6.5 JWT Claims ```json { "vid": "", "iss": "facet", "aud": "view-access", "iat": 1234567890, "exp": 1234571490, "jti": "" } ``` --- ## 7. Import & Enrichment Architecture ### 7.1 Import Pipeline ``` ┌─────────────────────────────────────────────────────────┐ │ GitHub Import Flow │ ├─────────────────────────────────────────────────────────┤ │ │ │ 1. PREVIEW: Admin enters repo URL │ │ POST /api/github/preview │ │ → Fetch metadata, languages, topics, README │ │ → Display to user before import │ │ │ │ 2. IMPORT: Admin confirms import │ │ POST /api/github/import │ │ → Create Source record │ │ → Optionally invoke AI enrichment │ │ → Create ImportProposal (pending) │ │ │ │ 3. REVIEW: Admin reviews each field │ │ /admin/review/[id] │ │ → Apply / Ignore / Lock per field │ │ → Edit values before applying │ │ │ │ 4. APPLY: Create/update Project │ │ POST /api/proposals/{id}/apply │ │ → Apply selected fields │ │ → Update field_locks on project │ │ → Link source to project │ │ │ │ 5. REFRESH: Sync later │ │ POST /api/github/refresh/{sourceId} │ │ → Fetch fresh data │ │ → Respect field_locks │ │ → Create new proposal for review │ │ │ └─────────────────────────────────────────────────────────┘ ``` ### 7.2 Field Locking When a user customizes a field (edits it beyond the imported value), they can "lock" it: ```json { "field_locks": { "title": false, // Can be updated on refresh "summary": true, // Locked - preserved on refresh "description": true // Locked } } ``` **Behavior**: Locked fields are skipped during refresh import. Unlocked fields appear in the new proposal for review. ### 7.3 AI Enrichment AI enrichment is **optional** and **user-controlled**: 1. User provides their own API keys (encrypted at rest) 2. User chooses privacy mode: - **Full**: Send full README to AI - **Summary**: Send first 500 chars only - **None**: No AI, use raw GitHub data 3. AI output goes to proposal, not directly to project 4. User reviews and can edit every AI-generated field **AI Guardrails** (in prompt): - "Do not invent metrics or statistics" - "Stay factual, avoid marketing language" - "Use neutral, professional tone" --- ## 8. Admin UX Philosophy ### 8.1 Design Principles | Principle | Implementation | |-----------|----------------| | **Calm, not CMS** | No dashboard for its own sake. Information density is earned. | | **Empty ≠ broken** | First-run shows welcoming copy, not error states or zeros. | | **No analytics theater** | No graphs showing "0 views this week". | | **Tending, not managing** | Editing should feel like gardening your profile, not filling forms. | ### 8.2 Empty States When content is missing, show helpful guidance: - **Dashboard empty**: "This is your space. You might start by adding your profile..." - **No views**: "Views let you show different versions of your profile to different audiences." - **No activity**: "Nothing here yet — and that's okay." - **No AI providers**: "AI enrichment is optional. Add your own API key if you'd like AI-assisted summaries." ### 8.3 Admin Navigation ``` /admin Dashboard (overview) /admin/homepage Homepage configuration -- Your Information -- /admin/contacts Contact methods (with protection levels) /admin/experience Manage work history /admin/projects Manage portfolio /admin/education Manage education /admin/certifications Manage certifications /admin/awards Manage awards /admin/skills Manage skills /admin/custom Custom content sections /admin/import GitHub import & Resume AI parsing -- Your Voice -- /admin/posts Blog posts /admin/talks Speaking engagements -- Testimonials -- /admin/testimonials Manage testimonials /admin/testimonials/requests Request links -- Views & Sharing -- /admin/views Manage curated views/facets /admin/views/new Create new view /admin/views/[id] Edit specific view /admin/tokens Manage share tokens /admin/media Media library -- Settings -- /admin/settings Main settings /admin/settings/account Account & security /admin/settings/appearance Appearance settings /admin/settings/general General settings /admin/settings/analytics Analytics (Google Analytics) /admin/settings/integrations AI providers & integrations /admin/settings/tags Admin tags /admin/settings/about About Facet (changelog, version) ``` ### 8.4 Visual Design - **Consistent icons**: SVG icon set in `$lib/icons.ts`, no emojis - **Destructive actions**: Red styling, confirmation dialogs - **Loading states**: Skeleton loaders, not spinners everywhere - **Dark mode**: Full support via Tailwind dark classes --- ## 9. Public UX Philosophy ### 9.1 Design Principles | Principle | Implementation | |-----------|----------------| | **Readability first** | Clean typography, generous whitespace | | **Fast** | SSR, minimal JS, optimized images | | **Accessible** | Semantic HTML, ARIA labels, keyboard nav | | **Printable** | Print-friendly CSS for resume printing | | **SEO basics** | Meta tags, Open Graph, canonical URLs | ### 9.2 Public Page Structure ``` ┌────────────────────────────────────────┐ │ Profile Hero │ │ Avatar Name Headline Location │ │ Summary (Markdown) │ │ CTA Button (if view has one) │ └────────────────────────────────────────┘ ┌────────────────────────────────────────┐ │ Experience Section │ │ [Cards with company, title, dates] │ └────────────────────────────────────────┘ ┌────────────────────────────────────────┐ │ Projects Section │ │ [Grid of project cards with covers] │ └────────────────────────────────────────┘ ┌────────────────────────────────────────┐ │ Education Section │ └────────────────────────────────────────┘ ┌────────────────────────────────────────┐ │ Skills Section │ │ [Grouped by category] │ └────────────────────────────────────────┘ ┌────────────────────────────────────────┐ │ Footer │ │ Contact links • Facet │ └────────────────────────────────────────┘ ``` ### 9.3 Password Prompt For password-protected views: - Minimal, clean prompt - No indication of what content is behind it - Single password field + submit - Error: "Incorrect password" (not "view not found") --- ## 10. Security Invariants ### 10.1 Non-Negotiable Properties | Invariant | Enforcement | |-----------|-------------| | **Private content returns 404** | Never 401/403 for private/unlisted without token | | **Tokens never stored raw** | HMAC hash only in database | | **API keys encrypted at rest** | AES-256-GCM with derived key | | **No public collection access** | All collections require auth for direct API | | **Rate limiting on auth endpoints** | Strict tier (5/min) on password check | | **Optional 2FA** | TOTP with recovery codes for admin login | | **Webhook egress is hardened** | HMAC-SHA256 signed payloads + SSRF protection on target URLs | | **API keys are scoped & hashed** | `read:*`/`write:*` scopes; only HMAC hash stored, raw key shown once | ### 10.2 Collection Access Model **Deny-by-default**: All PocketBase collections require authentication for direct HTTP access. ``` Public data flows through custom endpoints: /api/view/{slug}/access → View metadata /api/view/{slug}/data → View content (with visibility checks) /api/homepage → Legacy aggregated public content These endpoints use server-side queries that bypass collection rules, allowing them to serve public content while maintaining access control. ``` ### 10.3 Key Derivation From master `ENCRYPTION_KEY`: - **Encryption key**: `SHA256(master + ":encryption")` - **HMAC key**: `SHA256(master + ":hmac")` - **JWT signing key**: `SHA256(master + ":jwt")` ### 10.4 Rate Limiting Tiers | Tier | Rate | Burst | Endpoints | |------|------|-------|-----------| | Strict | 5/min | 3 | `/api/password/check` | | Moderate | 10/min | 5 | `/api/share/validate` | | Normal | 60/min | 10 | `/api/view/{slug}/*`, `/api/homepage` | --- ## 11. Extensibility Boundaries ### 11.1 What Can Be Extended | Extension Point | Mechanism | |-----------------|-----------| | **New import sources** | Add to `sources.type` enum, create service | | **New AI providers** | Add to `ai_providers.type` enum, extend AIService | | **Custom sections** | Future: `custom_sections` collection | | **Themes** | Future: Per-view CSS/theme selection | ### 11.2 What Should NOT Be Extended | Boundary | Reason | |----------|--------| | **Multi-user** | Fundamental single-owner assumption | | **Social features** | Violates core philosophy | | **Tracking/analytics** | Privacy-first commitment | | **SaaS features** | Self-hosted by design | ### 11.3 Plugin Architecture Facet does not currently support plugins. Future extensibility may include: - Custom Go hooks in a designated directory - Theme packages (CSS + layout overrides) - Import adapters for new sources --- ## 12. Known Tradeoffs ### 12.1 Accepted Limitations | Tradeoff | Reason | |----------|--------| | **SQLite only** | Perfect for single-user, no need for horizontal scaling | | **Single container** | Simplest deployment, one port to expose | | **No CDN for files** | Local file storage keeps it simple; S3 optional | | **No JWT revocation** | Stateless tokens; 1-hour expiry is acceptable | | **PocketBase pre-v1.0** | Widely used, pinned version, documented upgrade path | ### 12.2 Explicit Constraints | Constraint | Implication | |------------|-------------| | **One profile per instance** | Family members need separate instances | | **No scheduled sync** | GitHub refresh is manual (could add cron job) | | **No granular permissions** | Admin is all-or-nothing | | **No version history** | Changes overwrite; backup externally | --- ## 13. Feature Gaps & Future Work ### 13.1 Current Gaps Based on codebase analysis, these features are incomplete or missing: | Gap | Current State | Impact | |-----|---------------|--------| | ~~**Project detail pages**~~ | ~~Route exists, no implementation~~ | ✅ Complete: `/projects/{slug}` | | ~~**Post/blog pages**~~ | ~~Collection exists, no public UI~~ | ✅ Complete: `/posts/{slug}` | | ~~**Talks section**~~ | ~~Collection exists, no UI~~ | ✅ Complete: Public display with video embeds | | ~~**Certifications section**~~ | ~~Collection exists, no UI~~ | ✅ Complete: Public display with issuer grouping | | ~~**View editor**~~ | ~~Basic, no drag-drop~~ | ✅ Complete: Full editor with section/item selection | | ~~**Media library**~~ | ~~No implementation~~ | ✅ Complete: `/admin/media` with listing, filters, delete | | ~~**Share token management UI**~~ | ~~Listed in views page, no full UI~~ | ✅ Complete: `/admin/tokens` with full CRUD | | **Scheduled GitHub sync** | Not implemented | Manual refresh only | | ~~**Resume PDF export**~~ | ~~Not implemented~~ | ✅ Complete: Print stylesheet + AI resume generation | | ~~**Theme customization**~~ | ~~Not implemented~~ | ✅ Partial: Accent colors, custom CSS, dark mode | | ~~**Demo mode**~~ | ~~Not implemented~~ | ✅ Complete: "The Doctor" profile with one-click toggle | | **Audit logging** | Minimal | Cannot review full access history | | ~~**Email notifications**~~ | ~~Not implemented~~ | ✅ Complete: SMTP-based admin notifications on testimonial submission | | ~~**Testimonials**~~ | ~~Not implemented~~ | ✅ Complete: Full system with request links, verification | | ~~**Custom content**~~ | ~~Not implemented~~ | ✅ Complete: User-defined content sections | | ~~**Contact protection**~~ | ~~Not implemented~~ | ✅ Complete: 4-tier protection, per-view visibility | | ~~**Version notifications**~~ | ~~Not implemented~~ | ✅ Complete: Check GitHub for updates | | ~~**Webhooks**~~ | ~~Not implemented~~ | ✅ Complete: HMAC-SHA256 signing, SSRF protection, retry + auto-disable, delivery log | | ~~**Public REST API**~~ | ~~Not implemented~~ | ✅ Complete: `/api/v1/*` with scoped API keys (`read:*`/`write:*`) | | ~~**System alerts inbox**~~ | ~~Not implemented~~ | ✅ Complete: `/admin/alerts` | | ~~**Newsletter**~~ | ~~Not implemented~~ | ✅ Complete: Multi-list/segments + compose UI | | ~~**Comments**~~ | ~~Not implemented~~ | ✅ Complete: Threaded, moderation queue, per-item toggle | | ~~**Resume parsing / generation**~~ | ~~Not implemented~~ | ✅ Complete: AI parse (PDF/DOCX) + AI generate (PDF/DOCX) | | ~~**Writing assistant**~~ | ~~Not implemented~~ | ✅ Complete: 5 tones + critique mode | | ~~**2FA**~~ | ~~Not implemented~~ | ✅ Complete: Optional TOTP with recovery codes | | ~~**Multi-language UI**~~ | ~~Not implemented~~ | ✅ Complete: en/de/elvish/klingon/lolcat, CI-enforced parity | | ~~**External media embeds**~~ | ~~Not implemented~~ | ✅ Complete: YouTube, Vimeo, Loom, SoundCloud, Spotify, CodePen, Figma, Immich | | ~~**Data export**~~ | ~~Deferred~~ | ✅ Complete: JSON/YAML export, RSS feed, iCal | | ~~**View Analytics**~~ | ~~Not implemented~~ | ✅ Complete: Owner-facing access/view metrics | | **Scheduled GitHub sync** | Not implemented | Manual refresh only | | **QR codes** | Not implemented | Generate for views/share links | ### 13.2 Proposed Priority Order 1. ~~**Core content pages**: Project details, posts, talks, certifications~~ ✅ Complete 2. ~~**View editor core**: Section toggles, item selection, hero overrides~~ ✅ Complete 3. ~~**View editor improvements**: Drag-drop ordering, preview pane~~ ✅ Complete 4. ~~**Share token management**: Full CRUD UI with usage stats~~ ✅ Complete 5. ~~**Resume export**: Generate PDF from profile/view~~ ✅ Complete (AI generation + print) 6. ~~**Theme system**: Light/dark modes, color customization~~ ✅ Complete (accent colors, custom CSS) 7. **Scheduled sync**: Cron-based GitHub refresh 8. ~~**Media library**: Image optimization (thumb/WebP/srcset), storage insights~~ ✅ Complete 9. ~~**Demo mode**: Production-safe showcase persona with multiple views~~ ✅ Complete 10. **Audit log**: Access history for share tokens (minimal currently) 11. ~~**Testimonials**: Social proof collection~~ ✅ Complete 12. ~~**Contact protection**: Per-view contact visibility~~ ✅ Complete 13. ~~**View Analytics Dashboard**: Surface existing usage data~~ ✅ Complete (owner-facing metrics) 14. 🔜 **QR Codes**: Generate for views/share links 15. ~~**Webhooks**: Notify external services on events~~ ✅ Complete (HMAC-SHA256, SSRF protection, retry/auto-disable, delivery log) --- ## 14. Print & Export System ### 14.1 Print Stylesheet Facet includes a comprehensive print stylesheet that optimizes public views for printing and PDF generation via the browser's native Print function (Ctrl+P / Cmd+P). #### Design Goals | Goal | Implementation | |------|----------------| | **ATS-friendly** | Clean text, minimal formatting, semantic structure | | **One-click print** | Print button visible on public views | | **Readable** | Optimized typography, appropriate spacing | | **Complete** | All visible content printed, nothing hidden | | **No dark mode** | Forces light colors for printing | #### Print Optimizations 1. **Hidden Elements** - Theme toggle button - Print button itself - Interactive elements (hover states) - Background gradients and decorative images 2. **Typography Adjustments** - Serif font for body text (better for printing) - Slightly smaller base font size - Increased line height for readability - Black text on white background 3. **Layout Changes** - Full-width content (no max-width constraints) - Reduced padding and margins - Cards rendered without shadows/borders - Page breaks avoided inside sections 4. **Link Handling** - URLs displayed after link text - Contact links shown with full URLs - External links marked appropriately #### CSS Structure ```css @media print { /* Force light mode colors */ * { color-adjust: exact; -webkit-print-color-adjust: exact; } /* Hide UI elements */ .print\\:hidden { display: none !important; } /* Typography */ body { font-family: Georgia, serif; font-size: 11pt; } /* Layout */ main { max-width: 100%; padding: 0; } .card { box-shadow: none; border: 1px solid #e5e7eb; } /* Page breaks */ section { page-break-inside: avoid; } article { page-break-inside: avoid; } } ``` ### 14.2 Print Button A print button is displayed on public view pages (not admin). The button: - Appears in a non-intrusive location (top-right, near theme toggle) - Has a printer icon for clarity - Triggers `window.print()` on click - Is hidden when printing (class `print:hidden`) ### 14.3 Future Enhancements These export features are planned for later phases: | Feature | Status | Notes | |---------|--------|-------| | **Server-side PDF (resume)** | ✅ Complete | AI resume generation exports to PDF and DOCX | | **Data export (JSON)** | ✅ Complete | Export all profile data for backup | | **Data export (YAML)** | ✅ Complete | Export in Facet format | | **RSS feed / iCal** | ✅ Complete | `/rss.xml` for posts, `/talks.ics` for talks | | **Static HTML snapshot** | Deferred | Self-contained offline version | --- ## Appendix A: API Reference ### Public Endpoints | Method | Path | Rate Limit | Description | |--------|------|------------|-------------| | GET | `/api/default-view` | Normal | Get default view slug | | GET | `/api/view/{slug}/access` | Normal | Get view access requirements | | GET | `/api/view/{slug}/data` | Normal | Get view content | | GET | `/api/project/{slug}` | Normal | Get public project details | | GET | `/api/homepage` | Normal | Legacy aggregated content | | POST | `/api/share/validate` | Moderate | Validate share token | | POST | `/api/password/check` | Strict | Validate view password | | POST | `/api/testimonials/submit` | Strict | Submit a testimonial | | POST | `/api/testimonials/verify/email` | Strict | Send verification email | | GET | `/api/testimonials/verify/email/{token}` | Moderate | Complete email verification | | GET | `/api/public/testimonials` | Normal | List approved testimonials | ### Authenticated Endpoints | Method | Path | Description | |--------|------|-------------| | POST | `/api/share/generate` | Generate new share token | | POST | `/api/share/revoke/{id}` | Revoke share token | | POST | `/api/github/preview` | Preview GitHub repo | | POST | `/api/github/import` | Import GitHub repo | | POST | `/api/github/refresh/{id}` | Refresh source | | POST | `/api/ai/test/{id}` | Test AI provider | | POST | `/api/ai/enrich` | Enrich content with AI | | POST | `/api/proposals/{id}/apply` | Apply import proposal | | POST | `/api/proposals/{id}/reject` | Reject import proposal | | POST | `/api/password/set` | Set view password | | GET | `/api/smtp-settings` | Get SMTP configuration | | PUT | `/api/smtp-settings` | Update SMTP configuration | | POST | `/api/smtp-settings/test` | Send test email | | POST | `/api/testimonials/requests` | Create testimonial request link | | GET | `/api/testimonials/requests` | List request links | | GET | `/api/testimonials` | List all testimonials | | POST | `/api/testimonials/{id}/approve` | Approve testimonial | | POST | `/api/testimonials/{id}/reject` | Reject testimonial | | GET | `/api/testimonials/pending-count` | Get pending testimonial count | ### Public REST API (v1) Authenticated with scoped API keys via `Authorization: Bearer `. Self-hosted exposes `read:*` and `write:*` scopes for `profile`, `posts`, `projects`, `skills`, and `experience` only. | Method | Path | Scope | |--------|------|-------| | GET | `/api/v1/profile` | `read:profile` | | GET / POST | `/api/v1/posts` | `read:posts` / `write:posts` | | PATCH / DELETE | `/api/v1/posts/{id}` | `write:posts` | | GET / POST | `/api/v1/projects` | `read:projects` / `write:projects` | | PATCH / DELETE | `/api/v1/projects/{id}` | `write:projects` | | GET / POST | `/api/v1/skills` | `read:skills` / `write:skills` | | PATCH / DELETE | `/api/v1/skills/{id}` | `write:skills` | | GET / POST | `/api/v1/experience` | `read:experience` / `write:experience` | | PATCH / DELETE | `/api/v1/experience/{id}` | `write:experience` | ### Webhooks & System Alerts (admin) | Method | Path | Description | |--------|------|-------------| | GET / POST | `/api/admin/webhooks` | List / create webhook (auto-generates HMAC secret) | | PATCH / DELETE | `/api/admin/webhooks/{id}` | Update / delete webhook | | GET | `/api/admin/system-alerts` | List system alerts (filterable) | | POST | `/api/admin/system-alerts/{id}/acknowledge` | Acknowledge one alert | | POST | `/api/admin/system-alerts/acknowledge-all` | Acknowledge all | | DELETE | `/api/admin/system-alerts/{id}` | Remove alert | --- ## Appendix B: Environment Variables | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `ENCRYPTION_KEY` | No | Auto-generated | 32-byte hex key for encryption. Auto-generated and saved to `/data/.encryption_key` if not set | | `PORT` | No | `8080` | Public port | | `APP_URL` | No | `http://localhost:8080` | Public URL | | `TRUST_PROXY` | No | `false` | Trust proxy headers for IP | | `ADMIN_EMAILS` | No | — | Comma-separated admin allowlist | | `ADMIN_ENABLED` | No | `false` | Enable PocketBase admin UI | | `DATA_PATH` | No | `./data` | Database and uploads path | | `SEED_DATA` | No | — | Seed mode: `dev` for dev profile, unset for none | | `LOG_LEVEL` | No | `info` | Logging verbosity | | `UPLOADS_PATH` | No | `./uploads` | Upload file storage path | | `SMTP_HOST` | No | — | SMTP server for email notifications | | `SMTP_PORT` | No | `587` | SMTP server port | | `SMTP_USERNAME` | No | — | SMTP authentication username | | `SMTP_PASSWORD` | No | — | SMTP authentication password | | `SMTP_TLS` | No | `true` | Enable TLS for SMTP connections | | `SMTP_SENDER_NAME` | No | `Facet` | Display name on outgoing emails | | `SMTP_SENDER_ADDRESS` | No | — | From address on outgoing emails | --- *This document is the authoritative design reference for Facet. Update it as the product evolves.*