Skip to content

Commit ea758f9

Browse files
backnotpropclaude
andauthored
Add configurable paste service URL for self-hosting (#582)
* Wire PLANNOTATOR_PASTE_URL through opencode/pi servers and Landing demo link OpenCode plugin only read PLANNOTATOR_SHARE_URL; add a getPasteApiUrl helper and thread it into plan/annotate/archive server starts. Pi extension's serverReview gains the same shareBaseUrl/pasteApiUrl env-var pair already used by serverPlan/serverAnnotate. Landing.tsx now accepts a shareBaseUrl prop for self-hosters' demo link. Paste-service CORS defaults grow a comment clarifying that self-hosters must override ALLOWED_ORIGINS. * Embed custom paste origin in short URL fragment When PLANNOTATOR_PASTE_URL is set to a non-default paste service, the generated short link now includes a base64url-encoded paste param in the fragment (#key=...&paste=...). The share portal and importFromShareUrl extract it on load so they can fetch from the right paste backend without needing a server — fixing broken short links for self-hosters who use a custom paste service but keep the hosted share portal. Backward compatible: links without a paste param continue to use the default or server-provided paste API URL as before. For provenance purposes, this commit was AI assisted. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0fe6c86 commit ea758f9

File tree

10 files changed

+51
-13
lines changed

10 files changed

+51
-13
lines changed

apps/opencode-plugin/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ Restart OpenCode. The `submit_plan` tool is now available.
6060
| `PLANNOTATOR_PORT` | Fixed port to use. Default: random locally, `19432` for remote sessions. |
6161
| `PLANNOTATOR_BROWSER` | Custom browser to open plans in. macOS: app name or path. Linux/Windows: executable path. |
6262
| `PLANNOTATOR_SHARE_URL` | Custom share portal URL for self-hosting. Default: `https://share.plannotator.ai`. |
63+
| `PLANNOTATOR_PASTE_URL` | Custom paste service URL for self-hosting. Default: `https://plannotator-paste.plannotator.workers.dev`. |
6364
| `PLANNOTATOR_PLAN_TIMEOUT_SECONDS` | Timeout for `submit_plan` review wait. Default: `345600` (96h). Set `0` to disable timeout. |
6465
6566
## Devcontainer / Docker

apps/opencode-plugin/commands.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export interface CommandDeps {
3434
reviewHtmlContent: string;
3535
getSharingEnabled: () => Promise<boolean>;
3636
getShareBaseUrl: () => string | undefined;
37+
getPasteApiUrl: () => string | undefined;
3738
directory?: string;
3839
}
3940

@@ -147,7 +148,7 @@ export async function handleAnnotateCommand(
147148
event: any,
148149
deps: CommandDeps
149150
) {
150-
const { client, htmlContent, getSharingEnabled, getShareBaseUrl } = deps;
151+
const { client, htmlContent, getSharingEnabled, getShareBaseUrl, getPasteApiUrl } = deps;
151152

152153
// @ts-ignore - Event properties contain arguments
153154
const filePath = event.properties?.arguments || event.arguments || "";
@@ -228,6 +229,7 @@ export async function handleAnnotateCommand(
228229
sourceInfo,
229230
sharingEnabled: await getSharingEnabled(),
230231
shareBaseUrl: getShareBaseUrl(),
232+
pasteApiUrl: getPasteApiUrl(),
231233
htmlContent,
232234
onReady: handleAnnotateServerReady,
233235
});
@@ -271,7 +273,7 @@ export async function handleAnnotateLastCommand(
271273
event: any,
272274
deps: CommandDeps
273275
): Promise<string | null> {
274-
const { client, htmlContent, getSharingEnabled, getShareBaseUrl } = deps;
276+
const { client, htmlContent, getSharingEnabled, getShareBaseUrl, getPasteApiUrl } = deps;
275277

276278
// @ts-ignore - Event properties contain sessionID
277279
const sessionId = event.properties?.sessionID;
@@ -317,6 +319,7 @@ export async function handleAnnotateLastCommand(
317319
mode: "annotate-last",
318320
sharingEnabled: await getSharingEnabled(),
319321
shareBaseUrl: getShareBaseUrl(),
322+
pasteApiUrl: getPasteApiUrl(),
320323
htmlContent,
321324
onReady: handleAnnotateServerReady,
322325
});
@@ -336,7 +339,7 @@ export async function handleArchiveCommand(
336339
event: any,
337340
deps: CommandDeps
338341
) {
339-
const { client, htmlContent, getSharingEnabled, getShareBaseUrl } = deps;
342+
const { client, htmlContent, getSharingEnabled, getShareBaseUrl, getPasteApiUrl } = deps;
340343

341344
client.app.log({ level: "info", message: "Opening plan archive..." });
342345

@@ -346,6 +349,7 @@ export async function handleArchiveCommand(
346349
mode: "archive",
347350
sharingEnabled: await getSharingEnabled(),
348351
shareBaseUrl: getShareBaseUrl(),
352+
pasteApiUrl: getPasteApiUrl(),
349353
htmlContent,
350354
onReady: handleServerReady,
351355
});

apps/opencode-plugin/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,10 @@ export const PlannotatorPlugin: Plugin = async (ctx) => {
195195
return process.env.PLANNOTATOR_SHARE_URL || undefined;
196196
}
197197

198+
function getPasteApiUrl(): string | undefined {
199+
return process.env.PLANNOTATOR_PASTE_URL || undefined;
200+
}
201+
198202
function getPlanTimeoutSeconds(): number | null {
199203
const raw = process.env.PLANNOTATOR_PLAN_TIMEOUT_SECONDS?.trim();
200204
if (!raw) return DEFAULT_PLAN_TIMEOUT_SECONDS;
@@ -363,6 +367,7 @@ Do NOT proceed with implementation until your plan is approved.`);
363367
reviewHtmlContent: getReviewHtml(),
364368
getSharingEnabled,
365369
getShareBaseUrl,
370+
getPasteApiUrl,
366371
directory: ctx.directory,
367372
};
368373

@@ -405,6 +410,7 @@ Do NOT proceed with implementation until your plan is approved.`);
405410
reviewHtmlContent: getReviewHtml(),
406411
getSharingEnabled,
407412
getShareBaseUrl,
413+
getPasteApiUrl,
408414
directory: ctx.directory,
409415
};
410416

@@ -448,6 +454,7 @@ Do NOT proceed with implementation until your plan is approved.`);
448454
origin: "opencode",
449455
sharingEnabled,
450456
shareBaseUrl: getShareBaseUrl(),
457+
pasteApiUrl: getPasteApiUrl(),
451458
htmlContent: getPlanHtml(),
452459
opencodeClient: ctx.client,
453460
onReady: async (url, isRemote, port) => {

apps/paste-service/core/cors.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ const BASE_CORS_HEADERS = {
44
"Access-Control-Max-Age": "86400",
55
};
66

7+
// Defaults target the hosted plannotator.ai deployment.
8+
// Self-hosters should set PASTE_ALLOWED_ORIGINS (Bun) or ALLOWED_ORIGINS (Cloudflare)
9+
// to their own portal origin so requests from the hosted share.plannotator.ai
10+
// portal are not granted CORS access against their service.
711
export function getAllowedOrigins(envValue?: string): string[] {
812
if (envValue) {
913
return envValue.split(",").map((o) => o.trim());

apps/paste-service/wrangler.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,7 @@ id = "9bc2647f6f5244499c26c90d87a743a0"
99
preview_id = "6efae5ac33c4443ba8f0a0b83a2eb111"
1010

1111
[vars]
12+
# Default values target the hosted plannotator.ai deployment.
13+
# Self-hosters must override ALLOWED_ORIGINS to point at their own portal,
14+
# either by editing this file or setting it via `wrangler secret put`.
1215
ALLOWED_ORIGINS = "https://share.plannotator.ai,http://localhost:3001"

apps/pi-extension/plannotator-browser.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,7 @@ export async function openCodeReview(
356356
htmlContent: reviewHtmlContent,
357357
sharingEnabled: process.env.PLANNOTATOR_SHARE !== "disabled",
358358
shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined,
359+
pasteApiUrl: process.env.PLANNOTATOR_PASTE_URL || undefined,
359360
onCleanup: worktreeCleanup,
360361
});
361362

apps/pi-extension/server/serverReview.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ export async function startReviewServer(options: {
147147
error?: string;
148148
sharingEnabled?: boolean;
149149
shareBaseUrl?: string;
150+
pasteApiUrl?: string;
150151
prMetadata?: PRMetadata;
151152
/** Working directory for agent processes (e.g., --local worktree). Independent of diff pipeline. */
152153
agentCwd?: string;
@@ -281,6 +282,8 @@ export async function startReviewServer(options: {
281282
options.sharingEnabled ?? process.env.PLANNOTATOR_SHARE !== "disabled";
282283
const shareBaseUrl =
283284
(options.shareBaseUrl ?? process.env.PLANNOTATOR_SHARE_URL) || undefined;
285+
const pasteApiUrl =
286+
(options.pasteApiUrl ?? process.env.PLANNOTATOR_PASTE_URL) || undefined;
284287
let resolveDecision!: (result: {
285288
approved: boolean;
286289
feedback: string;
@@ -421,6 +424,7 @@ export async function startReviewServer(options: {
421424
gitContext: hasLocalAccess ? options.gitContext : undefined,
422425
sharingEnabled,
423426
shareBaseUrl,
427+
pasteApiUrl,
424428
repoInfo,
425429
isWSL: wslFlag,
426430
...(options.agentCwd && { agentCwd: options.agentCwd }),

packages/ui/components/Landing.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import { ModeToggle } from "./ModeToggle";
44

55
interface LandingProps {
66
onEnter?: () => void;
7+
shareBaseUrl?: string;
78
}
89

9-
export const Landing: React.FC<LandingProps> = ({ onEnter }) => {
10+
export const Landing: React.FC<LandingProps> = ({ onEnter, shareBaseUrl }) => {
11+
const demoUrl = shareBaseUrl || "https://share.plannotator.ai";
1012
return (
1113
<div className="min-h-screen bg-background text-foreground">
1214
{/* Nav */}
@@ -112,7 +114,7 @@ export const Landing: React.FC<LandingProps> = ({ onEnter }) => {
112114
</button>
113115
) : (
114116
<a
115-
href="https://share.plannotator.ai"
117+
href={demoUrl}
116118
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-primary text-primary-foreground font-medium hover:opacity-90 transition-opacity"
117119
>
118120
Open Demo

packages/ui/hooks/useSharing.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,15 @@ export function useSharing(
114114
if (pathMatch) {
115115
const pasteId = pathMatch[1];
116116

117-
// Extract encryption key from URL fragment: #key=<base64url>
117+
// Extract key and optional paste origin from fragment: #key=<k>&paste=<base64url>
118118
const fragment = window.location.hash.slice(1);
119-
const encryptionKey = fragment.startsWith('key=') ? fragment.slice(4) : undefined;
119+
const params = new URLSearchParams(fragment);
120+
const encryptionKey = params.get('key') ?? undefined;
121+
const pasteFromFragment = params.get('paste')
122+
? atob(params.get('paste')!.replace(/-/g, '+').replace(/_/g, '/'))
123+
: undefined;
120124

121-
const payload = await loadFromPasteId(pasteId, pasteApiUrl, encryptionKey);
125+
const payload = await loadFromPasteId(pasteId, pasteFromFragment ?? pasteApiUrl, encryptionKey);
122126
if (payload) {
123127
setMarkdown(payload.p);
124128

@@ -279,11 +283,15 @@ export function useSharing(
279283
let payload: SharePayload | undefined;
280284

281285
// Check for short URL pattern: /p/<id> with optional #key=<key> fragment
282-
const shortMatch = url.match(/\/p\/([A-Za-z0-9]{6,16})(?:#key=([A-Za-z0-9_-]+))?(?:\?|#|$)/);
286+
const shortMatch = url.match(/\/p\/([A-Za-z0-9]{6,16})(?:#(.*))?(?:\?|$)/);
283287
if (shortMatch) {
284288
const pasteId = shortMatch[1];
285-
const encryptionKey = shortMatch[2]; // undefined if no key fragment
286-
const loaded = await loadFromPasteId(pasteId, pasteApiUrl, encryptionKey);
289+
const fragParams = new URLSearchParams(shortMatch[2] ?? '');
290+
const encryptionKey = fragParams.get('key') ?? undefined;
291+
const pasteFromFragment = fragParams.get('paste')
292+
? atob(fragParams.get('paste')!.replace(/-/g, '+').replace(/_/g, '/'))
293+
: undefined;
294+
const loaded = await loadFromPasteId(pasteId, pasteFromFragment ?? pasteApiUrl, encryptionKey);
287295
if (!loaded) {
288296
return { success: false, count: 0, planTitle: '', error: 'Failed to load from short URL — paste may have expired' };
289297
}

packages/ui/utils/sharing.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -267,8 +267,12 @@ export async function createShortShareUrl(
267267
}
268268

269269
const result = (await response.json()) as { id: string };
270-
// Key in fragment — never sent to server per HTTP spec
271-
const shortUrl = `${shareBase}/p/${result.id}#key=${key}`;
270+
// Embed paste origin in fragment when non-default so the share portal can
271+
// fetch from the right service without a server.
272+
const pasteParam = pasteApi !== DEFAULT_PASTE_API
273+
? `&paste=${btoa(pasteApi).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')}`
274+
: '';
275+
const shortUrl = `${shareBase}/p/${result.id}#key=${key}${pasteParam}`;
272276

273277
return { shortUrl, id: result.id };
274278
} catch (e) {

0 commit comments

Comments
 (0)