fix(rtasks): mount checklist routes at top level to bypass space auth

The checklist check/send endpoints don't need space context — the HMAC
token and API key provide their own auth. Routes are now:
  GET  /rtasks/check/:token
  POST /api/rtasks/send

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-16 12:09:37 -07:00
parent be92e7839b
commit d0fbbd2ee5
4 changed files with 119 additions and 108 deletions

View File

@ -0,0 +1,113 @@
/**
* Email checklist routes mounted at top level to bypass space auth middleware.
* GET /rtasks/check/:token verify HMAC, toggle AC, render page
* POST /api/rtasks/send build + send checklist email
*/
import { Hono } from "hono";
import { checklistConfig } from "./lib/checklist-config";
import { createToken, verifyToken } from "./lib/token";
import { readTask, checkAC } from "./lib/backlog";
import { renderEmailHTML, renderWebPage, renderError } from "./lib/render";
export const checklistCheckRoutes = new Hono();
export const checklistApiRoutes = new Hono();
// ── GET /rtasks/check/:token ──
checklistCheckRoutes.get("/:token", async (c) => {
const token = c.req.param("token");
let verified;
try {
verified = await verifyToken(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 checklist link has expired. Request a new checklist email."
: "This link is not valid. It may have been corrupted.";
return c.html(renderError(title, body), title === "Link Expired" ? 410 : 400);
}
try {
await checkAC(verified.repo, verified.taskId, verified.acIndex);
const task = await readTask(verified.repo, verified.taskId);
const tokens = new Map<number, string>();
for (const ac of task.criteria) {
if (!ac.checked) {
tokens.set(ac.index, await createToken(verified.repo, verified.taskId, ac.index));
}
}
return c.html(renderWebPage(task.title, verified.taskId, task.criteria, tokens, verified.acIndex));
} catch (err) {
console.error("[Tasks/checklist] Check error:", err);
return c.html(renderError("Error", "Could not update task. Please try again."), 500);
}
});
// ── POST /api/rtasks/send ──
checklistApiRoutes.post("/send", async (c) => {
const authHeader = c.req.header("Authorization");
if (!authHeader || authHeader !== `Bearer ${checklistConfig.apiKey}`) {
return c.json({ error: "Unauthorized" }, 401);
}
const body = await c.req.json<{
repo: string;
taskId: string;
to: string;
subject?: string;
}>();
if (!body.repo || !body.taskId || !body.to) {
return c.json({ error: "Missing required fields: repo, taskId, to" }, 400);
}
try {
const task = await readTask(body.repo, body.taskId);
const tokens = new Map<number, string>();
for (const ac of task.criteria) {
if (!ac.checked) {
tokens.set(ac.index, await createToken(body.repo, body.taskId, ac.index));
}
}
if (tokens.size === 0) {
return c.json({ error: "All acceptance criteria already checked" }, 400);
}
const subject = body.subject || `Checklist: ${task.title}`;
const html = renderEmailHTML(task.title, body.taskId, task.criteria, tokens);
const nodemailer = await import("nodemailer");
const transport = nodemailer.createTransport({
host: process.env.SMTP_HOST || "mail.rmail.online",
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 result = await transport.sendMail({
from: process.env.SMTP_FROM || `rTasks <${process.env.SMTP_USER || "noreply@rmail.online"}>`,
to: body.to,
subject,
html,
});
const firstToken = tokens.values().next().value;
const url = `${checklistConfig.baseUrl}/rtasks/check/${firstToken}`;
return c.json({ ok: true, messageId: result.messageId, url, unchecked: tokens.size });
} catch (err) {
console.error("[Tasks/checklist] Send error:", err);
const message = err instanceof Error ? err.message : "Unknown error";
return c.json({ error: message }, 500);
}
});

View File

@ -44,7 +44,7 @@ function checklist(criteria: AcceptanceCriterion[], tokens: Map<number, string>,
return `<div class="row${hi}"><span class="cb ck">&#10003;</span><span class="txt done">#${ac.index} ${esc(ac.text)}</span></div>`; return `<div class="row${hi}"><span class="cb ck">&#10003;</span><span class="txt done">#${ac.index} ${esc(ac.text)}</span></div>`;
} }
const token = tokens.get(ac.index); const token = tokens.get(ac.index);
const url = token ? `${checklistConfig.baseUrl}/demo/rtasks/checklist/${token}` : "#"; const url = token ? `${checklistConfig.baseUrl}/rtasks/check/${token}` : "#";
return `<div class="row${hi}"><a href="${url}" class="cb uc" title="Click to check off">&nbsp;</a><span class="txt">#${ac.index} ${esc(ac.text)}</span></div>`; return `<div class="row${hi}"><a href="${url}" class="cb uc" title="Click to check off">&nbsp;</a><span class="txt">#${ac.index} ${esc(ac.text)}</span></div>`;
}) })
.join("\n"); .join("\n");

View File

