@nxgt/janus-graphql 0.1.1 → 0.2.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.
@@ -0,0 +1,182 @@
1
+ # A step-up over GraphQL
2
+
3
+ This page is for a mutation a stolen session should not run alone: changing
4
+ the e-mail, a payment method, deleting the account. `@fresh(maxAge)` lets it
5
+ run only for a session that proved who it is less than `maxAge` seconds ago;
6
+ older, it answers `STEP_UP_REQUIRED`, and the client proves it again with a
7
+ step-up. `@nxgt/janus` issues and checks the code, and stamps the session.
8
+ This page shows the whole flow as GraphQL. For the directive alone, see
9
+ [directives](directives.md#freshmaxage-int); for the step-up itself, every
10
+ error and the codes an app confirms, see
11
+ [`@nxgt/janus`'s step-up guide](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/step-up.md).
12
+
13
+ ## The schema
14
+
15
+ ```graphql
16
+ type Mutation {
17
+ changeEmail(email: String!): User @fresh(maxAge: 600) # ten minutes
18
+ deleteAccount: Boolean! @fresh(maxAge: 300) # five
19
+
20
+ requestStepUp: StepUpChallenge! @authenticated
21
+ confirmStepUp(challenge: String!, code: String!): Boolean! @authenticated
22
+ }
23
+
24
+ type StepUpChallenge {
25
+ challenge: String!
26
+ via: String! # "email", or "secondFactor": the code is in the user's app
27
+ }
28
+ ```
29
+
30
+ `@fresh` implies a signed-in user: an anonymous request is `UNAUTHENTICATED`,
31
+ 401, before freshness is read. The two step-up mutations need a signed-in
32
+ user and no fresh one: they are how a session becomes fresh.
33
+
34
+ ## The resolvers
35
+
36
+ ```ts
37
+ import { requireUser, type JanusContext } from '@nxgt/janus-graphql';
38
+ import type { YogaInitialContext } from 'graphql-yoga';
39
+
40
+ type Context = YogaInitialContext & JanusContext<typeof auth>;
41
+
42
+ const resolvers = {
43
+ Mutation: {
44
+ changeEmail: (_: unknown, { email }: { email: string }, ctx: Context) =>
45
+ users.changeEmail(ctx, email), // runs only on a fresh session
46
+
47
+ requestStepUp: async (_: unknown, __: unknown, ctx: Context) => {
48
+ const user = await requireUser(ctx); // the session's user — never an id the client sent
49
+ const issued = await auth.stepUp.request(user);
50
+ if (issued.via === 'email') {
51
+ await sendMail(issued.email, 'Confirm it is you', `${issued.code} confirms the action.`);
52
+ }
53
+ return { challenge: issued.challenge, via: issued.via }; // never the code
54
+ },
55
+
56
+ confirmStepUp: async (
57
+ _: unknown,
58
+ { challenge, code }: { challenge: string; code: string },
59
+ ctx: Context,
60
+ ) => {
61
+ await auth.stepUp.confirm(ctx.request, challenge, code); // stamps this request's session
62
+ return true;
63
+ },
64
+ },
65
+ };
66
+ ```
67
+
68
+ `stepUp.confirm` takes the **request**, not a user: it stamps the session
69
+ that request presents, and only a standing session of the challenge's user.
70
+ `ctx.request` is the `Request` Yoga puts on the context, and carries the same
71
+ token or cookie as the mutation that was refused. With several user types,
72
+ `stepUp` is on each type that has an e-mail — `auth.patient.stepUp` — and
73
+ `requireUser(ctx, { type: 'patient' })` narrows the user to it.
74
+
75
+ **Wire `janusMaskError()`.** A wrong code is a `JanusError` the resolver lets
76
+ through — `CODE_INVALID`, 401, with `attemptsLeft` — and without the mask
77
+ Yoga answers it as a 500.
78
+
79
+ ## What the client does
80
+
81
+ 1. It sends `changeEmail`. The session is older than ten minutes:
82
+
83
+ ```json
84
+ { "data": { "changeEmail": null },
85
+ "errors": [{ "message": "Forbidden", "path": ["changeEmail"], "extensions": { "code": "STEP_UP_REQUIRED" } }] }
86
+ ```
87
+
88
+ The response is a 403. The client reads `extensions.code`, never the
89
+ message, which is the fixed one of the status.
90
+
91
+ 2. It sends `requestStepUp`, and keeps `challenge`. The user reads the code
92
+ in the e-mail — or in their app, when `via` is `"secondFactor"`.
93
+
94
+ 3. It sends `confirmStepUp(challenge, code)`. From now on, the session is
95
+ fresh for `maxAge` seconds.
96
+
97
+ 4. It sends `changeEmail` again, which runs.
98
+
99
+ | `confirmStepUp` answers | Status | The client |
100
+ | --- | --- | --- |
101
+ | `true` | 200 | sends the refused mutation again |
102
+ | `CODE_INVALID`, with `attemptsLeft` | 401 | asks for the code again; at `0`, starts over at `requestStepUp` |
103
+ | `TOKEN_SPENT`, `TOKEN_EXPIRED`, `TOKEN_UNKNOWN`, `TOKEN_STALE` | 400 | starts over at `requestStepUp` |
104
+ | `SECOND_FACTOR_ACTIVE` | 409 | starts over: the user's app confirms now |
105
+ | `SECOND_FACTOR_NOT_ENROLLED` | 409 | starts over: the factor was disabled, an e-mailed code confirms now |
106
+ | `USER_INACTIVE` | 403 | stops: the account was deactivated |
107
+ | `SERVICE_UNAVAILABLE` | 503 | retries later |
108
+
109
+ `requestStepUp` itself answers `USER_INACTIVE`, 403, for a deactivated
110
+ user, and `NOT_FOUND`, 404, for a user with no e-mail to send a code to
111
+ — an optional e-mail left empty.
112
+
113
+ **Rate-limit `requestStepUp` per user.** A new challenge takes only a new
114
+ request, and each one sends an e-mail: a few an hour is plenty for a person.
115
+
116
+ ## In a resolver: `requireFresh(ctx, maxAge)`
117
+
118
+ Where the rule depends on the arguments, ask in the resolver:
119
+
120
+ ```ts
121
+ import { requireFresh } from '@nxgt/janus-graphql';
122
+
123
+ const resolvers = {
124
+ Mutation: {
125
+ transfer: async (_: unknown, { amount }: { amount: number }, ctx: Context) => {
126
+ if (amount > 1_000) await requireFresh(ctx, '5m'); // STEP_UP_REQUIRED past five minutes
127
+ return payments.transfer(ctx, amount);
128
+ },
129
+ },
130
+ };
131
+ ```
132
+
133
+ It answers the session, and refuses as `@fresh` does. `maxAge` is a
134
+ duration with its unit — `'5m'`, `'300s'` — never a bare number: the
135
+ compiler refuses one, and so does the call at run time, because
136
+ `@nxgt/janus` reads a bare number as milliseconds where `@fresh` reads
137
+ seconds.
138
+
139
+ ## Subscriptions
140
+
141
+ **A `@fresh` on the subscription field is checked when it subscribes.** A
142
+ stale session is refused before the stream starts; a fresh one keeps
143
+ receiving events past `maxAge`, as it keeps its session. `@authenticated`
144
+ and `@permission` on the field are asked again on every event; its
145
+ `@fresh` is not. To end a stream when the session is no longer fresh, close
146
+ it from the server.
147
+
148
+ **A `@fresh` on the payload's type, or on one of its fields, is checked on
149
+ every event**, as any field is: those fields resolve anew for each event,
150
+ and once the session is past `maxAge` they answer `STEP_UP_REQUIRED` while
151
+ the stream goes on.
152
+
153
+ ```graphql
154
+ type Subscription {
155
+ receipts: Receipt @fresh(maxAge: 600) # checked once, when it subscribes
156
+ }
157
+ type Receipt @fresh(maxAge: 600) { # checked on every event that carries one
158
+ amount: Int
159
+ }
160
+ ```
161
+
162
+ ## Testing it
163
+
164
+ `useJanus({ clock })` is the clock `@fresh` and `requireFresh()` read. Give
165
+ it the one given to `janus()`, and a spec moves both at once:
166
+
167
+ ```ts
168
+ import { fixedClock, janus } from '@nxgt/janus';
169
+
170
+ const clock = fixedClock(Date.UTC(2026, 0, 1));
171
+ const auth = janus({ /* … */ clock });
172
+ const yoga = createYoga({ schema, plugins: [useJanus({ auth, clock })] });
173
+
174
+ const { token } = await auth.signUp({ email, password }); // signed in now
175
+ clock.advance(10 * 60_000); // ten minutes later
176
+ // changeEmail now answers STEP_UP_REQUIRED
177
+ ```
178
+
179
+ Without `clock`, both read the system's. A `janus()` on a fixed clock and a
180
+ `useJanus()` without one disagree on the time: a session signed in on
181
+ the fixed clock looks days old, and every `@fresh` field answers
182
+ `STEP_UP_REQUIRED`.
package/docs/roadmap.md CHANGED
@@ -12,11 +12,6 @@ Nothing between releases.
12
12
  - **Subscriptions over graphql-ws** — `janusConnection`, which authenticates
13
13
  a WebSocket connection once, from its `connectionParams` or its upgrade
14
14
  request, and gives every operation on it the same `ctx.janus`.
15
- - **`@fresh(maxAge)`** — a field only a session that proved who it is less
16
- than `maxAge` ago may resolve — signed in, or confirmed since by
17
- `auth.stepUp.confirm`: a password change, a new e-mail, a payment method,
18
- asked again of a session that is days old. `@nxgt/janus` 0.12's
19
- `assertFresh` is the check, and `STEP_UP_REQUIRED` the error it answers.
20
15
  - **The renewed session cookie on GraphQL responses** — `authenticate`
21
16
  renews a sliding session in passing; the response would then carry its
22
17
  `Set-Cookie`, so a browser keeps the new expiry without an HTTP route of
@@ -46,6 +41,17 @@ Nothing yet.
46
41
 
47
42
  Newest first; from the first release on, the package's CHANGELOG holds every one.
48
43
 
44
+ - **`@fresh(maxAge)` and `requireFresh()`, v0.2.0.** A field, a type or an
45
+ interface only a session that proved who it is less than `maxAge` seconds
46
+ ago may resolve — signed in, or confirmed since by `auth.stepUp.confirm`:
47
+ a new e-mail or a payment method, asked again of a session that is days
48
+ old. An older session is `STEP_UP_REQUIRED`, 403, which tells the client
49
+ to ask for a step-up; checked after `@authenticated` and before any
50
+ `@permission`, and for a subscription when it subscribes.
51
+ `requireFresh(ctx, '10m')` is the same check in a resolver, and
52
+ `useJanus({ clock })` the clock both read. `@nxgt/janus` 0.12's
53
+ `assertFresh` is the check.
54
+
49
55
  - **The first release, v0.1.0.** GraphQL for `@nxgt/janus`, as an envelop
50
56
  plugin: `useJanus()` with a lazy `ctx.janus`, authenticated only when a
51
57
  field asks; `@authenticated` on fields, types and interfaces, for any user
@@ -15,6 +15,7 @@ for what causes each.
15
15
  - [`TypeError: applyJanusDirectives(): @authenticated on … names the user type '…', which is not one of …`](#typeerror-applyjanusdirectives-authenticated-on--names-the-user-type--which-is-not-one-of-)
16
16
  - [`TypeError: applyJanusDirectives(): @authenticated on … names no user type`](#typeerror-applyjanusdirectives-authenticated-on--names-no-user-type)
17
17
  - [`TypeError: applyJanusDirectives(): the @authenticated on … admit no user type in common`](#typeerror-applyjanusdirectives-the-authenticated-on--admit-no-user-type-in-common)
18
+ - [`TypeError: applyJanusDirectives(): @fresh on … asks maxAge: …`](#typeerror-applyjanusdirectives-fresh-on--asks-maxage-)
18
19
  - [`TypeError: applyJanusDirectives(): @permission on … needs the permissions() instance`](#typeerror-applyjanusdirectives-permission-on--needs-the-permissions-instance)
19
20
  - [`TypeError: applyJanusDirectives(): @permission on … names the object type '…', which is not one of …`](#typeerror-applyjanusdirectives-permission-on--names-the-object-type--which-is-not-one-of-)
20
21
  - [`TypeError: applyJanusDirectives(): @permission on … asks '…', which … does not declare`](#typeerror-applyjanusdirectives-permission-on--asks--which--does-not-declare)
@@ -23,20 +24,27 @@ for what causes each.
23
24
  - [`TypeError: applyJanusDirectives(): @permission on … reads the …'s id from …, and … reads '…' of the object itself (fromField)`](#typeerror-applyjanusdirectives-permission-on--reads-the-s-id-from--and--reads--of-the-object-itself-fromfield)
24
25
  - [`TypeError: applyJanusDirectives(): @permission on … asks '…' of …, which reaches a when()`](#typeerror-applyjanusdirectives-permission-on--asks--of--which-reaches-a-when)
25
26
  - [`TypeError: applyJanusDirectives(): @permission on … finds loaders.…, which is not a function`](#typeerror-applyjanusdirectives-permission-on--finds-loaders-which-is-not-a-function)
26
- - [`Unknown directive "@authenticated"`, or `"@permission"`](#unknown-directive-authenticated-or-permission)
27
+ - [`Unknown directive "@authenticated"`, `"@fresh"` or `"@permission"`](#unknown-directive-authenticated-fresh-or-permission)
28
+ - [`Argument "@fresh(maxAge:)" of type "Int!" is required`, or `Argument "maxAge" has invalid value …`](#argument-freshmaxage-of-type-int-is-required-or-argument-maxage-has-invalid-value-)
27
29
  - [`TypeError: applyJanusDirectives(): type '…' is not a user type of auth`](#typeerror-applyjanusdirectives-type--is-not-a-user-type-of-auth)
28
30
  - [`TypeError: useJanus(): auth is not what janus() answered`](#typeerror-usejanus-auth-is-not-what-janus-answered)
29
31
  - [`TypeError: useJanus(): access is not what permissions() answered`](#typeerror-usejanus-access-is-not-what-permissions-answered)
32
+ - [`TypeError: useJanus(): clock is not a Clock`](#typeerror-usejanus-clock-is-not-a-clock)
30
33
 
31
34
  **In a response**
32
35
  - [`UNAUTHENTICATED` for a signed-in user](#unauthenticated-for-a-signed-in-user)
33
36
  - [`FORBIDDEN` where the user should be allowed](#forbidden-where-the-user-should-be-allowed)
37
+ - [`STEP_UP_REQUIRED`, 403](#step_up_required-403)
38
+ - [`STEP_UP_REQUIRED` for a user who just signed in](#step_up_required-for-a-user-who-just-signed-in)
34
39
  - [`SERVICE_UNAVAILABLE`, 503, on every guarded field](#service_unavailable-503-on-every-guarded-field)
35
40
  - [`Unexpected error.`, 500, where a `JanusError` was thrown](#unexpected-error-500-where-a-januserror-was-thrown)
36
41
  - [`TypeError: … ctx.janus is not set`](#typeerror--ctxjanus-is-not-set)
37
42
  - [`TypeError: useJanus(): the GraphQL context has no request to authenticate`](#typeerror-usejanus-the-graphql-context-has-no-request-to-authenticate)
38
43
  - [`TypeError: can(): ctx.janus.access is not set`](#typeerror-can-ctxjanusaccess-is-not-set)
39
44
  - [`TypeError: requireUser(): type is an empty list, which no user could pass`](#typeerror-requireuser-type-is-an-empty-list-which-no-user-could-pass)
45
+ - [`TypeError: requireFresh(): maxAge is a duration with its unit`](#typeerror-requirefresh-maxage-is-a-duration-with-its-unit)
46
+ - [`TypeError: requireFresh(): maxAge: "…" is not a duration`](#typeerror-requirefresh-maxage--is-not-a-duration)
47
+ - [`TypeError: requireFresh(): maxAge: a duration must be above zero`](#typeerror-requirefresh-maxage-a-duration-must-be-above-zero)
40
48
  - [`@permission on … resolved no object id from … — answered NOT_FOUND`](#permission-on--resolved-no-object-id-from---answered-not_found)
41
49
  - [`NOT_FOUND` where the user should be allowed](#not_found-where-the-user-should-be-allowed)
42
50
  - [A 403 that tells a user the object exists](#a-403-that-tells-a-user-the-object-exists)
@@ -52,6 +60,9 @@ for what causes each.
52
60
  - [`'…' does not exist in type 'Loaders<…>'`, or `'Conditions<…>'`](#-does-not-exist-in-type-loaders-or-conditions)
53
61
  - [`Type '{ …: … }' is not assignable to type 'never'`, on `loaders`](#type-----is-not-assignable-to-type-never-on-loaders)
54
62
  - [`Source has 0 element(s) but target requires 1`, on `requireUser()`](#source-has-0-elements-but-target-requires-1-on-requireuser)
63
+ - [`Argument of type '600' is not assignable to parameter of type '`${number}ms` | …'`, on `requireFresh()`](#argument-of-type-600-is-not-assignable-to-parameter-of-type-numberms---on-requirefresh)
64
+ - [`Expected 2 arguments, but got 1`, on `requireFresh()`](#expected-2-arguments-but-got-1-on-requirefresh)
65
+ - [`Type 'number' is not assignable to type 'Clock'`](#type-number-is-not-assignable-to-type-clock)
55
66
 
56
67
  ## At start-up
57
68
 
@@ -95,6 +106,24 @@ type Ward @authenticated(type: ["staff"]) {
95
106
  **Fix:** widen one of them, or move the field to a type whose directive
96
107
  admits the users it is for.
97
108
 
109
+ ### `TypeError: applyJanusDirectives(): @fresh on … asks maxAge: …`
110
+
111
+ **When:** at start-up, for a schema that uses `@fresh`.
112
+
113
+ **Why:** `maxAge` is not above zero — `@fresh(maxAge: 0)` or a negative
114
+ number — so no session could ever be fresh enough, and every request would
115
+ be refused. The message names the field, and the location the directive was
116
+ written on when it is not the field: `@fresh on Account (read by
117
+ Account.email)`.
118
+
119
+ **Fix:** write a number of **seconds** above zero:
120
+
121
+ ```graphql
122
+ type Mutation {
123
+ changeEmail(email: String!): User @fresh(maxAge: 600) # ten minutes
124
+ }
125
+ ```
126
+
98
127
  ### `TypeError: applyJanusDirectives(): @permission on … needs the permissions() instance`
99
128
 
100
129
  **When:** at start-up, for a schema that uses `@permission`.
@@ -192,7 +221,7 @@ than a function of it, from JavaScript or past a cast.
192
221
 
193
222
  **Fix:** `loaders: { record: (id) => records.find(id) }`.
194
223
 
195
- ### `Unknown directive "@authenticated"`, or `"@permission"`
224
+ ### `Unknown directive "@authenticated"`, `"@fresh"` or `"@permission"`
196
225
 
197
226
  **Why:** graphql-js builds the schema before `useJanus()` sees it, and the
198
227
  directive is not declared.
@@ -202,6 +231,18 @@ directive is not declared.
202
231
  generator reading files takes
203
232
  `node_modules/@nxgt/janus-graphql/graphql/janus.graphqls`.
204
233
 
234
+ ### `Argument "@fresh(maxAge:)" of type "Int!" is required`, or `Argument "maxAge" has invalid value …`
235
+
236
+ graphql 16 words the first `Directive "@fresh" argument "maxAge" of type
237
+ "Int!" is required, but it was not provided.`
238
+
239
+ **Why:** graphql-js refuses the directive at start-up: `@fresh` without
240
+ `maxAge`, when the schema is built; or with a value that is not an `Int` —
241
+ a fraction, a string such as `"10m"` — when `useJanus()` reads it.
242
+
243
+ **Fix:** a whole number of seconds: `@fresh(maxAge: 600)`. A duration with a
244
+ unit belongs to `requireFresh(ctx, '10m')`, in a resolver.
245
+
205
246
  ### `TypeError: applyJanusDirectives(): type '…' is not a user type of auth`
206
247
 
207
248
  **When:** at start-up, with `useJanus({ auth, type })` or
@@ -228,6 +269,15 @@ its place, or the module that exports `auth` not loaded yet.
228
269
  **Fix:** `useJanus({ auth, access })`, with `access` what `permissions()`
229
270
  answered — or leave `access` out when no field uses `@permission` or `can()`.
230
271
 
272
+ ### `TypeError: useJanus(): clock is not a Clock`
273
+
274
+ **Why:** `clock` has no `now()` — a timestamp such as `Date.now()`, or a
275
+ `Date`, passed from JavaScript or cast past the compiler.
276
+
277
+ **Fix:** pass the clock given to `janus()` — `fixedClock(…)` from
278
+ `@nxgt/janus` in a spec — or leave `clock` out for the system's:
279
+ `useJanus({ auth, clock })`.
280
+
231
281
  ## In a response
232
282
 
233
283
  ### `UNAUTHENTICATED` for a signed-in user
@@ -251,12 +301,37 @@ says applies to it too.
251
301
  **Fix:** read the directives on the field, on its type, and on every
252
302
  interface the type implements.
253
303
 
304
+ ### `STEP_UP_REQUIRED`, 403
305
+
306
+ **Why:** a field `@fresh(maxAge)` guards, or a `requireFresh(ctx, maxAge)`,
307
+ was reached by a session that proved who it is `maxAge` ago or more — at
308
+ its sign-in, or at its last `auth.stepUp.confirm`. Renewing a sliding
309
+ session does not count. This is the answer working: it tells the client to
310
+ ask for a step-up.
311
+
312
+ **Fix:** in the client, on `extensions.code === 'STEP_UP_REQUIRED'`, run the
313
+ step-up — `requestStepUp`, then `confirmStepUp(challenge, code)` — and send
314
+ the request again. The mutations are in
315
+ [the step-up over GraphQL](guide/step-up.md).
316
+
317
+ ### `STEP_UP_REQUIRED` for a user who just signed in
318
+
319
+ **Why:** `@fresh` reads the time from `useJanus({ clock })`, and the session
320
+ was stamped with `janus()`'s. Two different clocks: `janus({ clock:
321
+ fixedClock(…) })` with `useJanus()` given none, in a spec — the session looks
322
+ days old. Or `maxAge` written as minutes: `@fresh(maxAge: 10)` is ten
323
+ seconds, not ten minutes.
324
+
325
+ **Fix:** give both the same clock — `useJanus({ auth, clock })` — and write
326
+ `maxAge` in seconds: `@fresh(maxAge: 600)` for ten minutes.
327
+
254
328
  ### `SERVICE_UNAVAILABLE`, 503, on every guarded field
255
329
 
256
330
  **Why:** a store could not answer — `STORE_FAILED`. The sessions store for
257
- `@authenticated`, `requireUser()` and `ctx.janus.user()`; the relation store
331
+ `@authenticated`, `@fresh`, `requireUser()`, `requireFresh()` and
332
+ `ctx.janus.user()`; the relation store
258
333
  for `can()` and `@permission` — and a loader of yours that threw
259
- `StoreFailure`. It is never answered `UNAUTHENTICATED` or `FORBIDDEN`.
334
+ `StoreFailure`. It is never answered `UNAUTHENTICATED`, `FORBIDDEN` or `STEP_UP_REQUIRED`.
260
335
 
261
336
  **Fix:** the store: its connection, its credentials, its availability. The
262
337
  message carries nothing of the store's; log the error where you call
@@ -307,6 +382,30 @@ signed-in user:
307
382
  const user = await requireUser(ctx, allowed.length > 0 ? { type: allowed } : {});
308
383
  ```
309
384
 
385
+ ### `TypeError: requireFresh(): maxAge is a duration with its unit`
386
+
387
+ **Why:** `requireFresh(ctx, 600)` — a bare number, from JavaScript or built
388
+ at run time. `@nxgt/janus` would read it as 600 milliseconds, where
389
+ `@fresh(maxAge: 600)` reads ten minutes, so it is refused rather than
390
+ guessed. The compiler refuses a literal number.
391
+
392
+ **Fix:** write the unit: `requireFresh(ctx, '10m')`, or `'600s'`.
393
+
394
+ ### `TypeError: requireFresh(): maxAge: "…" is not a duration`
395
+
396
+ **Why:** the string is not a number followed by `ms`, `s`, `m`, `h` or `d`
397
+ — `'10 minutes'`, `'10 m'`.
398
+
399
+ **Fix:** `requireFresh(ctx, '10m')`.
400
+
401
+ ### `TypeError: requireFresh(): maxAge: a duration must be above zero`
402
+
403
+ **Why:** `requireFresh(ctx, '0m')` — no session is ever fresh within no
404
+ time, so every request would be refused. The compiler lets it through: the
405
+ type reads a number and a unit, not their value.
406
+
407
+ **Fix:** a duration above zero, `requireFresh(ctx, '10m')`.
408
+
310
409
  ### `@permission on … resolved no object id from … — answered NOT_FOUND`
311
410
 
312
411
  **Where:** a warning, through `process.emitWarning` with the code
@@ -469,3 +568,28 @@ throws
469
568
 
470
569
  **Fix:** name at least one user type, `{ type: ['staff'] }`, or leave `type`
471
570
  out to admit any signed-in user.
571
+
572
+ ### `Argument of type '600' is not assignable to parameter of type '`${number}ms` | …'`, on `requireFresh()`
573
+
574
+ The full message ends `` '`${number}ms` | `${number}s` | `${number}m` |
575
+ `${number}h` | `${number}d`' ``; the same for `'10 minutes'`.
576
+
577
+ **Why:** `requireFresh(ctx, maxAge)` takes a duration with its unit. A bare
578
+ number would be milliseconds to `@nxgt/janus` and seconds to `@fresh`, so
579
+ neither is guessed.
580
+
581
+ **Fix:** `requireFresh(ctx, '10m')`.
582
+
583
+ ### `Expected 2 arguments, but got 1`, on `requireFresh()`
584
+
585
+ **Why:** `requireFresh(ctx)` — how recent the proof must be has no default.
586
+
587
+ **Fix:** `requireFresh(ctx, '10m')`.
588
+
589
+ ### `Type 'number' is not assignable to type 'Clock'`
590
+
591
+ **Why:** `useJanus({ auth, clock: Date.now() })` — a timestamp, where a
592
+ clock answers `now()`.
593
+
594
+ **Fix:** pass the clock given to `janus()`, `fixedClock(…)` in a spec, or
595
+ leave `clock` out.
@@ -7,6 +7,14 @@ directive @authenticated(
7
7
  type: [String!]
8
8
  ) on OBJECT | INTERFACE | FIELD_DEFINITION
9
9
 
10
+ """
11
+ Only a session that proved who it is less than `maxAge` seconds ago reaches
12
+ this field, or every field of this type or interface: signed in, or confirmed
13
+ since by a step-up. Anonymous is answered UNAUTHENTICATED, an older session
14
+ STEP_UP_REQUIRED. Where several apply, the smallest `maxAge` holds.
15
+ """
16
+ directive @fresh(maxAge: Int!) on OBJECT | INTERFACE | FIELD_DEFINITION
17
+
10
18
  """
11
19
  What a denied `@permission` answers.
12
20
  """
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nxgt/janus-graphql",
3
- "version": "0.1.1",
4
- "description": "GraphQL for @nxgt/janus: an envelop plugin with the signed-in user on the context, fields guarded by @authenticated and @permission, and every error with the status it deserves",
3
+ "version": "0.2.0",
4
+ "description": "GraphQL for @nxgt/janus: an envelop plugin with the signed-in user on the context, fields guarded by @authenticated, @fresh and @permission, and every error with the status it deserves",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",