Scope CORS to browser routes only, fail closed on CORS_ORIGIN #4

Merged
matt merged 4 commits from mailship-32x-cors-is-not-production-safe-wildcard-ori-52b into main 2026-09-10 19:46:02 +00:00
17 changed files with 2 additions and 743 deletions
Showing only changes of commit a4463fdab4 - Show all commits

View file

@ -1,6 +1,5 @@
node_modules/
dist/
web/dist/
.git/
.gitattributes
.gitignore

View file

@ -25,12 +25,7 @@ COPY src/ ./src/
COPY src/pages/ ./src/pages/
COPY src/emails/ ./src/emails/
# Build web UI
COPY web/package.json web/pnpm-lock.yaml web/pnpm-workspace.yaml web/tsconfig.json web/vite.config.js web/index.html ./web/
COPY web/src/ ./web/src/
RUN cd web && pnpm install --frozen-lockfile && pnpm run build
# Build TypeScript (runs tsc && build:html && build:web)
# Build TypeScript (runs tsc && build:html)
RUN pnpm run build
# Production image
@ -51,7 +46,6 @@ RUN pnpm install --frozen-lockfile --prod
# Copy build artifacts
COPY --from=build /app/dist/ ./dist/
COPY --from=build /app/web/dist/ ./web/dist/
COPY --from=build /app/src/pages/ ./dist/pages/
COPY --from=build /app/src/emails/ ./dist/emails/

2
build-in-production.sh Executable file → Normal file
View file

@ -2,11 +2,9 @@
# Remove link overrides
node remove-pnpm-overrides.js package.json
node remove-pnpm-overrides.js web/package.json
# When CI=true as it is on render.com, removing link overrides breaks the lockfile
pnpm i --no-frozen-lockfile
(cd web && pnpm i --no-frozen-lockfile)
# Build everything
pnpm run build

View file

