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

@classytic/access

v0.4.0

Published

Access ENTITLEMENT engine - time-bound (or lifetime) grants of access to any resource: course enrollment, file download, API access, membership, feature flag, any service. Like a session, but durable: the grant is a permanent record, the RIGHT to access i

Readme

@classytic/access

Access entitlement engine. Durable, time-bound (or lifetime) grants of access to any resource: a course enrollment, a file download, an API key's scope, a membership, a feature flag, any service. Like a session, but the grant is a permanent record and the right to access is computed, never TTL-deleted.

Single responsibility: it answers "does this subject hold a live grant to this resource, right now?". It does not model the purchase (@classytic/order), the money (@classytic/revenue), or the catalog item (@classytic/catalog) — those compose around it via events and bridges.

order completes ──▶ access.grant({ subject, resource, accessExpiresAt })
                       │
gate / middleware ─▶ access.check(...) ──▶ { allowed, reason, expiresAt, remaining }
                       │
host cron ─────────▶ access.expireDue()  (CAS active→expired, emits access:expired)
refund ────────────▶ access.refundByOrigin('Order', id)

The one idea: separate the record from the right

An entitlement row is a ledger entry: "this subject was granted access to this resource, via this order, for this window." Whether access is usable right now is decided by a pure function over the window + status, never by deleting the row.

This is deliberate (PACKAGE_RULES Concurrency House Rules: "self-healing CAS expiry over TTL-delete indexes — model expiry as a state transition so the row survives as a record"). A TTL-delete would destroy learner progress, revoke earned certificates, and kill the "renew" upsell. Here:

  • Stored status records only explicit lifecycle events: active, suspended, revoked, refunded, consumed, expired.
  • expired is derived at read time from the window — the gate denies the instant a window lapses, before any sweeper runs. A background expireDue() sweep later CAS-writes status: 'expired' and emits access:expired so downstream reacts once. The row is never deleted; terminal states free the live-uniqueness slot so a re-purchase inserts a fresh grant.

The pure decision (@classytic/access/decision)

evaluateAccess is a pure function with no mongoose import, published as its own subpath so the exact same rule runs on the server, at an edge node, and in the UI:

import { evaluateAccess, hasAccess } from '@classytic/access/decision';

const d = evaluateAccess({ status: 'active', accessExpiresAt: '2027-01-01T00:00:00Z' });
// { allowed: true, reason: 'active', expiresAt: 1798761600000, remaining: null }

evaluateAccess({ status: 'active', accessExpiresAt: yesterday }); // { allowed: false, reason: 'expired' }
evaluateAccess({ status: 'active', usage: { limit: 3, used: 3 } }); // { allowed: false, reason: 'quota_exhausted' }

Deny precedence (most-terminal first): revoked → refunded → suspended → consumed → not_yet_active → expired → quota_exhausted → active.

Quick start

import mongoose from 'mongoose';
import { createAccess } from '@classytic/access';

const access = await createAccess({
  connection: mongoose.connection,
  tenant: { fieldType: 'objectId' }, // Better Auth organization._id; pass `false` for platform-wide grants
  // eventTransport: new RedisEventTransport({ url }),  // any arc transport drops in
});

// Grant a 1-year course on purchase
await access.repositories.entitlement.grant(
  {
    subjectRef: userId, subjectModel: 'user',
    resourceRef: courseId, resourceType: 'course',
    source: 'purchase',
    accessExpiresAt: new Date(Date.now() + 365 * 864e5),
    originRef: orderId, originModel: 'Order',
  },
  { organizationId: orgId },
);

// Gate a lesson. The target is { id } or the COMPLETE identity tuple, so two subject
// namespaces sharing an id (a `user` and an `apiKey` both keyed `42`), or two resource types
// sharing a ref, cannot resolve to each other's grant.
const { allowed, reason } = await access.repositories.entitlement.check(
  { subjectModel: 'user', subjectRef: userId, resourceType: 'course', resourceRef: courseId },
  { organizationId: orgId },
);
if (!allowed) return reply.code(403).send({ code: `access.${reason}` });

Repository verbs

| Verb | Purpose | |---|---| | grant(input, ctx) | Idempotent grant (atomic getOrCreate per live subject+resource+scope). Re-grant restarts the window. Emits access:granted / access:extended. | | checkMany(subject, capabilities, ctx) | Decide many capabilities for ONE subject in ONE query. Results come back in input order; an empty list is refused. | | check(target, ctx) | Hot path. Read-only evaluateAccess → { allowed, reason, expiresAt, remaining, entitlement }. The target is { id } or the COMPLETE identity tuple (subjectModel + subjectRef + resourceType + resourceRef); a partial tuple is refused (InvalidCheckRequestError), never resolved to "whichever row sorts first". | | consume(target, { key, amount? }, ctx) | Spend metered units (download / API call / seat) under a REQUIRED idempotency key: a replayed key spends nothing, a reused key with a different amount throws ConsumptionKeyConflictError. Race-proof capacity guard; flips to consumed + emits access:exhausted at the cap. | | listConsumptions(entitlementId, { limit }, ctx) | The append-only usage ledger of one grant (who spent what, when). | | extend(id, { accessExpiresAt \| addMs }, ctx) | Renew — push the window out / reactivate an expired grant. Emits access:extended. | | suspend / resume | Pause and restore (membership freeze). | | revoke(id, { reason }, ctx) | Pull access permanently (terminal). | | expireDue({ now, limit }, ctx) | Sweeper (host cron): CAS active → expired for lapsed windows, emits access:expired. The row survives. | | refundByOrigin(model, ref, ctx) | Cascade: mark every live grant from an order/transaction refunded. | | listForSubject(subjectRef, ctx, options?) | A bounded subject grant list, newest-first; options narrow subject model, resource type, and live grants. |

Metered access (API / downloads / seats)

usageLimit makes a grant metered. consume is atomic and never overshoots:

const e = await access.repositories.entitlement.grant(
  { subjectRef: keyId, subjectModel: 'apiKey', resourceRef: 'reports', resourceType: 'api',
    scopeKey: 'reports:read', usageLimit: 1000, source: 'subscription' },
  ctx,
);
await access.repositories.entitlement.consume({ id: String(e._id) }, { key: scanId, amount: 1 }, ctx); // 999 left; a repeat of `scanId` spends nothing
// 1000 concurrent consumes → exactly 1000 succeed, the rest throw QuotaExhaustedError

Arc integration

import { defineResource } from '@classytic/arc';
import { createAdapter } from '@classytic/arc/adapter';
import { requireAuth, requireRoles } from '@classytic/arc/permissions';

export default defineResource({
  name: 'entitlement',
  prefix: '/access/entitlements',
  adapter: createAdapter(access.models.Entitlement, access.repositories.entitlement),
  permissions: { list: requireAuth(), get: requireAuth(), create: requireRoles('admin') },
  actions: {
    suspend: { handler: (id, _d, req) => access.repositories.entitlement.suspend(id, {}, req.scope) },
    resume:  { handler: (id, _d, req) => access.repositories.entitlement.resume(id, req.scope) },
    revoke:  { handler: (id, d, req) => access.repositories.entitlement.revoke(id, d, req.scope) },
    extend:  { handler: (id, d, req) => access.repositories.entitlement.extend(id, d, req.scope) },
    consume: { handler: (id, d, req) => access.repositories.entitlement.consume({ id }, { key: d.key, amount: d.amount }, req.scope) },
  },
});

// Event catalog → arc EventRegistry (publish-time validation + OpenAPI)
import { accessEventDefinitions } from '@classytic/access/events';
for (const def of accessEventDefinitions) registry.register(def);

Events

access:granted · access:extended · access:suspended · access:resumed · access:revoked · access:expired · access:exhausted · access:refunded. Every payload carries the entitlement snapshot. Subscribe glob-style; drop in any arc EventTransport.

Design notes

  • Polymorphic subject and resource (String + bridge, rule 7) — one model serves user/apiKey/team subjects and course/download/api/membership/feature resources.
  • Tenant-scoped by multiTenantPlugin (tenant: false for platform-wide grants like a user's API key).
  • No TTL index, no cron required for the gate — expiry is lazy at read time; expireDue exists only to emit events and make status queryable.
  • Indexes are race-proof: one active grant per (org, subject, resource, scope) via a partial unique index; terminal states free the slot.

License

MIT

Trademark

Code is MIT-licensed. "Classytic"/"arc" names + logos are trademarks of Classytic LLC — see TRADEMARK.md.