Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ on:
paths:
- 'apps/marketing/**'
- 'apps/portal/**'
- 'apps/paste-service/**'
- 'packages/**'
workflow_dispatch:
inputs:
Expand All @@ -19,6 +20,7 @@ on:
- all
- marketing
- portal
- paste

permissions:
id-token: write
Expand All @@ -30,6 +32,7 @@ jobs:
outputs:
marketing: ${{ steps.changes.outputs.marketing }}
portal: ${{ steps.changes.outputs.portal }}
paste: ${{ steps.changes.outputs.paste }}
steps:
- uses: actions/checkout@v4

Expand All @@ -47,12 +50,18 @@ jobs:
else
echo "portal=false" >> $GITHUB_OUTPUT
fi
if [[ "${{ inputs.target }}" == "all" || "${{ inputs.target }}" == "paste" ]]; then
echo "paste=true" >> $GITHUB_OUTPUT
else
echo "paste=false" >> $GITHUB_OUTPUT
fi
else
# For push events, check what changed
git fetch origin ${{ github.event.before }} --depth=1 2>/dev/null || true

MARKETING_CHANGED=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }} 2>/dev/null | grep -E '^(apps/marketing/|packages/)' || true)
PORTAL_CHANGED=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }} 2>/dev/null | grep -E '^(apps/portal/|packages/)' || true)
PASTE_CHANGED=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }} 2>/dev/null | grep -E '^apps/paste-service/' || true)

if [[ -n "$MARKETING_CHANGED" ]]; then
echo "marketing=true" >> $GITHUB_OUTPUT
Expand All @@ -65,6 +74,12 @@ jobs:
else
echo "portal=false" >> $GITHUB_OUTPUT
fi

if [[ -n "$PASTE_CHANGED" ]]; then
echo "paste=true" >> $GITHUB_OUTPUT
else
echo "paste=false" >> $GITHUB_OUTPUT
fi
fi

deploy-marketing:
Expand Down Expand Up @@ -132,3 +147,25 @@ jobs:
aws cloudfront create-invalidation \
--distribution-id EP0KB9EFUWYXR \
--paths "/*"

deploy-paste:
needs: detect-changes
if: needs.detect-changes.outputs.paste == 'true'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4

- uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Install dependencies
run: bun install

- name: Deploy to Cloudflare
working-directory: apps/paste-service
run: npx wrangler deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ claude --plugin-dir ./apps/hook
| `PLANNOTATOR_PORT` | Fixed port to use. Default: random locally, `19432` for remote sessions. |
| `PLANNOTATOR_BROWSER` | Custom browser to open plans in. macOS: app name or path. Linux/Windows: executable path. |
| `PLANNOTATOR_SHARE_URL` | Custom base URL for share links (self-hosted portal). Default: `https://share.plannotator.ai`. |
| `PLANNOTATOR_PASTE_URL` | Base URL of the paste service API for short URL sharing. Default: `https://paste.plannotator.ai`. |
| `PLANNOTATOR_PASTE_URL` | Base URL of the paste service API for short URL sharing. Default: `https://plannotator-paste.plannotator.workers.dev`. |

**Legacy:** `SSH_TTY` and `SSH_CONNECTION` are still detected. Prefer `PLANNOTATOR_REMOTE=1` for explicit control.

Expand Down
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,16 @@ Interactive Plan Review for AI Coding Agents. Mark up and refine your plans usin

Plannotator lets you privately share plans, annotations, and feedback with colleagues. For example, a colleague can annotate a shared plan, and you can import their feedback to send directly back to the coding agent.

Plans are shared via compressed URL through a static site: **share.plannotator.ai**
**Small plans** are encoded entirely in the URL hash — no server involved, nothing stored anywhere. The data never leaves your browser.

