npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@molecule/api-resource-grade

v1.0.1

Published

Per-student per-course assignment grades + GPA computation + transcript aggregation

Readme

@molecule/api-resource-grade

Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit src/index.ts JSDoc, not this file.

Grade resource for molecule.dev.

Stores per-student per-course assignment grades and computes course averages, GPAs, and transcripts. Letter-grade resolution is driven by an injected {@link GradeScale} so different institutions can use different scales (4.0, 4.3, plus/minus, etc.). All user-facing text is i18n-ready via the companion @molecule/api-locales-resource-grade bond.

Quick Start

import {
  routes,
  requestHandlerMap,
  getCourseAverage,
  getGpa,
  getTranscript,
  defaultGradeScale,
} from '@molecule/api-resource-grade'

Type

resource

Installation

npm install @molecule/api-resource-grade @molecule/api-database @molecule/api-i18n @molecule/api-locales-resource-grade @molecule/api-logger @molecule/api-permissions @molecule/api-resource

API

Interfaces

CourseAverage

Course-level average for a student.

interface CourseAverage {
  /** Enrollment whose grades were aggregated. */
  enrollmentId: string
  /** Student. */
  userId: string
  /** Course. */
  courseId: string
  /** Total earned points across all grades. */
  earnedPoints: number
  /** Total possible points across all grades. */
  possiblePoints: number
  /** Average as a percentage 0–100, or null if `possiblePoints === 0`. */
  averagePercent: number | null
  /** Optional letter, derived if a scale was supplied. */
  letter: string | null
  /** Number of grades aggregated. */
  gradeCount: number
}

Gpa

GPA computation for a student across all courses.

interface Gpa {
  /** Student. */
  userId: string
  /** Weighted GPA on the supplied scale (4.0 default). */
  gpa: number
  /** Number of courses contributing to the GPA. */
  courseCount: number
}

Grade

A single graded assignment for a student in a course.

enrollmentId joins the student to the course; assignmentId identifies the graded artifact. letter is optional and is derived from the score percentage via a configurable {@link GradeScale}.

interface Grade {
  /** Unique identifier. */
  id: string
  /** Foreign key to the student-course enrollment. */
  enrollmentId: string
  /** Foreign key to the assignment being graded. */
  assignmentId: string
  /** Foreign key to the student (denormalised for fast GPA / transcript queries). */
  userId: string
  /** Foreign key to the course (denormalised for fast course-average queries). */
  courseId: string
  /** Number of points the student earned. */
  scorePoints: number
  /** Maximum points possible on this assignment. */
  maxPoints: number
  /** Optional letter-grade label (A, B+, etc.) — derived via the active scale. */
  letter: string | null
  /** Optional teacher comment. */
  comment: string | null
  /** ISO 8601 posting timestamp. */
  postedAt: string
  /** ISO 8601 creation timestamp. */
  createdAt: string
  /** ISO 8601 last-updated timestamp. */
  updatedAt: string
}

GradeScale

A configurable letter-grade scale.

Different institutions use different scales (4.0 vs 4.3, plus/minus, etc.). The scale is injected per call rather than baked in.

interface GradeScale {
  /** Human-readable name, e.g. "US 4.0 plus/minus". */
  name: string
  /** Rungs ordered however the caller likes — the resolver sorts them. */
  rungs: GradeScaleRung[]
}

GradeScaleRung

A single rung of a letter-grade scale.

minPercent is inclusive: a score percentage p matches the rung iff p >= minPercent. The first rung whose threshold is met (scanning highest-to-lowest) wins. gpaPoints is the GPA contribution for the rung (typically 0–4 on a 4.0 scale).

interface GradeScaleRung {
  /** Letter label, e.g. "A", "B+", "F". */
  letter: string
  /** Inclusive lower bound on score percentage, 0–100. */
  minPercent: number
  /** GPA contribution for this rung. */
  gpaPoints: number
}

PostGradeInput

Input for posting a new grade.

interface PostGradeInput {
  /** Foreign key to the student-course enrollment. */
  enrollmentId: string
  /** Foreign key to the assignment being graded. */
  assignmentId: string
  /** Foreign key to the student. */
  userId: string
  /** Foreign key to the course. */
  courseId: string
  /** Points earned. */
  scorePoints: number
  /** Maximum possible points. */
  maxPoints: number
  /** Optional teacher comment. */
  comment?: string | null
  /** Optional letter-grade scale. If supplied, `letter` is derived. */
  scale?: GradeScale
}

