Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
8.3 KiB
Public Note Sharing — Design Spec
Issue: #7 — Public links and shared notes Date: 2026-03-20
Overview
Allow sharing individual notes via a public URL that anyone can view without authentication. Read-only sharing via unguessable tokens. Integrates into the existing ShareDialog alongside collaborator management.
Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Share UI location | Inside existing ShareDialog | One "Share" entry point for all sharing (public + collaborators) |
| Expiry | Deferred to future version | Simpler v1; active/revoked toggle only |
| Rate limiting | In-memory per-IP | Lightweight, resets on restart; token entropy handles enumeration |
| Architecture | Separated shares-service | Follows collaborators-service pattern; clean module boundary |
| Sharing icon | Globe (public) or Users (collaborators) | Single icon slot; tooltip shows combined sharing state |
Schema
New sharedNotes table in src/lib/server/db/schema.ts:
export const sharedNotes = sqliteTable('shared_notes', {
noteId: text('note_id').primaryKey().references(() => notes.id, { onDelete: 'cascade' }),
token: text('token').notNull().unique(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
expiresAt: integer('expires_at', { mode: 'timestamp' }), // NULL = no expiry, reserved for future use
});
noteIdas primary key — one share per note, no separateidneededintegertimestamps withmode: 'timestamp'— matches all other tables in the schemacreatedAtset in service layer (new Date()), not via SQL default- CASCADE delete — deleting the note removes the share
- Token:
nanoid(21)— ~128 bits of entropy - No update to
deleteUser()needed — shares cascade through notes (foreign keys enabled viaPRAGMA foreign_keys = ONin db connection)
Types
Add to src/lib/types/index.ts:
export interface NoteShare {
noteId: string;
token: string;
createdAt: Date;
expiresAt: Date | null;
}
Extend the Note interface:
export interface Note {
// ...existing fields
shareToken?: string; // populated during hydration if a public share exists
}
Service Layer
New file: src/lib/server/shares-service.ts
| Function | Params | Returns | Description |
|---|---|---|---|
createShare |
db, noteId, userId |
{ token } |
Verify ownership, generate nanoid(21), insert row, return token. If share already exists, return existing token. |
revokeShare |
db, noteId, userId |
void |
Verify ownership, delete share row |
getShareByNoteId |
db, noteId |
NoteShare | null |
Return share if exists (for ShareDialog) |
getSharedNote |
db, token |
SharedNoteData | null |
Public — fetch note by token, return null if trashed or not found |
fetchSharesForNotes |
db, noteIds |
Map<string, string> |
Batch fetch — returns Map of noteId → token for hydration (only token needed for NoteCard indicator) |
SharedNoteData type (returned to public viewers):
interface SharedNoteData {
title: string;
content: string;
checklistMode: boolean;
color: NoteColor;
attachments: Attachment[];
createdAt: Date;
updatedAt: Date;
}
API Endpoints
POST /api/notes/[id]/share
- Auth: Required (owner only)
- Body: None
- Response:
{ token: string, url: string } - Behavior: Creates share or returns existing one. URL constructed from request origin +
/s/${token}
DELETE /api/notes/[id]/share
- Auth: Required (owner only)
- Response:
204 No Content - Behavior: Deletes share row. Link immediately returns 404.
GET /api/shared/[token]
- Auth: None (public)
- Rate limit: 60 requests/min per IP (in-memory)
- Response:
SharedNoteDataJSON - Error cases:
- Invalid/unknown token → 404
- Note is trashed → 404
- Rate limited → 429
GET /api/shared/[token]/attachment/[attachmentId]
- Auth: None (public)
- Rate limit: Same as above
- Response: Image binary (proxied from attachment storage)
- Behavior: Verify attachment belongs to the shared note, then serve
Routes
src/routes/
(share)/
+layout.svelte # Minimal layout: parchment bg, centered, no app chrome
s/[token]/
+page.server.ts # Load shared note by token via shares-service
+page.svelte # Read-only note renderer
Auth Middleware
Add /s/ and /api/shared to PUBLIC_PATHS in src/hooks.server.ts. Both are needed — /s/ for the public page route, /api/shared for the public API endpoints.
Public Page Design
- Parchment background (
--bg-base) - Centered content card (
--bg-surface,--border-subtle,--card-shadow) - Note title in JetBrains Mono
- Rendered markdown content or checklist (read-only)
- Inline images from attachments
- No tags, no edit controls, no sidebar, no navigation
- Footer: "POWERED BY CRUMBS" in Press Start 2P pixel font, links to project
UI Components
ShareDialog Changes
Add a "Public link" section above the existing collaborators section:
Inactive state:
- Globe icon + "Public link" label + toggle switch (off)
- Subtitle: "Anyone with the link can view this note (read-only)"
Active state:
- Globe icon + "Public link" label + toggle switch (on, gold)
- URL input (readonly) + "Copy" button
- Subtitle: "Toggle off to revoke access. The link will return a 404."
- Border highlight in
--primarygold
Toggle on → calls POST /api/notes/[id]/share, displays URL
Toggle off → calls DELETE /api/notes/[id]/share, hides URL
Copy button → clipboard API, brief "Copied!" feedback
NoteCard Share Indicator
Single icon slot in top-right of NoteCard (replaces showing both icons):
| State | Icon | Color |
|---|---|---|
| Public link active | Globe (Lucide) |
--primary gold |
| Collaborators only | Users (Lucide) |
--text-muted |
| Both | Globe (Lucide) |
--primary gold |
| Neither | No icon | — |
Hover tooltip shows combined sharing info regardless of which icon is displayed:
- "Shared publicly via link" (if public share exists)
- "Shared with N collaborator(s)" (if collaborators exist)
- Both lines if both exist
NoteEditor Share Button
Existing Share button behavior unchanged. The Globe/Users icon logic from NoteCard also applies to the editor toolbar button to indicate current sharing state.
Rate Limiting
New file: src/lib/server/ip-rate-limit.ts — separate from the existing DB-backed rate-limit.ts (which is login-specific).
// In-memory store: Map<IP, { count: number, windowStart: number }>
// 60 requests per minute per IP
// Applied to GET /api/shared/[token] and GET /api/shared/[token]/attachment/[id]
Resets on server restart. No database table needed.
Hydration
hydrateNotes() in notes-service.ts must be updated to call fetchSharesForNotes() and populate shareToken on each note. This follows the existing pattern where hydrateNotes already batch-fetches tags, attachments, and collaborators.
Security
- Tokens are unguessable: nanoid(21) = ~128 bits of entropy
/s/and/api/sharedroutes bypass auth middleware — only these prefixes- Trashed notes return 404 even if share token is valid
- Only note owners can create/revoke shares — collaborators cannot
- No user information exposed on public page (no username, no email, no tags)
- Rate limiting prevents brute-force enumeration
- Attachments served through share-specific route (validates attachment belongs to shared note)
Out of Scope (v1)
- Expiry dates / auto-expire
- Password-protected shares
- Edit access through share links
- Share analytics / view counts
- Email notifications for share events
- E2EE interaction (no E2EE exists)
Acceptance Criteria
- Share toggle in ShareDialog creates/revokes public link
- Unique nanoid(21) token generated per shared note
- Public
/s/[token]route renders note without auth - Copy link to clipboard with feedback
- Revoke share → link returns 404
- Globe indicator on NoteCard for publicly shared notes
- Combined tooltip showing all sharing state
- Trashed notes return 404 even if shared
- Minimal, parchment-styled public page with "Powered by Crumbs"
- In-memory rate limiting on public endpoints
- Attachments accessible on public page