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
57 changes: 45 additions & 12 deletions .claude/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,68 +4,101 @@
{
"name": "pantry-host",
"runtimeExecutable": "sh",
"runtimeArgs": ["-c", "export NVM_DIR=\"$HOME/.nvm\" && . \"$NVM_DIR/nvm.sh\" && nvm use && set -a && source .env.local && export DEFAULT_THEME=claude && set +a && npx @limlabs/rex dev --root packages/app --host 0.0.0.0"],
"runtimeArgs": [
"-c",
"export NVM_DIR=\"$HOME/.nvm\" && . \"$NVM_DIR/nvm.sh\" && nvm use && set -a && source .env.local && export DEFAULT_THEME=claude && set +a && npx @limlabs/rex dev --root packages/app --host 0.0.0.0"
],
"port": 3000
},
{
"name": "pantry-host-mini-prod",
"runtimeExecutable": "sh",
"runtimeArgs": ["-c", "export NVM_DIR=\"$HOME/.nvm\" && . \"$NVM_DIR/nvm.sh\" && nvm use && set -a && source .env.local && export DEFAULT_THEME=claude && export PUBLIC_API_ORIGIN=http://100.125.77.118:4001 && set +a && cd packages/app && npx @limlabs/rex start --host 0.0.0.0"],
"runtimeArgs": [
"-c",
"export NVM_DIR=\"$HOME/.nvm\" && . \"$NVM_DIR/nvm.sh\" && nvm use && set -a && source .env.local && export DEFAULT_THEME=claude && export PUBLIC_API_ORIGIN=http://100.125.77.118:4001 && set +a && cd packages/app && npx @limlabs/rex start --host 0.0.0.0"
],
"port": 3000
},
{
"name": "graphql-server",
"runtimeExecutable": "sh",
"runtimeArgs": ["-c", "export NVM_DIR=\"$HOME/.nvm\" && . \"$NVM_DIR/nvm.sh\" && nvm use && cd packages/app && set -a && source ../../.env.local && set +a && npx tsx graphql-server.ts"],
"runtimeArgs": [
"-c",
"export NVM_DIR=\"$HOME/.nvm\" && . \"$NVM_DIR/nvm.sh\" && nvm use && cd packages/app && set -a && source ../../.env.local && set +a && npx tsx graphql-server.ts"
],
"port": 4001
},
{
"name": "graphql-server-local",
"runtimeExecutable": "sh",
"runtimeArgs": ["-c", "export NVM_DIR=\"$HOME/.nvm\" && . \"$NVM_DIR/nvm.sh\" && nvm use && cd packages/app && set -a && source ../../.env.local && export SQLITE_DB_PATH=./pantry.db && set +a && npx tsx graphql-server.ts"],
"runtimeArgs": [
"-c",
"export NVM_DIR=\"$HOME/.nvm\" && . \"$NVM_DIR/nvm.sh\" && nvm use && cd packages/app && set -a && source ../../.env.local && export SQLITE_DB_PATH=./pantry.db && set +a && npx tsx graphql-server.ts"
],
"port": 4001
},
{
"name": "marketing",
"runtimeExecutable": "sh",
"runtimeArgs": ["-c", "export NVM_DIR=\"$HOME/.nvm\" && . \"$NVM_DIR/nvm.sh\" && nvm use && cd packages/marketing && npx vite --port 5173"],
"runtimeArgs": [
"-c",
"export NVM_DIR=\"$HOME/.nvm\" && . \"$NVM_DIR/nvm.sh\" && nvm use && cd packages/marketing && npx vite --port 5173"
],
"port": 5173
},
{
"name": "marketing-prod",
"runtimeExecutable": "sh",
"runtimeArgs": ["-c", "export NVM_DIR=\"$HOME/.nvm\" && . \"$NVM_DIR/nvm.sh\" && nvm use && cd packages/marketing && npx vite preview --port 5173 --strictPort"],
"runtimeArgs": [
"-c",
"export NVM_DIR=\"$HOME/.nvm\" && . \"$NVM_DIR/nvm.sh\" && nvm use && cd packages/marketing && npx vite preview --port 5173 --strictPort"
],
"port": 5173
},
{
"name": "web",
"runtimeExecutable": "sh",
"runtimeArgs": ["-c", "export NVM_DIR=\"$HOME/.nvm\" && . \"$NVM_DIR/nvm.sh\" && nvm use && cd packages/web && npx vite --port 5174"],
"runtimeArgs": [
"-c",
"export NVM_DIR=\"$HOME/.nvm\" && . \"$NVM_DIR/nvm.sh\" && nvm use && cd packages/web && npx vite --port 5174 --host"
],
"port": 5174
},
{
"name": "mcp-server",
"runtimeExecutable": "sh",
"runtimeArgs": ["-c", "export NVM_DIR=\"$HOME/.nvm\" && . \"$NVM_DIR/nvm.sh\" && nvm use && cd packages/mcp && set -a && source ../../.env.local && set +a && npx tsx src/index.ts --http"],
"runtimeArgs": [
"-c",
"export NVM_DIR=\"$HOME/.nvm\" && . \"$NVM_DIR/nvm.sh\" && nvm use && cd packages/mcp && set -a && source ../../.env.local && set +a && npx tsx src/index.ts --http"
],
"port": 5001
},
{
"name": "pantry-host-prod",
"runtimeExecutable": "sh",
"runtimeArgs": ["-c", "export NVM_DIR=\"$HOME/.nvm\" && . \"$NVM_DIR/nvm.sh\" && nvm use && set -a && source .env.local && export DEFAULT_THEME=claude && set +a && cd packages/app && rex build && rex start --host 0.0.0.0"],
"runtimeArgs": [
"-c",
"export NVM_DIR=\"$HOME/.nvm\" && . \"$NVM_DIR/nvm.sh\" && nvm use && set -a && source .env.local && export DEFAULT_THEME=claude && set +a && cd packages/app && rex build && rex start --host 0.0.0.0"
],
"port": 3000
},
{
"name": "graphql-server-prod",
"runtimeExecutable": "sh",
"runtimeArgs": ["-c", "export NVM_DIR=\"$HOME/.nvm\" && . \"$NVM_DIR/nvm.sh\" && nvm use && cd packages/app && set -a && source ../../.env.local && export SQLITE_DB_PATH=./pantry.db && set +a && npx tsx graphql-server.ts"],
"runtimeArgs": [
"-c",
"export NVM_DIR=\"$HOME/.nvm\" && . \"$NVM_DIR/nvm.sh\" && nvm use && cd packages/app && set -a && source ../../.env.local && export SQLITE_DB_PATH=./pantry.db && set +a && npx tsx graphql-server.ts"
],
"port": 4001
},
{
"name": "graphql-server-rs",
"runtimeExecutable": "sh",
"runtimeArgs": ["-c", "cd packages/server && set -a && [ -f ../../.env.local ] && source ../../.env.local; export SQLITE_DB_PATH=\"${SQLITE_DB_PATH:-../app/pantry.db}\" && set +a && cargo run"],
"runtimeArgs": [
"-c",
"cd packages/server && set -a && [ -f ../../.env.local ] && source ../../.env.local; export SQLITE_DB_PATH=\"${SQLITE_DB_PATH:-../app/pantry.db}\" && set +a && cargo run"
],
"port": 4001
}
]
}
}
1 change: 1 addition & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
"./components/ImageBoundary": "./src/components/ImageBoundary.tsx",
"./sql/schema": "./src/sql/schema.ts",
"./atproto-publish": "./src/atproto-publish.ts",
"./atproto-broker": "./src/atproto-broker.ts",
"./pds-published": "./src/pds-published.ts",
"./contexts/BlueskyAuth": "./src/contexts/BlueskyAuth.tsx",
"./components/BlueskyCallback": "./src/components/BlueskyCallback.tsx",
Expand Down
259 changes: 259 additions & 0 deletions packages/shared/src/atproto-broker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
/**
* Publish broker — lets a self-hosted Pantry Host instance publish to
* Bluesky through a popup on the hosted Tier 1 origin
* (my.pantryhost.app), where AT Protocol OAuth actually works.
*
* Why: AT OAuth requires the client_id to be a publicly-fetchable
* HTTPS document and web-client redirect URIs to live on that origin.
* A LAN / Tailscale / plain-HTTP self-hosted box can't satisfy either,
* so it can never complete the OAuth dance itself (see issue #37).
* The broker popup runs on the public origin, holds the OAuth session
* there, and performs the browser → PDS write on the requester's
* behalf. The flow is:
*
* self-hosted page ──window.open──▶ my.pantryhost.app/publish-broker
* │ │
* │◀───────── ph-broker:ready ────────│
* │────────── ph-broker:request ─────▶│ (payload; Blobs structured-clone)
* │ │ user reviews + CONFIRMS in popup
* │ │──▶ PDS (putRecord, browser-side)
* │◀───────── ph-broker:result ───────│ (receipts, targetOrigin-locked)
*
* Security invariants (keep these when editing):
* - Tokens/DPoP keys NEVER leave the broker origin. Only record
* payloads go in; only receipts come out.
* - The broker accepts exactly ONE request per popup lifetime — no
* payload swapping after the confirm UI renders.
* - The confirm click happens INSIDE the popup (real URL bar, no
* clickjacking), with the requesting origin displayed prominently.
* - Replies use an explicit targetOrigin (the captured requester
* origin), never '*'. The ready ping carries no data, so '*' is
* acceptable there (the popup can't know its opener's origin first).
* - No auto-publish: nothing is written without a user gesture in the
* trusted origin.
*
* The record still travels browser → PDS; Pantry Host infrastructure
* stores nothing. The trade-off is availability/trust coupling to the
* hosted origin — the fully-sovereign alternatives (own public domain,
* app passwords) are tracked in #37.
*/

