Skip to content
Closed
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ Use logical CSS spacing props (`margin/padding` inline/block/start/end), not phy

Check work: `pnpm build:desktop` (builds packages, runs biome check, tsc, vite build, cargo check). For quick iteration use `pnpm check` and desktop tsc.

Test the web app by appending `?test=1` to the dev server URL — bypasses the connect / workspace-picker screens. Requires `VITE_TEST_CONVEX_URL` and `VITE_TEST_WORKSPACE_ID` in `apps/www/.env.local` (see `apps/www/.env.example`).
Test the web app by appending `?test=1` to the dev server URL — bypasses the connect / workspace-picker screens. Requires `VITE_TEST_CONVEX_URL`, `VITE_TEST_WORKSPACE_ID`, and `VITE_TEST_TOKEN` in `apps/www/.env.local` (see `apps/www/.env.example`).

When asked why you made a decision, answer why. Don't take it as a challenge to your approach, or pressure to change your solution.

Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,18 @@ pnpm check # run Biome
pnpm typecheck # typecheck all packages
```

## Cloud Sync tokens

Cloud Sync uses per-device tokens. Bootstrap the first admin token, then pass a
token when connecting a workspace:

```sh
hubble tokens mint "MacBook" --scope admin --url https://your-deployment.convex.cloud
HUBBLE_TOKEN=hbl_admin_... hubble cloud create --name Notes --url https://your-deployment.convex.cloud
hubble tokens list --url https://your-deployment.convex.cloud --token hbl_admin_...
hubble tokens revoke <device-id> --url https://your-deployment.convex.cloud --token hbl_admin_...
```

## Documentation

- [`CONTRIBUTING.md`](./CONTRIBUTING.md) covers the contribution flow, local setup, and pre-PR checks.
Expand All @@ -95,4 +107,4 @@ This project follows our [Code of Conduct](./CODE_OF_CONDUCT.md). To report a se

## License

Hubble.md is licensed under the [MIT License](./LICENSE).
Hubble.md is licensed under the [MIT License](./LICENSE).
1 change: 1 addition & 0 deletions apps/www/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
# Without ?test=1 these are inert — normal dev sessions are unaffected.
VITE_TEST_CONVEX_URL=
VITE_TEST_WORKSPACE_ID=
VITE_TEST_TOKEN=
17 changes: 11 additions & 6 deletions apps/www/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { workspaceStore } from "./store/state";

type Connection = {
url: string;
token: string;
workspaceId: string | null;
};

Expand All @@ -34,13 +35,14 @@ function readTestBootstrap(): Connection | null {
if (params.get("test") !== "1") return null;
const url = import.meta.env.VITE_TEST_CONVEX_URL;
const workspaceId = import.meta.env.VITE_TEST_WORKSPACE_ID;
if (!url || !workspaceId) {
const token = import.meta.env.VITE_TEST_TOKEN;
if (!url || !workspaceId || !token) {
console.warn(
"?test=1 set but VITE_TEST_CONVEX_URL / VITE_TEST_WORKSPACE_ID are missing — falling back to normal routing.",
"?test=1 set but VITE_TEST_CONVEX_URL / VITE_TEST_WORKSPACE_ID / VITE_TEST_TOKEN are missing — falling back to normal routing.",
);
return null;
}
return { url, workspaceId };
return { url, token, workspaceId };
}

export default function App() {
Expand All @@ -64,9 +66,10 @@ function AppRoutes() {
navigate("/", { replace: true });
};

const handleConnected = (url: string) => {
const handleConnected = (url: string, token: string) => {
setConnection({
url,
token,
workspaceId: getWorkspaceIdFromPath(location.pathname),
});
};
Expand Down Expand Up @@ -128,7 +131,7 @@ function HomeRoute({
onDisconnect,
}: {
connection: Connection | null;
onConnected: (url: string) => void;
onConnected: (url: string, token: string) => void;
onSelected: (workspaceId: string) => void;
onDisconnect: () => void;
}) {
Expand All @@ -154,6 +157,7 @@ function HomeRoute({
return (
<OpenWorkspaceScreen
url={connection.url}
token={connection.token}
onSelected={onSelected}
onDisconnect={onDisconnect}
/>
Expand All @@ -169,7 +173,7 @@ function WorkspaceRoute({
}: {
connection: Connection | null;
filePath?: string | null;
onConnected: (url: string) => void;
onConnected: (url: string, token: string) => void;
onWorkspaceLoaded: (workspaceId: string) => void;
onDisconnect: () => void;
}) {
Expand All @@ -188,6 +192,7 @@ function WorkspaceRoute({
return (
<AppShell
url={connection.url}
token={connection.token}
workspaceId={workspaceId}
filePath={routeFilePath}
onSelectFile={(path) => {
Expand Down
10 changes: 8 additions & 2 deletions apps/www/src/connection/connection.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,27 @@
const URL_KEY = "hubble.connection.url";
const TOKEN_KEY = "hubble.connection.token";
const WORKSPACE_ID_KEY = "hubble.connection.workspaceId";

export type StoredConnection = {
url: string;
token: string;
workspaceId: string | null;
};

export function readConnection(): StoredConnection | null {
const url = localStorage.getItem(URL_KEY);
if (!url) return null;
const token = localStorage.getItem(TOKEN_KEY);
if (!url || !token) return null;
return {
url,
token,
workspaceId: localStorage.getItem(WORKSPACE_ID_KEY),
};
}

export function saveConnectionUrl(url: string): void {
export function saveConnection(url: string, token: string): void {
localStorage.setItem(URL_KEY, url);
localStorage.setItem(TOKEN_KEY, token);
}

export function saveWorkspace(id: string): void {
Expand All @@ -29,5 +34,6 @@ export function clearWorkspace(): void {

export function disconnect(): void {
localStorage.removeItem(URL_KEY);
localStorage.removeItem(TOKEN_KEY);
clearWorkspace();
}
24 changes: 18 additions & 6 deletions apps/www/src/screens/ConnectScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,36 @@
import { api } from "@hubble.md/sync-backend";
import { ConvexHttpClient } from "convex/browser";
import { useState } from "react";
import { saveConnectionUrl } from "../connection/connection";
import { saveConnection } from "../connection/connection";
import { categorizeError, describeError } from "../connection/convex-error";
import { ensureDeviceId } from "../connection/deviceId";

type Props = {
onConnected: (url: string) => void;
onConnected: (url: string, token: string) => void;
};

export function ConnectScreen({ onConnected }: Props) {
const [url, setUrl] = useState("");
const [token, setToken] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);

const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
const trimmed = url.trim();
if (!trimmed) return;
const trimmedToken = token.trim();
if (!trimmed || !trimmedToken) return;
setBusy(true);
setError(null);
try {
const client = new ConvexHttpClient(trimmed);
await client.query(api.sync.getWorkspace, {
auth: { token: trimmedToken },
name: "__hubble_connect_probe__",
});
saveConnectionUrl(trimmed);
saveConnection(trimmed, trimmedToken);
ensureDeviceId();
onConnected(trimmed);
onConnected(trimmed, trimmedToken);
} catch (err) {
setError(describeError(categorizeError(err)));
} finally {
Expand All @@ -44,7 +47,7 @@ export function ConnectScreen({ onConnected }: Props) {
<div>
<h1 className="m-0 text-base font-semibold">Connect to hubble.md</h1>
<p className="m-0 mt-1 text-xs text-muted-foreground">
Paste the URL of your Convex deployment.
Paste your Convex deployment URL and device token.
</p>
</div>
<input
Expand All @@ -59,6 +62,15 @@ export function ConnectScreen({ onConnected }: Props) {
disabled={busy}
className="rounded-sm border border-border bg-background px-2.5 py-1.5 text-sm outline-none focus:border-ring"
/>
<input
type="password"
required
value={token}
onChange={(event) => setToken(event.target.value)}
placeholder="hbl_write_..."
disabled={busy}
className="rounded-sm border border-border bg-background px-2.5 py-1.5 text-sm outline-none focus:border-ring"
/>
{error && (
<p className="m-0 rounded-sm bg-muted px-2.5 py-1.5 text-xs text-destructive">
{error}
Expand Down
12 changes: 10 additions & 2 deletions apps/www/src/screens/OpenWorkspaceScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,19 @@ type Workspace = Doc<"workspaces">;

type Props = {
url: string;
token: string;
onSelected: (id: string) => void;
onDisconnect: () => void;
};

export function OpenWorkspaceScreen({ url, onSelected, onDisconnect }: Props) {
export function OpenWorkspaceScreen({
url,
token,
onSelected,
onDisconnect,
}: Props) {
const [client] = useState(() => new ConvexHttpClient(url));
const auth = { token };
const [workspaces, setWorkspaces] = useState<Workspace[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [name, setName] = useState("");
Expand All @@ -25,7 +32,7 @@ export function OpenWorkspaceScreen({ url, onSelected, onDisconnect }: Props) {
let cancelled = false;
(async () => {
try {
const result = await client.query(api.sync.listWorkspaces, {});
const result = await client.query(api.sync.listWorkspaces, { auth });
if (cancelled) return;
setWorkspaces(result);
if (result.length === 1) {
Expand Down Expand Up @@ -54,6 +61,7 @@ export function OpenWorkspaceScreen({ url, onSelected, onDisconnect }: Props) {
setError(null);
try {
const id = await client.mutation(api.sync.createWorkspace, {
auth,
name: trimmed,
});
select(id);
Expand Down
23 changes: 14 additions & 9 deletions apps/www/src/shell/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { Toolbar } from "./Toolbar";

type Props = {
url: string;
token: string;
workspaceId: string;
filePath: string | null;
onSelectFile: (path: string) => void;
Expand All @@ -34,6 +35,7 @@ type Props = {

export function AppShell({
url,
token,
workspaceId,
filePath,
onSelectFile,
Expand All @@ -49,12 +51,14 @@ export function AppShell({

// biome-ignore lint/correctness/useExhaustiveDependencies: snapshot reloads only when workspace identity changes; file route changes load below
useEffect(() => {
void loadWorkspaceSnapshot(url, workspaceId, filePath).then((loaded) => {
if (!loaded) return;
saveWorkspace(workspaceId);
onWorkspaceLoaded(workspaceId);
});
}, [url, workspaceId]);
void loadWorkspaceSnapshot(url, token, workspaceId, filePath).then(
(loaded) => {
if (!loaded) return;
saveWorkspace(workspaceId);
onWorkspaceLoaded(workspaceId);
},
);
}, [url, token, workspaceId]);

useEffect(() => {
if (workspace.snapshot?.id !== workspaceId) return;
Expand All @@ -74,7 +78,7 @@ export function AppShell({
// biome-ignore lint/correctness/useExhaustiveDependencies: subscription owns its lifecycle by url+workspaceId
useEffect(() => {
if (!workspace.snapshot) return;
const subscriber = createConvexSubscriber(url);
const subscriber = createConvexSubscriber(url, token);
const unsubscribe = subscriber.onFilesChanged(
workspace.snapshot.id,
() => {
Expand All @@ -98,7 +102,7 @@ export function AppShell({
unsubscribeAssets();
void subscriber.close();
};
}, [url, workspace.snapshot]);
}, [url, token, workspace.snapshot]);

useEffect(() => {
if (newNoteName !== null) {
Expand Down Expand Up @@ -176,6 +180,7 @@ export function AppShell({
sidebar={
<Sidebar
url={url}
token={token}
workspaceId={workspace.snapshot.id}
workspaceName={workspace.snapshot.name}
onSelectFile={onSelectFile}
Expand All @@ -189,7 +194,7 @@ export function AppShell({
<ExternalChangeBanner
message={workspace.error}
onReload={() => {
void loadWorkspaceSnapshot(url, workspaceId, filePath);
void loadWorkspaceSnapshot(url, token, workspaceId, filePath);
}}
/>
)}
Expand Down
4 changes: 3 additions & 1 deletion apps/www/src/shell/CreateWorkspaceForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import { categorizeError, describeError } from "../connection/convex-error";

type Props = {
client: ConvexHttpClient;
token: string;
onCreated: (id: string) => void;
};

export function CreateWorkspaceForm({ client, onCreated }: Props) {
export function CreateWorkspaceForm({ client, token, onCreated }: Props) {
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
Expand All @@ -22,6 +23,7 @@ export function CreateWorkspaceForm({ client, onCreated }: Props) {
setError(null);
try {
const id = await client.mutation(api.sync.createWorkspace, {
auth: { token },
name: trimmed,
});
onCreated(id);
Expand Down
3 changes: 3 additions & 0 deletions apps/www/src/shell/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ import { WorkspaceSwitcher } from "./WorkspaceSwitcher";

export function Sidebar({
url,
token,
workspaceId,
workspaceName,
onSelectFile,
onSwitch,
onDisconnect,
}: {
url: string;
token: string;
workspaceId: string;
workspaceName: string;
onSelectFile: (path: string) => void;
Expand All @@ -43,6 +45,7 @@ export function Sidebar({
header={
<WorkspaceSwitcher
url={url}
token={token}
currentWorkspaceId={workspaceId}
currentWorkspaceName={workspaceName}
onSelect={onSwitch}
Expand Down
Loading
Loading