From 3526874aaadf123d2ac8a7d40a08865d828432b2 Mon Sep 17 00:00:00 2001 From: Ben Holmes Date: Sat, 4 Jul 2026 09:53:47 -0400 Subject: [PATCH] Add Cloud Sync device tokens --- AGENTS.md | 2 +- README.md | 14 +- apps/www/.env.example | 1 + apps/www/src/App.tsx | 17 +- apps/www/src/connection/connection.ts | 10 +- apps/www/src/screens/ConnectScreen.tsx | 24 ++- apps/www/src/screens/OpenWorkspaceScreen.tsx | 12 +- apps/www/src/shell/AppShell.tsx | 23 ++- apps/www/src/shell/CreateWorkspaceForm.tsx | 4 +- apps/www/src/shell/Sidebar.tsx | 3 + apps/www/src/shell/WorkspaceSwitcher.tsx | 9 +- apps/www/src/store/actions.ts | 30 ++- apps/www/src/vite-env.d.ts | 1 + packages/cli/src/index.ts | 162 +++++++++++++++- packages/convex-client/src/index.ts | 36 +++- packages/sync-backend/convex/schema.ts | 13 ++ packages/sync-backend/convex/sync.ts | 188 ++++++++++++++++--- packages/sync/src/backend.ts | 7 +- packages/sync/src/sync.ts | 9 +- packages/sync/src/types.ts | 1 + 20 files changed, 482 insertions(+), 84 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 42d1c289..013e64e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index 1567bb25..14068192 100644 --- a/README.md +++ b/README.md @@ -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 --url https://your-deployment.convex.cloud --token hbl_admin_... +``` + ## Documentation - [`CONTRIBUTING.md`](./CONTRIBUTING.md) covers the contribution flow, local setup, and pre-PR checks. @@ -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). \ No newline at end of file +Hubble.md is licensed under the [MIT License](./LICENSE). diff --git a/apps/www/.env.example b/apps/www/.env.example index 302f33de..b58a0f31 100644 --- a/apps/www/.env.example +++ b/apps/www/.env.example @@ -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= diff --git a/apps/www/src/App.tsx b/apps/www/src/App.tsx index 0f88d1b7..eaea9037 100644 --- a/apps/www/src/App.tsx +++ b/apps/www/src/App.tsx @@ -16,6 +16,7 @@ import { workspaceStore } from "./store/state"; type Connection = { url: string; + token: string; workspaceId: string | null; }; @@ -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() { @@ -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), }); }; @@ -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; }) { @@ -154,6 +157,7 @@ function HomeRoute({ return ( @@ -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; }) { @@ -188,6 +192,7 @@ function WorkspaceRoute({ return ( { diff --git a/apps/www/src/connection/connection.ts b/apps/www/src/connection/connection.ts index c5b5cb54..f3203fc7 100644 --- a/apps/www/src/connection/connection.ts +++ b/apps/www/src/connection/connection.ts @@ -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 { @@ -29,5 +34,6 @@ export function clearWorkspace(): void { export function disconnect(): void { localStorage.removeItem(URL_KEY); + localStorage.removeItem(TOKEN_KEY); clearWorkspace(); } diff --git a/apps/www/src/screens/ConnectScreen.tsx b/apps/www/src/screens/ConnectScreen.tsx index 8b9112de..00a38131 100644 --- a/apps/www/src/screens/ConnectScreen.tsx +++ b/apps/www/src/screens/ConnectScreen.tsx @@ -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(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 { @@ -44,7 +47,7 @@ export function ConnectScreen({ onConnected }: Props) {

Connect to hubble.md

- Paste the URL of your Convex deployment. + Paste your Convex deployment URL and device token.