@ -19,11 +19,7 @@ import type { SyncServer } from '../../server/local-first/sync-server';
import { boardSchema, boardDocId, createTaskItem } from './schemas'; import { boardSchema, boardDocId, createTaskItem } from './schemas';
import type { BoardDoc, TaskItem, BoardMeta } from './schemas'; import type { BoardDoc, TaskItem, BoardMeta } from './schemas';
// Email checklist feature — HMAC-signed links toggle backlog AC items // Email checklist routes exported separately — see checklist-routes.ts
import { checklistConfig } from "./lib/checklist-config";
import { createToken, verifyToken } from "./lib/token";
import { readTask, checkAC } from "./lib/backlog";
import { renderEmailHTML, renderWebPage, renderError } from "./lib/render";
const routes = new Hono(); const routes = new Hono();
@ -458,108 +454,6 @@ routes.get("/api/spaces/:slug/activity", async (c) => {
return c.json([]); return c.json([]);
}); });
// ── Email Checklist: GET /checklist/:token ──
// Clicked from email — the signed token IS the auth.
routes.get("/checklist/:token", async (c) => {
const token = c.req.param("token");
let verified;
try {
verified = await verifyToken(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 checklist link has expired. Request a new checklist email."
: "This link is not valid. It may have been corrupted.";
return c.html(renderError(title, body), title === "Link Expired" ? 410 : 400);
}
try {
await checkAC(verified.repo, verified.taskId, verified.acIndex);
const task = await readTask(verified.repo, verified.taskId);
const tokens = new Map<number, string>();
for (const ac of task.criteria) {
if (!ac.checked) {
tokens.set(ac.index, await createToken(verified.repo, verified.taskId, ac.index));
}
}
return c.html(renderWebPage(task.title, verified.taskId, task.criteria, tokens, verified.acIndex));
} catch (err) {
console.error("[Tasks/checklist] Check error:", err);
return c.html(renderError("Error", "Could not update task. Please try again."), 500);
}
});
// ── Email Checklist: POST /checklist/send ──
// Auth: Bearer token matching RTASKS_API_KEY env var.
routes.post("/checklist/send", async (c) => {
const authHeader = c.req.header("Authorization");
if (!authHeader || authHeader !== `Bearer ${checklistConfig.apiKey}`) {
return c.json({ error: "Unauthorized" }, 401);
}
const body = await c.req.json<{
repo: string;
taskId: string;
to: string;
subject?: string;
}>();
if (!body.repo || !body.taskId || !body.to) {
return c.json({ error: "Missing required fields: repo, taskId, to" }, 400);
}
try {
const task = await readTask(body.repo, body.taskId);
const tokens = new Map<number, string>();
for (const ac of task.criteria) {
if (!ac.checked) {
tokens.set(ac.index, await createToken(body.repo, body.taskId, ac.index));
}
}
if (tokens.size === 0) {
return c.json({ error: "All acceptance criteria already checked" }, 400);
}
const subject = body.subject || `Checklist: ${task.title}`;
const html = renderEmailHTML(task.title, body.taskId, task.criteria, tokens);
// Use rspace's existing SMTP transport (lazy import to avoid circular deps)
const nodemailer = await import("nodemailer");
const transport = nodemailer.createTransport({
host: process.env.SMTP_HOST || "mail.rmail.online",
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 result = await transport.sendMail({
from: process.env.SMTP_FROM || `rTasks <${process.env.SMTP_USER || "noreply@rmail.online"}>`,
to: body.to,
subject,
html,
});
const firstToken = tokens.values().next().value;
const url = `${checklistConfig.baseUrl}/demo/rtasks/checklist/${firstToken}`;
return c.json({ ok: true, messageId: result.messageId, url, unchecked: tokens.size });
} catch (err) {
console.error("[Tasks/checklist] Send error:", err);
const message = err instanceof Error ? err.message : "Unknown error";
return c.json({ error: message }, 500);
}
});
// ── Page route ── // ── Page route ──
routes.get("/", (c) => { routes.get("/", (c) => {
const space = c.req.param("space") || "demo"; const space = c.req.param("space") || "demo";

View File

@ -57,6 +57,7 @@ import { voteModule } from "../modules/rvote/mod";
import { notesModule } from "../modules/rnotes/mod"; import { notesModule } from "../modules/rnotes/mod";
import { mapsModule } from "../modules/rmaps/mod"; import { mapsModule } from "../modules/rmaps/mod";
import { tasksModule } from "../modules/rtasks/mod"; import { tasksModule } from "../modules/rtasks/mod";
import { checklistCheckRoutes, checklistApiRoutes } from "../modules/rtasks/checklist-routes";
import { tripsModule } from "../modules/rtrips/mod"; import { tripsModule } from "../modules/rtrips/mod";
import { calModule } from "../modules/rcal/mod"; import { calModule } from "../modules/rcal/mod";
import { networkModule } from "../modules/rnetwork/mod"; import { networkModule } from "../modules/rnetwork/mod";
@ -402,6 +403,9 @@ app.route("/api/notifications", notificationRouter);
// ── MI — AI assistant endpoints ── // ── MI — AI assistant endpoints ──
app.route("/api/mi", miRoutes); app.route("/api/mi", miRoutes);
// ── Email Checklist (top-level, bypasses space auth) ──
app.route("/rtasks/check", checklistCheckRoutes);
app.route("/api/rtasks", checklistApiRoutes);
// ── EncryptID proxy (forward /encryptid/* to encryptid container) ── // ── EncryptID proxy (forward /encryptid/* to encryptid container) ──
const ENCRYPTID_INTERNAL = process.env.ENCRYPTID_INTERNAL_URL || "http://encryptid:3000"; const ENCRYPTID_INTERNAL = process.env.ENCRYPTID_INTERNAL_URL || "http://encryptid:3000";