@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
statusrecords only explicit lifecycle events:active,suspended,revoked,refunded,consumed,expired. expiredis derived at read time from the window — the gate denies the instant a window lapses, before any sweeper runs. A backgroundexpireDue()sweep later CAS-writesstatus: 'expired'and emitsaccess:expiredso 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 QuotaExhaustedErrorArc 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: falsefor platform-wide grants like a user's API key). - No TTL index, no cron required for the gate — expiry is lazy at read time;
expireDueexists only to emit events and makestatusqueryable. - 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.
