@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.
@@ -0,0 +1,14 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/permission.ts", "../src/provide.ts", "../src/session.ts", "../src/bind.ts", "../src/errors.ts"],
4
+ "sourcesContent": [
5
+ "import type {\n\tCheckableOf,\n\tCtxOf,\n\tFieldsOf,\n\tModelConfig,\n\tObjectTypeOf,\n\tPermissions,\n\tSubjectRef,\n} from '@nxgt/janus/permissions';\nimport type { Context, MiddlewareHandler } from 'hono';\n\n/**\n * An object of type `T` as the application loads it: its id, **every field a\n * `fromField` of its type reads**, and whatever else it carries. The type is\n * the middleware's to add.\n */\nexport type ObjectData<C extends ModelConfig, T extends ObjectTypeOf<C>> = {\n\treadonly id: string;\n} & { readonly [F in FieldsOf<C, T>]: string | null };\n\nexport type Awaitable<V> = V | Promise<V>;\n\n/**\n * The options of `permission()`: `ctx` required exactly when a condition of\n * the permission is reachable, as for `can()`.\n */\nexport type PermissionOptions<\n\tC extends ModelConfig,\n\tT extends ObjectTypeOf<C>,\n\tP extends string,\n\tO,\n> = {\n\t/**\n\t * Who asks. `c.var.user` — set by `session()` — when absent; pass it when\n\t * your users are not `janus()`'s. `null` is anonymous.\n\t */\n\treadonly subject?: (c: Context) => Awaitable<SubjectRef<C> | null>;\n} & ([CtxOf<C, T, P>] extends [never]\n\t? { readonly ctx?: never }\n\t: {\n\t\t\t/** The condition's context, read from the request and the loaded object. */\n\t\t\treadonly ctx: (c: Context, object: O) => Awaitable<CtxOf<C, T, P>>;\n\t\t});\n\n/**\n * The options argument: required exactly when `ctx` is. Loose when `T` or `P`\n * is its whole constraint — what the compiler falls back to when the object\n * type or the permission was wrong — so the error names that argument, not a\n * missing one; the rule `can()` follows.\n */\nexport type OptionsArgs<\n\tC extends ModelConfig,\n\tT extends ObjectTypeOf<C>,\n\tP extends string,\n\tO,\n> = [ObjectTypeOf<C>] extends [T]\n\t? IsSingle<ObjectTypeOf<C>> extends true\n\t\t? PermissionArgs<C, T, P, O>\n\t\t: [options?: LooseOptions]\n\t: PermissionArgs<C, T, P, O>;\n\n/** `OptionsArgs` once `T` is known right: loose when `P` is its whole constraint. */\ntype PermissionArgs<\n\tC extends ModelConfig,\n\tT extends ObjectTypeOf<C>,\n\tP extends string,\n\tO,\n> = [CheckableOf<C, T>] extends [P]\n\t? IsSingle<CheckableOf<C, T>> extends true\n\t\t? StrictArgs<C, T, P, O>\n\t\t: [options?: LooseOptions]\n\t: StrictArgs<C, T, P, O>;\n\ntype StrictArgs<\n\tC extends ModelConfig,\n\tT extends ObjectTypeOf<C>,\n\tP extends string,\n\tO,\n> = [CtxOf<C, T, P>] extends [never]\n\t? [options?: PermissionOptions<C, T, P, O>]\n\t: [options: PermissionOptions<C, T, P, O>];\n\ntype LooseOptions = {\n\treadonly subject?: (c: Context) => unknown;\n\treadonly ctx?: (c: Context, object: never) => unknown;\n};\n\ntype UnionToIntersection<U> = (\n\tU extends unknown\n\t\t? (union: U) => void\n\t\t: never\n) extends (intersection: infer I) => void\n\t? I\n\t: never;\n\n/** Whether `U` is one type rather than a union of several. */\ntype IsSingle<U> = [U] extends [UnionToIntersection<U>] ? true : false;\n\ntype LooseCan = (\n\tsubject: SubjectRef<ModelConfig> | null,\n\tpermission: string,\n\tobject: { readonly type: string; readonly id: string },\n\toptions?: { readonly ctx: unknown },\n) => Promise<boolean>;\n\n/**\n * The middleware that lets a request through only if its subject holds\n * `permission` on an object of `type`: it loads the object once, with `load`,\n * checks it with `access.can`, and hands it to the route as `c.var.object`.\n *\n * | The request | Answered |\n * | --- | --- |\n * | anonymous | 401, no body — before the object is loaded |\n * | `load` answers `null` | 404, no body |\n * | a denial | 403, no body |\n * | allowed | the route runs, `c.var.object` set |\n *\n * One per route: a second one throws a `TypeError`, since both would claim\n * `c.var.object`. A parent object is checked through an arrow in the model.\n *\n * **A failure throws**, as everywhere: a store that cannot answer is\n * `STORE_FAILED`, never a 403 — `janusErrors()` answers it 503.\n */\nexport function permission<\n\tC extends ModelConfig,\n\tconst T extends ObjectTypeOf<C>,\n\tconst P extends CheckableOf<C, T>,\n\tO extends ObjectData<C, T>,\n>(\n\taccess: Pick<Permissions<C>, 'can'>,\n\tpermission: P,\n\ttype: T,\n\tload: (c: Context) => Awaitable<O | null>,\n\t...options: OptionsArgs<C, T, P, O>\n): MiddlewareHandler<{\n\t// biome-ignore lint/style/useNamingConvention: Hono's `Env` names the key, not us.\n\tVariables: { object: O };\n}>;\nexport function permission(\n\taccess: { readonly can: unknown },\n\tpermission: string,\n\ttype: string,\n\tload: (c: Context) => Awaitable<{ readonly id: string } | null>,\n\toptions: {\n\t\treadonly subject?: (\n\t\t\tc: Context,\n\t\t) => Awaitable<SubjectRef<ModelConfig> | null>;\n\t\treadonly ctx?: (c: Context, object: unknown) => Awaitable<unknown>;\n\t} = {},\n): MiddlewareHandler {\n\tconst can = access.can as LooseCan;\n\n\treturn async (c, next) => {\n\t\tif (c.get('object') !== undefined) {\n\t\t\tthrow new TypeError(\n\t\t\t\t'permission(): c.var.object is already set — one permission() per route; check a parent object through an arrow in the model',\n\t\t\t);\n\t\t}\n\t\tconst subject =\n\t\t\toptions.subject === undefined ? userOf(c) : await options.subject(c);\n\t\tif (subject === null || subject === undefined) return c.body(null, 401);\n\n\t\tconst object = await load(c);\n\t\tif (object === null) return c.body(null, 404);\n\n\t\tconst allowed = await can(\n\t\t\tsubject,\n\t\t\tpermission,\n\t\t\tview(object, type),\n\t\t\toptions.ctx === undefined\n\t\t\t\t? undefined\n\t\t\t\t: { ctx: await options.ctx(c, object) },\n\t\t);\n\t\tif (!allowed) return c.body(null, 403);\n\n\t\tc.set('object', object);\n\t\tawait next();\n\t};\n}\n\n/**\n * A `load` reading one path parameter: `find(id)`, or `null` — a 404 — when\n * the route has no such parameter.\n *\n * ```ts\n * permission(access, 'view', 'record', byParam('id', (id) => records.find(id)))\n * ```\n */\nexport function byParam<O>(\n\tname: string,\n\tfind: (id: string) => Awaitable<O | null>,\n): (c: Context) => Awaitable<O | null> {\n\treturn (c) => {\n\t\tconst id = c.req.param(name);\n\t\treturn id === undefined ? null : find(id);\n\t};\n}\n\n/**\n * The object as `can()` reads it: `type` added, every other field read from\n * the loaded object itself — so a getter, a class's `#private` state or an\n * ORM document's accessors answer as they do in the route. A spread would\n * copy own enumerable fields only.\n */\nfunction view(\n\tobject: object,\n\ttype: string,\n): { readonly type: string; readonly id: string } {\n\treturn new Proxy(object, {\n\t\tget: (target, key) => (key === 'type' ? type : Reflect.get(target, key)),\n\t\thas: (target, key) => key === 'type' || Reflect.has(target, key),\n\t}) as { readonly type: string; readonly id: string };\n}\n\n/** `c.var.user`, which `session()` sets — or a wiring error when nothing did. */\nfunction userOf(c: Context): SubjectRef<ModelConfig> | null {\n\tconst user: unknown = c.get('user');\n\tif (user === undefined) {\n\t\tthrow new TypeError(\n\t\t\t'permission(): c.var.user is not set — put session(auth) before it, or pass { subject }',\n\t\t);\n\t}\n\treturn user as SubjectRef<ModelConfig> | null;\n}\n",
6
+ "import type { MiddlewareHandler } from 'hono';\n\n/** What `provide()` takes: the `janus()` instance, the `permissions()` one, or both. */\nexport interface Instances {\n\treadonly auth?: object;\n\treadonly access?: object;\n}\n\n/**\n * The middleware that puts the instances on the context — `c.var.auth`, and\n * `c.var.access` — so a route writes users and tuples through them rather\n * than through a module import: `c.var.access.grant(record, 'owner', user)`.\n *\n * Only what is passed is set, and typed.\n */\nexport function provide<const I extends Instances>(\n\tinstances: I,\n): MiddlewareHandler<{\n\t// biome-ignore lint/style/useNamingConvention: Hono's `Env` names the key, not us.\n\tVariables: { [K in keyof I & keyof Instances]: I[K] };\n}> {\n\treturn async (c, next) => {\n\t\tif (instances.auth !== undefined) c.set('auth', instances.auth);\n\t\tif (instances.access !== undefined) c.set('access', instances.access);\n\t\tawait next();\n\t};\n}\n",
7
+ "import type { Session, SharedApi } from '@nxgt/janus';\nimport type { Context, MiddlewareHandler } from 'hono';\nimport { getCookie } from 'hono/cookie';\n\n/** Anything `janus()` answered: the part of it this package calls. */\nexport type Auth<U extends { readonly type: string }> = Pick<\n\tSharedApi<U>,\n\t'authenticate' | 'signOut' | 'cookie'\n>;\n\n/** The users an `auth` instance knows, as a union narrowed by `user.type`. */\nexport type UserOfAuth<A> = A extends Auth<infer U> ? U : never;\n\n/**\n * What `session()` sets on the context: `c.var.user` and `c.var.session`,\n * both `null` for an anonymous request unless the route requires a user.\n *\n * `new Hono<SessionEnv<typeof auth>>()` types every route of an app that\n * wires `app.use(session(auth))` once.\n */\nexport type SessionEnv<\n\tA,\n\tT extends UserOfAuth<A>['type'] = UserOfAuth<A>['type'],\n\tRequired extends boolean = false,\n> = {\n\t// biome-ignore lint/style/useNamingConvention: Hono's `Env` names the key, not us.\n\tVariables: Required extends true\n\t\t? {\n\t\t\t\tuser: Extract<UserOfAuth<A>, { readonly type: T }>;\n\t\t\t\tsession: Session;\n\t\t\t}\n\t\t: {\n\t\t\t\tuser: Extract<UserOfAuth<A>, { readonly type: T }> | null;\n\t\t\t\tsession: Session | null;\n\t\t\t};\n};\n\nexport interface SessionOptions<T extends string> {\n\t/** Only a user of this type is authenticated here; any other is anonymous. */\n\treadonly type?: T;\n\t/**\n\t * `true`: an anonymous request is answered 401, with no body, and the\n\t * route never runs — so `c.var.user` is never `null` in it. Only a literal\n\t * `true` types it so: a computed `boolean` leaves `c.var.user` nullable.\n\t */\n\treadonly required?: boolean;\n}\n\n/**\n * The middleware that reads who a request belongs to — `auth.authenticate`\n * on the request — and sets `c.var.user` and `c.var.session`.\n *\n * **An outage is not anonymous**: a store that cannot answer rejects with\n * `STORE_FAILED`, and this middleware lets it through to `app.onError` —\n * `janusErrors()` answers it 503, never 401.\n *\n * A session renewed in passing is sent again as a cookie, after the route\n * ran — but only to a request that presented it as one: a client that sends\n * `Authorization: Bearer` is not handed a cookie it never asked for. And\n * never over a session cookie the route set itself — `sendSession` after a\n * sign-in, `signOut`'s clearing — which a browser would otherwise overwrite.\n */\nexport function session<\n\tA extends Auth<{ readonly type: string }>,\n\tconst T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type'],\n>(\n\tauth: A,\n\toptions: SessionOptions<T> & { readonly required: true },\n): MiddlewareHandler<SessionEnv<A, T, true>>;\nexport function session<\n\tA extends Auth<{ readonly type: string }>,\n\tconst T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type'],\n>(\n\tauth: A,\n\toptions?: SessionOptions<T> & { readonly required?: false },\n): MiddlewareHandler<SessionEnv<A, T>>;\nexport function session<\n\tA extends Auth<{ readonly type: string }>,\n\tconst T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type'],\n>(auth: A, options: SessionOptions<T>): MiddlewareHandler<SessionEnv<A, T>>;\nexport function session(\n\tauth: Auth<{ readonly type: string }>,\n\toptions: SessionOptions<string> = {},\n): MiddlewareHandler {\n\treturn async (c, next) => {\n\t\tconst current = await auth.authenticate(\n\t\t\tc.req.raw,\n\t\t\toptions.type === undefined ? undefined : { type: options.type },\n\t\t);\n\n\t\tif (current === null && options.required === true) {\n\t\t\treturn c.body(null, 401);\n\t\t}\n\t\tc.set('user', current?.user ?? null);\n\t\tc.set('session', current?.session ?? null);\n\n\t\tawait next();\n\n\t\t// The route's own cookie wins: a sign-in or a sign-out behind this\n\t\t// middleware must not be undone by the session it replaced.\n\t\tconst name = `${auth.cookie.name}=`;\n\t\tif (\n\t\t\tcurrent?.renewed === true &&\n\t\t\tgetCookie(c, auth.cookie.name) === current.token &&\n\t\t\t!c.res.headers.getSetCookie().some((value) => value.startsWith(name))\n\t\t) {\n\t\t\tsendSession(c, auth, current);\n\t\t}\n\t};\n}\n\n/**\n * Sends the session cookie — after `signUp`, `signIn`, or anything else that\n * answered a session token and its session — and answers the user. Appends: a\n * cookie set before stays.\n *\n * ```ts\n * const user = sendSession(c, auth, await auth.signIn({ email, password }));\n * return c.json({ id: user.id }); // the token is in the cookie, never in the body\n * ```\n */\nexport function sendSession<U>(\n\tc: Context,\n\tauth: Pick<Auth<{ readonly type: string }>, 'cookie'>,\n\tsignedIn: {\n\t\treadonly token: string;\n\t\treadonly session: Session;\n\t\treadonly user: U;\n\t},\n): U {\n\tc.header(\n\t\t'Set-Cookie',\n\t\tauth.cookie.serialize(signedIn.token, signedIn.session),\n\t\t{\n\t\t\tappend: true,\n\t\t},\n\t);\n\treturn signedIn.user;\n}\n\n/**\n * Revokes the session the request presents and clears the cookie — cleared\n * whatever the answer, so a browser holding a stale cookie drops it too.\n * `false` when the request presented no session, or an unknown one.\n */\nexport async function signOut(\n\tc: Context,\n\tauth: Pick<Auth<{ readonly type: string }>, 'signOut' | 'cookie'>,\n): Promise<boolean> {\n\tconst revoked = await auth.signOut(c.req.raw);\n\tc.header('Set-Cookie', auth.cookie.clear(), { append: true });\n\treturn revoked;\n}\n",
8
+ "import type { Session } from '@nxgt/janus';\nimport type {\n\tCheckableOf,\n\tModelConfig,\n\tObjectTypeOf,\n\tPermissions,\n} from '@nxgt/janus/permissions';\nimport type { Context, MiddlewareHandler } from 'hono';\nimport {\n\ttype Awaitable,\n\ttype ObjectData,\n\ttype OptionsArgs,\n\tpermission,\n} from './permission';\nimport { provide } from './provide';\nimport {\n\ttype Auth,\n\ttype SessionEnv,\n\ttype SessionOptions,\n\tsendSession,\n\tsession,\n\tsignOut,\n\ttype UserOfAuth,\n} from './session';\n\n/** `session(auth, options?)`, with `auth` bound: the same three forms. */\nexport interface BoundSession<A> {\n\t<const T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type']>(\n\t\toptions: SessionOptions<T> & { readonly required: true },\n\t): MiddlewareHandler<SessionEnv<A, T, true>>;\n\t<const T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type']>(\n\t\toptions?: SessionOptions<T> & { readonly required?: false },\n\t): MiddlewareHandler<SessionEnv<A, T>>;\n\t<const T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type']>(\n\t\toptions: SessionOptions<T>,\n\t): MiddlewareHandler<SessionEnv<A, T>>;\n}\n\n/** `permission(access, …)`, with `access` bound. */\nexport type BoundPermission<C extends ModelConfig> = <\n\tconst T extends ObjectTypeOf<C>,\n\tconst P extends CheckableOf<C, T>,\n\tO extends ObjectData<C, T>,\n>(\n\tpermission: P,\n\ttype: T,\n\tload: (c: Context) => Awaitable<O | null>,\n\t...options: OptionsArgs<C, T, P, O>\n) => MiddlewareHandler<{\n\t// biome-ignore lint/style/useNamingConvention: Hono's `Env` names the key, not us.\n\tVariables: { object: O };\n}>;\n\n/** The functions of this package that take `auth`, with `auth` bound. */\nexport interface BoundAuth<A> {\n\treadonly session: BoundSession<A>;\n\t/** `sendSession(c, auth, signedIn)`: sets the cookie, answers the user. */\n\treadonly sendSession: <U>(\n\t\tc: Context,\n\t\tsignedIn: {\n\t\t\treadonly token: string;\n\t\t\treadonly session: Session;\n\t\t\treadonly user: U;\n\t\t},\n\t) => U;\n\t/** `signOut(c, auth)`. */\n\treadonly signOut: (c: Context) => Promise<boolean>;\n}\n\n/** What `bindJanus()` takes: the `janus()` instance, the `permissions()` one, or both. */\nexport interface Bindable {\n\treadonly auth?: Auth<{ readonly type: string }>;\n\t/** What `permission()` takes: a `permissions()` instance, or its `can`. */\n\treadonly access?: { readonly can: unknown };\n}\n\n/**\n * What `bindJanus(instances)` answers: `provide()` always; `session`,\n * `sendSession` and `signOut` with `auth`; `permission` with `access`.\n */\nexport type Bound<I extends Bindable> = {\n\t/** `provide(instances)`: every instance bound, on the context. */\n\treadonly provide: () => MiddlewareHandler<{\n\t\t// biome-ignore lint/style/useNamingConvention: Hono's `Env` names the key, not us.\n\t\tVariables: { [K in keyof I & keyof Bindable]: I[K] };\n\t}>;\n} & (I['auth'] extends Auth<{ readonly type: string }>\n\t? BoundAuth<I['auth']>\n\t: unknown) &\n\t(I['access'] extends Pick<Permissions<infer C>, 'can'>\n\t\t? { readonly permission: BoundPermission<C> }\n\t\t: unknown);\n\n/**\n * This package's functions with the instances bound once, so no route\n * repeats them:\n *\n * ```ts\n * const j = bindJanus({ auth, access });\n * const app = new Hono()\n * .use(j.session(), j.provide())\n * .get('/records/:id', j.permission('view', 'record', recordOf), (c) => c.json(c.var.object));\n * ```\n *\n * The same functions, typed the same way: `j.session({ required: true })` is\n * `session(auth, { required: true })`. Only what is given is bound — a\n * `permission` without `access` is absent from the type.\n */\nexport function bindJanus<const I extends Bindable>(instances: I): Bound<I> {\n\tconst { auth, access } = instances;\n\tconst bound: Record<string, unknown> = {\n\t\tprovide: () => provide(instances),\n\t};\n\tif (auth !== undefined) {\n\t\tbound.session = (options: SessionOptions<string> = {}) =>\n\t\t\tsession(auth, options);\n\t\tbound.sendSession = (\n\t\t\tc: Context,\n\t\t\tsignedIn: Parameters<typeof sendSession>[2],\n\t\t) => sendSession(c, auth, signedIn);\n\t\tbound.signOut = (c: Context) => signOut(c, auth);\n\t}\n\tif (access !== undefined) {\n\t\tconst guard = permission as unknown as (\n\t\t\taccess: object,\n\t\t\t...rest: unknown[]\n\t\t) => MiddlewareHandler;\n\t\tbound.permission = (...rest: unknown[]) => guard(access, ...rest);\n\t}\n\treturn bound as Bound<I>;\n}\n",
9
+ "import { JanusError, type JanusErrorCode } from '@nxgt/janus';\nimport type { Context, ErrorHandler } from 'hono';\nimport type { HTTPException } from 'hono/http-exception';\nimport type { ContentfulStatusCode } from 'hono/utils/http-status';\n\n/**\n * The status each code deserves. Exhaustive: a code added to `@nxgt/janus`\n * stops this file compiling instead of answering `undefined`.\n *\n * `STORE_FAILED` is 503 and nothing else — **an outage is not a negative\n * answer**, and a 401 or a 404 would tell every user they do not exist.\n */\nexport function statusOf(code: JanusErrorCode): ContentfulStatusCode {\n\tswitch (code) {\n\t\tcase 'STORE_FAILED':\n\t\t\treturn 503;\n\t\tcase 'NOT_FOUND':\n\t\t\treturn 404;\n\t\tcase 'LOGIN_TAKEN':\n\t\tcase 'VERSION_CONFLICT':\n\t\t\treturn 409;\n\t\tcase 'USER_INVALID':\n\t\tcase 'PASSWORD_TOO_SHORT':\n\t\tcase 'HASH_UNSUPPORTED':\n\t\tcase 'INVALID_CURSOR':\n\t\tcase 'TOKEN_UNKNOWN':\n\t\tcase 'TOKEN_SPENT':\n\t\tcase 'TOKEN_EXPIRED':\n\t\tcase 'TOKEN_STALE':\n\t\t\treturn 400;\n\t\tcase 'CREDENTIALS_INVALID':\n\t\t\treturn 401;\n\t\tcase 'USER_INACTIVE':\n\t\t\treturn 403;\n\t\tcase 'UNSUPPORTED':\n\t\t\treturn 501;\n\t\tcase 'PERMISSION_DEPTH':\n\t\t\treturn 500;\n\t}\n}\n\n/**\n * The body a refusal is answered with: its `code`, and only what the client\n * can act on — the fields that failed the schema, the policy's minimum. Never\n * `reason`, `login`, a hash prefix or a cause: those are for your logs.\n */\nexport function bodyOf(error: JanusError): {\n\treadonly code: JanusErrorCode;\n\treadonly issues?: JanusError['issues'];\n\treadonly minLength?: number;\n} {\n\tswitch (error.code) {\n\t\tcase 'USER_INVALID':\n\t\t\treturn { code: error.code, issues: error.issues ?? [] };\n\t\tcase 'PASSWORD_TOO_SHORT':\n\t\t\treturn error.minLength === undefined\n\t\t\t\t? { code: error.code }\n\t\t\t\t: { code: error.code, minLength: error.minLength };\n\t\tdefault:\n\t\t\treturn { code: error.code };\n\t}\n}\n\n/**\n * Hono's own answer to an error it was not told about, copied from its\n * default handler: the response of an `HTTPException` — merged with the\n * headers the route set before throwing — and a logged 500 for anything else.\n */\nfunction honoDefault(error: Error | HTTPException, c: Context): Response {\n\tif ('getResponse' in error) {\n\t\tconst response = error.getResponse();\n\t\treturn c.newResponse(response.body, response);\n\t}\n\tconsole.error(error);\n\treturn c.text('Internal Server Error', 500);\n}\n\n/** What `janusErrors()` takes. */\nexport interface JanusErrorsOptions {\n\t/**\n\t * Called with every `JanusError` answered 5xx — `STORE_FAILED`,\n\t * `UNSUPPORTED`, `PERMISSION_DEPTH`: the ones the server must fix, not the\n\t * client — before it is answered. Its `slot`, `operation`, `reason` and\n\t * `cause` are for your logs; the body never carries them.\n\t *\n\t * **It cannot stop the answer**: one that throws, or rejects, is a\n\t * `process.emitWarning`, and the 503 is sent all the same.\n\t */\n\treadonly report?: (error: JanusError, c: Context) => unknown;\n\t/** Every error that is not a `JanusError`. Hono's own handling when absent. */\n\treadonly fallback?: ErrorHandler;\n}\n\n/**\n * An `app.onError` handler: every `JanusError` answered with its status and\n * `bodyOf(error)`; anything else handed to `fallback` — Hono's own behaviour\n * when absent.\n *\n * Works for either side of `@nxgt/janus`: `can()`'s `STORE_FAILED` is a 503\n * here too, never a 403.\n *\n * ```ts\n * app.onError(janusErrors({ report: (error) => logger.error(error) }));\n * ```\n */\nexport function janusErrors(options: JanusErrorsOptions = {}): ErrorHandler {\n\tconst { report, fallback = honoDefault } = options;\n\treturn (error, c) => {\n\t\tif (!(error instanceof JanusError)) return fallback(error, c);\n\t\tconst status = statusOf(error.code);\n\t\tif (status >= 500 && report !== undefined) reportSafely(report, error, c);\n\t\treturn c.json(bodyOf(error), status);\n\t};\n}\n\n/** `report`, whose own failure is a warning — never a lost answer. */\nfunction reportSafely(\n\treport: NonNullable<JanusErrorsOptions['report']>,\n\terror: JanusError,\n\tc: Context,\n): void {\n\tconst warn = (failure: unknown) =>\n\t\tprocess.emitWarning(\n\t\t\t`janusErrors: report failed on ${error.code}: ${failure instanceof Error ? failure.name : typeof failure}`,\n\t\t);\n\ttry {\n\t\tconst reported = report(error, c);\n\t\tif (reported instanceof Promise) reported.then(undefined, warn);\n\t} catch (failure) {\n\t\twarn(failure);\n\t}\n}\n"
10
+ ],
11
+ "mappings": "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",
12
+ "debugId": "93D6634A3905348664756E2164756E21",
13
+ "names": []
14
+ }
@@ -0,0 +1,80 @@
1
+ import type { CheckableOf, CtxOf, FieldsOf, ModelConfig, ObjectTypeOf, Permissions, SubjectRef } from '@nxgt/janus/permissions';
2
+ import type { Context, MiddlewareHandler } from 'hono';
3
+ /**
4
+ * An object of type `T` as the application loads it: its id, **every field a
5
+ * `fromField` of its type reads**, and whatever else it carries. The type is
6
+ * the middleware's to add.
7
+ */
8
+ export type ObjectData<C extends ModelConfig, T extends ObjectTypeOf<C>> = {
9
+ readonly id: string;
10
+ } & {
11
+ readonly [F in FieldsOf<C, T>]: string | null;
12
+ };
13
+ export type Awaitable<V> = V | Promise<V>;
14
+ /**
15
+ * The options of `permission()`: `ctx` required exactly when a condition of
16
+ * the permission is reachable, as for `can()`.
17
+ */
18
+ export type PermissionOptions<C extends ModelConfig, T extends ObjectTypeOf<C>, P extends string, O> = {
19
+ /**
20
+ * Who asks. `c.var.user` — set by `session()` — when absent; pass it when
21
+ * your users are not `janus()`'s. `null` is anonymous.
22
+ */
23
+ readonly subject?: (c: Context) => Awaitable<SubjectRef<C> | null>;
24
+ } & ([CtxOf<C, T, P>] extends [never] ? {
25
+ readonly ctx?: never;
26
+ } : {
27
+ /** The condition's context, read from the request and the loaded object. */
28
+ readonly ctx: (c: Context, object: O) => Awaitable<CtxOf<C, T, P>>;
29
+ });
30
+ /**
31
+ * The options argument: required exactly when `ctx` is. Loose when `T` or `P`
32
+ * is its whole constraint — what the compiler falls back to when the object
33
+ * type or the permission was wrong — so the error names that argument, not a
34
+ * missing one; the rule `can()` follows.
35
+ */
36
+ export type OptionsArgs<C extends ModelConfig, T extends ObjectTypeOf<C>, P extends string, O> = [ObjectTypeOf<C>] extends [T] ? IsSingle<ObjectTypeOf<C>> extends true ? PermissionArgs<C, T, P, O> : [options?: LooseOptions] : PermissionArgs<C, T, P, O>;
37
+ /** `OptionsArgs` once `T` is known right: loose when `P` is its whole constraint. */
38
+ type PermissionArgs<C extends ModelConfig, T extends ObjectTypeOf<C>, P extends string, O> = [CheckableOf<C, T>] extends [P] ? IsSingle<CheckableOf<C, T>> extends true ? StrictArgs<C, T, P, O> : [options?: LooseOptions] : StrictArgs<C, T, P, O>;
39
+ type StrictArgs<C extends ModelConfig, T extends ObjectTypeOf<C>, P extends string, O> = [CtxOf<C, T, P>] extends [never] ? [options?: PermissionOptions<C, T, P, O>] : [options: PermissionOptions<C, T, P, O>];
40
+ type LooseOptions = {
41
+ readonly subject?: (c: Context) => unknown;
42
+ readonly ctx?: (c: Context, object: never) => unknown;
43
+ };
44
+ type UnionToIntersection<U> = (U extends unknown ? (union: U) => void : never) extends (intersection: infer I) => void ? I : never;
45
+ /** Whether `U` is one type rather than a union of several. */
46
+ type IsSingle<U> = [U] extends [UnionToIntersection<U>] ? true : false;
47
+ /**
48
+ * The middleware that lets a request through only if its subject holds
49
+ * `permission` on an object of `type`: it loads the object once, with `load`,
50
+ * checks it with `access.can`, and hands it to the route as `c.var.object`.
51
+ *
52
+ * | The request | Answered |
53
+ * | --- | --- |
54
+ * | anonymous | 401, no body — before the object is loaded |
55
+ * | `load` answers `null` | 404, no body |
56
+ * | a denial | 403, no body |
57
+ * | allowed | the route runs, `c.var.object` set |
58
+ *
59
+ * One per route: a second one throws a `TypeError`, since both would claim
60
+ * `c.var.object`. A parent object is checked through an arrow in the model.
61
+ *
62
+ * **A failure throws**, as everywhere: a store that cannot answer is
63
+ * `STORE_FAILED`, never a 403 — `janusErrors()` answers it 503.
64
+ */
65
+ export declare function permission<C extends ModelConfig, const T extends ObjectTypeOf<C>, const P extends CheckableOf<C, T>, O extends ObjectData<C, T>>(access: Pick<Permissions<C>, 'can'>, permission: P, type: T, load: (c: Context) => Awaitable<O | null>, ...options: OptionsArgs<C, T, P, O>): MiddlewareHandler<{
66
+ Variables: {
67
+ object: O;
68
+ };
69
+ }>;
70
+ /**
71
+ * A `load` reading one path parameter: `find(id)`, or `null` — a 404 — when
72
+ * the route has no such parameter.
73
+ *
74
+ * ```ts
75
+ * permission(access, 'view', 'record', byParam('id', (id) => records.find(id)))
76
+ * ```
77
+ */
78
+ export declare function byParam<O>(name: string, find: (id: string) => Awaitable<O | null>): (c: Context) => Awaitable<O | null>;
79
+ export {};
80
+ //# sourceMappingURL=permission.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"permission.d.ts","sourceRoot":"","sources":["../src/permission.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,19 @@
1
+ import type { MiddlewareHandler } from 'hono';
2
+ /** What `provide()` takes: the `janus()` instance, the `permissions()` one, or both. */
3
+ export interface Instances {
4
+ readonly auth?: object;
5
+ readonly access?: object;
6
+ }
7
+ /**
8
+ * The middleware that puts the instances on the context — `c.var.auth`, and
9
+ * `c.var.access` — so a route writes users and tuples through them rather
10
+ * than through a module import: `c.var.access.grant(record, 'owner', user)`.
11
+ *
12
+ * Only what is passed is set, and typed.
13
+ */
14
+ export declare function provide<const I extends Instances>(instances: I): MiddlewareHandler<{
15
+ Variables: {
16
+ [K in keyof I & keyof Instances]: I[K];
17
+ };
18
+ }>;
19
+ //# sourceMappingURL=provide.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"provide.d.ts","sourceRoot":"","sources":["../src/provide.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AAE9C,wFAAwF;AACxF,MAAM,WAAW,SAAS;IACzB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;GAMG;AACH,wBAAgB,OAAO,CAAC,KAAK,CAAC,CAAC,SAAS,SAAS,EAChD,SAAS,EAAE,CAAC,GACV,iBAAiB,CAAC;IAEpB,SAAS,EAAE;SAAG,CAAC,IAAI,MAAM,CAAC,GAAG,MAAM,SAAS,GAAG,CAAC,CAAC,CAAC,CAAC;KAAE,CAAC;CACtD,CAAC,CAMD"}
@@ -0,0 +1,91 @@
1
+ import type { Session, SharedApi } from '@nxgt/janus';
2
+ import type { Context, MiddlewareHandler } from 'hono';
3
+ /** Anything `janus()` answered: the part of it this package calls. */
4
+ export type Auth<U extends {
5
+ readonly type: string;
6
+ }> = Pick<SharedApi<U>, 'authenticate' | 'signOut' | 'cookie'>;
7
+ /** The users an `auth` instance knows, as a union narrowed by `user.type`. */
8
+ export type UserOfAuth<A> = A extends Auth<infer U> ? U : never;
9
+ /**
10
+ * What `session()` sets on the context: `c.var.user` and `c.var.session`,
11
+ * both `null` for an anonymous request unless the route requires a user.
12
+ *
13
+ * `new Hono<SessionEnv<typeof auth>>()` types every route of an app that
14
+ * wires `app.use(session(auth))` once.
15
+ */
16
+ export type SessionEnv<A, T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type'], Required extends boolean = false> = {
17
+ Variables: Required extends true ? {
18
+ user: Extract<UserOfAuth<A>, {
19
+ readonly type: T;
20
+ }>;
21
+ session: Session;
22
+ } : {
23
+ user: Extract<UserOfAuth<A>, {
24
+ readonly type: T;
25
+ }> | null;
26
+ session: Session | null;
27
+ };
28
+ };
29
+ export interface SessionOptions<T extends string> {
30
+ /** Only a user of this type is authenticated here; any other is anonymous. */
31
+ readonly type?: T;
32
+ /**
33
+ * `true`: an anonymous request is answered 401, with no body, and the
34
+ * route never runs — so `c.var.user` is never `null` in it. Only a literal
35
+ * `true` types it so: a computed `boolean` leaves `c.var.user` nullable.
36
+ */
37
+ readonly required?: boolean;
38
+ }
39
+ /**
40
+ * The middleware that reads who a request belongs to — `auth.authenticate`
41
+ * on the request — and sets `c.var.user` and `c.var.session`.
42
+ *
43
+ * **An outage is not anonymous**: a store that cannot answer rejects with
44
+ * `STORE_FAILED`, and this middleware lets it through to `app.onError` —
45
+ * `janusErrors()` answers it 503, never 401.
46
+ *
47
+ * A session renewed in passing is sent again as a cookie, after the route
48
+ * ran — but only to a request that presented it as one: a client that sends
49
+ * `Authorization: Bearer` is not handed a cookie it never asked for. And
50
+ * never over a session cookie the route set itself — `sendSession` after a
51
+ * sign-in, `signOut`'s clearing — which a browser would otherwise overwrite.
52
+ */
53
+ export declare function session<A extends Auth<{
54
+ readonly type: string;
55
+ }>, const T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type']>(auth: A, options: SessionOptions<T> & {
56
+ readonly required: true;
57
+ }): MiddlewareHandler<SessionEnv<A, T, true>>;
58
+ export declare function session<A extends Auth<{
59
+ readonly type: string;
60
+ }>, const T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type']>(auth: A, options?: SessionOptions<T> & {
61
+ readonly required?: false;
62
+ }): MiddlewareHandler<SessionEnv<A, T>>;
63
+ export declare function session<A extends Auth<{
64
+ readonly type: string;
65
+ }>, const T extends UserOfAuth<A>['type'] = UserOfAuth<A>['type']>(auth: A, options: SessionOptions<T>): MiddlewareHandler<SessionEnv<A, T>>;
66
+ /**
67
+ * Sends the session cookie — after `signUp`, `signIn`, or anything else that
68
+ * answered a session token and its session — and answers the user. Appends: a
69
+ * cookie set before stays.
70
+ *
71
+ * ```ts
72
+ * const user = sendSession(c, auth, await auth.signIn({ email, password }));
73
+ * return c.json({ id: user.id }); // the token is in the cookie, never in the body
74
+ * ```
75
+ */
76
+ export declare function sendSession<U>(c: Context, auth: Pick<Auth<{
77
+ readonly type: string;
78
+ }>, 'cookie'>, signedIn: {
79
+ readonly token: string;
80
+ readonly session: Session;
81
+ readonly user: U;
82
+ }): U;
83
+ /**
84
+ * Revokes the session the request presents and clears the cookie — cleared
85
+ * whatever the answer, so a browser holding a stale cookie drops it too.
86
+ * `false` when the request presented no session, or an unknown one.
87
+ */
88
+ export declare function signOut(c: Context, auth: Pick<Auth<{
89
+ readonly type: string;
90
+ }>, 'signOut' | 'cookie'>): Promise<boolean>;
91
+ //# sourceMappingURL=session.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"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"}
package/docs/README.md ADDED
@@ -0,0 +1,15 @@
1
+ # @nxgt/janus-hono — documentation
2
+
3
+ The [README](../README.md) shows the whole wiring in one example; these pages
4
+ give the detail.
5
+
6
+ | Page | Read it when |
7
+ | --- | --- |
8
+ | [The routes of an application](guide/routes.md) | You are writing sign-up, sign-in, sign-out and the e-mail flows as Hono routes, with one user type or several |
9
+ | [Guarded routes and writing tuples](guide/permissions.md) | You are guarding a route with a permission, passing a condition's context, or granting and revoking from a route |
10
+ | [Troubleshooting](troubleshooting.md) | A route answers something you did not expect, or the compiler refuses one |
11
+ | [Roadmap](roadmap.md) | You want to know what is coming, what shipped, and what is deliberately not planned |
12
+
13
+ The words — user, session, object, permission, failure, denial — are
14
+ [`@nxgt/janus`'s vocabulary](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/vocabulary.md),
15
+ and mean the same here.
@@ -0,0 +1,225 @@
1
+ # Guarded routes and writing tuples
2
+
3
+ The permissions side of `@nxgt/janus` in Hono routes. A **guarded route** runs
4
+ only when its subject holds a permission on the object it serves:
5
+ `permission()` makes it one. `provide()` puts the instances on the context, for
6
+ the routes that grant and revoke. The model, `can` and the tuples are
7
+ [`@nxgt/janus`'s permissions guide](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/guide/permissions.md);
8
+ this page is only about the routes.
9
+
10
+ The examples use this model, and one loader for its records:
11
+
12
+ ```ts
13
+ import { byParam } from '@nxgt/janus-hono';
14
+ import { defineModel, fromField, permissions, when } from '@nxgt/janus/permissions';
15
+
16
+ const model = defineModel({
17
+ subjects: auth.types, // 'patient', 'staff'
18
+ types: {
19
+ record: {
20
+ relations: {
21
+ owner: ['patient'],
22
+ // Read from the record itself; the lookup lets list() find a doctor's records.
23
+ doctor: fromField('doctorId', 'staff', {
24
+ lookup: (staffId) => records.idsByDoctor(staffId),
25
+ }),
26
+ },
27
+ permissions: {
28
+ view: ['owner', 'doctor'],
29
+ edit: [when('owner', (ctx: { locked: boolean }) => !ctx.locked)],
30
+ },
31
+ },
32
+ },
33
+ });
34
+
35
+ export const access = permissions({ model, store: relations });
36
+
37
+ /** The record a route's `:id` names; `null`, a 404, when there is none. */
38
+ export const recordOf = byParam('id', (id) => records.find(id)); // records: your own store
39
+ ```
40
+
41
+ `byParam(name, find)` is a `load`: it reads one path parameter and answers
42
+ `find(id)`. Written by hand, it is
43
+
44
+ ```ts
45
+ import type { Context } from 'hono';
46
+
47
+ const recordOfByHand = (c: Context) => {
48
+ const id = c.req.param('id');
49
+ return id === undefined ? null : records.find(id);
50
+ };
51
+ ```
52
+
53
+ A loader takes a plain `Context` because it is written apart from any route,
54
+ and `permission()` is too: Hono types a path's parameters only in a handler
55
+ written inline on that route. So `c.req.param('id')` is `string | undefined`
56
+ in a loader, and the missing case is answered `null`. Pass `find` as an arrow
57
+ — `(id) => records.find(id)` — when it is a method that reads `this`.
58
+
59
+ ## A guarded route
60
+
61
+ ```ts
62
+ import { permission, session } from '@nxgt/janus-hono';
63
+
64
+ app.get(
65
+ '/records/:id',
66
+ session(auth),
67
+ permission(access, 'view', 'record', recordOf),
68
+ (c) => c.json(c.var.object),
69
+ );
70
+ ```
71
+
72
+ `permission(access, permission, type, load)` — the order of `list(subject,
73
+ permission, type)` — runs in this order:
74
+
75
+ | Step | When it stops | Answered |
76
+ | --- | --- | --- |
77
+ | 1. The subject: `c.var.user`, set by `session()` | anonymous | 401, no body — nothing is loaded |
78
+ | 2. `load(c)` | it answers `null` | 404, no body |
79
+ | 3. `access.can(subject, permission, object)`, the object seen with `type` added | `false` | 403, no body |
80
+ | 4. `c.set('object', object)`, then the route | — | the route's answer |
81
+
82
+ A relation store that cannot answer at step 3 **throws** `STORE_FAILED`, and
83
+ `janusErrors()` answers it 503 — never 403: an outage does not deny anybody.
84
+ Whatever `load` throws reaches `app.onError` too, and goes to
85
+ `janusErrors({ fallback })`.
86
+
87
+ ### What `load` answers
88
+
89
+ The object as your application keeps it: its `id`, **every field a
90
+ `fromField` of its type reads** — `doctorId` here — and whatever else it
91
+ carries. The route gets exactly that, with its own type: `c.var.object` is
92
+ your record, not a reference to it, and the route does not load it again.
93
+
94
+ `can()` sees the object with `type` set to the object type named in
95
+ `permission()` — a `type` field of your own is not read by the check, and
96
+ stays in `c.var.object`. Every other field is read from the object itself, so
97
+ a class instance's getters and an ORM document's accessors answer as they do
98
+ in the route.
99
+
100
+ An object missing a `fromField`'s field is a compile error. At run time it
101
+ would be a `TypeError` from `can()`, never a denial. A field that holds nobody
102
+ is `null`.
103
+
104
+ ### One per route
105
+
106
+ `permission()` sets `c.var.object`, so a route has one. A second throws a
107
+ `TypeError` at the first request rather than type `c.var.object` as both
108
+ objects at once. A parent — the folder of a record — is checked through an
109
+ arrow in the model: `view: ['owner', 'folder->view']`.
110
+
111
+ ### A condition's context
112
+
113
+ A permission that reaches a `when()` needs its `ctx`, and `permission()`
114
+ requires the option exactly then, typed from the condition — through arrows
115
+ too. It is a function of the request and the loaded object:
116
+
117
+ ```ts
118
+ app.put(
119
+ '/records/:id',
120
+ session(auth),
121
+ permission(access, 'edit', 'record', recordOf, {
122
+ ctx: (c, record) => ({ locked: record.locked }),
123
+ }),
124
+ async (c) => c.json(await records.update(c.var.object.id, await c.req.json())),
125
+ );
126
+ ```
127
+
128
+ ### Another subject
129
+
130
+ `c.var.user` is the subject by default. Pass `subject` when your users are not
131
+ `janus()`'s — the permissions side used alone — or when the subject is not the
132
+ signed-in user:
133
+
134
+ ```ts
135
+ permission(access, 'view', 'record', recordOf, {
136
+ subject: (c) => subjectOfApiKey(c.req.header('x-api-key')), // { type, id } or null
137
+ });
138
+ ```
139
+
140
+ `null` is anonymous, and answered 401 before anything is loaded. Without
141
+ `session()` before it and without `subject`, `permission()` throws a
142
+ `TypeError` at the first request: a wiring error, never an anonymous 401.
143
+
144
+ ### Hiding what exists
145
+
146
+ A 403 tells the caller the object exists. Where that is a leak, make `load`
147
+ answer `null` for what the user may not see — ask `can()` there, so a record
148
+ they may not view is a 404 like one that does not exist:
149
+
150
+ ```ts
151
+ permission(access, 'view', 'record', async (c) => {
152
+ const record = await recordOf(c);
153
+ return record !== null &&
154
+ (await access.can(c.get('user'), 'view', { ...record, type: 'record' }))
155
+ ? record
156
+ : null;
157
+ });
158
+ ```
159
+
160
+ ## A list route
161
+
162
+ `permission()` guards one object. For the objects a user may see, ask
163
+ `list()` — through `provide()` — and load what it answers:
164
+
165
+ ```ts
166
+ app.get(
167
+ '/records',
168
+ session(auth, { required: true }),
169
+ provide({ access }),
170
+ async (c) => {
171
+ const page = await c.var.access.list(c.var.user, 'view', 'record', {
172
+ after: c.req.query('after') ?? null,
173
+ });
174
+ return c.json({
175
+ records: await records.findMany(page.items),
176
+ nextCursor: page.nextCursor,
177
+ });
178
+ },
179
+ );
180
+ ```
181
+
182
+ `list()` reverses every rule of `view`, so `doctor` needs its `lookup`:
183
+ without one, this call does not compile. When a user only ever sees what
184
+ names them — a patient's own records — a query filtered on `c.var.user.id`
185
+ is shorter, and needs no `list()`.
186
+
187
+ ## Writing tuples from a route
188
+
189
+ `provide({ auth, access })` sets `c.var.auth` and `c.var.access` to the
190
+ instances — only those given, and typed — so a route writes through the
191
+ context rather than a module import, and a test can hand those routes other
192
+ instances. `permission()` takes its instance as an argument.
193
+
194
+ ```ts
195
+ import { permission, provide, session } from '@nxgt/janus-hono';
196
+
197
+ const app = new Hono().use(session(auth), provide({ auth, access }));
198
+
199
+ app.post('/records', session(auth, { type: 'patient', required: true }), async (c) => {
200
+ const record = await records.create(await c.req.json());
201
+ await c.var.access.grant({ type: 'record', id: record.id }, 'owner', c.var.user);
202
+ return c.json(record, 201);
203
+ });
204
+
205
+ app.delete(
206
+ '/records/:id/owner',
207
+ session(auth, { type: 'patient', required: true }),
208
+ permission(access, 'edit', 'record', recordOf, {
209
+ ctx: (c, record) => ({ locked: record.locked }),
210
+ }),
211
+ async (c) => {
212
+ await c.var.access.revoke({ type: 'record', id: c.var.object.id }, 'owner', c.var.user);
213
+ return c.body(null, 204);
214
+ },
215
+ );
216
+ ```
217
+
218
+ `grant` and `revoke` are typed from the model as everywhere: a relation read
219
+ from a field — `doctor` — cannot be granted, and a subject the relation does
220
+ not admit is a compile error. Both are idempotent.
221
+
222
+ ## See also
223
+
224
+ - [The routes of an application](routes.md) — `session()`, the cookie, the statuses
225
+ - [Troubleshooting](../troubleshooting.md)