Transcript

Full transcript for a student.

interface Transcript {
  /** Student. */
  userId: string
  /** One line per course. */
  lines: TranscriptLine[]
  /** GPA across all courses (only present if a scale was supplied). */
  gpa: number | null
}

TranscriptLine

Transcript line: one row per course the student has graded work in.

interface TranscriptLine {
  /** Course. */
  courseId: string
  /** Enrollment. */
  enrollmentId: string
  /** Average percent across the course's grades, or null if no points. */
  averagePercent: number | null
  /** Letter for the average, if a scale was supplied. */
  letter: string | null
  /** Number of graded assignments. */
  gradeCount: number
}

Types

UpdateGradeInput

Input for amending an existing grade.

type UpdateGradeInput = Partial<
  Pick<PostGradeInput, 'scorePoints' | 'maxPoints' | 'comment' | 'scale'>
>

Functions

authenticate()

Route middleware that requires any authenticated session before a read route runs (list, read, courseAverage). It does NOT itself scope to a user — the handler performs the per-row / per-resource ownership scoping (a student sees only their own grades; an admin sees all). Forwards Unauthorized to the error handler when there is no session.

Academic records are sensitive PII (e.g. FERPA-protected), so the read side is gated exactly like the write side — never left open. Exposed as a requestHandlerMap key so the injector's route scanner preserves it (a bare 'authenticate' middleware string that isn't a handler-map key is silently dropped — the same trap that once left these routes fully unauthenticated).

function authenticate(): MoleculeRequestHandler

Returns: An Express-compatible middleware function.

bucketByKey(grades, keyOf)

Aggregate grades by an arbitrary key, summing points and counting rows. Internal helper — exported for tests.

function bucketByKey(
  grades: Grade[],
  keyOf: (g: Grade) => string,
): Map<string, { earnedPoints: number; possiblePoints: number; gradeCount: number }>

courseAverage(req, res)

Returns the course average for a single enrollment.

404 if the enrollment has no grades. The default 4.0 plus/minus scale is used unless ?scale=raw is passed (which suppresses the letter).

Fail-closed authorization (defense-in-depth, independent of the route middleware): rejects an anonymous caller (401). The enrollment's owning student is resolved from the aggregated grades; a non-admin caller is only served when that owner is themselves (average.userId === session.userId), otherwise 403. A grade admin (instructor/registrar) sees any enrollment's average.

