@nxgt/janus 0.1.0 → 0.1.2
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 +121 -25
- package/dist/auth/config.d.ts +3 -3
- package/dist/auth/config.d.ts.map +1 -1
- package/dist/auth/context.d.ts +1 -1
- package/dist/auth/context.d.ts.map +1 -1
- package/dist/auth/janus.d.ts +2 -2
- package/dist/auth/janus.d.ts.map +1 -1
- package/dist/auth/outage.d.ts +11 -17
- package/dist/auth/outage.d.ts.map +1 -1
- package/dist/auth/port/types.d.ts +6 -6
- package/dist/chunks/index-6p56fpbe.js.map +2 -2
- package/dist/chunks/{index-6dytvy3h.js → index-hm4v76kd.js} +7 -29
- package/dist/chunks/index-hm4v76kd.js.map +11 -0
- package/dist/conformance/assert.d.ts +1 -1
- package/dist/conformance/index.js +3 -3
- package/dist/conformance/index.js.map +5 -5
- package/dist/errors/janus-error.d.ts +1 -1
- package/dist/index.d.ts +9 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +33 -12
- package/dist/index.js.map +7 -6
- package/dist/permissions/index.d.ts +1 -1
- package/dist/permissions/index.d.ts.map +1 -1
- package/dist/permissions/index.js +3 -3
- package/dist/permissions/index.js.map +5 -5
- package/dist/permissions/model.d.ts +36 -30
- package/dist/permissions/model.d.ts.map +1 -1
- package/dist/stores/guard.d.ts +24 -0
- package/dist/stores/guard.d.ts.map +1 -0
- package/dist/subjects/subject.d.ts +2 -2
- package/docs/README.md +19 -3
- package/docs/guide/adapters.md +4 -4
- package/docs/guide/errors.md +3 -3
- package/docs/guide/passwords.md +1 -1
- package/docs/guide/permissions.md +23 -6
- package/docs/guide/sessions.md +2 -2
- package/docs/guide/users.md +8 -7
- package/docs/guide/vocabulary.md +75 -3
- package/docs/roadmap.md +13 -5
- package/docs/troubleshooting.md +9 -9
- package/package.json +2 -2
- package/dist/chunks/index-6dytvy3h.js.map +0 -11
package/docs/README.md
CHANGED
|
@@ -3,15 +3,31 @@
|
|
|
3
3
|
The [README](../README.md) shows that it works; these pages show how, one area
|
|
4
4
|
at a time, with an example for every option.
|
|
5
5
|
|
|
6
|
+
Janus has two sides, each usable alone — see
|
|
7
|
+
[Three ways to use it](../README.md#three-ways-to-use-it) — and the words
|
|
8
|
+
below are defined once, in [Words](guide/vocabulary.md#words).
|
|
9
|
+
|
|
10
|
+
### Identities — `janus()`
|
|
11
|
+
|
|
6
12
|
| Page | Read it when |
|
|
7
13
|
| --- | --- |
|
|
8
|
-
| [Users
|
|
14
|
+
| [Users](guide/users.md) | You are wiring `janus()`, declaring one or several user types, or calling `create`, `update`, `list`, `delete` |
|
|
9
15
|
| [Sessions](guide/sessions.md) | You need to know who a request belongs to, set or clear the cookie, renew, sign out, or test expiry |
|
|
10
16
|
| [E-mail verification and password reset](guide/email-flows.md) | You are sending a verification or reset link, and handling what comes back |
|
|
11
17
|
| [Password hashing](guide/passwords.md) | You are choosing a hasher, raising its cost, moving to argon2id, or importing hashes from another system |
|
|
18
|
+
|
|
19
|
+
### Permissions — `@nxgt/janus/permissions`
|
|
20
|
+
|
|
21
|
+
| Page | Read it when |
|
|
22
|
+
| --- | --- |
|
|
12
23
|
| [Permissions](guide/permissions.md) | You are modelling access with `defineModel`, `fromField` and `when`, and calling `can`, `list`, `grant`, `revoke` |
|
|
24
|
+
|
|
25
|
+
### Shared by both sides
|
|
26
|
+
|
|
27
|
+
| Page | Read it when |
|
|
28
|
+
| --- | --- |
|
|
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` |
|
|
13
30
|
| [Errors](guide/errors.md) | You are turning what the package throws into a status code, and want every code and what it carries |
|
|
14
|
-
| [
|
|
15
|
-
| [Writing an adapter](guide/adapters.md) | You are implementing the store port 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, and running the conformance suites |
|
|
16
32
|
| [Troubleshooting](troubleshooting.md) | You have an error message and want its cause and its fix |
|
|
17
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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Writing an adapter — the
|
|
1
|
+
# Writing an adapter — the ports and `@nxgt/janus/conformance`
|
|
2
2
|
|
|
3
3
|
This page is for putting `janus()` or `permissions()` on a database of your
|
|
4
4
|
choice: the two ports you implement, the rules they carry, and the conformance
|
|
@@ -100,7 +100,7 @@ interface TokenStore {
|
|
|
100
100
|
and answers it **as it was before the call**, in **one conditional write**.
|
|
101
101
|
Twenty concurrent calls must produce exactly one answer with `spentAt: null`;
|
|
102
102
|
in MongoDB that is one `findOneAndUpdate` returning the document before the
|
|
103
|
-
update. A read followed by a write lets two requests redeem one reset
|
|
103
|
+
update. A read followed by a write lets two requests redeem one reset token.
|
|
104
104
|
|
|
105
105
|
Expiry is the core's decision: a read answers a stored session verbatim,
|
|
106
106
|
lapsed or revoked, and never a record it has changed.
|
|
@@ -278,10 +278,10 @@ for (const conformanceCase of allCases) {
|
|
|
278
278
|
|
|
279
279
|
| Export | What it is |
|
|
280
280
|
| --- | --- |
|
|
281
|
-
| `allCases`, `userStoreCases`, `sessionStoreCases`, `tokenStoreCases`, `outageCases` | The
|
|
281
|
+
| `allCases`, `userStoreCases`, `sessionStoreCases`, `tokenStoreCases`, `outageCases` | The identity stores' cases, as `ConformanceCase` objects with a stable `id` |
|
|
282
282
|
| `runCase(case, harness)` | Runs one; throws on failure, answers `{ passed: true }` or `{ skipped }` |
|
|
283
283
|
| `SKIP_REASONS` | The reasons the suite gives itself |
|
|
284
|
-
| `allRelationCases`, `relationStoreCases`, `relationOutageCases`, `runRelationCase` | The same for the relation
|
|
284
|
+
| `allRelationCases`, `relationStoreCases`, `relationOutageCases`, `runRelationCase` | The same for the relation store |
|
|
285
285
|
| `referenceHarness()`, `referenceRelationHarness()` | The suites against the reference stores: the examples to copy |
|
|
286
286
|
|
|
287
287
|
Types: `ConformanceHarness`, `OpenedStores`, `StoreFaults`, `ConformanceCase`,
|
package/docs/guide/errors.md
CHANGED
|
@@ -53,8 +53,8 @@ instead of answering `undefined`.
|
|
|
53
53
|
answers `null`, `false` or an empty page. A store that cannot answer — a
|
|
54
54
|
refused connection, a timeout, a primary stepping down, a bug in the adapter —
|
|
55
55
|
rejects with `STORE_FAILED`. Answer 503. Mapping it to a 404, to `null` or to
|
|
56
|
-
`false` turns an outage into a silent lockout:
|
|
57
|
-
told they do not.
|
|
56
|
+
`false` turns an outage into a silent lockout: every user is
|
|
57
|
+
told they do not exist.
|
|
58
58
|
|
|
59
59
|
## Two kinds of refusal
|
|
60
60
|
|
|
@@ -109,7 +109,7 @@ async function signIn(email: string, password: string): Promise<Response> {
|
|
|
109
109
|
```
|
|
110
110
|
|
|
111
111
|
- **Never put `reason` in a response body.** `unknownLogin` tells an attacker
|
|
112
|
-
which
|
|
112
|
+
which users exist.
|
|
113
113
|
- **`STORE_FAILED` is never a 401 or a 404.** Test for it before anything that
|
|
114
114
|
would read as "no".
|
|
115
115
|
- **`VERSION_CONFLICT` is a retry**: read the user again, reapply, write with
|
package/docs/guide/passwords.md
CHANGED
|
@@ -127,7 +127,7 @@ per prefix the old system wrote (`$2a$`, `$2b$`, `$2y$` for bcrypt).
|
|
|
127
127
|
- The plain password is never stored, and never appears in an error message.
|
|
128
128
|
- The hash never reaches a `User` — `hasPassword` says whether there is one.
|
|
129
129
|
- `signIn` compares against a dummy hash when nobody holds the login, so the
|
|
130
|
-
time taken does not reveal which
|
|
130
|
+
time taken does not reveal which users exist. The store's own latency
|
|
131
131
|
still can, and that limit is stated rather than denied.
|
|
132
132
|
|
|
133
133
|
## See also
|
|
@@ -6,6 +6,11 @@ Zanzibar's model — relations between objects and subjects, permissions
|
|
|
6
6
|
computed from them — **without its infrastructure**: the tuples live in your
|
|
7
7
|
database, so a read follows a write and there is nothing to cache.
|
|
8
8
|
|
|
9
|
+
It is the **permissions** side, and it is usable alone: the example below wires
|
|
10
|
+
it to `janus()` so the user types become the subjects, but `subjects` takes
|
|
11
|
+
any list of names — `defineModel({ subjects: ['user'], … })` — when your users
|
|
12
|
+
live elsewhere. Importing `@nxgt/janus/permissions` loads no identity code.
|
|
13
|
+
|
|
9
14
|
```ts
|
|
10
15
|
import { z } from 'zod';
|
|
11
16
|
import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
|
|
@@ -46,7 +51,11 @@ await access.can(grace, 'view', team); // false
|
|
|
46
51
|
## The model
|
|
47
52
|
|
|
48
53
|
```ts
|
|
49
|
-
function defineModel<
|
|
54
|
+
function defineModel<
|
|
55
|
+
const Subjects extends readonly string[], // auth.types
|
|
56
|
+
const Ts extends ModelConfig['types'] & ModelTypesOf<Subjects[number], Ts>, // what your editor completes
|
|
57
|
+
>(config: { readonly subjects: Subjects; readonly types: Ts }): PermissionModel<{ readonly subjects: Subjects; readonly types: Ts }>;
|
|
58
|
+
// ModelTypesOf<S, Ts>: the names each relation and rule of each type may take
|
|
50
59
|
|
|
51
60
|
interface ModelConfig {
|
|
52
61
|
readonly subjects: readonly string[]; // pass auth.types
|
|
@@ -84,15 +93,23 @@ like a permission.
|
|
|
84
93
|
|
|
85
94
|
A relation naming a type that does not exist, a rule naming nothing, an arrow
|
|
86
95
|
to a permission its target lacks, a name that is both a relation and a
|
|
87
|
-
permission: each is a compile error **on the offending
|
|
88
|
-
|
|
96
|
+
permission: each is a compile error **on the offending name** — on the whole
|
|
97
|
+
`fromField(…)` or `when(…)` call for those two. Except for that last one,
|
|
98
|
+
which says to rename one, the error lists what you could have written, with
|
|
99
|
+
"Did you mean" when one is close.
|
|
100
|
+
|
|
101
|
+
Your editor offers those names as you type — subject types and subject sets
|
|
102
|
+
in a relation, subject types in `fromField`, relations, permissions and arrows
|
|
103
|
+
in a rule and in `when` — because `defineModel` types its `types` with a
|
|
104
|
+
constraint an editor reads, not only with a check. A spec asks the TypeScript
|
|
105
|
+
language service what it completes, so a change that loses it fails.
|
|
89
106
|
|
|
90
107
|
```ts
|
|
91
108
|
defineModel({
|
|
92
109
|
subjects: ['staff'],
|
|
93
110
|
types: {
|
|
94
111
|
team: {
|
|
95
|
-
// @ts-expect-error — '"staf" is not
|
|
112
|
+
// @ts-expect-error — Type '"staf"' is not assignable to type '"staff" | "team" | "team#member"'. Did you mean '"staff"'?
|
|
96
113
|
relations: { member: ['staf'] },
|
|
97
114
|
},
|
|
98
115
|
},
|
|
@@ -257,8 +274,8 @@ own for that.
|
|
|
257
274
|
### `grant` and `revoke`
|
|
258
275
|
|
|
259
276
|
```ts
|
|
260
|
-
grant(object, relation,
|
|
261
|
-
revoke(object, relation,
|
|
277
|
+
grant(object, relation, subject): Promise<void>;
|
|
278
|
+
revoke(object, relation, subject): Promise<void>;
|
|
262
279
|
```
|
|
263
280
|
|
|
264
281
|
Both are typed from the model: only a stored relation (never a `fromField`),
|
package/docs/guide/sessions.md
CHANGED
|
@@ -53,11 +53,11 @@ await auth.authenticate({ headers: { cookie: `janus-session=${token}` } });
|
|
|
53
53
|
```
|
|
54
54
|
|
|
55
55
|
It reads `Authorization: Bearer`, then `X-Session-Token`, then the cookie.
|
|
56
|
-
**The first credential present wins, not the first valid one**: a client that
|
|
56
|
+
**The first session credential present wins, not the first valid one**: a client that
|
|
57
57
|
sends a lapsed bearer beside a live cookie is anonymous. An `Authorization`
|
|
58
58
|
header of another scheme (`Basic`) is not a session credential.
|
|
59
59
|
|
|
60
|
-
It answers `null` — anonymous — for no credential, an unknown token, a lapsed
|
|
60
|
+
It answers `null` — anonymous — for no session credential, an unknown token, a lapsed
|
|
61
61
|
or revoked session, a user deleted or inactive, or a user of another type than
|
|
62
62
|
`options.type`:
|
|
63
63
|
|
package/docs/guide/users.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# Users — `janus()`
|
|
2
2
|
|
|
3
3
|
This page is for wiring `janus()` and managing users with it: the
|
|
4
|
-
configuration, one or several
|
|
5
|
-
answers.
|
|
4
|
+
configuration, one or several user types, and every method a user type
|
|
5
|
+
answers. It is the **identities** side, usable alone: nothing here needs
|
|
6
|
+
[permissions](permissions.md), and `@nxgt/janus` loads none of their code. Sessions have [their own page](sessions.md), and so do the
|
|
6
7
|
[e-mail flows](email-flows.md) and [password hashing](passwords.md).
|
|
7
8
|
|
|
8
9
|
```ts
|
|
@@ -71,8 +72,8 @@ before the store sees it.
|
|
|
71
72
|
|
|
72
73
|
| Option | Type | Default | Effect |
|
|
73
74
|
| --- | --- | --- | --- |
|
|
74
|
-
| `user` | Standard Schema | — | One
|
|
75
|
-
| `users` | `{ [type]: UserTypeConfig }` | — | Several
|
|
75
|
+
| `user` | Standard Schema | — | One user type, named `'user'`. Exactly one of `user` and `users` |
|
|
76
|
+
| `users` | `{ [type]: UserTypeConfig }` | — | Several user types. See [Several user types](#several-user-types) |
|
|
76
77
|
| `password.login` | a field name | — | The field users sign in with: a **top-level, required string** field. A typo is a compile error |
|
|
77
78
|
| `password.normalize` | `'none' \| 'lowercase' \| 'lowercaseTrim' \| 'nfkcLowercaseTrim' \| (value) => string` | `'lowercaseTrim'` | Applied to the login before any store sees it, at sign-up and at sign-in alike |
|
|
78
79
|
| `password.minLength` | integer ≥ 1 | `8` | Below it: `PASSWORD_TOO_SHORT` |
|
|
@@ -122,7 +123,7 @@ A function is accepted, and must be deterministic: the same rule normalises
|
|
|
122
123
|
at sign-up and at sign-in. An e-mail used by the e-mail flows is always
|
|
123
124
|
compared lower-cased and trimmed, whatever the login's rule.
|
|
124
125
|
|
|
125
|
-
## Several
|
|
126
|
+
## Several user types
|
|
126
127
|
|
|
127
128
|
```ts
|
|
128
129
|
const Patient = z.object({ email: z.email(), birthDate: z.string() });
|
|
@@ -151,7 +152,7 @@ if (current?.user.type === 'staff') current.user.service; // narrowed by type
|
|
|
151
152
|
|
|
152
153
|
Each type carries `schema` and, optionally, `password`, `email`, `session`
|
|
153
154
|
and `schemaVersion`, with the defaults above. A login is unique **per type**:
|
|
154
|
-
the same e-mail may hold a patient
|
|
155
|
+
the same e-mail may hold a patient user and a staff user. A type name is
|
|
155
156
|
camelCase, and may not be one of `janus()`'s own methods (`authenticate`,
|
|
156
157
|
`signOut`, `signOutEverywhere`, `findUser`, `getUser`, `cookie`,
|
|
157
158
|
`collectExpired`, `types`) — a compile error, and a `TypeError` for a
|
|
@@ -274,4 +275,4 @@ fields against your schema, not that `password` is a string.
|
|
|
274
275
|
|
|
275
276
|
- [Sessions](sessions.md) — `authenticate`, the cookie, signing out
|
|
276
277
|
- [Errors](errors.md) — every code, and the status it deserves
|
|
277
|
-
- [Writing an adapter](adapters.md) — the
|
|
278
|
+
- [Writing an adapter](adapters.md) — the identity stores behind `store`
|
package/docs/guide/vocabulary.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# The shared vocabulary
|
|
2
2
|
|
|
3
|
-
This page
|
|
4
|
-
|
|
3
|
+
This page defines the words the documentation uses, then the small pieces
|
|
4
|
+
`@nxgt/janus` exports for both sides: subjects and the tuple notation, ids,
|
|
5
5
|
pagination, durations and clocks. All of it is imported from `@nxgt/janus`.
|
|
6
6
|
|
|
7
7
|
```ts
|
|
@@ -18,6 +18,78 @@ formatTuple({
|
|
|
18
18
|
parseDuration('8h', 'session.lifespan'); // 28800000
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
+
## Words
|
|
22
|
+
|
|
23
|
+
One word per idea, the same in the README, these guides, the error messages
|
|
24
|
+
and the code. The **Not** column lists the words it replaces, so a search for
|
|
25
|
+
either finds this row.
|
|
26
|
+
|
|
27
|
+
### The two sides
|
|
28
|
+
|
|
29
|
+
| Word | Means | Not |
|
|
30
|
+
| --- | --- | --- |
|
|
31
|
+
| **side** | One of the two things Janus does, each usable alone: identities or permissions | "half", "module" |
|
|
32
|
+
| **identities** | The side that answers *who is this?* — users, logins, passwords, sessions, one-time tokens. `janus()`, from `@nxgt/janus` | "auth", "authentication", in prose; `auth` is only the variable name in examples |
|
|
33
|
+
| **permissions** | The side that answers *may they?* — a model, the tuples stored against it, `can`, `list`, `grant`, `revoke`. `@nxgt/janus/permissions` | "authorization", "access control", "ACL" |
|
|
34
|
+
|
|
35
|
+
### Identities
|
|
36
|
+
|
|
37
|
+
| Word | Means | Not |
|
|
38
|
+
| --- | --- | --- |
|
|
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 |
|
|
41
|
+
| **schema** | A user type's Standard Schema: the fields a user carries | the model |
|
|
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
|
+
| **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* | |
|
|
44
|
+
| **password policy** | The rules a new password must meet: `minLength`, `normalize` | the model |
|
|
45
|
+
| **session** | A signed-in period, carried by a **session token** | |
|
|
46
|
+
| **lapsed**, **revoked**, **renewed** | A session past its `expiresAt`; one ended by a sign-out or a password reset; one whose `expiresAt` moved in passing (`renewAfter`) | "expired" — kept for `TOKEN_EXPIRED`, a one-time token |
|
|
47
|
+
| **anonymous** | A request that presents no session credential, or one that authenticates nobody: `authenticate` answers `null` | "unauthenticated", "guest", "logged out" |
|
|
48
|
+
| **bearer client** | A client that sends its session token as `Authorization: Bearer` rather than in a cookie | |
|
|
49
|
+
| **one-time token** | A single-use token sent by e-mail, for verification or a password reset | "code" — `code` is an error's code |
|
|
50
|
+
| **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 | |
|
|
51
|
+
| **e-mail flow** | `verifyEmail` or `resetPassword`: send a one-time token, then confirm it | |
|
|
52
|
+
|
|
53
|
+
### Permissions
|
|
54
|
+
|
|
55
|
+
| Word | Means | Not |
|
|
56
|
+
| --- | --- | --- |
|
|
57
|
+
| **model** | What `defineModel()` answers: the subject types, the object types, their relations and permissions | "schema", "policy" |
|
|
58
|
+
| **subject type** | A name listed in `subjects`: `auth.types` when wired to `janus()`, your own names otherwise | |
|
|
59
|
+
| **object** | What a permission is about: `{ type: 'document', id }` | "resource" |
|
|
60
|
+
| **subject** | Who a permission is about: a user, an object, or a subject set | "principal", "actor" |
|
|
61
|
+
| **entity** | `{ type, id }`: a user or an object — a subject that is not a set (`Entity`, `deleteEntity`) | |
|
|
62
|
+
| **subject set** | Everyone holding one relation on one object: `team:t1#member` | "group" — a group is an object with a `member` relation |
|
|
63
|
+
| **relation** | A named link, stored as tuples or read from a field (`fromField`) | |
|
|
64
|
+
| **holder** | What a relation admits: `'staff'`, or the subject set `'team#member'` | |
|
|
65
|
+
| **permission** | A name computed from relations and other permissions by its rules | |
|
|
66
|
+
| **rule** | One entry of a permission: a relation, another permission, an arrow, or one of them under a condition | |
|
|
67
|
+
| **arrow** | A rule that follows a relation to another object's permission: `'team->view'` | |
|
|
68
|
+
| **condition** | A predicate on a rule, written with `when`, run on the `ctx` passed to `can()` and `list()` | |
|
|
69
|
+
| **tuple** | One stored fact — object, relation, subject: `document:d1#viewer@user:u1` | "grant", "ACL entry" |
|
|
70
|
+
| **guarded route** | A route that runs only when its subject holds a permission on the object it serves — `permission()` in `@nxgt/janus-hono` | |
|
|
71
|
+
|
|
72
|
+
### Stores
|
|
73
|
+
|
|
74
|
+
| Word | Means | Not |
|
|
75
|
+
| --- | --- | --- |
|
|
76
|
+
| **store** | Where a side keeps its data, behind a port. The **identity stores** are `users`, `sessions` and `tokens`; the **relation store** holds the tuples | "database", "repository" |
|
|
77
|
+
| **port** | The interface a store implements: `JanusStores` for the identity stores — named after the package, not the side — and `RelationStore` | "driver" |
|
|
78
|
+
| **adapter** | A package implementing the ports for one database: `@nxgt/janus-mongo` | "plugin", "connector" |
|
|
79
|
+
| **integration** | A package fitting Janus into one web framework: `@nxgt/janus-hono`. It implements no port | "plugin", "adapter" |
|
|
80
|
+
|
|
81
|
+
### Answers
|
|
82
|
+
|
|
83
|
+
The words of [the one rule](../../README.md#the-one-rule): each is a different
|
|
84
|
+
answer, and none stands in for another.
|
|
85
|
+
|
|
86
|
+
| Word | Means |
|
|
87
|
+
| --- | --- |
|
|
88
|
+
| **absence** | Nothing there: `null`, `[]`, an empty page, `0` |
|
|
89
|
+
| **denial** | A permission not held: `false` |
|
|
90
|
+
| **failure** | A store that could not answer — an **outage**. It throws `STORE_FAILED`: never an absence, never a denial |
|
|
91
|
+
| **refusal** | Anything thrown: a `JanusError` at call time — a taken login, a wrong password, a failure — or a `TypeError` at wiring time, from how the library was called. See [Two kinds of refusal](errors.md#two-kinds-of-refusal) |
|
|
92
|
+
|
|
21
93
|
## Subjects and the tuple notation
|
|
22
94
|
|
|
23
95
|
```ts
|
|
@@ -30,7 +102,7 @@ interface RelationTuple { readonly object: Entity; readonly relation: string; re
|
|
|
30
102
|
**Subjects are typed**: `{ type, id }` for one entity, `{ type, id, relation }`
|
|
31
103
|
for a subject set — every `member` of `team:t1`. `type` is the same word as a
|
|
32
104
|
user's own, so a user id and a subject id are the same thing, and
|
|
33
|
-
`subjectOf(user)` is the one-line join between the two
|
|
105
|
+
`subjectOf(user)` is the one-line join between the two sides of the package.
|
|
34
106
|
It copies `type` and `id` only, so none of the user's own fields ever reaches
|
|
35
107
|
a tuple.
|
|
36
108
|
|
package/docs/roadmap.md
CHANGED
|
@@ -5,7 +5,10 @@ dates here, and the version something shipped in is the only number.
|
|
|
5
5
|
|
|
6
6
|
## Now
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
- **A Hono integration** — in a package of its own, `@nxgt/janus-hono`:
|
|
9
|
+
the session middleware, the cookie, a route guarded by a permission, the
|
|
10
|
+
instances on the context, and every error as its status. Built, not yet
|
|
11
|
+
published.
|
|
9
12
|
|
|
10
13
|
## Next
|
|
11
14
|
|
|
@@ -13,7 +16,7 @@ Nothing yet.
|
|
|
13
16
|
|
|
14
17
|
## Later
|
|
15
18
|
|
|
16
|
-
- **More official adapters** — the
|
|
19
|
+
- **More official adapters** — the ports are cut where atomicity is not
|
|
17
20
|
required, so users, sessions and permission tuples can each live in the
|
|
18
21
|
database that suits them. MongoDB is the first adapter
|
|
19
22
|
([`@nxgt/janus-mongo`](https://www.npmjs.com/package/@nxgt/janus-mongo)).
|
|
@@ -28,8 +31,8 @@ Nothing yet.
|
|
|
28
31
|
bundler do. Rewriting the declarations for `nodenext` was tried and reverted:
|
|
29
32
|
it breaks the same contract one step later. Use `"moduleResolution":
|
|
30
33
|
"bundler"`.
|
|
31
|
-
- **A Kratos-shaped surface** — no `identity.traits`, no
|
|
32
|
-
|
|
34
|
+
- **A Kratos-shaped surface** — no `identity.traits`, no login derived from a
|
|
35
|
+
schema annotation (Kratos's `identifier`). A user is your schema's fields at the top level,
|
|
33
36
|
and the flows are calls (`signUp`, `signIn`, `authenticate`).
|
|
34
37
|
- **`snake_case` keys** — every key, option and record field is `camelCase`,
|
|
35
38
|
and a lint rule holds it. Error codes are `SCREAMING_SNAKE` because they are
|
|
@@ -60,6 +63,11 @@ Nothing yet.
|
|
|
60
63
|
|
|
61
64
|
The first public release, v0.1.
|
|
62
65
|
|
|
66
|
+
- **`defineModel` completed by your editor** — subject types and subject sets
|
|
67
|
+
in a relation, subject types in `fromField`, relations, permissions and
|
|
68
|
+
arrows in a rule and in `when`; a wrong name's error lists the names it
|
|
69
|
+
could have been. — v0.1.2
|
|
70
|
+
|
|
63
71
|
- **The model decides what a stored tuple grants** — `can()` and `list()` follow
|
|
64
72
|
only the holders a relation admits, as `grant()` writes only those: a tuple
|
|
65
73
|
stored past `grant()`, by an older model or by hand, grants nothing. — v0.1
|
|
@@ -88,6 +96,6 @@ The first public release, v0.1.
|
|
|
88
96
|
— v0.1
|
|
89
97
|
- **The conformance suite** — `@nxgt/janus/conformance`, the suite an adapter
|
|
90
98
|
runs, outages included. — v0.1
|
|
91
|
-
- **The
|
|
99
|
+
- **The identity stores' port and its in-memory reference** — `JanusStores` and
|
|
92
100
|
`createMemoryStores()`, for your tests and as the model for an adapter.
|
|
93
101
|
— v0.1
|
package/docs/troubleshooting.md
CHANGED
|
@@ -127,14 +127,14 @@ Every entry here is a `TypeError` thrown by `janus(...)` itself, before any
|
|
|
127
127
|
request. With several user types, the option is prefixed by the type:
|
|
128
128
|
`janus: users.staff: password.login …`.
|
|
129
129
|
|
|
130
|
-
### `janus: pass either user (one
|
|
130
|
+
### `janus: pass either user (one user type) or users (several user types), and exactly one of them`
|
|
131
131
|
|
|
132
132
|
**When:** `janus({...})`, with both `user` and `users`, or neither.
|
|
133
|
-
**Why:** `user` is the shorthand for one
|
|
133
|
+
**Why:** `user` is the shorthand for one user type; `users` declares several, each with its own schema and options. The two cannot be mixed.
|
|
134
134
|
**Fix:**
|
|
135
135
|
|
|
136
136
|
```ts
|
|
137
|
-
// One
|
|
137
|
+
// One user type
|
|
138
138
|
janus({ user: User, password: { login: 'email' }, store, hasher });
|
|
139
139
|
|
|
140
140
|
// Several
|
|
@@ -294,7 +294,7 @@ Also `janus: cookie.name must be a cookie-name token — letters, digits and !#$
|
|
|
294
294
|
|
|
295
295
|
**When:** any call that reaches the store — `authenticate`, `signIn`, `get`, `can` — while the database is down, times out, or the adapter throws.
|
|
296
296
|
**Why:** a store that cannot answer throws; it never answers `null`. The driver's own error is on `error.cause` — not in the message, because a driver message can hold a connection string, and a connection string holds a password.
|
|
297
|
-
**Fix:** answer **503**, and log `cause`. Never map it to 401, 404, `null` or `false`: that turns an outage into a silent lockout, where
|
|
297
|
+
**Fix:** answer **503**, and log `cause`. Never map it to 401, 404, `null` or `false`: that turns an outage into a silent lockout, where every user is told they do not exist.
|
|
298
298
|
|
|
299
299
|
```ts
|
|
300
300
|
import { JanusError } from '@nxgt/janus';
|
|
@@ -333,7 +333,7 @@ const user = await auth.find(id); // null when there is nobody
|
|
|
333
333
|
`StoreConflict` with `on: 'login'`, carrying `login` and `userType`.
|
|
334
334
|
|
|
335
335
|
**When:** `signUp`, `create`, or an `update` that changes the login, when another user of **the same type** holds it after normalisation (`Ada@Example.com` and `ada@example.com` collide by default).
|
|
336
|
-
**Why:** the store's unique constraint refused the write. A login is unique per user type: one e-mail may hold a patient
|
|
336
|
+
**Why:** the store's unique constraint refused the write. A login is unique per user type: one e-mail may hold a patient user and a staff user.
|
|
337
337
|
**Fix:** answer 409. If two concurrent sign-ups with one login both succeed, the unique index is missing — with `@nxgt/janus-mongo`, run `syncMongoStores(db)`.
|
|
338
338
|
|
|
339
339
|
### `VERSION_CONFLICT` — `<call>: expected version <n>, found <m>`
|
|
@@ -378,7 +378,7 @@ Also `changePassword: the current password does not match`.
|
|
|
378
378
|
**Fix:** answer 401 with the same body whatever the reason:
|
|
379
379
|
|
|
380
380
|
```ts
|
|
381
|
-
// Never: { reason: error.reason } — `unknownLogin` tells an attacker which
|
|
381
|
+
// Never: { reason: error.reason } — `unknownLogin` tells an attacker which users exist.
|
|
382
382
|
return new Response('Wrong e-mail or password', { status: 401 });
|
|
383
383
|
```
|
|
384
384
|
|
|
@@ -401,7 +401,7 @@ janus({ ..., hasher: scryptHasher(), verifiers: [bcryptVerifier] }); // a Passwo
|
|
|
401
401
|
### `USER_INACTIVE` — `<call>: the user is inactive`
|
|
402
402
|
|
|
403
403
|
**When:** `signIn`, with the **right** password, for a user set inactive.
|
|
404
|
-
**Why:** an inactive user keeps their record and password, and every sign-in is refused. It is checked after the password, so only somebody who knows the password learns the
|
|
404
|
+
**Why:** an inactive user keeps their record and password, and every sign-in is refused. It is checked after the password, so only somebody who knows the password learns the user is inactive.
|
|
405
405
|
**Fix:** answer 403, or reactivate: `await auth.setActive(user, true)`.
|
|
406
406
|
|
|
407
407
|
### `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`
|
|
@@ -469,7 +469,7 @@ do {
|
|
|
469
469
|
Also `list: listing <type>#<permission> crossed more than <n> relations without an answer`. `PermissionDepthError`, carrying `permission` and `maxDepth`.
|
|
470
470
|
|
|
471
471
|
**When:** `access.can(...)` or `access.list(...)`.
|
|
472
|
-
**Why:** the walk crossed more than `maxDepth` relations (25 by default) and
|
|
472
|
+
**Why:** the walk crossed more than `maxDepth` relations (25 by default) and reached no decision. It is not a denial: a check that stopped half-way decided nothing. A cycle in the data — a team member of itself — is cut silently and never causes this.
|
|
473
473
|
**Fix:** answer 500 and look at the model or the data: a very deep hierarchy, or a chain of subject sets. If the depth is genuine, raise it:
|
|
474
474
|
|
|
475
475
|
```ts
|
|
@@ -579,7 +579,7 @@ most common:
|
|
|
579
579
|
|
|
580
580
|
| Message | Fix |
|
|
581
581
|
| --- | --- |
|
|
582
|
-
| `defineModel: subjects must be an array of
|
|
582
|
+
| `defineModel: subjects must be an array of subject type names — auth.types from janus(), or your own` | `defineModel({ subjects: auth.types, types })` with `janus()`, or your own names alone: `subjects: ['user']`. |
|
|
583
583
|
| `defineModel: types declares no object type` | Declare at least one type under `types`. |
|
|
584
584
|
| `defineModel: the object type "<name>" must be a camelCase name — letters and digits, starting with a lowercase letter` | Also for relation and permission names. |
|
|
585
585
|
| `defineModel: "<name>" names a user type and an object type; a subject of type "<name>" would be ambiguous` | Rename the object type. |
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nxgt/janus",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Embeddable, type-safe
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Embeddable, type-safe identities and permissions: bring your own database",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./dist/index.js",
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 3,
|
|
3
|
-
"sources": ["../src/pagination/cursor-page.ts", "../src/auth/outage.ts"],
|
|
4
|
-
"sourcesContent": [
|
|
5
|
-
"import { InvalidCursorError } from '../errors/janus-error';\n\n/**\n * One page of results, and where the next one starts.\n *\n * `nextCursor` is `null` on the last page and a string on every other, so\n * `while (cursor)` is the loop and there is no separate \"done\" flag to forget.\n *\n * **There is no `total`**, and that is deliberate — the same choice\n * `@nxgt/mongo`'s cursor page makes, for the same reason: an exact count over a\n * large table is a second scan, and no adapter should be made to promise one.\n * An application that needs a count asks its own store for one, where it can\n * decide what the count is allowed to cost.\n *\n * This type is **redefined here rather than imported** from `@nxgt/mongo`. That\n * is deliberate duplication, recorded in AGENTS.md: a port that every adapter\n * implements cannot make one database library a dependency of the contract.\n */\nexport interface CursorPage<T> {\n\treadonly items: readonly T[];\n\treadonly nextCursor: string | null;\n}\n\n/** How many a page holds when the caller did not say. */\nexport const DEFAULT_PAGE_SIZE = 20;\n\n/**\n * The most a page may hold, whatever the caller asked for.\n *\n * Bounded in the core rather than left to each adapter, so a caller cannot ask\n * one store for a million rows and be refused by another. An application that\n * wants everything pages for it.\n */\nexport const MAX_PAGE_SIZE = 100;\n\n/**\n * The limit a store will be given, from the limit a caller asked for.\n *\n * `where` names the call the consumer wrote — `listIdentities`, not an internal\n * function — because several calls in this package take a `limit` and a message\n * that does not say which one leaves the reader to guess. That naming rule is\n * `nxgt-data`'s, and it is the reason its errors read the way they do.\n *\n * A bare `TypeError`: a limit is written in the application's own code, so this\n * cannot come from a request.\n */\nexport function pageLimit(limit: number | undefined, where: string): number {\n\tif (limit === undefined) return DEFAULT_PAGE_SIZE;\n\n\tif (!Number.isInteger(limit) || limit < 1) {\n\t\tthrow new TypeError(\n\t\t\t`${where}: limit must be an integer of at least 1, or absent`,\n\t\t);\n\t}\n\n\treturn Math.min(limit, MAX_PAGE_SIZE);\n}\n\n/**\n * Refuses a cursor this store did not mint.\n *\n * Exported for adapter authors: a cursor from another store, another ordering\n * or another version of the adapter must be refused, **never treated as an\n * absent cursor**. A silent fall back to the first page makes a caller paging a\n * list loop for ever, and the loop looks like a slow query rather than a bug.\n */\nexport function invalidCursor(\n\twhere: string,\n\tcursor: string,\n): InvalidCursorError {\n\t// The cursor's own bytes are not in the message: it is opaque, it may be\n\t// long, and printing it tells the reader nothing they can act on. Its length\n\t// is enough to tell \"truncated in transit\" from \"written by another store\".\n\treturn new InvalidCursorError(\n\t\t`${where}: this cursor was not minted by this store, or was minted for another ordering (${cursor.length} characters)`,\n\t);\n}\n",
|
|
6
|
-
"/**\n * **The one place a store call is caught, and the one place a `null` becomes an\n * error.**\n *\n * The transposition of `call.ts` in `nxgt-ory`'s SDK — \"the only place a call\n * is unwrapped\" — into a core with no status codes. The invariant this whole\n * package is built around is checked by reading this file:\n *\n * > An absence is `null`. A failure throws.\n *\n * `outage.spec.ts` reads every other file of `src/auth/` and fails if a\n * `catch` appears in one, because the failure this design exists to prevent is\n * a single careless `catch { return null }`. There are two `catch`es below: the\n * guard's always rethrows, and {@link unlessVersionConflict}'s absorbs one\n * named conflict and rethrows everything else. The spec holds both to that.\n */\n\nimport {\n\ttype JanusError,\n\tJanusError as JanusErrorClass,\n\tStoreConflict,\n\tStoreFailure,\n} from '../errors/janus-error';\nimport type { RelationStore } from '../permissions/port/types';\nimport type { JanusStores } from './port/types';\n\ntype Slot = keyof JanusStores | 'relations';\n\n/**\n * The methods whose answer is legitimately nothing: `undefined` from them is\n * not a forgotten `return`. Every other method answers a value, `null`,\n * `false`, `0` or a page — **never `undefined`**.\n */\nconst ANSWERS_NOTHING = new Set(['insertSession', 'insertToken', 'write']);\n\n/**\n * The stores, with every method guarded.\n *\n * - A `JanusError` the adapter threw — `StoreFailure`, `StoreConflict`,\n * `NotFoundError` — passes through untouched, so `instanceof` still holds.\n * - **Anything else it threw is a failure**: a driver error, a `TypeError` from\n * a bug in the adapter, a string. It becomes `StoreFailure` with the original\n * as `cause`, naming the slot and the method. It is never an absence.\n * - A method that answers `undefined` where the port says `null` is a store\n * that forgot to answer, and becomes `StoreFailure` rather than \"not found\".\n * Rule 2 of the port, enforced at run time for the JavaScript adapter the\n * compiler never saw.\n *\n * The optional `deleteExpiredSessions` is guarded when present and left absent\n * when absent, so capability detection still reads the truth.\n */\nexport function guardStores(stores: JanusStores): JanusStores {\n\treturn {\n\t\tusers: guardSlot('users', stores.users),\n\t\tsessions: guardSlot('sessions', stores.sessions),\n\t\ttokens: guardSlot('tokens', stores.tokens),\n\t};\n}\n\n/**\n * The relation store, guarded the same way: the permission engine's only way\n * to a store, so a failure there is `STORE_FAILED` and never a denial.\n */\nexport function guardRelations(store: RelationStore): RelationStore {\n\treturn guardSlot('relations', store);\n}\n\nfunction guardSlot<S extends object>(slot: Slot, store: S): S {\n\tconst guarded: Record<string, unknown> = {};\n\n\tfor (const method of methodsOf(store)) {\n\t\tconst original = (store as Record<string, unknown>)[method];\n\t\tif (typeof original !== 'function') continue;\n\n\t\tguarded[method] = async (...args: unknown[]) => {\n\t\t\tlet answer: unknown;\n\n\t\t\ttry {\n\t\t\t\tanswer = await original.apply(store, args);\n\t\t\t} catch (error) {\n\t\t\t\t// Rethrown, always. The only question is under which class.\n\t\t\t\tthrow asFailure(error, slot, method);\n\t\t\t}\n\n\t\t\tif (answer === undefined && !ANSWERS_NOTHING.has(method)) {\n\t\t\t\tthrow new StoreFailure(\n\t\t\t\t\t`${slot}.${method} answered undefined: an absence is null, so this store forgot to answer`,\n\t\t\t\t\t{ slot, operation: method },\n\t\t\t\t);\n\t\t\t}\n\n\t\t\treturn answer;\n\t\t};\n\t}\n\n\treturn guarded as S;\n}\n\n/** Own and prototype methods, so a class-based store is guarded like a literal. */\nfunction methodsOf(store: object): string[] {\n\tconst names = new Set<string>();\n\n\tfor (\n\t\tlet proto: object | null = store;\n\t\tproto !== null && proto !== Object.prototype;\n\t\tproto = Object.getPrototypeOf(proto)\n\t) {\n\t\tfor (const name of Object.getOwnPropertyNames(proto)) {\n\t\t\tif (name !== 'constructor') names.add(name);\n\t\t}\n\t}\n\n\treturn [...names];\n}\n\nfunction asFailure(error: unknown, slot: Slot, method: string): JanusError {\n\tif (error instanceof JanusErrorClass) return error;\n\n\t// The message names where, never what: a driver's message may hold a\n\t// connection string, and a connection string holds a password. It is kept\n\t// as `cause`, for the operator's logs.\n\treturn new StoreFailure(`${slot}.${method}: the store could not answer`, {\n\t\tslot,\n\t\toperation: method,\n\t\tcause: error,\n\t});\n}\n\n/**\n * A value the caller requires, or the refusal an absence deserves.\n *\n * The only place in the core where a `null` from a store turns into an error —\n * `get` becoming `NOT_FOUND`. Written as a function so that the conversion is\n * visible at every call site and nowhere else.\n */\nexport function required<T>(value: T | null, absent: () => JanusError): T {\n\tif (value === null) throw absent();\n\treturn value;\n}\n\n/**\n * A write that may lose a race, and is allowed to: its answer, or `null` when\n * the record's version moved under it.\n *\n * **Only `StoreConflict('version')` is absorbed.** A failure still throws, a\n * taken login still throws, `NOT_FOUND` still throws: an outage never reads as\n * \"somebody else wrote first\". It exists for writes the caller did not ask for\n * — rewriting a password hash on sign-in — where losing to a concurrent update\n * means only that the next sign-in tries again.\n */\nexport async function unlessVersionConflict<T>(\n\twrite: Promise<T>,\n): Promise<T | null> {\n\ttry {\n\t\treturn await write;\n\t} catch (error) {\n\t\tif (error instanceof StoreConflict && error.on === 'version') return null;\n\t\tthrow error;\n\t}\n}\n"
|
|
7
|
-
],
|
|
8
|
-
"mappings": ";;;;;;;;AAwBO,IAAM,qBAAoB;AAS1B,IAAM,iBAAgB;AAatB,SAAS,UAAS,CAAC,OAA2B,OAAuB;AAAA,EAC3E,IAAI,UAAU;AAAA,IAAW,OAAO;AAAA,EAEhC,IAAI,CAAC,OAAO,UAAU,KAAK,KAAK,QAAQ,GAAG;AAAA,IAC1C,MAAM,IAAI,UACT,GAAG,0DACJ;AAAA,EACD;AAAA,EAEA,OAAO,KAAK,IAAI,OAAO,cAAa;AAAA;AAW9B,SAAS,cAAa,CAC5B,OACA,QACqB;AAAA,EAIrB,OAAO,IAAI,oBACV,GAAG,wFAAwF,OAAO,oBACnG;AAAA;;;AC1CD,IAAM,kBAAkB,IAAI,IAAI,CAAC,iBAAiB,eAAe,OAAO,CAAC;AAkBlE,SAAS,WAAW,CAAC,QAAkC;AAAA,EAC7D,OAAO;AAAA,IACN,OAAO,UAAU,SAAS,OAAO,KAAK;AAAA,IACtC,UAAU,UAAU,YAAY,OAAO,QAAQ;AAAA,IAC/C,QAAQ,UAAU,UAAU,OAAO,MAAM;AAAA,EAC1C;AAAA;AAOM,SAAS,cAAc,CAAC,OAAqC;AAAA,EACnE,OAAO,UAAU,aAAa,KAAK;AAAA;AAGpC,SAAS,SAA2B,CAAC,MAAY,OAAa;AAAA,EAC7D,MAAM,UAAmC,CAAC;AAAA,EAE1C,WAAW,UAAU,UAAU,KAAK,GAAG;AAAA,IACtC,MAAM,WAAY,MAAkC;AAAA,IACpD,IAAI,OAAO,aAAa;AAAA,MAAY;AAAA,IAEpC,QAAQ,UAAU,UAAU,SAAoB;AAAA,MAC/C,IAAI;AAAA,MAEJ,IAAI;AAAA,QACH,SAAS,MAAM,SAAS,MAAM,OAAO,IAAI;AAAA,QACxC,OAAO,OAAO;AAAA,QAEf,MAAM,UAAU,OAAO,MAAM,MAAM;AAAA;AAAA,MAGpC,IAAI,WAAW,aAAa,CAAC,gBAAgB,IAAI,MAAM,GAAG;AAAA,QACzD,MAAM,IAAI,cACT,GAAG,QAAQ,iFACX,EAAE,MAAM,WAAW,OAAO,CAC3B;AAAA,MACD;AAAA,MAEA,OAAO;AAAA;AAAA,EAET;AAAA,EAEA,OAAO;AAAA;AAIR,SAAS,SAAS,CAAC,OAAyB;AAAA,EAC3C,MAAM,QAAQ,IAAI;AAAA,EAElB,SACK,QAAuB,MAC3B,UAAU,QAAQ,UAAU,OAAO,WACnC,QAAQ,OAAO,eAAe,KAAK,GAClC;AAAA,IACD,WAAW,QAAQ,OAAO,oBAAoB,KAAK,GAAG;AAAA,MACrD,IAAI,SAAS;AAAA,QAAe,MAAM,IAAI,IAAI;AAAA,IAC3C;AAAA,EACD;AAAA,EAEA,OAAO,CAAC,GAAG,KAAK;AAAA;AAGjB,SAAS,SAAS,CAAC,OAAgB,MAAY,QAA4B;AAAA,EAC1E,IAAI,iBAAiB;AAAA,IAAiB,OAAO;AAAA,EAK7C,OAAO,IAAI,cAAa,GAAG,QAAQ,sCAAsC;AAAA,IACxE;AAAA,IACA,WAAW;AAAA,IACX,OAAO;AAAA,EACR,CAAC;AAAA;AAUK,SAAS,QAAW,CAAC,OAAiB,QAA6B;AAAA,EACzE,IAAI,UAAU;AAAA,IAAM,MAAM,OAAO;AAAA,EACjC,OAAO;AAAA;AAaR,eAAsB,qBAAwB,CAC7C,OACoB;AAAA,EACpB,IAAI;AAAA,IACH,OAAO,MAAM;AAAA,IACZ,OAAO,OAAO;AAAA,IACf,IAAI,iBAAiB,kBAAiB,MAAM,OAAO;AAAA,MAAW,OAAO;AAAA,IACrE,MAAM;AAAA;AAAA;",
|
|
9
|
-
"debugId": "047FF5FD235ABA3764756E2164756E21",
|
|
10
|
-
"names": []
|
|
11
|
-
}
|