Skip to content

Commit 0309c54

Browse files
backnotpropclaude
andcommitted
feat: Deploy paste service with end-to-end encryption (zero-knowledge)
Add AES-256-GCM client-side encryption to the short URL sharing flow. Plans are encrypted in the browser before upload — the paste service stores only ciphertext it cannot read. The decryption key lives only in the URL fragment (#key=...) and never leaves the browser. - New packages/shared/crypto.ts with encrypt/decrypt via Web Crypto API - Encrypt before POST in createShortShareUrl, decrypt on load - Parse #key= fragment in useSharing.ts for both direct and import URLs - Deploy Cloudflare Worker with KV namespaces to workers.dev - Add deploy-paste CI/CD job to deploy.yml - Fix Cache-Control from public to private, no-store - Update ExportModal strings with encryption messaging - Update README, docs, and blog with zero-knowledge encryption details Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bf4589e commit 0309c54

15 files changed

Lines changed: 194 additions & 30 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ on:
77
paths:
88
- 'apps/marketing/**'
99
- 'apps/portal/**'
10+
- 'apps/paste-service/**'
1011
- 'packages/**'
1112
workflow_dispatch:
1213
inputs:
@@ -19,6 +20,7 @@ on:
1920
- all
2021
- marketing
2122
- portal
23+
- paste
2224

2325
permissions:
2426
id-token: write
@@ -30,6 +32,7 @@ jobs:
3032
outputs:
3133
marketing: ${{ steps.changes.outputs.marketing }}
3234
portal: ${{ steps.changes.outputs.portal }}
35+
paste: ${{ steps.changes.outputs.paste }}
3336
steps:
3437
- uses: actions/checkout@v4
3538

@@ -47,12 +50,18 @@ jobs:
4750
else
4851
echo "portal=false" >> $GITHUB_OUTPUT
4952
fi
53+
if [[ "${{ inputs.target }}" == "all" || "${{ inputs.target }}" == "paste" ]]; then
54+
echo "paste=true" >> $GITHUB_OUTPUT
55+
else
56+
echo "paste=false" >> $GITHUB_OUTPUT
57+
fi
5058
else
5159
# For push events, check what changed
5260
git fetch origin ${{ github.event.before }} --depth=1 2>/dev/null || true
5361
5462
MARKETING_CHANGED=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }} 2>/dev/null | grep -E '^(apps/marketing/|packages/)' || true)
5563
PORTAL_CHANGED=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }} 2>/dev/null | grep -E '^(apps/portal/|packages/)' || true)
64+
PASTE_CHANGED=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }} 2>/dev/null | grep -E '^apps/paste-service/' || true)
5665
5766
if [[ -n "$MARKETING_CHANGED" ]]; then
5867
echo "marketing=true" >> $GITHUB_OUTPUT
@@ -65,6 +74,12 @@ jobs:
6574
else
6675
echo "portal=false" >> $GITHUB_OUTPUT
6776
fi
77+
78+
if [[ -n "$PASTE_CHANGED" ]]; then
79+
echo "paste=true" >> $GITHUB_OUTPUT
80+
else
81+
echo "paste=false" >> $GITHUB_OUTPUT
82+
fi
6883
fi
6984
7085
deploy-marketing:
@@ -132,3 +147,25 @@ jobs:
132147
aws cloudfront create-invalidation \
133148
--distribution-id EP0KB9EFUWYXR \
134149
--paths "/*"
150+
151+
deploy-paste:
152+
needs: detect-changes
153+
if: needs.detect-changes.outputs.paste == 'true'
154+
runs-on: ubuntu-latest
155+
environment: production
156+
steps:
157+
- uses: actions/checkout@v4
158+
159+
- uses: oven-sh/setup-bun@v2
160+
with:
161+
bun-version: latest
162+
163+
- name: Install dependencies
164+
run: bun install
165+
166+
- name: Deploy to Cloudflare
167+
working-directory: apps/paste-service
168+
run: npx wrangler deploy
169+
env:
170+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
171+
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ claude --plugin-dir ./apps/hook
7979
| `PLANNOTATOR_PORT` | Fixed port to use. Default: random locally, `19432` for remote sessions. |
8080
| `PLANNOTATOR_BROWSER` | Custom browser to open plans in. macOS: app name or path. Linux/Windows: executable path. |
8181
| `PLANNOTATOR_SHARE_URL` | Custom base URL for share links (self-hosted portal). Default: `https://share.plannotator.ai`. |
82-
| `PLANNOTATOR_PASTE_URL` | Base URL of the paste service API for short URL sharing. Default: `https://paste.plannotator.ai`. |
82+
| `PLANNOTATOR_PASTE_URL` | Base URL of the paste service API for short URL sharing. Default: `https://plannotator-paste.plannotator.workers.dev`. |
8383

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

