@alxia/janus

中文文档
0.4.2发布于 2026/11/07MITTypeScriptES Module零依赖近 30 天下载 1932

Identities and sessions for alxia on @softistx/janus: the user typed in the context, the session cookie kept and renewed, a typed 401

原始使用文档
文档目录8 节
  1. Usage
  2. session(accounts, options?)
  3. ctx.auth : signing in and out
  4. janusErrors(options?)
  5. permission(access, permission, type, load, options?)
  6. Reading the app's context
  7. API
  8. Documentation

@alxia/janus

Identities, sessions and permissions for alxia, on @softistx/janus: your process, your database. The user typed in the context, the session cookie kept and renewed, janus's refusals answered with their status and a safe body. No dependency.

bun add @alxia/janus @softistx/janus @alxia/core
bun add -d typescript

@softistx/janus is private, on GitHub Packages. Map its scope in your bunfig.toml and export a token that reads packages (read:packages):

[install.scopes]
"@softistx" = { url = "https://npm.pkg.github.com/", token = "$GITHUB_TOKEN" }

It mirrors @softistx/janus-hono: the same cookie, the same rules, the same bodies.

A BFF that calls a janus API, rather than running @softistx/janus itself, wants @alxia/react-router/session.

Usage

import { alxia, validate } from '@alxia/core';
import { janusErrors, session } from '@alxia/janus';
import { createMemoryStores, janus, scryptHasher } from '@softistx/janus';
import { z } from 'zod';

const accounts = janus({
    user: z.object({ email: z.email(), name: z.string() }),
    password: { login: 'email' },
    store: createMemoryStores(),       // your database's adapter in production
    hasher: scryptHasher(),
});

const SignIn = z.object({ email: z.string(), password: z.string() });

const app = alxia()
    .use(janusErrors(), session(accounts))                // errors first, then the session: anonymous may sign in
    .post('/signin', validate({ body: SignIn }), async ({ body, auth, reply }) => {
        const signedIn = await accounts.signIn(body);
        return reply.ok({ id: auth.send(signedIn).id });  // the token in the cookie
    })
    .post('/signout', async ({ auth, reply }) => reply.ok(await auth.signOut()))
    .use(session(accounts, { required: true }))           // the routes after it, and any unmatched request
    .get('/me', ({ user, reply }) => reply.ok(user));     // user typed by the schema

janusErrors() and session() are middlewares. janusErrors() answers what is thrown behind it, so give it to use before session().

session(accounts, options?)

The routes after it read user, session and auth. With required: true, an anonymous request is a 401 { error: 'unauthenticated' } and user is never null. On the app, a required session also runs on a request no route matches: an anonymous request to a missing path gets the 401, not the 404. Put it in a group to guard only some routes; without, it is null for an anonymous request. A boolean known only at run time types both: the 401, and a user that may be null. type narrows to one user type of a multi-type janus(); device names the device cookie.

The session is read from Authorization: Bearer, X-Session-Token, or the cookie. One renewed in passing is sent again as a cookie after the route — only to a request that presented it as one, and never over a session cookie the route set itself. An outage is not anonymous: a store that cannot answer is STORE_FAILED, answered 503 by janusErrors().

ctx.auth: signing in and out

Sign-in stays on your janus() instance — accounts.signIn, accounts.signUp, accounts.patient.signIn, a code, a link — and ctx.auth does the request's part, bound to it:

ctx.auth.
send(signedIn)sets the session cookie (and the device cookie, with a deviceToken) and returns the user: the token is never in a body
signOut()revokes the request's session and clears its cookie, whatever the answer
devicethe device cookie's value, or null, for signIn(…, { device: ctx.auth.device }) with janus({ devices })

Put the sign-in routes behind a session(accounts) that is not required: behind required: true, an anonymous request — anyone signing in — is a 401. Outside a route (a job, a test), sendSession(ctx, accounts, signedIn), signOut(ctx, accounts) and deviceOf(ctx) are the same functions, unbound.

janusErrors(options?)

A try/catch middleware: every JanusError thrown behind it — by session(), permission() or a route — is answered with janus's status and a body holding its code and only what a client can act on — the issues, a minimum length, attempts left, seconds to wait (with Retry-After). Never a login, a reason or a cause. report(error, ctx) is called for the 5xx. Anything else goes on, thrown, to the middlewares before it. On an app with alxia({ errors: 'problem' }), it answers an RFC 9457 problem instead, the code and the rest of that body its extensions; a required session()'s 401 and permission()'s 401, 404 and 403 follow the app's format too. Declared after session(), it would not see the session's STORE_FAILED: it goes first, and after the observers (logger(), secureHeaders()), so that they see its reply. An observer settles next() without swallowing the error, so janusErrors() before one, or before createI18n(), still answers it.

statuscodes
400USER_INVALID, PASSWORD_TOO_SHORT, TOKEN_*…
401CREDENTIALS_INVALID, CODE_INVALID
403USER_INACTIVE, STEP_UP_REQUIRED
404NOT_FOUND
409LOGIN_TAKEN, VERSION_CONFLICT, SECOND_FACTOR_*
429MAIL_THROTTLED (@softistx/janus 0.18+), with retryAfter and Retry-After
500PERMISSION_DEPTH
501UNSUPPORTED
503STORE_FAILED

permission(access, permission, type, load, options?)

A guard on @softistx/janus/permissions: the routes after it run only if the subject — the session's user, or options.subject(ctx) — holds permission on the object load finds, which they read as object. Anonymous is a 401, nothing loaded a 404, a denial a 403, each typed. Scope it with group:

