Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ download caps, email gates, expiry beyond seven days, and `never` expiry.
|----------|---------|
| `HASNA_ATTACHMENTS_DB_PATH` | SQLite database override |
| `ATTACHMENTS_API_TOKEN`, `HASNA_ATTACHMENTS_API_TOKEN` | Local `/api` authentication |
| `HASNA_TODOS_API_KEY`, `TODOS_API_KEY` | Todos API authentication for task integrations |
| `HASNA_TODOS_API_URL`, `TODOS_API_URL` | Trusted non-default Todos API origin for sending todos authentication |
| `ATTACHMENTS_MAX_SIZE` | Upload limit override in bytes |
| `ATTACHMENTS_TRACK_COSTS` | Enables economy tracking when set |
| `ATTACHMENTS_ECONOMY_URL` | Economy URL; defaults to `http://localhost:3460` |
Expand Down
4 changes: 4 additions & 0 deletions scripts/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ unset HASNA_ATTACHMENTS_API_URL
unset HASNA_ATTACHMENTS_API_KEY
unset ATTACHMENTS_API_URL
unset ATTACHMENTS_API_KEY
unset HASNA_TODOS_API_KEY
unset TODOS_API_KEY
unset HASNA_TODOS_API_URL
unset TODOS_API_URL

PASS=0
FAIL=0
Expand Down
3 changes: 3 additions & 0 deletions src/cli/commands/complete-task.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ describe("completeTaskWithFiles", () => {
it("uploads files, PATCHes evidence into task metadata, then completes", async () => {
const upload = makeUpload("att_001", "https://example.com/att_001");
const fakeFetch = makeFetch();
process.env.HASNA_TODOS_API_KEY = "remote-key";

const result = await completeTaskWithFiles(
"TASK-001",
Expand All @@ -182,13 +183,15 @@ describe("completeTaskWithFiles", () => {
makeStoreFactory(upload),
fakeFetch
);
delete process.env.HASNA_TODOS_API_KEY;

expect(upload).toHaveBeenCalledTimes(1);
expect(upload).toHaveBeenCalledWith("/tmp/file.txt", { expiry: undefined });

const calls = (fakeFetch as ReturnType<typeof mock>).mock.calls as Array<[string, RequestInit | undefined]>;
// GET -> PATCH -> POST /complete
expect(calls).toHaveLength(3);
expect(calls.every(([, init]) => new Headers(init?.headers).get("x-api-key") === "remote-key")).toBe(true);
expect(calls[0][0]).toBe("http://localhost:3000/api/tasks/TASK-001");
expect((calls[0][1]?.method ?? "GET").toUpperCase()).toBe("GET");
expect(calls[1][0]).toBe("http://localhost:3000/api/tasks/TASK-001");
Expand Down
12 changes: 7 additions & 5 deletions src/cli/commands/complete-task.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Command } from "commander";
import { resolveStore, type Store } from "../../core/store";
import { withTodosAuth } from "../../core/todos";

export interface CompleteTaskOptions {
file?: string[];
Expand Down Expand Up @@ -75,7 +76,7 @@ export async function completeTaskWithFiles(

// 2. Read the current task so we can merge (not clobber) its metadata and honor
// optimistic concurrency via its version.
const getResponse = await fetchFn(taskUrl);
const getResponse = await fetchFn(taskUrl, withTodosAuth(taskUrl));
if (!getResponse.ok) {
if (getResponse.status === 404) {
throw new Error(`Task not found: ${taskId}`);
Expand Down Expand Up @@ -114,11 +115,11 @@ export async function completeTaskWithFiles(
if (typeof task.version === "number") {
patchBody.version = task.version;
}
const patchResponse = await fetchFn(taskUrl, {
const patchResponse = await fetchFn(taskUrl, withTodosAuth(taskUrl, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patchBody),
});
}));
if (!patchResponse.ok) {
const responseBody = await patchResponse.text().catch(() => "");
throw new Error(
Expand All @@ -127,11 +128,12 @@ export async function completeTaskWithFiles(
}

// 4. Mark the task complete.
const completeResponse = await fetchFn(`${taskUrl}/complete`, {
const completeUrl = `${taskUrl}/complete`;
const completeResponse = await fetchFn(completeUrl, withTodosAuth(completeUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
}));
if (!completeResponse.ok) {
if (completeResponse.status === 404) {
throw new Error(`Task not found: ${taskId}`);
Expand Down
3 changes: 3 additions & 0 deletions src/cli/commands/link-task.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,15 @@ describe("linkAttachmentToTask", () => {
mockFindById.mockImplementation(() => att);

const fakeFetch = makeFetch(200);
process.env.TODOS_API_KEY = "remote-key";
await linkAttachmentToTask("att_abc123", "TASK-001", "http://localhost:3000", fakeFetch);
delete process.env.TODOS_API_KEY;

expect(fakeFetch).toHaveBeenCalledTimes(1);
const [url, opts] = (fakeFetch as ReturnType<typeof mock>).mock.calls[0] as [string, RequestInit];
expect(url).toBe("http://localhost:3000/api/tasks/TASK-001");
expect(opts.method).toBe("PATCH");
expect(new Headers(opts.headers).get("x-api-key")).toBe("remote-key");

const body = JSON.parse(opts.body as string);
expect(body.metadata._attachments).toHaveLength(1);
Expand Down
5 changes: 3 additions & 2 deletions src/cli/commands/link-task.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Command } from "commander";
import { resolveStore } from "../../core/store";
import { withTodosAuth } from "../../core/todos";

export interface LinkTaskOptions {
todosUrl?: string;
Expand Down Expand Up @@ -42,15 +43,15 @@ export async function linkAttachmentToTask(
};

const url = `${todosUrl}/api/tasks/${taskId}`;
const response = await fetchFn(url, {
const response = await fetchFn(url, withTodosAuth(url, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
metadata: {
_attachments: [entry],
},
}),
});
}));

if (!response.ok) {
if (response.status === 404) {
Expand Down
4 changes: 4 additions & 0 deletions src/cli/commands/resolve-evidence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,13 @@ describe("resolveEvidence", () => {
{ id: "att_abc123", link: "https://stale-link.example.com", filename: "report.pdf", size: 1258291 },
]);
const fakeFetch = makeFetch(200, task);
process.env.TODOS_API_KEY = "remote-key";

const result = await resolveEvidence("TASK-001", { todosUrl: "http://localhost:3000" }, fakeFetch);
delete process.env.TODOS_API_KEY;
const [, init] = (fakeFetch as ReturnType<typeof mock>).mock.calls[0] as [string, RequestInit];

expect(new Headers(init.headers).get("x-api-key")).toBe("remote-key");
expect(result).toHaveLength(1);
expect(result[0].id).toBe("att_abc123");
// Should use the DB link (fresh), not the stale one stored in the task
Expand Down
3 changes: 2 additions & 1 deletion src/cli/commands/resolve-evidence.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Command } from "commander";
import { resolveStore } from "../../core/store";
import { withTodosAuth } from "../../core/todos";

export interface ResolveEvidenceOptions {
todosUrl?: string;
Expand Down Expand Up @@ -36,7 +37,7 @@ export async function resolveEvidence(

let response: Response;
try {
response = await fetchFn(url);
response = await fetchFn(url, withTodosAuth(url));
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`Could not reach todos server at ${todosUrl}: ${message}`);
Expand Down
8 changes: 8 additions & 0 deletions src/cli/commands/task-journal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,11 @@ describe("fetchTaskMeta", () => {
assignee: "aurelius",
created_at: "2026-03-14T10:23:00Z",
});
process.env.TODOS_API_KEY = "remote-key";
const meta = await fetchTaskMeta("TASK-001", "http://localhost:3000", fakeFetch);
delete process.env.TODOS_API_KEY;
const [, init] = (fakeFetch as ReturnType<typeof mock>).mock.calls[0] as [string, RequestInit];
expect(new Headers(init.headers).get("x-api-key")).toBe("remote-key");
expect(meta).not.toBeNull();
expect(meta?.subject).toBe("Fix auth bug");
expect(meta?.status).toBe("completed");
Expand Down Expand Up @@ -124,7 +128,11 @@ describe("fetchTaskHistory", () => {
{ timestamp: "2026-03-14T10:45:00Z", action: "started", actor: "aurelius" },
{ timestamp: "2026-03-14T11:30:00Z", action: "completed", actor: "aurelius", progress: 100 },
]);
process.env.HASNA_TODOS_API_KEY = "remote-key";
const history = await fetchTaskHistory("TASK-001", "http://localhost:3000", fakeFetch);
delete process.env.HASNA_TODOS_API_KEY;
const [, init] = (fakeFetch as ReturnType<typeof mock>).mock.calls[0] as [string, RequestInit];
expect(new Headers(init.headers).get("x-api-key")).toBe("remote-key");
expect(history).toHaveLength(3);
expect(history[0].action).toBe("created");
expect(history[0].actor).toBe("julius");
Expand Down
10 changes: 7 additions & 3 deletions src/cli/commands/task-journal.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Command } from "commander";
import type { Attachment } from "../../core/db";
import { resolveStore, type Store } from "../../core/store";
import { withTodosAuth } from "../../core/todos";

export interface TaskJournalOptions {
todosUrl?: string;
Expand Down Expand Up @@ -39,7 +40,8 @@ export async function fetchTaskMeta(
fetchFn: typeof fetch = fetch
): Promise<TaskMeta | null> {
try {
const response = await fetchFn(`${todosUrl}/api/tasks/${taskId}`);
const url = `${todosUrl}/api/tasks/${taskId}`;
const response = await fetchFn(url, withTodosAuth(url));
if (response.status === 404) return null;
if (!response.ok) return null;
const data = await response.json() as Record<string, unknown>;
Expand All @@ -66,7 +68,8 @@ export async function fetchTaskHistory(
fetchFn: typeof fetch = fetch
): Promise<TaskHistoryEntry[]> {
try {
const response = await fetchFn(`${todosUrl}/api/tasks/${taskId}/history`);
const url = `${todosUrl}/api/tasks/${taskId}/history`;
const response = await fetchFn(url, withTodosAuth(url));
if (!response.ok) return [];
const data = await response.json() as unknown;
if (!Array.isArray(data)) return [];
Expand Down Expand Up @@ -261,7 +264,8 @@ export function registerTaskJournal(program: Command): void {
if (!todosReachable && journal.attachments.length === 0 && !journal.task.subject) {
// Attempt a direct 404 check
try {
const response = await fetch(`${todosUrl}/api/tasks/${taskId}`);
const url = `${todosUrl}/api/tasks/${taskId}`;
const response = await fetch(url, withTodosAuth(url));
if (response.status === 404) {
process.stderr.write(`Error: Task not found: ${taskId}\n`);
process.exit(1);
Expand Down
4 changes: 4 additions & 0 deletions src/cli/commands/watch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,7 @@ describe("handleTaskEvent", () => {

describe("connectAndWatch reconnect logic", () => {
it("reconnects after stream error with backoff", async () => {
process.env.TODOS_API_KEY = "remote-key";
let callCount = 0;
const controller = new AbortController();
const sleepCalls: number[] = [];
Expand Down Expand Up @@ -382,7 +383,10 @@ describe("connectAndWatch reconnect logic", () => {
stdoutSpy.mockRestore();
}

delete process.env.TODOS_API_KEY;
expect(callCount).toBe(2);
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(new Headers(init.headers).get("x-api-key")).toBe("remote-key");
expect(sleepCalls).toHaveLength(1);
expect(sleepCalls[0]).toBe(5000);
const errOutput = err.join("");
Expand Down
3 changes: 2 additions & 1 deletion src/cli/commands/watch.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Command } from "commander";
import { resolveStore, type Store } from "../../core/store";
import { withTodosAuth } from "../../core/todos";
import { checkAttachment } from "./health-check";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -146,7 +147,7 @@ export async function connectAndWatch(
process.stdout.write(`[watch] Connecting to ${url}\n`);
}

const response = await fetchFn(url, { signal });
const response = await fetchFn(url, withTodosAuth(url, { signal }));

if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
Expand Down
67 changes: 67 additions & 0 deletions src/core/todos.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { withTodosAuth } from "./todos";

function clearTodosEnv() {
delete process.env.HASNA_TODOS_API_KEY;
delete process.env.TODOS_API_KEY;
delete process.env.HASNA_TODOS_API_URL;
delete process.env.TODOS_API_URL;
}

beforeEach(clearTodosEnv);
afterEach(clearTodosEnv);

describe("withTodosAuth", () => {
it("returns the original request init when no API key is configured", () => {
const init = { method: "POST" };

expect(withTodosAuth("http://localhost:3000/api/tasks/TASK-001", init)).toBe(init);
expect(withTodosAuth()).toBeUndefined();
});

it("uses TODOS_API_KEY as an x-api-key header for the default todos origin", () => {
process.env.TODOS_API_KEY = "todos-fallback-key";

const init = withTodosAuth("http://localhost:3000/api/tasks/TASK-001", {
headers: { "Content-Type": "application/json" },
});
const headers = new Headers(init?.headers);

expect(headers.get("x-api-key")).toBe("todos-fallback-key");
expect(headers.get("content-type")).toBe("application/json");
});

it("prefers a non-empty HASNA_TODOS_API_KEY", () => {
process.env.HASNA_TODOS_API_KEY = "hasna-key";
process.env.TODOS_API_KEY = "fallback-key";

const headers = new Headers(withTodosAuth("http://localhost:3000/api/tasks/TASK-001")?.headers);

expect(headers.get("x-api-key")).toBe("hasna-key");
});

it("falls back when HASNA_TODOS_API_KEY is empty", () => {
process.env.HASNA_TODOS_API_KEY = "";
process.env.TODOS_API_KEY = "fallback-key";

const headers = new Headers(withTodosAuth("http://localhost:3000/api/tasks/TASK-001")?.headers);

expect(headers.get("x-api-key")).toBe("fallback-key");
});

it("does not forward the API key to an arbitrary override origin", () => {
process.env.HASNA_TODOS_API_KEY = "hasna-key";
const init = { method: "GET" };

expect(withTodosAuth("https://example.invalid/api/tasks/TASK-001", init)).toBe(init);
});

it("allows a remote origin only when it is explicitly configured", () => {
process.env.HASNA_TODOS_API_URL = "https://todos.example.com";
process.env.HASNA_TODOS_API_KEY = "hasna-key";

const headers = new Headers(withTodosAuth("https://todos.example.com/api/tasks/TASK-001")?.headers);

expect(headers.get("x-api-key")).toBe("hasna-key");
});
});
35 changes: 35 additions & 0 deletions src/core/todos.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
const DEFAULT_TODOS_ORIGIN = new URL("http://localhost:3000").origin;

function parseOrigin(url: string | URL | undefined): string | null {
if (!url) return null;
try {
return new URL(url).origin;
} catch {
return null;
}
}

function trustedTodosOrigins(): Set<string> {
const origins = new Set<string>([DEFAULT_TODOS_ORIGIN]);
for (const value of [process.env.HASNA_TODOS_API_URL, process.env.TODOS_API_URL]) {
const origin = parseOrigin(value);
if (origin) origins.add(origin);
}
return origins;
}

export function withTodosAuth(
requestUrl?: string | URL,
init?: RequestInit
): RequestInit | undefined {
const apiKey = process.env.HASNA_TODOS_API_KEY || process.env.TODOS_API_KEY;
if (!apiKey) return init;

const requestOrigin = parseOrigin(requestUrl);
if (!requestOrigin || !trustedTodosOrigins().has(requestOrigin)) return init;

const headers = new Headers(init?.headers);
headers.set("x-api-key", apiKey);

return { ...init, headers };
}
Loading
Loading