README.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,19 @@ Interactive Plan Review for AI Coding Agents. Mark up and refine your plans usin
3939

4040
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.
4141

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

44-
- No backend or database; nothing is stored
45-
- The site's deployment is open source
46-
- 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/))
44+
**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.
45+
46+
- Zero-knowledge storage — not even the service operator can read stored plans (similar to [PrivateBin](https://privatebin.info/))
47+
- No accounts, no tracking, no cookies on the share portal
48+
- Fully open source and self-hostable ([see docs](https://plannotator.ai/docs/guides/sharing-and-collaboration/))
49+
50+
> [!NOTE]
51+
> [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.
4752
4853
> [!NOTE]
49-
> [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.
54+
> 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.
5055
5156
## Install
5257

apps/marketing/src/content/blog/sharing-plans-with-your-team.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,8 @@ The hash fragment of a URL is never sent to a server in HTTP requests — that's
108108
This means:
109109

110110
- **No accounts.** No sign-ups, no OAuth, no tokens.
111-
- **No storage.** Nothing is persisted anywhere. Close the tab and the data exists only in the URL you copied.
111+
- **No storage (small plans).** Nothing is persisted anywhere. Close the tab and the data exists only in the URL you copied.
112+
- **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.
112113
- **No tracking.** The share portal has no analytics, no cookies, no telemetry.
113114
- **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`.
114115

apps/marketing/src/content/docs/guides/self-hosting.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,11 @@ Plannotator has three components. Only the hook is required.
2020

2121
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.
2222

23-
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.
23+
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.
2424

2525
**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.
2626

27-
**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.
27+
**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.
2828

2929
## 1. Install the Hook
3030

apps/marketing/src/content/docs/guides/sharing-and-collaboration.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,12 @@ When a plan is too large for a URL (~2KB+ compressed), messaging apps like Slack
7070
5. A short URL like `share.plannotator.ai/p/aBcDeFgH` is generated
7171
6. Both the short URL and the full hash URL are shown — the short URL is safe for messaging apps
7272

73-
### Privacy
73+
### Privacy & encryption
7474

75+
- Plans are **end-to-end encrypted** (AES-256-GCM) in your browser before upload — the paste service stores only ciphertext it cannot read
76+
- 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.
7577
- Plans are only uploaded when you explicitly click "Create short link" — no data leaves your machine until you confirm
76-
- Pastes auto-expire and are permanently deleted (hosted: a few days, self-hosted: configurable via `PASTE_TTL_DAYS`)
78+
- Pastes auto-expire and are permanently deleted (hosted: 7 days, self-hosted: configurable via `PASTE_TTL_DAYS`)
7779
- The paste service is fully open source — you can audit exactly what it does
7880
- Self-hosters can run their own paste service for complete control — see the [self-hosting guide](/docs/guides/self-hosting/)
7981
- If the paste service is unavailable, the full hash URL is always available as fallback
@@ -88,3 +90,4 @@ By default, share URLs point to `https://share.plannotator.ai`. You can self-hos
8890
- The share portal is a static page — it only reads the hash and renders client-side
8991
- No analytics, no tracking, no cookies on the share portal
9092
- 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)
93+
- 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/)

apps/marketing/src/content/docs/reference/api-endpoints.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ Body:
138138

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

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

143143
| Endpoint | Method | Purpose |
144144
|----------|--------|---------|

apps/marketing/src/content/docs/reference/environment-variables.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ All Plannotator environment variables and their defaults.
2323

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

2828
### Self-hosted paste service
2929

apps/paste-service/core/handler.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ export async function handleRequest(
131131
{
132132
headers: {
133133
...cors,
134-
"Cache-Control": "public, max-age=3600",
134+
"Cache-Control": "private, no-store",
135135
},
136136
}
137137
);

apps/paste-service/wrangler.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ compatibility_date = "2024-12-01"
55
[[kv_namespaces]]
66
binding = "PASTE_KV"
77
# Run `wrangler kv:namespace create PASTE_KV` to get your IDs and fill them in.
8-
id = "REPLACE_WITH_KV_NAMESPACE_ID"
9-
preview_id = "REPLACE_WITH_PREVIEW_KV_NAMESPACE_ID"
8+
id = "9bc2647f6f5244499c26c90d87a743a0"
9+
preview_id = "6efae5ac33c4443ba8f0a0b83a2eb111"
1010

1111
[vars]
1212
ALLOWED_ORIGINS = "https://share.plannotator.ai,http://localhost:3001"

0 commit comments

Comments
 (0)