+ 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 && (

{error} diff --git a/apps/www/src/screens/OpenWorkspaceScreen.tsx b/apps/www/src/screens/OpenWorkspaceScreen.tsx index 86a15e6e..39b2985f 100644 --- a/apps/www/src/screens/OpenWorkspaceScreen.tsx +++ b/apps/www/src/screens/OpenWorkspaceScreen.tsx @@ -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(null); const [error, setError] = useState(null); const [name, setName] = useState(""); @@ -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) { @@ -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); diff --git a/apps/www/src/shell/AppShell.tsx b/apps/www/src/shell/AppShell.tsx index 16053edf..e0ed91b1 100644 --- a/apps/www/src/shell/AppShell.tsx +++ b/apps/www/src/shell/AppShell.tsx @@ -24,6 +24,7 @@ import { Toolbar } from "./Toolbar"; type Props = { url: string; + token: string; workspaceId: string; filePath: string | null; onSelectFile: (path: string) => void; @@ -34,6 +35,7 @@ type Props = { export function AppShell({ url, + token, workspaceId, filePath, onSelectFile, @@ -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; @@ -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, () => { @@ -98,7 +102,7 @@ export function AppShell({ unsubscribeAssets(); void subscriber.close(); }; - }, [url, workspace.snapshot]); + }, [url, token, workspace.snapshot]); useEffect(() => { if (newNoteName !== null) { @@ -176,6 +180,7 @@ export function AppShell({ sidebar={ { - void loadWorkspaceSnapshot(url, workspaceId, filePath); + void loadWorkspaceSnapshot(url, token, workspaceId, filePath); }} /> )} diff --git a/apps/www/src/shell/CreateWorkspaceForm.tsx b/apps/www/src/shell/CreateWorkspaceForm.tsx index 5b1e6bd4..df6411fb 100644 --- a/apps/www/src/shell/CreateWorkspaceForm.tsx +++ b/apps/www/src/shell/CreateWorkspaceForm.tsx @@ -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(null); @@ -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); diff --git a/apps/www/src/shell/Sidebar.tsx b/apps/www/src/shell/Sidebar.tsx index 5b6b047e..51acb9cd 100644 --- a/apps/www/src/shell/Sidebar.tsx +++ b/apps/www/src/shell/Sidebar.tsx @@ -11,6 +11,7 @@ import { WorkspaceSwitcher } from "./WorkspaceSwitcher"; export function Sidebar({ url, + token, workspaceId, workspaceName, onSelectFile, @@ -18,6 +19,7 @@ export function Sidebar({ onDisconnect, }: { url: string; + token: string; workspaceId: string; workspaceName: string; onSelectFile: (path: string) => void; @@ -43,6 +45,7 @@ export function Sidebar({ header={ void; @@ -16,6 +17,7 @@ type Props = { export function WorkspaceSwitcher({ url, + token, currentWorkspaceId, currentWorkspaceName, onSelect, @@ -31,7 +33,9 @@ export function WorkspaceSwitcher({ let cancelled = false; (async () => { try { - const result = await client.query(api.sync.listWorkspaces, {}); + const result = await client.query(api.sync.listWorkspaces, { + auth: { token }, + }); if (cancelled) return; setWorkspaces(result); } catch (err) { @@ -41,7 +45,7 @@ export function WorkspaceSwitcher({ return () => { cancelled = true; }; - }, [client]); + }, [client, token]); return ( <> @@ -93,6 +97,7 @@ export function WorkspaceSwitcher({ > { setCreateOpen(false); onSelect(id); diff --git a/apps/www/src/store/actions.ts b/apps/www/src/store/actions.ts index f224f952..efb95e18 100644 --- a/apps/www/src/store/actions.ts +++ b/apps/www/src/store/actions.ts @@ -20,21 +20,27 @@ import { type Ctx = { backend: SyncBackend; workspaceId: string; + token: string; deviceId: string; }; let ctx: Ctx | null = null; -function createCtx(url: string, workspaceId: string): Ctx { +function createCtx(url: string, token: string, workspaceId: string): Ctx { return { - backend: createConvexBackend(url), + backend: createConvexBackend(url, token), workspaceId, + token, deviceId: ensureDeviceId(), }; } -export function initActions(url: string, workspaceId: string): void { - ctx = createCtx(url, workspaceId); +export function initActions( + url: string, + token: string, + workspaceId: string, +): void { + ctx = createCtx(url, token, workspaceId); } export function teardownActions(): void { @@ -60,12 +66,14 @@ type WorkspaceSnapshot = { async function fetchWorkspaceSnapshot( url: string, + token: string, workspaceId: string, selectedPath: string | null, ): Promise { const client = new ConvexHttpClient(url); - const workspacesPromise = client.query(api.sync.listWorkspaces, {}); - const backend = createConvexBackend(url); + const auth = { token }; + const workspacesPromise = client.query(api.sync.listWorkspaces, { auth }); + const backend = createConvexBackend(url, token); const filesPromise = backend.getFiles(workspaceId); const assetsPromise = backend.getAssets(workspaceId); const [files, assets] = await Promise.all([filesPromise, assetsPromise]); @@ -104,6 +112,7 @@ export const loadWorkspaceSnapshot = latest( async ( { isStale }, url: string, + token: string, workspaceId: string, selectedPath: string | null = null, ): Promise => { @@ -118,11 +127,12 @@ export const loadWorkspaceSnapshot = latest( try { const snapshot = await fetchWorkspaceSnapshot( url, + token, workspaceId, selectedPath, ); if (isStale()) return false; - ctx = createCtx(url, workspaceId); + ctx = createCtx(url, token, workspaceId); appStore.set((state) => ({ workspace: { ...state.workspace, @@ -302,7 +312,7 @@ export async function resolveAssetDownloadUrl( notePath: string, path: string, ): Promise { - const { backend } = requireCtx(); + const { backend, workspaceId } = requireCtx(); const assetPath = resolveMarkdownAssetPath(notePath, path); const asset = workspaceStore .get() @@ -310,7 +320,7 @@ export async function resolveAssetDownloadUrl( if (!asset) return null; const cached = assetDownloadUrlCache.get(assetPath); if (cached?.storageId === asset.storageId) return cached.url; - const url = await backend.getAssetDownloadUrl(asset.storageId); + const url = await backend.getAssetDownloadUrl(workspaceId, asset.storageId); assetDownloadUrlCache.set(assetPath, { storageId: asset.storageId, url }); return url; } @@ -323,7 +333,7 @@ export async function uploadAssetFile(args: { const bytes = await args.file.arrayBuffer(); const contentHash = await computeBytesHash(bytes); const paths = assetPathsForNote(args.path, contentHash, args.file); - const uploadUrl = await backend.generateAssetUploadUrl(); + const uploadUrl = await backend.generateAssetUploadUrl(workspaceId); const uploadResponse = await fetch(uploadUrl, { method: "POST", headers: { "Content-Type": args.file.type || "application/octet-stream" }, diff --git a/apps/www/src/vite-env.d.ts b/apps/www/src/vite-env.d.ts index 85d8a640..dce16353 100644 --- a/apps/www/src/vite-env.d.ts +++ b/apps/www/src/vite-env.d.ts @@ -3,6 +3,7 @@ interface ImportMetaEnv { readonly VITE_TEST_CONVEX_URL?: string; readonly VITE_TEST_WORKSPACE_ID?: string; + readonly VITE_TEST_TOKEN?: string; } interface ImportMeta { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 3d1b77c0..602cb3fc 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { randomBytes } from "node:crypto"; import { resolve } from "node:path"; import { parseArgs as parseNodeArgs } from "node:util"; import { @@ -15,7 +16,10 @@ import { writeSyncState, } from "@hubble.md/sync"; import { createNodeFileSystem } from "@hubble.md/sync/node"; +import { api } from "@hubble.md/sync-backend"; +import type { Id } from "@hubble.md/sync-backend/types"; import chokidar from "chokidar"; +import { ConvexHttpClient } from "convex/browser"; const fs = createNodeFileSystem(); @@ -29,6 +33,8 @@ type CliArgs = { workspaceName?: string; workspaceId?: string; deploymentUrl?: string; + token?: string; + scope?: "read" | "write" | "admin"; extraArgs: string[]; workspacePath: string; }; @@ -52,6 +58,11 @@ async function main() { return; } + if (parsed.command === "tokens") { + await runTokensCommand(parsed); + return; + } + printUsage(); process.exitCode = 1; } @@ -89,6 +100,30 @@ async function runCloudCommand(parsed: CliArgs) { process.exitCode = 1; } +async function runTokensCommand(parsed: CliArgs) { + const [action, ...extraArgs] = parsed.extraArgs; + if (extraArgs.length > 1) { + printUsage(); + process.exitCode = 1; + return; + } + + switch (action) { + case "mint": + await runTokenMint(parsed, extraArgs[0]); + return; + case "list": + await runTokenList(parsed); + return; + case "revoke": + await runTokenRevoke(parsed, extraArgs[0]); + return; + } + + printUsage(); + process.exitCode = 1; +} + async function runManualSync(workspacePath: string) { const cloudSync = await readCloudSyncConfig(workspacePath); if (!cloudSync) return; @@ -116,10 +151,13 @@ async function runCreate(workspacePath: string, opts: CliArgs) { } const deploymentUrl = getDeploymentUrl(opts); - const backend = createConvexBackend(deploymentUrl); + const token = getToken(opts); + if (!token) return; + const backend = createConvexBackend(deploymentUrl, token); const workspaceId = await backend.createWorkspace(opts.workspaceName); await writeCloudConnection(workspacePath, { deploymentUrl, + token, workspaceId, label: opts.workspaceName, }); @@ -138,7 +176,9 @@ async function runConnect(workspacePath: string, opts: CliArgs) { } const deploymentUrl = getDeploymentUrl(opts); - const backend = createConvexBackend(deploymentUrl); + const token = getToken(opts); + if (!token) return; + const backend = createConvexBackend(deploymentUrl, token); const workspaceId = opts.workspaceId ?? (await backend.getWorkspace(opts.workspaceName ?? "")); @@ -150,6 +190,7 @@ async function runConnect(workspacePath: string, opts: CliArgs) { await writeCloudConnection(workspacePath, { deploymentUrl, + token, workspaceId, label: opts.workspaceName ?? workspaceId, }); @@ -159,6 +200,7 @@ async function writeCloudConnection( workspacePath: string, opts: { deploymentUrl: string; + token: string; workspaceId: string; label: string; }, @@ -168,6 +210,7 @@ async function writeCloudConnection( const config = await writeCloudSyncConfig(fs, workspacePath, { provider: "convex", deploymentUrl: opts.deploymentUrl, + token: opts.token, workspaceId: opts.workspaceId, deviceId, backgroundSync: current.cloudSync?.backgroundSync ?? false, @@ -182,12 +225,76 @@ function getDeploymentUrl(opts: Pick): string { return opts.deploymentUrl ?? getConvexUrl(); } +function getToken(opts: Pick): string | null { + const token = opts.token ?? process.env.HUBBLE_TOKEN; + if (token) return token; + console.error("Missing required --token or HUBBLE_TOKEN."); + process.exitCode = 1; + return null; +} + +function createAuth(token: string) { + return { token }; +} + +function createDeviceToken(scope: "read" | "write" | "admin"): string { + return `hbl_${scope}_${randomBytes(32).toString("base64url")}`; +} + +async function runTokenMint(parsed: CliArgs, label?: string) { + if (!label) { + console.error("Missing token label."); + process.exitCode = 1; + return; + } + const deploymentUrl = getDeploymentUrl(parsed); + const token = createDeviceToken(parsed.scope ?? "write"); + const adminToken = parsed.token ?? process.env.HUBBLE_TOKEN; + const client = new ConvexHttpClient(deploymentUrl); + await client.mutation(api.sync.mintDevice, { + auth: adminToken ? createAuth(adminToken) : undefined, + label, + scope: parsed.scope ?? "write", + token, + }); + console.log(token); +} + +async function runTokenList(parsed: CliArgs) { + const token = getToken(parsed); + if (!token) return; + const client = new ConvexHttpClient(getDeploymentUrl(parsed)); + const devices = await client.query(api.sync.listDevices, { + auth: createAuth(token), + }); + for (const device of devices) { + const status = device.revokedAt ? "revoked" : "active"; + console.log(`${device._id}\t${device.scope}\t${status}\t${device.label}`); + } +} + +async function runTokenRevoke(parsed: CliArgs, id?: string) { + const token = getToken(parsed); + if (!token) return; + if (!id) { + console.error("Missing device id."); + process.exitCode = 1; + return; + } + const client = new ConvexHttpClient(getDeploymentUrl(parsed)); + await client.mutation(api.sync.revokeDevice, { + auth: createAuth(token), + id: id as Id<"devices">, + }); + console.log(`Revoked ${id}`); +} + async function syncOnce( workspacePath: string, cloudSync: CloudSyncConfig, reason: string, ) { - const backend = createConvexBackend(cloudSync.deploymentUrl); + const backend = createConvexBackend(cloudSync.deploymentUrl, cloudSync.token); const result = await runSync(backend, fs, workspacePath); logResult(reason, result); return result; @@ -204,7 +311,7 @@ async function syncContinuously( const scheduler = createSyncScheduler(workspacePath, cloudSync); await scheduler.enqueue("startup"); - const subscriber = createConvexSubscriber(convexUrl); + const subscriber = createConvexSubscriber(convexUrl, cloudSync.token); const unsubscribe = subscriber.onFilesChanged( cloudSync.workspaceId, () => { @@ -342,8 +449,19 @@ function parseCliArgs(argv: string[]) { name: { type: "string" }, id: { type: "string" }, url: { type: "string" }, + token: { type: "string" }, + scope: { type: "string" }, }, }); + const scope = values.scope; + if ( + scope !== undefined && + scope !== "read" && + scope !== "write" && + scope !== "admin" + ) { + return { error: "--scope must be read, write, or admin." } as const; + } const [command, ...extraArgs] = positionals; return { command, @@ -351,6 +469,8 @@ function parseCliArgs(argv: string[]) { workspaceName: values.name, workspaceId: values.id, deploymentUrl: values.url, + token: values.token, + scope, extraArgs, workspacePath: values.cwd ? resolve(values.cwd) : process.cwd(), } as const; @@ -363,6 +483,10 @@ function parseCliArgs(argv: string[]) { function printHelp(args: CliArgs) { if (args.command !== "cloud") { + if (args.command === "tokens") { + printTokensHelp(); + return; + } printRootHelp(); return; } @@ -395,9 +519,11 @@ function printHelp(args: CliArgs) { function printRootHelp() { console.log("Usage:"); console.log(" hubble [--cwd path] cloud "); + console.log(" hubble tokens "); console.log(""); console.log("Commands:"); console.log(" cloud Manage Cloud Sync"); + console.log(" tokens Manage device tokens"); } function printCloudHelp() { @@ -414,23 +540,27 @@ function printCloudHelp() { function printCreateHelp() { console.log("Usage:"); - console.log(" hubble [--cwd path] cloud create --name name [--url url]"); + console.log( + " hubble [--cwd path] cloud create --name name [--url url] [--token token]", + ); console.log(""); console.log("Options:"); console.log(" --name name Remote workspace name to create"); console.log(" --url url Convex deployment URL"); + console.log(" --token tok Admin token; defaults to HUBBLE_TOKEN"); } function printConnectHelp() { console.log("Usage:"); console.log( - " hubble [--cwd path] cloud connect (--name name|--id id) [--url url]", + " hubble [--cwd path] cloud connect (--name name|--id id) [--url url] [--token token]", ); console.log(""); console.log("Options:"); console.log(" --name name Existing remote workspace name"); console.log(" --id id Existing remote workspace id"); console.log(" --url url Convex deployment URL"); + console.log(" --token tok Device token; defaults to HUBBLE_TOKEN"); } function printSyncHelp() { @@ -454,15 +584,31 @@ function printDisconnectHelp() { console.log("Removes cloudSync from .hubble/config.json."); } +function printTokensHelp() { + console.log("Usage:"); + console.log( + " hubble tokens mint label [--scope read|write|admin] [--url url] [--token admin]", + ); + console.log(" hubble tokens list [--url url] [--token admin]"); + console.log(" hubble tokens revoke id [--url url] [--token admin]"); +} + function printUsage() { console.error("Usage:"); - console.error(" hubble [--cwd path] cloud create --name name [--url url]"); console.error( - " hubble [--cwd path] cloud connect (--name name|--id id) [--url url]", + " hubble [--cwd path] cloud create --name name [--url url] [--token token]", + ); + console.error( + " hubble [--cwd path] cloud connect (--name name|--id id) [--url url] [--token token]", ); console.error(" hubble [--cwd path] cloud sync"); console.error(" hubble [--cwd path] cloud watch"); console.error(" hubble [--cwd path] cloud disconnect"); + console.error( + " hubble tokens mint label [--scope read|write|admin] [--url url] [--token admin]", + ); + console.error(" hubble tokens list [--url url] [--token admin]"); + console.error(" hubble tokens revoke id [--url url] [--token admin]"); } void main(); diff --git a/packages/convex-client/src/index.ts b/packages/convex-client/src/index.ts index cfae4323..6014d1e5 100644 --- a/packages/convex-client/src/index.ts +++ b/packages/convex-client/src/index.ts @@ -17,18 +17,25 @@ export type Subscriber = { close(): Promise; }; -export function createConvexBackend(url: string): SyncBackend { +type AuthArgs = { token: string }; + +export function createConvexBackend(url: string, token: string): SyncBackend { const client = new ConvexHttpClient(url); + const auth: AuthArgs = { token }; return { async getWorkspace(name) { - const workspace = await client.query(api.sync.getWorkspace, { name }); + const workspace = await client.query(api.sync.getWorkspace, { + auth, + name, + }); return workspace?._id ?? null; }, async createWorkspace(name) { - return client.mutation(api.sync.createWorkspace, { name }); + return client.mutation(api.sync.createWorkspace, { auth, name }); }, async getFiles(workspaceId, opts) { const files = await client.query(api.sync.getFilesByWorkspace, { + auth, workspaceId: workspaceId as Id<"workspaces">, since: opts?.since, includeDeleted: opts?.includeDeleted, @@ -40,17 +47,20 @@ export function createConvexBackend(url: string): SyncBackend { async pushFile(args) { await client.mutation(api.sync.pushFile, { ...args, + auth, workspaceId: args.workspaceId as Id<"workspaces">, }); }, async softDeleteFile(args) { await client.mutation(api.sync.softDeleteFile, { ...args, + auth, workspaceId: args.workspaceId as Id<"workspaces">, }); }, async getAssets(workspaceId, since) { return client.query(api.sync.getAssetsByWorkspace, { + auth, workspaceId: workspaceId as Id<"workspaces">, since, }); @@ -58,6 +68,7 @@ export function createConvexBackend(url: string): SyncBackend { async pushAsset(args) { await client.mutation(api.sync.pushAsset, { ...args, + auth, workspaceId: args.workspaceId as Id<"workspaces">, storageId: args.storageId as Id<"_storage">, }); @@ -65,22 +76,29 @@ export function createConvexBackend(url: string): SyncBackend { async softDeleteAsset(args) { await client.mutation(api.sync.softDeleteAsset, { ...args, + auth, workspaceId: args.workspaceId as Id<"workspaces">, }); }, - async generateAssetUploadUrl() { - return client.mutation(api.sync.generateAssetUploadUrl, {}); + async generateAssetUploadUrl(workspaceId) { + return client.mutation(api.sync.generateAssetUploadUrl, { + auth, + workspaceId: workspaceId as Id<"workspaces">, + }); }, - async getAssetDownloadUrl(storageId) { + async getAssetDownloadUrl(workspaceId, storageId) { return client.query(api.sync.getAssetDownloadUrl, { + auth, + workspaceId: workspaceId as Id<"workspaces">, storageId: storageId as Id<"_storage">, }); }, }; } -export function createConvexSubscriber(url: string): Subscriber { +export function createConvexSubscriber(url: string, token: string): Subscriber { const client = new ConvexClient(url); + const auth: AuthArgs = { token }; return { onFilesChanged(workspaceId, callback, onError) { // Convex's onUpdate fires immediately with current state, then on @@ -90,7 +108,7 @@ export function createConvexSubscriber(url: string): Subscriber { // where changes during subscription setup get dropped. return client.onUpdate( api.sync.getFilesByWorkspace, - { workspaceId: workspaceId as Id<"workspaces"> }, + { auth, workspaceId: workspaceId as Id<"workspaces"> }, () => callback(), onError, ); @@ -98,7 +116,7 @@ export function createConvexSubscriber(url: string): Subscriber { onAssetsChanged(workspaceId, callback, onError) { return client.onUpdate( api.sync.getAssetsByWorkspace, - { workspaceId: workspaceId as Id<"workspaces"> }, + { auth, workspaceId: workspaceId as Id<"workspaces"> }, () => callback(), onError, ); diff --git a/packages/sync-backend/convex/schema.ts b/packages/sync-backend/convex/schema.ts index adc5d04c..31c1f272 100644 --- a/packages/sync-backend/convex/schema.ts +++ b/packages/sync-backend/convex/schema.ts @@ -2,6 +2,19 @@ import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; export default defineSchema({ + devices: defineTable({ + label: v.string(), + tokenHash: v.string(), + scope: v.union(v.literal("read"), v.literal("write"), v.literal("admin")), + workspaceIds: v.array(v.id("workspaces")), + createdAt: v.number(), + expiresAt: v.number(), + revokedAt: v.optional(v.number()), + lastUsedAt: v.optional(v.number()), + lastUsedIp: v.optional(v.string()), + lastUsedUserAgent: v.optional(v.string()), + }).index("by_tokenHash", ["tokenHash"]), + workspaces: defineTable({ name: v.string(), createdAt: v.number(), diff --git a/packages/sync-backend/convex/sync.ts b/packages/sync-backend/convex/sync.ts index 0c739542..7a7dbe28 100644 --- a/packages/sync-backend/convex/sync.ts +++ b/packages/sync-backend/convex/sync.ts @@ -1,9 +1,10 @@ import { v } from "convex/values"; -import type { Id } from "./_generated/dataModel"; +import type { Doc, Id } from "./_generated/dataModel"; import { internalMutation, type MutationCtx, mutation, + type QueryCtx, query, } from "./_generated/server"; import { @@ -19,6 +20,114 @@ async function contentHash(content: string): Promise { return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); } +const tokenArg = v.object({ + token: v.string(), + ip: v.optional(v.string()), + userAgent: v.optional(v.string()), +}); +const scopeArg = v.union( + v.literal("read"), + v.literal("write"), + v.literal("admin"), +); + +type AuthScope = "read" | "write" | "admin"; +type AuthCtx = QueryCtx | MutationCtx; + +async function sha256(value: string): Promise { + return contentHash(value); +} + +function canUseScope(actual: AuthScope, required: AuthScope): boolean { + if (actual === "admin") return true; + if (actual === "write") return required !== "admin"; + return required === "read"; +} + +async function hasDevices(ctx: AuthCtx): Promise { + const first = await ctx.db.query("devices").first(); + return first !== null; +} + +async function requireDevice( + ctx: AuthCtx, + auth: { token: string }, + requiredScope: AuthScope, + workspaceId?: Id<"workspaces">, +): Promise> { + const tokenHash = await sha256(auth.token); + const device = await ctx.db + .query("devices") + .withIndex("by_tokenHash", (q) => q.eq("tokenHash", tokenHash)) + .unique(); + const now = Date.now(); + if (!device || device.revokedAt !== undefined || device.expiresAt <= now) { + throw new Error("Invalid or expired device token"); + } + if (!canUseScope(device.scope, requiredScope)) { + throw new Error("Device token scope denied"); + } + if ( + workspaceId !== undefined && + device.workspaceIds.length > 0 && + !device.workspaceIds.includes(workspaceId) + ) { + throw new Error("Device token workspace denied"); + } + return device; +} + +export const mintDevice = mutation({ + args: { + auth: v.optional(tokenArg), + label: v.string(), + scope: scopeArg, + workspaceIds: v.optional(v.array(v.id("workspaces"))), + expiresAt: v.optional(v.number()), + token: v.string(), + }, + handler: async ( + ctx, + { auth, label, scope, workspaceIds, expiresAt, token }, + ) => { + if (await hasDevices(ctx)) { + if (!auth) throw new Error("Admin token required"); + await requireDevice(ctx, auth, "admin"); + } + const now = Date.now(); + const tokenHash = await sha256(token); + const existing = await ctx.db + .query("devices") + .withIndex("by_tokenHash", (q) => q.eq("tokenHash", tokenHash)) + .unique(); + if (existing) throw new Error("Device token already exists"); + return ctx.db.insert("devices", { + label, + tokenHash, + scope, + workspaceIds: workspaceIds ?? [], + createdAt: now, + expiresAt: expiresAt ?? now + 365 * 24 * 60 * 60 * 1000, + }); + }, +}); + +export const listDevices = query({ + args: { auth: tokenArg }, + handler: async (ctx, { auth }) => { + await requireDevice(ctx, auth, "admin"); + return ctx.db.query("devices").collect(); + }, +}); + +export const revokeDevice = mutation({ + args: { auth: tokenArg, id: v.id("devices") }, + handler: async (ctx, { auth, id }) => { + await requireDevice(ctx, auth, "admin"); + await ctx.db.patch(id, { revokedAt: Date.now() }); + }, +}); + async function upsertFile( ctx: MutationCtx, args: { @@ -60,8 +169,9 @@ async function upsertFile( } export const getWorkspace = query({ - args: { name: v.string() }, - handler: async (ctx, { name }) => { + args: { auth: tokenArg, name: v.string() }, + handler: async (ctx, { auth, name }) => { + await requireDevice(ctx, auth, "read"); return ctx.db .query("workspaces") .withIndex("by_name", (q) => q.eq("name", name)) @@ -70,8 +180,9 @@ export const getWorkspace = query({ }); export const createWorkspace = mutation({ - args: { name: v.string() }, - handler: async (ctx, { name }) => { + args: { auth: tokenArg, name: v.string() }, + handler: async (ctx, { auth, name }) => { + await requireDevice(ctx, auth, "admin"); const existing = await ctx.db .query("workspaces") .withIndex("by_name", (q) => q.eq("name", name)) @@ -82,8 +193,9 @@ export const createWorkspace = mutation({ }); export const listWorkspaces = query({ - args: {}, - handler: async (ctx) => { + args: { auth: tokenArg }, + handler: async (ctx, { auth }) => { + await requireDevice(ctx, auth, "read"); return ctx.db.query("workspaces").collect(); }, }); @@ -91,10 +203,12 @@ export const listWorkspaces = query({ export const getFilesByWorkspace = query({ args: { workspaceId: v.id("workspaces"), + auth: tokenArg, since: v.optional(v.number()), includeDeleted: v.optional(v.boolean()), }, - handler: async (ctx, { workspaceId, since, includeDeleted }) => { + handler: async (ctx, { auth, workspaceId, since, includeDeleted }) => { + await requireDevice(ctx, auth, "read", workspaceId); const q = ctx.db.query("files").withIndex("by_workspace", (q) => { const base = q.eq("workspaceId", workspaceId); return since !== undefined ? base.gt("updatedAt", since) : base; @@ -107,6 +221,7 @@ export const getFilesByWorkspace = query({ export const pushFile = mutation({ args: { workspaceId: v.id("workspaces"), + auth: tokenArg, path: v.string(), contentHash: v.string(), content: v.string(), @@ -114,8 +229,9 @@ export const pushFile = mutation({ }, handler: async ( ctx, - { workspaceId, path, contentHash, content, deviceId }, + { auth, workspaceId, path, contentHash, content, deviceId }, ) => { + await requireDevice(ctx, auth, "write", workspaceId); return upsertFile(ctx, { workspaceId, path, @@ -129,10 +245,12 @@ export const pushFile = mutation({ export const softDeleteFile = mutation({ args: { workspaceId: v.id("workspaces"), + auth: tokenArg, path: v.string(), deviceId: v.string(), }, - handler: async (ctx, { workspaceId, path, deviceId }) => { + handler: async (ctx, { auth, workspaceId, path, deviceId }) => { + await requireDevice(ctx, auth, "write", workspaceId); const existing = await ctx.db .query("files") .withIndex("by_workspace_path", (q) => @@ -195,8 +313,9 @@ async function upsertAsset( } export const generateAssetUploadUrl = mutation({ - args: {}, - handler: async (ctx) => { + args: { auth: tokenArg, workspaceId: v.id("workspaces") }, + handler: async (ctx, { auth, workspaceId }) => { + await requireDevice(ctx, auth, "write", workspaceId); return ctx.storage.generateUploadUrl(); }, }); @@ -204,6 +323,7 @@ export const generateAssetUploadUrl = mutation({ export const pushAsset = mutation({ args: { workspaceId: v.id("workspaces"), + auth: tokenArg, path: v.string(), storageId: v.id("_storage"), contentHash: v.string(), @@ -211,8 +331,9 @@ export const pushAsset = mutation({ }, handler: async ( ctx, - { workspaceId, path, storageId, contentHash, deviceId }, + { auth, workspaceId, path, storageId, contentHash, deviceId }, ) => { + await requireDevice(ctx, auth, "write", workspaceId); return upsertAsset(ctx, { workspaceId, path, @@ -226,9 +347,11 @@ export const pushAsset = mutation({ export const getAssetsByWorkspace = query({ args: { workspaceId: v.id("workspaces"), + auth: tokenArg, since: v.optional(v.number()), }, - handler: async (ctx, { workspaceId, since }) => { + handler: async (ctx, { auth, workspaceId, since }) => { + await requireDevice(ctx, auth, "read", workspaceId); const q = ctx.db.query("assets").withIndex("by_workspace", (q) => { const base = q.eq("workspaceId", workspaceId); return since !== undefined ? base.gt("updatedAt", since) : base; @@ -238,8 +361,19 @@ export const getAssetsByWorkspace = query({ }); export const getAssetDownloadUrl = query({ - args: { storageId: v.id("_storage") }, - handler: async (ctx, { storageId }) => { + args: { + auth: tokenArg, + workspaceId: v.id("workspaces"), + storageId: v.id("_storage"), + }, + handler: async (ctx, { auth, workspaceId, storageId }) => { + await requireDevice(ctx, auth, "read", workspaceId); + const asset = await ctx.db + .query("assets") + .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId)) + .filter((q) => q.eq(q.field("storageId"), storageId)) + .first(); + if (!asset) return null; return ctx.storage.getUrl(storageId); }, }); @@ -247,10 +381,12 @@ export const getAssetDownloadUrl = query({ export const softDeleteAsset = mutation({ args: { workspaceId: v.id("workspaces"), + auth: tokenArg, path: v.string(), deviceId: v.string(), }, - handler: async (ctx, { workspaceId, path, deviceId }) => { + handler: async (ctx, { auth, workspaceId, path, deviceId }) => { + await requireDevice(ctx, auth, "write", workspaceId); const existing = await ctx.db .query("assets") .withIndex("by_workspace_path", (q) => @@ -270,8 +406,9 @@ export const softDeleteAsset = mutation({ }); export const listOrphanAssetCandidates = query({ - args: { workspaceId: v.id("workspaces") }, - handler: async (ctx, { workspaceId }) => { + args: { auth: tokenArg, workspaceId: v.id("workspaces") }, + handler: async (ctx, { auth, workspaceId }) => { + await requireDevice(ctx, auth, "read", workspaceId); // Full-workspace scan for admin inspection. Avoid calling from reactive UI // paths or save/sync flows; large workspaces should use an indexed design. const [files, assets] = await Promise.all([ @@ -329,8 +466,9 @@ async function markOrphanAssetCandidatesForWorkspace( } export const markOrphanAssetCandidates = mutation({ - args: { workspaceId: v.id("workspaces") }, - handler: async (ctx, { workspaceId }) => { + args: { auth: tokenArg, workspaceId: v.id("workspaces") }, + handler: async (ctx, { auth, workspaceId }) => { + await requireDevice(ctx, auth, "write", workspaceId); return markOrphanAssetCandidatesForWorkspace(ctx, workspaceId); }, }); @@ -384,10 +522,12 @@ async function deleteOrphanAssetsForWorkspace( export const deleteOrphanAssets = mutation({ args: { + auth: tokenArg, workspaceId: v.id("workspaces"), gracePeriodMs: v.number(), }, - handler: async (ctx, { workspaceId, gracePeriodMs }) => { + handler: async (ctx, { auth, workspaceId, gracePeriodMs }) => { + await requireDevice(ctx, auth, "write", workspaceId); return deleteOrphanAssetsForWorkspace(ctx, workspaceId, gracePeriodMs); }, }); @@ -423,12 +563,14 @@ export const runOrphanAssetCleanupForAllWorkspaces = internalMutation({ export const debugRemoteEdit = mutation({ args: { + auth: tokenArg, workspaceId: v.id("workspaces"), path: v.string(), content: v.string(), deviceId: v.optional(v.string()), }, - handler: async (ctx, { workspaceId, path, content, deviceId }) => { + handler: async (ctx, { auth, workspaceId, path, content, deviceId }) => { + await requireDevice(ctx, auth, "write", workspaceId); return upsertFile(ctx, { workspaceId, path, diff --git a/packages/sync/src/backend.ts b/packages/sync/src/backend.ts index 67c9247e..87ce345c 100644 --- a/packages/sync/src/backend.ts +++ b/packages/sync/src/backend.ts @@ -36,6 +36,9 @@ export interface SyncBackend { deviceId: string; }): Promise; - generateAssetUploadUrl(): Promise; - getAssetDownloadUrl(storageId: string): Promise; + generateAssetUploadUrl(workspaceId: string): Promise; + getAssetDownloadUrl( + workspaceId: string, + storageId: string, + ): Promise; } diff --git a/packages/sync/src/sync.ts b/packages/sync/src/sync.ts index aa8cba80..ebd98870 100644 --- a/packages/sync/src/sync.ts +++ b/packages/sync/src/sync.ts @@ -24,6 +24,7 @@ export async function init( workspacePath: string; workspaceName: string; deploymentUrl: string; + token: string; backgroundSync?: boolean; }, ): Promise { @@ -37,6 +38,7 @@ export async function init( const cloudSync: CloudSyncConfig = { provider: "convex", deploymentUrl: opts.deploymentUrl, + token: opts.token, workspaceId, deviceId: crypto.randomUUID(), backgroundSync: opts.backgroundSync ?? false, @@ -205,7 +207,7 @@ export async function sync( const remoteAssetByPath = new Map(remoteAssets.map((a) => [a.path, a])); async function pushAsset(path: string, hash: string) { - const uploadUrl = await backend.generateAssetUploadUrl(); + const uploadUrl = await backend.generateAssetUploadUrl(workspaceId); const data = await fs.readBinaryFile(`${workspacePath}/${path}`); const res = await fetch(uploadUrl, { method: "POST", @@ -225,7 +227,10 @@ export async function sync( } async function pullAsset(remote: RemoteAsset) { - const url = await backend.getAssetDownloadUrl(remote.storageId); + const url = await backend.getAssetDownloadUrl( + workspaceId, + remote.storageId, + ); if (!url) return; const res = await fetch(url); const buf = new Uint8Array(await res.arrayBuffer()); diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index ea8e8e21..efb7c9ae 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -5,6 +5,7 @@ export const WorkspaceConfigSchema = z.object({ .object({ provider: z.literal("convex"), deploymentUrl: z.string(), + token: z.string(), workspaceId: z.string(), deviceId: z.string(), backgroundSync: z.boolean(),