@stratum-hq/hono 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,15 +10,28 @@ npm install @stratum-hq/hono @stratum-hq/sdk @stratum-hq/core hono
10
10
 
11
11
  ## Quick Start
12
12
 
13
+ Read the tenant from a claim of a verified JWT:
14
+
13
15
  ```typescript
14
16
  import { Hono } from "hono";
17
+ import { jwt } from "hono/jwt";
15
18
  import { stratumMiddleware } from "@stratum-hq/hono";
16
19
  import { getTenantContext } from "@stratum-hq/sdk";
17
20
 
18
21
  const app = new Hono();
19
22
 
23
+ const jwtSecret = process.env.JWT_SECRET;
24
+ if (!jwtSecret) {
25
+ throw new Error("JWT_SECRET is required to verify bearer tokens.");
26
+ }
27
+
28
+ // hono/jwt rejects a missing or invalid bearer token with 401 and stores the
29
+ // verified claims as jwtPayload. Register it before stratumMiddleware.
30
+ app.use("*", jwt({ secret: jwtSecret, alg: "HS256" }));
31
+
20
32
  app.use("*", stratumMiddleware({
21
- header: "x-tenant-id",
33
+ // Read the tenant from the verified `tenant_id` claim
34
+ jwtClaim: "tenant_id",
22
35
  // Optional: fetch the full tenant context (ancestry, config, permissions)
23
36
  resolve: async (tenantId) => sdkClient.resolveTenant(tenantId),
24
37
  }));
@@ -30,6 +43,16 @@ app.get("/users", (c) => {
30
43
  });
31
44
  ```
32
45
 
46
+ `stratumMiddleware` does not verify a token itself; `jwtClaim` reads the `jwtPayload` that Hono's JWT middleware sets.
47
+
48
+ ### Client-controlled sources
49
+
50
+ `pathParam` and `header` both take the tenant ID from the request as the client sent it, so any client can name another tenant. Use them only when something you control authorizes the caller for that tenant (for example a later middleware that compares it with a claim of the verified token). Header mode additionally requires `trustTenantHeader: true`, which is appropriate only when a gateway you control sets the header, removes any client copy, and is the only way to reach the server:
51
+
52
+ ```typescript
53
+ app.use("*", stratumMiddleware({ header: "x-tenant-id", trustTenantHeader: true }));
54
+ ```
55
+
33
56
  ## Options
34
57
 
35
58
  `stratumMiddleware(options)` extracts the tenant ID from exactly one source, in this precedence:
@@ -37,15 +60,16 @@ app.get("/users", (c) => {
37
60
  | Option | Behavior |
38
61
  |--------|----------|
39
62
  | `jwtClaim` | Read the claim from Hono's `jwtPayload` context variable |
40
- | `pathParam` | Read a URL path parameter (`c.req.param(name)`) |
41
- | `header` | Read a request header (default: `x-tenant-id`) |
63
+ | `pathParam` | Read a URL path parameter (`c.req.param(name)`). Client-controlled: authorize the caller for that tenant separately |
64
+ | `header` | Read a request header (default: `x-tenant-id`). Requires `trustTenantHeader: true` |
65
+ | `trustTenantHeader` | Allow header mode. Without it, and without `jwtClaim` or `pathParam`, `stratumMiddleware` throws at construction (default: `false`) |
42
66
  | `resolve` | Optional callback `(tenantId) => TenantContext` to populate ancestry, config, and permissions |
43
67
 
44
- If no tenant ID is found, the middleware responds with `400 { error: "Missing tenant ID" }`. Without a `resolve` callback the context is a placeholder (empty config/permissions) — provide `resolve` for real tenant data.
68
+ If no tenant ID is found, the middleware responds with `400 { error: "Missing tenant ID" }`. If `resolve` rejects with a tenant error from `@stratum-hq/core`, for example from `StratumClient.resolveTenant`, the middleware responds with 404 `TENANT_NOT_FOUND`, 403 `TENANT_SUSPENDED`, 410 `TENANT_ARCHIVED`, or 403 `FORBIDDEN`. A control plane timeout gets 504 `CONTROL_PLANE_TIMEOUT`. An `UnauthorizedError` for the SDK's own API key gets 500 `CONTROL_PLANE_AUTH_FAILED` and a `console.error` line. Other errors go to the Hono error handler. Without a `resolve` callback the context is a placeholder (empty config/permissions) — provide `resolve` for real tenant data.
45
69
 
46
70
  ## Features
47
71
 
48
- - Tenant extraction from header, JWT claim, or path parameter
72
+ - Tenant extraction from a verified JWT claim, a path parameter, or a trusted-gateway header
49
73
  - Binds tenant context via `runWithTenantContext` so downstream handlers can call `getTenantContext()`
50
74
  - Lightweight — structural types only, no heavy dependencies
51
75
 
@@ -7,10 +7,24 @@ export interface StratumMiddlewareOptions {
7
7
  jwtClaim?: string;
8
8
  /** URL path parameter name to extract tenant ID from */
9
9
  pathParam?: string;
10
+ /**
11
+ * Allow reading the tenant ID from a request header. A client can set any
12
+ * header, so this must be enabled explicitly, and only when a gateway you
13
+ * control sets the header and strips any client-sent copy. Without it,
14
+ * `stratumMiddleware` throws at construction unless `jwtClaim` or
15
+ * `pathParam` is set. Default: false.
16
+ */
17
+ trustTenantHeader?: boolean;
10
18
  /**
11
19
  * Optional callback to resolve a full tenant context from the tenant ID.
12
20
  * When provided, the middleware will call this to obtain ancestry, config,
13
21
  * and permissions instead of using placeholder values.
22
+ *
23
+ * If the callback rejects with a tenant error from `@stratum-hq/core`, for
24
+ * example from `StratumClient.resolveTenant`, the middleware answers 404,
25
+ * 403 or 410. A control plane timeout answers 504. A rejected SDK API key
26
+ * answers 500 and writes the cause to `console.error`. Other errors go to
27
+ * the Hono error handler.
14
28
  */
15
29
  resolve?: (tenantId: string) => Promise<ResolvedTenantContext> | ResolvedTenantContext;
16
30
  }
@@ -1 +1 @@
1
- {"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../src/middleware.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAiB,iBAAiB,EAAE,MAAM,MAAM,CAAC;AAE7D,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,kBAAkB,CAAC;AAG9D,MAAM,WAAW,wBAAwB;IACvC,qEAAqE;IACrE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,+CAA+C;IAC/C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,wDAAwD;IACxD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,qBAAqB,CAAC,GAAG,qBAAqB,CAAC;CACxF;AAkBD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,GAAE,wBAA6B,GACrC,iBAAiB,CAqCnB"}
1
+ {"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../src/middleware.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAiB,iBAAiB,EAAE,MAAM,MAAM,CAAC;AAG7D,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,kBAAkB,CAAC;AAG9D,MAAM,WAAW,wBAAwB;IACvC,qEAAqE;IACrE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,+CAA+C;IAC/C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,wDAAwD;IACxD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;OAMG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B;;;;;;;;;;OAUG;IACH,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,qBAAqB,CAAC,GAAG,qBAAqB,CAAC;CACxF;AAkBD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,GAAE,wBAA6B,GACrC,iBAAiB,CAmDnB"}
@@ -1,4 +1,4 @@
1
- import { runWithTenantContext } from "@stratum-hq/sdk";
1
+ import { controlPlaneErrorResponse, runWithTenantContext, tenantErrorResponse } from "@stratum-hq/sdk";
2
2
  import { IsolationStrategy } from "@stratum-hq/core";
3
3
  function extractFromHeader(c, header) {
4
4
  return c.req.header(header) ?? undefined;
@@ -20,6 +20,10 @@ function extractFromPathParam(c, param) {
20
20
  * AsyncLocalStorage context via `runWithTenantContext()`.
21
21
  */
22
22
  export function stratumMiddleware(options = {}) {
23
+ if (!options.jwtClaim && !options.pathParam && options.trustTenantHeader !== true) {
24
+ throw new Error("[stratum] stratumMiddleware would read the tenant ID from an unverified request header. " +
25
+ "Use jwtClaim with a verified JWT, or set trustTenantHeader: true if a trusted gateway sets the header.");
26
+ }
23
27
  return async (c, next) => {
24
28
  let tenantId;
25
29
  if (options.jwtClaim) {
@@ -36,21 +40,30 @@ export function stratumMiddleware(options = {}) {
36
40
  return c.json({ error: "Missing tenant ID" }, 400);
37
41
  }
38
42
  c.set("tenantId", tenantId);
39
- const ctx = options.resolve
40
- ? await options.resolve(tenantId)
41
- : /**
42
- * @warning Placeholder context — ancestry_path, resolved_config, and
43
- * resolved_permissions are stub values. Provide a `resolve` callback
44
- * to populate real tenant data.
45
- */
46
- {
47
- tenant_id: tenantId,
48
- ancestry_path: tenantId,
49
- depth: 0,
50
- resolved_config: {},
51
- resolved_permissions: {},
52
- isolation_strategy: IsolationStrategy.SHARED_RLS,
53
- };
43
+ let ctx;
44
+ try {
45
+ ctx = options.resolve
46
+ ? await options.resolve(tenantId)
47
+ : /**
48
+ * @warning Placeholder context — ancestry_path, resolved_config, and
49
+ * resolved_permissions are stub values. Provide a `resolve` callback
50
+ * to populate real tenant data.
51
+ */
52
+ {
53
+ tenant_id: tenantId,
54
+ ancestry_path: tenantId,
55
+ depth: 0,
56
+ resolved_config: {},
57
+ resolved_permissions: {},
58
+ isolation_strategy: IsolationStrategy.SHARED_RLS,
59
+ };
60
+ }
61
+ catch (err) {
62
+ const response = tenantErrorResponse(err, tenantId) ?? controlPlaneErrorResponse(err);
63
+ if (!response)
64
+ throw err;
65
+ return c.json(response.body, response.status);
66
+ }
54
67
  return runWithTenantContext(ctx, () => next());
55
68
  };
56
69
  }
@@ -1 +1 @@
1
- {"version":3,"file":"middleware.js","sourceRoot":"","sources":["../src/middleware.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AAEvD,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAiBrD,SAAS,iBAAiB,CAAC,CAAU,EAAE,MAAc;IACnD,OAAO,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,SAAS,CAAC;AAC3C,CAAC;AAED,SAAS,mBAAmB,CAAC,CAAU,EAAE,KAAa;IACpD,8EAA8E;IAC9E,MAAM,OAAO,GAAG,CAAC,CAAC,GAAG,CAAC,YAAY,CAAwC,CAAC;IAC3E,IAAI,CAAC,OAAO;QAAE,OAAO,SAAS,CAAC;IAC/B,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC;IAC7B,OAAO,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC;AACvD,CAAC;AAED,SAAS,oBAAoB,CAAC,CAAU,EAAE,KAAa;IACrD,OAAO,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,IAAI,SAAS,CAAC;AACzC,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,iBAAiB,CAC/B,UAAoC,EAAE;IAEtC,OAAO,KAAK,EAAE,CAAU,EAAE,IAAU,EAAE,EAAE;QACtC,IAAI,QAA4B,CAAC;QAEjC,IAAI,OAAO,CAAC,QAAQ,EAAE,CAAC;YACrB,QAAQ,GAAG,mBAAmB,CAAC,CAAC,EAAE,OAAO,CAAC,QAAQ,CAAC,CAAC;QACtD,CAAC;aAAM,IAAI,OAAO,CAAC,SAAS,EAAE,CAAC;YAC7B,QAAQ,GAAG,oBAAoB,CAAC,CAAC,EAAE,OAAO,CAAC,SAAS,CAAC,CAAC;QACxD,CAAC;aAAM,CAAC;YACN,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,IAAI,aAAa,CAAC;YAC/C,QAAQ,GAAG,iBAAiB,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC;QAC1C,CAAC;QAED,IAAI,CAAC,QAAQ,EAAE,CAAC;YACd,OAAO,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,mBAAmB,EAAE,EAAE,GAAG,CAAC,CAAC;QACrD,CAAC;QAED,CAAC,CAAC,GAAG,CAAC,UAAU,EAAE,QAAQ,CAAC,CAAC;QAE5B,MAAM,GAAG,GAA0B,OAAO,CAAC,OAAO;YAChD,CAAC,CAAC,MAAM,OAAO,CAAC,OAAO,CAAC,QAAQ,CAAC;YACjC,CAAC,CAAC;;;;iBAIG;gBACH;oBACE,SAAS,EAAE,QAAQ;oBACnB,aAAa,EAAE,QAAQ;oBACvB,KAAK,EAAE,CAAC;oBACR,eAAe,EAAE,EAAE;oBACnB,oBAAoB,EAAE,EAAE;oBACxB,kBAAkB,EAAE,iBAAiB,CAAC,UAAU;iBACjD,CAAC;QAEN,OAAO,oBAAoB,CAAC,GAAG,EAAE,GAAG,EAAE,CAAC,IAAI,EAAE,CAAC,CAAC;IACjD,CAAC,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"middleware.js","sourceRoot":"","sources":["../src/middleware.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stratum-hq/hono",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Stratum Hono integration — tenant extraction middleware with ALS context",
5
5
  "keywords": [
6
6
  "multi-tenancy",
@@ -38,16 +38,16 @@
38
38
  "registry": "https://registry.npmjs.org/"
39
39
  },
40
40
  "peerDependencies": {
41
- "@stratum-hq/core": ">=1.0.0",
42
- "@stratum-hq/sdk": ">=1.0.0",
41
+ "@stratum-hq/core": "^1.0.0",
42
+ "@stratum-hq/sdk": "^1.2.0",
43
43
  "hono": ">=4.0.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@stratum-hq/core": "*",
47
47
  "@stratum-hq/sdk": "*",
48
- "hono": "^4.0.0",
48
+ "hono": "^4.13.9",
49
49
  "typescript": "^5.4.0",
50
- "vitest": "^1.6.0"
50
+ "vitest": "^4.1.11"
51
51
  },
52
52
  "license": "MIT",
53
53
  "author": "Christian Crank",