import type { PublishableMenu, PublishableRecipe } from './atproto-publish';

// ── Protocol ─────────────────────────────────────────────────────────────

export const BROKER_READY = 'ph-broker:ready';
export const BROKER_REQUEST = 'ph-broker:request';
export const BROKER_RESULT = 'ph-broker:result';

export interface BrokerReceipt {
uri: string;
cid: string;
handle: string;
dryRun: boolean;
}

export type BrokerRequest =
| {
type: typeof BROKER_REQUEST;
action: 'publish';
kind: 'recipe';
recipe: PublishableRecipe;
/** Re-publish target for records that predate deterministic
* rkeys — omit to publish at rkeyForLocal(recipe.id). */
rkey?: string;
/** photoUrl → bytes, fetched by the requester (same-origin
* there; the broker origin can't reach a LAN box). */
photoBlobs?: Record<string, Blob>;
dryRun: boolean;
}
| {
type: typeof BROKER_REQUEST;
action: 'publish';
kind: 'menu';
menu: PublishableMenu;
rkey?: string;
/** recipeId → known-good strongRef, so already-published
* children are reused instead of re-published. */
existingRefs?: Record<string, { uri: string; cid: string }>;
photoBlobs?: Record<string, Blob>;
dryRun: boolean;
}
| {
type: typeof BROKER_REQUEST;
action: 'unpublish';
kind: 'recipe' | 'menu';
uri: string;
/** Shown in the confirm UI so the user knows what they're deleting. */
title: string;
dryRun: boolean;
};

