diff --git a/modules/rcal/mod.ts b/modules/rcal/mod.ts index c9160400..2799471f 100644 --- a/modules/rcal/mod.ts +++ b/modules/rcal/mod.ts @@ -17,7 +17,8 @@ import { verifyToken, extractToken } from "../../server/auth"; import { renderLanding } from "./landing"; import type { SyncServer } from '../../server/local-first/sync-server'; import { calendarSchema, calendarDocId } from './schemas'; -import type { CalendarDoc, CalendarEvent, CalendarSource, SavedCalendarView, ScheduledItemMetadata } from './schemas'; +import type { CalendarDoc, CalendarEvent, CalendarSource, SavedCalendarView, ScheduledItemMetadata, EventAttendee } from './schemas'; +import { sendMagicLinks } from "../../server/magic-link/send"; let _syncServer: SyncServer | null = null; @@ -690,6 +691,69 @@ routes.delete("/api/events/:id", async (c) => { return c.json({ ok: true }); }); +// ── API: RSVP Magic Links ── + +// POST /api/events/:id/invite — create RSVP magic links for an event +routes.post("/api/events/:id/invite", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const id = c.req.param("id"); + + const doc = ensureDoc(dataSpace); + const event = doc.events[id]; + if (!event) return c.json({ error: "Event not found" }, 404); + + const body = await c.req.json<{ participants: Array<{ name: string; email: string }>; sendEmail?: boolean }>(); + if (!body.participants?.length) return c.json({ error: "participants array required" }, 400); + + const results = await sendMagicLinks({ + space: dataSpace, + type: "rsvp", + targetId: id, + title: event.title, + participants: body.participants, + sendEmail: body.sendEmail, + }); + + return c.json({ ok: true, invites: results }); +}); + +// GET /api/events/:id/rsvp — get RSVP tallies for an event +routes.get("/api/events/:id/rsvp", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const id = c.req.param("id"); + + const doc = ensureDoc(dataSpace); + const event = doc.events[id]; + if (!event) return c.json({ error: "Event not found" }, 404); + + const attendees = (event.attendees || []) as EventAttendee[]; + const tallies = { + yes: attendees.filter((a) => a.status === "yes").length, + no: attendees.filter((a) => a.status === "no").length, + maybe: attendees.filter((a) => a.status === "maybe").length, + pending: attendees.filter((a) => a.status === "pending").length, + total: attendees.length, + }; + + return c.json({ + eventId: id, + title: event.title, + tallies, + attendees: attendees.map((a) => ({ + name: a.name, + status: a.status, + respondedAt: a.respondedAt ? new Date(a.respondedAt).toISOString() : null, + source: a.source, + })), + }); +}); + // ── API: Sources ── routes.get("/api/sources", async (c) => { diff --git a/modules/rcal/schemas.ts b/modules/rcal/schemas.ts index 95d0fa40..5f1def55 100644 --- a/modules/rcal/schemas.ts +++ b/modules/rcal/schemas.ts @@ -23,6 +23,16 @@ export interface CalendarSource { createdAt: number; } +export interface EventAttendee { + name: string; + email?: string; + status: 'yes' | 'no' | 'maybe' | 'pending'; + respondedAt: number; + source: 'magic-link' | 'app' | 'import'; + /** Prevent duplicate responses from same magic link */ + tokenHash?: string; +} + export interface CalendarEvent { id: string; title: string; @@ -52,7 +62,7 @@ export interface CalendarEvent { rToolEntityId: string | null; locationBreadcrumb: string | null; // "Earth > Europe > Germany > Berlin" bookingStatus: string | null; // "booked" | "unbooked" | null (placeholder for booking pipeline) - attendees: unknown[]; + attendees: EventAttendee[]; attendeeCount: number; tags: string[] | null; metadata: unknown | null; diff --git a/modules/rchoices/mod.ts b/modules/rchoices/mod.ts index 46d88bae..79fbc912 100644 --- a/modules/rchoices/mod.ts +++ b/modules/rchoices/mod.ts @@ -14,6 +14,11 @@ import type { RSpaceModule } from "../../shared/module"; import { renderLanding } from "./landing"; import { getModuleInfoList } from "../../shared/module"; import { getDocumentData, addShapes } from "../../server/community-store"; +import { verifyToken, extractToken } from "../../server/auth"; +import { syncServer } from "../../server/sync-instance"; +import { choicesDocId } from "./schemas"; +import type { ChoicesDoc } from "./schemas"; +import { sendMagicLinks } from "../../server/magic-link/send"; const routes = new Hono(); @@ -46,6 +51,35 @@ routes.get("/api/choices", async (c) => { return c.json({ choices, total: choices.length }); }); +// POST /api/choices/:sessionId/invite — create magic links for a poll session +routes.post("/api/choices/:sessionId/invite", async (c) => { + const authToken = extractToken(c.req.raw.headers); + if (!authToken) return c.json({ error: "Authentication required" }, 401); + try { await verifyToken(authToken); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const sessionId = c.req.param("sessionId"); + + const doc = syncServer.getDoc(choicesDocId(space)); + if (!doc) return c.json({ error: "Choices doc not found" }, 404); + const session = doc.sessions[sessionId]; + if (!session) return c.json({ error: "Session not found" }, 404); + + const body = await c.req.json<{ participants: Array<{ name: string; email: string }>; sendEmail?: boolean }>(); + if (!body.participants?.length) return c.json({ error: "participants array required" }, 400); + + const results = await sendMagicLinks({ + space, + type: "poll", + targetId: sessionId, + title: session.title, + participants: body.participants, + sendEmail: body.sendEmail, + }); + + return c.json({ ok: true, invites: results }); +}); + // GET / — choices page (default tab: spider) routes.get("/", (c) => { const spaceSlug = c.req.param("space") || "demo"; diff --git a/server/index.ts b/server/index.ts index 66d401c6..a9f77163 100644 --- a/server/index.ts +++ b/server/index.ts @@ -66,6 +66,7 @@ import { notesModule } from "../modules/rnotes/mod"; import { mapsModule } from "../modules/rmaps/mod"; import { tasksModule } from "../modules/rtasks/mod"; import { checklistCheckRoutes, checklistApiRoutes } from "../modules/rtasks/checklist-routes"; +import { magicLinkRoutes } from "./magic-link/routes"; import { tripsModule } from "../modules/rtrips/mod"; import { calModule } from "../modules/rcal/mod"; import { networkModule } from "../modules/rnetwork/mod"; @@ -522,6 +523,9 @@ app.route("/api/mi", miRoutes); app.route("/rtasks/check", checklistCheckRoutes); app.route("/api/rtasks", checklistApiRoutes); +// ── Magic Link Responses (top-level, bypasses space auth) ── +app.route("/respond", magicLinkRoutes); + // ── EncryptID proxy (forward /encryptid/* to encryptid container) ── const ENCRYPTID_INTERNAL = process.env.ENCRYPTID_INTERNAL_URL || "http://encryptid:3000"; app.all("/encryptid/*", async (c) => { @@ -3527,6 +3531,7 @@ const server = Bun.serve({ url.pathname.startsWith("/data/") || url.pathname.startsWith("/encryptid/") || url.pathname.startsWith("/rtasks/check/") || + url.pathname.startsWith("/respond/") || url.pathname.startsWith("/.well-known/") || url.pathname === "/about" || url.pathname === "/admin" || @@ -3615,7 +3620,7 @@ const server = Bun.serve({ // Only match canonical bare domain, not stacked subdomains like rspace.rspace.online if (!subdomain && (hostClean === "rspace.online" || hostClean === "www.rspace.online")) { // Top-level routes that must bypass module rewriting - if (url.pathname.startsWith("/rtasks/check/")) { + if (url.pathname.startsWith("/rtasks/check/") || url.pathname.startsWith("/respond/")) { return app.fetch(req); } diff --git a/server/magic-link/config.ts b/server/magic-link/config.ts new file mode 100644 index 00000000..9fa53f68 --- /dev/null +++ b/server/magic-link/config.ts @@ -0,0 +1,7 @@ +/** Configuration for magic link responses (polls & RSVPs). */ + +export const magicLinkConfig = { + hmacSecret: process.env.MAGIC_LINK_HMAC_SECRET || "", + baseUrl: process.env.SITE_URL || "https://rspace.online", + tokenExpiryDays: parseInt(process.env.MAGIC_LINK_EXPIRY_DAYS || "7", 10), +} as const; diff --git a/server/magic-link/render.ts b/server/magic-link/render.ts new file mode 100644 index 00000000..5c1fd41b --- /dev/null +++ b/server/magic-link/render.ts @@ -0,0 +1,144 @@ +/** Standalone HTML rendering for magic link response pages. */ + +import { magicLinkConfig } from "./config"; +import type { ChoiceSession, ChoiceOption } from "../../modules/rchoices/schemas"; +import type { CalendarEvent } from "../../modules/rcal/schemas"; + +function esc(str: string): string { + return str.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +const styles = ` +body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f8fafc;color:#1e293b;margin:0;padding:20px} +.card{max-width:500px;margin:40px auto;background:#fff;border-radius:12px;box-shadow:0 1px 3px rgba(0,0,0,.1);padding:32px} +h1{font-size:20px;margin:0 0 4px;color:#0f172a} +.sub{font-size:14px;color:#64748b;margin-bottom:24px} +.opt{display:block;width:100%;padding:14px 20px;margin:8px 0;border:2px solid #e2e8f0;border-radius:10px;background:#fff;cursor:pointer;font-size:16px;text-align:left;color:#1e293b;text-decoration:none;box-sizing:border-box;transition:border-color .15s,background .15s} +.opt:hover{border-color:#6366f1;background:#eef2ff} +.opt.sel{border-color:#22c55e;background:#f0fdf4} +.rsvp-row{display:flex;gap:10px;margin:16px 0} +.rsvp-btn{flex:1;padding:14px;border:2px solid #e2e8f0;border-radius:10px;background:#fff;cursor:pointer;font-size:15px;text-align:center;text-decoration:none;color:#1e293b;transition:border-color .15s,background .15s} +.rsvp-btn:hover{border-color:#6366f1;background:#eef2ff} +.rsvp-btn.sel{border-color:#22c55e;background:#f0fdf4} +.banner{padding:12px;border-radius:8px;margin-bottom:20px;font-size:14px} +.banner-ok{background:#22c55e;color:#fff} +.banner-info{background:#3b82f6;color:#fff} +.meta{font-size:13px;color:#64748b;margin:4px 0} +.ft{text-align:center;font-size:12px;color:#94a3b8;margin-top:24px} +.label{font-size:13px;color:#94a3b8;margin-top:16px;margin-bottom:4px} +`; + +// ── Poll page ── + +export function renderPollPage( + session: ChoiceSession, + token: string, + respondentName: string, + existingChoice?: string, + justVoted?: string, +): string { + let banner = ""; + if (justVoted) { + const opt = session.options.find((o) => o.id === justVoted); + banner = ``; + } else if (existingChoice) { + const opt = session.options.find((o) => o.id === existingChoice); + banner = ``; + } + + const optionButtons = session.options + .map((opt) => { + const selected = (existingChoice === opt.id || justVoted === opt.id) ? " sel" : ""; + return `
+ + +
`; + }) + .join("\n"); + + return ` +${esc(session.title)} - rChoices +
+${banner} +

