From 6b42d3770b8e021b4aea0b938927ccd441b5a600 Mon Sep 17 00:00:00 2001 From: caleb Date: Tue, 7 Apr 2026 13:46:51 -0500 Subject: [PATCH 1/7] new encryption message + migration to v3 --- packages/utils/compression.ts | 4 + packages/web/public/DOCS.md | 72 ++++++ packages/web/src/app/docs/page.tsx | 189 +++++++++++++++ .../web/src/app/writer/[address]/page.tsx | 13 +- packages/web/src/components/Entry.tsx | 9 +- packages/web/src/components/EntryList.tsx | 4 +- .../components/EntryListWithCreateInput.tsx | 8 +- packages/web/src/components/MigrateModal.tsx | 218 ++++++++++++++++++ packages/web/src/components/NavDropdown.tsx | 67 +++++- packages/web/src/components/SavedView.tsx | 12 +- packages/web/src/utils/hooks.ts | 11 +- packages/web/src/utils/keyCache.ts | 26 ++- packages/web/src/utils/signer.ts | 24 +- packages/web/src/utils/utils.ts | 16 +- 14 files changed, 643 insertions(+), 30 deletions(-) create mode 100644 packages/web/src/components/MigrateModal.tsx diff --git a/packages/utils/compression.ts b/packages/utils/compression.ts index 25d957a..24c3d04 100644 --- a/packages/utils/compression.ts +++ b/packages/utils/compression.ts @@ -18,6 +18,10 @@ export const processRawContent = (raw: string) => { if (raw.startsWith("br:")) { version = "br"; decompressed = decompressBrotli(raw.slice(3)); + } else if (raw.startsWith("enc:v3:br:")) { + version = "enc:v3:br"; + } else if (raw.startsWith("enc:v2:br:")) { + version = "enc:v2:br"; } else if (raw.startsWith("enc:br:")) { version = "enc:br"; } diff --git a/packages/web/public/DOCS.md b/packages/web/public/DOCS.md index b252261..c2fbf12 100644 --- a/packages/web/public/DOCS.md +++ b/packages/web/public/DOCS.md @@ -427,3 +427,75 @@ Get user data for an address. | `address` | `address` | User wallet address | **Response:** `{ user: User }` + +--- + +## Content Encoding + +Entry content stored onchain goes through a multi-step encoding pipeline before being passed to the API. The `content` / `chunkContent` fields in create and update requests contain the final encoded string — not raw markdown. + +### Pipeline + +``` +Markdown → Compress (Brotli) → Encrypt (optional) → Prefix → Store +``` + +1. **Write** — Author composes content in markdown +2. **Compress** — Content is Brotli-compressed (quality 11) and Base64-encoded +3. **Encrypt** (private entries only) — Compressed content is encrypted with AES-GCM and Base64-encoded +4. **Prefix** — A version prefix is prepended to indicate the encoding format +5. **Store** — The prefixed string is signed (EIP-712) and stored onchain as the entry content + +### Format Prefixes + +The version prefix at the start of the stored content string indicates how to decode it: + +| Prefix | Encryption | Compression | Description | +|--------|------------|-------------|-------------| +| `br:` | None | Brotli | Public entry, compressed only | +| `enc:v3:br:` | AES-GCM (v3 key) | Brotli | Private entry, current format | +| `enc:v2:br:` | AES-GCM (v2 key) | Brotli | Private entry, legacy format | +| `enc:br:` | AES-GCM (v1 key) | Brotli | Private entry, legacy format | + +**Examples:** +- Public: `br:GxoAAI2pVgqN...` (Brotli-compressed, Base64-encoded markdown) +- Private: `enc:v3:br:A7f3kQ9x...` (encrypted + compressed) + +### Compression + +All content is compressed with [Brotli](https://github.com/nicolo-ribaudo/brotli-wasm) at quality level 11 (maximum), then Base64-encoded. This reduces onchain storage costs. + +``` +markdown → TextEncoder → Brotli compress → Base64 encode +``` + +### Encryption + +Private entries are encrypted **after** compression using [AES-GCM](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/encrypt#aes-gcm) (128-bit key, 12-byte random IV). + +``` +compressed content → AES-GCM encrypt → prepend IV → Base64 encode +``` + +The encryption key is deterministically derived from a wallet signature: + +1. User signs a fixed message with `personal_sign` +2. Signature is hashed with Keccak-256 +3. First 16 bytes of the hash become the AES key + +The key never leaves the client. Only the entry author can decrypt their private entries — the server and contract store opaque ciphertext. + +**V1, V2, and V3 keys** differ only in the message signed during key derivation. V3 is the current default and includes a security warning to only sign on writer.place. V1 and V2 are supported for backward compatibility with older entries. A migration tool is available in the app to re-encrypt legacy entries with the V3 key. + +### Decoding + +To read an entry, reverse the pipeline based on the prefix: + +| Prefix | Steps | +|--------|-------| +| `br:` | Strip prefix → Base64 decode → Brotli decompress | +| `enc:v3:br:` | Strip prefix → Base64 decode → AES-GCM decrypt (v3 key) → Brotli decompress | +| `enc:v2:br:` | Strip prefix → Base64 decode → AES-GCM decrypt (v2 key) → Brotli decompress | +| `enc:br:` | Strip prefix → Base64 decode → AES-GCM decrypt (v1 key) → Brotli decompress | + +Public entries are decoded server-side and returned as plaintext in the `decompressed` field. Private entries are returned as the raw encoded string and decrypted client-side using the author's wallet. diff --git a/packages/web/src/app/docs/page.tsx b/packages/web/src/app/docs/page.tsx index a264243..de7f0de 100644 --- a/packages/web/src/app/docs/page.tsx +++ b/packages/web/src/app/docs/page.tsx @@ -339,6 +339,11 @@ const tocItems = [ { id: "entries", label: "Entries", depth: 1 }, { id: "color", label: "Color", depth: 1 }, { id: "user", label: "User", depth: 1 }, + { id: "content-encoding", label: "Content Encoding", depth: 0 }, + { id: "format-prefixes", label: "Format Prefixes", depth: 1 }, + { id: "compression", label: "Compression", depth: 1 }, + { id: "encryption", label: "Encryption", depth: 1 }, + { id: "decoding", label: "Decoding", depth: 1 }, ]; function TableOfContents() { @@ -1192,6 +1197,190 @@ export default function DocsPage() { /> + + {/* CONTENT ENCODING */} +
+ + Content Encoding + +

+ Entry content goes through a multi-step encoding pipeline before + being stored onchain. The content /{" "} + chunkContent fields in API requests contain the final + encoded string, not raw markdown. +

+ +
+

+ Markdown → Compress → Encrypt (optional) → Prefix + → Store +

+
+ +
+

+ The version prefix at the start of the stored content string + indicates how to decode it. +

+ +
+
+ br: +

+ Public entry. Brotli compressed, Base64 encoded. No encryption. +

+

+ br:GxoAAI2pVgqN... +

+
+ +
+ + enc:v3:br: + +

+ Private entry, current format. AES-GCM encrypted with v3 key, + Brotli compressed. +

+

+ enc:v3:br:A7f3kQ9x... +

+
+ +
+ + enc:v2:br: + +

+ Private entry, legacy format. AES-GCM encrypted with v2 key, + Brotli compressed. +

+
+ +
+ + enc:br: + +

+ Private entry, legacy format. AES-GCM encrypted with v1 key, + Brotli compressed. +

+
+
+
+ +
+

+ All content is compressed with Brotli at quality level 11 + (maximum), then Base64 encoded. This reduces onchain storage + costs. +

+ +
+

+ markdown → TextEncoder → Brotli compress (quality 11) + → Base64 encode +

+
+
+ +
+

+ Private entries are encrypted after compression using AES-GCM with + a 128-bit key and 12-byte random IV. +

+ +
+

+ compressed content → AES-GCM encrypt → prepend IV + → Base64 encode +

+
+ +

+ The encryption key is deterministically derived from a wallet + signature: +

+ +
    +
  1. + User signs a fixed message with{" "} + personal_sign +
  2. +
  3. Signature is hashed with Keccak-256
  4. +
  5. First 16 bytes of the hash become the AES key
  6. +
+ +

+ The key never leaves the client. Only the entry author can decrypt + their private entries — the server and contract store opaque + ciphertext. +

+ +

+ V1, V2, and V3 keys differ only in the message signed during key + derivation. V3 is the current default and includes a security + warning to only sign on writer.place. V1 and V2 are supported for + backward compatibility with older entries. A migration tool is + available in the app to re-encrypt legacy entries with the V3 key. +

+
+ +
+

+ To read an entry, reverse the pipeline based on the prefix. +

+ +
+
+ br: +

+ Strip prefix → Base64 decode → Brotli decompress +

+
+ +
+ + enc:v3:br: + +

+ Strip prefix → Base64 decode → AES-GCM decrypt (v3 + key) → Brotli decompress +

+
+ +
+ + enc:v2:br: + +

+ Strip prefix → Base64 decode → AES-GCM decrypt (v2 + key) → Brotli decompress +

+
+ +
+ + enc:br: + +

+ Strip prefix → Base64 decode → AES-GCM decrypt (v1 + key) → Brotli decompress +

+
+
+ +

+ Public entries are decoded server-side and returned as plaintext in + the decompressed{" "} + field. Private entries are returned as the raw encoded string and + decrypted client-side using the author's wallet. +

+
+
diff --git a/packages/web/src/app/writer/[address]/page.tsx b/packages/web/src/app/writer/[address]/page.tsx index adfb381..a025dba 100644 --- a/packages/web/src/app/writer/[address]/page.tsx +++ b/packages/web/src/app/writer/[address]/page.tsx @@ -69,7 +69,7 @@ export default function WriterPage() { setUnlockError("Signature request was rejected."); }, []); - const { processedEntries, hasLockedPrivateEntries } = useProcessedEntries( + const { processedEntries, hasLockedPrivateEntries, processedOnce } = useProcessedEntries( writer?.entries, address, { @@ -80,20 +80,26 @@ export default function WriterPage() { const hasPrivateEntries = writer?.entries?.some((entry) => isEntryPrivate(entry)) ?? false; + const allEntriesPrivate = + (writer?.entries?.length ?? 0) > 0 && + writer?.entries?.every((entry) => isEntryPrivate(entry)); const showUnlockBanner = hasPrivateEntries && hasLockedPrivateEntries; const showLockedEntries = !allowDecryption || Boolean(unlockError); useEffect(() => { if (!wallet || !hasPrivateEntries) return; // Auto-unlock only if the key is already cached (no signature prompt). - if (hasCachedDerivedKey(wallet, "v2")) { + if ( + hasCachedDerivedKey(wallet, "v3") || + hasCachedDerivedKey(wallet, "v2") + ) { setAllowDecryption(true); } }, [wallet, hasPrivateEntries]); // Show loading state during the gap where entries exist but processedEntries hasn't been populated yet const isEntriesProcessing = - writer?.entries?.length && processedEntries.length === 0; + writer?.entries?.length && processedEntries.length === 0 && !processedOnce; const walletAddress = wallet?.address?.toLowerCase(); const { data: savedData } = useQuery({ queryKey: ["saved", walletAddress], @@ -169,6 +175,7 @@ export default function WriterPage() { isUnlocking={allowDecryption && !unlockError} unlockError={unlockError} showLockedEntries={showLockedEntries} + emptyMessage={allEntriesPrivate && !canCreateEntries ? "no public entries" : "no entries yet"} onUnlock={() => { setUnlockError(null); setAllowDecryption(true); diff --git a/packages/web/src/components/Entry.tsx b/packages/web/src/components/Entry.tsx index 86f225d..f9e465e 100644 --- a/packages/web/src/components/Entry.tsx +++ b/packages/web/src/components/Entry.tsx @@ -178,8 +178,12 @@ export default function Entry({ if (isEntryPrivate(initialEntry)) { setEncrypted(true); if (wallet && isWalletAuthor(wallet, initialEntry)) { + const needsV3 = initialEntry.raw?.startsWith("enc:v3:br:"); const needsV2 = initialEntry.raw?.startsWith("enc:v2:br:"); const needsV1 = initialEntry.raw?.startsWith("enc:br:"); + const keyV3 = needsV3 + ? await getCachedDerivedKey(wallet, "v3") + : undefined; const keyV2 = needsV2 ? await getCachedDerivedKey(wallet, "v2") : undefined; @@ -190,6 +194,7 @@ export default function Entry({ keyV2, initialEntry, keyV1, + keyV3, ); setProcessedEntry(processed); setEditedContent(processed.decompressed ?? ""); @@ -286,9 +291,9 @@ export default function Entry({ const compressedContent = await compress(editedContent); let versionedCompressedContent = `br:${compressedContent}`; if (encrypted) { - const key = await getCachedDerivedKey(wallet, "v2"); + const key = await getCachedDerivedKey(wallet, "v3"); const encryptedContent = await encrypt(key, compressedContent); - versionedCompressedContent = `enc:v2:br:${encryptedContent}`; + versionedCompressedContent = `enc:v3:br:${encryptedContent}`; } // Store expected raw content for polling comparison expectedRawContentRef.current = versionedCompressedContent; diff --git a/packages/web/src/components/EntryList.tsx b/packages/web/src/components/EntryList.tsx index 66797e2..684e0c4 100644 --- a/packages/web/src/components/EntryList.tsx +++ b/packages/web/src/components/EntryList.tsx @@ -93,11 +93,11 @@ export default function EntryList({ {isUnlocking ? "Unlocking..." : "Unlock to view"} - {unlockError && ( + {/* {unlockError && ( Signature rejected - )} + )} */}
{createdAt} diff --git a/packages/web/src/components/EntryListWithCreateInput.tsx b/packages/web/src/components/EntryListWithCreateInput.tsx index 179a321..c750ed7 100644 --- a/packages/web/src/components/EntryListWithCreateInput.tsx +++ b/packages/web/src/components/EntryListWithCreateInput.tsx @@ -24,6 +24,7 @@ export default function EntryListWithCreateInput({ unlockError, onUnlock, showLockedEntries = false, + emptyMessage = "no entries yet", }: { writerTitle: string; writerAddress: string; @@ -34,6 +35,7 @@ export default function EntryListWithCreateInput({ unlockError?: string | null; onUnlock?: () => void; showLockedEntries?: boolean; + emptyMessage?: string; }) { const [isExpanded, setIsExpanded] = useState(false); const [wallet] = useOPWallet(); @@ -64,9 +66,9 @@ export default function EntryListWithCreateInput({ const compressedContent = await compress(markdown); let versionedCompressedContent = `br:${compressedContent}`; if (encrypted) { - const key = await getCachedDerivedKey(wallet, "v2"); + const key = await getCachedDerivedKey(wallet, "v3"); const encryptedContent = await encrypt(key, compressedContent); - versionedCompressedContent = `enc:v2:br:${encryptedContent}`; + versionedCompressedContent = `enc:v3:br:${encryptedContent}`; } const { signature, nonce, chunkCount, chunkContent } = @@ -116,7 +118,7 @@ export default function EntryListWithCreateInput({ )} {!isExpanded && processedEntries.length === 0 && (
- no entries yet + {emptyMessage}
)}
diff --git a/packages/web/src/components/MigrateModal.tsx b/packages/web/src/components/MigrateModal.tsx new file mode 100644 index 0000000..63fd1fb --- /dev/null +++ b/packages/web/src/components/MigrateModal.tsx @@ -0,0 +1,218 @@ +"use client"; + +import { type Entry, editEntry } from "@/utils/api"; +import { useOPWallet } from "@/utils/hooks"; +import { getCachedDerivedKey } from "@/utils/keyCache"; +import { signUpdate } from "@/utils/signer"; +import { compress, decrypt, decompress, encrypt } from "@/utils/utils"; +import { useQueryClient } from "@tanstack/react-query"; +import { VisuallyHidden } from "radix-ui"; +import { useCallback, useState } from "react"; +import type { Hex } from "viem"; +import { LoadingRelic } from "./LoadingRelic"; +import { Modal, ModalDescription, ModalTitle } from "./dsl/Modal"; + +export interface MigrateEntry { + entry: Entry; + writerAddress: string; + writerTitle: string; +} + +type MigrationStatus = "idle" | "migrating" | "done" | "error"; + +export function MigrateModal({ + open, + onClose, + entriesToMigrate, +}: { + open: boolean; + onClose: () => void; + entriesToMigrate: MigrateEntry[]; +}) { + const [wallet] = useOPWallet(); + const queryClient = useQueryClient(); + const [status, setStatus] = useState("idle"); + const [migratedCount, setMigratedCount] = useState(0); + const [errorMessage, setErrorMessage] = useState(null); + const [migratedIds, setMigratedIds] = useState>(new Set()); + + const handleClose = useCallback(() => { + setStatus("idle"); + setMigratedCount(0); + setErrorMessage(null); + setMigratedIds(new Set()); + onClose(); + }, [onClose]); + + const migrate = useCallback(async () => { + if (!wallet || entriesToMigrate.length === 0) return; + setStatus("migrating"); + setMigratedCount(0); + setErrorMessage(null); + + try { + const keyV3 = await getCachedDerivedKey(wallet, "v3"); + + for (const { entry, writerAddress } of entriesToMigrate) { + // Decrypt with old key + let decrypted: string; + if (entry.raw?.startsWith("enc:v2:br:")) { + const keyV2 = await getCachedDerivedKey(wallet, "v2"); + decrypted = await decrypt(keyV2, entry.raw.slice(10)); + } else if (entry.raw?.startsWith("enc:br:")) { + const keyV1 = await getCachedDerivedKey(wallet, "v1"); + decrypted = await decrypt(keyV1, entry.raw.slice(7)); + } else { + continue; + } + + // Decompress to get original markdown, then re-compress and re-encrypt with v3 + const markdown = await decompress(decrypted); + const compressed = await compress(markdown); + const encrypted = await encrypt(keyV3, compressed); + const newContent = `enc:v3:br:${encrypted}`; + + const { signature, nonce, totalChunks, content } = await signUpdate( + wallet, + { + entryId: Number(entry.onChainId), + address: writerAddress as Hex, + content: newContent, + }, + ); + + await editEntry({ + address: writerAddress, + id: Number(entry.onChainId), + signature, + nonce, + totalChunks, + content, + }); + + setMigratedCount((c) => c + 1); + setMigratedIds((prev) => new Set(prev).add(entry.id)); + } + + queryClient.invalidateQueries({ queryKey: ["writer"] }); + queryClient.invalidateQueries({ queryKey: ["get-writers"] }); + setStatus("done"); + } catch (error) { + console.error("Migration failed", error); + setErrorMessage( + "Migration was interrupted. Already migrated entries are safe.", + ); + setStatus("error"); + } + }, [wallet, entriesToMigrate, queryClient]); + + // Group entries by writer for display + const entriesByWriter = entriesToMigrate.reduce< + Record + >((acc, item) => { + if (!acc[item.writerAddress]) { + acc[item.writerAddress] = { title: item.writerTitle, entries: [] }; + } + acc[item.writerAddress].entries.push(item); + return acc; + }, {}); + + return ( + + + Migrate Private Entries + + Re-encrypt private entries with the new signing key + + + +
+

+ Migrate your private entries to the new encryption key. The new key + includes a security warning so you know to only sign it on + writer.place. +

+ + {/* Entry list */} +
+ {Object.entries(entriesByWriter).map( + ([writerAddress, { title, entries }]) => ( +
+

+ {title} +

+
+ {entries.map(({ entry }) => { + const isMigrated = migratedIds.has(entry.id); + const version = entry.raw?.startsWith("enc:v2:br:") + ? "v2" + : "v1"; + return ( +
+ + entry #{entry.onChainId} + + + {isMigrated ? "v3" : version} + +
+ ); + })} +
+
+ ), + )} +
+ + {status === "idle" && ( + + )} + + {status === "migrating" && ( +
+ + + Migrating {migratedCount}/{entriesToMigrate.length}... + +
+ )} + + {status === "done" && ( +

+ Migrated {migratedCount}{" "} + {migratedCount === 1 ? "entry" : "entries"} successfully. +

+ )} + + {status === "error" && errorMessage && ( +
+

+ {errorMessage} +

+ +
+ )} +
+
+ ); +} diff --git a/packages/web/src/components/NavDropdown.tsx b/packages/web/src/components/NavDropdown.tsx index 4c15905..f1bb905 100644 --- a/packages/web/src/components/NavDropdown.tsx +++ b/packages/web/src/components/NavDropdown.tsx @@ -1,5 +1,7 @@ "use client"; +import type { Writer } from "@/utils/api"; +import { useOPWallet } from "@/utils/hooks"; import { clearAllCachedKeys } from "@/utils/keyCache"; import { type ThemeMode, @@ -8,12 +10,15 @@ import { setStoredThemeMode, subscribeSystemThemeChange, } from "@/utils/theme"; +import { isEntryPrivate, isWalletAuthor } from "@/utils/utils"; import { usePrivy } from "@privy-io/react-auth"; +import { useQueryClient } from "@tanstack/react-query"; import Image from "next/image"; import { usePathname, useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { ColorModal } from "./ColorModal"; -import { queryClient } from "./Providers"; +import { type MigrateEntry, MigrateModal } from "./MigrateModal"; +import { queryClient as globalQueryClient } from "./Providers"; import { Dropdown, DropdownItem } from "./dsl/Dropdown"; function ThemeButton({ @@ -48,10 +53,17 @@ function ThemeButton({ ); } +function isLegacyEncrypted(raw: string | undefined | null): boolean { + if (!raw) return false; + return raw.startsWith("enc:v2:br:") || raw.startsWith("enc:br:"); +} + export function NavDropdown() { - const { logout, authenticated, login } = usePrivy(); + const { logout, authenticated, login, user } = usePrivy(); + const [wallet] = useOPWallet(); const router = useRouter(); const pathname = usePathname(); + const queryClient = useQueryClient(); const navItems = [ { label: "Home", href: "/home" }, @@ -59,9 +71,41 @@ export function NavDropdown() { ...(authenticated ? [{ label: "Saved", href: "/saved" }] : []), ].filter((item) => item.href !== pathname); const [open, setOpen] = useState(false); + const [migrateOpen, setMigrateOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false); const [themeMode, setThemeMode] = useState("system"); + // Derive legacy entries from the cached get-writers query + const address = user?.wallet?.address; + const writersData = queryClient.getQueryData([ + "get-writers", + address, + ]); + + const entriesToMigrate = useMemo(() => { + if (!writersData || !wallet) return []; + const result: MigrateEntry[] = []; + for (const writer of writersData) { + for (const entry of writer.entries) { + if ( + isEntryPrivate(entry) && + isWalletAuthor(wallet, entry) && + isLegacyEncrypted(entry.raw) && + entry.onChainId + ) { + result.push({ + entry, + writerAddress: writer.address, + writerTitle: writer.title, + }); + } + } + } + return result; + }, [writersData, wallet]); + + const hasLegacyEntries = entriesToMigrate.length > 0; + useEffect(() => { if (typeof window === "undefined") return; const initialMode = getStoredThemeMode(); @@ -121,12 +165,22 @@ export function NavDropdown() { )} + {authenticated && hasLegacyEntries && ( + setMigrateOpen(true)}> +
+ Migrate + + {entriesToMigrate.length} + +
+
+ )} {authenticated ? ( logout().then(() => { clearAllCachedKeys(); - queryClient.clear(); + globalQueryClient.clear(); }) } > @@ -157,6 +211,11 @@ export function NavDropdown() { setOpen(false)} /> + setMigrateOpen(false)} + entriesToMigrate={entriesToMigrate} + /> ); } diff --git a/packages/web/src/components/SavedView.tsx b/packages/web/src/components/SavedView.tsx index 3ff3b0d..212262f 100644 --- a/packages/web/src/components/SavedView.tsx +++ b/packages/web/src/components/SavedView.tsx @@ -162,10 +162,16 @@ function MixedSavedGrid({ ); if (needs.length === 0) return; + const needsV3 = needs.some((entry) => + entry.raw?.startsWith("enc:v3:br:"), + ); const needsV2 = needs.some((entry) => entry.raw?.startsWith("enc:v2:br:"), ); const needsV1 = needs.some((entry) => entry.raw?.startsWith("enc:br:")); + const keyV3 = needsV3 + ? await getCachedDerivedKey(activeWallet, "v3") + : undefined; const keyV2 = needsV2 ? await getCachedDerivedKey(activeWallet, "v2") : undefined; @@ -177,7 +183,7 @@ function MixedSavedGrid({ async (entry) => [ entry.id, - await processPrivateEntry(keyV2, entry, keyV1), + await processPrivateEntry(keyV2, entry, keyV1, keyV3), ] as const, ), ); @@ -299,11 +305,11 @@ function MixedSavedGrid({ {canUnlock ? "Unlock to view" : "Private"} - {unlockError && canUnlock && ( + {/* {unlockError && canUnlock && ( Signature rejected - )} + )} */} ) : (
diff --git a/packages/web/src/utils/hooks.ts b/packages/web/src/utils/hooks.ts index c4bfc08..7d6bc0a 100644 --- a/packages/web/src/utils/hooks.ts +++ b/packages/web/src/utils/hooks.ts @@ -79,6 +79,7 @@ export function useProcessedEntries( ) { const [wallet, walletReady] = useOPWallet(); const [processedEntries, setProcessedEntries] = useState([]); + const [processedOnce, setProcessedOnce] = useState(false); const allowDecryption = options?.allowDecryption ?? false; const onDecryptError = options?.onDecryptError; @@ -96,6 +97,7 @@ export function useProcessedEntries( return true; }); setProcessedEntries(visibleEntries); + setProcessedOnce(true); // Process private entries in background (non-blocking) async function processPrivateEntriesInBackground() { @@ -117,12 +119,18 @@ export function useProcessedEntries( try { // Get derived keys (cached to avoid multiple signature requests) + const needsV3 = privateEntriesToProcess.some((entry) => + entry.raw?.startsWith("enc:v3:br:"), + ); const needsV2 = privateEntriesToProcess.some((entry) => entry.raw?.startsWith("enc:v2:br:"), ); const needsV1 = privateEntriesToProcess.some((entry) => entry.raw?.startsWith("enc:br:"), ); + const derivedKeyV3 = needsV3 + ? await getCachedDerivedKey(wallet!, "v3") + : undefined; const derivedKeyV2 = needsV2 ? await getCachedDerivedKey(wallet!, "v2") : undefined; @@ -151,6 +159,7 @@ export function useProcessedEntries( derivedKeyV2, entry, derivedKeyV1, + derivedKeyV3, ); await setCachedEntry(writerAddress, entryId, processed, { walletAddress, @@ -185,5 +194,5 @@ export function useProcessedEntries( (entry) => isEntryPrivate(entry) && !entry.decompressed, ); - return { processedEntries, hasLockedPrivateEntries }; + return { processedEntries, hasLockedPrivateEntries, processedOnce }; } diff --git a/packages/web/src/utils/keyCache.ts b/packages/web/src/utils/keyCache.ts index 3b4fd9a..745c3db 100644 --- a/packages/web/src/utils/keyCache.ts +++ b/packages/web/src/utils/keyCache.ts @@ -1,17 +1,23 @@ import type { ConnectedWallet } from "@privy-io/react-auth"; -import { getDerivedSigningKeyV1, getDerivedSigningKeyV2 } from "./signer"; +import { + getDerivedSigningKeyV1, + getDerivedSigningKeyV2, + getDerivedSigningKeyV3, +} from "./signer"; + +export type KeyVersion = "v1" | "v2" | "v3"; // In-memory cache for derived keys (session-scoped for security) // Key format: "walletAddress:version" const keyCache = new Map(); -function keyCacheKey(walletAddress: string, version: "v1" | "v2"): string { +function keyCacheKey(walletAddress: string, version: KeyVersion): string { return `${walletAddress.toLowerCase()}:${version}`; } export async function getCachedDerivedKey( wallet: ConnectedWallet, - version: "v1" | "v2", + version: KeyVersion, ): Promise { const cacheKey = keyCacheKey(wallet.address, version); @@ -23,9 +29,11 @@ export async function getCachedDerivedKey( // Derive key and cache it const key = - version === "v2" - ? await getDerivedSigningKeyV2(wallet) - : await getDerivedSigningKeyV1(wallet); + version === "v3" + ? await getDerivedSigningKeyV3(wallet) + : version === "v2" + ? await getDerivedSigningKeyV2(wallet) + : await getDerivedSigningKeyV1(wallet); keyCache.set(cacheKey, key); return key; @@ -33,19 +41,21 @@ export async function getCachedDerivedKey( export function hasCachedDerivedKey( wallet: ConnectedWallet, - version: "v1" | "v2", + version: KeyVersion, ): boolean { const cacheKey = keyCacheKey(wallet.address, version); return keyCache.has(cacheKey); } export async function getCachedDerivedKeys(wallet: ConnectedWallet): Promise<{ + keyV3: Uint8Array; keyV2: Uint8Array; keyV1: Uint8Array; }> { + const keyV3 = await getCachedDerivedKey(wallet, "v3"); const keyV2 = await getCachedDerivedKey(wallet, "v2"); const keyV1 = await getCachedDerivedKey(wallet, "v1"); - return { keyV2, keyV1 }; + return { keyV3, keyV2, keyV1 }; } export function clearCachedKeysForWallet(walletAddress: string): void { diff --git a/packages/web/src/utils/signer.ts b/packages/web/src/utils/signer.ts index 59cfc9b..9197dd6 100644 --- a/packages/web/src/utils/signer.ts +++ b/packages/web/src/utils/signer.ts @@ -255,5 +255,25 @@ export async function getDerivedSigningKeyV2( return key.slice(0, 16); // Use the first 16 bytes for a 128-bit key } -// Default export uses v2 for new encryptions -export const getDerivedSigningKey = getDerivedSigningKeyV2; +// V3 key with security warning message +export async function getDerivedSigningKeyV3( + wallet: ConnectedWallet, +): Promise { + const message = + "Writer: write (privately) today, forever. NOTE\uFE0F: Only sign this message on https://writer.place. Signing elsewhere may expose your private entries"; + const encodedMessage = `0x${Buffer.from(message, "utf8").toString("hex")}`; + const provider = await wallet.getEthereumProvider(); + const method = "personal_sign"; + + const signature = await provider.request({ + method, + params: [encodedMessage, wallet.address], + }); + + const hash = keccak256(signature); + const key = Uint8Array.from(Buffer.from(hash.slice(2), "hex")); + return key.slice(0, 16); +} + +// Default export uses v3 for new encryptions +export const getDerivedSigningKey = getDerivedSigningKeyV3; diff --git a/packages/web/src/utils/utils.ts b/packages/web/src/utils/utils.ts index e0f82f4..00801ea 100644 --- a/packages/web/src/utils/utils.ts +++ b/packages/web/src/utils/utils.ts @@ -248,12 +248,23 @@ export async function processPrivateEntry( keyV2: Uint8Array | undefined, entry: Entry, keyV1?: Uint8Array, + keyV3?: Uint8Array, ): Promise { + if (entry.raw?.startsWith("enc:v3:br:")) { + if (!keyV3) { + throw new Error("V3 key required to decrypt entry"); + } + const decrypted = await decrypt(keyV3, entry.raw.slice(10)); // "enc:v3:br:" = 10 chars + const decompressed = await decompress(decrypted); + return { + ...entry, + decompressed, + }; + } if (entry.raw?.startsWith("enc:v2:br:")) { if (!keyV2) { throw new Error("V2 key required to decrypt entry"); } - // New format - use v2 key const decrypted = await decrypt(keyV2, entry.raw.slice(10)); // "enc:v2:br:" = 10 chars const decompressed = await decompress(decrypted); return { @@ -280,7 +291,8 @@ export function isEntryPrivate(entry: Entry) { return ( entry.version?.startsWith("enc:") || entry.raw?.startsWith("enc:br:") || - entry.raw?.startsWith("enc:v2:br:") + entry.raw?.startsWith("enc:v2:br:") || + entry.raw?.startsWith("enc:v3:br:") ); } From 6f200aebfc6a97574a53387d407ff66eeaba162d Mon Sep 17 00:00:00 2001 From: caleb Date: Tue, 7 Apr 2026 13:56:36 -0500 Subject: [PATCH 2/7] rejection fix & new message --- packages/web/src/components/CreateInput.tsx | 21 +++++++++++++-------- packages/web/src/utils/signer.ts | 2 +- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/web/src/components/CreateInput.tsx b/packages/web/src/components/CreateInput.tsx index 301b951..85e382e 100644 --- a/packages/web/src/components/CreateInput.tsx +++ b/packages/web/src/components/CreateInput.tsx @@ -114,14 +114,19 @@ export default function CreateInput({ if (markdown.trim() === "") return; setLoadingContent(markdown); setIsSubmitting(true); - await onSubmit({ markdown, encrypted }); - editorRef.current?.setMarkdown(""); - setMarkdown(""); - setIsSubmitting(false); - setHasFocus(false); - setIsExpanded(false); - setEncrypted(false); - onExpand?.(false); + try { + await onSubmit({ markdown, encrypted }); + editorRef.current?.setMarkdown(""); + setMarkdown(""); + setHasFocus(false); + setIsExpanded(false); + setEncrypted(false); + onExpand?.(false); + } catch (error) { + console.error("Submit failed:", error); + } finally { + setIsSubmitting(false); + } }; const isLoadingOrSubmitting = isLoading || isSubmitting; diff --git a/packages/web/src/utils/signer.ts b/packages/web/src/utils/signer.ts index 9197dd6..cdf0753 100644 --- a/packages/web/src/utils/signer.ts +++ b/packages/web/src/utils/signer.ts @@ -260,7 +260,7 @@ export async function getDerivedSigningKeyV3( wallet: ConnectedWallet, ): Promise { const message = - "Writer: write (privately) today, forever. NOTE\uFE0F: Only sign this message on https://writer.place. Signing elsewhere may expose your private entries"; + "Writer: write (privately) today, forever.\n\nNOTE: Only sign this message on https://writer.place."; const encodedMessage = `0x${Buffer.from(message, "utf8").toString("hex")}`; const provider = await wallet.getEthereumProvider(); const method = "personal_sign"; From 2147fdd73a9c6bff3608dc77ebfc3f3fb0ee6219 Mon Sep 17 00:00:00 2001 From: caleb Date: Tue, 7 Apr 2026 21:27:32 -0500 Subject: [PATCH 3/7] more --- packages/web/public/DOCS.md | 6 +- packages/web/src/app/docs/page.tsx | 276 ++++++++------------ packages/web/src/app/fund/page.tsx | 36 +-- packages/web/src/app/globals.css | 4 + packages/web/src/components/icons/Check.tsx | 22 ++ packages/web/src/components/icons/Copy.tsx | 23 ++ 6 files changed, 172 insertions(+), 195 deletions(-) create mode 100644 packages/web/src/components/icons/Check.tsx create mode 100644 packages/web/src/components/icons/Copy.tsx diff --git a/packages/web/public/DOCS.md b/packages/web/public/DOCS.md index c2fbf12..9f21bb8 100644 --- a/packages/web/public/DOCS.md +++ b/packages/web/public/DOCS.md @@ -454,8 +454,8 @@ The version prefix at the start of the stored content string indicates how to de |--------|------------|-------------|-------------| | `br:` | None | Brotli | Public entry, compressed only | | `enc:v3:br:` | AES-GCM (v3 key) | Brotli | Private entry, current format | -| `enc:v2:br:` | AES-GCM (v2 key) | Brotli | Private entry, legacy format | -| `enc:br:` | AES-GCM (v1 key) | Brotli | Private entry, legacy format | +| `enc:v2:br:` | AES-GCM (v2 key) | Brotli | Deprecated | +| `enc:br:` | AES-GCM (v1 key) | Brotli | Deprecated | **Examples:** - Public: `br:GxoAAI2pVgqN...` (Brotli-compressed, Base64-encoded markdown) @@ -487,6 +487,8 @@ The key never leaves the client. Only the entry author can decrypt their private **V1, V2, and V3 keys** differ only in the message signed during key derivation. V3 is the current default and includes a security warning to only sign on writer.place. V1 and V2 are supported for backward compatibility with older entries. A migration tool is available in the app to re-encrypt legacy entries with the V3 key. +**Important:** Because the encryption key is derived from signing a specific message, anyone who tricks you into signing that same message on a different site could derive the same key and decrypt your private entries. The V3 message explicitly states to only sign on `https://writer.place`. Always verify the requesting site before signing. + ### Decoding To read an entry, reverse the pipeline based on the prefix: diff --git a/packages/web/src/app/docs/page.tsx b/packages/web/src/app/docs/page.tsx index de7f0de..b50e3a9 100644 --- a/packages/web/src/app/docs/page.tsx +++ b/packages/web/src/app/docs/page.tsx @@ -1,6 +1,9 @@ "use client"; +import { Check } from "@/components/icons/Check"; +import { Copy } from "@/components/icons/Copy"; import Image from "next/image"; +import Link from "next/link"; import { useEffect, useRef, useState } from "react"; import { MAX_CONTENT_LENGTH, MAX_TITLE_LENGTH } from "utils/constants"; @@ -33,38 +36,9 @@ function CopyButton({ value }: { value: string }) { className="text-neutral-500 hover:text-primary transition-colors align-middle" > {copied ? ( - + ) : ( - + )} ); @@ -114,7 +88,7 @@ function AnchorHeading({ function RelicDivider({ seed }: { seed: string }) { const logo = getLogoForSection(seed); return ( -
+
- +
+ {title} - {children} + {description &&

{description}

} +
{children}
); } @@ -169,7 +145,7 @@ function Endpoint({ return (
-

{description}

- {auth &&

Auth: {auth}

} +

{description}

+ {auth &&

Auth: {auth}

} {params && params.length > 0 && ( -
+

Parameters

-
+
{params.map((p) => ( -
+
{p.name} : {p.type} @@ -214,13 +190,13 @@ function Endpoint({
)} {body && body.length > 0 && ( -
+

Request Body

-
+
{body.map((b) => (
{b.name} @@ -236,9 +212,7 @@ function Endpoint({ )} {response && (
-

+

Response

{response} @@ -267,21 +241,23 @@ function ContractFunction({ return (
- - {name} - -

{description}

- {access &&

Access: {access}

} +
+ + {name} + +

{description}

+ {access &&

Access: {access}

} +
{params && params.length > 0 && ( -
+

Parameters

-
+
{params.map((p) => (
{p.name} @@ -296,9 +272,9 @@ function ContractFunction({
)} {returns && ( -
+

Returns

@@ -308,11 +284,11 @@ function ContractFunction({ {events && events.length > 0 && (

Events

-
+
{events.map((e) => ( {e} @@ -422,7 +398,7 @@ export default function DocsPage() { return (
-

+

Writer is an onchain writing platform. Content is permanently stored on Optimism through smart contracts.

@@ -431,47 +407,45 @@ export default function DocsPage() {
Smart Contracts -
+ -
-

- Factory contract that deploys Writer + WriterStorage pairs using - CREATE2 for deterministic addresses. -

- +
-
-

- Main logic contract for managing entries with role-based access - control. -

- +
Reading @@ -605,7 +577,7 @@ export default function DocsPage() { Writing @@ -800,7 +772,7 @@ export default function DocsPage() { Administration @@ -849,12 +821,10 @@ export default function DocsPage() { />
-
-

- Storage contract that holds all entry data. Only the Writer logic - contract can modify state, enforced by the onlyLogic modifier. -

- +
-
-

- Simple registry mapping user addresses to their chosen hex color. -

- +
Base URL: -
+

https://api.writer.place @@ -1213,24 +1182,23 @@ export default function DocsPage() { encoded string, not raw markdown.

-
+

Markdown → Compress → Encrypt (optional) → Prefix → Store

-
-

- The version prefix at the start of the stored content string - indicates how to decode it. -

- +
br:

- Public entry. Brotli compressed, Base64 encoded. No encryption. + Public entry. Brotli compressed, Base64 encoded. No + encryption.

br:GxoAAI2pVgqN... @@ -1250,36 +1218,23 @@ export default function DocsPage() {

-
- - enc:v2:br: - -

- Private entry, legacy format. AES-GCM encrypted with v2 key, - Brotli compressed. -

+
+ enc:v2:br: +

Deprecated

-
- - enc:br: - -

- Private entry, legacy format. AES-GCM encrypted with v1 key, - Brotli compressed. -

+
+ enc:br: +

Deprecated

-
-

- All content is compressed with Brotli at quality level 11 - (maximum), then Base64 encoded. This reduces onchain storage - costs. -

- -
+
+

markdown → TextEncoder → Brotli compress (quality 11) → Base64 encode @@ -1287,13 +1242,11 @@ export default function DocsPage() {

-
-

- Private entries are encrypted after compression using AES-GCM with - a 128-bit key and 12-byte random IV. -

- -
+
+

compressed content → AES-GCM encrypt → prepend IV → Base64 encode @@ -1305,7 +1258,9 @@ export default function DocsPage() { signature:

-
    +
    1. User signs a fixed message with{" "} personal_sign @@ -1320,20 +1275,30 @@ export default function DocsPage() { ciphertext.

      -

      +

      V1, V2, and V3 keys differ only in the message signed during key derivation. V3 is the current default and includes a security warning to only sign on writer.place. V1 and V2 are supported for backward compatibility with older entries. A migration tool is available in the app to re-encrypt legacy entries with the V3 key.

      + +

      + Because the encryption key is derived from signing a specific + message, anyone who tricks you into signing that same message on a + different site could derive the same key and decrypt your private + entries. The V3 message explicitly states to only sign on{" "} + + https://writer.place + + . Always verify the requesting site before signing. +

-
-

- To read an entry, reverse the pipeline based on the prefix. -

- +
br: @@ -1352,30 +1317,21 @@ export default function DocsPage() {

-
- - enc:v2:br: - -

- Strip prefix → Base64 decode → AES-GCM decrypt (v2 - key) → Brotli decompress -

+
+ enc:v2:br: +

Deprecated

-
- - enc:br: - -

- Strip prefix → Base64 decode → AES-GCM decrypt (v1 - key) → Brotli decompress -

+
+ enc:br: +

Deprecated

- Public entries are decoded server-side and returned as plaintext in - the decompressed{" "} + Public entries are decoded server-side and returned as plaintext + in the{" "} + decompressed{" "} field. Private entries are returned as the raw encoded string and decrypted client-side using the author's wallet.

diff --git a/packages/web/src/app/fund/page.tsx b/packages/web/src/app/fund/page.tsx index 4075fde..96b87c6 100644 --- a/packages/web/src/app/fund/page.tsx +++ b/packages/web/src/app/fund/page.tsx @@ -1,5 +1,7 @@ "use client"; +import { Check } from "@/components/icons/Check"; +import { Copy } from "@/components/icons/Copy"; import { type RelayWallet, getRelayWallets } from "@/utils/api"; import { useQuery } from "@tanstack/react-query"; import Image from "next/image"; @@ -40,39 +42,7 @@ function WalletRow({ wallet }: { wallet: RelayWallet }) { aria-label="Copy address" className="text-neutral-500 hover:text-primary cursor-pointer" > - {copied ? ( - - ) : ( - - )} + {copied ? : }
{displayBalance} ETH diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index 691a741..734619f 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -49,6 +49,10 @@ color: var(--color-white); background-color: var(--color-background); } + + .font-mono { + font-size: 15px; + } } ::-webkit-scrollbar { diff --git a/packages/web/src/components/icons/Check.tsx b/packages/web/src/components/icons/Check.tsx new file mode 100644 index 0000000..8c165f5 --- /dev/null +++ b/packages/web/src/components/icons/Check.tsx @@ -0,0 +1,22 @@ +import type { SvgProps } from "."; + +export function Check({ title, ...props }: SvgProps) { + return ( + + ); +} diff --git a/packages/web/src/components/icons/Copy.tsx b/packages/web/src/components/icons/Copy.tsx new file mode 100644 index 0000000..4fc21df --- /dev/null +++ b/packages/web/src/components/icons/Copy.tsx @@ -0,0 +1,23 @@ +import type { SvgProps } from "."; + +export function Copy({ title, ...props }: SvgProps) { + return ( + + ); +} From b70770b9fa3a25d1da7d19f3faa759f976bf0591 Mon Sep 17 00:00:00 2001 From: caleb Date: Tue, 7 Apr 2026 21:43:27 -0500 Subject: [PATCH 4/7] up --- packages/web/src/app/docs/page.tsx | 83 ++++++++++++++---------------- 1 file changed, 38 insertions(+), 45 deletions(-) diff --git a/packages/web/src/app/docs/page.tsx b/packages/web/src/app/docs/page.tsx index b50e3a9..0675509 100644 --- a/packages/web/src/app/docs/page.tsx +++ b/packages/web/src/app/docs/page.tsx @@ -145,29 +145,33 @@ function Endpoint({ return (
-
copyAnchor(id)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - copyAnchor(id); - } - }} - role="button" - tabIndex={0} - aria-label="Copy anchor" - > - {method} - {path} - - § - +
+
copyAnchor(id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + copyAnchor(id); + } + }} + role="button" + tabIndex={0} + aria-label="Copy anchor" + > + + {method} + + {path} + + § + +
+

{description}

+ {auth &&

Auth: {auth}

}
-

{description}

- {auth &&

Auth: {auth}

} {params && params.length > 0 && (

{/* API */} -

+
API @@ -1168,24 +1172,24 @@ export default function DocsPage() {
{/* CONTENT ENCODING */} -
+
Content Encoding

Entry content goes through a multi-step encoding pipeline before - being stored onchain. The content /{" "} - chunkContent fields in API requests contain the final - encoded string, not raw markdown. + being stored onchain. The content{" "} + & chunkContent fields in API + requests contain the final encoded string, not raw markdown.

- Markdown → Compress → Encrypt (optional) → Prefix - → Store + markdown → compress → encrypt (optional) → prefix + → store

@@ -1234,8 +1238,8 @@ export default function DocsPage() { title="Compression" description="All content is compressed with Brotli at quality level 11 (maximum), then Base64 encoded. This reduces onchain storage costs." > -
-

+

+

markdown → TextEncoder → Brotli compress (quality 11) → Base64 encode

@@ -1246,8 +1250,8 @@ export default function DocsPage() { title="Encryption" description="Private entries are encrypted after compression using AES-GCM with a 128-bit key and 12-byte random IV." > -
-

+

+

compressed content → AES-GCM encrypt → prepend IV → Base64 encode

@@ -1282,17 +1286,6 @@ export default function DocsPage() { backward compatibility with older entries. A migration tool is available in the app to re-encrypt legacy entries with the V3 key.

- -

- Because the encryption key is derived from signing a specific - message, anyone who tricks you into signing that same message on a - different site could derive the same key and decrypt your private - entries. The V3 message explicitly states to only sign on{" "} - - https://writer.place - - . Always verify the requesting site before signing. -

Date: Tue, 7 Apr 2026 21:52:58 -0500 Subject: [PATCH 5/7] more docs --- packages/web/public/DOCS.md | 2 +- packages/web/src/app/docs/page.tsx | 20 ++++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/web/public/DOCS.md b/packages/web/public/DOCS.md index 9f21bb8..9fbd79e 100644 --- a/packages/web/public/DOCS.md +++ b/packages/web/public/DOCS.md @@ -466,7 +466,7 @@ The version prefix at the start of the stored content string indicates how to de All content is compressed with [Brotli](https://github.com/nicolo-ribaudo/brotli-wasm) at quality level 11 (maximum), then Base64-encoded. This reduces onchain storage costs. ``` -markdown → TextEncoder → Brotli compress → Base64 encode +markdown → UTF-8 encode → brotli compress → base64 encode ``` ### Encryption diff --git a/packages/web/src/app/docs/page.tsx b/packages/web/src/app/docs/page.tsx index 0675509..9322430 100644 --- a/packages/web/src/app/docs/page.tsx +++ b/packages/web/src/app/docs/page.tsx @@ -1240,8 +1240,8 @@ export default function DocsPage() { >

- markdown → TextEncoder → Brotli compress (quality 11) - → Base64 encode + markdown → UTF-8 encode → brotli compress (quality 11) + → base64 encode

@@ -1253,7 +1253,7 @@ export default function DocsPage() {

compressed content → AES-GCM encrypt → prepend IV - → Base64 encode + → base64 encode

@@ -1295,8 +1295,10 @@ export default function DocsPage() {
br: -

- Strip prefix → Base64 decode → Brotli decompress +

+ strip prefix → base64 decode → brotli decompress

@@ -1304,9 +1306,11 @@ export default function DocsPage() { enc:v3:br: -

- Strip prefix → Base64 decode → AES-GCM decrypt (v3 - key) → Brotli decompress +

+ strip prefix → base64 decode → AES-GCM decrypt (v3 + key) → brotli decompress

From 8c191991fba21d16fd3c88c36125b3c5c723f406 Mon Sep 17 00:00:00 2001 From: caleb Date: Wed, 8 Apr 2026 09:06:25 -0500 Subject: [PATCH 6/7] up --- packages/web/src/components/MigrateModal.tsx | 8 ++++---- packages/web/src/components/dsl/Modal.tsx | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/web/src/components/MigrateModal.tsx b/packages/web/src/components/MigrateModal.tsx index 63fd1fb..25f8bdb 100644 --- a/packages/web/src/components/MigrateModal.tsx +++ b/packages/web/src/components/MigrateModal.tsx @@ -4,7 +4,7 @@ import { type Entry, editEntry } from "@/utils/api"; import { useOPWallet } from "@/utils/hooks"; import { getCachedDerivedKey } from "@/utils/keyCache"; import { signUpdate } from "@/utils/signer"; -import { compress, decrypt, decompress, encrypt } from "@/utils/utils"; +import { compress, decompress, decrypt, encrypt } from "@/utils/utils"; import { useQueryClient } from "@tanstack/react-query"; import { VisuallyHidden } from "radix-ui"; import { useCallback, useState } from "react"; @@ -150,7 +150,7 @@ export function MigrateModal({ return (
- Migrated {migratedCount}{" "} - {migratedCount === 1 ? "entry" : "entries"} successfully. + Migrated {migratedCount} {migratedCount === 1 ? "entry" : "entries"}{" "} + successfully.

)} diff --git a/packages/web/src/components/dsl/Modal.tsx b/packages/web/src/components/dsl/Modal.tsx index 458b6a1..0e3f140 100644 --- a/packages/web/src/components/dsl/Modal.tsx +++ b/packages/web/src/components/dsl/Modal.tsx @@ -18,7 +18,7 @@ export function Modal({ open, onClose, children, className }: ModalProps) { // }} className={cn( "DialogContent", - "fixed top-1/2 left-1/2 z-[101] transform -translate-x-1/2 -translate-y-1/2 w-80vw max-w-450px max-h-85vh p-[25px] bg-neutral-200 dark:bg-neutral-800 rounded-lg", + "fixed top-1/2 left-1/2 z-[101] transform -translate-x-1/2 -translate-y-1/2 w-80vw max-w-[450px] max-h-85vh p-[25px] bg-white dark:bg-neutral-800 rounded-lg", className, )} > From cbd9352b60eabcc5b8a461c3223996664d3ad46c Mon Sep 17 00:00:00 2001 From: caleb Date: Fri, 10 Apr 2026 00:25:02 -0500 Subject: [PATCH 7/7] migrate --- packages/web/src/app/layout.tsx | 16 ++++++++-------- packages/web/src/components/MigrateModal.tsx | 18 ++++++++++-------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx index 3c7ae59..5db8321 100644 --- a/packages/web/src/app/layout.tsx +++ b/packages/web/src/app/layout.tsx @@ -69,20 +69,20 @@ export default async function RootLayout({ className="min-h-dvh" suppressHydrationWarning > - +