export type BrokerResult =
| {
type: typeof BROKER_RESULT;
ok: true;
action: 'publish';
kind: 'recipe' | 'menu';
receipt: BrokerReceipt;
/** Menu publishes: receipts for children published inline, so
* the requester can persist them for future Reuse. */
childReceipts?: Record<string, BrokerReceipt>;
}
| { type: typeof BROKER_RESULT; ok: true; action: 'unpublish' }
| { type: typeof BROKER_RESULT; ok: false; error: 'cancelled' | 'sign-in-failed' | string };

// ── Broker location ──────────────────────────────────────────────────────

export const DEFAULT_BROKER_URL = 'https://my.pantryhost.app/publish-broker';

/** Resolve the broker URL: Vite env (web dev), meta tag (Rex — same
* channel as atproto-publish-dry-run), then the hosted default. */
export function brokerUrl(): string {
try {
// Exact `import.meta.env.VITE_X` token required — Vite's env
// replacement doesn't recognize optional-chained (`?.`) access,
// so that "defensive" spelling silently reads undefined forever.
// The try/catch handles non-Vite bundlers (Rex: env is undefined
// → TypeError → caught).
// @ts-expect-error import.meta.env is a Vite-ism
const vite = import.meta.env.VITE_ATPROTO_BROKER_URL;
if (vite) return String(vite);
} catch {
/* not vite */
}
if (typeof document !== 'undefined') {
const meta = document.querySelector('meta[name="atproto-broker-url"]')?.getAttribute('content');
if (meta) return meta;
}
return DEFAULT_BROKER_URL;
}

