All files / routes/partner index.ts

100% Statements 23/23
100% Branches 4/4
100% Functions 2/2
100% Lines 22/22

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107                                                                                                              10x         6x               6x 1x 1x   5x 5x     1x             1x   1x 1x 1x 1x 1x 1x 1x 1x             1x 1x 1x 1x 1x      
/**
 * The partner-facing sub-router (F-21, RRM partner surface).
 *
 * Mounted at `/api/partner`. Everything here is a partner looking at their own
 * data, so the session guard is applied once, at this boundary, rather than
 * repeated in each handler — one place to read, one place to get wrong.
 *
 * The guard is registered against the two data subtrees BY NAME rather than as
 * a blanket `use("*", ...)`. `/api/partner/auth/*` (request-otp, verify-otp,
 * logout, session) is public by contract and is mounted here too, so a
 * wildcard would shadow it and demand a session from the very endpoints that
 * exist to create one. Commit 9672d79f fixed exactly that bug on the
 * social-studio router; this is that lesson applied ahead of time.
 *
 * Both the bare path and the `/*` form are registered for each subtree. The
 * `/*` form alone does match the bare path on the Hono in this repo today —
 * measured, not assumed — so the second registration is redundant right now.
 * It stays because the failure mode if that ever changes is not a 404, it is
 * `GET /me` served without a session. `index.test.ts` asserts the bare paths
 * are guarded, so the redundancy is a second line of defence rather than the
 * only one.
 */
 
import { Hono, type MiddlewareHandler } from "hono";
import type { RrmProspect } from "../../db/schema/rrm";
import { requirePartnerSession } from "../../lib/rrm/partner-session";
import { contextMiddleware } from "../../middleware";
import auth from "./auth.routes";
import me, { type PartnerEnv } from "./me.routes";
import payouts from "./payouts.routes";
import referrals from "./referrals.routes";
import share from "./share.routes";
 
type PartnerRouterEnv = {
	Bindings: CloudflareBindings;
	Variables: PartnerEnv["Variables"] & {
		/** What `requirePartnerSession` itself publishes. */
		prospectId?: string;
		prospect?: RrmProspect;
	};
};
 
/**
 * The guard, plus one line of adaptation.
 *
 * `requirePartnerSession` publishes the partner as `prospectId`; the handlers
 * in this directory read `partnerId`. The names differ because the
 * generic one is ambiguous in a codebase where admin routes also carry a
 * prospect in context, and a handler that scopes money by a variable someone
 * else might set is a handler one careless `c.set` away from a leak.
 *
 * Bridged here rather than renamed in either place: this is the file whose job
 * is wiring, and doing it here means the handlers keep a name that can only
 * have come from an authenticated partner session.
 */
const guard: MiddlewareHandler<PartnerRouterEnv> = (c, next) =>
	requirePartnerSession(c, async () => {
		// Either name. The middleware publishes `prospectId` today; the build
		// contract for it named `partnerId`. Reading both means a rename
		// on that side is a no-op here rather than a 404 on every data route.
		const prospectId = c.get("partnerId") ?? c.get("prospectId");
		// The guard answers 401 rather than calling `next` when it cannot
		// resolve a partner, so this is unreachable with an empty id — the
		// check is here so that if that ever stops being true, the request
		// fails closed instead of querying the ledger with `undefined`.
		// Assigning `c.res` rather than returning: this runs as the guard's
		// `next`, whose return value Hono discards, and an unfinalized context
		// surfaces as a 500 instead of an answer.
		if (!prospectId) {
			c.res = c.json({ error: "unauthenticated" }, 401);
			return;
		}
		c.set("partnerId", prospectId);
		await next();
	});
 
const partner = new Hono<PartnerRouterEnv>();
 
// Every route below reads `c.get("dal")`, which is set in exactly one place:
// middleware/context.middleware.ts. Each sub-router mounts it itself — see
// routes/pro/index.ts:39 and routes/admin/index.ts:45 — because index.ts does
// not apply it globally. Without this line every partner request, including
// login, throws on `c.get("dal")` being undefined.
partner.use("*", contextMiddleware);
 
partner.use("/me", guard);
partner.use("/me/*", guard);
partner.use("/share", guard);
partner.use("/share/*", guard);
partner.use("/referrals", guard);
partner.use("/referrals/*", guard);
partner.use("/payouts", guard);
partner.use("/payouts/*", guard);
 
// `/auth` is mounted here but deliberately NOT in the guard list above: these
// are the endpoints that issue the session, so requiring one would be a lock
// with its key inside. Every directory barrel in this repo mounts its own
// route files (see routes/admin/rrm/index.ts), so mounting `/api/partner` gets
// the whole partner surface and login cannot be left switched off by accident.
partner.route("/auth", auth);
partner.route("/me", me);
partner.route("/referrals", referrals);
partner.route("/share", share);
partner.route("/payouts", payouts);
 
export default partner;