@nxgt/janus 0.1.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/LICENSE +21 -0
- package/README.md +554 -0
- package/dist/auth/config.d.ts +201 -0
- package/dist/auth/config.d.ts.map +1 -0
- package/dist/auth/context.d.ts +87 -0
- package/dist/auth/context.d.ts.map +1 -0
- package/dist/auth/hashers.d.ts +35 -0
- package/dist/auth/hashers.d.ts.map +1 -0
- package/dist/auth/index.d.ts +9 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/janus.d.ts +38 -0
- package/dist/auth/janus.d.ts.map +1 -0
- package/dist/auth/outage.d.ts +61 -0
- package/dist/auth/outage.d.ts.map +1 -0
- package/dist/auth/port/assert-stores.d.ts +16 -0
- package/dist/auth/port/assert-stores.d.ts.map +1 -0
- package/dist/auth/port/memory.d.ts +25 -0
- package/dist/auth/port/memory.d.ts.map +1 -0
- package/dist/auth/port/types.d.ts +393 -0
- package/dist/auth/port/types.d.ts.map +1 -0
- package/dist/auth/secrets.d.ts +17 -0
- package/dist/auth/secrets.d.ts.map +1 -0
- package/dist/auth/sessions.d.ts +31 -0
- package/dist/auth/sessions.d.ts.map +1 -0
- package/dist/auth/standard-schema.d.ts +41 -0
- package/dist/auth/standard-schema.d.ts.map +1 -0
- package/dist/auth/types.d.ts +353 -0
- package/dist/auth/types.d.ts.map +1 -0
- package/dist/auth/users.d.ts +9 -0
- package/dist/auth/users.d.ts.map +1 -0
- package/dist/chunks/index-658b6mr2.js +269 -0
- package/dist/chunks/index-658b6mr2.js.map +11 -0
- package/dist/chunks/index-6dytvy3h.js +93 -0
- package/dist/chunks/index-6dytvy3h.js.map +11 -0
- package/dist/chunks/index-6p56fpbe.js +147 -0
- package/dist/chunks/index-6p56fpbe.js.map +12 -0
- package/dist/chunks/index-fgb3t64y.js +77 -0
- package/dist/chunks/index-fgb3t64y.js.map +10 -0
- package/dist/conformance/assert.d.ts +36 -0
- package/dist/conformance/assert.d.ts.map +1 -0
- package/dist/conformance/cases/outage.d.ts +3 -0
- package/dist/conformance/cases/outage.d.ts.map +1 -0
- package/dist/conformance/cases/sessions.d.ts +3 -0
- package/dist/conformance/cases/sessions.d.ts.map +1 -0
- package/dist/conformance/cases/tokens.d.ts +3 -0
- package/dist/conformance/cases/tokens.d.ts.map +1 -0
- package/dist/conformance/cases/users.d.ts +3 -0
- package/dist/conformance/cases/users.d.ts.map +1 -0
- package/dist/conformance/describe.d.ts +74 -0
- package/dist/conformance/describe.d.ts.map +1 -0
- package/dist/conformance/fixtures.d.ts +11 -0
- package/dist/conformance/fixtures.d.ts.map +1 -0
- package/dist/conformance/index.d.ts +33 -0
- package/dist/conformance/index.d.ts.map +1 -0
- package/dist/conformance/index.js +1113 -0
- package/dist/conformance/index.js.map +18 -0
- package/dist/conformance/reference.d.ts +13 -0
- package/dist/conformance/reference.d.ts.map +1 -0
- package/dist/conformance/relations.d.ts +67 -0
- package/dist/conformance/relations.d.ts.map +1 -0
- package/dist/conformance/types.d.ts +71 -0
- package/dist/conformance/types.d.ts.map +1 -0
- package/dist/errors/janus-error.d.ts +259 -0
- package/dist/errors/janus-error.d.ts.map +1 -0
- package/dist/ids/id.d.ts +55 -0
- package/dist/ids/id.d.ts.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +962 -0
- package/dist/index.js.map +19 -0
- package/dist/pagination/cursor-page.d.ts +53 -0
- package/dist/pagination/cursor-page.d.ts.map +1 -0
- package/dist/permissions/engine.d.ts +46 -0
- package/dist/permissions/engine.d.ts.map +1 -0
- package/dist/permissions/index.d.ts +24 -0
- package/dist/permissions/index.d.ts.map +1 -0
- package/dist/permissions/index.js +685 -0
- package/dist/permissions/index.js.map +15 -0
- package/dist/permissions/input.d.ts +29 -0
- package/dist/permissions/input.d.ts.map +1 -0
- package/dist/permissions/model.d.ts +368 -0
- package/dist/permissions/model.d.ts.map +1 -0
- package/dist/permissions/port/memory.d.ts +17 -0
- package/dist/permissions/port/memory.d.ts.map +1 -0
- package/dist/permissions/port/types.d.ts +84 -0
- package/dist/permissions/port/types.d.ts.map +1 -0
- package/dist/permissions/resolve.d.ts +59 -0
- package/dist/permissions/resolve.d.ts.map +1 -0
- package/dist/permissions/reverse.d.ts +53 -0
- package/dist/permissions/reverse.d.ts.map +1 -0
- package/dist/permissions/walk.d.ts +29 -0
- package/dist/permissions/walk.d.ts.map +1 -0
- package/dist/subjects/notation.d.ts +35 -0
- package/dist/subjects/notation.d.ts.map +1 -0
- package/dist/subjects/subject.d.ts +75 -0
- package/dist/subjects/subject.d.ts.map +1 -0
- package/dist/time/clock.d.ts +31 -0
- package/dist/time/clock.d.ts.map +1 -0
- package/dist/time/duration.d.ts +23 -0
- package/dist/time/duration.d.ts.map +1 -0
- package/docs/README.md +17 -0
- package/docs/guide/adapters.md +296 -0
- package/docs/guide/email-flows.md +146 -0
- package/docs/guide/errors.md +146 -0
- package/docs/guide/passwords.md +136 -0
- package/docs/guide/permissions.md +347 -0
- package/docs/guide/sessions.md +211 -0
- package/docs/guide/users.md +277 -0
- package/docs/guide/vocabulary.md +163 -0
- package/docs/roadmap.md +93 -0
- package/docs/troubleshooting.md +648 -0
- package/package.json +70 -0
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
# Troubleshooting `@nxgt/janus`
|
|
2
|
+
|
|
3
|
+
Each entry is headed by the text you see: a compiler error, a message, or an
|
|
4
|
+
error `code`. Search this page for the words of your message.
|
|
5
|
+
|
|
6
|
+
How the messages are shaped:
|
|
7
|
+
|
|
8
|
+
- **Every message starts with the call you wrote**: `signIn: …`,
|
|
9
|
+
`resetPassword.confirm: …`, `can: …`. With several user types, that call is
|
|
10
|
+
prefixed by the type: `staff.signIn: …`. Below, `<call>` stands for it.
|
|
11
|
+
- **A `TypeError` is a wiring mistake**: it comes from how the application was
|
|
12
|
+
put together — a configuration, a store, a model — and never from a request.
|
|
13
|
+
Fix the code; no handler should answer one.
|
|
14
|
+
- **A `JanusError` is a refusal at call time**, on a value that could have come
|
|
15
|
+
from a request. It carries a `code` you can `switch` on; the heading of each
|
|
16
|
+
entry below names it.
|
|
17
|
+
|
|
18
|
+
## Index
|
|
19
|
+
|
|
20
|
+
**Install and types**
|
|
21
|
+
- [`TS2834: Relative import paths need explicit file extensions …`](#ts2834-relative-import-paths-need-explicit-file-extensions-in-ecmascript-imports-when---moduleresolution-is-node16-or-nodenext)
|
|
22
|
+
- [`TS2305: Module '"@nxgt/janus"' has no exported member '<name>'.`](#ts2305-module-nxgtjanus-has-no-exported-member-name)
|
|
23
|
+
- [`error instanceof StoreFailure` is `false` for an outage](#error-instanceof-storefailure-is-false-for-an-outage)
|
|
24
|
+
|
|
25
|
+
**Configuring `janus()`**
|
|
26
|
+
- [`janus: pass either user … or users …, and exactly one of them`](#janus-pass-either-user-one-kind-of-user-or-users-several-kinds-and-exactly-one-of-them)
|
|
27
|
+
- [`janus: user must be a Standard Schema …`](#janus-user-must-be-a-standard-schema--a-zod-4-valibot-or-arktype-schema)
|
|
28
|
+
- [`janus: a user type signs in with a password and no hasher is wired …`](#janus-a-user-type-signs-in-with-a-password-and-no-hasher-is-wired--pass-hasher-scrypthasher-or-bunhasher-on-bun-there-is-no-silent-fallback)
|
|
29
|
+
- [`bunHasher: Bun.password is not available …`](#bunhasher-bunpassword-is-not-available--this-runtime-is-not-bun-wire-scrypthasher-instead)
|
|
30
|
+
- [`scryptHasher: cost is log2(N), an integer from 10 to 20 …`](#scrypthasher-cost-is-log2n-an-integer-from-10-to-20--17-is-the-recommended-value)
|
|
31
|
+
- [`janus: two hashers claim the prefix "<prefix>" …`](#janus-two-hashers-claim-the-prefix-prefix--which-one-verifies-would-depend-on-their-order)
|
|
32
|
+
- [`janus: store.<slot> has no method <method>, which the port requires`](#janus-storeslot-has-no-method-method-which-the-port-requires)
|
|
33
|
+
- [`janus: relations must be a relation store — relations.deleteEntity is missing`](#janus-relations-must-be-a-relation-store--relationsdeleteentity-is-missing)
|
|
34
|
+
- [`janus: password.login must name a top-level field of the schema, such as "email"`](#janus-passwordlogin-must-name-a-top-level-field-of-the-schema-such-as-email)
|
|
35
|
+
- [`janus: password.login "<field>" did not name a string in validated <type> fields …`](#janus-passwordlogin-field-did-not-name-a-string-in-validated-type-fields--it-must-name-a-required-string-field)
|
|
36
|
+
- [`janus: the user type "<name>" must be a camelCase name …`](#janus-the-user-type-name-must-be-a-camelcase-name--letters-and-digits-starting-with-a-letter)
|
|
37
|
+
- [`janus: session.lifespan: "<value>" is not a duration …`](#janus-sessionlifespan-value-is-not-a-duration-write-a-number-followed-by-ms-s-m-h-or-d--for-example-15m-or-720h)
|
|
38
|
+
- [`janus: cookie.sameSite "none" requires cookie.secure …`](#janus-cookiesamesite-none-requires-cookiesecure--browsers-refuse-the-cookie-otherwise)
|
|
39
|
+
- [Other `janus:` wiring messages](#other-janus-wiring-messages)
|
|
40
|
+
|
|
41
|
+
**Users, sessions and tokens**
|
|
42
|
+
- [`STORE_FAILED` — `<slot>.<method>: the store could not answer`](#store_failed--slotmethod-the-store-could-not-answer)
|
|
43
|
+
- [`STORE_FAILED` — `<slot>.<method> answered undefined …`](#store_failed--slotmethod-answered-undefined-an-absence-is-null-so-this-store-forgot-to-answer)
|
|
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)
|
|
46
|
+
- [`VERSION_CONFLICT` — `<call>: expected version <n>, found <m>`](#version_conflict--call-expected-version-n-found-m)
|
|
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
|
+
- [`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)
|
|
49
|
+
- [`CREDENTIALS_INVALID` — `<call>: the login and the password do not match`](#credentials_invalid--call-the-login-and-the-password-do-not-match)
|
|
50
|
+
- [`HASH_UNSUPPORTED` — `<call>: no wired verifier claims the prefix "<prefix>"`](#hash_unsupported--call-no-wired-verifier-claims-the-prefix-prefix)
|
|
51
|
+
- [`scryptHasher: the stored hash has the $scrypt$ prefix and not its format`](#scrypthasher-the-stored-hash-has-the-scrypt-prefix-and-not-its-format)
|
|
52
|
+
- [`USER_INACTIVE` — `<call>: the user is inactive`](#user_inactive--call-the-user-is-inactive)
|
|
53
|
+
- [`TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`](#token_unknown-token_spent-token_expired)
|
|
54
|
+
- [`TOKEN_STALE` — `<call>: the token was sent to an e-mail the user no longer has`](#token_stale--call-the-token-was-sent-to-an-e-mail-the-user-no-longer-has)
|
|
55
|
+
- [`INVALID_CURSOR` — `<call>: this cursor was not minted by this store …`](#invalid_cursor--call-this-cursor-was-not-minted-by-this-store-or-was-minted-for-another-ordering-n-characters)
|
|
56
|
+
- [`<call>: limit must be an integer of at least 1, or absent`](#call-limit-must-be-an-integer-of-at-least-1-or-absent)
|
|
57
|
+
- [`UNSUPPORTED` — `collectExpired: store.sessions does not implement deleteExpiredSessions …`](#unsupported--collectexpired-storesessions-does-not-implement-deleteexpiredsessions--its-store-expires-sessions-on-its-own-or-implement-the-method)
|
|
58
|
+
- [`<call>: the <type> type does not sign in with a password …`](#call-the-type-type-does-not-sign-in-with-a-password--add-password--login--to-it)
|
|
59
|
+
- [`authenticate()` answers `null` although a valid cookie was sent](#authenticate-answers-null-although-a-valid-cookie-was-sent)
|
|
60
|
+
|
|
61
|
+
**Permissions**
|
|
62
|
+
- [`PERMISSION_DEPTH` — `can: checking <type>#<permission> crossed more than <n> relations without an answer`](#permission_depth--can-checking-typepermission-crossed-more-than-n-relations-without-an-answer)
|
|
63
|
+
- [`permissions: this model was not made by defineModel() …`](#permissions-this-model-was-not-made-by-definemodel--pass-what-definemodel-answered)
|
|
64
|
+
- [`permissions: store.<method> is missing`](#permissions-storemethod-is-missing)
|
|
65
|
+
- [`can: <type>.<relation> reads <field>, which the object does not carry …`](#can-typerelation-reads-field-which-the-object-does-not-carry--pass-the-loaded-object-spread)
|
|
66
|
+
- [`can: <type>.<name> reaches a condition, and no ctx was passed — pass { ctx }`](#can-typename-reaches-a-condition-and-no-ctx-was-passed--pass--ctx-)
|
|
67
|
+
- [`list: <type>.<relation> is read from a field, and has no lookup …`](#list-typerelation-is-read-from-a-field-and-has-no-lookup-to-find-the-types-naming-id--)
|
|
68
|
+
- [`list: the lookup of <type>.<relation> must answer an array of ids`](#list-the-lookup-of-typerelation-must-answer-an-array-of-ids)
|
|
69
|
+
- [`grant: <type>.<relation> is read from <field>; there is nothing to store …`](#grant-typerelation-is-read-from-field-there-is-nothing-to-store--change-the-type-instead)
|
|
70
|
+
- [`grant: <type>.<relation> is not held by <holder>`](#grant-typerelation-is-not-held-by-holder)
|
|
71
|
+
- [Other `can:`, `list:`, `grant:` and `revoke:` messages](#other-can-list-grant-and-revoke-messages)
|
|
72
|
+
- [`defineModel: …`](#definemodel-)
|
|
73
|
+
|
|
74
|
+
**Subjects**
|
|
75
|
+
- [`parseTuple: "<text>" is not a relation tuple; expected type:id#relation@subject`](#parsetuple-text-is-not-a-relation-tuple-expected-typeidrelationsubject)
|
|
76
|
+
|
|
77
|
+
**Conformance (adapter authors)**
|
|
78
|
+
- [`describeJanusStores: no test runner on globalThis …`](#describejanusstores-no-test-runner-on-globalthis--pass-runner--describe-it--under-bun-test-import-them-from-buntest)
|
|
79
|
+
- [`JANUS_CONFORMANCE_SKIPPED` — `faults not provided: the outage invariant is not proven for this adapter`](#janus_conformance_skipped--faults-not-provided-the-outage-invariant-is-not-proven-for-this-adapter)
|
|
80
|
+
- [`the error is named <Class> but is not @nxgt/janus's <Class>: two copies of @nxgt/janus are installed …`](#the-error-is-named-class-but-is-not-nxgtjanuss-class-two-copies-of-nxgtjanus-are-installed-the-adapter-must-list-it-as-a-peer-dependency-never-a-dependency)
|
|
81
|
+
- [`expected null, got undefined — an absence is null; undefined is a store that forgot to answer`](#expected-null-got-undefined--an-absence-is-null-undefined-is-a-store-that-forgot-to-answer)
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## Install and types
|
|
86
|
+
|
|
87
|
+
### `TS2834: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'.`
|
|
88
|
+
|
|
89
|
+
**When:** `tsc` on your project, reported inside `node_modules/@nxgt/janus/dist/*.d.ts`, once per import line.
|
|
90
|
+
**Why:** the declarations import their siblings without an extension (`'./auth/index'`), the way a bundler resolves them. `moduleResolution: "nodenext"` (or `"node16"`) demands an extension on every relative import and is **not supported** by this package.
|
|
91
|
+
**Fix:** resolve as a bundler does — Bun, Vite, esbuild and every other bundler already do:
|
|
92
|
+
|
|
93
|
+
```jsonc
|
|
94
|
+
// tsconfig.json
|
|
95
|
+
{
|
|
96
|
+
"compilerOptions": {
|
|
97
|
+
"module": "preserve", // or "esnext"
|
|
98
|
+
"moduleResolution": "bundler"
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Do not patch the declarations to add `.js` extensions: that support is out of
|
|
104
|
+
scope, and a patched copy breaks on the next install.
|
|
105
|
+
|
|
106
|
+
### `TS2305: Module '"@nxgt/janus"' has no exported member '<name>'.`
|
|
107
|
+
|
|
108
|
+
**When:** `tsc` on your own file, for an export the README shows — typically `janus`, `createMemoryStores` or `scryptHasher`.
|
|
109
|
+
**Why:** the same `moduleResolution: "nodenext"` as above, with `skipLibCheck: true` hiding the `TS2834` errors in the declarations. The `export * from './auth/index'` in the package's entry declaration does not resolve, so everything it re-exports is missing.
|
|
110
|
+
**Fix:** `"moduleResolution": "bundler"`, as in the entry above.
|
|
111
|
+
|
|
112
|
+
### `error instanceof StoreFailure` is `false` for an outage
|
|
113
|
+
|
|
114
|
+
**When:** at run time, with an adapter from another package (`@nxgt/janus-mongo`, or your own). An outage is then handled as an unknown error, and a taken login can be reported as an outage.
|
|
115
|
+
**Why:** two copies of `@nxgt/janus` are installed — the adapter depends on it instead of peering it, or the versions it accepts do not include yours. The adapter throws its copy's `StoreFailure`, and your code tests against the other.
|
|
116
|
+
**Fix:** one copy. An adapter lists `@nxgt/janus` in `peerDependencies`, never `dependencies`; then check that only one is installed:
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
bun pm ls --all | grep @nxgt/janus
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Configuring `janus()`
|
|
125
|
+
|
|
126
|
+
Every entry here is a `TypeError` thrown by `janus(...)` itself, before any
|
|
127
|
+
request. With several user types, the option is prefixed by the type:
|
|
128
|
+
`janus: users.staff: password.login …`.
|
|
129
|
+
|
|
130
|
+
### `janus: pass either user (one kind of user) or users (several kinds), and exactly one of them`
|
|
131
|
+
|
|
132
|
+
**When:** `janus({...})`, with both `user` and `users`, or neither.
|
|
133
|
+
**Why:** `user` is the shorthand for one kind of user; `users` declares several, each with its own schema and options. The two cannot be mixed.
|
|
134
|
+
**Fix:**
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
// One kind of user
|
|
138
|
+
janus({ user: User, password: { login: 'email' }, store, hasher });
|
|
139
|
+
|
|
140
|
+
// Several
|
|
141
|
+
janus({
|
|
142
|
+
users: {
|
|
143
|
+
patient: { schema: Patient, password: { login: 'email' } },
|
|
144
|
+
staff: { schema: Staff, password: { login: 'username' } },
|
|
145
|
+
},
|
|
146
|
+
store,
|
|
147
|
+
hasher,
|
|
148
|
+
});
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### `janus: user must be a Standard Schema — a Zod 4, Valibot or ArkType schema`
|
|
152
|
+
|
|
153
|
+
With several types: `janus: users.<type>: schema must be a Standard Schema — a Zod 4, Valibot or ArkType schema`.
|
|
154
|
+
|
|
155
|
+
**When:** `janus({...})`.
|
|
156
|
+
**Why:** the value has no `['~standard'].validate`. It is a plain object, a TypeScript type used as a value, or a schema from a library version that does not implement [Standard Schema](https://standardschema.dev).
|
|
157
|
+
**Fix:** pass the schema object itself:
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
import { z } from 'zod'; // Zod 4
|
|
161
|
+
const User = z.object({ email: z.email(), name: z.string() });
|
|
162
|
+
janus({ user: User, password: { login: 'email' }, store, hasher });
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### `janus: a user type signs in with a password and no hasher is wired — pass hasher: scryptHasher(), or bunHasher() on Bun. There is no silent fallback`
|
|
166
|
+
|
|
167
|
+
**When:** `janus({...})`, when a type has `password` and the configuration has no `hasher`.
|
|
168
|
+
**Why:** picking a hashing algorithm for you would be a silent choice about your users' passwords.
|
|
169
|
+
**Fix:**
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
import { janus, scryptHasher } from '@nxgt/janus';
|
|
173
|
+
|
|
174
|
+
janus({ user: User, password: { login: 'email' }, store, hasher: scryptHasher() });
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### `bunHasher: Bun.password is not available — this runtime is not Bun; wire scryptHasher() instead`
|
|
178
|
+
|
|
179
|
+
**When:** calling `bunHasher()` under Node, or in a test runner that is not Bun.
|
|
180
|
+
**Why:** `bunHasher()` is argon2id through `Bun.password`, which only Bun provides.
|
|
181
|
+
**Fix:** `scryptHasher()` runs on both. To keep verifying argon2id hashes written under Bun while running on Node, you need a verifier for the `$argon2id$` prefix of your own in `verifiers`.
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
const hasher = typeof Bun === 'undefined' ? scryptHasher() : bunHasher();
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### `scryptHasher: cost is log2(N), an integer from 10 to 20 — 17 is the recommended value`
|
|
188
|
+
|
|
189
|
+
**When:** `scryptHasher({ cost })`.
|
|
190
|
+
**Why:** `cost` is the exponent, not N: `cost: 131072` asks for 2^131072.
|
|
191
|
+
**Fix:** leave it out in production (17). In tests, `scryptHasher({ cost: 10 })` is fast and still runs every line.
|
|
192
|
+
|
|
193
|
+
### `janus: two hashers claim the prefix "<prefix>" — which one verifies would depend on their order`
|
|
194
|
+
|
|
195
|
+
Also: `janus: every hasher needs a non-empty prefix`.
|
|
196
|
+
|
|
197
|
+
**When:** `janus({...})` with `verifiers`.
|
|
198
|
+
**Why:** a stored hash is verified by the hasher whose `prefix` it starts with. Two hashers with one prefix — typically `hasher` repeated in `verifiers` — would make that depend on list order.
|
|
199
|
+
**Fix:** `hasher` already verifies its own hashes; `verifiers` only lists the *other* formats your database holds.
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
janus({ ..., hasher: scryptHasher(), verifiers: [legacyBcrypt] }); // not [scryptHasher(), legacyBcrypt]
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### `janus: store.<slot> has no method <method>, which the port requires`
|
|
206
|
+
|
|
207
|
+
Also: `janus: store.<slot> is missing`, `janus: store must be an object with users, sessions and tokens`, `janus: store.sessions.deleteExpiredSessions must be a function or absent`.
|
|
208
|
+
|
|
209
|
+
**When:** `janus({...})`, from JavaScript or with a store typed loosely. TypeScript refuses a partial store at compile time and names the method.
|
|
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
|
+
**Fix:** pass the three stores, whole:
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
import { createMemoryStores, janus } from '@nxgt/janus';
|
|
215
|
+
|
|
216
|
+
janus({ ..., store: createMemoryStores() });
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### `janus: relations must be a relation store — relations.deleteEntity is missing`
|
|
220
|
+
|
|
221
|
+
**When:** `janus({ ..., relations })`.
|
|
222
|
+
**Why:** `relations` takes the **relation store** — the same one `permissions()` takes as `store` — not what `permissions()` answers, nor the model.
|
|
223
|
+
**Fix:**
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
import { createMemoryRelations, permissions } from '@nxgt/janus/permissions';
|
|
227
|
+
|
|
228
|
+
const relations = createMemoryRelations();
|
|
229
|
+
const auth = janus({ ..., relations }); // deleting a user deletes their tuples
|
|
230
|
+
const access = permissions({ model, store: relations });
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### `janus: password.login must name a top-level field of the schema, such as "email"`
|
|
234
|
+
|
|
235
|
+
Also `janus: email must name a top-level field of the schema, such as "email"`, and `janus: password.login: "<field>" is a field janus sets itself`.
|
|
236
|
+
|
|
237
|
+
**When:** `janus({...})`, from JavaScript; TypeScript refuses these on the `login` or `email` key first.
|
|
238
|
+
**Why:** a login and an e-mail are top-level fields of your schema, by name — not a path (`'contact.email'`), and not a field janus sets (`id`, `type`, `active`, `version`, …).
|
|
239
|
+
**Fix:**
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
janus({ user: z.object({ username: z.string() }), password: { login: 'username' }, ... });
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### `janus: password.login "<field>" did not name a string in validated <type> fields — it must name a required string field`
|
|
246
|
+
|
|
247
|
+
**When:** `signUp`, `create` or `update`, when the validated fields have no string at the login field.
|
|
248
|
+
**Why:** the login field is optional or not a string in the schema. TypeScript refuses that shape at compile time; a schema typed loosely gets here.
|
|
249
|
+
**Fix:** make the login a required string: `username: z.string()`, not `z.string().optional()`.
|
|
250
|
+
|
|
251
|
+
### `janus: the user type "<name>" must be a camelCase name — letters and digits, starting with a letter`
|
|
252
|
+
|
|
253
|
+
Also `janus: "<name>" cannot name a user type — janus() answers a method of that name` and `janus: users declares no user type`.
|
|
254
|
+
|
|
255
|
+
**When:** `janus({ users: {...} })`.
|
|
256
|
+
**Why:** each type becomes a property of what `janus()` answers — `auth.staff` — so it must be a name, and not one of the shared methods (`authenticate`, `signOut`, `signOutEverywhere`, `findUser`, `getUser`, `cookie`, `collectExpired`, `types`).
|
|
257
|
+
**Fix:** `users: { staffMember: {...} }`, not `'staff-member'` or `cookie`.
|
|
258
|
+
|
|
259
|
+
### `janus: session.lifespan: "<value>" is not a duration; write a number followed by ms, s, m, h or d — for example "15m" or "720h"`
|
|
260
|
+
|
|
261
|
+
The same for `session.renewAfter`, `tokens.verifyEmail` and `tokens.resetPassword`. Also `<option>: a duration must be above zero` and `<option>: a duration in milliseconds must be a finite number above zero`.
|
|
262
|
+
|
|
263
|
+
**When:** `janus({...})`.
|
|
264
|
+
**Why:** a duration is a number of milliseconds, or a number followed by one unit. `'30 m'` compiles — TypeScript's `${number}` accepts the space — and is refused here.
|
|
265
|
+
**Fix:**
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
janus({ ..., session: { lifespan: '8h', renewAfter: '30m' }, tokens: { resetPassword: '1h' } });
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### `janus: cookie.sameSite "none" requires cookie.secure — browsers refuse the cookie otherwise`
|
|
272
|
+
|
|
273
|
+
Also `janus: cookie.name must be a cookie-name token — letters, digits and !#$%&'*+-.^_\`|~, with no space, ";" or "="`.
|
|
274
|
+
|
|
275
|
+
**When:** `janus({ ..., cookie })`.
|
|
276
|
+
**Why:** browsers drop a `SameSite=None` cookie that is not `Secure`, so every sign-in would silently fail to stick.
|
|
277
|
+
**Fix:** keep `secure: true` with `sameSite: 'none'`, or use the default `sameSite: 'lax'` when the site and the API share a site.
|
|
278
|
+
|
|
279
|
+
### Other `janus:` wiring messages
|
|
280
|
+
|
|
281
|
+
| Message | Fix |
|
|
282
|
+
| --- | --- |
|
|
283
|
+
| `janus: expected a configuration object` | Pass an object to `janus()`. |
|
|
284
|
+
| `janus: password.minLength must be an integer of at least 1` | `password: { login: 'email', minLength: 12 }`. |
|
|
285
|
+
| `janus: password.normalize must be "none", "lowercase", "lowercaseTrim", "nfkcLowercaseTrim" or a function` | One of those names, or `(login) => string`. |
|
|
286
|
+
|
|
287
|
+
---
|
|
288
|
+
|
|
289
|
+
## Users, sessions and tokens
|
|
290
|
+
|
|
291
|
+
### `STORE_FAILED` — `<slot>.<method>: the store could not answer`
|
|
292
|
+
|
|
293
|
+
`StoreFailure`, for example `users.findUserByLogin: the store could not answer`.
|
|
294
|
+
|
|
295
|
+
**When:** any call that reaches the store — `authenticate`, `signIn`, `get`, `can` — while the database is down, times out, or the adapter throws.
|
|
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 everybody with an account is told they do not have one.
|
|
298
|
+
|
|
299
|
+
```ts
|
|
300
|
+
import { JanusError } from '@nxgt/janus';
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
return await auth.authenticate(request);
|
|
304
|
+
} catch (error) {
|
|
305
|
+
if (error instanceof JanusError && error.code === 'STORE_FAILED') {
|
|
306
|
+
console.error(error.cause);
|
|
307
|
+
return new Response('Try again shortly', { status: 503 });
|
|
308
|
+
}
|
|
309
|
+
throw error;
|
|
310
|
+
}
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
### `STORE_FAILED` — `<slot>.<method> answered undefined: an absence is null, so this store forgot to answer`
|
|
314
|
+
|
|
315
|
+
**When:** with a store you wrote, on the first call to that method.
|
|
316
|
+
**Why:** a method that can find nothing must answer `null`. `undefined` is also what a missing `return` produces, so it is treated as a bug in the store, not as "not found".
|
|
317
|
+
**Fix:** `return doc ?? null;` — and run `@nxgt/janus/conformance` against the store.
|
|
318
|
+
|
|
319
|
+
### `NOT_FOUND` — `<call>: no <type> has this id`
|
|
320
|
+
|
|
321
|
+
`NotFoundError`, from `get`, `getUser`, `update`, `setActive`, `setPassword`, `verifyEmail.send`. Also `<call>: the user has no e-mail`.
|
|
322
|
+
|
|
323
|
+
**When:** the id names nobody, names a user of another type (`auth.staff.get(patientId)`), or is not an id at all.
|
|
324
|
+
**Why:** `get*` calls turn an absence into `NOT_FOUND`; `find*` calls answer `null` instead.
|
|
325
|
+
**Fix:** answer 404, or use `find` when absence is an ordinary outcome:
|
|
326
|
+
|
|
327
|
+
```ts
|
|
328
|
+
const user = await auth.find(id); // null when there is nobody
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
### `LOGIN_TAKEN` — `<call>: the login "<login>" is taken by another <type>`
|
|
332
|
+
|
|
333
|
+
`StoreConflict` with `on: 'login'`, carrying `login` and `userType`.
|
|
334
|
+
|
|
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 account and a staff account.
|
|
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
|
+
|
|
339
|
+
### `VERSION_CONFLICT` — `<call>: expected version <n>, found <m>`
|
|
340
|
+
|
|
341
|
+
`StoreConflict` with `on: 'version'`, carrying `expectedVersion` and `actualVersion`.
|
|
342
|
+
|
|
343
|
+
**When:** a write with `ifVersion`, after someone else changed the user.
|
|
344
|
+
**Why:** nothing was written. A sign-in moves `version` too, when it rewrites a stale password hash — so a user read before somebody signed in, then passed as `ifVersion`, conflicts.
|
|
345
|
+
**Fix:** read the user again and retry, or answer 409:
|
|
346
|
+
|
|
347
|
+
```ts
|
|
348
|
+
const user = await auth.get(id);
|
|
349
|
+
await auth.update(user, { name }, { ifVersion: user.version });
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
### `USER_INVALID` — `<call>: the fields do not match the <type> schema (<n> issues, at <paths>)`
|
|
353
|
+
|
|
354
|
+
`UserInvalidError`, carrying `issues` — each a `path` and a `message`.
|
|
355
|
+
|
|
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.
|
|
358
|
+
**Fix:** answer 400, field by field:
|
|
359
|
+
|
|
360
|
+
```ts
|
|
361
|
+
if (error instanceof UserInvalidError) {
|
|
362
|
+
return Response.json({ issues: error.issues }, { status: 400 });
|
|
363
|
+
}
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
### `PASSWORD_TOO_SHORT` — `<call>: the password is shorter than the policy's <n> characters`
|
|
367
|
+
|
|
368
|
+
**When:** `signUp`, `setPassword`, `changePassword`, `resetPassword.confirm`.
|
|
369
|
+
**Why:** the type's `password.minLength` (8 by default). `resetPassword.confirm` checks this **before** spending the token, so the visitor can retry with the same link.
|
|
370
|
+
**Fix:** answer 400 with `error.minLength`, or change the policy: `password: { login: 'email', minLength: 12 }`.
|
|
371
|
+
|
|
372
|
+
### `CREDENTIALS_INVALID` — `<call>: the login and the password do not match`
|
|
373
|
+
|
|
374
|
+
Also `changePassword: the current password does not match`.
|
|
375
|
+
|
|
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.
|
|
378
|
+
**Fix:** answer 401 with the same body whatever the reason:
|
|
379
|
+
|
|
380
|
+
```ts
|
|
381
|
+
// Never: { reason: error.reason } — `unknownLogin` tells an attacker which accounts exist.
|
|
382
|
+
return new Response('Wrong e-mail or password', { status: 401 });
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
### `HASH_UNSUPPORTED` — `<call>: no wired verifier claims the prefix "<prefix>"`
|
|
386
|
+
|
|
387
|
+
**When:** `signIn` or `changePassword`, for a user whose stored hash was written by another system — typically after an import.
|
|
388
|
+
**Why:** the hash starts with a prefix (`$2b$`, `$argon2id$`, …) that neither `hasher` nor any of `verifiers` claims.
|
|
389
|
+
**Fix:** wire a verifier for that format; the next successful sign-in rewrites the hash with `hasher`:
|
|
390
|
+
|
|
391
|
+
```ts
|
|
392
|
+
janus({ ..., hasher: scryptHasher(), verifiers: [bcryptVerifier] }); // a PasswordHasher whose prefix is '$2b$'
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
### `scryptHasher: the stored hash has the $scrypt$ prefix and not its format`
|
|
396
|
+
|
|
397
|
+
**When:** `signIn` or `changePassword`, a `TypeError`.
|
|
398
|
+
**Why:** the stored hash starts with `$scrypt$` but is not `$scrypt$ln=…,r=…,p=…$<salt>$<key>`: it was truncated or written by another scrypt implementation.
|
|
399
|
+
**Fix:** repair the record, or `setPassword` for that user. It is not treated as a wrong password on purpose.
|
|
400
|
+
|
|
401
|
+
### `USER_INACTIVE` — `<call>: the user is inactive`
|
|
402
|
+
|
|
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 account is inactive.
|
|
405
|
+
**Fix:** answer 403, or reactivate: `await auth.setActive(user, true)`.
|
|
406
|
+
|
|
407
|
+
### `TOKEN_UNKNOWN`, `TOKEN_SPENT`, `TOKEN_EXPIRED`
|
|
408
|
+
|
|
409
|
+
`TokenError`: `<call>: no such token`, `<call>: the token was already used`, `<call>: the token has expired`.
|
|
410
|
+
|
|
411
|
+
**When:** `verifyEmail.confirm`, `resetPassword.confirm`.
|
|
412
|
+
**Why:** a token is spent by its first use, and an expired one is spent too. `TOKEN_UNKNOWN` also covers a token whose user was deleted. The defaults are 24 h for `verifyEmail`, 1 h for `resetPassword`.
|
|
413
|
+
**Fix:** answer 400 and offer to send a new link. To change the lifetimes:
|
|
414
|
+
|
|
415
|
+
```ts
|
|
416
|
+
janus({ ..., tokens: { verifyEmail: '72h', resetPassword: '2h' } });
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
### `TOKEN_STALE` — `<call>: the token was sent to an e-mail the user no longer has`
|
|
420
|
+
|
|
421
|
+
**When:** `verifyEmail.confirm` or `resetPassword.confirm`, after the user changed their e-mail.
|
|
422
|
+
**Why:** confirming it would verify an address nobody holds any more. The token is spent.
|
|
423
|
+
**Fix:** send a new token to the current address: `await auth.verifyEmail.send(user)`.
|
|
424
|
+
|
|
425
|
+
### `INVALID_CURSOR` — `<call>: this cursor was not minted by this store, or was minted for another ordering (<n> characters)`
|
|
426
|
+
|
|
427
|
+
**When:** `list({ after })`.
|
|
428
|
+
**Why:** `after` is not a `nextCursor` this store answered — edited, truncated, or from another list. It is never read as "first page", which would make a paging loop run forever.
|
|
429
|
+
**Fix:** pass `nextCursor` back as it came; `null` means the last page:
|
|
430
|
+
|
|
431
|
+
```ts
|
|
432
|
+
let after: string | null = null;
|
|
433
|
+
do {
|
|
434
|
+
const page = await auth.list({ after, limit: 100 });
|
|
435
|
+
after = page.nextCursor;
|
|
436
|
+
} while (after);
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
### `<call>: limit must be an integer of at least 1, or absent`
|
|
440
|
+
|
|
441
|
+
**When:** `list({ limit })` of users, or `access.list(..., { limit })`, a `TypeError`.
|
|
442
|
+
**Why:** `limit` is a positive integer. Above 100 it is capped at 100, not refused.
|
|
443
|
+
**Fix:** leave it out (20), or pass `1`–`100`.
|
|
444
|
+
|
|
445
|
+
### `UNSUPPORTED` — `collectExpired: store.sessions does not implement deleteExpiredSessions — its store expires sessions on its own, or implement the method`
|
|
446
|
+
|
|
447
|
+
**When:** `auth.collectExpired()`.
|
|
448
|
+
**Why:** the sessions store does not implement the optional `deleteExpiredSessions` — usually because it expires sessions itself (a MongoDB TTL index, a Redis `EXPIRE`).
|
|
449
|
+
**Fix:** do not schedule `collectExpired` for that store. Expiry does not depend on it: the core compares `expiresAt` on every `authenticate`.
|
|
450
|
+
|
|
451
|
+
### `<call>: the <type> type does not sign in with a password — add password: { login } to it`
|
|
452
|
+
|
|
453
|
+
**When:** `signIn`, `findByLogin`, `setPassword`, `changePassword`, `resetPassword.*`, from JavaScript. In TypeScript, these methods are absent from a type without `password`.
|
|
454
|
+
**Why:** the user type has no `password` option.
|
|
455
|
+
**Fix:** `users: { staff: { schema: Staff, password: { login: 'email' } } }`.
|
|
456
|
+
|
|
457
|
+
### `authenticate()` answers `null` although a valid cookie was sent
|
|
458
|
+
|
|
459
|
+
**When:** `auth.authenticate(request)` on a request that also carries an `Authorization: Bearer` or an `X-Session-Token` header.
|
|
460
|
+
**Why:** the **first credential present** wins, not the first valid one: `Authorization: Bearer`, then `X-Session-Token`, then the cookie. A lapsed bearer beside a live cookie is anonymous.
|
|
461
|
+
**Fix:** stop the client sending the stale header. A `null` is never an outage: when the store cannot answer, `authenticate` rejects with `STORE_FAILED` — answer 503, not 401.
|
|
462
|
+
|
|
463
|
+
---
|
|
464
|
+
|
|
465
|
+
## Permissions
|
|
466
|
+
|
|
467
|
+
### `PERMISSION_DEPTH` — `can: checking <type>#<permission> crossed more than <n> relations without an answer`
|
|
468
|
+
|
|
469
|
+
Also `list: listing <type>#<permission> crossed more than <n> relations without an answer`. `PermissionDepthError`, carrying `permission` and `maxDepth`.
|
|
470
|
+
|
|
471
|
+
**When:** `access.can(...)` or `access.list(...)`.
|
|
472
|
+
**Why:** the walk crossed more than `maxDepth` relations (25 by default) and found no grant. 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
|
+
**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
|
+
|
|
475
|
+
```ts
|
|
476
|
+
const access = permissions({ model, store, maxDepth: 50 });
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
### `permissions: this model was not made by defineModel() — pass what defineModel() answered`
|
|
480
|
+
|
|
481
|
+
**When:** `permissions({ model, store })`.
|
|
482
|
+
**Why:** `model` is the plain configuration object, or a copy of the model (spread, `structuredClone`, JSON round trip).
|
|
483
|
+
**Fix:**
|
|
484
|
+
|
|
485
|
+
```ts
|
|
486
|
+
import { defineModel, permissions } from '@nxgt/janus/permissions';
|
|
487
|
+
|
|
488
|
+
const model = defineModel({ subjects: auth.types, types: { ... } });
|
|
489
|
+
const access = permissions({ model, store });
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
### `permissions: store.<method> is missing`
|
|
493
|
+
|
|
494
|
+
Also `permissions: maxDepth must be a positive integer`.
|
|
495
|
+
|
|
496
|
+
**When:** `permissions({ model, store })`.
|
|
497
|
+
**Why:** `store` must be a relation store — `write`, `has`, `findSubjectSets`, `findEntities`, `findObjects`, `deleteEntity` — not the `{ users, sessions, tokens }` given to `janus()`.
|
|
498
|
+
**Fix:** `permissions({ model, store: createMemoryRelations() })`, or your adapter's relation store.
|
|
499
|
+
|
|
500
|
+
### `can: <type>.<relation> reads <field>, which the object does not carry — pass the loaded object, spread`
|
|
501
|
+
|
|
502
|
+
Also `can: <type>.<relation> reads <field>, which the object holds as something other than a string id`.
|
|
503
|
+
|
|
504
|
+
**When:** `access.can(subject, permission, object)` on a type with a `fromField` relation.
|
|
505
|
+
**Why:** `fromField('doctorId', 'staff')` reads `doctorId` from the object you pass. Missing, it is an error, never a denial. `null` in the field holds nobody.
|
|
506
|
+
**Fix:** pass the loaded object, spread:
|
|
507
|
+
|
|
508
|
+
```ts
|
|
509
|
+
await access.can(staff, 'view', { type: 'record', ...record });
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
### `can: <type>.<name> reaches a condition, and no ctx was passed — pass { ctx }`
|
|
513
|
+
|
|
514
|
+
Also `list: <type>.<name> reaches a condition, and no ctx was passed — pass { ctx }`.
|
|
515
|
+
|
|
516
|
+
**When:** `can` or `list`, when a rule reached by the check is a `when(...)`.
|
|
517
|
+
**Why:** a condition runs on the `ctx` you pass. A missing `ctx` is a caller's bug, not a denial.
|
|
518
|
+
**Fix:**
|
|
519
|
+
|
|
520
|
+
```ts
|
|
521
|
+
await access.can(staff, 'edit', { type: 'record', ...record }, { ctx: { onShift } });
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
### `list: <type>.<relation> is read from a field, and has no lookup to find the <type>s naming <id> — …`
|
|
525
|
+
|
|
526
|
+
**When:** `access.list(subject, permission, type)` through a `fromField` relation.
|
|
527
|
+
**Why:** `list()` walks backwards from the subject, so it cannot read a field of objects it has not found yet. It asks your `lookup` for the ids of the objects whose field names the subject.
|
|
528
|
+
**Fix:**
|
|
529
|
+
|
|
530
|
+
```ts
|
|
531
|
+
fromField('doctorId', 'staff', { lookup: (id) => db.records.idsWhere({ doctorId: id }) });
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
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.
|
|
535
|
+
|
|
536
|
+
### `list: the lookup of <type>.<relation> must answer an array of ids`
|
|
537
|
+
|
|
538
|
+
**When:** `access.list(...)`.
|
|
539
|
+
**Why:** the `lookup` answered something other than `string[]` — documents, `ObjectId`s, or `undefined`.
|
|
540
|
+
**Fix:** `lookup: async (id) => (await findRecords({ doctorId: id })).map((r) => r.id)`.
|
|
541
|
+
|
|
542
|
+
### `grant: <type>.<relation> is read from <field>; there is nothing to store — change the <type> instead`
|
|
543
|
+
|
|
544
|
+
Also with `revoke:`.
|
|
545
|
+
|
|
546
|
+
**When:** `access.grant(...)` or `access.revoke(...)` on a `fromField` relation.
|
|
547
|
+
**Why:** that relation lives in the object's own data, not in a tuple.
|
|
548
|
+
**Fix:** update the object — for example set `record.doctorId` — in your own database.
|
|
549
|
+
|
|
550
|
+
### `grant: <type>.<relation> is not held by <holder>`
|
|
551
|
+
|
|
552
|
+
Also with `revoke:`.
|
|
553
|
+
|
|
554
|
+
**When:** `access.grant(object, relation, subject)`.
|
|
555
|
+
**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.
|
|
556
|
+
**Fix:** grant a subject the relation admits, or add the holder to the model: `member: ['staff', 'team#member']`.
|
|
557
|
+
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] })`.
|
|
558
|
+
|
|
559
|
+
### Other `can:`, `list:`, `grant:` and `revoke:` messages
|
|
560
|
+
|
|
561
|
+
Each is a `TypeError` naming the call. TypeScript refuses most of them on the argument first.
|
|
562
|
+
|
|
563
|
+
| Message | Fix |
|
|
564
|
+
| --- | --- |
|
|
565
|
+
| `can: "<name>" is not a relation or a permission of <type>` | Ask a name the type declares. |
|
|
566
|
+
| `<call>: "<type>" is not an object type of the model` | Use a type declared under `types`. |
|
|
567
|
+
| `<call>: the object must be { type, id, …its fields }` | `{ type: 'record', ...record }`. |
|
|
568
|
+
| `<call>: the subject must be a user, or { type, id }` | Pass the user from `janus()`, or `{ type, id }`. `null` is anonymous and answers `false`. |
|
|
569
|
+
| `<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. |
|
|
570
|
+
| `<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' }`. |
|
|
571
|
+
| `grant: "<relation>" is not a relation of <type>` | Grant a relation, never a permission. |
|
|
572
|
+
| `list: the type must be an object type of the model` | The third argument is a type name: `'record'`. |
|
|
573
|
+
| `list: after must be the nextCursor of a page, or null` | Pass `nextCursor` back as it came. |
|
|
574
|
+
|
|
575
|
+
### `defineModel: …`
|
|
576
|
+
|
|
577
|
+
`defineModel` refuses with a `TypeError` what only running it can see. The
|
|
578
|
+
most common:
|
|
579
|
+
|
|
580
|
+
| Message | Fix |
|
|
581
|
+
| --- | --- |
|
|
582
|
+
| `defineModel: subjects must be an array of user types — pass auth.types from janus()` | `defineModel({ subjects: auth.types, types })`. |
|
|
583
|
+
| `defineModel: types declares no object type` | Declare at least one type under `types`. |
|
|
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
|
+
| `defineModel: "<name>" names a user type and an object type; a subject of type "<name>" would be ambiguous` | Rename the object type. |
|
|
586
|
+
| `defineModel: types.<type>: "<name>" names a relation and a permission; rename one` | One name, one meaning. |
|
|
587
|
+
| `defineModel: types.<type>.permissions: <a> → <b> → <a> is a loop no relation ends` | A permission must cross a relation before it reaches itself again. |
|
|
588
|
+
| `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']`. |
|
|
589
|
+
| `defineModel: … "<rule>" names "<target>", which <type> does not declare` | Arrow to a relation or permission of the target type. |
|
|
590
|
+
| `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. |
|
|
591
|
+
|
|
592
|
+
---
|
|
593
|
+
|
|
594
|
+
## Subjects
|
|
595
|
+
|
|
596
|
+
### `parseTuple: "<text>" is not a relation tuple; expected type:id#relation@subject`
|
|
597
|
+
|
|
598
|
+
Also `parseSubject: "<text>" is not a subject; expected type:id, or type:id#relation for a subject set`.
|
|
599
|
+
|
|
600
|
+
**When:** `parseTuple(...)` or `parseSubject(...)`, a `TypeError`.
|
|
601
|
+
**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 `:`.
|
|
602
|
+
**Fix:**
|
|
603
|
+
|
|
604
|
+
```ts
|
|
605
|
+
import { parseTuple } from '@nxgt/janus';
|
|
606
|
+
|
|
607
|
+
parseTuple('record:r1#viewer@staff:u1');
|
|
608
|
+
parseTuple('record:r1#viewer@team:t1#member');
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
---
|
|
612
|
+
|
|
613
|
+
## Conformance (adapter authors)
|
|
614
|
+
|
|
615
|
+
### `describeJanusStores: no test runner on globalThis — pass runner: { describe, it } (under bun test: import them from 'bun:test')`
|
|
616
|
+
|
|
617
|
+
The same for `describeRelationStores`.
|
|
618
|
+
|
|
619
|
+
**When:** loading the spec file.
|
|
620
|
+
**Why:** `bun test` gives a file `describe` and `it` as bare identifiers, not as properties of `globalThis`. jest, and vitest with `globals: true`, are found without it.
|
|
621
|
+
**Fix:**
|
|
622
|
+
|
|
623
|
+
```ts
|
|
624
|
+
import { describe, it } from 'bun:test';
|
|
625
|
+
import { describeJanusStores } from '@nxgt/janus/conformance';
|
|
626
|
+
|
|
627
|
+
describeJanusStores({ name: 'my adapter', runner: { describe, it }, harness });
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
### `JANUS_CONFORMANCE_SKIPPED` — `faults not provided: the outage invariant is not proven for this adapter`
|
|
631
|
+
|
|
632
|
+
A process warning, and the outage cases reported as skipped.
|
|
633
|
+
|
|
634
|
+
**When:** running the suite with a harness that opens stores without `faults`.
|
|
635
|
+
**Why:** the outage cases need a way to make the database fail. Their absence is reported, never passed over.
|
|
636
|
+
**Fix:** return `faults` from `open()`, failing **only the method named**, the way the database really fails — for MongoDB, the `failCommand` failpoint. A wrapper that throws in front of the adapter proves the wrapper, not the adapter.
|
|
637
|
+
|
|
638
|
+
### `the error is named <Class> but is not @nxgt/janus's <Class>: two copies of @nxgt/janus are installed. The adapter must list it as a peer dependency, never a dependency`
|
|
639
|
+
|
|
640
|
+
**When:** a conformance case that checks the class of an error — the outage and conflict cases.
|
|
641
|
+
**Why:** the adapter imports its own copy of `@nxgt/janus`, so its `StoreFailure` or `StoreConflict` is not the core's, and `instanceof` fails.
|
|
642
|
+
**Fix:** move `@nxgt/janus` to `peerDependencies` (and `devDependencies` for the tests), then reinstall.
|
|
643
|
+
|
|
644
|
+
### `expected null, got undefined — an absence is null; undefined is a store that forgot to answer`
|
|
645
|
+
|
|
646
|
+
**When:** a conformance case on a `find*` method.
|
|
647
|
+
**Why:** the adapter answers `undefined` for "not found". Many drivers do; the port does not.
|
|
648
|
+
**Fix:** `return document ?? null;`.
|