${esc(session.title)}

+
Hi ${esc(respondentName)} — tap your choice below
+
${session.options.length} option${session.options.length === 1 ? "" : "s"} · ${session.type} vote
+${optionButtons} +
rChoices · rspace.online · Link expires in ${magicLinkConfig.tokenExpiryDays} days
+
`; +} + +// ── RSVP page ── + +const rsvpLabels: Record = { yes: "Going", no: "Not Going", maybe: "Maybe" }; +const rsvpEmoji: Record = { yes: "✓", no: "✗", maybe: "?" }; + +export function renderRsvpPage( + event: CalendarEvent, + token: string, + respondentName: string, + currentStatus?: string, + justResponded?: string, +): string { + let banner = ""; + if (justResponded) { + banner = ``; + } else if (currentStatus && currentStatus !== "pending") { + banner = ``; + } + + const startDate = event.startTime + ? new Date(event.startTime).toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" }) + : ""; + const startTime = event.startTime && !event.allDay + ? new Date(event.startTime).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }) + : ""; + const endTime = event.endTime && !event.allDay + ? new Date(event.endTime).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }) + : ""; + + const timeLine = event.allDay + ? `

All day · ${startDate}

` + : `

${startDate}${startTime ? ` · ${startTime}` : ""}${endTime ? ` – ${endTime}` : ""}

