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
41 changes: 41 additions & 0 deletions packages/inbox/src/cursor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// A decoded pagination cursor is only meaningful against the exact
// view/sort/filter it was minted under — `@corbits/mailbox` embeds all
// three in every cursor it mints precisely so a caller can reject a
// cross-set replay (paging `?group=action`, then reusing that cursor
// under `?group=mention`) instead of silently seeking into the wrong
// result set. `decodeMailboxListCursor` only checks the cursor is
// well-formed; this is the cross-check `@corbits/mailbox`'s own
// `mount.ts` route performs and every other caller must perform itself.
import {
canonicalMailboxFilter,
type MailboxFilter,
type MailboxInboxView,
type MailboxListCursor,
type MailboxSort,
} from "@corbits/mailbox";

export type CursorScope = {
view: MailboxInboxView;
sort: MailboxSort;
filter: MailboxFilter;
};

export type CursorMismatch = "view" | "sort" | "filter" | null;

/**
* Which field of `cursor` disagrees with `expected`, or `null` when the
* cursor was minted for exactly this scope. Checked in the same order
* `@corbits/mailbox`'s `mount.ts` checks it, so a caller can reuse its
* exact error strings (`cursor does not match inbox <field>`).
*/
export function cursorScopeMismatch(
cursor: MailboxListCursor,
expected: CursorScope,
): CursorMismatch {
if (cursor.view !== expected.view) return "view";
if (cursor.sort !== expected.sort) return "sort";
if (cursor.filter !== canonicalMailboxFilter(expected.filter)) {
return "filter";
}
return null;
}
18 changes: 18 additions & 0 deletions packages/inbox/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type { TenantEnv } from "@intx/hub-api";
import { getLogger } from "@intx/log";
import { Hono } from "hono";

import { cursorScopeMismatch } from "./cursor";
import { isInboxGroup, type InboxGroup } from "./group";
import { itemsEligibleForClearDone, itemsEligibleForMarkAllRead } from "./bulk";
import {
Expand Down Expand Up @@ -212,13 +213,30 @@ export function createInboxRoutes(
if (decoded === null) {
return c.json({ error: "malformed cursor" }, 400);
}
// A cursor is only meaningful against the exact view/sort/filter it
// was minted under (CL-7206) — paging `?group=action` then replaying
// that cursor under `?group=mention` must be rejected, not silently
// seek into the wrong result set. Same error vocabulary as
// `@corbits/mailbox`'s own `mount.ts` cross-check.
const mismatch = cursorScopeMismatch(decoded, {
view: "all",
sort: "date",
filter,
});
if (mismatch !== null) {
return c.json(
{ error: `cursor does not match inbox ${mismatch}` },
400,
);
}
cursor = decoded;
}

const listMailboxOpts = {
tenantId: tenant.id,
principalId: principal.id,
view: "all" as const,
sort: "date" as const,
limit,
priorities: WORKBENCH_INBOX_PRIORITIES,
};
Expand Down
62 changes: 62 additions & 0 deletions packages/inbox/test/cursor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, expect, test } from "bun:test";
import type { MailboxListCursor } from "@corbits/mailbox";
import { cursorScopeMismatch } from "../src/cursor";

function cursor(overrides: Partial<MailboxListCursor> = {}): MailboxListCursor {
return {
createdAt: "2026-01-01T00:00:00.000000Z",
id: "msg_1",
view: "all",
sort: "date",
filter: "classification=action",
...overrides,
};
}

describe("cursorScopeMismatch", () => {
test("matching cursor: no mismatch", () => {
const mismatch = cursorScopeMismatch(cursor(), {
view: "all",
sort: "date",
filter: { classification: "action" },
});
expect(mismatch).toBeNull();
});

test("cursor minted under a different group filter is rejected", () => {
// Paged `?group=action`, then replayed that cursor under `?group=mention`.
const actionCursor = cursor({ filter: "classification=action" });
const mismatch = cursorScopeMismatch(actionCursor, {
view: "all",
sort: "date",
filter: { classification: "mention" },
});
expect(mismatch).toBe("filter");
});

test("cursor minted under a different view is rejected", () => {
const mismatch = cursorScopeMismatch(cursor({ view: "unread" }), {
view: "all",
sort: "date",
filter: {},
});
expect(mismatch).toBe("view");
});

test("cursor minted under a different sort is rejected", () => {
const mismatch = cursorScopeMismatch(cursor({ sort: "priority" }), {
view: "all",
sort: "date",
filter: {},
});
expect(mismatch).toBe("sort");
});

test("view is checked before sort and filter", () => {
const mismatch = cursorScopeMismatch(
cursor({ view: "unread", sort: "priority", filter: "status=done" }),
{ view: "all", sort: "date", filter: {} },
);
expect(mismatch).toBe("view");
});
});
73 changes: 73 additions & 0 deletions packages/inbox/test/routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Route-level tests for the parts of `GET /` that reject before touching the
// database — the cursor/filter cross-check (CL-7206) chiefly. `db` is a
// stub that throws on any call, which is itself the assertion that a
// rejected request never reaches the mailbox store.
import { describe, expect, test } from "bun:test";
import { Hono } from "hono";
import type { TenantEnv } from "@intx/hub-api";
import {
createInMemoryMailboxEventBus,
type MailboxDb,
} from "@corbits/mailbox";
import { createInboxRoutes } from "../src/routes";

const TENANT = { id: "tnt_1" };
const PRINCIPAL = { id: "prn_1" };

function neverCalledDb(): MailboxDb {
return new Proxy(
{},
{
get() {
throw new Error("db should not be reached on a rejected request");
},
},
) as MailboxDb;
}

function mount(): Hono<TenantEnv> {
const routes = createInboxRoutes({
db: neverCalledDb(),
bus: createInMemoryMailboxEventBus(),
});
const app = new Hono<TenantEnv>();
app.use("*", async (c, next) => {
c.set("tenant", TENANT as never);
c.set("principal", PRINCIPAL as never);
await next();
});
app.route("/", routes);
return app;
}

describe("GET / cursor/filter cross-check", () => {
test("a cursor minted under ?group=action is rejected when replayed under ?group=mention", async () => {
const app = mount();
// Minted the same way `listUserMailbox` mints one: base64url JSON with
// view/sort/filter embedded, filter canonicalized to `classification=action`.
const payload = {
createdAt: "2026-01-01T00:00:00.000000Z",
id: "msg_1",
view: "all",
sort: "date",
filter: "classification=action",
};
const cursor = Buffer.from(JSON.stringify(payload)).toString("base64url");

const response = await app.request(
`/?group=mention&cursor=${encodeURIComponent(cursor)}`,
);

expect(response.status).toBe(400);
const body = (await response.json()) as { error: string };
expect(body.error).toBe("cursor does not match inbox filter");
});

test("a well-formed cursor with no query params is malformed-rejected without a filter mismatch masking it", async () => {
const app = mount();
const response = await app.request("/?cursor=not-valid-base64url!!!");
expect(response.status).toBe(400);
const body = (await response.json()) as { error: string };
expect(body.error).toBe("malformed cursor");
});
});
Loading