Merge branch 'dev'
CI/CD / deploy (push) Failing after 3m14s Details

This commit is contained in:
Jeff Emmett 2026-04-09 13:41:32 -04:00
commit 6cc87ead3f
14 changed files with 686 additions and 27 deletions

View File

@ -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) => {

View File

@ -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;

View File

@ -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<ChoicesDoc>(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";

View File

@ -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<WSData>({
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<WSData>({
// 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);
}

View File

@ -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;

144
server/magic-link/render.ts Normal file
View File

@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
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 = `<div class="banner banner-ok">Your vote for <strong>${esc(opt?.label || justVoted)}</strong> has been recorded.</div>`;
} else if (existingChoice) {
const opt = session.options.find((o) => o.id === existingChoice);
banner = `<div class="banner banner-info">You previously voted for <strong>${esc(opt?.label || existingChoice)}</strong>. Tap another option to change your vote.</div>`;
}
const optionButtons = session.options
.map((opt) => {
const selected = (existingChoice === opt.id || justVoted === opt.id) ? " sel" : "";
return `<form method="POST" action="${magicLinkConfig.baseUrl}/respond/${esc(token)}" style="margin:0">
<input type="hidden" name="choice" value="${esc(opt.id)}">
<button type="submit" class="opt${selected}" style="border-left:4px solid ${esc(opt.color || '#6366f1')}">${esc(opt.label)}</button>
</form>`;
})
.join("\n");
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(session.title)} - rChoices</title><style>${styles}</style></head>
<body><div class="card">
${banner}
<h1>${esc(session.title)}</h1>
<div class="sub">Hi ${esc(respondentName)} &mdash; tap your choice below</div>
<div class="label">${session.options.length} option${session.options.length === 1 ? "" : "s"} &middot; ${session.type} vote</div>
${optionButtons}
<div class="ft">rChoices &middot; rspace.online &middot; Link expires in ${magicLinkConfig.tokenExpiryDays} days</div>
</div></body></html>`;
}
// ── RSVP page ──
const rsvpLabels: Record<string, string> = { yes: "Going", no: "Not Going", maybe: "Maybe" };
const rsvpEmoji: Record<string, string> = { yes: "&#10003;", no: "&#10007;", maybe: "&#63;" };
export function renderRsvpPage(
event: CalendarEvent,
token: string,
respondentName: string,
currentStatus?: string,
justResponded?: string,
): string {
let banner = "";
if (justResponded) {
banner = `<div class="banner banner-ok">You responded <strong>${esc(rsvpLabels[justResponded] || justResponded)}</strong>.</div>`;
} else if (currentStatus && currentStatus !== "pending") {
banner = `<div class="banner banner-info">You previously responded <strong>${esc(rsvpLabels[currentStatus] || currentStatus)}</strong>. Tap to change.</div>`;
}
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
? `<p class="meta">All day &middot; ${startDate}</p>`
: `<p class="meta">${startDate}${startTime ? ` &middot; ${startTime}` : ""}${endTime ? ` &ndash; ${endTime}` : ""}</p>`;
const location = event.locationName
? `<p class="meta">${esc(event.locationName)}</p>`
: event.isVirtual && event.virtualUrl
? `<p class="meta">Virtual &middot; <a href="${esc(event.virtualUrl)}">${esc(event.virtualPlatform || "Join")}</a></p>`
: "";
const buttons = ["yes", "no", "maybe"]
.map((status) => {
const selected = (currentStatus === status || justResponded === status) ? " sel" : "";
return `<form method="POST" action="${magicLinkConfig.baseUrl}/respond/${esc(token)}" style="margin:0;flex:1">
<input type="hidden" name="status" value="${status}">
<button type="submit" class="rsvp-btn${selected}">${rsvpEmoji[status]} ${rsvpLabels[status]}</button>
</form>`;
})
.join("\n");
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>RSVP: ${esc(event.title)} - rCal</title><style>${styles}</style></head>
<body><div class="card">
${banner}
<h1>${esc(event.title)}</h1>
${timeLine}
${location}
${event.description ? `<p style="font-size:14px;color:#475569;margin-top:12px">${esc(event.description)}</p>` : ""}
<div class="sub" style="margin-top:16px">Hi ${esc(respondentName)} &mdash; will you attend?</div>
<div class="rsvp-row">
${buttons}
</div>
<div class="ft">rCal &middot; rspace.online &middot; Link expires in ${magicLinkConfig.tokenExpiryDays} days</div>
</div></body></html>`;
}
// ── Error page ──
export function renderError(title: string, message: string): string {
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Error - rSpace</title><style>${styles} .err{color:#ef4444}</style></head>
<body><div class="card" style="text-align:center"><h1 class="err">${esc(title)}</h1>
<p style="color:#64748b">${esc(message)}</p>
<div class="ft">rSpace &middot; rspace.online</div></div></body></html>`;
}

182
server/magic-link/routes.ts Normal file
View File

@ -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<ChoicesDoc>(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<ChoicesDoc>(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<CalendarDoc>(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<CalendarDoc>(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<ChoicesDoc>(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<CalendarDoc>(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);
}

90
server/magic-link/send.ts Normal file
View File

@ -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<MagicLinkResult[]> {
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
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 `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f8fafc;color:#1e293b;margin:0;padding:20px">
<div style="max-width:500px;margin:40px auto;background:#fff;border-radius:12px;box-shadow:0 1px 3px rgba(0,0,0,.1);padding:32px">
<h1 style="font-size:20px;margin:0 0 4px;color:#0f172a">${esc(opts.title)}</h1>
<p style="font-size:14px;color:#64748b;margin:8px 0 24px">Hi ${esc(participant.name)}, you've been invited to respond to this ${typeLabel.toLowerCase()}.</p>
<a href="${esc(url)}" style="display:inline-block;padding:14px 28px;background:#6366f1;color:#fff;border-radius:10px;text-decoration:none;font-size:16px;font-weight:600">${actionLabel}</a>
<p style="font-size:12px;color:#94a3b8;margin-top:24px">This link expires in ${magicLinkConfig.tokenExpiryDays} days. No account required.</p>
<p style="font-size:11px;color:#cbd5e1;margin-top:16px">Sent by rSpace &middot; rspace.online</p>
</div></body></html>`;
}

110
server/magic-link/token.ts Normal file
View File

@ -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<CryptoKey> {
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<string> {
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<string> {
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<VerifiedMagicToken> {
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,
};
}

View File

@ -52,11 +52,12 @@ const FAVICON_BADGE_MAP: Record<string, { badge: string; color: string }> = {
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 `<script data-mod="${moduleId}">(function(){var m=${FAVICON_BADGE_JSON};var id=document.currentScript.dataset.mod;if(!id||!m[id])return;var b=m[id];var s='<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" rx="14" fill="'+b.color+'"/><text x="32" y="44" text-anchor="middle" font-size="26" font-family="sans-serif">'+b.badge+'</text></svg>';var l=document.querySelector('link[rel="icon"]');if(l)l.href="data:image/svg+xml,"+encodeURIComponent(s)})()</script>`;
const b = FAVICON_BADGE_MAP[moduleId];
if (!b) return '';
const json = JSON.stringify(b);
return `<script>(function(){var b=${json};var s='<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" rx="14" fill="'+b.color+'"/><text x="32" y="44" text-anchor="middle" font-size="26" font-family="sans-serif">'+b.badge+'</text></svg>';var l=document.querySelector('link[rel="icon"]');if(l)l.href="data:image/svg+xml,"+encodeURIComponent(s)})()</script>`;
}
// ── Content-hash cache busting ──
@ -283,7 +284,7 @@ export function renderShell(opts: ShellOptions): string {
<script>if (window.self !== window.parent) document.documentElement.classList.add('rspace-embedded');</script>
${styles}
${head}
<style>${WELCOME_CSS}</style>
${spaceSlug === 'demo' ? `<style>${WELCOME_CSS}</style>` : ''}
<style>${ACCESS_GATE_CSS}</style>
<style>${INFO_PANEL_CSS}</style>
<style>${SUBNAV_CSS}</style>
@ -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);
}
});

View File

@ -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. */

View File

@ -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;

View File

@ -59,8 +59,12 @@ export class RStackUserDashboard extends HTMLElement {
connectedCallback() {
this.#render();
// 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() {
// Space changed — reset cache

View File

@ -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) {
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 ──