@nxgt/janus-mongo
The MongoDB adapter for @nxgt/janus: its identity stores —
users, sessions and one-time tokens — the relation store of
@nxgt/janus/permissions, and the opt-in API key store, over one database, on
@nxgt/mongo.
It passes both @nxgt/janus/conformance suites against a real mongod, outages
included. createMongoAdapter(db) gives both sides under the names janus()
takes them, so one spread wires them; each is also usable alone, as in
@nxgt/janus: createMongoStores for identities, createMongoRelations for
permissions.
import { janus, scryptHasher } from '@nxgt/janus';
import { permissions } from '@nxgt/janus/permissions';
import { createMongoAdapter, syncMongoAdapter } from '@nxgt/janus-mongo';
await syncMongoAdapter(db); // a deployment step: creates the collections and indexes (API keys: syncMongoApiKeys, apart)
const mongo = createMongoAdapter(db); // { store, relations }
export const auth = janus({
user: User,
password: { login: 'email' },
hasher: scryptHasher(),
...mongo, // deleting a user deletes their tuples too
});
export const access = permissions({ model, store: mongo.relations });
Install
bun add @nxgt/janus-mongo @nxgt/janus @nxgt/mongo mongodb zod
bun add -d typescript
Every peer is required: @nxgt/janus, @nxgt/mongo (>=0.17.0 <1), mongodb
(7), zod (4.6.5 or later, which @nxgt/mongo defines collections with) and typescript
(6 or 7). @nxgt/janus is a peer, never a dependency: this package defines no
error class and throws the peer's own, so instanceof StoreFailure holds in
your code. Like it, this package expects "moduleResolution": "bundler": the
declarations import without extensions, so nodenext is not supported.
The @nxgt/mongo floor is tested, not claimed: the package's specs and its
typecheck run on @nxgt/mongo 0.17.0 as well, in the Floors job, on every CI
run.
API
| Export | What it is |
|---|---|
createMongoAdapter(db) | { store, relations }: both of the below, keyed as janus() takes them, so janus({ …, ...mongo }) wires both. Connects to nothing. |
syncMongoAdapter(db, options?) | syncMongoStores and syncMongoRelations in one step: the four collections. It does not create apiKeys. syncAll(db) of @nxgt/mongo creates five (users, sessions, tokens, relations and apiKeys), since importing the package registers all five definitions. |
MongoAdapter | The type of what createMongoAdapter answers. |
createMongoStores(db) | The { users, sessions, tokens } that janus() takes as store. Connects to nothing. Implements the optional users.findUsers, so auth.<type>.findMany(ids) is one $in query. |
syncMongoStores(db, options?) | Creates the three collections, their validators and indexes, and answers what it changed. Needs dbAdmin; run it when you deploy, never per request. |
users, sessions, tokens | The @nxgt/mongo definitions. Defining them registers them, so syncAll(db) syncs them with your own collections. |
janusCollections | The three definitions, in sync order. |
createMongoRelations(db) | The RelationStore that permissions() takes as store, and janus() as relations. Connects to nothing. |
syncMongoRelations(db, options?) | Creates the relations collection and its indexes. A separate step: an application that uses identities alone keeps no tuples. |
relations | Its @nxgt/mongo definition. |
createMongoApiKeys(db) | The ApiKeyStore of @nxgt/janus' opt-in API key port. Opt-in, so it is not part of createMongoAdapter. Connects to nothing. |
syncMongoApiKeys(db, options?) | Creates the apiKeys collection and its indexes. A separate step, like syncMongoRelations: syncMongoAdapter does not run it. |
apiKeys | Its @nxgt/mongo definition. Importing the package registers it, so syncAll(db) creates it. |
What the database holds
The port's records, with the id under _id and nothing else renamed or
encoded. A document read in a shell reads like the record in the code.
| Collection | Indexes |
|---|---|
users | loginUnique on { type, logins }, unique — a login is unique per user type · typeId on { type, _id } for listing |
sessions | tokenHashUnique · userId · expiry, a TTL index |
tokens | _id is the token's hash · userId · expiry, a TTL index |
relations | _id is the tuple, { object: { type, id }, relation, subject: { type, id, relation? } } — unique by construction · objectRelation for one hop forwards · subjectObjects for findObjects, which it serves with no in-memory sort (measured: 11 keys examined for a page of 10) |
apiKeys | _id is the key's id · userId on { userId, _id }, for listing a user's keys · expiry, a TTL index that drops a key 30 days after expiresAt (the grace keeps a lapsed key listable as "expired") |
No secret is stored: sessions and tokens hold sha256 of the secret,
passwords a self-describing hash, and a user's secondFactor.secret a TOTP
secret @nxgt/janus has already sealed with your application's key —
v1.<key id>.… — which the store keeps byte for byte. Its
secondFactor.recoveryCodes are keyed hashes of the recovery codes, never a
code, kept in order; a factor without the field reads as having none.
countAttempt counts an attempt at a code in one findOneAndUpdate with
$inc, so twenty concurrent attempts answer twenty distinct counts.
spendUserTokens is one updateMany on { userId, kind, spentAt: null } —
and _id: { $ne: except } when except is given —
through the index deleteUserTokens already reads: each token it spends is
matched and written in one step, so a racing redemption and it never both
spend the same one.
Traps
-
Read from the primary.
@nxgt/januscounts on a read seeing every write that completed before it: a sign-in re-reads the user to catch a password written while it ran. Adbwhose client reads from secondaries (readPreference: 'secondaryPreferred') misses that write, and the sign-in goes through. Hand the adapter adbthat reads from the primary — the driver's default:import { MongoClient } from 'mongodb'; const client = new MongoClient(url); // readPreference 'primary' by default const adapter = createMongoAdapter(client.db('app')); -
Upgrading to 0.3: run the sync before deploying. No document is rewritten — a user without
secondFactorreads as having none, a token withoutcodeHashandattemptsasnulland0— but the validators the previous sync wrote refuse the new fields. UntilsyncMongoAdapter(db)has run, every sign-up and every one-time token fails withSTORE_FAILED, caused byDocument failed validation. Upgrading has the steps. -
Upgrading to 0.5: run the sync before deploying, again. No document is rewritten — a second factor without
recoveryCodesreads as having none — but the validator the previous sync wrote refuses the new field. UntilsyncMongoAdapter(db)has run, every write of a second factor —enroll,activate, a confirmed code — fails withSTORE_FAILED, caused byDocument failed validation. And finish the rollout before users hold recovery codes: an instance still on 0.4 writes a factor back without them when it accepts a code. -
Upgrading to 0.6: run the sync before deploying, once more. The token kind
stepUpis new, and thekindenum the previous sync wrote refuses it: untilsyncMongoAdapter(db)has run, every step-up request fails withSTORE_FAILED, caused byDocument failed validation. No document is rewritten. -
Upgrading to 0.7: the sync again, for the same reason. The token kind
magicLink—@nxgt/janus0.15's sign-in link — is new: until the sync has run, everymagicLink.requestfails withSTORE_FAILED, caused byDocument failed validation. No document is rewritten. -
Upgrading to 0.9: the sync again, for the same reason. The token kind
changeEmail—@nxgt/janus0.23's link to a new address — is new: until the sync has run, the first write of such a token fails withSTORE_FAILED, caused byDocument failed validation. No document is rewritten. -
Upgrading to 0.10: run the sync before deploying. The
avatarslot —@nxgt/janus0.29's — is new, and the strict validator the previous sync wrote refuses it: untilsyncMongoAdapter(db)has run, only a write that sets an avatar fails, withSTORE_FAILED, caused byDocument failed validation. Sign-up and every other write pass, and no document is rewritten. The sync guide has the detail. -
The TTL indexes are storage hygiene, not the expiry. MongoDB's TTL monitor runs every sixty seconds, so a lapsed session can stay readable for up to a minute. The core compares
expiresAton every read, and that is what expires it. -
auth.collectExpired()answersUNSUPPORTEDwith this adapter. The TTL indexes already drop lapsed sessions and tokens, sodeleteExpiredSessionsanddeleteExpiredTokensare deliberately not implemented. -
No sync function is called for you —
syncMongoAdapter, orsyncMongoStoresfor identities alone. The core never manages a schema. Without it there is no unique index, and nothing stops two concurrent sign-ups with one login. -
A duplicate key on an index other than the login index is a
StoreFailure, notLOGIN_TAKEN. Such a duplicate is an adapter bug, and reporting it as a taken login would tell somebody their e-mail is in use when it is not. -
A relation write of more than one tuple is a transaction, and MongoDB runs transactions on a replica set only.
grantandrevokewrite one tuple and run anywhere; on a standalone mongod a multi-tuple write fails withSTORE_FAILED. The transaction is not retried: the driver'swithTransactionwould retry an outage for two minutes before answering, and a write is idempotent, so retry it yourself. -
findObjectsorders ids as MongoDB does, by UTF-8 bytes.list()sorts what it gathers itself, so its pages do not depend on it; a caller of the store directly sees bytes order, which differs from JavaScript's<only past U+FFFF.
Documentation
- Guides — wiring the stores, syncing the collections
- Troubleshooting — by the error message you see
- Roadmap — what is next, and what is not planned
Type safety, counted
Eleven plausible mistakes are refused by the compiler, each with a
@ts-expect-error case in test/types/adapter.ts:
- a connection string instead of a database;
@nxgt/mongo's connection instead of itsdb, tocreateMongoAdapter;- the driver's
MongoClientinstead of one of its databases; @nxgt/mongo's connection instead of itsdb, tosyncMongoAdapter;- the same, to
syncMongoRelations; - the promise
connectMongoanswers, not awaited; - a sync option in the wrong case:
dryrunfordryRun; - the whole adapter as
janus()'sstore, instead of spreading it; - the relation store as
janus()'sstore; - the whole adapter as
permissions()'sstore, instead ofmongo.relations; - the identity stores as
permissions()'sstore.
Licence
MIT