feat(FN-3739): add fusion-plugin-even-cards workspace plugin
- Add a new fusion-plugin-even-cards package with typed card formatting utilities and board/task card generation - Implement authenticated plugin routes for board card endpoints and plugin registration wiring - Add unit tests for auth handling, board routes, and card formatting behavior - Document plugin in PLUGIN_AUTHORING guide and register it in pnpm workspace Fusion-Task-Id: FN-3739
This commit is contained in:
26
plugins/fusion-plugin-even-cards/src/__tests__/auth.test.ts
Normal file
26
plugins/fusion-plugin-even-cards/src/__tests__/auth.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { requireApiKey } from "../routes/auth.js";
|
||||
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||
|
||||
describe("requireApiKey", () => {
|
||||
it("returns 503 when api key setting is missing", () => {
|
||||
const result = requireApiKey({ settings: {}, logger } as any, { headers: {} });
|
||||
expect(result).toEqual({ ok: false, response: { status: 503, body: { error: "plugin not configured" } } });
|
||||
});
|
||||
|
||||
it("returns 401 when header missing", () => {
|
||||
const result = requireApiKey({ settings: { apiKey: "secret" }, logger } as any, { headers: {} });
|
||||
expect(result).toEqual({ ok: false, response: { status: 401, body: { error: "unauthorized" } } });
|
||||
});
|
||||
|
||||
it("returns 401 when key does not match", () => {
|
||||
const result = requireApiKey({ settings: { apiKey: "secret" }, logger } as any, { headers: { authorization: "Bearer nope" } });
|
||||
expect(result).toEqual({ ok: false, response: { status: 401, body: { error: "unauthorized" } } });
|
||||
});
|
||||
|
||||
it("returns ok on matching key", () => {
|
||||
const result = requireApiKey({ settings: { apiKey: "secret" }, logger } as any, { headers: { authorization: "Bearer secret" } });
|
||||
expect(result).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import plugin from "../index.js";
|
||||
import type { FusionTask } from "../cards/types.js";
|
||||
|
||||
function makeTask(id: string, column: FusionTask["column"], updatedAt: string): FusionTask {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
description: id,
|
||||
column,
|
||||
status: "pending",
|
||||
priority: "normal",
|
||||
createdAt: "2026-05-08T10:00:00.000Z",
|
||||
updatedAt,
|
||||
currentStep: 0,
|
||||
steps: [],
|
||||
dependencies: [],
|
||||
} as FusionTask;
|
||||
}
|
||||
|
||||
function createContext(tasks: FusionTask[], apiKey = "secret") {
|
||||
return {
|
||||
settings: { apiKey },
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
taskStore: {
|
||||
listTasks: vi.fn(async () => tasks),
|
||||
getTask: vi.fn(async (id: string) => tasks.find((task) => task.id === id)),
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
function route(path: string) {
|
||||
return plugin.routes!.find((entry) => entry.path === path)!;
|
||||
}
|
||||
|
||||
describe("even cards routes", () => {
|
||||
it("returns 401 when auth header missing", async () => {
|
||||
const response = (await route("/board/cards").handler({ headers: {} }, createContext([]))) as any;
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 503 when plugin is not configured", async () => {
|
||||
const response = (await route("/board/cards").handler({ headers: { authorization: "Bearer secret" } }, createContext([], ""))) as any;
|
||||
expect(response.status).toBe(503);
|
||||
});
|
||||
|
||||
it("returns deck on happy path", async () => {
|
||||
const tasks = [makeTask("FN-1", "todo", "2026-05-08T11:00:00.000Z"), makeTask("FN-2", "in-progress", "2026-05-08T12:00:00.000Z")];
|
||||
const response = (await route("/board/cards").handler({ headers: { authorization: "Bearer secret" } }, createContext(tasks))) as any;
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.deck.cards[0].id).toBe("summary");
|
||||
expect(response.body.deck.cards).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("filters columns in memory", async () => {
|
||||
const tasks = [makeTask("FN-1", "todo", "2026-05-08T11:00:00.000Z"), makeTask("FN-2", "triage", "2026-05-08T12:00:00.000Z")];
|
||||
const response = (await route("/board/cards").handler(
|
||||
{ headers: { authorization: "Bearer secret" }, query: { columns: "triage" } },
|
||||
createContext(tasks),
|
||||
)) as any;
|
||||
expect(response.body.deck.summary.counts.triage).toBe(1);
|
||||
expect(response.body.deck.summary.counts.todo).toBe(0);
|
||||
});
|
||||
|
||||
it("clamps max bounds", async () => {
|
||||
const tasks = Array.from({ length: 30 }, (_, idx) => makeTask(`FN-${idx + 1}`, "todo", `2026-05-08T12:${String(idx).padStart(2, "0")}:00.000Z`));
|
||||
const low = (await route("/board/cards").handler(
|
||||
{ headers: { authorization: "Bearer secret" }, query: { max: "0" } },
|
||||
createContext(tasks),
|
||||
)) as any;
|
||||
const high = (await route("/board/cards").handler(
|
||||
{ headers: { authorization: "Bearer secret" }, query: { max: "99" } },
|
||||
createContext(tasks),
|
||||
)) as any;
|
||||
|
||||
expect(low.body.deck.cards).toHaveLength(1);
|
||||
expect(high.body.deck.cards).toHaveLength(20);
|
||||
});
|
||||
|
||||
it("returns board summary", async () => {
|
||||
const tasks = [makeTask("FN-1", "todo", "2026-05-08T11:00:00.000Z")];
|
||||
const response = (await route("/board").handler({ headers: { authorization: "Bearer secret" } }, createContext(tasks))) as any;
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.summary.counts.todo).toBe(1);
|
||||
});
|
||||
|
||||
it("returns task deck for known id", async () => {
|
||||
const tasks = [makeTask("FN-1", "todo", "2026-05-08T11:00:00.000Z")];
|
||||
const response = (await route("/tasks/:id/cards").handler(
|
||||
{ headers: { authorization: "Bearer secret" }, params: { id: "FN-1" } },
|
||||
createContext(tasks),
|
||||
)) as any;
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.deck.cards[0].id).toBe("FN-1");
|
||||
});
|
||||
|
||||
it("returns 404 for unknown task id", async () => {
|
||||
const response = (await route("/tasks/:id/cards").handler(
|
||||
{ headers: { authorization: "Bearer secret" }, params: { id: "FN-404" } },
|
||||
createContext([]),
|
||||
)) as any;
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it("excludes archived tasks from deck cards", async () => {
|
||||
const tasks = [makeTask("FN-1", "archived", "2026-05-08T12:00:00.000Z"), makeTask("FN-2", "todo", "2026-05-08T11:00:00.000Z")];
|
||||
const response = (await route("/board/cards").handler({ headers: { authorization: "Bearer secret" } }, createContext(tasks))) as any;
|
||||
const ids = response.body.deck.cards.map((card: any) => card.id);
|
||||
expect(ids).toEqual(["summary", "FN-2"]);
|
||||
});
|
||||
});
|
||||
75
plugins/fusion-plugin-even-cards/src/__tests__/cards.test.ts
Normal file
75
plugins/fusion-plugin-even-cards/src/__tests__/cards.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { boardToDeck } from "../cards/board-cards.js";
|
||||
import { formatRelativeAge, formatTaskId, statusBadge, truncateLine, wrapLines } from "../cards/format.js";
|
||||
import { taskToCard } from "../cards/task-cards.js";
|
||||
import type { FusionTask } from "../cards/types.js";
|
||||
|
||||
function makeTask(id: string, column: FusionTask["column"], updatedAt: string): FusionTask {
|
||||
return {
|
||||
id,
|
||||
description: `Task ${id}`,
|
||||
title: `Title ${id}`,
|
||||
column,
|
||||
status: "pending",
|
||||
priority: "normal",
|
||||
currentStep: 0,
|
||||
steps: [],
|
||||
dependencies: [],
|
||||
createdAt: "2026-05-08T10:00:00.000Z",
|
||||
updatedAt,
|
||||
} as FusionTask;
|
||||
}
|
||||
|
||||
describe("cards format", () => {
|
||||
it("truncates at boundary", () => {
|
||||
expect(truncateLine("12345", 5)).toBe("12345");
|
||||
expect(truncateLine("123456", 5)).toBe("1234…");
|
||||
});
|
||||
|
||||
it("wraps across lines", () => {
|
||||
expect(wrapLines("one two three four", 7, 3)).toEqual(["one two", "three", "four"]);
|
||||
});
|
||||
|
||||
it("formats task id and relative age", () => {
|
||||
expect(formatTaskId("fn-42")).toBe("FN-42");
|
||||
expect(formatRelativeAge("2026-05-08T11:58:00.000Z", "2026-05-08T12:00:00.000Z")).toBe("2m");
|
||||
});
|
||||
|
||||
it("maps all task columns to badges", () => {
|
||||
expect(statusBadge("triage").tone).toBe("triage");
|
||||
expect(statusBadge("todo").tone).toBe("todo");
|
||||
expect(statusBadge("in-progress").tone).toBe("in-progress");
|
||||
expect(statusBadge("in-review").tone).toBe("in-review");
|
||||
expect(statusBadge("done").tone).toBe("done");
|
||||
expect(statusBadge("archived").tone).toBe("neutral");
|
||||
});
|
||||
});
|
||||
|
||||
describe("board deck", () => {
|
||||
it("summarizes counts and handles empty board", () => {
|
||||
const deck = boardToDeck([], { now: "2026-05-08T12:00:00.000Z" });
|
||||
expect(deck.cards).toHaveLength(1);
|
||||
expect(deck.summary.counts.todo).toBe(0);
|
||||
});
|
||||
|
||||
it("creates a task card", () => {
|
||||
const card = taskToCard(makeTask("FN-7", "in-review", "2026-05-08T12:00:00.000Z"), { now: "2026-05-08T12:10:00.000Z" });
|
||||
expect(card.id).toBe("FN-7");
|
||||
expect(card.badge.tone).toBe("in-review");
|
||||
expect(card.lines.join(" ")).toContain("Assignee unassigned");
|
||||
});
|
||||
|
||||
it("caps deck and sorts deterministically", () => {
|
||||
const tasks: FusionTask[] = [
|
||||
makeTask("FN-1", "todo", "2026-05-08T11:00:00.000Z"),
|
||||
makeTask("FN-9", "in-progress", "2026-05-08T11:00:00.000Z"),
|
||||
makeTask("FN-2", "done", "2026-05-08T12:00:00.000Z"),
|
||||
makeTask("FN-3", "archived", "2026-05-08T12:00:00.000Z"),
|
||||
];
|
||||
const deck = boardToDeck(tasks, { maxCards: 3, now: "2026-05-08T12:00:00.000Z" });
|
||||
|
||||
expect(deck.cards).toHaveLength(3);
|
||||
expect(deck.cards[1].id).toBe("FN-9");
|
||||
expect(deck.cards[2].id).toBe("FN-1");
|
||||
});
|
||||
});
|
||||
43
plugins/fusion-plugin-even-cards/src/cards/board-cards.ts
Normal file
43
plugins/fusion-plugin-even-cards/src/cards/board-cards.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { DEFAULT_MAX_CARDS_PER_DECK, DEFAULT_MAX_CHARS_PER_LINE, DEFAULT_MAX_LINES_PER_CARD, statusBadge, truncateLine, wrapLines } from "./format.js";
|
||||
import { taskToCard } from "./task-cards.js";
|
||||
import type { BoardSummary, CardDeck, FusionColumn, FusionTask, GlassesCard } from "./types.js";
|
||||
|
||||
const COLUMN_ORDER: FusionColumn[] = ["triage", "todo", "in-progress", "in-review", "done", "archived"];
|
||||
|
||||
function boardSummary(tasks: FusionTask[]): BoardSummary {
|
||||
const counts = Object.fromEntries(COLUMN_ORDER.map((column) => [column, 0]));
|
||||
let updatedAt: string | null = null;
|
||||
for (const task of tasks) {
|
||||
counts[task.column] = (counts[task.column] ?? 0) + 1;
|
||||
if (!updatedAt || task.updatedAt > updatedAt) updatedAt = task.updatedAt;
|
||||
}
|
||||
return { counts, updatedAt };
|
||||
}
|
||||
|
||||
function summaryCard(summary: BoardSummary, now: string, maxCharsPerLine: number, maxLines: number): GlassesCard {
|
||||
const summaryText = `Triage ${summary.counts.triage} Todo ${summary.counts.todo} Doing ${summary.counts["in-progress"]} Review ${summary.counts["in-review"]} Done ${summary.counts.done}`;
|
||||
return {
|
||||
id: "summary",
|
||||
kind: "summary",
|
||||
title: truncateLine("Fusion board", maxCharsPerLine),
|
||||
lines: wrapLines(summaryText, maxCharsPerLine, maxLines),
|
||||
badge: statusBadge("todo"),
|
||||
updatedAt: summary.updatedAt ?? now,
|
||||
};
|
||||
}
|
||||
|
||||
export function boardToDeck(tasks: FusionTask[], opts?: { maxCharsPerLine?: number; maxLines?: number; maxCards?: number; now?: string }): CardDeck {
|
||||
const maxCharsPerLine = opts?.maxCharsPerLine ?? DEFAULT_MAX_CHARS_PER_LINE;
|
||||
const maxLines = opts?.maxLines ?? DEFAULT_MAX_LINES_PER_CARD;
|
||||
const maxCards = opts?.maxCards ?? DEFAULT_MAX_CARDS_PER_DECK;
|
||||
const now = opts?.now ?? new Date().toISOString();
|
||||
|
||||
const summary = boardSummary(tasks);
|
||||
const active = tasks
|
||||
.filter((task) => task.column !== "archived" && task.column !== "done")
|
||||
.sort((a, b) => (b.updatedAt === a.updatedAt ? b.id.localeCompare(a.id) : b.updatedAt.localeCompare(a.updatedAt)))
|
||||
.slice(0, Math.max(0, maxCards - 1));
|
||||
|
||||
const cards: GlassesCard[] = [summaryCard(summary, now, maxCharsPerLine, maxLines), ...active.map((task) => taskToCard(task, { maxCharsPerLine, maxLines, now }))];
|
||||
return { cards, summary };
|
||||
}
|
||||
70
plugins/fusion-plugin-even-cards/src/cards/format.ts
Normal file
70
plugins/fusion-plugin-even-cards/src/cards/format.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { CardStatusBadge, FusionColumn } from "./types.js";
|
||||
|
||||
export const DEFAULT_MAX_CHARS_PER_LINE = 24;
|
||||
export const DEFAULT_MAX_LINES_PER_CARD = 4;
|
||||
export const DEFAULT_MAX_CARDS_PER_DECK = 8;
|
||||
|
||||
export function truncateLine(input: string, max: number): string {
|
||||
const value = input.trim();
|
||||
if (value.length <= max) return value;
|
||||
if (max <= 1) return "…";
|
||||
return `${value.slice(0, max - 1)}…`;
|
||||
}
|
||||
|
||||
export function wrapLines(text: string, max: number, maxLines: number): string[] {
|
||||
const words = text.trim().split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) return [];
|
||||
const lines: string[] = [];
|
||||
let current = "";
|
||||
|
||||
for (const word of words) {
|
||||
const candidate = current ? `${current} ${word}` : word;
|
||||
if (candidate.length <= max) {
|
||||
current = candidate;
|
||||
continue;
|
||||
}
|
||||
if (current) {
|
||||
lines.push(current);
|
||||
if (lines.length === maxLines) return lines.map((line, idx) => (idx === maxLines - 1 ? truncateLine(line, max) : line));
|
||||
}
|
||||
current = word.length <= max ? word : truncateLine(word, max);
|
||||
}
|
||||
|
||||
if (current && lines.length < maxLines) {
|
||||
lines.push(current);
|
||||
}
|
||||
|
||||
return lines.slice(0, maxLines).map((line, idx, arr) => (idx === arr.length - 1 ? truncateLine(line, max) : line));
|
||||
}
|
||||
|
||||
export function statusBadge(column: FusionColumn): CardStatusBadge {
|
||||
switch (column) {
|
||||
case "triage":
|
||||
case "todo":
|
||||
case "in-progress":
|
||||
case "in-review":
|
||||
case "done":
|
||||
return { label: column, tone: column };
|
||||
case "archived":
|
||||
default:
|
||||
return { label: column, tone: "neutral" };
|
||||
}
|
||||
}
|
||||
|
||||
export function formatTaskId(id: string): string {
|
||||
return id.trim().toUpperCase();
|
||||
}
|
||||
|
||||
export function formatRelativeAge(createdAtIso: string, nowIso: string): string {
|
||||
const created = Date.parse(createdAtIso);
|
||||
const now = Date.parse(nowIso);
|
||||
if (!Number.isFinite(created) || !Number.isFinite(now) || now <= created) return "0m";
|
||||
|
||||
const diffMs = now - created;
|
||||
const minutes = Math.floor(diffMs / 60000);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d`;
|
||||
}
|
||||
20
plugins/fusion-plugin-even-cards/src/cards/task-cards.ts
Normal file
20
plugins/fusion-plugin-even-cards/src/cards/task-cards.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { DEFAULT_MAX_CHARS_PER_LINE, DEFAULT_MAX_LINES_PER_CARD, formatRelativeAge, formatTaskId, statusBadge, truncateLine, wrapLines } from "./format.js";
|
||||
import type { FusionTask, GlassesCard } from "./types.js";
|
||||
|
||||
export function taskToCard(task: FusionTask, opts?: { maxCharsPerLine?: number; maxLines?: number; now?: string }): GlassesCard {
|
||||
const maxCharsPerLine = opts?.maxCharsPerLine ?? DEFAULT_MAX_CHARS_PER_LINE;
|
||||
const maxLines = opts?.maxLines ?? DEFAULT_MAX_LINES_PER_CARD;
|
||||
const now = opts?.now ?? new Date().toISOString();
|
||||
const assignee = task.assignedAgentId ?? task.assigneeUserId ?? "unassigned";
|
||||
const body = [`Priority ${task.priority ?? "normal"}`, `Assignee ${assignee}`, `Age ${formatRelativeAge(task.createdAt, now)}`].join(" ");
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
taskId: task.id,
|
||||
kind: "task",
|
||||
title: truncateLine(formatTaskId(task.id) + " " + (task.title?.trim() || task.description), maxCharsPerLine),
|
||||
lines: wrapLines(body, maxCharsPerLine, maxLines),
|
||||
badge: statusBadge(task.column),
|
||||
updatedAt: task.updatedAt,
|
||||
};
|
||||
}
|
||||
40
plugins/fusion-plugin-even-cards/src/cards/types.ts
Normal file
40
plugins/fusion-plugin-even-cards/src/cards/types.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export type FusionColumn = "triage" | "todo" | "in-progress" | "in-review" | "done" | "archived";
|
||||
|
||||
export interface FusionTask {
|
||||
id: string;
|
||||
title?: string;
|
||||
description: string;
|
||||
column: FusionColumn;
|
||||
priority?: string;
|
||||
assignedAgentId?: string;
|
||||
assigneeUserId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type CardTone = "triage" | "todo" | "in-progress" | "in-review" | "done" | "neutral";
|
||||
|
||||
export interface CardStatusBadge {
|
||||
label: string;
|
||||
tone: CardTone;
|
||||
}
|
||||
|
||||
export interface GlassesCard {
|
||||
id: string;
|
||||
kind: "summary" | "task";
|
||||
title: string;
|
||||
lines: string[];
|
||||
badge: CardStatusBadge;
|
||||
taskId?: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BoardSummary {
|
||||
counts: Record<string, number>;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface CardDeck {
|
||||
cards: GlassesCard[];
|
||||
summary: BoardSummary;
|
||||
}
|
||||
39
plugins/fusion-plugin-even-cards/src/index.ts
Normal file
39
plugins/fusion-plugin-even-cards/src/index.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { definePlugin } from "@fusion/plugin-sdk";
|
||||
import type { FusionPlugin, PluginSettingSchema } from "@fusion/plugin-sdk";
|
||||
import { boardRoutes } from "./routes/board-routes.js";
|
||||
|
||||
const settingsSchema: Record<string, PluginSettingSchema> = {
|
||||
apiKey: {
|
||||
type: "password",
|
||||
label: "Companion API Key",
|
||||
description: "Bearer token used by the glasses companion app.",
|
||||
required: true,
|
||||
group: "Authentication",
|
||||
},
|
||||
boardPollingMs: {
|
||||
type: "number",
|
||||
label: "Board Polling Interval (ms)",
|
||||
defaultValue: 10000,
|
||||
group: "On-device Cards",
|
||||
},
|
||||
};
|
||||
|
||||
const plugin: FusionPlugin = definePlugin({
|
||||
manifest: {
|
||||
id: "fusion-plugin-even-cards",
|
||||
name: "Even Cards",
|
||||
version: "0.1.0",
|
||||
description: "On-device card payloads for board/task status in Even Realities flows",
|
||||
author: "Fusion Team",
|
||||
settingsSchema,
|
||||
},
|
||||
state: "installed",
|
||||
routes: [...boardRoutes],
|
||||
hooks: {
|
||||
onLoad: async (ctx) => {
|
||||
ctx.logger.info("Even Cards plugin loaded");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
39
plugins/fusion-plugin-even-cards/src/routes/auth.ts
Normal file
39
plugins/fusion-plugin-even-cards/src/routes/auth.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
import type { PluginContext, PluginRouteResponse } from "@fusion/plugin-sdk";
|
||||
|
||||
function toDigest(value: string): Buffer {
|
||||
return createHash("sha256").update(value).digest();
|
||||
}
|
||||
|
||||
function readBearer(headers: Record<string, string | string[] | undefined>): string | undefined {
|
||||
const auth = headers.authorization ?? headers.Authorization;
|
||||
const header = Array.isArray(auth) ? auth[0] : auth;
|
||||
if (!header) return undefined;
|
||||
const match = header.match(/^Bearer\s+(.+)$/i);
|
||||
return match?.[1]?.trim() || undefined;
|
||||
}
|
||||
|
||||
export function requireApiKey(
|
||||
ctx: PluginContext,
|
||||
req: { headers: Record<string, string | string[] | undefined> },
|
||||
): { ok: true } | { ok: false; response: PluginRouteResponse } {
|
||||
const expected = typeof ctx.settings.apiKey === "string" ? ctx.settings.apiKey.trim() : "";
|
||||
if (!expected) {
|
||||
return { ok: false, response: { status: 503, body: { error: "plugin not configured" } } };
|
||||
}
|
||||
|
||||
const provided = readBearer(req.headers ?? {});
|
||||
if (!provided) {
|
||||
return { ok: false, response: { status: 401, body: { error: "unauthorized" } } };
|
||||
}
|
||||
|
||||
const expectedDigest = toDigest(expected);
|
||||
const providedDigest = toDigest(provided);
|
||||
const valid = timingSafeEqual(expectedDigest, providedDigest);
|
||||
|
||||
if (!valid) {
|
||||
return { ok: false, response: { status: 401, body: { error: "unauthorized" } } };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
79
plugins/fusion-plugin-even-cards/src/routes/board-routes.ts
Normal file
79
plugins/fusion-plugin-even-cards/src/routes/board-routes.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { PluginContext, PluginRouteDefinition, PluginRouteResponse } from "@fusion/plugin-sdk";
|
||||
import { boardToDeck } from "../cards/board-cards.js";
|
||||
import { DEFAULT_MAX_CARDS_PER_DECK, DEFAULT_MAX_CHARS_PER_LINE, DEFAULT_MAX_LINES_PER_CARD } from "../cards/format.js";
|
||||
import { taskToCard } from "../cards/task-cards.js";
|
||||
import type { CardDeck, FusionColumn, FusionTask } from "../cards/types.js";
|
||||
import { requireApiKey } from "./auth.js";
|
||||
|
||||
const ALLOWED_COLUMNS: FusionColumn[] = ["triage", "todo", "in-progress", "in-review", "done", "archived"];
|
||||
|
||||
function parseColumns(raw: unknown): Set<FusionColumn> | null {
|
||||
if (typeof raw !== "string" || !raw.trim()) return null;
|
||||
const parsed = raw
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter((value): value is FusionColumn => ALLOWED_COLUMNS.includes(value as FusionColumn));
|
||||
return parsed.length ? new Set(parsed) : null;
|
||||
}
|
||||
|
||||
function parseMax(raw: unknown): number {
|
||||
const value = typeof raw === "string" ? Number.parseInt(raw, 10) : Number.NaN;
|
||||
if (!Number.isFinite(value)) return DEFAULT_MAX_CARDS_PER_DECK;
|
||||
return Math.max(1, Math.min(20, Math.floor(value)));
|
||||
}
|
||||
|
||||
function requestData(req: unknown): { headers: Record<string, string | string[] | undefined>; query: Record<string, unknown>; params: Record<string, unknown> } {
|
||||
const candidate = (req ?? {}) as { headers?: Record<string, string | string[] | undefined>; query?: Record<string, unknown>; params?: Record<string, unknown> };
|
||||
return { headers: candidate.headers ?? {}, query: candidate.query ?? {}, params: candidate.params ?? {} };
|
||||
}
|
||||
|
||||
async function getBoardCards(req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> {
|
||||
const request = requestData(req);
|
||||
const auth = requireApiKey(ctx, { headers: request.headers });
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const all = ((await ctx.taskStore.listTasks({ includeArchived: false })) as FusionTask[]) ?? [];
|
||||
const columns = parseColumns(request.query.columns);
|
||||
const filtered = columns ? all.filter((task) => columns.has(task.column)) : all;
|
||||
const maxCards = parseMax(request.query.max);
|
||||
const deck = boardToDeck(filtered, { maxCharsPerLine: DEFAULT_MAX_CHARS_PER_LINE, maxLines: DEFAULT_MAX_LINES_PER_CARD, maxCards });
|
||||
return { status: 200, body: { deck, generatedAt: new Date().toISOString() } };
|
||||
}
|
||||
|
||||
async function getBoardSummary(req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> {
|
||||
const request = requestData(req);
|
||||
const auth = requireApiKey(ctx, { headers: request.headers });
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const all = ((await ctx.taskStore.listTasks({ includeArchived: false })) as FusionTask[]) ?? [];
|
||||
const columns = parseColumns(request.query.columns);
|
||||
const filtered = columns ? all.filter((task) => columns.has(task.column)) : all;
|
||||
const deck = boardToDeck(filtered, { maxCharsPerLine: DEFAULT_MAX_CHARS_PER_LINE, maxLines: DEFAULT_MAX_LINES_PER_CARD, maxCards: 1 });
|
||||
|
||||
return { status: 200, body: { summary: deck.summary, updatedAt: deck.summary.updatedAt } };
|
||||
}
|
||||
|
||||
async function getTaskCards(req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> {
|
||||
const request = requestData(req);
|
||||
const auth = requireApiKey(ctx, { headers: request.headers });
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const taskId = typeof request.params.id === "string" ? request.params.id.trim() : "";
|
||||
if (!taskId) return { status: 400, body: { error: "task id is required" } };
|
||||
|
||||
const task = (await ctx.taskStore.getTask(taskId)) as FusionTask | undefined;
|
||||
if (!task) return { status: 404, body: { error: "task not found" } };
|
||||
|
||||
const deck: CardDeck = {
|
||||
cards: [taskToCard(task, { maxCharsPerLine: DEFAULT_MAX_CHARS_PER_LINE, maxLines: DEFAULT_MAX_LINES_PER_CARD })],
|
||||
summary: boardToDeck([task], { maxCards: 1 }).summary,
|
||||
};
|
||||
|
||||
return { status: 200, body: { deck, generatedAt: new Date().toISOString() } };
|
||||
}
|
||||
|
||||
export const boardRoutes: PluginRouteDefinition[] = [
|
||||
{ method: "GET", path: "/board/cards", handler: getBoardCards, description: "Read-only board card deck" },
|
||||
{ method: "GET", path: "/board", handler: getBoardSummary, description: "Read-only board summary" },
|
||||
{ method: "GET", path: "/tasks/:id/cards", handler: getTaskCards, description: "Read-only single task card deck" },
|
||||
];
|
||||
Reference in New Issue
Block a user