Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/components/modals/budget-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { useUserStore } from "@/stores/user-store"
import { isSphinx, hasWebLN, payInvoice, payL402, topUpLsat, fetchTransactionHistory, pollPaymentStatus, fetchBuyLsatChallenge, savePendingLsat, getPendingLsat, clearPendingLsat, topUpStatus, withdraw, TransactionRow, PendingLsatChallenge } from "@/lib/sphinx"
import { getActionDisplayLabel, getActionBadgeColor, isViewGrantRow } from "@/lib/transaction-display"
import { isMocksEnabled, MOCK_TRANSACTIONS } from "@/lib/mock-data"
import { cookieStorage } from "@/lib/cookie-storage"
import { cookieStorage, AUTH_COOKIE_DAYS } from "@/lib/cookie-storage"
import { api } from "@/lib/api"
import { decodeInvoiceExpiry, decodeInvoiceAmountSats } from "@/lib/invoice-utils"
import { formatCountdown } from "@/lib/format-countdown"
Expand Down Expand Up @@ -228,6 +228,7 @@ export function BudgetModal() {
identifier: pendingChallenge.id,
preimage: "",
}),
AUTH_COOKIE_DAYS
)
clearPendingLsat()
setPendingChallenge(null)
Expand Down Expand Up @@ -292,7 +293,7 @@ export function BudgetModal() {
await api.get<{ balance: number }>("/balance", {
Authorization: `LSAT ${parsed.macaroon}:`,
})
cookieStorage.setItem("l402", JSON.stringify(parsed))
cookieStorage.setItem("l402", JSON.stringify(parsed), AUTH_COOKIE_DAYS)
await refreshBalance()
setStep("success")
} catch {
Expand Down Expand Up @@ -392,6 +393,7 @@ export function BudgetModal() {
identifier: challenge.id,
preimage: "",
}),
AUTH_COOKIE_DAYS
)
clearPendingLsat()
setPendingChallenge(null)
Expand Down
95 changes: 95 additions & 0 deletions src/lib/__tests__/cookie-storage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { describe, it, expect, beforeEach } from "vitest"
import { cookieStorage, AUTH_COOKIE_DAYS } from "@/lib/cookie-storage"

describe("cookieStorage", () => {
beforeEach(() => {
// Clear all cookies between tests
document.cookie.split(";").forEach((c) => {
const key = c.trim().split("=")[0]
document.cookie = `${key}=; Max-Age=0; Path=/`
})
})

it("exports AUTH_COOKIE_DAYS as 30", () => {
expect(AUTH_COOKIE_DAYS).toBe(30)
})

describe("setItem without days (session cookie)", () => {
it("stores the value and does not include Max-Age", () => {
cookieStorage.setItem("test_key", "test_value")
expect(cookieStorage.getItem("test_key")).toBe("test_value")
// document.cookie strips attributes — only the key=value pair is readable
// Verify Max-Age is NOT in the raw string when we can inspect it
// (jsdom exposes the full string via document.cookie which only shows key=value)
expect(document.cookie).toContain("test_key=")
})
})

describe("setItem with days (persistent cookie)", () => {
it("stores the value and includes Max-Age when days is supplied", () => {
// Intercept document.cookie setter to capture the full string
let capturedCookieString = ""
const descriptor = Object.getOwnPropertyDescriptor(Document.prototype, "cookie")!
const originalSet = descriptor.set!

Object.defineProperty(document, "cookie", {
set(value: string) {
capturedCookieString = value
originalSet.call(document, value)
},
get: descriptor.get,
configurable: true,
})

cookieStorage.setItem("persistent_key", "persistent_value", AUTH_COOKIE_DAYS)

// Restore original descriptor
Object.defineProperty(document, "cookie", descriptor)

expect(capturedCookieString).toContain("Max-Age=")
expect(capturedCookieString).toContain(`${AUTH_COOKIE_DAYS * 24 * 60 * 60}`)
expect(cookieStorage.getItem("persistent_key")).toBe("persistent_value")
})
})

describe("setItem without days (session cookie — no Max-Age)", () => {
it("does NOT include Max-Age in the cookie string", () => {
let capturedCookieString = ""
const descriptor = Object.getOwnPropertyDescriptor(Document.prototype, "cookie")!
const originalSet = descriptor.set!

Object.defineProperty(document, "cookie", {
set(value: string) {
capturedCookieString = value
originalSet.call(document, value)
},
get: descriptor.get,
configurable: true,
})

cookieStorage.setItem("session_key", "session_value")

Object.defineProperty(document, "cookie", descriptor)

expect(capturedCookieString).not.toContain("Max-Age=")
expect(cookieStorage.getItem("session_key")).toBe("session_value")
})
})

it("getItem returns null for missing keys", () => {
expect(cookieStorage.getItem("nonexistent")).toBeNull()
})

it("removeItem deletes a stored cookie", () => {
cookieStorage.setItem("to_remove", "bye")
expect(cookieStorage.getItem("to_remove")).toBe("bye")
cookieStorage.removeItem("to_remove")
expect(cookieStorage.getItem("to_remove")).toBeNull()
})

it("handles special characters in values", () => {
const value = JSON.stringify({ macaroon: "abc123", preimage: "xyz==", identifier: "id/1" })
cookieStorage.setItem("l402", value)
expect(cookieStorage.getItem("l402")).toBe(value)
})
})
2 changes: 2 additions & 0 deletions src/lib/cookie-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
* Cookies are written with Secure; SameSite=Strict; Path=/ to reduce XSS
* exposure. All methods are no-ops during SSR (typeof window === "undefined").
*/
export const AUTH_COOKIE_DAYS = 30

export const cookieStorage = {
getItem(key: string): string | null {
if (typeof window === "undefined") return null
Expand Down
9 changes: 5 additions & 4 deletions src/lib/sphinx/bridge.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { SignedMessage } from "./types"
import { isSphinx } from "./detect"
import { cookieStorage } from "@/lib/cookie-storage"
import { cookieStorage, AUTH_COOKIE_DAYS } from "@/lib/cookie-storage"

// sphinx-bridge communicates via postMessage with the Sphinx webview host
// eslint-disable-next-line @typescript-eslint/no-require-imports
Expand Down Expand Up @@ -38,7 +38,7 @@ export async function getSignedMessage(): Promise<SignedMessage> {
const result = await webln.signMessage(message)
if (result?.signature) {
const signed = { message, signature: result.signature }
cookieStorage.setItem("signature", JSON.stringify(signed))
cookieStorage.setItem("signature", JSON.stringify(signed), AUTH_COOKIE_DAYS)
return signed
}
} catch (error) {
Expand All @@ -63,7 +63,7 @@ export async function getSignedMessage(): Promise<SignedMessage> {
)
const result = await sphinx.signMessage(message)
const signed = { message, signature: result.signature }
cookieStorage.setItem("signature", JSON.stringify(signed))
cookieStorage.setItem("signature", JSON.stringify(signed), AUTH_COOKIE_DAYS)
return signed
} catch (error) {
console.error("Failed to sign message:", error)
Expand Down Expand Up @@ -100,7 +100,8 @@ export async function getL402(): Promise<string> {
macaroon: token.macaroon,
identifier: token.identifier,
preimage: token.preimage,
})
}),
AUTH_COOKIE_DAYS
)
return `LSAT ${token.macaroon}:${token.preimage}`
}
Expand Down
8 changes: 5 additions & 3 deletions src/lib/sphinx/payment.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Lsat } from "lsat-js"
import { isSphinx } from "./detect"
import { api } from "../api"
import { cookieStorage } from "@/lib/cookie-storage"
import { cookieStorage, AUTH_COOKIE_DAYS } from "@/lib/cookie-storage"
import { isMocksEnabled, MOCK_TRANSACTIONS } from "@/lib/mock-data"
import { decodeInvoiceAmountSats } from "@/lib/invoice-utils"

Expand Down Expand Up @@ -76,7 +76,8 @@ async function payViaSphinx(
macaroon: lsat.baseMacaroon,
identifier: lsat.id,
preimage: stored.preimage,
})
}),
AUTH_COOKIE_DAYS
)
setBudget(budgetAmount)
return
Expand Down Expand Up @@ -115,7 +116,8 @@ async function payViaWebLN(
macaroon: lsat.baseMacaroon,
identifier: lsat.id,
preimage: payment.preimage,
})
}),
AUTH_COOKIE_DAYS
)
}
setBudget(budgetAmount)
Expand Down
Loading