@nxgt/janus 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +87 -14
  2. package/dist/auth/index.d.ts +1 -1
  3. package/dist/auth/index.d.ts.map +1 -1
  4. package/dist/auth/port/types.d.ts +70 -2
  5. package/dist/auth/port/types.d.ts.map +1 -1
  6. package/dist/auth/users.d.ts.map +1 -1
  7. package/dist/chunks/{index-hm4v76kd.js → index-53y1afjz.js} +2 -2
  8. package/dist/chunks/{index-fgb3t64y.js → index-mgh85djb.js} +2 -2
  9. package/dist/chunks/{index-6p56fpbe.js → index-qwfkhqkk.js} +22 -4
  10. package/dist/chunks/{index-6p56fpbe.js.map → index-qwfkhqkk.js.map} +4 -4
  11. package/dist/chunks/{index-0xarpm7z.js → index-thtyq7a9.js} +14 -3
  12. package/dist/chunks/{index-0xarpm7z.js.map → index-thtyq7a9.js.map} +3 -3
  13. package/dist/conformance/cases/outage.d.ts.map +1 -1
  14. package/dist/conformance/cases/tokens.d.ts.map +1 -1
  15. package/dist/conformance/cases/users.d.ts.map +1 -1
  16. package/dist/conformance/fixtures.d.ts.map +1 -1
  17. package/dist/conformance/index.js +111 -5
  18. package/dist/conformance/index.js.map +6 -6
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +12 -5
  22. package/dist/index.js.map +4 -4
  23. package/dist/permissions/index.js +47 -21
  24. package/dist/permissions/index.js.map +6 -6
  25. package/dist/permissions/input.d.ts +5 -3
  26. package/dist/permissions/input.d.ts.map +1 -1
  27. package/dist/permissions/model.d.ts +19 -8
  28. package/dist/permissions/model.d.ts.map +1 -1
  29. package/dist/permissions/resolve.d.ts.map +1 -1
  30. package/dist/permissions/reverse.d.ts.map +1 -1
  31. package/dist/subjects/notation.d.ts +7 -3
  32. package/dist/subjects/notation.d.ts.map +1 -1
  33. package/dist/subjects/subject.d.ts +53 -0
  34. package/dist/subjects/subject.d.ts.map +1 -1
  35. package/docs/README.md +1 -1
  36. package/docs/guide/adapters.md +124 -3
  37. package/docs/guide/permissions.md +50 -18
  38. package/docs/guide/vocabulary.md +10 -5
  39. package/docs/roadmap.md +19 -5
  40. package/docs/troubleshooting.md +34 -3
  41. package/package.json +1 -1
  42. /package/dist/chunks/{index-hm4v76kd.js.map → index-53y1afjz.js.map} +0 -0
  43. /package/dist/chunks/{index-fgb3t64y.js.map → index-mgh85djb.js.map} +0 -0
