hydro-plugin-handler — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited hydro-plugin-handler (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
This skill covers how to handle HTTP requests and WebSocket connections in Hydro plugins: Handler lifecycle, route registration, parameter decorators, response building, the operation mechanism, and WebSocket handlers.
ctx.Route(name: string, path: string, HandlerClass: typeof Handler, ...permPrivChecker);| Param | Type | Description |
|---|---|---|
name | string | Route name, used in url('route_name', { key: val }) to generate URLs |
path | string | URL path pattern, supports :param placeholders (e.g. /blog/:uid/:did) |
HandlerClass | class extends Handler | The handler class |
...permPrivChecker | PERM / PRIV / Function / arrays | Permission checks applied at route level |
// Single permission (bitwise bigint)
ctx.Route('my_route', '/my', MyHandler, PERM.PERM_VIEW_PROBLEM);
// Single privilege (number)
ctx.Route('admin_route', '/admin', AdminHandler, PRIV.PRIV_EDIT_SYSTEM);
// Multiple: PERM array, PRIV array, or combined
ctx.Route('my_route', '/my', MyHandler, PERM.PERM_VIEW_PROBLEM, PRIV.PRIV_USER_PROFILE);
// Custom checker function
ctx.Route('my_route', '/my', MyHandler, (this: Handler) => {
if (!this.user.someCondition) throw new ForbiddenError();
});The route name is derived from the Handler class name with Handler suffix removed. For example ContestDetailHandler → contest_detail. The name is used:
{{ url('contest_detail', { tid: tdoc._id }) }}this.url('contest_detail', { tid })data-page attribute on <html> for frontend page matchingWhen an HTTP request arrives, WebService.handleHttp() executes the following steps in order. Each step is either a handler method call, an event hook, or a log marker.
Step Type Description
──────────────────────────────────────────────────────────────────
log/__init log Record init timestamp
init method CSRF protection, basic setup
handler/init event ctx.serial('handler/init', h)
handler/before-prepare/* event ctx.serial('handler/before-prepare/${name}#${method}', h)
event ctx.serial('handler/before-prepare/${name}', h)
event ctx.serial('handler/before-prepare', h)
log/__prepare log Record prepare start
__prepare method Base class data loading (lowest layer)
_prepare method Intermediate data loading
prepare method Final pre-check / business setup
log/__prepareDone log Record prepare end
handler/before/* event ctx.serial('handler/before/${name}#${method}', h)
event ctx.serial('handler/before/${name}', h)
event ctx.serial('handler/before', h)
log/__method log Record method start
all method Runs for ALL HTTP methods
method method 'get', 'post', 'put', 'delete', etc.
log/__methodDone log Record method end
[post${operation}] method Only for POST with body.operation
log/__operationDone log Record operation end
after method Post-method cleanup / UI setup
handler/after/* event ctx.serial('handler/after/${name}#${method}', h)
event ctx.serial('handler/after/${name}', h)
event ctx.serial('handler/after', h)
cleanup method Final cleanup (always runs)
handler/finish/* event ctx.serial('handler/finish/${name}#${method}', h)
event ctx.serial('handler/finish/${name}', h)
event ctx.serial('handler/finish', h)
log/__finish log Record finish timestamp handler/error/${name} event ctx.serial('handler/error/${name}', h, e)
handler/error event ctx.serial('handler/error', h, e)
onerror method h.onerror(e) → renders error pageAny handler method can return a step name to jump to that step:
async prepare() {
if (!this.user.hasPriv(PRIV.PRIV_USER_PROFILE)) {
this.response.redirect = '/login';
return 'after'; // Skip method execution, jump to 'after'
}
}Hydro uses an inheritance-based convention for the prepare chain:
| Method | Layer | Purpose | Example |
|---|---|---|---|
__prepare | Base class | Load core shared data | ContestDetailBaseHandler.__prepare loads tdoc/tsdoc |
_prepare | Intermediate | Load related data, preprocess params | ProblemHandler._prepare loads pdoc by pid |
prepare | Final subclass | Permission checks, business-specific setup | ContestDetailHandler.prepare checks if contest is hidden |
This allows subclass handlers to inherit data loading from parent classes without repeating code.
class MyHandler extends Handler {
@param('id', Types.ObjectId)
@param('page', Types.PositiveInt, true)
async get(domainId: string, id: ObjectId, page = 1) {
const data = await MyModel.get(domainId, id);
this.response.template = 'my_detail.html';
this.response.body = { data, page };
}
}class MyHandler extends Handler {
@param('title', Types.Title)
@param('content', Types.Content)
async post(domainId: string, title: string, content: string) {
const id = await MyModel.create(domainId, title, content);
this.response.redirect = this.url('my_detail', { id });
}
}When POST body contains an { "operation": "star" } field, the framework converts it to a method name:
operation: "star" → calls postStar()operation: "delete_item" → calls postDeleteItem() (snake_case → camelCase)class BlogDetailHandler extends Handler {
// GET request
@param('did', Types.ObjectId)
async get(domainId: string, did: ObjectId) {
this.response.template = 'blog_detail.html';
this.response.body = { ddoc: await BlogModel.get(did) };
}
// POST without operation → post()
async post() {
this.checkPriv(PRIV.PRIV_USER_PROFILE);
}
// POST with operation=star → postStar()
@param('did', Types.ObjectId)
async postStar(domainId: string, did: ObjectId) {
await BlogModel.setStar(did, this.user._id, true);
this.back({ star: true });
}
// POST with operation=unstar → postUnstar()
@param('did', Types.ObjectId)
async postUnstar(domainId: string, did: ObjectId) {
await BlogModel.setStar(did, this.user._id, false);
this.back({ star: false });
}
}all() methodRuns before the specific HTTP method. Useful for shared logic:
class MyHandler extends Handler {
async all() {
// Runs for GET, POST, PUT, DELETE, etc.
this.response.body = { sharedData: '...' };
}
async get() {
// GET-specific logic, can use this.response.body from all()
}
}Decorators extract and validate parameters from different parts of the request.
| Decorator | Source | Example |
|---|---|---|
@param(name, Type, ...) | GET query + POST body + route params | General purpose |
@get(name, Type, ...) | URL query string only (?key=val) | GET parameters |
@post(name, Type, ...) | Request body only | POST form/JSON |
@route(name, Type, ...) | URL path parameters (/:id) | Path params |
@query(name, Type, ...) | Alias for @get | — |
import { Types } from 'hydrooj';
// Also: any Schemastery Schema can be used as a type
Types.String // string, non-empty
Types.Int // integer
Types.PositiveInt // positive integer (> 0)
Types.Float // float number
Types.Boolean // boolean
Types.ObjectId // MongoDB ObjectId (24-char hex string)
Types.Title // string, length 1-256
Types.Content // string, non-empty
Types.Cuid // string, valid CUID
Types.Array // array
Types.UnsignedInt // integer >= 0
Types.Range // number range string like "1-100"
Types.PositiveFloat // float > 0
// Schema as type (validates AND converts)
@param('config', Schema.object({
name: Schema.string(),
value: Schema.number(),
}))// Third argument = true → optional (undefined if missing)
@param('page', Types.PositiveInt, true)
async get(domainId: string, page = 1) { }
// Optional with convert: third arg = 'convert'
// Will pass through undefined but still apply convert if present
@param('filter', Types.String, 'convert')// Fourth positional arg = validator function
@param('age', Types.Int, false, (v) => v >= 0 && v <= 150)
// Custom convert function
@param('ids', Types.String, false, null, (v) => v.split(',').map(Number))
// Full form: @param(name, type, isOptional, validator, converter)domainId is always the first parameter and is automatically injected from the request context. It does NOT come from the decorator source — it's extracted from the route or the session.
@param('id', Types.ObjectId)
async get(domainId: string, id: ObjectId) {
// domainId is auto-injected, not from @param
// id comes from the decorated source
}When the first argument name starts with domainId (case-insensitive), the framework detects this and passes domainId as the first argument automatically.
@requireSudoThe @requireSudo decorator protects sensitive operations by requiring the user to re-authenticate (similar to Linux sudo). This prevents accidental destructive actions when a superadmin is logged in with "remember password" enabled.
When applied to a handler method:
this.session.sudo exists and is less than 1 hour oldsession.sudoArgs, redirects to /user/sudo for password/2FA re-verificationimport { requireSudo, Handler, param, Types, ObjectId } from 'hydrooj';
class AdminSettingsHandler extends Handler {
@param('key', Types.String)
@param('value', Types.String)
@requireSudo
async postUpdate(domainId: string, key: string, value: string) {
// User must have re-authenticated within the last hour
await SystemModel.set(key, value);
this.back();
}
}@requireSudoIMPORTANT: Teachers using superadmin accounts in classrooms is a key security concern. Always use @requireSudo for operations that could cause serious damage if an unauthorized person triggers them from a remembered session.
// After successful sudo, the session stores:
session.sudo = Date.now(); // Timestamp of sudo verification
session.sudoArgs = { // Original request state saved before redirect
method: 'post',
referer: request.headers.referer,
args: this.args,
redirect: request.originalPath,
};
// Sudo session expires after 1 hour (Time.hour)
// The stored referer is restored after sudo completesthis.response.template = 'my_page.html';
this.response.body = { title: 'Hello', items: [...] };
// Nunjucks renders my_page.html with the body as template contextIMPORTANT — `request.json` behavior: If the request has Accept: application/json header, the template is NOT rendered — this.response.body is returned as JSON instead. This is controlled by request.json:
// Set in framework/framework/base.ts:
json: (ctx.request.headers.accept || '').includes('application/json'),When request.json is true:
response.body is serialized to JSON (response.type = 'application/json')response.redirect is also skipped (redirect URL is included in JSON body as url field){ error: ... }This means the same handler/route serves both HTML and JSON based on the Accept header:
// In handler code, check this.request.json:
if (this.request.json) {
// API call — skip template-only logic
return;
}
// Real example from contest handler:
async get(domainId: string, tid: ObjectId) {
this.response.template = 'contest_detail.html';
this.response.body = { tdoc, tsdoc };
if (this.request.json) return; // Skip template-only content processing
this.response.body.tdoc.content = /* process content */;
}this.response.body = { success: true, data: [...] };
// No template set → automatically returns JSON// Redirect to a named route
this.response.redirect = this.url('my_detail', { id });
// Redirect to a literal URL
this.response.redirect = '/some/path';this.back({ star: true }); // Sets body + redirects to request referer or '/'this.binary(buffer, 'report.pdf'); // Sets Content-Disposition headerthis.response.status = 201; // HTTP status code
this.response.type = 'text/plain'; // Content-Typethis.response.set('X-Custom-Header', 'value');| Property | Type | Description |
|---|---|---|
this.response.body | any | Response data (object for JSON, template context for HTML) |
this.response.template | string | Nunjucks template name |
this.response.redirect | string | Redirect URL |
this.response.status | number | HTTP status code |
this.response.type | string | Content-Type |
Every handler has access to these built-in properties:
| Property | Type | Description |
|---|---|---|
this.user | User | Current logged-in user (or guest) |
this.domain | DomainDoc | Current domain |
this.request | KoaRequest | Koa request object |
this.response | KoaResponse | Koa response object |
this.args | Record<string, any> | All parsed arguments + timing data |
this.session | Session | Session data |
this.ctx | Context | Cordis context (access services) |
// Permission checks
this.checkPerm(PERM.PERM_EDIT_PROBLEM); // Throws ForbiddenError if no permission
this.checkPriv(PRIV.PRIV_EDIT_SYSTEM); // Throws if no privilege
this.checkPriv(PRIV.PRIV_USER_PROFILE); // Common: must be logged in
// Rate limiting
this.limitRate('add_blog', 3600, 60); // (key, period_seconds, max_attempts)
// URL generation
this.url('route_name', { key: val }); // Generate URL from route name + params
// Translation
this.translate('key'); // i18n translateafter() MethodThe after() method runs after the main HTTP method handler and before the handler/after event hooks. It's commonly used for post-processing the response.
Real example from ContestDetailBaseHandler:
@param('tid', Types.ObjectId, true)
async after(domainId: string, tid: ObjectId) {
if (!tid || this.tdoc.rule === 'homework') return;
if (this.request.json || !this.response.template) return;
// Set breadcrumb navigation after the page is loaded
this.response.body.overrideNav = [
{ name: 'contest_main', args: {}, displayName: 'Back to contest list', checker: () => true },
{ name: 'contest_detail', displayName: this.tdoc.title, args: { tid }, checker: () => true },
{ name: 'contest_scoreboard', args: { tid, prefix: 'contest_scoreboard' },
checker: () => contest.canShowScoreboard.call(this, this.tdoc, true) },
];
}WebSocket connections use ConnectionHandler instead of Handler.
import { ConnectionHandler, subscribe } from 'hydrooj';
class MyWsHandler extends ConnectionHandler<Context> {
@subscribe('my-channel')
async send(channel: string) {
// this.send(data) — send data to client
// this.close() — close connection
// this.conn — the connection object
}
async prepare() {
// Optional: setup before subscribing
}
async message(payload: any) {
// Handle incoming messages from client
}
async cleanup() {
// Cleanup when connection closes
}
}ctx.Connection('my_ws', '/ws/my-channel', MyWsHandler);// Listen for new subscriptions
ctx.on('subscription/enable', (channel, h, privileged, onDispose) => {
// h is the ConnectionHandler instance
// onDispose(callback) registers cleanup for this subscription
onDispose(() => { /* cleanup */ });
});handler/create events fire_prepare() → prepare() called@subscribe decoratormessage() handler receives incoming messagescleanup() runs on disconnect/errorConnectionHandler also supports SSE when accessed via HTTP (not WebSocket upgrade):
// SSE auto-detected when client connects via HTTP but handler is ConnectionHandler
// this.conn.send(data) works for both WebSocket and SSEHandlers support class inheritance for sharing logic:
// Base handler: loads shared data
class ContestDetailBaseHandler extends Handler {
tdoc?: Tdoc;
tsdoc?: any;
@param('tid', Types.ObjectId, true)
async __prepare(domainId: string, tid: ObjectId) {
if (!tid) return;
[this.tdoc, this.tsdoc] = await Promise.all([
contest.get(domainId, tid),
contest.getStatus(domainId, tid, this.user._id),
]);
}
}
// Sub handler: adds specific logic
class ContestScoreboardHandler extends ContestDetailBaseHandler {
@param('tid', Types.ObjectId)
async prepare(domainId: string, tid: ObjectId) {
// tdoc already loaded by __prepare
if (!contest.canShowScoreboard.call(this, this.tdoc, true)) {
throw new ContestNotLiveError(tid);
}
}
@param('tid', Types.ObjectId)
async get(domainId: string, tid: ObjectId) {
this.response.template = 'contest_scoreboard.html';
this.response.body = { tdoc: this.tdoc };
}
}When using inheritance, the three prepare layers map naturally:
__prepare in the base class — load shared data (e.g., tdoc)_prepare in intermediate class — load secondary data (e.g., pdoc)prepare in the leaf class — permission checks, final setupEach layer uses @param decorators independently. The framework collects all @param decorators across the inheritance chain for each method name.
class ItemHandler extends Handler {
item?: ItemDoc;
@param('id', Types.ObjectId, true)
async _prepare(domainId: string, id?: ObjectId) {
if (id) {
this.item = await ItemModel.get(domainId, id);
if (!this.item) throw new NotFoundError(id);
}
}
// List items
@param('page', Types.PositiveInt, true)
async get(domainId: string, page = 1) {
const [items, count] = await this.ctx.db.paginate(
ItemModel.getMulti(domainId), page, 20,
);
this.response.template = 'item_list.html';
this.response.body = { items, count, page };
}
// Create item (POST without operation)
@param('title', Types.Title)
@param('content', Types.Content)
async post(domainId: string, title: string, content: string) {
this.checkPerm(PERM.PERM_CREATE_PROBLEM);
await this.limitRate('create_item', 60, 10);
const id = await ItemModel.add(domainId, title, content, this.user._id);
this.response.redirect = this.url('item_detail', { id });
}
// Edit (POST with operation=edit)
@param('id', Types.ObjectId)
@param('title', Types.Title)
async postEdit(domainId: string, id: ObjectId, title: string) {
if (!this.user.own(this.item!)) this.checkPerm(PERM.PERM_EDIT_PROBLEM);
await ItemModel.edit(domainId, id, { title });
this.back();
}
// Delete (POST with operation=delete)
@param('id', Types.ObjectId)
async postDelete(domainId: string, id: ObjectId) {
if (!this.user.own(this.item!)) this.checkPerm(PERM.PERM_EDIT_PROBLEM);
await ItemModel.del(domainId, id);
this.response.redirect = this.url('item_list');
}
}
// Register once, handles GET + POST + operations
ctx.Route('item_list', '/item', ItemHandler);
ctx.Route('item_detail', '/item/:id', ItemHandler);class ApiHandler extends Handler {
@param('query', Types.String)
async get(domainId: string, query: string) {
const results = await searchItems(domainId, query);
this.response.body = { results }; // No template = JSON response
}
}~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.