@nxgt/janus-hono 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 +205 -0
- package/dist/bind.d.ts +76 -0
- package/dist/bind.d.ts.map +1 -0
- package/dist/errors.d.ts +50 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +181 -0
- package/dist/index.js.map +14 -0
- package/dist/permission.d.ts +80 -0
- package/dist/permission.d.ts.map +1 -0
- package/dist/provide.d.ts +19 -0
- package/dist/provide.d.ts.map +1 -0
- package/dist/session.d.ts +91 -0
- package/dist/session.d.ts.map +1 -0
- package/docs/README.md +15 -0
- package/docs/guide/permissions.md +225 -0
- package/docs/guide/routes.md +243 -0
- package/docs/roadmap.md +40 -0
- package/docs/troubleshooting.md +324 -0
- package/package.json +62 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Steve Tsala
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# @nxgt/janus-hono
|
|
2
|
+
|
|
3
|
+
[`@nxgt/janus`](https://www.npmjs.com/package/@nxgt/janus) in a
|
|
4
|
+
[Hono](https://hono.dev) app: the signed-in user on every request, the session
|
|
5
|
+
cookie set and cleared, a route guarded by a permission, the instances on the
|
|
6
|
+
context to grant and revoke through, and every error answered with the status
|
|
7
|
+
it deserves — an outage as 503, never as 401 or 403.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { janusErrors, sendSession, session, signOut } from '@nxgt/janus-hono';
|
|
11
|
+
import { Hono } from 'hono';
|
|
12
|
+
import { auth } from './auth'; // what janus() answered
|
|
13
|
+
|
|
14
|
+
const app = new Hono()
|
|
15
|
+
.post('/sign-in', async (c) => {
|
|
16
|
+
const { email, password } = await c.req.json();
|
|
17
|
+
const user = sendSession(c, auth, await auth.signIn({ email, password }));
|
|
18
|
+
return c.json({ id: user.id }); // the token is in the cookie, not the body
|
|
19
|
+
})
|
|
20
|
+
.post('/sign-out', async (c) => {
|
|
21
|
+
await signOut(c, auth);
|
|
22
|
+
return c.body(null, 204);
|
|
23
|
+
})
|
|
24
|
+
.get('/me', session(auth, { required: true }), (c) =>
|
|
25
|
+
c.json({ email: c.var.user.email }), // never null here
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
app.onError(janusErrors()); // CREDENTIALS_INVALID → 401, STORE_FAILED → 503, …
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
> **0.x.** A minor version may still change the surface; the changelog says how.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
bun add @nxgt/janus-hono @nxgt/janus hono
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Every peer is required: `@nxgt/janus`, `hono` (4.13.4 or later) and
|
|
40
|
+
`typescript` (6). `@nxgt/janus` is a **peer**, never a dependency: this package
|
|
41
|
+
defines no error class, so the `JanusError` you catch is the one you import.
|
|
42
|
+
Like `@nxgt/janus`, it expects `"moduleResolution": "bundler"`: the
|
|
43
|
+
declarations import without extensions, so `nodenext` is not supported.
|
|
44
|
+
|
|
45
|
+
## API
|
|
46
|
+
|
|
47
|
+
| Export | What it is |
|
|
48
|
+
| --- | --- |
|
|
49
|
+
| `session(auth, options?)` | Middleware. Reads who the request belongs to — `auth.authenticate(c.req.raw)` — and sets `c.var.user` and `c.var.session`, `null` for an anonymous request. `{ required: true }` answers an anonymous request 401 and types `c.var.user` as never `null`. `{ type: 'staff' }` treats a user of any other type as anonymous. Sends a renewed session's cookie again |
|
|
50
|
+
| `sendSession(c, auth, signedIn)` | Appends the session cookie to the response — after `signUp`, `signIn`, or anything that answered `{ token, session, user }` — and answers the user |
|
|
51
|
+
| `signOut(c, auth)` | Revokes the session the request presents and clears the cookie, whatever the answer. `false` when the request presented no session, or an unknown one |
|
|
52
|
+
| `janusErrors({ report?, fallback? })` | An `app.onError` handler: every `JanusError` answered with `statusOf(code)` and `bodyOf(error)`; anything else to `fallback`, or to Hono's own handling. `report(error, c)` sees every one answered 5xx first — `STORE_FAILED` and the like, for your logs |
|
|
53
|
+
| `statusOf(code)` | The status a code deserves: `STORE_FAILED` 503, `CREDENTIALS_INVALID` 401, `USER_INACTIVE` 403, `LOGIN_TAKEN` 409, … Exhaustive over `JanusErrorCode` |
|
|
54
|
+
| `bodyOf(error)` | `{ code }`, plus `issues` for `USER_INVALID` and `minLength` for `PASSWORD_TOO_SHORT` — what the client can act on, and nothing else |
|
|
55
|
+
| `SessionOptions<Type>` | `{ type?, required? }`, the options of `session()` — for a wrapper of your own |
|
|
56
|
+
| `SessionEnv<typeof auth, Type?, Required?>` | The `Env` `session()` sets, for `new Hono<SessionEnv<typeof auth>>()` |
|
|
57
|
+
| `UserOfAuth<typeof auth>` | The users an instance knows, as a union narrowed by `user.type` |
|
|
58
|
+
| `permission(access, permission, type, load, options?)` | Middleware. Loads the object with `load(c)`, checks `access.can(c.var.user, permission, object)` with `type` added, and sets `c.var.object` to what `load` answered. Anonymous: 401, before loading. `load` answers `null`: 404. A denial: 403. `{ ctx: (c, object) => … }` is required exactly when the permission reaches a condition; `{ subject: (c) => … }` replaces `c.var.user`. One per route |
|
|
59
|
+
| `byParam(name, find)` | A `load` for `permission()`: `find(c.req.param(name))`, or `null` — a 404 — when the route has no such parameter |
|
|
60
|
+
| `bindJanus({ auth?, access? })` | The functions above with the instances bound: `session(options?)`, `sendSession(c, signedIn)` and `signOut(c)` with `auth`; `permission(permission, type, load, options?)` with `access`; `provide()` always |
|
|
61
|
+
| `provide({ auth?, access? })` | Middleware. Sets `c.var.auth` and `c.var.access` to the instances given — only those — for a route that writes users or tuples |
|
|
62
|
+
| `ObjectData<C, Type>`, `PermissionOptions`, `Instances` | The types of `load`'s answer, of `permission()`'s options and of `provide()`'s argument |
|
|
63
|
+
|
|
64
|
+
The whole status table is in [`@nxgt/janus`'s errors guide](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/errors.md).
|
|
65
|
+
|
|
66
|
+
## Permissions
|
|
67
|
+
|
|
68
|
+
`permission()` guards a route: the object is loaded once, checked, and handed
|
|
69
|
+
to the route. `provide()` puts the instances on the context, so the route that
|
|
70
|
+
creates a record grants its owner through `c.var.access`:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import { permission, provide, session } from '@nxgt/janus-hono';
|
|
74
|
+
import { Hono } from 'hono';
|
|
75
|
+
import { access, recordOf } from './access'; // the guide's model, and a loader
|
|
76
|
+
import { auth } from './auth';
|
|
77
|
+
import { records } from './records'; // your own store
|
|
78
|
+
|
|
79
|
+
const app = new Hono()
|
|
80
|
+
.use(session(auth), provide({ auth, access }))
|
|
81
|
+
.get(
|
|
82
|
+
'/records/:id',
|
|
83
|
+
permission(access, 'view', 'record', recordOf),
|
|
84
|
+
(c) => c.json(c.var.object), // your record type, loaded once
|
|
85
|
+
)
|
|
86
|
+
.put(
|
|
87
|
+
'/records/:id',
|
|
88
|
+
permission(access, 'edit', 'record', recordOf, {
|
|
89
|
+
ctx: (c, record) => ({ locked: record.locked }), // `edit` reaches a when()
|
|
90
|
+
}),
|
|
91
|
+
async (c) => c.json(await records.update(c.var.object.id, await c.req.json())),
|
|
92
|
+
)
|
|
93
|
+
.post('/records', session(auth, { type: 'patient', required: true }), async (c) => {
|
|
94
|
+
const record = await records.create(await c.req.json());
|
|
95
|
+
await c.var.access.grant({ type: 'record', id: record.id }, 'owner', c.var.user);
|
|
96
|
+
return c.json(record, 201);
|
|
97
|
+
});
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The model — `owner`, a `doctor` read from the record, `edit` under a condition
|
|
101
|
+
— and `recordOf` are those of the [guarded routes guide](docs/guide/permissions.md). The
|
|
102
|
+
permission, the object type, the fields a `fromField` reads and the `ctx`
|
|
103
|
+
of a condition are typed from the model, as they are for `can()`.
|
|
104
|
+
`janusErrors()` answers the failures of both sides alike — `can()`'s
|
|
105
|
+
`STORE_FAILED` is a 503, never a 403.
|
|
106
|
+
|
|
107
|
+
### Bound once
|
|
108
|
+
|
|
109
|
+
`bindJanus({ auth, access })` answers the same functions with the instances
|
|
110
|
+
bound, so no route repeats them — typed exactly as the unbound ones:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import { bindJanus, byParam } from '@nxgt/janus-hono';
|
|
114
|
+
|
|
115
|
+
const j = bindJanus({ auth, access });
|
|
116
|
+
|
|
117
|
+
const app = new Hono()
|
|
118
|
+
.use(j.session(), j.provide())
|
|
119
|
+
.get('/records/:id', j.permission('view', 'record', byParam('id', (id) => records.find(id))), (c) =>
|
|
120
|
+
c.json(c.var.object),
|
|
121
|
+
)
|
|
122
|
+
.post('/sign-out', async (c) => {
|
|
123
|
+
await j.signOut(c);
|
|
124
|
+
return c.body(null, 204);
|
|
125
|
+
});
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Only what is given is bound: `bindJanus({ auth })` has no `permission`.
|
|
129
|
+
|
|
130
|
+
Each side is usable alone. An application that signs users in some other way
|
|
131
|
+
passes `{ subject: (c) => … }` to `permission()`; one without permissions uses
|
|
132
|
+
`session()` and `janusErrors()` only.
|
|
133
|
+
|
|
134
|
+
## Traps
|
|
135
|
+
|
|
136
|
+
- **Without `app.onError(janusErrors())`, an outage is Hono's 500.** Not a
|
|
137
|
+
401 — `session()` never turns a failure into an anonymous request — but not
|
|
138
|
+
the 503 that tells a client to retry either.
|
|
139
|
+
- **`app.use(session(auth))` on its own line does not type the routes after
|
|
140
|
+
it.** Hono types a chain, not statements. Chain the calls, or declare the app
|
|
141
|
+
as `new Hono<SessionEnv<typeof auth>>()`.
|
|
142
|
+
- **A renewed session is sent again only as a cookie, and only to a request
|
|
143
|
+
that presented one.** A client that sends `Authorization: Bearer` is renewed
|
|
144
|
+
too — its session token does not change — but it is never handed a cookie it did not
|
|
145
|
+
ask for. It reads `session.expiresAt` if it needs to.
|
|
146
|
+
- **`required` is typed only for a literal `true`.** A computed `boolean`
|
|
147
|
+
compiles, and leaves `c.var.user` nullable.
|
|
148
|
+
- **`required` answers 401 with no body and no `WWW-Authenticate`.** Wrap the
|
|
149
|
+
route yourself for another answer: `session(auth)`, then check
|
|
150
|
+
`c.var.user === null`.
|
|
151
|
+
- **The cookie is `Secure` by default.** A browser drops it over plain
|
|
152
|
+
`http://`, `localhost` aside in most browsers. Set `cookie: { secure: false }`
|
|
153
|
+
in `janus()` for a development server that is not on `localhost`, never in
|
|
154
|
+
production.
|
|
155
|
+
- **`janusErrors()` logs nothing by itself.** A `STORE_FAILED` is answered 503
|
|
156
|
+
without a line in your logs unless you pass `report` —
|
|
157
|
+
`janusErrors({ report: (error) => logger.error(error) })`.
|
|
158
|
+
- **`bodyOf` never carries `reason`, `login` or a cause.** `CREDENTIALS_INVALID`
|
|
159
|
+
says one thing for an unknown login, a missing password and a wrong one, so a
|
|
160
|
+
response cannot tell which users exist. Log the error before you answer it
|
|
161
|
+
if you need the reason: `janusErrors` is a function of `(error, c)` you can
|
|
162
|
+
wrap.
|
|
163
|
+
- **`load` does not know the route's path.** It takes a plain `Context`:
|
|
164
|
+
`permission()` is built before Hono attaches it to a route, so
|
|
165
|
+
`c.req.param('id')` is `string | undefined` there. Answer `null` for a
|
|
166
|
+
missing id — that is a 404 — or let `byParam('id', find)` do it.
|
|
167
|
+
- **One `permission()` per route.** Both would claim `c.var.object`, so a
|
|
168
|
+
second throws a `TypeError`. Check a parent object through an arrow in the
|
|
169
|
+
model.
|
|
170
|
+
- **`permission()` needs `session()` before it**, or `{ subject }`. Without
|
|
171
|
+
either, it throws a `TypeError` at the first request — a wiring error, not
|
|
172
|
+
an anonymous 401.
|
|
173
|
+
- **A denial is 403, so it tells that the object exists.** Where that is a
|
|
174
|
+
leak, load only what the user may see and let `load` answer `null` — a 404.
|
|
175
|
+
|
|
176
|
+
## Documentation
|
|
177
|
+
|
|
178
|
+
- [Guides](docs/README.md) — wiring the middleware, the routes of a sign-in, guarded routes
|
|
179
|
+
- [Troubleshooting](docs/troubleshooting.md) — by the symptom or message you see
|
|
180
|
+
- [Roadmap](docs/roadmap.md) — what is next, and what is not planned
|
|
181
|
+
|
|
182
|
+
## Type safety, counted
|
|
183
|
+
|
|
184
|
+
Twenty-five plausible mistakes are refused by the compiler, each with a
|
|
185
|
+
`@ts-expect-error` case in `test/types/`:
|
|
186
|
+
|
|
187
|
+
- six in `session.ts`: reading `c.var.user` where it may be `null` (twice,
|
|
188
|
+
directly and through `SessionEnv`), a field of another user type, a user type
|
|
189
|
+
the instance does not know (twice, in `session()` and in `SessionEnv`), and a
|
|
190
|
+
cookie sent without its session;
|
|
191
|
+
- ten in `permission.ts`: a permission the object type does not declare, an
|
|
192
|
+
object type the model does not (twice: in a model of one object type and of
|
|
193
|
+
several, each reported on the misspelled argument), an object loaded without
|
|
194
|
+
a field a `fromField` reads, a condition reached with no `ctx`, a `ctx` of the
|
|
195
|
+
wrong shape, a `ctx` where no condition is reachable, a field the loaded
|
|
196
|
+
object does not have, granting a relation read from a field, and an instance
|
|
197
|
+
`provide()` was not given;
|
|
198
|
+
- nine in `bind.ts`: the same refusals through `bindJanus()` — a nullable
|
|
199
|
+
user, an unknown user type, a missing `ctx`, a misspelled permission — a
|
|
200
|
+
`permission`, `session`, `sendSession` or `signOut` it was not given the
|
|
201
|
+
instance for, and an `access` that is no `permissions()` instance.
|
|
202
|
+
|
|
203
|
+
## Licence
|
|
204
|
+
|
|
205
|
+
MIT
|
package/dist/bind.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Session } from '@nxgt/janus';
|
|
2
|
+
import type { CheckableOf, ModelConfig, ObjectTypeOf, Permissions } from '@nxgt/janus/permissions';
|
|
3
|
+
import type { Context, MiddlewareHandler } from 'hono';
|
|
4
|
+
import { type Awaitable, type ObjectData, type OptionsArgs } from './permission';
|
|
5
|
+
import { type Auth, type SessionEnv, type SessionOptions, type UserOfAuth } from './session';
|
|
6
|
+
/** `session(auth, options?)`, with `auth` bound: the same three forms. */
|
|
7
|
+
export interface BoundSession<A> {
|
|
8
|
+
<const T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type']>(options: SessionOptions<T> & {
|
|
9
|
+
readonly required: true;
|
|
10
|
+
}): MiddlewareHandler<SessionEnv<A, T, true>>;
|
|
11
|
+
<const T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type']>(options?: SessionOptions<T> & {
|
|
12
|
+
readonly required?: false;
|
|
13
|
+
}): MiddlewareHandler<SessionEnv<A, T>>;
|
|
14
|
+
<const T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type']>(options: SessionOptions<T>): MiddlewareHandler<SessionEnv<A, T>>;
|
|
15
|
+
}
|
|
16
|
+
/** `permission(access, …)`, with `access` bound. */
|
|
17
|
+
export type BoundPermission<C extends ModelConfig> = <const T extends ObjectTypeOf<C>, const P extends CheckableOf<C, T>, O extends ObjectData<C, T>>(permission: P, type: T, load: (c: Context) => Awaitable<O | null>, ...options: OptionsArgs<C, T, P, O>) => MiddlewareHandler<{
|
|
18
|
+
Variables: {
|
|
19
|
+
object: O;
|
|
20
|
+
};
|
|
21
|
+
}>;
|
|
22
|
+
/** The functions of this package that take `auth`, with `auth` bound. */
|
|
23
|
+
export interface BoundAuth<A> {
|
|
24
|
+
readonly session: BoundSession<A>;
|
|
25
|
+
/** `sendSession(c, auth, signedIn)`: sets the cookie, answers the user. */
|
|
26
|
+
readonly sendSession: <U>(c: Context, signedIn: {
|
|
27
|
+
readonly token: string;
|
|
28
|
+
readonly session: Session;
|
|
29
|
+
readonly user: U;
|
|
30
|
+
}) => U;
|
|
31
|
+
/** `signOut(c, auth)`. */
|
|
32
|
+
readonly signOut: (c: Context) => Promise<boolean>;
|
|
33
|
+
}
|
|
34
|
+
/** What `bindJanus()` takes: the `janus()` instance, the `permissions()` one, or both. */
|
|
35
|
+
export interface Bindable {
|
|
36
|
+
readonly auth?: Auth<{
|
|
37
|
+
readonly type: string;
|
|
38
|
+
}>;
|
|
39
|
+
/** What `permission()` takes: a `permissions()` instance, or its `can`. */
|
|
40
|
+
readonly access?: {
|
|
41
|
+
readonly can: unknown;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* What `bindJanus(instances)` answers: `provide()` always; `session`,
|
|
46
|
+
* `sendSession` and `signOut` with `auth`; `permission` with `access`.
|
|
47
|
+
*/
|
|
48
|
+
export type Bound<I extends Bindable> = {
|
|
49
|
+
/** `provide(instances)`: every instance bound, on the context. */
|
|
50
|
+
readonly provide: () => MiddlewareHandler<{
|
|
51
|
+
Variables: {
|
|
52
|
+
[K in keyof I & keyof Bindable]: I[K];
|
|
53
|
+
};
|
|
54
|
+
}>;
|
|
55
|
+
} & (I['auth'] extends Auth<{
|
|
56
|
+
readonly type: string;
|
|
57
|
+
}> ? BoundAuth<I['auth']> : unknown) & (I['access'] extends Pick<Permissions<infer C>, 'can'> ? {
|
|
58
|
+
readonly permission: BoundPermission<C>;
|
|
59
|
+
} : unknown);
|
|
60
|
+
/**
|
|
61
|
+
* This package's functions with the instances bound once, so no route
|
|
62
|
+
* repeats them:
|
|
63
|
+
*
|
|
64
|
+
* ```ts
|
|
65
|
+
* const j = bindJanus({ auth, access });
|
|
66
|
+
* const app = new Hono()
|
|
67
|
+
* .use(j.session(), j.provide())
|
|
68
|
+
* .get('/records/:id', j.permission('view', 'record', recordOf), (c) => c.json(c.var.object));
|
|
69
|
+
* ```
|
|
70
|
+
*
|
|
71
|
+
* The same functions, typed the same way: `j.session({ required: true })` is
|
|
72
|
+
* `session(auth, { required: true })`. Only what is given is bound — a
|
|
73
|
+
* `permission` without `access` is absent from the type.
|
|
74
|
+
*/
|
|
75
|
+
export declare function bindJanus<const I extends Bindable>(instances: I): Bound<I>;
|
|
76
|
+
//# sourceMappingURL=bind.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bind.d.ts","sourceRoot":"","sources":["../src/bind.ts"],"names":[],"mappings":"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"}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { JanusError, type JanusErrorCode } from '@nxgt/janus';
|
|
2
|
+
import type { Context, ErrorHandler } from 'hono';
|
|
3
|
+
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
|
4
|
+
/**
|
|
5
|
+
* The status each code deserves. Exhaustive: a code added to `@nxgt/janus`
|
|
6
|
+
* stops this file compiling instead of answering `undefined`.
|
|
7
|
+
*
|
|
8
|
+
* `STORE_FAILED` is 503 and nothing else — **an outage is not a negative
|
|
9
|
+
* answer**, and a 401 or a 404 would tell every user they do not exist.
|
|
10
|
+
*/
|
|
11
|
+
export declare function statusOf(code: JanusErrorCode): ContentfulStatusCode;
|
|
12
|
+
/**
|
|
13
|
+
* The body a refusal is answered with: its `code`, and only what the client
|
|
14
|
+
* can act on — the fields that failed the schema, the policy's minimum. Never
|
|
15
|
+
* `reason`, `login`, a hash prefix or a cause: those are for your logs.
|
|
16
|
+
*/
|
|
17
|
+
export declare function bodyOf(error: JanusError): {
|
|
18
|
+
readonly code: JanusErrorCode;
|
|
19
|
+
readonly issues?: JanusError['issues'];
|
|
20
|
+
readonly minLength?: number;
|
|
21
|
+
};
|
|
22
|
+
/** What `janusErrors()` takes. */
|
|
23
|
+
export interface JanusErrorsOptions {
|
|
24
|
+
/**
|
|
25
|
+
* Called with every `JanusError` answered 5xx — `STORE_FAILED`,
|
|
26
|
+
* `UNSUPPORTED`, `PERMISSION_DEPTH`: the ones the server must fix, not the
|
|
27
|
+
* client — before it is answered. Its `slot`, `operation`, `reason` and
|
|
28
|
+
* `cause` are for your logs; the body never carries them.
|
|
29
|
+
*
|
|
30
|
+
* **It cannot stop the answer**: one that throws, or rejects, is a
|
|
31
|
+
* `process.emitWarning`, and the 503 is sent all the same.
|
|
32
|
+
*/
|
|
33
|
+
readonly report?: (error: JanusError, c: Context) => unknown;
|
|
34
|
+
/** Every error that is not a `JanusError`. Hono's own handling when absent. */
|
|
35
|
+
readonly fallback?: ErrorHandler;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* An `app.onError` handler: every `JanusError` answered with its status and
|
|
39
|
+
* `bodyOf(error)`; anything else handed to `fallback` — Hono's own behaviour
|
|
40
|
+
* when absent.
|
|
41
|
+
*
|
|
42
|
+
* Works for either side of `@nxgt/janus`: `can()`'s `STORE_FAILED` is a 503
|
|
43
|
+
* here too, never a 403.
|
|
44
|
+
*
|
|
45
|
+
* ```ts
|
|
46
|
+
* app.onError(janusErrors({ report: (error) => logger.error(error) }));
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export declare function janusErrors(options?: JanusErrorsOptions): ErrorHandler;
|
|
50
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,KAAK,cAAc,EAAE,MAAM,aAAa,CAAC;AAC9D,OAAO,KAAK,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,MAAM,CAAC;AAElD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AAEnE;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,cAAc,GAAG,oBAAoB,CA2BnE;AAED;;;;GAIG;AACH,wBAAgB,MAAM,CAAC,KAAK,EAAE,UAAU,GAAG;IAC1C,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAC;IAC9B,QAAQ,CAAC,MAAM,CAAC,EAAE,UAAU,CAAC,QAAQ,CAAC,CAAC;IACvC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC5B,CAWA;AAgBD,kCAAkC;AAClC,MAAM,WAAW,kBAAkB;IAClC;;;;;;;;OAQG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC;IAC7D,+EAA+E;IAC/E,QAAQ,CAAC,QAAQ,CAAC,EAAE,YAAY,CAAC;CACjC;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,WAAW,CAAC,OAAO,GAAE,kBAAuB,GAAG,YAAY,CAQ1E"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nxgt/janus-hono` — `@nxgt/janus` in a Hono app.
|
|
3
|
+
*
|
|
4
|
+
* - `session(auth, options?)` — middleware: `c.var.user` and `c.var.session`
|
|
5
|
+
* on every request, a 401 where a user is required, the renewed cookie sent
|
|
6
|
+
* again;
|
|
7
|
+
* - `sendSession(c, auth, signedIn)` and `signOut(c, auth)` — the cookie, set
|
|
8
|
+
* and cleared; `sendSession` answers the user;
|
|
9
|
+
* - `permission(access, permission, type, load)` — middleware: the route runs
|
|
10
|
+
* only if the subject holds the permission on the object `load` answers,
|
|
11
|
+
* and gets that object as `c.var.object`; `byParam(name, find)` is a `load`
|
|
12
|
+
* reading one path parameter;
|
|
13
|
+
* - `provide({ auth, access })` — the instances on the context, for a route
|
|
14
|
+
* that writes users or tuples;
|
|
15
|
+
* - `janusErrors({ report?, fallback? })` — `app.onError`: every `JanusError`
|
|
16
|
+
* as its status, `STORE_FAILED` as 503 and never as 401 or 403; `report`
|
|
17
|
+
* sees the 5xx ones first.
|
|
18
|
+
*
|
|
19
|
+
* - `bindJanus({ auth, access })` — the functions above with the instances
|
|
20
|
+
* bound once: `j.session()`, `j.permission('view', 'record', load)`, …
|
|
21
|
+
*
|
|
22
|
+
* It defines no error class: what it lets through is `@nxgt/janus`'s own.
|
|
23
|
+
*/
|
|
24
|
+
export { type Bindable, type Bound, type BoundAuth, type BoundPermission, type BoundSession, bindJanus, } from './bind';
|
|
25
|
+
export { bodyOf, type JanusErrorsOptions, janusErrors, statusOf, } from './errors';
|
|
26
|
+
export { byParam, type ObjectData, type PermissionOptions, permission, } from './permission';
|
|
27
|
+
export { type Instances, provide } from './provide';
|
|
28
|
+
export { type SessionEnv, type SessionOptions, sendSession, session, signOut, type UserOfAuth, } from './session';
|
|
29
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EACN,KAAK,QAAQ,EACb,KAAK,KAAK,EACV,KAAK,SAAS,EACd,KAAK,eAAe,EACpB,KAAK,YAAY,EACjB,SAAS,GACT,MAAM,QAAQ,CAAC;AAChB,OAAO,EACN,MAAM,EACN,KAAK,kBAAkB,EACvB,WAAW,EACX,QAAQ,GACR,MAAM,UAAU,CAAC;AAClB,OAAO,EACN,OAAO,EACP,KAAK,UAAU,EACf,KAAK,iBAAiB,EACtB,UAAU,GACV,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,KAAK,SAAS,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpD,OAAO,EACN,KAAK,UAAU,EACf,KAAK,cAAc,EACnB,WAAW,EACX,OAAO,EACP,OAAO,EACP,KAAK,UAAU,GACf,MAAM,WAAW,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// src/permission.ts
|
|
2
|
+
function permission(access, permission, type, load, options = {}) {
|
|
3
|
+
const can = access.can;
|
|
4
|
+
return async (c, next) => {
|
|
5
|
+
if (c.get("object") !== undefined) {
|
|
6
|
+
throw new TypeError("permission(): c.var.object is already set — one permission() per route; check a parent object through an arrow in the model");
|
|
7
|
+
}
|
|
8
|
+
const subject = options.subject === undefined ? userOf(c) : await options.subject(c);
|
|
9
|
+
if (subject === null || subject === undefined)
|
|
10
|
+
return c.body(null, 401);
|
|
11
|
+
const object = await load(c);
|
|
12
|
+
if (object === null)
|
|
13
|
+
return c.body(null, 404);
|
|
14
|
+
const allowed = await can(subject, permission, view(object, type), options.ctx === undefined ? undefined : { ctx: await options.ctx(c, object) });
|
|
15
|
+
if (!allowed)
|
|
16
|
+
return c.body(null, 403);
|
|
17
|
+
c.set("object", object);
|
|
18
|
+
await next();
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function byParam(name, find) {
|
|
22
|
+
return (c) => {
|
|
23
|
+
const id = c.req.param(name);
|
|
24
|
+
return id === undefined ? null : find(id);
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function view(object, type) {
|
|
28
|
+
return new Proxy(object, {
|
|
29
|
+
get: (target, key) => key === "type" ? type : Reflect.get(target, key),
|
|
30
|
+
has: (target, key) => key === "type" || Reflect.has(target, key)
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function userOf(c) {
|
|
34
|
+
const user = c.get("user");
|
|
35
|
+
if (user === undefined) {
|
|
36
|
+
throw new TypeError("permission(): c.var.user is not set — put session(auth) before it, or pass { subject }");
|
|
37
|
+
}
|
|
38
|
+
return user;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// src/provide.ts
|
|
42
|
+
function provide(instances) {
|
|
43
|
+
return async (c, next) => {
|
|
44
|
+
if (instances.auth !== undefined)
|
|
45
|
+
c.set("auth", instances.auth);
|
|
46
|
+
if (instances.access !== undefined)
|
|
47
|
+
c.set("access", instances.access);
|
|
48
|
+
await next();
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// src/session.ts
|
|
53
|
+
import { getCookie } from "hono/cookie";
|
|
54
|
+
function session(auth, options = {}) {
|
|
55
|
+
return async (c, next) => {
|
|
56
|
+
const current = await auth.authenticate(c.req.raw, options.type === undefined ? undefined : { type: options.type });
|
|
57
|
+
if (current === null && options.required === true) {
|
|
58
|
+
return c.body(null, 401);
|
|
59
|
+
}
|
|
60
|
+
c.set("user", current?.user ?? null);
|
|
61
|
+
c.set("session", current?.session ?? null);
|
|
62
|
+
await next();
|
|
63
|
+
const name = `${auth.cookie.name}=`;
|
|
64
|
+
if (current?.renewed === true && getCookie(c, auth.cookie.name) === current.token && !c.res.headers.getSetCookie().some((value) => value.startsWith(name))) {
|
|
65
|
+
sendSession(c, auth, current);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function sendSession(c, auth, signedIn) {
|
|
70
|
+
c.header("Set-Cookie", auth.cookie.serialize(signedIn.token, signedIn.session), {
|
|
71
|
+
append: true
|
|
72
|
+
});
|
|
73
|
+
return signedIn.user;
|
|
74
|
+
}
|
|
75
|
+
async function signOut(c, auth) {
|
|
76
|
+
const revoked = await auth.signOut(c.req.raw);
|
|
77
|
+
c.header("Set-Cookie", auth.cookie.clear(), { append: true });
|
|
78
|
+
return revoked;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// src/bind.ts
|
|
82
|
+
function bindJanus(instances) {
|
|
83
|
+
const { auth, access } = instances;
|
|
84
|
+
const bound = {
|
|
85
|
+
provide: () => provide(instances)
|
|
86
|
+
};
|
|
87
|
+
if (auth !== undefined) {
|
|
88
|
+
bound.session = (options = {}) => session(auth, options);
|
|
89
|
+
bound.sendSession = (c, signedIn) => sendSession(c, auth, signedIn);
|
|
90
|
+
bound.signOut = (c) => signOut(c, auth);
|
|
91
|
+
}
|
|
92
|
+
if (access !== undefined) {
|
|
93
|
+
const guard = permission;
|
|
94
|
+
bound.permission = (...rest) => guard(access, ...rest);
|
|
95
|
+
}
|
|
96
|
+
return bound;
|
|
97
|
+
}
|
|
98
|
+
// src/errors.ts
|
|
99
|
+
import { JanusError } from "@nxgt/janus";
|
|
100
|
+
function statusOf(code) {
|
|
101
|
+
switch (code) {
|
|
102
|
+
case "STORE_FAILED":
|
|
103
|
+
return 503;
|
|
104
|
+
case "NOT_FOUND":
|
|
105
|
+
return 404;
|
|
106
|
+
case "LOGIN_TAKEN":
|
|
107
|
+
case "VERSION_CONFLICT":
|
|
108
|
+
return 409;
|
|
109
|
+
case "USER_INVALID":
|
|
110
|
+
case "PASSWORD_TOO_SHORT":
|
|
111
|
+
case "HASH_UNSUPPORTED":
|
|
112
|
+
case "INVALID_CURSOR":
|
|
113
|
+
case "TOKEN_UNKNOWN":
|
|
114
|
+
case "TOKEN_SPENT":
|
|
115
|
+
case "TOKEN_EXPIRED":
|
|
116
|
+
case "TOKEN_STALE":
|
|
117
|
+
return 400;
|
|
118
|
+
case "CREDENTIALS_INVALID":
|
|
119
|
+
return 401;
|
|
120
|
+
case "USER_INACTIVE":
|
|
121
|
+
return 403;
|
|
122
|
+
case "UNSUPPORTED":
|
|
123
|
+
return 501;
|
|
124
|
+
case "PERMISSION_DEPTH":
|
|
125
|
+
return 500;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function bodyOf(error) {
|
|
129
|
+
switch (error.code) {
|
|
130
|
+
case "USER_INVALID":
|
|
131
|
+
return { code: error.code, issues: error.issues ?? [] };
|
|
132
|
+
case "PASSWORD_TOO_SHORT":
|
|
133
|
+
return error.minLength === undefined ? { code: error.code } : { code: error.code, minLength: error.minLength };
|
|
134
|
+
default:
|
|
135
|
+
return { code: error.code };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function honoDefault(error, c) {
|
|
139
|
+
if ("getResponse" in error) {
|
|
140
|
+
const response = error.getResponse();
|
|
141
|
+
return c.newResponse(response.body, response);
|
|
142
|
+
}
|
|
143
|
+
console.error(error);
|
|
144
|
+
return c.text("Internal Server Error", 500);
|
|
145
|
+
}
|
|
146
|
+
function janusErrors(options = {}) {
|
|
147
|
+
const { report, fallback = honoDefault } = options;
|
|
148
|
+
return (error, c) => {
|
|
149
|
+
if (!(error instanceof JanusError))
|
|
150
|
+
return fallback(error, c);
|
|
151
|
+
const status = statusOf(error.code);
|
|
152
|
+
if (status >= 500 && report !== undefined)
|
|
153
|
+
reportSafely(report, error, c);
|
|
154
|
+
return c.json(bodyOf(error), status);
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function reportSafely(report, error, c) {
|
|
158
|
+
const warn = (failure) => process.emitWarning(`janusErrors: report failed on ${error.code}: ${failure instanceof Error ? failure.name : typeof failure}`);
|
|
159
|
+
try {
|
|
160
|
+
const reported = report(error, c);
|
|
161
|
+
if (reported instanceof Promise)
|
|
162
|
+
reported.then(undefined, warn);
|
|
163
|
+
} catch (failure) {
|
|
164
|
+
warn(failure);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
export {
|
|
168
|
+
bindJanus,
|
|
169
|
+
bodyOf,
|
|
170
|
+
byParam,
|
|
171
|
+
janusErrors,
|
|
172
|
+
permission,
|
|
173
|
+
provide,
|
|
174
|
+
sendSession,
|
|
175
|
+
session,
|
|
176
|
+
signOut,
|
|
177
|
+
statusOf
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
//# debugId=93D6634A3905348664756E2164756E21
|
|
181
|
+
//# sourceMappingURL=index.js.map
|