Imported from vllnt/convex-invitations (
AGENTS.md). Install upstream withnpx skills add vllnt/convex-invitations. Copyright stays with the author.
This project uses Convex as its backend.
When working on Convex code, always read example/convex/_generated/ai/guidelines.md first for
important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that
override what you may have learned about Convex from training data.
Convex agent skills for common tasks can be installed by running npx convex ai-files install.
@vllnt/convex-invitations
The invite → accept → expire flow, as a Convex component. A host mutation issues a single-use,
expiring invitation to an opaque resource and gets back a token to hand to the invitee out of band;
the invitee redeems it with accept (single-use, returns the grant the host applies); a still-open
invite can be revoked, and an unaccepted invite expires when its TTL elapses. It follows the vllnt
Component Standard (see the oss-packages hub AGENTS.md).
Agent instructions
AGENTS.md is the sole agent-instruction source for this repository. Do not add
CLAUDE.md or .claude content.
Architecture
src/
├── shared.ts # constants: component name, lifecycle states, TTL, retention, batch size
├── test.ts # convex-test register() helper
├── client/
│ ├── index.ts # Invitations<TRole, TPayload> class (consumer-facing API)
│ └── types.ts # public TypeScript interfaces
└── component/
├── schema.ts # sandboxed table: invitations {token, resourceRef, role?, inviterRef?, inviteeRef?, payload?, state, createdAt, expiresAt, acceptedAt?, acceptedBy?, revokedAt?}
├── convex.config.ts # defineComponent("invitations")
├── mutations.ts # issue, accept, revoke, peek, prune
├── queries.ts # getByToken, listPending, listByResourceState
├── validators.ts # shared validators (invitationState, invitationView, jsonValue)
└── crons.ts # daily prune cron (self-rescheduling)
Sandboxed table: invitations — indexed by_token (redemption/lookup), by_resource_state
(pending-per-resource listing + audit), by_state_expires (TTL expiry sweep), and by_state_created
(retention sweep). No host tables are touched. The stored role/payload are opaque to the
component; the host narrows them via roleValidator/payloadValidator at the client boundary.
Ownership boundary
Component owns:
- The invitation envelope (
invitationstable) — issue, accept, revoke, expire, prune - Minting the single-use
token(via the client'sgenerateToken) and enforcing its uniqueness - Server-sourced time —
Date.now()inside every handler stampscreatedAt/acceptedAt/revokedAt; no caller clock - The lifecycle state machine:
pending → accepted | revoked | expired, single-use, terminal states final - TTL enforcement on read (accept/peek) and the daily prune cron (expire stale pendings + delete terminal past retention)
Host owns:
- The resource being invited to and its domain meaning (
resourceRef,role,payload) - Auth and authorization — whether a caller may issue, accept, or revoke a given invite
- Delivery of the token (email, link) — the component returns the token, never sends it
- Resolving identity into the opaque
inviterRef/inviteeRef/acceptedBysubjects - Applying the grant on accept (writing a membership, granting a role) — the component returns it, the host persists it
- The stored
role/payloadtypes (TRole/TPayload) — opaque to the component, narrowed by host validators
Auth: the component is completely auth-agnostic. The host resolves identity, decides access, and
gates who may read/deliver the token. There is no built-in scope dimension — the host namespaces its
resourceRefs itself, or mounts a second instance (app.use(component, { name })) for a static
partition.
Key design decisions
-
Single-use + terminal states are final (the core invariant):
acceptandrevokereject any transition out ofaccepted/revoked/expiredwith a codedConvexError. A replayed link or a duplicate accept — common with at-least-once delivery — can never double-grant. The single check-and-patch in one mutation also means two racing accepts yield exactly one winner. -
TTL enforced on read, but accept does not flip on expiry: accepting a past-
expiresAtinvite throwsEXPIRED. A thrown mutation rolls back its own writes, soacceptdoes NOT persist apending → expiredflip (that write would be lost anyway). The non-throwingpeekmutation and the prune cron persist the sweep instead. The invite can never be accepted regardless. -
peek(mutation) vsgetByToken(query): a Convex query cannot write, sogetByTokenreports the storedstateas-is (a stale pending may still readpending).peekis a mutation that flips a stale pending toexpiredand returns it — read-time TTL enforcement when the host wants it. -
Component-minted token, host-supplied generator: the client mints the token (
crypto.randomUUIDby default, overridable viagenerateToken) so the host never has to; uniqueness is enforced inissue(a collision throwsDUPLICATE_TOKEN). At this minimal stage the token is stored as-is — a later version hashes it via@vllnt/convex-tokens. -
Typed-generic opaque data, never
v.any()dumped raw:role/payloadride through the single documentedjsonValuealias and are narrowed toTRole/TPayloadby host parsers at the client boundary on both write and read — no unchecked cast. -
acceptreturns the grant; the host applies it: the component returns{ resourceRef, role?, payload? }and the host writes its own membership. This keeps the host-table write (which a sandboxed component cannot do) on the host side — and is exactly where a future version delegates to@vllnt/convex-memberships. -
Bounded prune, retention-before-TTL ordering:
prunedeletes terminal invites past retention FIRST, then flips stale pendings toexpired— so a row flipped this pass is not also deleted this pass (double-count); it is deleted next pass. Bounded perbatch, self-reschedules viactx.scheduler. Idempotent; the built-in daily cron drives it. Default TTL 7 days, retention 30 days. -
Backend-only (no
./reactentry): an invite-management surface is an ordinary reactiveuseQueryover the host's own re-exportedgetByToken/listPendingrefs — a dedicated hook would wrap the host'sapiwith no added value, and the token is delivered by the host, not revealed in a client component. Explicit analysis decision (see README); re-run when a real management-surface consumer appears.
Conventions
- Mutations in
mutations.ts, queries inqueries.ts(enforced by@vllnt/eslint-config/convex). - Explicit
args+returnson every Convex function. - Host data via typed generics / host validators — never
v.any()dumps;jsonValueis the documented last resort for the stored opaquerole/payload. - 100% test coverage is BLOCKING (
vitest.config.mtsthresholds: statements, branches, functions, lines). - Runtime deps: only official
@convex-dev/*+@vllnt/*.
Docs sync
| Changed | Update in the same commit |
|---|---|
| Public API (issue/accept/revoke/peek/getByToken/listPending/listByResourceState/prune signatures) | README API Reference table, docs/API.md, llms.txt context, regenerate llms-full.txt |
| Config options / defaults (validators, TTL, retention, generateToken, batch) | README API Reference, docs/API.md constructor section |
| Schema / table / indexes | README Architecture, docs/API.md |
| Error codes | docs/API.md → ## Error codes table |
peerDependencies.convex version |
llms.txt context line (convex@^X.Y.Z), docs/API.md Compatibility line, README Installation peer note |
| Lifecycle / state machine | docs/API.md mutation sections, Key design decisions above |
| Any change | pnpm generate:llms to keep llms-full.txt current |
Grep old values before committing (e.g. after a peerDependencies.convex bump, git grep "1.41.0" → only the new range survives).
Generated code
- Every
**/_generated/**file is owned exclusively by Convex CLI codegen. - Never create, edit, lint, or format generated files manually.
- Run
pnpm codegento regenerate them and commit the generated output unchanged.