`; + + const location = event.locationName + ? `

${esc(event.locationName)}

` + : event.isVirtual && event.virtualUrl + ? `

Virtual · ${esc(event.virtualPlatform || "Join")}

` + : ""; + + const buttons = ["yes", "no", "maybe"] + .map((status) => { + const selected = (currentStatus === status || justResponded === status) ? " sel" : ""; + return `
+ + +
`; + }) + .join("\n"); + + return ` +RSVP: ${esc(event.title)} - rCal +
+${banner} +

${esc(event.title)}

+${timeLine} +${location} +${event.description ? `

${esc(event.description)}

` : ""} +
Hi ${esc(respondentName)} — will you attend?
+
+${buttons} +
+
rCal · rspace.online · Link expires in ${magicLinkConfig.tokenExpiryDays} days
+
`; +} + +// ── Error page ── + +export function renderError(title: string, message: string): string { + return ` +Error - rSpace +

${esc(title)}

+

${esc(message)}

+
rSpace · rspace.online
`; +} diff --git a/server/magic-link/routes.ts b/server/magic-link/routes.ts new file mode 100644 index 00000000..8070bbd7 --- /dev/null +++ b/server/magic-link/routes.ts @@ -0,0 +1,182 @@ +/** + * Magic link routes — mounted at top level to bypass space auth middleware. + * GET /respond/:token — verify HMAC, render poll or RSVP page + * POST /respond/:token — submit response, re-render with confirmation + */ + +import { Hono } from "hono"; +import { verifyMagicToken } from "./token"; +import { renderPollPage, renderRsvpPage, renderError } from "./render"; +import { syncServer } from "../sync-instance"; +import { choicesDocId } from "../../modules/rchoices/schemas"; +import { calendarDocId } from "../../modules/rcal/schemas"; +import type { ChoicesDoc } from "../../modules/rchoices/schemas"; +import type { CalendarDoc } from "../../modules/rcal/schemas"; +import type { EventAttendee } from "../../modules/rcal/schemas"; + +export const magicLinkRoutes = new Hono(); + +// ── GET /respond/:token ── +magicLinkRoutes.get("/:token", async (c) => { + const token = c.req.param("token"); + + let verified; + try { + verified = await verifyMagicToken(token); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + const title = message === "Token expired" ? "Link Expired" : "Invalid Link"; + const body = message === "Token expired" + ? "This response link has expired. Request a new invitation." + : "This link is not valid. It may have been corrupted."; + return c.html(renderError(title, body), title === "Link Expired" ? 410 : 400); + } + + try { + if (verified.type === "poll") { + return c.html(renderPollForToken(verified, token)); + } else { + return c.html(renderRsvpForToken(verified, token)); + } + } catch (err) { + console.error("[MagicLink] Render error:", err); + return c.html(renderError("Error", "Could not load the response page."), 500); + } +}); + +// ── POST /respond/:token ── +magicLinkRoutes.post("/:token", async (c) => { + const token = c.req.param("token"); + + let verified; + try { + verified = await verifyMagicToken(token); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + const title = message === "Token expired" ? "Link Expired" : "Invalid Link"; + const body = message === "Token expired" + ? "This response link has expired. Request a new invitation." + : "This link is not valid. It may have been corrupted."; + return c.html(renderError(title, body), title === "Link Expired" ? 410 : 400); + } + + try { + const formData = await c.req.parseBody(); + + if (verified.type === "poll") { + const choice = formData.choice as string; + if (!choice) return c.html(renderError("Error", "No choice submitted."), 400); + + const docId = choicesDocId(verified.space); + const doc = syncServer.getDoc(docId); + if (!doc) return c.html(renderError("Not Found", "Poll data not found."), 404); + + const session = doc.sessions[verified.targetId]; + if (!session) return c.html(renderError("Not Found", "Poll session not found."), 404); + + // Validate choice is a valid option + if (!session.options.some((o) => o.id === choice)) { + return c.html(renderError("Error", "Invalid option selected."), 400); + } + + // Write vote keyed by sessionId:magic:tokenHash (dedup by token) + const voteKey = `${verified.targetId}:magic:${verified.tokenHash}`; + syncServer.changeDoc(docId, `magic link vote by ${verified.respondentName}`, (d) => { + d.votes[voteKey] = { + participantDid: `magic:${verified.tokenHash}`, + choices: { [choice]: 1 }, + updatedAt: Date.now(), + }; + }); + + return c.html(renderPollForToken(verified, token, choice)); + + } else { + // RSVP + const status = formData.status as string; + if (!status || !["yes", "no", "maybe"].includes(status)) { + return c.html(renderError("Error", "Invalid RSVP status."), 400); + } + + const docId = calendarDocId(verified.space); + const doc = syncServer.getDoc(docId); + if (!doc) return c.html(renderError("Not Found", "Calendar data not found."), 404); + + const event = doc.events[verified.targetId]; + if (!event) return c.html(renderError("Not Found", "Event not found."), 404); + + syncServer.changeDoc(docId, `magic link RSVP by ${verified.respondentName}`, (d) => { + const ev = d.events[verified.targetId]; + const attendees = (ev.attendees || []) as EventAttendee[]; + + // Find existing response by tokenHash (update, not duplicate) + const existingIdx = attendees.findIndex((a) => a.tokenHash === verified.tokenHash); + const attendee: EventAttendee = { + name: verified.respondentName, + status: status as "yes" | "no" | "maybe", + respondedAt: Date.now(), + source: "magic-link", + tokenHash: verified.tokenHash, + }; + + if (existingIdx >= 0) { + attendees[existingIdx] = attendee; + } else { + attendees.push(attendee); + } + + ev.attendees = attendees; + ev.attendeeCount = attendees.filter((a) => a.status === "yes").length; + }); + + return c.html(renderRsvpForToken(verified, token, status)); + } + } catch (err) { + console.error("[MagicLink] Submit error:", err); + return c.html(renderError("Error", "Could not submit your response. Please try again."), 500); + } +}); + +// ── Helpers ── + +function renderPollForToken( + verified: { space: string; targetId: string; respondentName: string; tokenHash: string }, + token: string, + justVoted?: string, +): string { + const docId = choicesDocId(verified.space); + const doc = syncServer.getDoc(docId); + if (!doc) return renderError("Not Found", "Poll data not found."); + + const session = doc.sessions[verified.targetId]; + if (!session) return renderError("Not Found", "Poll session not found."); + + // Check for existing vote by this token + const voteKey = `${verified.targetId}:magic:${verified.tokenHash}`; + const existingVote = doc.votes[voteKey]; + const existingChoice = existingVote + ? Object.entries(existingVote.choices).find(([, v]) => v === 1)?.[0] + : undefined; + + return renderPollPage(session, token, verified.respondentName, existingChoice, justVoted); +} + +function renderRsvpForToken( + verified: { space: string; targetId: string; respondentName: string; tokenHash: string }, + token: string, + justResponded?: string, +): string { + const docId = calendarDocId(verified.space); + const doc = syncServer.getDoc(docId); + if (!doc) return renderError("Not Found", "Calendar data not found."); + + const event = doc.events[verified.targetId]; + if (!event) return renderError("Not Found", "Event not found."); + + // Check for existing RSVP by this token + const attendees = (event.attendees || []) as EventAttendee[]; + const existing = attendees.find((a) => a.tokenHash === verified.tokenHash); + const currentStatus = existing?.status; + + return renderRsvpPage(event, token, verified.respondentName, currentStatus, justResponded); +} diff --git a/server/magic-link/send.ts b/server/magic-link/send.ts new file mode 100644 index 00000000..2865e094 --- /dev/null +++ b/server/magic-link/send.ts @@ -0,0 +1,90 @@ +/** Send magic link invitations via email. */ + +import { magicLinkConfig } from "./config"; +import { createMagicToken } from "./token"; + +export interface MagicLinkRecipient { + name: string; + email: string; +} + +export interface SendMagicLinkOpts { + space: string; + type: "poll" | "rsvp"; + targetId: string; + /** Title of the poll session or event (for email subject) */ + title: string; + participants: MagicLinkRecipient[]; + /** If false, only return URLs without sending emails */ + sendEmail?: boolean; +} + +export interface MagicLinkResult { + name: string; + email: string; + url: string; + sent: boolean; +} + +export async function sendMagicLinks(opts: SendMagicLinkOpts): Promise { + const results: MagicLinkResult[] = []; + + for (const participant of opts.participants) { + const token = await createMagicToken(opts.space, opts.type, opts.targetId, participant.name); + const url = `${magicLinkConfig.baseUrl}/respond/${token}`; + + let sent = false; + if (opts.sendEmail !== false && participant.email) { + try { + const nodemailer = await import("nodemailer"); + const transport = nodemailer.createTransport({ + host: process.env.SMTP_HOST || "mailcowdockerized-postfix-mailcow-1", + port: Number(process.env.SMTP_PORT) || 587, + secure: false, + auth: { + user: process.env.SMTP_USER || "noreply@rmail.online", + pass: process.env.SMTP_PASS, + }, + tls: { rejectUnauthorized: false }, + }); + + const typeLabel = opts.type === "poll" ? "Poll" : "RSVP"; + const subject = `${typeLabel}: ${opts.title}`; + const html = buildInviteEmail(opts, participant, url); + + await transport.sendMail({ + from: process.env.SMTP_FROM || `rSpace <${process.env.SMTP_USER || "noreply@rmail.online"}>`, + to: participant.email, + subject, + html, + }); + sent = true; + } catch (err) { + console.error(`[MagicLink] Email send failed for ${participant.email}:`, err); + } + } + + results.push({ name: participant.name, email: participant.email, url, sent }); + } + + return results; +} + +function esc(str: string): string { + return str.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +function buildInviteEmail(opts: SendMagicLinkOpts, participant: MagicLinkRecipient, url: string): string { + const typeLabel = opts.type === "poll" ? "Poll" : "Event RSVP"; + const actionLabel = opts.type === "poll" ? "Vote Now" : "RSVP Now"; + + return ` + +
+

