@nxgt/janus 0.1.3 → 0.2.1

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 (36) hide show
  1. package/README.md +42 -32
  2. package/dist/auth/context.d.ts +1 -1
  3. package/dist/auth/context.d.ts.map +1 -1
  4. package/dist/auth/port/types.d.ts +9 -5
  5. package/dist/auth/port/types.d.ts.map +1 -1
  6. package/dist/auth/sessions.d.ts.map +1 -1
  7. package/dist/auth/users.d.ts.map +1 -1
  8. package/dist/chunks/{index-658b6mr2.js → index-0xarpm7z.js} +3 -3
  9. package/dist/chunks/index-0xarpm7z.js.map +11 -0
  10. package/dist/chunks/index-6p56fpbe.js.map +2 -2
  11. package/dist/conformance/cases/sessions.d.ts.map +1 -1
  12. package/dist/conformance/cases/users.d.ts.map +1 -1
  13. package/dist/conformance/index.js +27 -4
  14. package/dist/conformance/index.js.map +4 -4
  15. package/dist/errors/janus-error.d.ts +2 -1
  16. package/dist/errors/janus-error.d.ts.map +1 -1
  17. package/dist/index.js +53 -11
  18. package/dist/index.js.map +7 -6
  19. package/dist/permissions/engine.d.ts +1 -1
  20. package/dist/permissions/index.js +17 -13
  21. package/dist/permissions/index.js.map +5 -5
  22. package/dist/permissions/model.d.ts +28 -28
  23. package/dist/permissions/model.d.ts.map +1 -1
  24. package/dist/permissions/resolve.d.ts.map +1 -1
  25. package/dist/stores/storable.d.ts +15 -0
  26. package/dist/stores/storable.d.ts.map +1 -0
  27. package/docs/guide/adapters.md +9 -4
  28. package/docs/guide/errors.md +3 -2
  29. package/docs/guide/permissions.md +287 -109
  30. package/docs/guide/sessions.md +3 -1
  31. package/docs/guide/users.md +6 -2
  32. package/docs/guide/vocabulary.md +23 -19
  33. package/docs/roadmap.md +62 -9
  34. package/docs/troubleshooting.md +36 -18
  35. package/package.json +1 -1
  36. package/dist/chunks/index-658b6mr2.js.map +0 -11
@@ -12,9 +12,9 @@ const user = { type: 'staff', id: mintId(), username: 'grace' };
12
12
  subjectOf(user); // { type: 'staff', id: '…' }: the user IS the subject
13
13
  formatTuple({
14
14
  object: { type: 'record', id: 'r1' },
15
- relation: 'viewer',
16
- subject: { type: 'team', id: 't1', relation: 'member' },
17
- }); // 'record:r1#viewer@team:t1#member'
15
+ relation: 'teams',
16
+ subject: { type: 'team', id: 't1' },
17
+ }); // 'record:r1#teams@team:t1'
18
18
  parseDuration('8h', 'session.lifespan'); // 28800000
19
19
  ```
20
20
 
@@ -46,7 +46,8 @@ either finds this row.
46
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
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
- | **one-time token** | A single-use token sent by e-mail, for verification or a password reset | "code" — `code` is an error's code |
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
51
  | **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
52
  | **e-mail flow** | `verifyEmail` or `resetPassword`: send a one-time token, then confirm it | |
52
53
 
@@ -59,14 +60,16 @@ either finds this row.
59
60
  | **object** | What a permission is about: `{ type: 'document', id }` | "resource" |
60
61
  | **subject** | Who a permission is about: a user, an object, or a subject set | "principal", "actor" |
61
62
  | **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 | |
63
+ | **subject set** | Everyone holding one relation on one object: `team:t1#members` | "group" — a group is an object with a `members` relation |
64
+ | **relation** | A named link, stored as tuples or read from a field (`fromField`). Declared under `related`, named in the plural: `members`, `doctors` | |
65
+ | **`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
+ | **holder** | What a relation admits: `'staff'`, or the subject set `'team#members'` | |
67
+ | **permission** | A name computed from relations and other permissions by its rules. Declared under `permits` | |
68
+ | **`permits`** | The key of an object type that declares its permissions, each a list of rules: `permits: { view: ['members'] }`. It was `permissions` before 0.2, and the old key is refused | "permissions" as a key — the word stays for the idea, and for `permissions()` |
66
69
  | **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'` | |
70
+ | **arrow** | A rule that follows a relation to another object's permission or relation: `'teams->view'` | |
68
71
  | **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" |
72
+ | **tuple** | One stored fact — object, relation, subject: `team:t1#members@staff:u1` | "grant", "ACL entry" |
70
73
  | **guarded route** | A route that runs only when its subject holds a permission on the object it serves — `permission()` in `@nxgt/janus-hono` | |
71
74
 
72
75
  ### Stores
@@ -76,7 +79,8 @@ either finds this row.
76
79
  | **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
80
  | **port** | The interface a store implements: `JanusStores` for the identity stores — named after the package, not the side — and `RelationStore` | "driver" |
78
81
  | **adapter** | A package implementing the ports for one database: `@nxgt/janus-mongo`. What its `create…Adapter(db)` answers is its stores, keyed as `janus()` takes them — `{ store, relations }` | "plugin", "connector" |
79
- | **integration** | A package fitting Janus into one web framework: `@nxgt/janus-hono`. It implements no port | "plugin", "adapter" |
82
+ | **integration** | A package fitting Janus into one web framework or one observability library: `@nxgt/janus-hono`, `@nxgt/janus-telemetry`. It implements no port | "plugin", "adapter" |
83
+ | **kit** | A package that opens the connections and wires adapters and integrations into one object for an application: `@nxgt/janus-kit`'s `connectKit` answers `{ auth, access, db, redis, ping, close }`. It implements no port, and `janus()` and `permissions()` are still written by the application | "framework", "starter" |
80
84
 