import { alxia } from '@alxia/core';
import { byParam, janusErrors, permission, session } from '@alxia/janus';
import { createMemoryStores, janus, scryptHasher } from '@softistx/janus';
import { createMemoryRelations, defineModel, permissions } from '@softistx/janus/permissions';
import { z } from 'zod';

const relations = createMemoryRelations();
const accounts = janus({
    user: z.object({ email: z.email() }),
    password: { login: 'email' },
    store: createMemoryStores(),
    relations,
    hasher: scryptHasher(),
});
const access = permissions({
    model: defineModel({
        subjects: accounts.types,
        types: { record: { related: { owners: ['user'] }, permits: { view: ['owners'] } } },
    }),
    store: relations,
});

const records = new Map([['r1', { id: 'r1', title: 'Blood test' }]]);
const findRecord = (id: string) => records.get(id) ?? null;

const app = alxia()
    .use(janusErrors())
    .use(session(accounts))
    .group('/records/:id', (record) =>
        record
            .use(permission(access, 'view', 'record', byParam('id', findRecord)))
            .get('/', ({ object, reply }) => reply(200, object)), // object: what findRecord found
    );

byParam(name, find) loads by a path parameter. A permission whose condition needs a context takes ctx: (ctx, object) => …, required by the types exactly then.

Reading the app's context

Annotate load, subject or ctx's parameter to read what an earlier middleware added. The guard then requires it: an app that does not give it before the guard cannot use it.

import type { BaseContext } from '@alxia/core';

const byTenant = permission(
    access,
    'view',
    'record',
    ({ tenant, pathParams }: BaseContext & { tenant: { records: Map<string, { id: string; title: string }> } }) =>
        tenant.records.get(pathParams['id'] ?? '') ?? null,
);

alxia().plugin(tenancy).use(session(accounts)).use(byTenant); // tenancy derives tenant
alxia().use(session(accounts)).use(byTenant); // a compile error: this app gives no `tenant`

A callback annotated any would require nothing, so the guard is refused on every app: annotate what it reads, or leave it unannotated.

API

export
session(accounts, options?)the middleware: user, session, auth
SessionOptionsits options: type, required, device
RequestAuth, SessionOpenedthe type of ctx.auth: send, signOut, device; and what send takes: token, session and user from @softistx/janus's SignedIn, and deviceToken?
sendSession, signOut, deviceOf, sendDevicethe cookies, outside a route
DEVICE_COOKIEthe device cookie's name, janus-device: the one @softistx/janus-hono uses, so a device one remembers the other does too
SendSessionOptions, DeviceCookieOptionstheir options: device; name, domain, path, sameSite, secure, maxAge
janusErrors(options?)the middleware: janus's refusals answered; use it before session()
JanusErrors, SessionMiddlewarethe types of what janusErrors() and session() make
JanusErrorsOptionsits options: report, called with every error answered 5xx
permission(…), byParam(…)the guard
PermissionOptions, OptionsArgsits options: subject, ctx; and the rest of its arguments, the options required exactly when the permission has a condition; both take what subject and ctx read beyond BaseContext as their last two, defaulted, parameters
bodyOf, statusOfa refusal's body and status
UnauthenticatedBody, JanusErrorBody, PermissionRefusedBodytheir types
UnauthenticatedProblem, JanusErrorProblem, PermissionRefusedProblemthe same answers under @alxia/core's alxia({ errors: 'problem' }): RFC 9457 problems, janusErrors()'s with its code and body as extensions
Auth, UserOfAuth, ObjectData, Awaitablethe part of janus() this package calls, the users it knows, an object as the application loads it, a value or its promise

Documentation

  • Guide: a page per area — sessions, signing in and out with ctx.auth and the device cookie, janus's errors and their statuses, and the permission guard, reading what an earlier middleware added included.
  • Troubleshooting: an error message, or a request anonymous, refused or a 404 when it should not be, and what to do about it.
  • Roadmap: what is coming, and what is not planned.
  • Recipes: Authenticate requests, Answer errors consistently.

版本与发布记录

@alxia/janus 共发布 26 个版本,首次发布于 2026/10/04;过去 12 个月发布了 26 个版本。

版本发布日期距今
0.4.2latest2026/11/071 天前
0.4.12026/11/071 天前
0.4.02026/11/062 天前
0.3.182026/11/026 天前
0.3.172026/11/017 天前
0.3.162026/10/2910 天前

关于 @alxia/janus 的常见问题

@alxia/janus 是什么?

Identities and sessions for alxia on @softistx/janus: the user typed in the context, the session cookie kept and renewed, a typed 401

如何安装 @alxia/janus?

在项目目录运行 npm install @alxia/janus,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:typescript(^6.0.3 || ^7.0.0)、@alxia/core(^0.19.0)、@softistx/janus(>=0.38.1 <0.42.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@alxia/janus 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@alxia/janus 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@alxia/janus 可以商用吗?

@alxia/janus 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@alxia/janus 的下载量有多少?

根据 npm 官方下载统计,@alxia/janus 在 2026/10/06 – 2026/11/04 这 30 天内被下载 1,932 次,日均约 64 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@alxia/janus 还在维护吗?

最新版本 0.4.2 发布于 2026/11/07(1 天前);至今共发布 26 个版本,其中过去 12 个月发布了 26 个;首次发布于 2026/10/04。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。