function courseAverage(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
  • req — The request with enrollmentId param and optional scale=raw query.
  • res — The response object.

create(req, res)

Posts a new grade for an assignment.

Validates that all foreign keys are present and that scoring is sane (scorePoints >= 0, maxPoints > 0, scorePoints <= maxPoints). If a scale is supplied on the input the resolved letter is stored.

Restricted to a grade-management authority (instructor/registrar/admin) and enforced here (not merely via route middleware): the row's userId is the student being graded — never the actor permitted to post the grade — so a non-admin caller is rejected (401 when unauthenticated, 403 otherwise) before any grade row is inserted — defense-in-depth that does not depend on the requireAdmin route middleware being wired, and that prevents a student posting arbitrary scores for any user.

function create(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
  • req — The request with {@link PostGradeInput} body.
  • res — The response object.

del(req, res)

Deletes a grade by ID.

Restricted to a grade-management authority (instructor/registrar/admin) and enforced here (not merely via route middleware): the row's userId is the student*, never the actor permitted to delete the grade, so a non-admin caller is rejected (401 when unauthenticated, 403 otherwise) before anything is deleted — defense-in-depth that does not depend on the requireAdmin route middleware being wired.

function del(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
  • req — The request object with id param.
  • res — The response object.

getCourseAverage(enrollmentId, scale)

Compute the average percent for a single course enrollment.

Sums earned and possible points across every grade for the enrollment and divides. Returns null averagePercent when no points are possible (e.g. enrolled but ungraded).

function getCourseAverage(enrollmentId: string, scale?: GradeScale): Promise<CourseAverage | null>
  • enrollmentId — The enrollment to aggregate.
  • scale — Optional letter-grade scale. If supplied the result includes a letter.

Returns: The course average, or null if the enrollment has no grades.

getGpa(userId, scale)

Compute a student's GPA across all their courses.

For each (userId, courseId) bucket the average percent is computed, resolved to a rung on the supplied scale, and the rung's gpaPoints contribute equally (unweighted) to the mean.

function getGpa(userId: string, scale: GradeScale): Promise<Gpa | null>
  • userId — The student.
  • scale — Letter-grade scale to use for resolution.

Returns: The student's GPA, or null if the student has no graded courses.

getTranscript(userId, scale)

Build a full transcript for a student.

One {@link TranscriptLine} per course with averages and (if a scale is supplied) letters. The final gpa is the same value {@link getGpa} would return.

function getTranscript(userId: string, scale?: GradeScale): Promise<Transcript | null>
  • userId — The student.
  • scale — Optional letter-grade scale.

Returns: The transcript, or null if the student has no graded courses.

gpa(req, res)

Returns a student's GPA on the default 4.0 plus/minus scale.

404 if the student has no graded courses.

Fail-closed authorization (defense-in-depth, independent of the route middleware): rejects an anonymous caller (401) and only allows the request when the caller is the student themselves (req.params.userId === session.userId) or a grade admin; otherwise 403. One student can never read another's GPA.

function gpa(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
  • req — The request with userId param.
  • res — The response object.

isGradeAdmin(res)

Resolves whether the current request's session belongs to an actor authorized to administer grades (post/amend/delete). Fail-closed: returns false when there is no authenticated session, and otherwise only true when the session carries an admin claim or a bonded permissions provider grants the manage grade permission.

Use this for in-handler defense-in-depth (it does not depend on the route middleware being preserved by the injector).

function isGradeAdmin(res: MoleculeResponse): Promise<boolean>
  • res — The response whose locals.session is inspected.

Returns: true when the session is an authorized grade admin.

list(req, res)

Lists grades with pagination and optional enrollmentId, userId, courseId, or assignmentId filters.

Fail-closed authorization (defense-in-depth, independent of the route middleware): rejects an anonymous caller (401). A non-admin caller is force- scoped to their OWN grades — the userId filter is overridden with the caller's session id, so an attacker-supplied ?userId= can never widen the result to another student (and the un-filtered "dump every grade" case is impossible for non-admins). A grade admin (instructor/registrar) may filter freely, including by an arbitrary userId.

function list(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
  • req — The request with optional page, perPage, and filter query params.
  • res — The response object.

read(req, res)

Reads a single grade by ID. Returns 404 if not found.

Fail-closed authorization (defense-in-depth, independent of the route middleware): rejects an anonymous caller (401) and, for a non-admin, only returns the grade when it belongs to the caller (grade.userId === userId); otherwise 403. A grade admin (instructor/registrar) sees any grade. This keeps one student from reading another student's grade by id even if the resource is wired without the authenticate route middleware.

function read(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
  • req — The request object with id param.
  • res — The response object.

requireAdmin()

Route middleware that gates the admin-only grade mutation routes (create, update, del). Calls next() only for an authenticated grade admin; otherwise forwards an error to the framework error handler — Unauthorized when no session is present, Forbidden when the session is authenticated but not authorized to manage grades.

Exposed as a requestHandlerMap key so the injector's route scanner keeps it (unlike the inert global 'authenticate' string, which is dropped).

function requireAdmin(): MoleculeRequestHandler

Returns: An Express-compatible middleware function.

requireSelfOrAdmin()

Route middleware for the per-student aggregate routes (/users/:userId/gpa, /users/:userId/transcript). Calls next() only when the caller is the student themselves (session.userId === req.params.userId) OR an authorized grade admin (instructor/registrar). Otherwise forwards Unauthorized (no session) or Forbidden (authenticated but neither owner nor admin) — so one student can never read another student's GPA or transcript.

Exposed as a requestHandlerMap key so the injector's route scanner preserves it.

function requireSelfOrAdmin(): MoleculeRequestHandler

Returns: An Express-compatible middleware function.

resolveLetter(percent, scale)

Resolve a score percentage to a letter on the given scale.

function resolveLetter(percent: number, scale: GradeScale): string | null
  • percent — Score percentage 0–100.
  • scale — The grade scale to apply.

Returns: The matching letter, or null if no rung matches.

resolveRung(percent, scale)

Resolve a score percentage to its letter-grade rung on the given scale.

function resolveRung(percent: number, scale: GradeScale): GradeScaleRung | null
  • percent — Score percentage 0–100.
  • scale — The grade scale to apply.

Returns: The matching rung, or null if no rung matches (empty scale).

toPercent(scorePoints, maxPoints)

Compute the percentage value of a score / max pair.

function toPercent(scorePoints: number, maxPoints: number): number | null
  • scorePoints — Earned points.
  • maxPoints — Possible points.

Returns: Percentage 0–100, or null if maxPoints <= 0.

transcript(req, res)

Returns a student's full transcript: per-course averages, letters, and overall GPA.

404 if the student has no grades. Pass ?scale=raw to suppress letter / GPA computation.

Fail-closed authorization (defense-in-depth, independent of the route middleware): rejects an anonymous caller (401) and only allows the request when the caller is the student themselves (req.params.userId === session.userId) or a grade admin; otherwise 403. One student can never read another's transcript.

function transcript(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
  • req — The request with userId param and optional scale=raw query.
  • res — The response object.

update(req, res)

Updates a grade by ID. Only scorePoints, maxPoints, and comment can be amended. If a scale is supplied the letter is recomputed against the new (or existing) score.

Restricted to a grade-management authority (instructor/registrar/admin) and enforced here (not merely via route middleware): the row's userId is the student*, never the actor permitted to amend the grade, so a non-admin caller is rejected (401 when unauthenticated, 403 otherwise) before anything is read or written — defense-in-depth that does not depend on the requireAdmin route middleware being wired, and that prevents a student editing their own grade.

function update(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
  • req — The request object with id param and {@link UpdateGradeInput} body.
  • res — The response object.

Constants

defaultGradeScale

The default US 4.0 plus/minus letter-grade scale.

Rounding is whatever the caller stores; the resolver only checks inclusive thresholds. Rungs are listed highest-first for clarity but {@link resolveLetter} sorts internally so any order works.

const defaultGradeScale: GradeScale

GRADE_ADMIN_PERMISSION

Session-claim permission string ('grade:manage') that, when present in a session's permissions array, grants grade administration without a bonded permissions provider.

const GRADE_ADMIN_PERMISSION: 'grade:manage'

GRADE_PERMISSION_ACTION

Permission action checked against @molecule/api-permissions for grade administration.

const GRADE_PERMISSION_ACTION: 'manage'

GRADE_PERMISSION_RESOURCE

Permission resource checked against @molecule/api-permissions for grade administration.

const GRADE_PERMISSION_RESOURCE: 'grade'

i18nRegistered

Whether i18n registration has completed.

const i18nRegistered: true

requestHandlerMap

Handler map keyed by route handler name.

requireAdmin (write routes), authenticate (read routes: list/read/ courseAverage), and requireSelfOrAdmin (per-student gpa/transcript) are the authorizer middlewares referenced by routes.ts. They must live here (as real handler-map keys) so the mlcl injector's route scanner preserves them — a bare middleware string that isn't a handler-map key is silently dropped, which is exactly what once left the entire read side unauthenticated.

const requestHandlerMap: {
  readonly courseAverage: typeof courseAverage
  readonly create: typeof create
  readonly del: typeof del
  readonly gpa: typeof gpa
  readonly list: typeof list
  readonly read: typeof read
  readonly transcript: typeof transcript
  readonly update: typeof update
  readonly authenticate: MoleculeRequestHandler
  readonly requireAdmin: MoleculeRequestHandler
  readonly requireSelfOrAdmin: MoleculeRequestHandler
}

routes

Route array for grade CRUD plus aggregate endpoints (course average, GPA, transcript).

const routes: readonly [
  {
    readonly method: 'post'
    readonly path: '/grades'
    readonly handler: 'create'
    readonly middlewares: readonly ['requireAdmin']
  },
  {
    readonly method: 'get'
    readonly path: '/grades'
    readonly handler: 'list'
    readonly middlewares: readonly ['authenticate']
  },
  {
    readonly method: 'get'
    readonly path: '/grades/:id'
    readonly handler: 'read'
    readonly middlewares: readonly ['authenticate']
  },
  {
    readonly method: 'patch'
    readonly path: '/grades/:id'
    readonly handler: 'update'
    readonly middlewares: readonly ['requireAdmin']
  },
  {
    readonly method: 'delete'
    readonly path: '/grades/:id'
    readonly handler: 'del'
    readonly middlewares: readonly ['requireAdmin']
  },
  {
    readonly method: 'get'
    readonly path: '/enrollments/:enrollmentId/grade-average'
    readonly handler: 'courseAverage'
    readonly middlewares: readonly ['authenticate']
  },
  {
    readonly method: 'get'
    readonly path: '/users/:userId/gpa'
    readonly handler: 'gpa'
    readonly middlewares: readonly ['requireSelfOrAdmin']
  },
  {
    readonly method: 'get'
    readonly path: '/users/:userId/transcript'
    readonly handler: 'transcript'
    readonly middlewares: readonly ['requireSelfOrAdmin']
  },
]

Injection Notes

Requirements

Peer dependencies:

  • @molecule/api-database ^1.0.1
  • @molecule/api-i18n ^1.0.1
  • @molecule/api-locales-resource-grade ^1.0.1
  • @molecule/api-logger ^1.0.1
  • @molecule/api-permissions ^1.0.1
  • @molecule/api-resource ^1.0.1

Runtime Dependencies

  • @molecule/api-database
  • @molecule/api-i18n
  • @molecule/api-locales-resource-grade
  • @molecule/api-logger
  • @molecule/api-permissions
  • @molecule/api-resource

Table: src/__setup__/grades.sql creates grades. An mlcl-scaffolded API replays __setup__/*.sql automatically on migrate; anywhere else run it once — nothing at runtime creates it.

A grade row's userId is the STUDENT being graded, NOT the actor allowed to edit it — letting the row's owner mutate it would let students change their own grades. Mutations (POST/PATCH/DELETE /grades…) are therefore role-gated and DENY BY DEFAULT: the caller needs an admin session claim (isAdmin, role: 'admin', or a grade:manage/admin permissions claim) or an @molecule/api-permissions grant (manage grade). Out of the box NO ONE can write grades — grant your instructor/registrar role first; a 403 here means "grant the permission", never "remove the gate". The gate is enforced both as the requireAdmin route middleware and inside every mutation handler (fail-closed), so it holds even when routes are wired without middlewares.

Reads are self-or-admin: GET /users/:userId/gpa and …/transcript reject callers whose session userId ≠ :userId unless they are a grade admin — handlers read the authenticated user from res.locals.session (mount behind your global auth middleware).

E2E Tests

Integration checklist — drive the real UI (live preview, no mocks) against the running API; adapt each item to this app's actual screens/roles and check every box off one by one. A box you can't check is a real bug to fix, never a skip. Note that out of the box NO ONE may write grades — first grant your instructor/registrar role an admin session claim (isAdmin / role 'admin') or an @molecule/api-permissions manage grade grant, else every POST/PATCH/ DELETE /grades correctly 403s and there is no instructor to test as.

  • [ ] As that instructor, enter a grade for a student on an assignment (POST /grades with enrollmentId, assignmentId, the student's userId, courseId, scorePoints, maxPoints); it persists (201) and — signed in AS THAT STUDENT — the score and derived letter appear on their own grades view (GET /grades, GET /grades/:id).
  • [ ] Score is range-validated: a negative scorePoints, a scorePoints above maxPoints, or maxPoints <= 0 is rejected (400 scoreOutOfRange / maxPointsPositive) and nothing is persisted — a student can never end up with an impossible negative or over-100% grade.
  • [ ] Aggregates are RIGHT, not just present: a course average (GET /enrollments/:enrollmentId/grade-average) equals 100 * sum(scorePoints) / sum(maxPoints) — so a big exam outweighs a small quiz because points ARE the weight (there is no separate assignment-weight field) — and GPA (GET /users/:userId/gpa) is the mean of each course's letter-rung gpaPoints on the active scale (each course counts equally; not credit-hour weighted). An assignment with NO grade row is EXCLUDED from the average, never counted as zero (the defined policy); confirm adding then removing one grade moves the average accordingly.
  • [ ] Editing a grade (PATCH /grades/:id) reflects immediately for the student and re-derives the letter/average, and updatedAt advances. (This resource stores no grader/updatedBy column — if your app audits WHO changed a grade, verify that trail too; if it doesn't, don't claim an audit that isn't modeled.)
  • [ ] PRIVACY / AUTHORIZATION — a student sees ONLY their own grades: signed in as student A, GET /grades never returns another student's rows (an attacker-supplied ?userId= is ignored and force-scoped to A), and id-guessing another student's grade / GPA / transcript / course-average returns 403 (or 404), never their data.
  • [ ] Only the instructor/course-staff may enter or edit: as a plain student, POST /grades (grading yourself or anyone else), PATCH /grades/:id to raise your own score, and DELETE /grades/:id all 403 through every exposed endpoint — a student can never write or alter a grade, only read their own.

Translations

Translation strings are provided by @molecule/api-locales-resource-grade.