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:
parent
be92e7839b
commit
d0fbbd2ee5
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
@ -44,7 +44,7 @@ function checklist(criteria: AcceptanceCriterion[], tokens: Map<number, string>,
|
|||
return `<div class="row${hi}"><span class="cb ck">✓</span><span class="txt done">#${ac.index} ${esc(ac.text)}</span></div>`;
|
||||
}
|
||||
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"> </a><span class="txt">#${ac.index} ${esc(ac.text)}</span></div>`;
|
||||
})
|
||||
.join("\n");
|
||||
|
|
|
|||
|
|
@ -19,11 +19,7 @@ import type { SyncServer } from '../../server/local-first/sync-server';
|
|||
import { boardSchema, boardDocId, createTaskItem } from './schemas';
|
||||
import type { BoardDoc, TaskItem, BoardMeta } from './schemas';
|
||||
|
||||
// Email checklist feature — HMAC-signed links toggle backlog AC items
|
||||
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";
|
||||
// Email checklist routes exported separately — see checklist-routes.ts
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
|
|
@ -458,108 +454,6 @@ routes.get("/api/spaces/:slug/activity", async (c) => {
|
|||
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 ──
|
||||
routes.get("/", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ import { voteModule } from "../modules/rvote/mod";
|
|||
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 { tripsModule } from "../modules/rtrips/mod";
|
||||
import { calModule } from "../modules/rcal/mod";
|
||||
import { networkModule } from "../modules/rnetwork/mod";
|
||||
|
|
@ -402,6 +403,9 @@ app.route("/api/notifications", notificationRouter);
|
|||
// ── MI — AI assistant endpoints ──
|
||||
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) ──
|
||||
const ENCRYPTID_INTERNAL = process.env.ENCRYPTID_INTERNAL_URL || "http://encryptid:3000";
|
||||
|
|
|
|||
Loading…
Reference in New Issue