@ -3,8 +3,7 @@
"type": "module",
"version": "1.0.0",
"scripts": {
"build": "tsc && pnpm run build:html && pnpm run build:web",
"build:web": "cd web && pnpm run build",
"build": "tsc && pnpm run build:html",
"build:html": "cp -r src/pages dist/ && cp -r src/emails dist/",
"check": "tsc --noEmit && eslint src",
"format": "prettier --write \"src/**/*.{ts,js,json,html}\"",

View file

@ -41,8 +41,6 @@ server.use('/', corsMiddleware)
server.use(express.json())
server.use(express.static('web/dist'))
// Rate limit for registration endpoints
server.use(
'/subscription',
@ -79,16 +77,6 @@ const addRoute = (method: 'get' | 'post' | 'put' | 'delete', path: string, handl
}
addRoute('get', '/', async (req: Request, res: Response) => {
try {
const {existsSync} = await import('fs')
const webIndex = new URL('../web/dist/index.html', import.meta.url)
if (existsSync(webIndex)) {
return res.send(await render('../web/dist/index.html'))
}
} catch {
// Fall through to JSON
}
res.json({
name: 'Mailship',
description: 'Email notification server for Nostr',

View file

@ -1,3 +0,0 @@
VITE_NOTIFIER_PUBKEY=
VITE_NOTIFIER_RELAY=
VITE_INDEXER_RELAYS=purplepag.es,relay.damus.io,relay.nostr.band

24
web/.gitignore vendored
View file

@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View file

@ -1,21 +0,0 @@
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
import { defineConfig } from "eslint/config";
export default defineConfig([
{ files: ["**/*.{js,mjs,cjs,ts}"], plugins: { js }, extends: ["js/recommended"] },
{ files: ["**/*.{js,mjs,cjs,ts}"], languageOptions: { globals: globals.browser } },
tseslint.configs.recommended,
{
files: ["src/**/*.{js,mjs,cjs,ts}"],
rules: {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{args: "none", destructuredArrayIgnorePattern: "^_d?$", caughtErrors: "none"},
],
},
},
]);

View file

@ -1,15 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Anchor Alerts</title>
</head>
<body class="bg-gray-50 min-h-screen">
<div class="container mx-auto px-4 py-8">
<div id="app" class="max-w-2xl mx-auto"></div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View file

@ -1,34 +0,0 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"check": "tsc --noEmit && eslint src",
"format": "eslint src --fix"
},
"devDependencies": {
"@eslint/js": "^9.25.1",
"@types/mithril": "^2.2.7",
"eslint": "^9.25.1",
"globals": "^16.0.0",
"typescript": "~5.7.2",
"typescript-eslint": "^8.31.1",
"vite": "^6.3.1"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.4",
"@welshman/feeds": "^0.6.3",
"@welshman/lib": "^0.6.3",
"@welshman/net": "^0.6.3",
"@welshman/signer": "^0.6.3",
"@welshman/store": "^0.6.3",
"@welshman/util": "^0.6.3",
"events": "^3.3.0",
"mithril": "^2.2.15",
"svelte": "^5.27.2",
"tailwindcss": "^4.1.4"
}
}

Binary file not shown.

View file

@ -1,2 +0,0 @@
allowBuilds:
esbuild: true

View file

@ -1,544 +0,0 @@
import './style.css'
import m from "mithril"
import {writable} from 'svelte/store'
import {getJson, removeNil, spec, parseJson, setJson, assoc, randomId, TIMEZONE, tryCatch, LOCALE} from '@welshman/lib'
import {withGetter} from '@welshman/store'
import {Router} from '@welshman/router'
import {validateFeed, ValidationError, displayFeeds, Feed} from '@welshman/feeds'
import {getAddress, getRelaysFromList, RelayMode, readList, asDecryptedEvent, normalizeRelayUrl, getTagValue, getTagValues, makeEvent, DELETE, TrustedEvent, StampedEvent, FEED, Address, getIdFilters, fromNostrURI, RELAYS} from '@welshman/util'
import {load, publish, defaultSocketPolicies, makeSocketPolicyAuth} from '@welshman/net'
import type {ISigner} from '@welshman/signer'
import {Nip07Signer, decrypt} from '@welshman/signer'
// Constants
const NOTIFIER_PUBKEY = import.meta.env.VITE_NOTIFIER_PUBKEY
const NOTIFIER_RELAY = import.meta.env.VITE_NOTIFIER_RELAY ? normalizeRelayUrl(import.meta.env.VITE_NOTIFIER_RELAY) : undefined
const INDEXER_RELAYS = (import.meta.env.VITE_INDEXER_RELAYS || 'purplepag.es,relay.damus.io,relay.nostr.band').split(',').map(normalizeRelayUrl)
const ALERT = 32830
const ALERT_STATUS = 32831
const TZ_OFFSET = parseInt(TIMEZONE.split(':')[0]!)
const CRON_DAILY_PATTERN = /^0 \d{1,2} \d{1,2} \* \* \*$/
const CRON_WEEKLY_PATTERN = /^0 \d{1,2} \d{1,2} \* \* 1$/
const PLUS_ICON = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 5V19M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`
const TRASH_ICON = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20.5 6H3.49991" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
<path d="M18.8333 8.5L18.3734 15.3991C18.1964 18.054 18.1079 19.3815 17.2429 20.1907C16.3779 21 15.0475 21 12.3867 21H11.6133C8.95252 21 7.62212 21 6.75711 20.1907C5.8921 19.3815 5.80361 18.054 5.62661 15.3991L5.16667 8.5" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
<path d="M6.5 6C6.55588 6 6.58382 6 6.60915 5.99936C7.43259 5.97849 8.15902 5.45491 8.43922 4.68032C8.44784 4.65649 8.45667 4.62999 8.47434 4.57697L8.57143 4.28571C8.65431 4.03708 8.69575 3.91276 8.75071 3.8072C8.97001 3.38607 9.37574 3.09364 9.84461 3.01877C9.96213 3 10.0932 3 10.3553 3H13.6447C13.9068 3 14.0379 3 14.1554 3.01877C14.6243 3.09364 15.03 3.38607 15.2493 3.8072C15.3043 3.91276 15.3457 4.03708 15.4286 4.28571L15.5257 4.57697C15.5433 4.62992 15.5522 4.65651 15.5608 4.68032C15.841 5.45491 16.5674 5.97849 17.3909 5.99936C17.4162 6 17.4441 6 17.5 6" stroke="#1C274C" stroke-width="1.5"/>
</svg>`
const ARROW_LEFT_ICON = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 12H5M5 12L12 19M5 12L12 5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`
// Types and state
type Alert = {
event: TrustedEvent
tags: string[][]
}
type AlertStatus = {
event: TrustedEvent
tags: string[][]
}
type AlertValues = {
feedAddress: string
freq: string
time: string,
email: string
secret: string
}
type State = {
failedToLogin: boolean
signer: ISigner
pubkey: string | undefined
alerts: Alert[]
alertDraft?: AlertValues,
alertStatuses: AlertStatus[]
alertsLoading: boolean
}
const state = withGetter(
writable({
failedToLogin: false,
signer: new Nip07Signer(),
pubkey: getJson('pubkey'),
alerts: [],
alertStatuses: [],
alertsLoading: false,
} as State)
)
// Actions
const login = async () => {
const {signer} = state.get()
try {
const pubkey = await signer.getPubkey()
state.update(assoc('pubkey', pubkey))
setJson('pubkey', pubkey)
} catch (e) {
state.update(assoc('failedToLogin', true))
}
}
const loadAlerts = async () => {
const {signer, pubkey} = state.get()
if (!NOTIFIER_RELAY) {
state.update(assoc('alertsLoading', false))
return
}
state.update(assoc('alertsLoading', true))
const events = await load({
relays: [NOTIFIER_RELAY],
filters: [
{kinds: [ALERT], authors: [pubkey!]},
{kinds: [ALERT_STATUS], "#p": [pubkey!]},
],
})
const alerts = await Promise.all(
events
.filter(spec({kind: ALERT}))
.map(async event => {
const tags = parseJson(await decrypt(signer, NOTIFIER_PUBKEY, event.content))
return {event, tags}
})
)
const alertStatuses = await Promise.all(
events
.filter(spec({kind: ALERT_STATUS}))
.map(async event => {
const tags = parseJson(await decrypt(signer, NOTIFIER_PUBKEY, event.content))
return {event, tags}
})
)
state.update($state => ({...$state, alertsLoading: false, alerts, alertStatuses}))
}
const deleteAlert = async (alert: Alert) => {
if (!NOTIFIER_RELAY) return
if (confirm("Are you sure you want to delete this alert?")) {
state.update(assoc('alertsLoading', true))
await publish({
relays: [NOTIFIER_RELAY],
event: await state.get().signer!.sign(
makeEvent(DELETE, {
tags: [
["k", String(alert.event.kind)],
["a", getAddress(alert.event)]
],
})
),
})
await loadAlerts()
}
}
export type AlertParams = {
feeds: Feed[]
freq: string
time: string
email: string
secret: string
}
export const makeAlert = async ({freq, time, email, feeds, secret}: AlertParams) => {
const {signer} = state.get()
const [hour, minute] = time.split(':')
const utcHour = (parseInt(hour) - TZ_OFFSET) % 24
const dow = freq === 'daily' ? '*' : freq
const cron = `0 ${minute} ${utcHour} * * ${dow}`
const tags = [
["cron", cron],
["email", email],
["channel", "email"],
["locale", LOCALE],
["timezone", TIMEZONE],
[
"handler",
"31990:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1685968093690",
"wss://relay.nostr.band/",
"web",
],
]
for (const feed of feeds) {
tags.push(["feed", JSON.stringify(feed)])
}
return signer.sign(
makeEvent(ALERT, {
content: await signer.nip44.encrypt(NOTIFIER_PUBKEY, JSON.stringify(tags)),
tags: [
["d", randomId()],
["p", NOTIFIER_PUBKEY],
],
})
)
}
export const publishAlert = async (params: AlertParams) => {
if (!NOTIFIER_RELAY) return
await publish({event: await makeAlert(params), relays: [NOTIFIER_RELAY]})
}
// Components
const Loader = {
view: () => m("div", { class: "flex justify-center py-4" }, [
m("div", {
class: "animate-spin rounded-full h-8 w-8 border-4 border-purple-200 border-t-purple-600"
})
])
}
const Login = {
view: () =>
m("button", {
onclick: login,
class: "w-full bg-purple-600 text-white font-semibold py-2 px-4 rounded-lg hover:bg-purple-700 transition-colors"
}, "Connect with Nostr"),
}
const AlertStatus: m.Component<{alert: Alert}> = {
view: vnode => {
const {alert} = vnode.attrs
const {alertStatuses} = state.get()
const address = getAddress(alert.event)
const alertStatus = alertStatuses.find(s => getTagValue('d', s.event.tags) === address)
const status = getTagValue('status', alertStatus?.tags || [])
const message = getTagValue('message', alertStatus?.tags || [])
const getStatusClasses = () => {
const baseClasses = "rounded-full px-3 py-1 text-sm border"
if (status === 'ok') return `${baseClasses} border-green-500 text-green-500`
if (status === 'pending') return `${baseClasses} border-yellow-500 text-yellow-500`
return `${baseClasses} border-red-500 text-red-500`
}
const getStatusDisplay = () => {
if (!status) return 'Inactive'
if (status === 'ok') return 'Active'
if (status === 'pending') return 'Pending'
return status.replace('-', ' ').replace(/^(.)/, x => x.toUpperCase())
}
return m("div", {class: getStatusClasses(), tooltip: message}, getStatusDisplay())
},
}
const AlertListItem: m.Component<{alert: Alert}> = {
view: vnode => {
const {alert} = vnode.attrs
const cron = getTagValue('cron', alert.tags)
const feeds = getTagValues('feed', alert.tags)
const channel = getTagValue('channel', alert.tags)
const description = displayFeeds(feeds.map(feed => parseJson(feed))) || "[invalid feed]"
let frequency = cron || "Unknown"
if (cron) {
if (CRON_DAILY_PATTERN.test(cron)) {
frequency = 'Daily'
} else if (CRON_WEEKLY_PATTERN.test(cron)) {
frequency = 'Weekly'
}
}
return m("div", { class: "flex items-start justify-between p-4" }, [
m("button", {
onclick: () => deleteAlert(alert),
class: "mr-4 mt-1",
tooltip: "Delete alert"
}, [m.trust(TRASH_ICON)]),
m("div", { class: "space-y-2 flex-grow" }, [
m("div", { class: "text-gray-600" }, `${frequency} alert via ${channel}`),
m("div", { class: "text-sm text-gray-500" }, `Events ${description}`)
]),
m(AlertStatus, {alert}),
])
}
}
const AlertList = {
oninit: loadAlerts,
view: () => {
const {alerts, alertsLoading} = state.get()
const content = alertsLoading
? m(Loader)
: alerts.length > 0
? alerts.map(alert => m(AlertListItem, {alert, key: alert.event.id}))
: m("div", { class: "text-center text-gray-500 py-8" }, [
"You don't have any alerts set up.",
])
return m("div", { class: "space-y-4" }, [
m("div", { class: "flex items-center justify-between mb-6" }, [
m("h1", { class: "text-2xl font-bold text-gray-900" }, "Your Nostr Alerts"),
m("a", {
href: "#!/alerts/new",
class: "flex items-center gap-2 bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 transition-colors",
}, [
m.trust(PLUS_ICON),
"Add Alert"
])
]),
m("div", { class: "bg-white shadow rounded-lg p-6" }, content)
])
}
}
const AlertCreate = {
oninit: () => {
state.update(assoc('alertDraft', {
email: getTagValue('email', state.get().alerts[0]?.tags || []) || "",
freq: 'daily',
time: '17:00',
feedAddress: "",
secret: "",
}))
},
view: () => {
const {pubkey, alertDraft, alertsLoading} = state.get()
const {email, feedAddress, freq, time, secret} = alertDraft!
const update = (newValues: Partial<AlertValues>) => {
state.update(assoc('alertDraft', {...alertDraft, ...newValues}))
}
const submit = async (e: Event) => {
e.preventDefault()
state.update(assoc('alertsLoading', true))
try {
if (!email.includes("@")) return alert("Please provide a valid email address")
const address = tryCatch(() => Address.fromNaddr(fromNostrURI(feedAddress)))
if (!address) return alert("Please provide a valid feed address")
if (address.kind !== FEED) return alert(`Please provide a valid feed address (kind ${FEED})`)
const selections = await load({
relays: INDEXER_RELAYS,
filters: [{kinds: [RELAYS], authors: [pubkey!, address.pubkey]}],
})
const router = Router.get()
const filters = getIdFilters([address.toString()])
const scenario = router.merge([
router.FromRelays(selections.flatMap(e => getRelaysFromList(readList(asDecryptedEvent(e)), RelayMode.Write))),
router.FromRelays(address.relays),
router.FromRelays(INDEXER_RELAYS),
])
const relays = scenario.limit(10).getUrls()
const [event] = await load({relays, filters})
if (!event) return alert("Sorry, we weren't able to find that feed")
const feedStrings = getTagValues('feed', event.tags)
if (feedStrings.length === 0) return alert('At least one feed is required')
const feeds = removeNil(feedStrings.map(parseJson))
if (feeds.length < feedStrings.length) return alert("At least one feed is invalid (must be valid JSON)")
const feedError = feeds.map(validateFeed).find(e => e instanceof ValidationError)
if (feedError) return alert(`At least one feed is invalid (${feedError.data.toLowerCase()}).`)
await publishAlert({freq, time, email, feeds, secret})
m.route.set("/alerts")
} catch (error) {
alert("Failed to create alert. Please try again.")
console.error('Error creating alert:', error)
} finally {
state.update(assoc('alertsLoading', false))
}
}
return m("div", { class: "space-y-4" }, [
m("div", { class: "flex items-center gap-4 mb-6" }, [
m("button", {
onclick: () => m.route.set("/alerts"),
class: "text-gray-600 hover:text-gray-900 cursor-pointer",
tooltip: "Back to alerts"
}, m.trust(ARROW_LEFT_ICON)),
m("h1", { class: "text-2xl font-bold text-gray-900" }, "Create Alert")
]),
m("div", { class: "bg-white shadow rounded-lg p-6" }, [
m("form", { class: "space-y-6", onsubmit: submit }, [
m("div", [
m("label", { class: "block text-sm font-medium text-gray-700 mb-1" }, "Email"),
m("input", {
type: "email",
placeholder: "Enter your email address",
value: email,
oninput: (e: InputEvent) => update({email: (e.target as HTMLInputElement).value}),
class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500"
})
]),
m("div", {class: "w-full flex gap-2"}, [
m("div", {class: "flex-grow"}, [
m("label", { class: "block text-sm font-medium text-gray-700 mb-1" }, "Frequency"),
m("select", {
value: freq,
onchange: (e: Event) => update({freq: (e.target as HTMLSelectElement).value}),
class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500"
}, [
m("option", { value: 'daily' }, "Daily"),
m("option", { value: '0' }, "Weekly on Sunday"),
m("option", { value: '1' }, "Weekly on Monday"),
m("option", { value: '2' }, "Weekly on Tuesday"),
m("option", { value: '3' }, "Weekly on Wednesday"),
m("option", { value: '4' }, "Weekly on Thursday"),
m("option", { value: '5' }, "Weekly on Friday"),
m("option", { value: '6' }, "Weekly on Saturday"),
])
]),
m("div", [
m("label", { class: "block text-sm font-medium text-gray-700 mb-1" }, "Time"),
m("input", {
value: time,
onchange: (e: Event) => update({time: (e.target as HTMLSelectElement).value}),
type: "time",
class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500"
})
]),
]),
m("div", [
m("label", { class: "block text-sm font-medium text-gray-700 mb-1" }, "Feed Address"),
m("div", { class: "space-y-2" }, [
m("input", {
type: "text",
placeholder: "naddr1...",
value: feedAddress,
oninput: (e: InputEvent) => update({feedAddress: (e.target as HTMLInputElement).value}),
class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500"
}),
m("p", { class: "text-sm text-gray-500" }, [
"Visit ",
m("a", {
href: "https://coracle.social/feeds",
target: "_blank",
class: "text-purple-600 hover:text-purple-800"
}, "coracle.social/feeds"),
" to search for existing feeds or create a new one. Copy the feed address (starts with 'naddr1') and paste it here."
])
])
]),
m("div", { class: "flex justify-end" }, [
m("button", {
type: "submit",
disabled: alertsLoading,
class: "bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
}, alertsLoading ? "Creating..." : "Create Alert")
])
])
])
])
}
}
const FailedToLogin = {
view: () =>
m("div", { class: "space-y-6 text-center" }, [
m("div", { class: "bg-red-50 border border-red-200 rounded-lg p-6" }, [
m("h2", { class: "text-red-800 font-semibold mb-2" }, "Unable to Connect"),
m("p", { class: "text-red-600 mb-4" }, "To use Anchor Alerts, you need a Nostr signer extension installed in your browser."),
m("div", { class: "space-y-3" }, [
m("button", {
onclick: () => window.location.reload(),
class: "w-full bg-red-100 text-red-700 font-medium py-2 px-4 rounded-lg hover:bg-red-200 transition-colors"
}, "Try Again"),
m("a", {
href: "https://nostrapps.com/#signers",
target: "_blank",
class: "block w-full bg-purple-600 text-white font-medium py-2 px-4 rounded-lg hover:bg-purple-700 transition-colors"
}, "Install a Nostr Signer")
])
])
])
}
const Layout: m.Component<{children: m.Children}> = {
view: vnode => {
const {children} = vnode.attrs
const {failedToLogin, pubkey} = state.get()
if (failedToLogin) {
return m(FailedToLogin)
}
if (!pubkey) {
return m("div", { class: "text-center space-y-4" }, [
m("h1", { class: "text-2xl font-bold text-gray-900 mb-2" }, "Welcome to Anchor Alerts"),
m("p", { class: "text-gray-600 mb-6" }, "Connect your Nostr signer to get started"),
m(Login)
])
}
return children
}
}
m.route(document.querySelector('#app')!, "/alerts", {
"/alerts": {
view: () => {
return m(Layout, {children: [m(AlertList)]})
},
},
"/alerts/new": {
view: () => {
return m(Layout, {children: [m(AlertCreate)]})
}
},
})
state.subscribe(() => m.redraw())
defaultSocketPolicies.push(
makeSocketPolicyAuth({
sign: (event: StampedEvent) => {
return state.get().signer?.sign(event)
},
}),
)
Object.assign(window, {setJson, getJson})

View file

@ -1,42 +0,0 @@
@import "tailwindcss";
a, button {
@apply cursor-pointer;
}
/* Tooltip styling */
[tooltip] {
@apply cursor-pointer relative;
}
[tooltip]:hover::after {
content: attr(tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
padding: 4px 8px;
background-color: rgba(0, 0, 0, 0.8);
color: white;
border-radius: 4px;
font-size: 14px;
white-space: nowrap;
z-index: 1000;
pointer-events: none;
/* Animation properties */
opacity: 0;
animation: tooltipFadeIn 0.2s ease-in-out forwards;
}
/* Keyframes for fade in animation */
@keyframes tooltipFadeIn {
from {
opacity: 0;
transform: translateX(-50%) translateY(0);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(-3px);
}
}

View file

@ -1 +0,0 @@
/// <reference types="vite/client" />

View file

@ -1,22 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View file

@ -1,11 +0,0 @@
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
server: {
port: 2893,
},
plugins: [
tailwindcss(),
],
})