81
85
  ### Answers
82
86
 
@@ -100,7 +104,7 @@ interface RelationTuple { readonly object: Entity; readonly relation: string; re
100
104
  ```
101
105
 
102
106
  **Subjects are typed**: `{ type, id }` for one entity, `{ type, id, relation }`
103
- for a subject set — every `member` of `team:t1`. `type` is the same word as a
107
+ for a subject set — everyone holding `members` on `team:t1`. `type` is the same word as a
104
108
  user's own, so a user id and a subject id are the same thing, and
105
109
  `subjectOf(user)` is the one-line join between the two sides of the package.
106
110
  It copies `type` and `id` only, so none of the user's own fields ever reaches
@@ -111,25 +115,25 @@ a tuple.
111
115
  | `subjectOf(user)` | `{ type, id }` of any object carrying both |
112
116
  | `isSubjectSet(subject)` | whether `relation` is a string |
113
117
  | `formatEntity(entity)` | `'record:r1'` |
114
- | `formatSubject(subject)` | `'staff:u1'`, or `'team:t1#member'` |
115
- | `formatTuple(tuple)` | `'record:r1#viewer@team:t1#member'` |
118
+ | `formatSubject(subject)` | `'staff:u1'`, or `'team:t1#members'` |
119
+ | `formatTuple(tuple)` | `'team:t1#members@team:t2#members'` |
116
120
  | `parseSubject(text)` | the `Subject` back |
117
121
  | `parseTuple(text)` | the `RelationTuple` back |
118
122
 
119
123
  ```ts
120
124
  import { isSubjectSet, parseSubject, parseTuple } from '@nxgt/janus';
121
125
 
122
- parseTuple('record:r1#viewer@staff:u1');
123
- // { object: { type: 'record', id: 'r1' }, relation: 'viewer', subject: { type: 'staff', id: 'u1' } }
126
+ parseTuple('team:t1#members@staff:u1');
127
+ // { object: { type: 'team', id: 't1' }, relation: 'members', subject: { type: 'staff', id: 'u1' } }
124
128
 
125
- const subject = parseSubject('team:t1#member');
126
- if (isSubjectSet(subject)) subject.relation; // 'member'
129
+ const subject = parseSubject('team:t1#members');
130
+ if (isSubjectSet(subject)) subject.relation; // 'members'
127
131
  ```
128
132
 
129
133
  The notation is for messages, logs and tests, not a wire format. No part may
130
134
  hold `@`, `#` or a parenthesis, and a type may not hold a `:`, so every string
131
135
  reads one way. `parseTuple` and `parseSubject` refuse anything else with a
132
- **bare `TypeError`** — including Keto's untyped subject, `record:r1#viewer@alice`,
136
+ **bare `TypeError`** — including Keto's untyped subject, `team:t1#members@grace`,
133
137
  and the message says what a subject is. Nothing in this package reads a tuple
134
138
  off the network, so a malformed string came from your own code.
135
139
 
package/docs/roadmap.md CHANGED
@@ -5,24 +5,55 @@ dates here, and the version something shipped in is the only number.
5
5
 
6
6
  ## Now
7
7
 
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.
8
+ - **A PostgreSQL adapter** — in a package of its own, `@nxgt/janus-drizzle`,
9
+ on Drizzle: both sides over one database, the tables in your own drizzle-kit
10
+ migrations. Built, not yet published.
11
+ - **Tracing and an audit trail** — in a package of its own,
12
+ `@nxgt/janus-telemetry`: a span per flow and per permission check, and the
13
+ security events worth keeping, never a login, a password, a session token
14
+ or a one-time token. Built, not yet published.
15
+ - **A Redis adapter for sessions and one-time tokens** — in a package of its
16
+ own, `@nxgt/janus-redis`: both are read on every request and ephemeral, so
17
+ they live in Redis, expired by Redis itself, while users stay in another
18
+ store. Built, not yet published.
12
19
 
13
20
  ## Next
14
21
 
15
- Nothing yet.
22
+ - **One-time codes** — a one-time token short enough to type, sent by e-mail
23
+ to sign in without a password, or to confirm a sensitive action, issued
24
+ and redeemed by `janus` like the verification and reset tokens today; and
25
+ TOTP, the one-time codes of an authenticator app, as a second factor.
26
+ - **Sending the e-mails** — in a package of its own, `@nxgt/janus-mail`, built
27
+ on a general mail toolkit shared with applications that are not about
28
+ sign-in: a `Mailer` port you plug your transport into (SMTP, Resend, SES…) —
29
+ a transport that fails throws, like a store — and default templates for
30
+ verification, password reset and one-time codes, in English and French. One
31
+ template per e-mail, never one HTML file per language: the layout is built
32
+ once with Maizzle and Tailwind CSS 4 — CSS inlined for mail clients — and
33
+ its text lives in ICU message catalogues, one per language, plurals and
34
+ dates included. Both are compiled when the package is built into typed
35
+ functions: `templates.verifyEmail({ locale: 'fr', link })` answers
36
+ `{ subject, html, text }`, every value escaped, a missing variable or an
37
+ unknown locale a compile error, a message that fails to format a throw —
38
+ never an e-mail sent with `{link}` in it. No template engine at run time.
39
+ The defaults are a starting point, not a requirement: add a language with a
40
+ catalogue, or replace any one template with your own function of the same
41
+ shape — built with the same toolkit, React Email or a plain string — and
42
+ keep the defaults for the rest.
43
+ - **Webhooks** — signed HTTP events when something happens to a user
44
+ (created, e-mail verified, password reset, deleted), so another service can
45
+ follow without polling: a signature it can check, retries on failure, and
46
+ the same rule as the audit trail — the user named by id, never a login, a
47
+ password, a session token or a one-time token in a payload, and every key
48
+ camelCase. Retries that run out are reported, never dropped in silence.
16
49
 
17
50
  ## Later
18
51
 
19
52
  - **More official adapters** — the ports are cut where atomicity is not
20
53
  required, so users, sessions and permission tuples can each live in the
21
54
  database that suits them. MongoDB is the first adapter
22
- ([`@nxgt/janus-mongo`](https://www.npmjs.com/package/@nxgt/janus-mongo)).
23
- - **A Redis adapter for sessions and tokens** — both are ephemeral and read on
24
- every request, which is why the port gives them slots of their own: they can
25
- live in Redis, with a native expiry, while users stay in another store.
55
+ ([`@nxgt/janus-mongo`](https://www.npmjs.com/package/@nxgt/janus-mongo));
56
+ PostgreSQL is under Now.
26
57
 
27
58
  ## Not planned
28
59
 
@@ -63,6 +94,28 @@ Nothing yet.
63
94
 
64
95
  The first public release, v0.1.
65
96
 
97
+ - **The model's keys read `related` and `permits`** — Keto's OPL words: an
98
+ object type declares `related: { members: ['staff', 'team#members'] }` and
99
+ `permits: { view: ['members'] }`, and relation names are plural by convention. Breaking:
100
+ `relations` and `permissions` as keys are refused, by the compiler and by
101
+ `defineModel`, with a message naming the new key —
102
+ `types.team.relations is now related: rename the key`. The `permissions()`
103
+ function and `janus({ relations })` keep their names. — next minor
104
+ - **`LOGIN_TAKEN` no longer quotes the login in its message**, in the memory
105
+ store and in both adapters; `error.login` still names it, and the
106
+ conformance suite checks it. — next patch
107
+ - **The conformance suite accepts a store with its own expiry** — a store
108
+ that drops a lapsed session at once, as a Redis TTL does, passes
109
+ `sessions.deleteUser`; one that still holds it must count it. — next patch
110
+ - **A NUL character or a lone surrogate never reaches a store** — refused in
111
+ fields with `USER_INVALID` on every adapter, rather than `STORE_FAILED` on
112
+ PostgreSQL alone; a login holding one is nobody's. The conformance suite
113
+ holds every adapter to round-tripping every other character. — next patch
114
+
115
+ - **A Hono integration** — [`@nxgt/janus-hono`](https://www.npmjs.com/package/@nxgt/janus-hono):
116
+ the session middleware, the cookie, a route guarded by a permission,
117
+ `bindJanus()` to bind the instances once, and every error as its status.
118
+ Its own 0.1.0, beside `@nxgt/janus` 0.1.3.
66
119
  - **`defineModel` completed by your editor** — subject types and subject sets
67
120
  in a relation, subject types in `fromField`, relations, permissions and
68
121
  arrows in a rule and in `when`; a wrong name's error lists the names it
@@ -42,7 +42,7 @@ How the messages are shaped:
42
42
  - [`STORE_FAILED` — `<slot>.<method>: the store could not answer`](#store_failed--slotmethod-the-store-could-not-answer)
43
43
  - [`STORE_FAILED` — `<slot>.<method> answered undefined …`](#store_failed--slotmethod-answered-undefined-an-absence-is-null-so-this-store-forgot-to-answer)
44
44
  - [`NOT_FOUND` — `<call>: no <type> has this id`](#not_found--call-no-type-has-this-id)
45
- - [`LOGIN_TAKEN` — `<call>: the login "<login>" is taken by another <type>`](#login_taken--call-the-login-login-is-taken-by-another-type)
45
+ - [`LOGIN_TAKEN` — `<call>: the login is taken by another <type>`](#login_taken--call-the-login-is-taken-by-another-type)
46
46
  - [`VERSION_CONFLICT` — `<call>: expected version <n>, found <m>`](#version_conflict--call-expected-version-n-found-m)
47
47
  - [`USER_INVALID` — `<call>: the fields do not match the <type> schema …`](#user_invalid--call-the-fields-do-not-match-the-type-schema-n-issues-at-paths)
48
48
  - [`PASSWORD_TOO_SHORT` — `<call>: the password is shorter than the policy's <n> characters`](#password_too_short--call-the-password-is-shorter-than-the-policys-n-characters)
@@ -328,9 +328,9 @@ try {
328
328
  const user = await auth.find(id); // null when there is nobody
329
329
  ```
330
330
 
331
- ### `LOGIN_TAKEN` — `<call>: the login "<login>" is taken by another <type>`
331
+ ### `LOGIN_TAKEN` — `<call>: the login is taken by another <type>`
332
332
 
333
- `StoreConflict` with `on: 'login'`, carrying `login` and `userType`.
333
+ `StoreConflict` with `on: 'login'`, carrying `login` and `userType`. The login is not in the message, which never carries a value: an e-mail in a log line is personal data. Read it from `error.login`.
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
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.
@@ -354,7 +354,7 @@ await auth.update(user, { name }, { ifVersion: user.version });
354
354
  `UserInvalidError`, carrying `issues` — each a `path` and a `message`.
355
355
 
356
356
  **When:** `signUp`, `create`, `update`.
357
- **Why:** your schema refused the fields. `update` merges the patch over the stored fields and validates the whole, so the refusal can name a field the patch did not touch. An issue whose message is `set by janus, not by a request` means the input carried a field janus sets (`id`, `active`, `version`, …) and your schema let it through.
357
+ **Why:** your schema refused the fields. `update` merges the patch over the stored fields and validates the whole, so the refusal can name a field the patch did not touch. An issue whose message is `set by janus, not by a request` means the input carried a field janus sets (`id`, `active`, `version`, …) and your schema let it through. An issue whose message is `holds a NUL character or a lone surrogate, which no store can keep` means a string — or, with `a key holds …`, an object key — carried `\u0000` or half of a surrogate pair: PostgreSQL refuses both, so janus refuses them on every adapter rather than answer `STORE_FAILED` on one. A key is reported by the path of the object holding it, never by the key itself — in janus's issues and in your schema's alike. An issue at your login field whose message is `normalises to a login that holds …` means your own `password.normalize` function produced such a login from a valid field: fix the function. On MongoDB or the memory store, a user written before janus refused these characters may already hold one: every `update` of that user is refused, whatever it patches, until the field is cleaned — patch it with a clean value in the same `update`.
358
358
  **Fix:** answer 400, field by field:
359
359
 
360
360
  ```ts
@@ -374,7 +374,7 @@ if (error instanceof UserInvalidError) {
374
374
  Also `changePassword: the current password does not match`.
375
375
 
376
376
  **When:** `signIn`, `changePassword`.
377
- **Why:** no user holds the login, the user has no password, or the password is wrong — **one code for the three**. `error.reason` (`unknownLogin`, `noPassword`, `wrongPassword`) tells them apart for your logs and your rate limiter.
377
+ **Why:** no user holds the login, the user has no password, or the password is wrong — **one code for the three**. `error.reason` (`unknownLogin`, `noPassword`, `wrongPassword`) tells them apart for your logs and your rate limiter. A login holding a NUL character or a lone surrogate is `unknownLogin`: no user can hold one.
378
378
  **Fix:** answer 401 with the same body whatever the reason:
379
379
 
380
380
  ```ts
@@ -514,7 +514,7 @@ Also `can: <type>.<relation> reads <field>, which the object holds as something
514
514
  **Fix:** pass the loaded object, spread:
515
515
 
516
516
  ```ts
517
- await access.can(staff, 'view', { type: 'record', ...record });
517
+ await access.can(grace, 'view', { type: 'record', ...record });
518
518
  ```
519
519
 
520
520
  ### `can: <type>.<name> reaches a condition, and no ctx was passed — pass { ctx }`
@@ -526,7 +526,7 @@ Also `list: <type>.<name> reaches a condition, and no ctx was passed — pass {
526
526
  **Fix:**
527
527
 
528
528
  ```ts
529
- await access.can(staff, 'edit', { type: 'record', ...record }, { ctx: { onShift } });
529
+ await access.can(grace, 'edit', { type: 'record', ...record }, { ctx: { onShift: true } });
530
530
  ```
531
531
 
532
532
  ### `list: <type>.<relation> is read from a field, and has no lookup to find the <type>s naming <id> — …`
@@ -536,7 +536,7 @@ await access.can(staff, 'edit', { type: 'record', ...record }, { ctx: { onShift
536
536
  **Fix:**
537
537
 
538
538
  ```ts
539
- fromField('doctorId', 'staff', { lookup: (id) => db.records.idsWhere({ doctorId: id }) });
539
+ doctors: fromField('doctorId', 'staff', { lookup: (staffId) => db.records.idsByDoctor(staffId) }),
540
540
  ```
541
541
 
542
542
  A `lookup` is your code and is not guarded: if it throws, `list()` rejects with that error. Never answer `[]` for a database that could not answer — that is an outage turned into a denial.
@@ -560,8 +560,8 @@ Also with `revoke:`.
560
560
  Also with `revoke:`.
561
561
 
562
562
  **When:** `access.grant(object, relation, subject)`.
563
- **Why:** the model's relation does not admit that kind of subject: `member: ['staff']` refuses a `patient`, and refuses the subject set `team#member` unless it is listed.
564
- **Fix:** grant a subject the relation admits, or add the holder to the model: `member: ['staff', 'team#member']`.
563
+ **Why:** the model's relation does not admit that kind of subject: `members: ['staff']` refuses a `patient`, and refuses the subject set `team#members` unless it is listed.
564
+ **Fix:** grant a subject the relation admits, or add the holder to the model: `members: ['staff', 'team#members']`.
565
565
  With `revoke:` on a tuple stored before the model stopped admitting it, the tuple already grants nothing; remove it through the store: `relations.write({ remove: [tuple] })`.
566
566
 
567
567
  ### Other `can:`, `list:`, `grant:` and `revoke:` messages
@@ -575,7 +575,7 @@ Each is a `TypeError` naming the call. TypeScript refuses most of them on the ar
575
575
  | `<call>: the object must be { type, id, …its fields }` | `{ type: 'record', ...record }`. |
576
576
  | `<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
577
  | `<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: 'member' }`. |
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' }`. |
579
579
  | `grant: "<relation>" is not a relation of <type>` | Grant a relation, never a permission. |
580
580
  | `list: the type must be an object type of the model` | The third argument is a type name: `'record'`. |
581
581
  | `list: after must be the nextCursor of a page, or null` | Pass `nextCursor` back as it came. |
@@ -587,15 +587,33 @@ most common:
587
587
 
588
588
  | Message | Fix |
589
589
  | --- | --- |
590
+ | `defineModel: types.<type>.relations is now related: rename the key` | The key before 0.2. Rename it, nothing else: `related: { members: ['staff'] }`. The compiler refuses it first: `team.relations is now related: rename the key`. |
591
+ | `defineModel: types.<type>.permissions is now permits: rename the key` | The same for the permissions: `permits: { view: ['members'] }`. The compiler refuses it first: `team.permissions is now permits: rename the key`. |
592
+ | `defineModel: types.<type>.<key> is not a key of an object type: related or permits` | An object type has two keys, `related` and `permits` — `permit:`, singular, is a typo. |
590
593
  | `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']`. |
591
594
  | `defineModel: types declares no object type` | Declare at least one type under `types`. |
592
- | `defineModel: the object type "<name>" must be a camelCase name — letters and digits, starting with a lowercase letter` | Also for relation and permission names. |
593
- | `defineModel: "<name>" names a user type and an object type; a subject of type "<name>" would be ambiguous` | Rename the object type. |
595
+ | `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
+ | `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). |
594
598
  | `defineModel: types.<type>: "<name>" names a relation and a permission; rename one` | One name, one meaning. |
595
- | `defineModel: types.<type>.permissions: <a> → <b> → <a> is a loop no relation ends` | A permission must cross a relation before it reaches itself again. |
596
- | `defineModel: … "<rule>" goes through "<relation>", which can hold a subject set; an arrow follows object types only` | An arrow's relation must hold object types: `team: ['team']`. |
599
+ | `defineModel: types.<type>.related.<relation> must be a non-empty array of subject types, or fromField()` | `members: ['staff']`, or `doctors: fromField('doctorId', 'staff')`. |
600
+ | `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. |
601
+ | `defineModel: types.<type>.permits.<permission> must be a non-empty array of rules` | `view: ['members']`. |
602
+ | `defineModel: pass { subjects, types }` | From JavaScript: `defineModel` was given something other than an object. |
603
+ | `defineModel: the subject type "<name>" must be a camelCase name` | A user type from `janus()` is always one; check a list of your own. |
604
+ | `defineModel: types.<type>.related.<relation>: fromField names "<name>", which is not a subject type` | `fromField('doctorId', 'staff')`: the second argument is a user type or an object type of the model. |
605
+ | `defineModel: types.<type>.related.<relation>: fromField must name a top-level field` | `fromField('doctorId', …)`, never `'doctor.id'`: the field is read from the object passed to `can()`. |
606
+ | `defineModel: types.<type>.related.<relation>: fromField's lookup must be a function` | `{ lookup: (staffId) => db.records.idsByDoctor(staffId) }`. |
607
+ | `defineModel: types.<type>.related.<relation>: a holder must be a string` | `members: ['staff', 'team#members']`. |
608
+ | `defineModel: types.<type>.permits.<permission>[<i>]: a rule is a name, an arrow, or when()` | `view: ['doctors', 'teams->view', when('doctors', test)]`. |
609
+ | `defineModel: types.<type>.permits.<permission>[<i>]: when() takes a function as its test` | `when('doctors', (ctx: { onShift: boolean }) => ctx.onShift)`. |
610
+ | `defineModel: types.<type>.permits.<permission>[<i>]: "<rule>" is not a relation or a permission of <type>` | Name a relation or a permission the type declares — `'members'`, `'manage'`. |
611
+ | `defineModel: types.<type>.permits: <a> → <b> → <a> is a loop no relation ends` | A permission must cross a relation before it reaches itself again. |
612
+ | `defineModel: … "<rule>" goes through "<relation>", which is not a relation of <type>` | An arrow starts from a relation of the same type: `'teams->view'` needs `teams` under `related`. |
613
+ | `defineModel: … "<rule>" goes through "<relation>", which can hold a subject set; an arrow follows object types only` — also `which can hold a <user type>` | An arrow's relation must hold object types: `teams: ['team']`. A user has no permissions to follow. The compiler refuses it first. |
597
614
  | `defineModel: … "<rule>" names "<target>", which <type> does not declare` | Arrow to a relation or permission of the target type. |
598
615
  | `defineModel: … reads <type>.<field>, and a subject set reaches <type>s nobody passed to can() — store that relation instead of reading it` | Only the object passed to `can()` carries data: a `fromField` there cannot be reached through a subject set or an arrow. Store it as a tuple. |
616
+ | `defineModel: types.<type>.permits.<permission>: "<relation>-><target>" reaches <type>.<target>, which reads <type>.<field>, and only the object passed to can() carries its data — store that relation instead of reading it` | The same through an arrow: `'teams->leads'` where the team's `leads` is a `fromField`. Store it as a tuple. |
599
617
 
600
618
  ---
601
619
 
@@ -606,14 +624,14 @@ most common:
606
624
  Also `parseSubject: "<text>" is not a subject; expected type:id, or type:id#relation for a subject set`.
607
625
 
608
626
  **When:** `parseTuple(...)` or `parseSubject(...)`, a `TypeError`.
609
- **Why:** subjects are typed. `record:r1#viewer@alice` — Keto's untyped subject — is refused. No part may hold `@`, `#` or a parenthesis, and a type may not hold `:`.
627
+ **Why:** subjects are typed. `team:t1#members@grace` — Keto's untyped subject — is refused. No part may hold `@`, `#` or a parenthesis, and a type may not hold `:`.
610
628
  **Fix:**
611
629
 
612
630
  ```ts
613
631
  import { parseTuple } from '@nxgt/janus';
614
632
 
615
- parseTuple('record:r1#viewer@staff:u1');
616
- parseTuple('record:r1#viewer@team:t1#member');
633
+ parseTuple('record:r1#teams@team:t1');
634
+ parseTuple('team:t1#members@team:t2#members');
617
635
  ```
618
636
 
619
637
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/janus",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
4
4
  "description": "Embeddable, type-safe identities and permissions: bring your own database",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,11 +0,0 @@
1
- {
2
- "version": 3,
3
- "sources": ["../src/ids/id.ts", "../src/auth/port/memory.ts"],
4
- "sourcesContent": [
5
- "/**\n * An id — of a user, a session: a UUIDv7, lowercase, hyphenated.\n *\n * **The core mints it, not the store**, and that decision pays for itself three\n * times over:\n *\n * - A UUIDv7 leads with a 48-bit millisecond timestamp, so ids sort in\n * creation order as strings. The pagination cursor is therefore *the last\n * id* — one index on the id, and the ordering is already total. A\n * store-minted id would need a `createdAt` index plus the id as a tiebreak,\n * because a timestamp alone is not a total order, and an opaque cursor per\n * adapter.\n * - `insertUser` becomes **idempotent under retry**: the id is decided\n * before the call, so a retry after a timeout writes the same row rather\n * than a second user.\n * - Every adapter reports the same shape, so moving an application from one\n * adapter to another is a copy rather than a rewrite of every stored\n * reference.\n *\n * The price, stated plainly: an adapter cannot reuse an existing numeric primary\n * key. It gets a `uuid` column, or a 36-character string, and the monotonic\n * prefix gives it the index locality a UUIDv4 destroys.\n */\nexport type Id = string;\n\nconst HEX: readonly string[] = Array.from({ length: 256 }, (_, i) =>\n\ti.toString(16).padStart(2, '0'),\n);\n\nlet lastMs = -1;\nlet sequence = 0;\n\n/**\n * The 12-bit sequence UUIDv7 keeps for ordering inside one millisecond.\n *\n * RFC 9562 calls this the \"replace leftmost random bits with an increased clock\n * precision\" method. Without it, two ids minted in the same millisecond order\n * by their random tail — still a total order, which is all the port requires,\n * but not creation order, and \"newest first\" would then be wrong for anything\n * created in a burst. A signup storm is exactly such a burst.\n */\nconst SEQUENCE_MAX = 0xfff;\n\n/**\n * A fresh id.\n *\n * Strictly increasing: within one millisecond it uses the sequence, and if that\n * overflows — more than 4096 ids in a millisecond, which no real\n * application reaches — it borrows the next millisecond rather than repeating\n * one. Across processes the 62 random bits of the tail are what keep two machines\n * apart.\n */\nexport function mintId(now: number = Date.now()): Id {\n\tlet ms = now;\n\n\tif (ms === lastMs) {\n\t\tsequence += 1;\n\t\tif (sequence > SEQUENCE_MAX) {\n\t\t\t// Borrow from the next millisecond. This keeps ids strictly\n\t\t\t// increasing at the cost of a timestamp a hair ahead of the clock,\n\t\t\t// which is the right trade: a repeated id would break the cursor.\n\t\t\tms = lastMs + 1;\n\t\t\tsequence = 0;\n\t\t}\n\t} else if (ms > lastMs) {\n\t\tsequence = 0;\n\t} else {\n\t\t// The clock went backwards — an NTP step, or a container resumed. Hold\n\t\t// the last millisecond and keep counting, so ids stay ordered even\n\t\t// though they stop matching the wall clock for a moment.\n\t\tms = lastMs;\n\t\tsequence += 1;\n\t\tif (sequence > SEQUENCE_MAX) {\n\t\t\tms = lastMs + 1;\n\t\t\tsequence = 0;\n\t\t}\n\t}\n\n\tlastMs = ms;\n\n\tconst bytes = new Uint8Array(16);\n\tcrypto.getRandomValues(bytes.subarray(8));\n\n\tbytes[0] = (ms / 2 ** 40) & 0xff;\n\tbytes[1] = (ms / 2 ** 32) & 0xff;\n\tbytes[2] = (ms / 2 ** 24) & 0xff;\n\tbytes[3] = (ms / 2 ** 16) & 0xff;\n\tbytes[4] = (ms / 2 ** 8) & 0xff;\n\tbytes[5] = ms & 0xff;\n\t// Version 7 in the high nibble, the sequence's top 4 bits below it.\n\tbytes[6] = 0x70 | ((sequence >> 8) & 0x0f);\n\tbytes[7] = sequence & 0xff;\n\t// Variant 10 in the two high bits of byte 8; the rest of it stays random.\n\tbytes[8] = 0x80 | ((bytes[8] as number) & 0x3f);\n\n\treturn `${HEX[bytes[0] as number]}${HEX[bytes[1] as number]}${HEX[bytes[2] as number]}${HEX[bytes[3] as number]}-${HEX[bytes[4] as number]}${HEX[bytes[5] as number]}-${HEX[bytes[6] as number]}${HEX[bytes[7] as number]}-${HEX[bytes[8] as number]}${HEX[bytes[9] as number]}-${HEX[bytes[10] as number]}${HEX[bytes[11] as number]}${HEX[bytes[12] as number]}${HEX[bytes[13] as number]}${HEX[bytes[14] as number]}${HEX[bytes[15] as number]}`;\n}\n\nconst UUID_V7 =\n\t/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;\n\n/**\n * Whether this string is an id this package could have minted.\n *\n * Used by an adapter to answer `null` for a malformed id **without reaching the\n * store**: an id arrives off a URL, and one of the wrong shape is \"no such\n * user\", not an outage and not a query. Rejecting it here also keeps a\n * hand-written id out of a store that would then hold something the cursor\n * cannot order.\n */\nexport function isId(value: string): boolean {\n\treturn UUID_V7.test(value);\n}\n\n/**\n * When this id was minted, from the id itself.\n *\n * Offered because the timestamp is *in* the id, so a `createdAt` column is a\n * convenience rather than the truth — and an adapter that loses one can still\n * answer. It is not a substitute for `createdAt`: the sequence may have borrowed\n * a millisecond, and a clock that stepped backwards is held rather than\n * followed, so this is accurate to the millisecond and no further.\n */\nexport function mintedAt(id: Id): Date {\n\tconst hex = id.replace(/-/g, '').slice(0, 12);\n\treturn new Date(Number.parseInt(hex, 16));\n}\n",
6
- "import { NotFoundError, StoreConflict } from '../../errors/janus-error';\nimport type { Id } from '../../ids/id';\nimport type {\n\tJanusStores,\n\tSessionId,\n\tSessionRecord,\n\tSessionStore,\n\tTokenRecord,\n\tTokenStore,\n\tUserPatch,\n\tUserRecord,\n\tUserStore,\n} from './types';\n\n/**\n * The reference implementation of the store port, in memory.\n *\n * **Shipped and documented, not a test helper.** It is what a consumer uses in\n * their own unit tests, and what an adapter author compares against when a\n * conformance case they do not understand turns red. So it keeps the six rules\n * for real rather than approximately:\n *\n * - uniqueness is a constraint — an index checked and written inside the same\n * synchronous step, which is what atomic means on one event loop;\n * - `consumeToken` reads and writes with no `await` between the two, so twenty\n * concurrent calls see exactly one unspent token;\n * - every record is **copied in and copied out**, so a caller that mutates what\n * it passed or what it got back cannot reach the store — the in-memory\n * version of \"bytes round-trip\";\n * - a patch applies only the fields it names, and only the fields the port\n * declares: a stray `version` or `id` in a patch from JavaScript is ignored,\n * not written.\n *\n * Every method is `async` even though none waits on anything: a caller that\n * forgot an `await` must fail here the way it would against a real database.\n */\nexport function createMemoryStores(): JanusStores {\n\treturn {\n\t\tusers: memoryUserStore(),\n\t\tsessions: memorySessionStore(),\n\t\ttokens: memoryTokenStore(),\n\t};\n}\n\nconst copy = <T>(value: T): T => structuredClone(value);\n\n/** The unique key of one login. `\\u0000` cannot occur in a type name the core accepts. */\nconst keyOf = (type: string, login: string): string => `${type}\\u0000${login}`;\n\nfunction memoryUserStore(): UserStore {\n\tconst byId = new Map<Id, UserRecord>();\n\t// The unique index: (type, login) key → the id holding it.\n\tconst byLogin = new Map<string, Id>();\n\n\t/** The first of `logins` held by a user of `type` other than `id`. */\n\tconst takenBy = (\n\t\ttype: string,\n\t\tlogins: readonly string[],\n\t\tid: Id,\n\t): string | undefined =>\n\t\tlogins.find((login) => {\n\t\t\tconst holder = byLogin.get(keyOf(type, login));\n\t\t\treturn holder !== undefined && holder !== id;\n\t\t});\n\n\tconst taken = (operation: string, type: string, login: string) =>\n\t\tnew StoreConflict(\n\t\t\t'login',\n\t\t\t`${operation}: the login \"${login}\" is taken by another ${type}`,\n\t\t\t{ login, userType: type, operation },\n\t\t);\n\n\treturn {\n\t\tasync insertUser(record) {\n\t\t\tconst stored = byId.get(record.id);\n\t\t\tif (stored !== undefined) return copy(stored);\n\n\t\t\tconst collision = takenBy(record.type, record.logins, record.id);\n\t\t\tif (collision !== undefined) {\n\t\t\t\tthrow taken('insertUser', record.type, collision);\n\t\t\t}\n\n\t\t\tconst written = copy(record);\n\t\t\tbyId.set(written.id, written);\n\t\t\tfor (const login of written.logins) {\n\t\t\t\tbyLogin.set(keyOf(written.type, login), written.id);\n\t\t\t}\n\n\t\t\treturn copy(written);\n\t\t},\n\n\t\tasync findUser(id) {\n\t\t\tconst stored = byId.get(id);\n\t\t\treturn stored === undefined ? null : copy(stored);\n\t\t},\n\n\t\tasync findUserByLogin(type, login) {\n\t\t\tconst id = byLogin.get(keyOf(type, login));\n\t\t\tconst stored = id === undefined ? undefined : byId.get(id);\n\t\t\treturn stored === undefined ? null : copy(stored);\n\t\t},\n\n\t\tasync listUsers({ type, after, limit }) {\n\t\t\t// A Map keeps insertion order, not id order: a retried insert or an\n\t\t\t// id minted on another machine can land out of sequence.\n\t\t\tconst ids = [...byId.values()]\n\t\t\t\t.filter((user) => user.type === type)\n\t\t\t\t.map((user) => user.id)\n\t\t\t\t.filter((id) => after === null || id > after)\n\t\t\t\t.sort();\n\t\t\tconst pageIds = ids.slice(0, limit);\n\t\t\tconst last = pageIds.at(-1);\n\n\t\t\treturn {\n\t\t\t\titems: pageIds.map((id) => copy(byId.get(id) as UserRecord)),\n\t\t\t\tnextCursor: ids.length > limit && last !== undefined ? last : null,\n\t\t\t};\n\t\t},\n\n\t\tasync updateUser(id, patch, ifVersion) {\n\t\t\tconst stored = byId.get(id);\n\n\t\t\tif (stored === undefined) {\n\t\t\t\tthrow new NotFoundError('updateUser: no user has this id', {\n\t\t\t\t\tuserId: id,\n\t\t\t\t\toperation: 'updateUser',\n\t\t\t\t});\n\t\t\t}\n\n\t\t\tif (stored.version !== ifVersion) {\n\t\t\t\tthrow new StoreConflict(\n\t\t\t\t\t'version',\n\t\t\t\t\t`updateUser: expected version ${ifVersion}, found ${stored.version}`,\n\t\t\t\t\t{\n\t\t\t\t\t\tuserId: id,\n\t\t\t\t\t\texpectedVersion: ifVersion,\n\t\t\t\t\t\tactualVersion: stored.version,\n\t\t\t\t\t\toperation: 'updateUser',\n\t\t\t\t\t},\n\t\t\t\t);\n\t\t\t}\n\n\t\t\tif (patch.logins !== undefined) {\n\t\t\t\tconst collision = takenBy(stored.type, patch.logins, id);\n\t\t\t\tif (collision !== undefined) {\n\t\t\t\t\tthrow taken('updateUser', stored.type, collision);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst written = applyPatch(stored, copy(patch));\n\n\t\t\tbyId.set(id, written);\n\t\t\tif (patch.logins !== undefined) {\n\t\t\t\tfor (const login of stored.logins) {\n\t\t\t\t\tbyLogin.delete(keyOf(stored.type, login));\n\t\t\t\t}\n\t\t\t\tfor (const login of written.logins) {\n\t\t\t\t\tbyLogin.set(keyOf(written.type, login), id);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn copy(written);\n\t\t},\n\n\t\tasync deleteUser(id) {\n\t\t\tconst stored = byId.get(id);\n\t\t\tif (stored === undefined) return false;\n\n\t\t\tbyId.delete(id);\n\t\t\tfor (const login of stored.logins) {\n\t\t\t\tbyLogin.delete(keyOf(stored.type, login));\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\t};\n}\n\n/**\n * The record a patch produces: the fields it names, replaced whole; the fields\n * it does not name, untouched.\n *\n * Reads each field by name rather than spreading the patch, so a key the port\n * does not declare — `version`, `id`, `type`, `createdAt`, or a `snake_case`\n * typo from JavaScript — never reaches the record. A key present as\n * `undefined` is absent, never an erasure.\n */\nfunction applyPatch(stored: UserRecord, patch: UserPatch): UserRecord {\n\treturn {\n\t\t...stored,\n\t\tschemaVersion: patch.schemaVersion ?? stored.schemaVersion,\n\t\tactive: patch.active ?? stored.active,\n\t\tfields: patch.fields ?? stored.fields,\n\t\tlogins: patch.logins ?? stored.logins,\n\t\tpassword: patch.password === undefined ? stored.password : patch.password,\n\t\temailVerifiedAt:\n\t\t\tpatch.emailVerifiedAt === undefined\n\t\t\t\t? stored.emailVerifiedAt\n\t\t\t\t: patch.emailVerifiedAt,\n\t\tversion: stored.version + 1,\n\t\tupdatedAt: patch.updatedAt,\n\t};\n}\n\nfunction memorySessionStore(): SessionStore {\n\tconst byId = new Map<SessionId, SessionRecord>();\n\tconst byTokenHash = new Map<string, SessionId>();\n\n\tconst revoke = (id: SessionId, stored: SessionRecord, at: Date): void => {\n\t\tbyId.set(id, { ...stored, revokedAt: new Date(at) });\n\t};\n\n\treturn {\n\t\tasync insertSession(record) {\n\t\t\tif (byId.has(record.id)) return;\n\n\t\t\tconst written = copy(record);\n\t\t\tbyId.set(written.id, written);\n\t\t\tbyTokenHash.set(written.tokenHash, written.id);\n\t\t},\n\n\t\tasync findSessionByTokenHash(tokenHash) {\n\t\t\tconst id = byTokenHash.get(tokenHash);\n\t\t\tconst stored = id === undefined ? undefined : byId.get(id);\n\t\t\treturn stored === undefined ? null : copy(stored);\n\t\t},\n\n\t\tasync extendSession(id, expiresAt) {\n\t\t\tconst stored = byId.get(id);\n\t\t\tif (stored === undefined || stored.revokedAt !== null) return null;\n\n\t\t\tconst written: SessionRecord = {\n\t\t\t\t...stored,\n\t\t\t\texpiresAt: new Date(expiresAt),\n\t\t\t};\n\t\t\tbyId.set(id, written);\n\t\t\treturn copy(written);\n\t\t},\n\n\t\tasync revokeSession(id, at) {\n\t\t\tconst stored = byId.get(id);\n\t\t\tif (stored === undefined) return false;\n\n\t\t\tif (stored.revokedAt === null) revoke(id, stored, at);\n\t\t\treturn true;\n\t\t},\n\n\t\tasync revokeUserSessions(userId, at, except) {\n\t\t\tlet revoked = 0;\n\n\t\t\tfor (const [id, stored] of byId) {\n\t\t\t\tif (\n\t\t\t\t\tstored.userId === userId &&\n\t\t\t\t\tstored.revokedAt === null &&\n\t\t\t\t\tid !== except\n\t\t\t\t) {\n\t\t\t\t\trevoke(id, stored, at);\n\t\t\t\t\trevoked += 1;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn revoked;\n\t\t},\n\n\t\tasync deleteUserSessions(userId) {\n\t\t\tlet deleted = 0;\n\n\t\t\tfor (const [id, stored] of byId) {\n\t\t\t\tif (stored.userId === userId) {\n\t\t\t\t\tbyId.delete(id);\n\t\t\t\t\tbyTokenHash.delete(stored.tokenHash);\n\t\t\t\t\tdeleted += 1;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn deleted;\n\t\t},\n\n\t\tasync deleteExpiredSessions(before) {\n\t\t\tlet deleted = 0;\n\n\t\t\tfor (const [id, stored] of byId) {\n\t\t\t\tif (stored.expiresAt.getTime() <= before.getTime()) {\n\t\t\t\t\tbyId.delete(id);\n\t\t\t\t\tbyTokenHash.delete(stored.tokenHash);\n\t\t\t\t\tdeleted += 1;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn deleted;\n\t\t},\n\t};\n}\n\nfunction memoryTokenStore(): TokenStore {\n\tconst byTokenHash = new Map<string, TokenRecord>();\n\n\treturn {\n\t\tasync insertToken(record) {\n\t\t\tif (byTokenHash.has(record.tokenHash)) return;\n\t\t\tbyTokenHash.set(record.tokenHash, copy(record));\n\t\t},\n\n\t\tasync consumeToken(tokenHash, kind, at) {\n\t\t\t// No `await` between this read and the write below: on one event loop\n\t\t\t// that is what makes the pair a single conditional write.\n\t\t\tconst stored = byTokenHash.get(tokenHash);\n\t\t\tif (stored === undefined || stored.kind !== kind) return null;\n\n\t\t\tconst before = copy(stored);\n\t\t\tif (stored.spentAt === null) {\n\t\t\t\tbyTokenHash.set(tokenHash, { ...stored, spentAt: new Date(at) });\n\t\t\t}\n\n\t\t\treturn before;\n\t\t},\n\n\t\tasync deleteUserTokens(userId) {\n\t\t\tlet deleted = 0;\n\n\t\t\tfor (const [tokenHash, stored] of byTokenHash) {\n\t\t\t\tif (stored.userId === userId) {\n\t\t\t\t\tbyTokenHash.delete(tokenHash);\n\t\t\t\t\tdeleted += 1;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn deleted;\n\t\t},\n\t};\n}\n"
7
- ],
8
- "mappings": "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",
9
- "debugId": "127433514BF2BB4B64756E2164756E21",
10
- "names": []
11
- }