@@ -1 +1 @@
1
- {"version":3,"file":"reverse.d.ts","sourceRoot":"","sources":["../../src/permissions/reverse.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AACnD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAClD,OAAO,EAEN,KAAK,aAAa,EAGlB,MAAM,WAAW,CAAC;AAEnB;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,qBAAa,OAAO;IAOlB,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IAX1B,OAAO,CAAC,QAAQ,CAA0C;IAC1D,OAAO,CAAC,OAAO,CAA0C;IACzD,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAqB;IAC1C,OAAO,CAAC,MAAM,CAAS;gBAGL,KAAK,EAAE,aAAa,EACpB,KAAK,EAAE,aAAa,EACpB,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE,OAAO,EACZ,OAAO,EAAE,OAAO,EAChB,QAAQ,EAAE,MAAM;IAG5B,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;IAcvE,gFAAgF;IAChF,OAAO,CAAC,IAAI;IAOZ,8EAA8E;YAChE,KAAK;YA2BL,QAAQ;YAgCR,UAAU;IAmBxB,oGAAoG;YACtF,KAAK;IAgCnB,+CAA+C;YACjC,OAAO;IAqBrB,yEAAyE;YAC3D,MAAM;IAoBpB,gEAAgE;IAChE,OAAO,CAAC,MAAM;CAYd"}
1
+ {"version":3,"file":"reverse.d.ts","sourceRoot":"","sources":["../../src/permissions/reverse.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AACnD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAClD,OAAO,EAEN,KAAK,aAAa,EAGlB,MAAM,WAAW,CAAC;AAEnB;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,qBAAa,OAAO;IAOlB,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IAX1B,OAAO,CAAC,QAAQ,CAA0C;IAC1D,OAAO,CAAC,OAAO,CAA0C;IACzD,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAqB;IAC1C,OAAO,CAAC,MAAM,CAAS;gBAGL,KAAK,EAAE,aAAa,EACpB,KAAK,EAAE,aAAa,EACpB,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE,OAAO,EACZ,OAAO,EAAE,OAAO,EAChB,QAAQ,EAAE,MAAM;IAG5B,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;IAcvE,gFAAgF;IAChF,OAAO,CAAC,IAAI;IAOZ,8EAA8E;YAChE,KAAK;YA2BL,QAAQ;YAgCR,UAAU;IAmBxB,oGAAoG;YACtF,KAAK;IAiCnB,+CAA+C;YACjC,OAAO;IAqBrB,yEAAyE;YAC3D,MAAM;IAoBpB,gEAAgE;IAChE,OAAO,CAAC,MAAM;CAYd"}
@@ -1,4 +1,4 @@
1
- import { type Entity, type RelationTuple, type Subject } from './subject';
1
+ import { type Entity, type RelationTuple, type SetOf, type Subject } from './subject';
2
2
  /**
3
3
  * Zanzibar's notation, with typed subjects:
4
4
  *
@@ -30,6 +30,10 @@ export declare function formatTuple(tuple: RelationTuple): string;
30
30
  * developer's own code.
31
31
  */
32
32
  export declare function parseTuple(text: string): RelationTuple;
33
- /** Reads one subject back: `staff:u1`, or `team:t1#member`. */
34
- export declare function parseSubject(text: string): Subject;
33
+ /**
34
+ * Reads one subject back: `staff:u1`, or `team:t1#member`. A set is answered
35
+ * as `setOf()` makes it, so `can()` and `grant()` read it as the set it names
36
+ * on a user type too.
37
+ */
38
+ export declare function parseSubject(text: string): Entity | SetOf;
35
39
  //# sourceMappingURL=notation.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"notation.d.ts","sourceRoot":"","sources":["../../src/subjects/notation.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,KAAK,MAAM,EAEX,KAAK,aAAa,EAClB,KAAK,OAAO,EACZ,MAAM,WAAW,CAAC;AAEnB;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAEnD;AAED,oDAAoD;AACpD,wBAAgB,aAAa,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,CAItD;AAED,iDAAiD;AACjD,wBAAgB,WAAW,CAAC,KAAK,EAAE,aAAa,GAAG,MAAM,CAExD;AASD;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,aAAa,CAkBtD;AAED,+DAA+D;AAC/D,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAiBlD"}
1
+ {"version":3,"file":"notation.d.ts","sourceRoot":"","sources":["../../src/subjects/notation.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,KAAK,MAAM,EAEX,KAAK,aAAa,EAClB,KAAK,KAAK,EACV,KAAK,OAAO,EAEZ,MAAM,WAAW,CAAC;AAEnB;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAEnD;AAED,oDAAoD;AACpD,wBAAgB,aAAa,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,CAItD;AAED,iDAAiD;AACjD,wBAAgB,WAAW,CAAC,KAAK,EAAE,aAAa,GAAG,MAAM,CAExD;AASD;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,aAAa,CAkBtD;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,KAAK,CAiBzD"}
@@ -72,4 +72,57 @@ export declare function subjectOf(user: {
72
72
  readonly type: string;
73
73
  readonly id: SubjectId;
74
74
  }): Entity;
75
+ /**
76
+ * The mark `setOf()` puts on a set: a symbol property, so it survives a spread
77
+ * and `Object.assign`, and no value read from a database or from JSON can
78
+ * carry it. Registered with `Symbol.for`, so two copies of this module — two
79
+ * bundles, two entry points — still agree on it.
80
+ */
81
+ declare const SET_OF: unique symbol;
82
+ /**
83
+ * A subject set `setOf()` made: everyone who holds `relation` on `type:id`.
84
+ *
85
+ * Marked, because a user is passed to `can()` and `grant()` as it is, fields
86
+ * flat on it: a user whose fields include `relation` must stay that user, and
87
+ * never read as the set of whoever holds that relation on them. For an object
88
+ * type, `{ type, id, relation }` is a set as written; for a user type — one
89
+ * the model also declares as an object type — only `setOf()` makes one.
90
+ *
91
+ * A spread keeps the mark. `JSON.stringify` and `structuredClone` drop it:
92
+ * through either, a set comes back as a user — call `setOf()` again, or
93
+ * `parseSubject()` on its notation.
94
+ */
95
+ export type SetOf<Type extends string = string, Relation extends string = string> = SubjectSet & {
96
+ readonly type: Type;
97
+ readonly relation: Relation;
98
+ readonly [SET_OF]: true;
99
+ };
100
+ /**
101
+ * A user or an object, never a set `setOf()` made: where a relation admits
102
+ * `staff` but not `staff#managers`, a set passed there is refused at compile
103
+ * time as it is at run time.
104
+ */
105
+ export type NotASet = {
106
+ readonly [SET_OF]?: never;
107
+ };
108
+ /**
109
+ * Everyone who holds `relation` on this user or object — `staff:s1#managers`.
110
+ *
111
+ * ```ts
112
+ * await access.grant(record, 'viewers', setOf(ada, 'managers'));
113
+ * ```
114
+ *
115
+ * Copies `type` and `id` only, like {@link subjectOf}: none of a user's own
116
+ * fields reaches a tuple.
117
+ */
118
+ export declare function setOf<const Type extends string, const Relation extends string>(entity: {
119
+ readonly type: Type;
120
+ readonly id: SubjectId;
121
+ }, relation: Relation): SetOf<Type, Relation>;
122
+ /**
123
+ * Whether `setOf()` made this value, or a spread of one — what `can()` and
124
+ * `grant()` read to tell a set on a user type from a user.
125
+ */
126
+ export declare function isSetOf(value: unknown): value is SetOf;
127
+ export {};
75
128
  //# sourceMappingURL=subject.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"subject.d.ts","sourceRoot":"","sources":["../../src/subjects/subject.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,SAAS,GAAG,MAAM,CAAC;AAE/B;;;;;;;;GAQG;AACH,MAAM,WAAW,MAAM;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,UAAW,SAAQ,MAAM;IACzC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC1B;AAED;;;GAGG;AACH,MAAM,MAAM,OAAO,GAAG,MAAM,GAAG,UAAU,CAAC;AAE1C;;;;;;GAMG;AACH,MAAM,WAAW,aAAa;IAC7B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;CAC1B;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,IAAI,UAAU,CAEpE;AAED;;;;;;;GAOG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE;IAC/B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,EAAE,SAAS,CAAC;CACvB,GAAG,MAAM,CAET"}
1
+ {"version":3,"file":"subject.d.ts","sourceRoot":"","sources":["../../src/subjects/subject.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,SAAS,GAAG,MAAM,CAAC;AAE/B;;;;;;;;GAQG;AACH,MAAM,WAAW,MAAM;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,UAAW,SAAQ,MAAM;IACzC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC1B;AAED;;;GAGG;AACH,MAAM,MAAM,OAAO,GAAG,MAAM,GAAG,UAAU,CAAC;AAE1C;;;;;;GAMG;AACH,MAAM,WAAW,aAAa;IAC7B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;CAC1B;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,IAAI,UAAU,CAEpE;AAED;;;;;;;GAOG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE;IAC/B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,EAAE,SAAS,CAAC;CACvB,GAAG,MAAM,CAET;AAED;;;;;GAKG;AACH,QAAA,MAAM,MAAM,EAAE,OAAO,MAAwC,CAAC;AAE9D;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,KAAK,CAChB,IAAI,SAAS,MAAM,GAAG,MAAM,EAC5B,QAAQ,SAAS,MAAM,GAAG,MAAM,IAC7B,UAAU,GAAG;IAChB,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC;IACpB,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAC;IAC5B,QAAQ,CAAC,CAAC,MAAM,CAAC,EAAE,IAAI,CAAC;CACxB,CAAC;AAEF;;;;GAIG;AACH,MAAM,MAAM,OAAO,GAAG;IAAE,QAAQ,CAAC,CAAC,MAAM,CAAC,CAAC,EAAE,KAAK,CAAA;CAAE,CAAC;AAEpD;;;;;;;;;GASG;AACH,wBAAgB,KAAK,CAAC,KAAK,CAAC,IAAI,SAAS,MAAM,EAAE,KAAK,CAAC,QAAQ,SAAS,MAAM,EAC7E,MAAM,EAAE;IAAE,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,EAAE,EAAE,SAAS,CAAA;CAAE,EACvD,QAAQ,EAAE,QAAQ,GAChB,KAAK,CAAC,IAAI,EAAE,QAAQ,CAAC,CAkBvB;AAED;;;GAGG;AACH,wBAAgB,OAAO,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,KAAK,CAMtD"}
package/docs/README.md CHANGED
@@ -28,6 +28,6 @@ below are defined once, in [Words](guide/vocabulary.md#words).
28
28
  | --- | --- |
29
29
  | [The shared vocabulary](guide/vocabulary.md) | You want the words the documentation uses, or subjects and the tuple notation, ids, cursors, durations or `fixedClock` |
30
30
  | [Errors](guide/errors.md) | You are turning what the package throws into a status code, and want every code and what it carries |
31
- | [Writing an adapter](guide/adapters.md) | You are implementing the identity stores or the relation store for your database, and running the conformance suites |
31
+ | [Writing an adapter](guide/adapters.md) | You are implementing the identity stores or the relation store for your database, upgrading one for `countAttempt` and the second factor, or running the conformance suites |
32
32
  | [Troubleshooting](troubleshooting.md) | You have an error message and want its cause and its fix |
33
33
  | [Roadmap](roadmap.md) | You want to know what is coming, what shipped, and what is deliberately not planned |
@@ -31,7 +31,7 @@ describeJanusStores({
31
31
 
32
32
  | Port | Taken by | Methods |
33
33
  | --- | --- | --- |
34
- | `JanusStores` — `{ users: UserStore, sessions: SessionStore, tokens: TokenStore }` | `janus({ store })` | 6 + 6 (+ 1 optional) + 3 |
34
+ | `JanusStores` — `{ users: UserStore, sessions: SessionStore, tokens: TokenStore }` | `janus({ store })` | 6 + 6 (+ 1 optional) + 4 |
35
35
  | `RelationStore` | `permissions({ store })`, `janus({ relations })` | 6 |
36
36
 
37
37
  They are separate on purpose: an application that only authenticates
@@ -77,6 +77,43 @@ interface UserStore {
77
77
  - `listUsers` pages in ascending id order; `after` is the last id of the
78
78
  previous page, already checked by the core.
79
79
 
80
+ #### A user's password and second factor
81
+
82
+ Both are one field of `UserRecord`, `null` when the user has none, and a
83
+ patch treats both alike: **absent keeps it, `null` removes it, a value
84
+ replaces it whole**.
85
+
86
+ ```ts
87
+ interface UserRecord {
88
+ // …id, type, schemaVersion, active, fields, logins…
89
+ readonly password: { readonly hash: string; readonly updatedAt: Date } | null;
90
+ readonly secondFactor: {
91
+ readonly method: 'totp';
92
+ readonly secret: string; // opaque: store it byte for byte
93
+ readonly confirmedAt: Date | null; // null while enrolment waits for a first code
94
+ readonly lastStep: number | null; // the time step of the last code accepted
95
+ } | null;
96
+ // …emailVerifiedAt, version, createdAt, updatedAt
97
+ }
98
+ ```
99
+
100
+ `secret` is **opaque to a store**: once the second factor ships, the core
101
+ will seal it with a key the application holds before a store sees it, so a
102
+ dump of the users cannot produce a code. Keep it like a password hash — byte
103
+ for byte, no parsing, no trimming. Store
104
+ the second factor whole: a method without a secret, or a `lastStep` without a
105
+ method, is a record the core never writes.
106
+
107
+ ```ts
108
+ import type { UserPatch } from '@nxgt/janus';
109
+
110
+ const keep: UserPatch = { updatedAt: new Date() }; // secondFactor untouched
111
+ const remove: UserPatch = { updatedAt: new Date(), secondFactor: null };
112
+ ```
113
+
114
+ An adapter that stored users before this field existed reads its absence as
115
+ `null`, never `undefined` (rule 2): a user with no second factor holds `null`.
116
+
80
117
  ### `SessionStore` and `TokenStore`
81
118
 
82
119
  ```ts
@@ -93,6 +130,7 @@ interface SessionStore {
93
130
  interface TokenStore {
94
131
  insertToken(record: TokenRecord): Promise<void>;
95
132
  consumeToken(tokenHash: string, kind: TokenKind, at: Date): Promise<TokenRecord | null>;
133
+ countAttempt(tokenHash: string, kind: TokenKind): Promise<TokenRecord | null>;
96
134
  deleteUserTokens(userId: Id): Promise<number>;
97
135
  }
98
136
  ```
@@ -103,12 +141,83 @@ Twenty concurrent calls must produce exactly one answer with `spentAt: null`;
103
141
  in MongoDB that is one `findOneAndUpdate` returning the document before the
104
142
  update. A read followed by a write lets two requests redeem one reset token.
105
143
 
144
+ A token is its hash, never its secret, and what it is for:
145
+
146
+ ```ts
147
+ type TokenKind = 'verifyEmail' | 'resetPassword' | 'secondFactor' | 'signInCode';
148
+
149
+ interface TokenRecord {
150
+ readonly tokenHash: string;
151
+ readonly kind: TokenKind;
152
+ readonly userId: Id;
153
+ readonly address: string;
154
+ readonly codeHash: string | null; // a signInCode's code, hashed; null for every other kind
155
+ readonly attempts: number; // 0 at insertion
156
+ readonly expiresAt: Date;
157
+ readonly spentAt: Date | null;
158
+ readonly createdAt: Date;
159
+ }
160
+ ```
161
+
162
+ A token redeemed for another kind is unknown: every method that takes a
163
+ `kind` matches on it. `codeHash` and `attempts` round-trip like every other
164
+ field. An adapter whose stored tokens predate them reads them as `null` and
165
+ `0`, as the three published adapters do, so no data migration is needed for
166
+ them.
167
+
106
168
  Expiry is the core's decision: a read answers a stored session verbatim,
107
169
  lapsed or revoked, and never a record it has changed. A store with its own
108
170
  expiry — a TTL index, a Redis key TTL — may drop a lapsed session or token
109
171
  before anyone asks: reads then answer `null`, and `deleteUserSessions` does
110
172
  not count it. The conformance suite accepts both.
111
173
 
174
+ ### `TokenStore.countAttempt`
175
+
176
+ Counts one attempt at a code against a token, and answers the token **as it
177
+ is after the call** — what bounds the attempts at a six-digit code:
178
+
179
+ | The stored token | Written | Answered |
180
+ | --- | --- | --- |
181
+ | unspent, of this `kind` | `attempts + 1` | the token, with the new count |
182
+ | spent, of this `kind` | nothing | the token as it is |
183
+ | another `kind`, or no token with this hash | nothing | `null` |
184
+
185
+ Like `consumeToken`, it is **one conditional write**, never a read followed by
186
+ a write: twenty concurrent calls answer the counts 1 to 20, each once. A count
187
+ two attempts both read is an attempt for free. Whether the count is past the
188
+ limit, and whether the code matches, is the core's decision after the call;
189
+ spending the token stays `consumeToken`'s.
190
+
191
+ A **lapsed** token is counted all the same, or answered `null` by a store that
192
+ has already dropped it (a TTL index, a Redis key TTL). Do not compare
193
+ `expiresAt` in the store: as for `consumeToken`, the core compares it after
194
+ the call.
195
+
196
+ In MongoDB, one `findOneAndUpdate` answering the document after it, then a
197
+ plain read for the spent case:
198
+
199
+ ```ts
200
+ import type { TokenStore } from '@nxgt/janus';
201
+
202
+ // tokens: your collection; toToken: your document → TokenRecord
203
+ export const countAttempt: TokenStore['countAttempt'] = async (tokenHash, kind) => {
204
+ const after = await tokens.findOneAndUpdate(
205
+ { _id: tokenHash, kind, spentAt: null },
206
+ { $inc: { attempts: 1 } },
207
+ { returnDocument: 'after' },
208
+ );
209
+ if (after !== null) return toToken(after);
210
+ const spent = await tokens.findOne({ _id: tokenHash, kind }); // written nothing
211
+ return spent === null ? null : toToken(spent);
212
+ };
213
+ ```
214
+
215
+ In SQL, `update … set attempts = attempts + 1 where token_hash = $1 and kind =
216
+ $2 and spent_at is null returning *`, then the same plain read. In Redis, one
217
+ Lua script: `HINCRBY` only when `spentAt` is empty, then `HGETALL`. Wrap the
218
+ driver's error in `StoreFailure` as in [the six rules](#the-six-rules):
219
+ `countAttempt` has its own outage case.
220
+
112
221
  ### `RelationStore`
113
222
 
114
223
  ```ts
@@ -172,7 +281,7 @@ An adapter **defines no error class**. It throws `@nxgt/janus`'s own
172
281
  copy of each class and `instanceof` holds in the application. A cursor it
173
282
  cannot read is `invalidCursor(where, cursor)`. Records, patches and page
174
283
  requests are exported as types: `UserRecord`, `UserPatch`, `UserPageRequest`,
175
- `PasswordRecord`, `SessionRecord`, `TokenRecord`, `TokenKind`, `Json`,
284
+ `PasswordRecord`, `SecondFactorRecord`, `SessionRecord`, `TokenRecord`, `TokenKind`, `Json`,
176
285
  `JsonObject`, and `ObjectPageRequest`, `RelationChanges` from
177
286
  `@nxgt/janus/permissions`.
178
287
 
@@ -185,7 +294,7 @@ compile error naming the missing method.
185
294
 
186
295
  | Suite | Cases | Harness opens |
187
296
  | --- | --- | --- |
188
- | `describeJanusStores({ name, harness, runner?, faults?, skip? })` | 38: users, sessions, tokens, and one outage per method whose honest answer can be "nothing" | `{ stores, faults?, close? }` |
297
+ | `describeJanusStores({ name, harness, runner?, faults?, skip? })` | 44: users, sessions, tokens, and one outage per method whose honest answer can be "nothing" — twelve of them | `{ stores, faults?, close? }` |
189
298
  | `describeRelationStores({ name, harness, runner?, faults?, skip? })` | 15: the relation store, and one outage per method | `{ store, faults?, close? }` |
190
299
 
191
300
  `harness.open()` is called **once per case** and must answer fresh, empty
@@ -202,6 +311,18 @@ stores: a case that leaks into the next is the hardest failure to debug.
202
311
 
203
312
  The suites import no test framework and no assertion library.
204
313
 
314
+ The second factor and attempts have their own cases — skip one by its id
315
+ while you work on it, never to ship:
316
+
317
+ | Case | Checks |
318
+ | --- | --- |
319
+ | `users.secondFactorSlot` | round-trip; a patch not naming it keeps it; `null` removes it |
320
+ | `tokens.countAttempt` | two calls answer `attempts` 1 then 2, `codeHash` as written; `consumeToken` answers the count |
321
+ | `tokens.countAttemptConcurrency` | twenty concurrent calls answer 1 to 20, each once |
322
+ | `tokens.countAttemptRace` | attempts racing one redemption: the counts answered unspent are 1 to the final count, and every answer after the spend carries that final count |
323
+ | `tokens.countAttemptSpent` | a spent token answered unchanged; another kind and an unknown hash answer `null` and count nothing |
324
+ | `outage.countAttempt` | a store that cannot answer rejects, never `null` |
325
+
205
326
  ### `faults`: prove the outage invariant
206
327
 
207
328
  `faults` is optional, and **its absence is reported, never passed over**:
@@ -182,8 +182,6 @@ the key you wrote, `types.record.permits.view` or `types.team.related.members`:
182
182
  - `relations` or `permissions` — the keys before 0.2 — and any key other than
183
183
  `related` and `permits`;
184
184
  - a name that is not camelCase;
185
- - an object type named like a user type — see
186
- [permissions on a user](#permissions-on-a-user);
187
185
  - a permission that reaches itself without crossing a relation (`view:
188
186
  ['edit'], edit: ['view']`) — no data could ever end that loop;
189
187
  - a subject set or an arrow that would have to read **another** object's
@@ -196,8 +194,8 @@ fine: the data ends it. See [a hierarchy](#a-hierarchy).
196
194
  ## Use cases
197
195
 
198
196
  Each case below runs against the clinic above, in order — except the two that
199
- say otherwise: a folder tree for a hierarchy, and an `account` for
200
- permissions on a user.
197
+ say otherwise: a folder tree for a hierarchy, and staff members who manage
198
+ each other for permissions on a user.
201
199
 
202
200
  ### A direct relation
203
201
 
@@ -231,6 +229,10 @@ await access.grant(team, 'members', { type: 'team', id: 't2', relation: 'members
231
229
  await access.can(grace, 'view', team); // true: grace is a member of t2, whose members are members of t1
232
230
  ```
233
231
 
232
+ `setOf(cardiology, 'members')`, from `@nxgt/janus`, writes the same set. On an
233
+ object type the two are the same; on a user type only `setOf` makes a set —
234
+ see [permissions on a user](#permissions-on-a-user).
235
+
234
236
  The set is followed as the data stands: revoke grace from `t2`, and she no
235
237
  longer views `t1`. A team member of itself — a cycle in the data — is cut, and
236
238
  is not an error.
@@ -347,38 +349,68 @@ await access.list(grace, 'edit', 'record', { ctx: { onShift: true } });
347
349
 
348
350
  ### Permissions on a user
349
351
 
350
- A user type cannot also be an object type: `defineModel` refuses `staff` in
351
- `types` — `"staff" names a user type and an object type`. To decide who may
352
- edit a staff member's account, declare an object type **whose id is the
353
- user's id**, and read it with `fromField('id', …)`:
352
+ A user type may also be an object type: declare `staff` under `types`, and a
353
+ staff member is an object like a record — asked `can()`, granted relations,
354
+ listed. To decide who may edit a staff member, give `staff` the relations that
355
+ say so:
354
356
 
355
357
  ```ts
356
- const accounts = permissions({
358
+ import { setOf } from '@nxgt/janus';
359
+
360
+ const people = permissions({
357
361
  model: defineModel({
358
362
  subjects: auth.types,
359
363
  types: {
360
- account: {
364
+ staff: {
361
365
  related: {
362
366
  self: fromField('id', 'staff', { lookup: async (staffId) => [staffId] }),
363
- managers: ['staff'],
367
+ managers: ['staff', 'staff#managers'],
364
368
  },
365
369
  permits: { edit: ['self', 'managers'] },
366
370
  },
371
+ note: {
372
+ related: { readers: ['staff', 'staff#managers'] },
373
+ permits: { read: ['readers'] },
374
+ },
367
375
  },
368
376
  }),
369
377
  store: relations,
370
378
  });
371
379
 
372
380
  const bob = await auth.staff.create({ username: 'bob' });
373
- await accounts.can(bob, 'edit', { type: 'account', id: bob.id }); // true: his own account
374
- await accounts.can(ada, 'edit', { type: 'account', id: bob.id }); // false
375
- await accounts.grant({ type: 'account', id: bob.id }, 'managers', ada);
376
- await accounts.can(ada, 'edit', { type: 'account', id: bob.id }); // true: she manages it
377
- await accounts.list(ada, 'edit', 'account'); // her own account, and bob's
381
+ await people.can(bob, 'edit', bob); // true: himself, read from his id
382
+ await people.can(ada, 'edit', bob); // false
383
+ await people.grant(bob, 'managers', ada);
384
+ await people.can(ada, 'edit', bob); // true: she manages him
385
+ await people.list(ada, 'edit', 'staff'); // herself, and bob
386
+ ```
387
+
388
+ **A user passed as it is, is that user** — never a set, even when one of its
389
+ fields is named `relation`. A set on a user type is made by **`setOf`**: every
390
+ staff member who manages bob reads the note, as the data stands:
391
+
392
+ ```ts
393
+ const note = { type: 'note', id: 'n1' } as const;
394
+ await people.grant(note, 'readers', setOf(bob, 'managers'));
395
+ await people.can(ada, 'read', note); // true: she manages bob
396
+ await people.can(bob, 'read', note); // false: bob is not his own manager
378
397
  ```
379
398
 
380
- The `lookup` answers the one account whose id is the subject's, so `list()`
381
- finds a user's own account too.
399
+ Only `setOf` marks a set. `{ type: 'staff', id: bob.id, relation: 'managers' }`
400
+ written out is bob himself at run time: granted silently where the relation
401
+ admits a staff member, refused where it does not. The compiler refuses it in
402
+ `grant()` and `revoke()`; `can()` and `list()` accept it and ask about bob, a
403
+ gap the README names. A spread
404
+ of a set keeps the mark; `JSON` and `structuredClone` drop it, so a set read
405
+ back from either is bob again — call `setOf` on it, or keep its notation
406
+ (`staff:…#managers`), which `parseSubject` reads back as the set. It copies
407
+ `type` and `id` only, so none of bob's fields reaches a tuple. A set on a user type
408
+ the model does not also declare under `types` is refused by `can()`, `list()`,
409
+ `grant()` and `revoke()` — and by the compiler first: there is no relation to
410
+ hold.
411
+
412
+ The `lookup` of `self` answers the one staff member whose id is the subject's,
413
+ so `list()` finds ada herself too.
382
414
 
383
415
  ## `permissions()`
384
416
 
@@ -37,7 +37,7 @@ either finds this row.
37
37
  | Word | Means | Not |
38
38
  | --- | --- | --- |
39
39
  | **user** | One stored person or machine, of one user type | "account" — kept only in *account takeover* and *account enumeration*, the names of those attacks |
40
- | **user type** | A kind of user with its own schema and login: `staff`, `patient`. Wired to permissions, its name is a subject type | "role": a role is a relation in the model |
40
+ | **user type** | A kind of user with its own schema and login: `staff`, `patient`. Wired to permissions, its name is a subject type — and may also be an object type, whose users are then objects too | "role": a role is a relation in the model |
41
41
  | **schema** | A user type's Standard Schema: the fields a user carries | the model |
42
42
  | **login** | The value a user signs in with: an e-mail, a username. **sign in** is the verb, **sign-in** the noun | "identifier" |
43
43
  | **credential** | What a caller presents to prove who they are: a login and a password at sign-in (`CredentialError`, `CREDENTIALS_INVALID`), or a token on a request — always written *session credential* | |
@@ -47,7 +47,9 @@ either finds this row.
47
47
  | **anonymous** | A request that presents no session credential, or one that authenticates nobody: `authenticate` answers `null` | "unauthenticated", "guest", "logged out" |
48
48
  | **bearer client** | A client that sends its session token as `Authorization: Bearer` rather than in a cookie | |
49
49
  | **one-time token** | A single-use token sent by e-mail, for verification or a password reset | "code" alone — `code` is an error's code |
50
- | **one-time code** | Planned, see [the roadmap](../roadmap.md#next): a one-time token short enough to type, sent by e-mail — or, for TOTP, computed by an authenticator app and never sent | "OTP", "PIN", "code" alone |
50
+ | **one-time code** | In progress, see [the roadmap](../roadmap.md): a one-time token short enough to type, sent by e-mail — or, for TOTP, computed by an authenticator app and never sent | "OTP", "PIN", "code" alone |
51
+ | **second factor** | What a user proves at sign-in beyond their password: a TOTP secret their authenticator app holds, `UserRecord.secondFactor`. **Enrolled** when stored, **confirmed** once a first code matched (`confirmedAt`) | "2FA", "MFA", "OTP device" |
52
+ | **attempt** | One code tried against a one-time token, counted by `countAttempt` in the token's `attempts` | "try"; "retry" is a repeated write, never a guess |
51
53
  | **token** | Never alone in prose: a *session token* or a *one-time token*. The `tokens` store and the `TOKEN_*` codes are one-time tokens only | |
52
54
  | **e-mail flow** | `verifyEmail` or `resetPassword`: send a one-time token, then confirm it | |
53
55
 
@@ -57,10 +59,11 @@ either finds this row.
57
59
  | --- | --- | --- |
58
60
  | **model** | What `defineModel()` answers: the subject types, the object types, their relations and permissions | "schema", "policy" |
59
61
  | **subject type** | A name listed in `subjects`: `auth.types` when wired to `janus()`, your own names otherwise | |
60
- | **object** | What a permission is about: `{ type: 'document', id }` | "resource" |
62
+ | **object** | What a permission is about: `{ type: 'document', id }`, or a user whose type the model also declares under `types` | "resource" |
61
63
  | **subject** | Who a permission is about: a user, an object, or a subject set | "principal", "actor" |
62
64
  | **entity** | `{ type, id }`: a user or an object — a subject that is not a set (`Entity`, `deleteEntity`) | |
63
- | **subject set** | Everyone holding one relation on one object: `team:t1#members` | "group" — a group is an object with a `members` relation |
65
+ | **subject set** | Everyone holding one relation on one object: `team:t1#members`. On a user type the model also declares as an object type, only `setOf()` makes one | "group" — a group is an object with a `members` relation |
66
+ | **`setOf`** | The function that makes a subject set from a user or an object and a relation: `setOf(bob, 'managers')`. What it answers is a `SetOf`, and `isSetOf` tells it from a user | |
64
67
  | **relation** | A named link, stored as tuples or read from a field (`fromField`). Declared under `related`, named in the plural: `members`, `doctors` | |
65
68
  | **`related`** | The key of an object type that declares its relations: `related: { members: ['staff'] }`. It was `relations` before 0.2, and the old key is refused | "relations" as a key — the word stays for the idea, and for `janus({ relations })` |
66
69
  | **holder** | What a relation admits: `'staff'`, or the subject set `'team#members'` | |
@@ -114,10 +117,12 @@ a tuple.
114
117
  | --- | --- |
115
118
  | `subjectOf(user)` | `{ type, id }` of any object carrying both |
116
119
  | `isSubjectSet(subject)` | whether `relation` is a string |
120
+ | `setOf(entity, relation)` | the subject set `{ type, id, relation }`, marked: on a user type, the only way to write one |
121
+ | `isSetOf(value)` | whether `setOf` made it, or a spread of it |
117
122
  | `formatEntity(entity)` | `'record:r1'` |
118
123
  | `formatSubject(subject)` | `'staff:u1'`, or `'team:t1#members'` |
119
124
  | `formatTuple(tuple)` | `'team:t1#members@team:t2#members'` |
120
- | `parseSubject(text)` | the `Subject` back |
125
+ | `parseSubject(text)` | the `Entity` back, or a `SetOf` — a set as `setOf` makes it |
121
126
  | `parseTuple(text)` | the `RelationTuple` back |
122
127
 
123
128
  ```ts
package/docs/roadmap.md CHANGED
@@ -5,14 +5,16 @@ dates here, and the version something shipped in is the only number.
5
5
 
6
6
  ## Now
7
7
 
8
- Nothing yet.
9
-
10
- ## Next
11
-
12
8
  - **One-time codes** — a one-time token short enough to type, sent by e-mail
13
9
  to sign in without a password, or to confirm a sensitive action, issued
14
10
  and redeemed by `janus` like the verification and reset tokens today; and
15
- TOTP, the one-time codes of an authenticator app, as a second factor.
11
+ TOTP, the one-time codes of an authenticator app, as a second factor, its
12
+ secret sealed with a key your application holds. `signIn` will answer
13
+ `{ status: 'signedIn' }` or `{ status: 'secondFactor', challenge }`. The
14
+ store port that holds them shipped in v0.4.0, below.
15
+
16
+ ## Next
17
+
16
18
  - **Sending the e-mails** — in a package of its own, `@nxgt/janus-mail`, built
17
19
  on a general mail toolkit shared with applications that are not about
18
20
  sign-in: a `Mailer` port you plug your transport into (SMTP, Resend, SES…) —
@@ -84,6 +86,18 @@ Nothing yet.
84
86
 
85
87
  Each entry names the version it came in.
86
88
 
89
+ - **The store port holds a second factor and counts attempts on a token,
90
+ v0.4.0** — `UserRecord.secondFactor`, a token's `codeHash` and `attempts`,
91
+ the token kinds `'secondFactor'` and `'signInCode'`, and
92
+ `TokenStore.countAttempt`, one conditional write per attempt, with six new
93
+ conformance cases. No flow uses them yet; the published adapters implement
94
+ them in `@nxgt/janus-drizzle` 0.2, `@nxgt/janus-mongo` 0.3 and
95
+ `@nxgt/janus-redis` 0.2.
96
+ - **Permissions on a user, v0.3.0** — a user type may also be an object type:
97
+ a staff member is the object `can()` asks about and is granted relations
98
+ on, like a record, and `grant(note, 'readers', setOf(bob, 'managers'))`
99
+ grants everyone who manages bob at once. A user
100
+ passed as it is stays that user, even with a field named `relation`.
87
101
  - **The adapters and the kit, each at its first release, v0.1.0, beside
88
102
  `@nxgt/janus` 0.2.2** —
89
103
  [`@nxgt/janus-drizzle`](https://www.npmjs.com/package/@nxgt/janus-drizzle),
@@ -208,6 +208,10 @@ Also: `janus: store.<slot> is missing`, `janus: store must be an object with use
208
208
 
209
209
  **When:** `janus({...})`, from JavaScript or with a store typed loosely. TypeScript refuses a partial store at compile time and names the method.
210
210
  **Why:** `store` is `{ users, sessions, tokens }`, and each slot must answer every method of the port. `deleteExpiredSessions` is the one optional method: absent, or a function.
211
+ An adapter written against `@nxgt/janus` 0.3 reports
212
+ `store.tokens has no method countAttempt` until it implements the method 0.4
213
+ added.
214
+
211
215
  **Fix:** pass the three stores, whole:
212
216
 
213
217
  ```ts
@@ -216,6 +220,18 @@ import { createMemoryStores, janus } from '@nxgt/janus';
216
220
  janus({ ..., store: createMemoryStores() });
217
221
  ```
218
222
 
223
+ For `countAttempt`, upgrade the published adapter to the release that
224
+ implements it — `@nxgt/janus-drizzle` 0.2, `@nxgt/janus-mongo` 0.3,
225
+ `@nxgt/janus-redis` 0.2:
226
+
227
+ ```bash
228
+ bun add @nxgt/janus@^0.4 @nxgt/janus-drizzle@^0.2 # or @nxgt/janus-mongo@^0.3, @nxgt/janus-redis@^0.2
229
+ ```
230
+
231
+ Your own adapter implements it as
232
+ [`TokenStore.countAttempt`](guide/adapters.md#tokenstorecountattempt) sets
233
+ out, then runs the conformance suite.
234
+
219
235
  ### `janus: relations must be a relation store — relations.deleteEntity is missing`
220
236
 
221
237
  **When:** `janus({ ..., relations })`.
@@ -571,11 +587,12 @@ Each is a `TypeError` naming the call. TypeScript refuses most of them on the ar
571
587
  | Message | Fix |
572
588
  | --- | --- |
573
589
  | `can: "<name>" is not a relation or a permission of <type>` | Ask a name the type declares. |
574
- | `<call>: "<type>" is not an object type of the model` | Use a type declared under `types`. |
590
+ | `<call>: "<type>" is not an object type of the model` | Use a type declared under `types`. Also for a subject set — `setOf` included — whose type is neither a user type nor an object type. |
575
591
  | `<call>: the object must be { type, id, …its fields }` | `{ type: 'record', ...record }`. |
576
592
  | `<call>: the subject must be a user, or { type, id }` | Pass the user from `janus()`, or `{ type, id }`. `null` is anonymous and answers `false`. |
577
593
  | `<call>: the object id must be a non-empty string without @, # or parentheses` | Also for `the subject id`. Those characters belong to the tuple notation. |
578
- | `<call>: "<relation>" is not a relation of <type>, so <type>:<id>#<relation> is no subject set` | A subject set names a relation of its type: `{ type: 'team', id, relation: 'members' }`. |
594
+ | `<call>: "<relation>" is not a relation of <type>, so <type>#<relation> is no subject set` | A subject set names a relation of its type: `{ type: 'team', id, relation: 'members' }`. |
595
+ | `<call>: <type> is a user type the model does not declare as an object type, so <type>#<relation> is no subject set` | `setOf(user, relation)` names a relation on that user: declare the user type under `types` too, with that relation — see [permissions on a user](guide/permissions.md#permissions-on-a-user). The compiler refuses it first. |
579
596
  | `grant: "<relation>" is not a relation of <type>` | Grant a relation, never a permission. |
580
597
  | `list: the type must be an object type of the model` | The third argument is a type name: `'record'`. |
581
598
  | `list: after must be the nextCursor of a page, or null` | Pass `nextCursor` back as it came. |
@@ -594,7 +611,6 @@ most common:
594
611
  | `defineModel: types declares no object type` | Declare at least one type under `types`. |
595
612
  | `defineModel: types.<type> must be an object` | Also `types.<type>.related must be an object` and `types.<type>.permits must be an object`: each is keyed by name — `related: { members: ['staff'] }`. |
596
613
  | `defineModel: the object type "<name>" must be a camelCase name — letters and digits, starting with a lowercase letter` | Also `types.<type>.related: "<name>" must be a camelCase name — …` for a relation, and `types.<type>.permits: …` for a permission. |
597
- | `defineModel: "<name>" names a user type and an object type; a subject of type "<name>" would be ambiguous` | Rename the object type. For permissions on a user, see [the guide](guide/permissions.md#permissions-on-a-user). |
598
614
  | `defineModel: types.<type>: "<name>" names a relation and a permission; rename one` | One name, one meaning. |
599
615
  | `defineModel: types.<type>.related.<relation> must be a non-empty array of subject types, or fromField()` | `members: ['staff']`, or `doctors: fromField('doctorId', 'staff')`. |
600
616
  | `defineModel: types.<type>.related.<relation>: "<holder>" is not a subject type` | Also `"<holder>" is not a subject set — it must name an object type and one of its relations`: `'team#members'`, a declared type and one of its relations. |
@@ -634,6 +650,21 @@ parseTuple('record:r1#teams@team:t1');
634
650
  parseTuple('team:t1#members@team:t2#members');
635
651
  ```
636
652
 
653
+ ### `setOf: pass a user or { type, id }, then a relation`
654
+
655
+ Also `setOf: the relation must be a non-empty string`.
656
+
657
+ **When:** `setOf(...)`, a `TypeError`.
658
+ **Why:** a set is everyone holding one relation on one user or object: it needs both.
659
+ **Fix:**
660
+
661
+ ```ts
662
+ import { setOf } from '@nxgt/janus';
663
+
664
+ setOf(bob, 'managers'); // a user from janus()
665
+ setOf({ type: 'team', id: 't1' }, 'members'); // an object
666
+ ```
667
+
637
668
  ---
638
669
 
639
670
  ## Conformance (adapter authors)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/janus",
3
- "version": "0.2.2",
3
+ "version": "0.4.0",
4
4
  "description": "Embeddable, type-safe identities and permissions: bring your own database",
5
5
  "license": "MIT",
6
6
  "type": "module",