Skip to content

Commit 52b47ea

Browse files
committed
Return 401 instead of a 500 when the host has not resolved a principal
A stock mount 500s on every knowledge route. The routes read c.get("principal") but mount at /api/knowledge/*, which is outside /api/tenants/:tenantId/* — the prefix Interchange's createResolveTenant covers, and where it reads the tenant from the path param. Nothing populates the context for our prefix, so a host that just calls mountKnowledgeEngine per the README gets: TypeError: undefined is not an object (evaluating 'principal.id') at packages/hub-api/src/middleware/grant.ts:55:9 requireGrant dereferences the principal with no guard of its own, so it throws before caller() runs — and caller() already has a good error message for exactly this case that nobody ever sees. The failure surfaces as a 500 with a stack trace pointing into Interchange, which reads as this SDK being broken rather than as the host missing middleware. Adds requirePrincipal(), mounted ahead of grantGuard on all three routes, returning 401 principal_required with an actionable message. The README documents the middleware the host has to supply. This is the minimum fix. Moving the routes under /api/tenants/:tenantId/ would remove the requirement entirely and need no host glue — worth considering, but it is a breaking path change so it should be a deliberate call rather than folded in here. Tests: 145 pass, 0 fail. Typecheck clean. Refs CL-4506 Claude-Session: https://claude.ai/code/session_017GTgGzn5xAwvkU2GAPAHpF
1 parent b705853 commit 52b47ea

5 files changed

Lines changed: 84 additions & 5 deletions

File tree

‎README.md‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import { loadKnowledgeConfig } from "@corbits/knowledge-engine/config";
4141
// `app` is your Interchange createApp (Hono<TenantEnv>). Pass the same grant
4242
// store + condition registry you give createApp/createRequireGrant.
4343
mountKnowledgeEngine(app, {
44-
config: loadKnowledgeConfig(), // or build the object yourself
44+
config: loadKnowledgeConfig(), // or build the object yourself
4545
grants: { grantStore, conditionRegistry },
4646
});
4747
```
@@ -51,6 +51,42 @@ That mounts `POST /api/knowledge/capture`, `POST /api/knowledge/search`, and
5151
`requireGrant("knowledge", <action>)`. Clients never send tenant or principal —
5252
identity is the context principal.
5353

54+
### The host must resolve tenant + principal for `/api/knowledge/*`
55+
56+
These routes read `c.get("principal")`, but they mount at `/api/knowledge/*` —
57+
**outside** `/api/tenants/:tenantId/*`, which is where Interchange's own
58+
`createResolveTenant` middleware is scoped and where it reads the tenant from
59+
the path param. Nothing populates the context for our prefix, so the host has to:
60+
61+
```ts
62+
// Mount BEFORE mountKnowledgeEngine — the grant guard runs first and needs a
63+
// principal on the context.
64+
app.use("/api/knowledge/*", async (c, next) => {
65+
const user = c.get("user");
66+
if (!user) return c.json({ error: "unauthorized" }, 401);
67+
68+
// Your choice how the tenant is selected — a header, a subdomain, or the
69+
// user's only membership. There is no path param to read.
70+
const tenantRow = await resolveTenantSomehow(c);
71+
const principalRow = await db.query.principal.findFirst({
72+
where: and(
73+
eq(principal.tenantId, tenantRow.id),
74+
eq(principal.kind, "user"),
75+
eq(principal.refId, user.id),
76+
),
77+
});
78+
if (!principalRow) return c.json({ error: "not a member" }, 403);
79+
if (principalRow.status !== "active")
80+
return c.json({ error: "inactive" }, 403);
81+
82+
c.set("tenant", tenantRow);
83+
c.set("principal", principalRow);
84+
await next();
85+
});
86+
```
87+
88+
Without it every knowledge route returns **401 `principal_required`**.
89+
5490
Apply the knowledge/vector schema once (idempotent):
5591

5692
```ts

‎src/routes/capture.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { type } from "arktype";
66
import { formatCaughtError, log } from "../log.ts";
77
import { parseAcl } from "../acl.ts";
88
import type { RouteDeps } from "./deps.ts";
9-
import { caller, grantGuard } from "./deps.ts";
9+
import { caller, grantGuard, requirePrincipal } from "./deps.ts";
1010

1111
const CaptureRequest = type({
1212
title: "string >= 1",
@@ -33,6 +33,7 @@ export function mountCaptureRoute(app: Hono<TenantEnv>, deps: RouteDeps): void {
3333
403: { description: "Missing the knowledge:capture grant" },
3434
},
3535
}),
36+
requirePrincipal(),
3637
grantGuard(deps, "capture"),
3738
validator("json", CaptureRequest),
3839
async (c) => {

‎src/routes/deps.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,43 @@ export function caller(c: Context<TenantEnv>): {
3838
return { scopeId: principal.tenantId, subjectId: principal.id };
3939
}
4040

41+
/**
42+
* Reject the request if the host has not put a principal on the context.
43+
*
44+
* Must run BEFORE `grantGuard`. Interchange's `requireGrant` reads
45+
* `principal.id` without a guard of its own, so on an unresolved context it
46+
* throws `TypeError: undefined is not an object` and the host sees a 500 with a
47+
* stack trace pointing into Interchange — which reads as this SDK being broken
48+
* rather than as the host missing middleware. `caller()` below has a perfectly
49+
* good error message for exactly this case, but it never gets to run.
50+
*
51+
* These routes mount at `/api/knowledge/*`, outside the
52+
* `/api/tenants/:tenantId/*` prefix that Interchange's `createResolveTenant`
53+
* covers, so an unresolved context is the DEFAULT for a host that just calls
54+
* `mountKnowledgeEngine`. See the README for the middleware the host supplies.
55+
*/
56+
export function requirePrincipal(): MiddlewareHandler<TenantEnv> {
57+
return async (c, next) => {
58+
if (!c.get("principal")) {
59+
return c.json(
60+
{
61+
error: {
62+
code: "principal_required",
63+
message:
64+
"No principal on the request context. The knowledge routes mount " +
65+
"at /api/knowledge/*, which is outside Interchange's " +
66+
"/api/tenants/:tenantId/* tenant middleware — the host must " +
67+
"resolve tenant + principal for this prefix. See the " +
68+
"@corbits/knowledge-engine README.",
69+
},
70+
},
71+
401,
72+
);
73+
}
74+
await next();
75+
};
76+
}
77+
4178
/** Route-guard middleware for a knowledge action (Interchange `requireGrant`). */
4279
export function grantGuard(
4380
deps: RouteDeps,

‎src/routes/search.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { formatCaughtError, log } from "../log.ts";
77
import { KnowledgeError } from "../knowledge.ts";
88
import { SearchResponseSchema } from "../core/schemas/search.ts";
99
import type { RouteDeps } from "./deps.ts";
10-
import { caller, grantGuard } from "./deps.ts";
10+
import { caller, grantGuard, requirePrincipal } from "./deps.ts";
1111

1212
const SearchRequest = type({
1313
query: "string >= 1",
@@ -31,6 +31,7 @@ export function mountSearchRoute(app: Hono<TenantEnv>, deps: RouteDeps): void {
3131
403: { description: "Missing the knowledge:search grant" },
3232
},
3333
}),
34+
requirePrincipal(),
3435
grantGuard(deps, "search"),
3536
validator("json", SearchRequest),
3637
async (c) => {

‎src/routes/timeline.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { describeRoute, resolver } from "hono-openapi";
44
import { type } from "arktype";
55

66
import type { RouteDeps } from "./deps.ts";
7-
import { caller, grantGuard } from "./deps.ts";
7+
import { caller, grantGuard, requirePrincipal } from "./deps.ts";
88

99
const TimelineResponse = type({
1010
events: type({
@@ -16,7 +16,10 @@ const TimelineResponse = type({
1616
}).array(),
1717
});
1818

19-
export function mountTimelineRoute(app: Hono<TenantEnv>, deps: RouteDeps): void {
19+
export function mountTimelineRoute(
20+
app: Hono<TenantEnv>,
21+
deps: RouteDeps,
22+
): void {
2023
app.get(
2124
"/api/knowledge/timeline",
2225
describeRoute({
@@ -32,6 +35,7 @@ export function mountTimelineRoute(app: Hono<TenantEnv>, deps: RouteDeps): void
3235
403: { description: "Missing the knowledge:search grant" },
3336
},
3437
}),
38+
requirePrincipal(),
3539
grantGuard(deps, "search"),
3640
(c) => {
3741
const { scopeId } = caller(c);

0 commit comments

Comments
 (0)