/** True when this origin cannot complete AT OAuth itself and should
* publish through the broker popup instead: not the spec loopback
* (127.0.0.1 / [::1]) and not an origin the hosted client's
* redirect_uris cover. Deliberately includes `localhost` — the spec
* rejects it for loopback OAuth, but the broker works fine there. */
export function shouldUseBroker(): boolean {
if (typeof window === 'undefined') return false;
const { hostname, origin } = window.location;
if (hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1') return false;
const brokerOrigin = new URL(brokerUrl()).origin;
// Direct OAuth works on the hosted origins themselves (and on the
// broker origin — a broker popup opening a broker would be silly).
if (origin === brokerOrigin) return false;
if (origin === 'https://my.pantryhost.app' || origin === 'https://pantryhost.app') return false;
return true;
}

// ── Requester-side helpers ───────────────────────────────────────────────

/** Fetch each photoUrl into a Blob from the REQUESTER's context, where
* `/uploads/…` paths are same-origin. Pass `fetchPhoto` to override
* resolution for schemes plain fetch can't reach — the web PWA's
* `opfs://` photos need its OPFS-aware resolver here just like they
* do for direct publishes. Failures are skipped — a photo never
* blocks a publish (same policy as direct publishing). */
export async function collectPhotoBlobs(
photoUrls: Array<string | null | undefined>,
fetchPhoto?: (photoUrl: string) => Promise<Blob | null>,
): Promise<Record<string, Blob>> {
const out: Record<string, Blob> = {};
const unique = [...new Set(photoUrls.filter((u): u is string => !!u))];
await Promise.all(
unique.map(async (url) => {
try {
if (fetchPhoto) {
const blob = await fetchPhoto(url);
if (blob) out[url] = blob;
return;
}
const res = await fetch(url);
if (res.ok) out[url] = await res.blob();
} catch {
/* skip — broker will publish without this photo */
}
}),
);
return out;
}

/** How long the requester waits for the user to finish in the popup.
* Generous: sign-in (an OAuth round-trip) can happen mid-flow. */
const BROKER_TIMEOUT_MS = 5 * 60 * 1000;
const POPUP_FEATURES = 'width=480,height=760,noopener=no';

/**
* Open the broker popup and run one request through it. MUST be called
* synchronously from a user gesture (click handler) or popup blockers
* eat the window — which is why `request` may be a Promise: the popup
* opens immediately on the click tick, and slow prep (photo Blob
* collection) resolves while the popup is still loading.
*
* The broker popup never navigates: OAuth sign-in runs in a NESTED
* popup (BlueskyAuth's signInPopup), because the PDS auth pages send
* COOP headers that would sever window.opener on a redirect round-trip
* and orphan the broker from us. The requester's promise stays pending
* across sign-in; the re-send-on-ready handling below is defensive
* (e.g. a manual reload of the popup before the request is accepted).
*/
export function publishViaBroker(
request: BrokerRequest | Promise<BrokerRequest>,
url = brokerUrl(),
): Promise<BrokerResult> {
const brokerOrigin = new URL(url).origin;
const popup = typeof window !== 'undefined' ? window.open(url, 'ph-publish-broker', POPUP_FEATURES) : null;
if (!popup) {
return Promise.resolve({
type: BROKER_RESULT,
ok: false,
error: 'Popup blocked — allow popups for this site and try again.',
});
}

return new Promise<BrokerResult>((resolve) => {
let settled = false;
const settle = (result: BrokerResult) => {
if (settled) return;
settled = true;
window.removeEventListener('message', onMessage);
clearInterval(closedPoll);
clearTimeout(deadline);
resolve(result);
};

const onMessage = async (event: MessageEvent) => {
if (event.origin !== brokerOrigin || event.source !== popup) return;
const data = event.data as { type?: string } | null;
if (data?.type === BROKER_READY) {
// (Re-)send the payload — targetOrigin locked to the broker.
try {
popup.postMessage(await request, brokerOrigin);
} catch (err) {
settle({ type: BROKER_RESULT, ok: false, error: err instanceof Error ? err.message : 'Failed to prepare the publish payload.' });
popup.close();
}
return;
}
if (data?.type === BROKER_RESULT) {
settle(data as BrokerResult);
popup.close();
}
};
window.addEventListener('message', onMessage);

// User closed the popup without finishing.
const closedPoll = setInterval(() => {
if (popup.closed) settle({ type: BROKER_RESULT, ok: false, error: 'cancelled' });
}, 500);

const deadline = setTimeout(() => {
settle({ type: BROKER_RESULT, ok: false, error: 'Timed out waiting for the publish window.' });
try {
popup.close();
} catch {
/* already gone */
}
}, BROKER_TIMEOUT_MS);
});
}
5 changes: 4 additions & 1 deletion packages/shared/src/atproto-publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,11 @@ const AUTO_TAG_RE = /^(bluesky|recipe-api|cooklang|mealdb|cocktaildb|publicdomai
export function isDryRun(): boolean {
// Vite (web package)
try {
// Exact `import.meta.env.VITE_X` token required — Vite's env
// replacement doesn't recognize optional-chained access; the
// try/catch covers non-Vite bundlers.
// @ts-expect-error - import.meta.env is a Vite-ism
const viteFlag = import.meta?.env?.VITE_ATPROTO_PUBLISH_DRY_RUN;
const viteFlag = import.meta.env.VITE_ATPROTO_PUBLISH_DRY_RUN;
if (viteFlag !== undefined) return String(viteFlag) !== 'false';
} catch {
// import.meta not available (Rex/Node) — fall through
Expand Down
Loading