- No backend or database; nothing is stored
- The site's deployment is open source
- You can self-host your own share site and point Plannotator to it via an environment variable ([see docs](https://plannotator.ai/docs/guides/sharing-and-collaboration/))
**Large plans** use a short link service with **end-to-end encryption**. Your plan is encrypted with AES-256-GCM in your browser before it's uploaded — the server stores only ciphertext it cannot read. The decryption key lives only in the URL you share and is never sent to the server. Pastes auto-delete after 7 days.

- Zero-knowledge storage — not even the service operator can read stored plans (similar to [PrivateBin](https://privatebin.info/))
- No accounts, no tracking, no cookies on the share portal
- Fully open source and self-hostable ([see docs](https://plannotator.ai/docs/guides/sharing-and-collaboration/))

> [!NOTE]
> [share.plannotator.ai](https://share.plannotator.ai) uses a default fallback (demo) plan that is hard-coded into the site. This isn't a leaked plan—the site has no storage layer.
> Short links are end-to-end encrypted. A single-use AES-256-GCM key is generated in your browser via the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/generateKey), used to encrypt the plan, and embedded in the URL fragment (`#key=...`). The key never leaves the browser — it is never sent to the paste service or any server. Only someone with the exact URL can decrypt the plan.

## Install

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,8 @@ The hash fragment of a URL is never sent to a server in HTTP requests — that's
This means:

- **No accounts.** No sign-ups, no OAuth, no tokens.
- **No storage.** Nothing is persisted anywhere. Close the tab and the data exists only in the URL you copied.
- **No storage (small plans).** Nothing is persisted anywhere. Close the tab and the data exists only in the URL you copied.
- **End-to-end encrypted (large plans).** When a plan is too large for a URL, short links encrypt your plan with AES-256-GCM in your browser before uploading. The paste service stores only ciphertext it cannot read — the decryption key lives only in the URL you share. Pastes auto-delete after 7 days.
- **No tracking.** The share portal has no analytics, no cookies, no telemetry.
- **Self-hostable.** If even a static page hosted by someone else isn't acceptable, you can [self-host the portal](/docs/guides/self-hosting/) and point Plannotator at it with `PLANNOTATOR_SHARE_URL`.

Expand Down
4 changes: 2 additions & 2 deletions apps/marketing/src/content/docs/guides/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@ Plannotator has three components. Only the hook is required.

Small plans are encoded entirely in the URL hash — the share portal reads the hash and renders the plan. No backend involved. The data remains private — it never leaves the URL.

Large plans don't fit in a URL. **When a user explicitly confirms** short link creation, the compressed plan is sent to the paste service, which stores it and returns a short ID. A compressed plan goes in, a link to retrieve it comes out. The share URL becomes `share.plannotator.ai/p/aBcDeFgH` (or `your-portal.example.com/p/aBcDeFgH` if self-hosting). When someone opens that link, the portal fetches the compressed data from the paste service, decompresses it, and renders the plan.
Large plans don't fit in a URL. **When a user explicitly confirms** short link creation, the plan is **encrypted in the browser** (AES-256-GCM) before being sent to the paste service, which stores only the ciphertext and returns a short ID. The decryption key is embedded in the URL fragment (`#key=...`) and never sent to the server — not even the paste service operator can read stored plans. When someone opens that link, the portal fetches the ciphertext, decrypts it client-side using the key from the URL, and renders the plan.

**Without paste service:** Sharing still works for plans that fit in a URL. Those plans stay completely private — the data lives only in the URL hash and never touches a server. Large plans show a warning that the URL may be truncated by messaging apps.

**With paste service:** Large plans get short, reliable URLs that work everywhere. Plannotator temporarily stores the compressed plan data — it auto-deletes after the configured TTL.
**With paste service:** Large plans get short, reliable URLs that work everywhere. Data is end-to-end encrypted and auto-deletes after the configured TTL.

## 1. Install the Hook

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,12 @@ When a plan is too large for a URL (~2KB+ compressed), messaging apps like Slack
5. A short URL like `share.plannotator.ai/p/aBcDeFgH` is generated
6. Both the short URL and the full hash URL are shown — the short URL is safe for messaging apps

### Privacy
### Privacy & encryption

- Plans are **end-to-end encrypted** (AES-256-GCM) in your browser before upload — the paste service stores only ciphertext it cannot read
- A single-use encryption key is generated in your browser via the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/generateKey). The key **never leaves the browser** — it is never sent to the paste service or any server. It exists only in the URL fragment (`#key=...`), which browsers never include in HTTP requests per the HTTP specification. Not even the service operator can decrypt stored plans.
- Plans are only uploaded when you explicitly click "Create short link" — no data leaves your machine until you confirm
- Pastes auto-expire and are permanently deleted (hosted: a few days, self-hosted: configurable via `PASTE_TTL_DAYS`)
- Pastes auto-expire and are permanently deleted (hosted: 7 days, self-hosted: configurable via `PASTE_TTL_DAYS`)
- The paste service is fully open source — you can audit exactly what it does
- Self-hosters can run their own paste service for complete control — see the [self-hosting guide](/docs/guides/self-hosting/)
- If the paste service is unavailable, the full hash URL is always available as fallback
Expand All @@ -88,3 +90,4 @@ By default, share URLs point to `https://share.plannotator.ai`. You can self-hos
- The share portal is a static page — it only reads the hash and renders client-side
- No analytics, no tracking, no cookies on the share portal
- Short URLs are opt-in — data is only uploaded when you explicitly click "Create short link" (see [Short URLs for large plans](#short-urls-for-large-plans) for details)
- Short URLs use end-to-end encryption (AES-256-GCM) — the decryption key is embedded in the URL fragment and never sent to the server. The paste service stores only opaque ciphertext, similar to [PrivateBin](https://privatebin.info/)
2 changes: 1 addition & 1 deletion apps/marketing/src/content/docs/reference/api-endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ Body:

Stores compressed plan data for short URL sharing. Runs as a separate service from the plan/review/annotate servers.

Default: `https://paste.plannotator.ai` (or self-hosted)
Default: `https://plannotator-paste.plannotator.workers.dev` (or self-hosted)

| Endpoint | Method | Purpose |
|----------|--------|---------|
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ All Plannotator environment variables and their defaults.

| Variable | Default | Description |
|----------|---------|-------------|
| `PLANNOTATOR_PASTE_URL` | `https://paste.plannotator.ai` | Base URL of the paste service API. Set this when self-hosting the paste service. |
| `PLANNOTATOR_PASTE_URL` | `https://plannotator-paste.plannotator.workers.dev` | Base URL of the paste service API. Set this when self-hosting the paste service. |

### Self-hosted paste service

Expand Down
2 changes: 1 addition & 1 deletion apps/paste-service/core/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ export async function handleRequest(
{
headers: {
...cors,
"Cache-Control": "public, max-age=3600",
"Cache-Control": "private, no-store",
},
}
);
Expand Down
4 changes: 2 additions & 2 deletions apps/paste-service/wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ compatibility_date = "2024-12-01"
[[kv_namespaces]]
binding = "PASTE_KV"
# Run `wrangler kv:namespace create PASTE_KV` to get your IDs and fill them in.
id = "REPLACE_WITH_KV_NAMESPACE_ID"
preview_id = "REPLACE_WITH_PREVIEW_KV_NAMESPACE_ID"
id = "9bc2647f6f5244499c26c90d87a743a0"
preview_id = "6efae5ac33c4443ba8f0a0b83a2eb111"

[vars]
ALLOWED_ORIGINS = "https://share.plannotator.ai,http://localhost:3001"
97 changes: 97 additions & 0 deletions packages/shared/crypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* AES-256-GCM encryption for zero-knowledge paste storage.
*
* Uses Web Crypto API — works in browsers, Bun, and edge runtimes.
* The key never leaves the client; it lives in the URL fragment.
*/

/**
* Encrypt a compressed base64url string with a fresh AES-256-GCM key.
*
* Returns { ciphertext, key } where:
* - ciphertext: base64url-encoded (12-byte IV prepended to GCM output)
* - key: base64url-encoded 256-bit key for the URL fragment
*/
export async function encrypt(
compressedData: string
): Promise<{ ciphertext: string; key: string }> {
const cryptoKey = await crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true,
['encrypt']
);

const iv = crypto.getRandomValues(new Uint8Array(12));
const plaintext = new TextEncoder().encode(compressedData);

const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
cryptoKey,
plaintext
);

// Prepend IV to ciphertext (IV || ciphertext+tag)
const combined = new Uint8Array(iv.length + encrypted.byteLength);
combined.set(iv, 0);
combined.set(new Uint8Array(encrypted), iv.length);

const rawKey = await crypto.subtle.exportKey('raw', cryptoKey);

return {
ciphertext: bytesToBase64url(combined),
key: bytesToBase64url(new Uint8Array(rawKey)),
};
}

/**
* Decrypt a ciphertext string using a base64url-encoded AES-256-GCM key.
*
* Expects ciphertext format: base64url(IV || encrypted+tag)
* Returns the original compressed base64url string.
*/
export async function decrypt(
ciphertext: string,
key: string
): Promise<string> {
const combined = base64urlToBytes(ciphertext);
const rawKey = base64urlToBytes(key);

const iv = combined.slice(0, 12);
const encrypted = combined.slice(12);

const cryptoKey = await crypto.subtle.importKey(
'raw',
rawKey,
{ name: 'AES-GCM', length: 256 },
false,
['decrypt']
);

const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
cryptoKey,
encrypted
);

return new TextDecoder().decode(decrypted);
}

// --- Helpers ---

function bytesToBase64url(bytes: Uint8Array): string {
// Loop to avoid RangeError on large payloads (same approach as compress.ts)
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}

function base64urlToBytes(b64: string): Uint8Array {
const base64 = b64.replace(/-/g, '+').replace(/_/g, '/');
const binary = atob(base64);
return Uint8Array.from(binary, c => c.charCodeAt(0));
}
3 changes: 2 additions & 1 deletion packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"version": "0.0.1",
"private": true,
"exports": {
"./compress": "./compress.ts"
"./compress": "./compress.ts",
"./crypto": "./crypto.ts"
}
}
6 changes: 3 additions & 3 deletions packages/ui/components/ExportModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ export const ExportModal: React.FC<ExportModalProps> = ({
</button>
</div>
<p className="text-[10px] text-muted-foreground mt-1">
Short link — safe for Slack, email, and messaging apps.
Encrypted short link. Your plan is end-to-end encrypted before it leaves your browser — not even the server can read it.
</p>
</div>
) : isGeneratingShortUrl ? (
Expand Down Expand Up @@ -334,13 +334,13 @@ export const ExportModal: React.FC<ExportModalProps> = ({
</div>
{!shortShareUrl && !isGeneratingShortUrl && !urlIsLarge && (
<p className="text-[10px] text-muted-foreground mt-1">
This URL contains the full plan and annotations.
Your plan is encoded entirely in the URL — it never touches a server.
</p>
)}
</div>

<p className="text-xs text-muted-foreground">
Anyone with this link can view and add to your annotations.
Only someone with this exact link can view your plan. Short links are end-to-end encrypted — the decryption key is in the URL and never sent to the server.
</p>
</div>
) : activeTab === 'notes' && showNotesTab ? (
Expand Down
18 changes: 13 additions & 5 deletions packages/ui/hooks/useSharing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,12 @@ export function useSharing(
const pathMatch = window.location.pathname.match(/^\/p\/([A-Za-z0-9]{6,16})$/);
if (pathMatch) {
const pasteId = pathMatch[1];
const payload = await loadFromPasteId(pasteId, pasteApiUrl);

// Extract encryption key from URL fragment: #key=<base64url>
const fragment = window.location.hash.slice(1);
const encryptionKey = fragment.startsWith('key=') ? fragment.slice(4) : undefined;

const payload = await loadFromPasteId(pasteId, pasteApiUrl, encryptionKey);
if (payload) {
setMarkdown(payload.p);

Expand All @@ -128,7 +133,9 @@ export function useSharing(

return true;
}
// Paste fetch failed — fall through to try the hash fragment instead.
// Paste fetch failed — short URL path can't fall back to hash parsing
// (the hash contains #key=, not plan data).
return false;
}

const payload = await parseShareHash();
Expand Down Expand Up @@ -254,11 +261,12 @@ export function useSharing(
try {
let payload: SharePayload | undefined;

// Check for short URL pattern: /p/<id>
const shortMatch = url.match(/\/p\/([A-Za-z0-9]{6,16})(?:\?|$)/);
// Check for short URL pattern: /p/<id> with optional #key=<key> fragment
const shortMatch = url.match(/\/p\/([A-Za-z0-9]{6,16})(?:#key=([A-Za-z0-9_-]+))?(?:\?|#|$)/);
if (shortMatch) {
const pasteId = shortMatch[1];
const loaded = await loadFromPasteId(pasteId, pasteApiUrl);
const encryptionKey = shortMatch[2]; // undefined if no key fragment
const loaded = await loadFromPasteId(pasteId, pasteApiUrl, encryptionKey);
if (!loaded) {
return { success: false, count: 0, planTitle: '', error: 'Failed to load from short URL — paste may have expired' };
}
Expand Down
Loading