@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.
- package/README.md +87 -14
- package/dist/auth/index.d.ts +1 -1
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/port/types.d.ts +70 -2
- package/dist/auth/port/types.d.ts.map +1 -1
- package/dist/auth/users.d.ts.map +1 -1
- package/dist/chunks/{index-hm4v76kd.js → index-53y1afjz.js} +2 -2
- package/dist/chunks/{index-fgb3t64y.js → index-mgh85djb.js} +2 -2
- package/dist/chunks/{index-6p56fpbe.js → index-qwfkhqkk.js} +22 -4
- package/dist/chunks/{index-6p56fpbe.js.map → index-qwfkhqkk.js.map} +4 -4
- package/dist/chunks/{index-0xarpm7z.js → index-thtyq7a9.js} +14 -3
- package/dist/chunks/{index-0xarpm7z.js.map → index-thtyq7a9.js.map} +3 -3
- package/dist/conformance/cases/outage.d.ts.map +1 -1
- package/dist/conformance/cases/tokens.d.ts.map +1 -1
- package/dist/conformance/cases/users.d.ts.map +1 -1
- package/dist/conformance/fixtures.d.ts.map +1 -1
- package/dist/conformance/index.js +111 -5
- package/dist/conformance/index.js.map +6 -6
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -5
- package/dist/index.js.map +4 -4
- package/dist/permissions/index.js +47 -21
- package/dist/permissions/index.js.map +6 -6
- package/dist/permissions/input.d.ts +5 -3
- package/dist/permissions/input.d.ts.map +1 -1
- package/dist/permissions/model.d.ts +19 -8
- package/dist/permissions/model.d.ts.map +1 -1
- package/dist/permissions/resolve.d.ts.map +1 -1
- package/dist/permissions/reverse.d.ts.map +1 -1
- package/dist/subjects/notation.d.ts +7 -3
- package/dist/subjects/notation.d.ts.map +1 -1
- package/dist/subjects/subject.d.ts +53 -0
- package/dist/subjects/subject.d.ts.map +1 -1
- package/docs/README.md +1 -1
- package/docs/guide/adapters.md +124 -3
- package/docs/guide/permissions.md +50 -18
- package/docs/guide/vocabulary.md +10 -5
- package/docs/roadmap.md +19 -5
- package/docs/troubleshooting.md +34 -3
- package/package.json +1 -1
- /package/dist/chunks/{index-hm4v76kd.js.map → index-53y1afjz.js.map} +0 -0
- /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;
|
|
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
|
-
/**
|
|
34
|
-
|
|
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,
|
|
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 |
|
package/docs/guide/adapters.md
CHANGED
|
@@ -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) +
|
|
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? })` |
|
|
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
|
|
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
|
|
351
|
-
|
|
352
|
-
edit a staff member
|
|
353
|
-
|
|
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
|
-
|
|
358
|
+
import { setOf } from '@nxgt/janus';
|
|
359
|
+
|
|
360
|
+
const people = permissions({
|
|
357
361
|
model: defineModel({
|
|
358
362
|
subjects: auth.types,
|
|
359
363
|
types: {
|
|
360
|
-
|
|
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
|
|
374
|
-
await
|
|
375
|
-
await
|
|
376
|
-
await
|
|
377
|
-
await
|
|
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
|
-
|
|
381
|
-
|
|
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
|
|
package/docs/guide/vocabulary.md
CHANGED
|
@@ -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** |
|
|
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 `
|
|
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),
|
package/docs/troubleshooting.md
CHANGED
|
@@ -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
|
|
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
|
File without changes
|
|
File without changes
|