HTTP Routes
Expose your own HTTP endpoints from a plugin.
ctx.http registers routes on the Sharkord HTTP server, under your plugin's namespace. Use them for webhooks, callbacks from external services, and anything that needs a plain URL.
Register a Route
import { Permission, type PluginContext } from "@sharkord/plugin-sdk";
const onLoad = (ctx: PluginContext) => {
// public: a webhook sender has no Sharkord token to present
ctx.http.post("/webhook", async (req, res) => {
res.writeHead(204);
res.end();
});
// any signed-in user
ctx.http.get("/preferences", async (req, res, { userId }) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(await load(userId)));
}, { auth: true });
// a signed-in user holding a permission
ctx.http.post("/purge", handler, { requires: Permission.MANAGE_MESSAGES });
};There is a method per verb (get, post, patch, delete, options), plus ctx.http.register(method, path, handler, options) if the verb is dynamic.
Handlers get Node's raw IncomingMessage and ServerResponse, plus a context object. Nothing is parsed and nothing is written for you: body parsing, route params and content types are yours to do, and your handler owns the response.
Authentication
A route is public unless its options say otherwise, because a webhook receiver has to be.
| Options | Who gets through |
|---|---|
| none | Anyone |
{ auth: true } | Any signed-in user |
{ requires: Permission } | A signed-in user holding that permission |
The host resolves the caller before your handler runs and answers 401 or 403 itself, so there is nothing to forget. The handler's third argument then carries { userId }, which is absent on a public route.
Routes are capabilities like commands and actions, so an admin can restrict one to roles from the plugin's permissions tab. It is named there by method and path, GET /hello.
That override is not symmetric, and the asymmetry is deliberate:
- Restricting a public route makes it authenticated, whatever the plugin declared. There is no caller to match against roles otherwise.
- Opening a route up cannot remove
auth. A handler that was written expecting auserIdkeeps getting one, so an admin cannot break it by widening access.
So auth is yours and stays yours, while requires is a default an admin can move in either direction.
Tokens are accepted as Authorization: Bearer <token> or the x-token header. From your own plugin UI, call the route through the store instead of handling tokens yourself:
import { actions } from "@sharkord/plugin-sdk/client";
const response = await actions.fetchPluginRoute("my-plugin", "/preferences");Uploads
The body is yours to read, so a file arrives as a stream you can write straight to disk:
ctx.http.post("/sounds", async (req, res, { userId }) => {
const filePath = path.join(ctx.dataPath, `${userId}-${Date.now()}.audio`);
await pipeline(req, fs.createWriteStream(filePath));
res.writeHead(201);
res.end();
}, { auth: true });The host sets no size limit on a plugin route's body, so count the bytes as they arrive and stop a body that is larger than you accept.
From your plugin UI, send the file with fetchPluginRoute, which adds the user's token so the handler gets their userId:
import { actions } from "@sharkord/plugin-sdk/client";
const upload = async (file: File) => {
const response = await actions.fetchPluginRoute("my-plugin", "/sounds", {
method: "POST",
headers: { "Content-Type": file.type },
body: file,
});
if (!response.ok) throw new Error(await response.text());
};Pass headers as a plain object: the token is merged in by spreading it, so a Headers instance loses its entries. Send the raw file as the body rather than FormData, unless you want to parse multipart yourself, and pass anything else you need, such as the file name, in a header or the query string.
URLs
A route registered as /status is served at:
https://your-server/plugins/<plugin-id>/statusRules for paths:
- Must start with
/. - Trailing slashes are ignored, so
/statusand/status/are the same route. *is allowed only as the final segment:/api/*matches everything below/api/. The most specific wildcard wins, so/api/v1/*beats/api/*.- Registering the same method and path twice replaces the first handler and logs an error.
- A plugin can register at most 100 routes.
Routes are removed when the plugin is disabled or unloaded, and requests to a plugin that is not loaded get a 404.
Notes
- Every plugin route shares one rate limit, 300 requests per minute per IP by default (
rateLimiters.pluginRouteinconfig.ini). Webhooks count against it too. - Requests are logged like any other HTTP request when
debugis on. - A handler that throws after it started writing drops the connection; one that throws before gets a generic 500.
- CORS headers are applied by the server according to
allowedOriginsinconfig.ini, so browsers on other origins may be blocked before your handler runs. - A public route is reachable by anyone on the internet who can guess the URL. If it does something that matters, verify a shared secret or a signature from the service calling you. See Security.