${esc(opts.title)}

+

Hi ${esc(participant.name)}, you've been invited to respond to this ${typeLabel.toLowerCase()}.

+${actionLabel} +

This link expires in ${magicLinkConfig.tokenExpiryDays} days. No account required.

+

Sent by rSpace · rspace.online

+
`; +} diff --git a/server/magic-link/token.ts b/server/magic-link/token.ts new file mode 100644 index 00000000..30a6c9f8 --- /dev/null +++ b/server/magic-link/token.ts @@ -0,0 +1,110 @@ +/** HMAC-SHA256 signed tokens for magic link responses. */ + +import { magicLinkConfig } from "./config"; + +export interface MagicLinkPayload { + /** space slug */ + s: string; + /** target type */ + t: "poll" | "rsvp"; + /** target id (sessionId or eventId) */ + i: string; + /** respondent name/label */ + n: string; + /** expiry (unix seconds) */ + e: number; +} + +interface TokenData extends MagicLinkPayload { + /** hex signature */ + h: string; +} + +async function getKey(): Promise { + return crypto.subtle.importKey( + "raw", + new TextEncoder().encode(magicLinkConfig.hmacSecret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign", "verify"], + ); +} + +function toBase64Url(data: string): string { + return btoa(data).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function fromBase64Url(b64: string): string { + return atob(b64.replace(/-/g, "+").replace(/_/g, "/")); +} + +function toHex(buf: ArrayBuffer): string { + return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +async function sign(payload: string): Promise { + const key = await getKey(); + const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload)); + return toHex(sig); +} + +export async function createMagicToken( + space: string, + type: "poll" | "rsvp", + targetId: string, + respondentName: string, +): Promise { + const expiry = Math.floor(Date.now() / 1000) + magicLinkConfig.tokenExpiryDays * 86400; + const payload: MagicLinkPayload = { s: space, t: type, i: targetId, n: respondentName, e: expiry }; + const payloadStr = `${payload.s}:${payload.t}:${payload.i}:${payload.n}:${payload.e}`; + const sig = await sign(payloadStr); + const token: TokenData = { ...payload, h: sig }; + return toBase64Url(JSON.stringify(token)); +} + +export interface VerifiedMagicToken { + space: string; + type: "poll" | "rsvp"; + targetId: string; + respondentName: string; + /** Short hash for dedup keying */ + tokenHash: string; +} + +export async function verifyMagicToken(token: string): Promise { + let data: TokenData; + try { + data = JSON.parse(fromBase64Url(token)); + } catch { + throw new Error("Invalid token format"); + } + + if (!data.s || !data.t || !data.i || !data.n || !data.e || !data.h) { + throw new Error("Malformed token"); + } + + if (data.t !== "poll" && data.t !== "rsvp") { + throw new Error("Invalid target type"); + } + + if (data.e < Math.floor(Date.now() / 1000)) { + throw new Error("Token expired"); + } + + const payloadStr = `${data.s}:${data.t}:${data.i}:${data.n}:${data.e}`; + const expected = await sign(payloadStr); + if (data.h !== expected) { + throw new Error("Invalid signature"); + } + + // Short hash for dedup (first 12 chars of signature) + const tokenHash = data.h.slice(0, 12); + + return { + space: data.s, + type: data.t, + targetId: data.i, + respondentName: data.n, + tokenHash, + }; +} diff --git a/server/shell.ts b/server/shell.ts index 6b2e6e17..c74d30bc 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -52,11 +52,12 @@ const FAVICON_BADGE_MAP: Record = { rstack: { badge: "r✨", color: "#c4b5fd" }, }; -const FAVICON_BADGE_JSON = JSON.stringify(FAVICON_BADGE_MAP); - /** Generate an inline script that sets the favicon to the module's badge SVG */ function faviconScript(moduleId: string): string { - return ``; + const b = FAVICON_BADGE_MAP[moduleId]; + if (!b) return ''; + const json = JSON.stringify(b); + return ``; } // ── Content-hash cache busting ── @@ -283,7 +284,7 @@ export function renderShell(opts: ShellOptions): string { ${styles} ${head} - + ${spaceSlug === 'demo' ? `` : ''} @@ -1066,12 +1067,10 @@ export function renderShell(opts: ShellOptions): string { // Reconcile remote layer changes (shared by BroadcastChannel + Automerge) function reconcileRemoteLayers(remoteLayers) { - console.log('[shell] reconcileRemoteLayers: remote=' + remoteLayers.length + ', local=' + layers.length + ', current=' + currentModuleId); // Guard: never let remote sync wipe all tabs when we have an active module. // Empty remote layers indicate a CRDT initial state or sync race, not // an intentional "close everything" action. if (remoteLayers.length === 0 && currentModuleId) { - console.log('[shell] BLOCKED empty remote layers (keeping local)'); // Keep local layers intact — remote has nothing useful tabBar.setLayers(layers); return; @@ -1160,24 +1159,17 @@ export function renderShell(opts: ShellOptions): string { // This prevents the visual desync where the tab highlights before content loads. tabBar.addEventListener('layer-switch', (e) => { const { layerId, moduleId } = e.detail; - console.log('[shell] layer-switch:', moduleId, 'layerId:', layerId, 'tabCache:', !!tabCache, 'layers:', layers.length); currentModuleId = moduleId; saveTabs(); if (tabCache) { const switchId = moduleId; // capture for staleness check tabCache.switchTo(moduleId).then(ok => { // If user already clicked a different tab, don't navigate for this one - if (currentModuleId !== switchId) { - console.log('[shell] switchTo result:', ok, 'for', switchId, '(stale, user switched to', currentModuleId + ')'); - return; - } - console.log('[shell] switchTo result:', ok, 'for', moduleId); + if (currentModuleId !== switchId) return; if (ok) { tabBar.setAttribute('active', layerId); } else { - const url = window.__rspaceNavUrl(spaceSlug, moduleId); - console.log('[shell] switchTo failed, navigating to:', url); - window.location.href = url; + window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId); } }).catch((err) => { if (currentModuleId !== switchId) return; // stale @@ -1185,7 +1177,6 @@ export function renderShell(opts: ShellOptions): string { window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId); }); } else { - console.log('[shell] no tabCache, navigating to:', window.__rspaceNavUrl(spaceSlug, moduleId)); window.location.href = window.__rspaceNavUrl(spaceSlug, moduleId); } }); diff --git a/shared/components/rstack-collab-overlay.ts b/shared/components/rstack-collab-overlay.ts index 6c4bff44..3826a696 100644 --- a/shared/components/rstack-collab-overlay.ts +++ b/shared/components/rstack-collab-overlay.ts @@ -88,8 +88,7 @@ export class RStackCollabOverlay extends HTMLElement { this.#render(); this.#renderBadge(); - // GC stale peers every 5s (all modes — prevents lingering ghost peers) - this.#gcInterval = setInterval(() => this.#gcPeers(), 5000); + // GC timer starts lazily when first peer arrives (see #ensureGcTimer) if (!this.#externalPeers) { // Explicit doc-id attribute (fallback) @@ -462,6 +461,13 @@ export class RStackCollabOverlay extends HTMLElement { // ── GC stale peers ── + /** Start GC timer lazily — only when peers exist, stop when they're all gone. */ + #ensureGcTimer() { + if (!this.#gcInterval && this.#peers.size > 0) { + this.#gcInterval = setInterval(() => this.#gcPeers(), 5000); + } + } + #gcPeers() { const now = Date.now(); const staleThreshold = this.#externalPeers ? 30000 : 15000; @@ -480,6 +486,11 @@ export class RStackCollabOverlay extends HTMLElement { } if (this.#panelOpen) this.#renderPanel(); } + // Stop timer when no peers remain + if (this.#peers.size === 0 && this.#gcInterval) { + clearInterval(this.#gcInterval); + this.#gcInterval = null; + } } /** Deduplicate peers by username, keeping the most recently seen entry per user. */ diff --git a/shared/components/rstack-comment-bell.ts b/shared/components/rstack-comment-bell.ts index a85c23d7..61f6170e 100644 --- a/shared/components/rstack-comment-bell.ts +++ b/shared/components/rstack-comment-bell.ts @@ -11,7 +11,7 @@ * Polls every 5s as fallback (sync may appear after component mounts). */ -const POLL_INTERVAL = 5_000; +const POLL_INTERVAL = 30_000; interface CommentMessage { text: string; diff --git a/shared/components/rstack-user-dashboard.ts b/shared/components/rstack-user-dashboard.ts index 429e14db..0e6caadf 100644 --- a/shared/components/rstack-user-dashboard.ts +++ b/shared/components/rstack-user-dashboard.ts @@ -59,7 +59,11 @@ export class RStackUserDashboard extends HTMLElement { connectedCallback() { this.#render(); - this.#loadData(); + // Defer data loading until the dashboard is actually visible. + // It starts display:none and only shows when all tabs are closed. + if (this.offsetParent !== null) { + this.#loadData(); + } } attributeChangedCallback() { diff --git a/website/shell.ts b/website/shell.ts index 27875615..653d6e89 100644 --- a/website/shell.ts +++ b/website/shell.ts @@ -46,12 +46,19 @@ RStackUserDashboard.define(); // ── Offline Runtime (lazy-loaded) ── // Automerge + WASM (~2.5MB) loaded in a separate chunk to avoid blocking first paint. -// Components that depend on window.__rspaceOfflineRuntime already handle late init. +// Deferred until the browser is idle so it doesn't compete with LCP resources. const spaceSlug = document.body?.getAttribute("data-space-slug"); if (spaceSlug) { - import('./shell-offline').then(m => m.initOffline(spaceSlug)).catch(e => { - console.warn("[shell] Failed to load offline chunk:", e); - }); + const loadOffline = () => { + import('./shell-offline').then(m => m.initOffline(spaceSlug)).catch(e => { + console.warn("[shell] Failed to load offline chunk:", e); + }); + }; + if ('requestIdleCallback' in window) { + (window as any).requestIdleCallback(loadOffline, { timeout: 3000 }); + } else { + setTimeout(loadOffline, 1500); + } } // ── Track space visits for dashboard recency sorting ──