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>`;
|
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 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>`;
|
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");
|
.join("\n");
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue