@vllnt/convex-memberships
v0.1.0
Published
Auth-agnostic membership and relationship graph (ReBAC tuples), as a Convex component
Downloads
674
Maintainers
Readme
@vllnt/convex-memberships
Auth-agnostic membership and relationship graph (ReBAC tuples), as a Convex component.
const memberships = new Memberships(components.memberships);
await memberships.add(ctx, userId, orgId, "admin");
const isAdmin = await memberships.isMember(ctx, userId, orgId, "admin");A tuple is (memberRef, resourceRef, relation), classified by an opaque
memberKind. Domain-neutral: org membership, document sharing, team roles, group
nesting — any "who relates to what, and how" question. Edges are flat —
transitive expansion (a member of a group that is a member of an org) is not
auto-resolved; it is planned for vNext.
Features
- Flat ReBAC tuples —
(memberRef, resourceRef, relation)with an opaquememberKind(defaults"user") and optionalstatus. - Idempotent edges —
adddedupes on the full tuple, returning the existing id and patchingmemberKind/status. - Paginated listing — members of a resource, and resources of a member, both paginated for guild/workspace scale.
- Relation filtering — list members narrowed to a single relation.
- Bounded membership checks — exact-relation, or any-relation via index (not a full member scan).
- Guarded relation transitions —
setRelationmoves an edge, guarded against collisions; missing source ⇒NOT_FOUND. - Auditable removal —
removereturns{ removed };documentsexposes storedcreatedAtandstatus. - Opaque refs — every ref/relation/kind/status is an arbitrary host string the component never inspects.
Installation
pnpm add @vllnt/convex-membershipsPeer dependency: convex@^1.45.0.
Usage
// convex/convex.config.ts
import { defineApp } from "convex/server";
import memberships from "@vllnt/convex-memberships/convex.config";
const app = defineApp();
app.use(memberships);
export default app;// convex/teams.ts — host owns auth; pass opaque refs in.
import { components } from "./_generated/api";
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { Memberships } from "@vllnt/convex-memberships";
const memberships = new Memberships(components.memberships);
export const join = mutation({
args: { userId: v.string(), orgId: v.string() },
handler: (ctx, { userId, orgId }) => memberships.add(ctx, userId, orgId),
});
export const isInOrg = query({
args: { userId: v.string(), orgId: v.string() },
handler: (ctx, { userId, orgId }) => memberships.isMember(ctx, userId, orgId),
});Client options: new Memberships(component, { defaultRelation = "member", defaultMemberKind = "user" }).
API Reference
| Method | Kind | Result |
|--------|------|--------|
| add(ctx, memberRef, resourceRef, relation?, memberKind?, status?) | mutation | id (tuple-idempotent; patches memberKind/status on re-add) |
| remove(ctx, memberRef, resourceRef, relation?) | mutation | { removed } (idempotent) |
| setRelation(ctx, memberRef, resourceRef, fromRelation, toRelation) | mutation | { ok, reason? } (NOT_FOUND | EXISTS) |
| listMembers(ctx, resourceRef, paginationOpts, relation?) | query | Page<MemberEntry> |
| members(ctx, memberRef, paginationOpts) | query | Page<ResourceEntry> |
| isMember(ctx, memberRef, resourceRef, relation?) | query | boolean |
| documents(ctx, paginationOpts, filter?) | query | Page<MembershipDoc> |
All listing methods require paginationOpts.numItems to be an integer from 1
through 1000 and cap maximumRowsRead at 1000, including reactive cursor ranges.
Invalid numeric pagination options throw INVALID_PAGE_SIZE.
Full reference: docs/API.md.
React
An optional, tree-shakeable ./react entry ships thin hooks over convex/react's
useQuery. react is an optional peer dependency — a backend-only consumer pulls
zero React. Each hook takes the host app's re-exported query reference.
import { useIsMember } from "@vllnt/convex-memberships/react";
import { api } from "../convex/_generated/api";
const isAdmin = useIsMember(api.memberships.isMember, {
memberRef: userId,
resourceRef: orgId,
relation: "admin",
}); // boolean | undefined (undefined while loading)| Hook | Args | Returns |
|------|------|---------|
| useIsMember(isMemberRef, { memberRef, resourceRef, relation? }) | the host's isMember ref | boolean \| undefined |
| useMembers(membersRef, { memberRef }) | the host's members ref | first page of resource entries \| undefined |
| useListMembers(listMembersRef, { resourceRef, relation? }) | the host's listMembers ref | first page of member entries \| undefined |
Security
- Auth-agnostic — the host resolves identity, decides who may create/read an edge, and passes opaque refs; tables are sandboxed (reached only via the client).
isMemberfootgun — called withoutrelation, it returnstruefor any stored relation ("invited","suspended","banned", …). For authz gates, always passrelationor enumerate the relations your policy accepts.
See docs/API.md.
Testing
pnpm test # single run
pnpm test:coverage # enforced 100% on covered filesTests run against the real component runtime via convex-test (@edge-runtime/vm), not mocks.
Contributing
See CONTRIBUTING.md.
Author
Built by bntvllnt · bntvllnt.com · X @bntvllnt
Part of the @vllnt Convex component fleet — vllnt.com
If this is useful, sponsor the work.
License
MIT — see LICENSE.
