@alxia/janus 0.3.8 → 0.3.10

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/README.md CHANGED
@@ -104,6 +104,7 @@ before `createI18n()`, still answers it.
104
104
  | 403 | `USER_INACTIVE`, `STEP_UP_REQUIRED` |
105
105
  | 404 | `NOT_FOUND` |
106
106
  | 409 | `LOGIN_TAKEN`, `VERSION_CONFLICT`, `SECOND_FACTOR_*` |
107
+ | 429 | `MAIL_THROTTLED` (`@nxgt/janus` 0.18+), with `retryAfter` and `Retry-After` |
107
108
  | 500 | `PERMISSION_DEPTH` |
108
109
  | 501 | `UNSUPPORTED` |
109
110
  | 503 | `STORE_FAILED` |
package/dist/errors.d.ts CHANGED
@@ -36,7 +36,7 @@ export type JanusErrors = Middleware<object, Promise<Next | Reply<JanusErrorStat
36
36
  * refused, a login taken, a store down — is answered with janus's status
37
37
  * and `bodyOf(error)` — under `alxia({ errors: 'problem' })`, a problem
38
38
  * whose extensions are that body — typed on the routes declared after it. A throttled
39
- * sign-in carries `Retry-After`. Anything else goes on, thrown, to the
39
+ * sign-in or e-mail (`MAIL_THROTTLED`) carries `Retry-After`. Anything else goes on, thrown, to the
40
40
  * middlewares before it, or to the route boundary's 500.
41
41
  *
42
42
  * Give it to `use` before `session()`, so that a store down while the
@@ -1 +1 @@
1
- {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,KAAK,WAAW,EAGhB,KAAK,UAAU,EAEf,KAAK,IAAI,EACT,KAAK,OAAO,EAGZ,KAAK,KAAK,EACV,MAAM,aAAa,CAAC;AACrB,OAAO,EACN,UAAU,EACV,KAAK,cAAc,EACnB,KAAK,gBAAgB,EACrB,QAAQ,EACR,MAAM,aAAa,CAAC;AAErB;;;;GAIG;AACH,MAAM,WAAW,cAAc;IAC9B,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;IAC5B,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED,wBAAgB,MAAM,CAAC,KAAK,EAAE,UAAU,GAAG,cAAc,CAmBxD;AAED,MAAM,WAAW,kBAAkB;IAClC;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,GAAG,EAAE,WAAW,KAAK,OAAO,CAAC;CACnE;AAED;;;;GAIG;AACH,MAAM,MAAM,iBAAiB,GAAG,OAAO,CAAC,gBAAgB,EAAE,cAAc,CAAC,CAAC;AAE1E,yFAAyF;AACzF,MAAM,MAAM,WAAW,GAAG,UAAU,CACnC,MAAM,EACN,OAAO,CAAC,IAAI,GAAG,KAAK,CAAC,gBAAgB,EAAE,cAAc,GAAG,iBAAiB,CAAC,CAAC,CAC3E,CAAC;AAEF;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,WAAW,CAAC,OAAO,GAAE,kBAAuB,GAAG,WAAW,CASzE;AA2BD,OAAO,EAAE,QAAQ,EAAE,CAAC"}
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,KAAK,WAAW,EAGhB,KAAK,UAAU,EAEf,KAAK,IAAI,EACT,KAAK,OAAO,EAGZ,KAAK,KAAK,EACV,MAAM,aAAa,CAAC;AACrB,OAAO,EACN,UAAU,EACV,KAAK,cAAc,EACnB,KAAK,gBAAgB,EACrB,QAAQ,EACR,MAAM,aAAa,CAAC;AAErB;;;;GAIG;AACH,MAAM,WAAW,cAAc;IAC9B,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;IAC5B,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED,wBAAgB,MAAM,CAAC,KAAK,EAAE,UAAU,GAAG,cAAc,CA0BxD;AAED,MAAM,WAAW,kBAAkB;IAClC;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,GAAG,EAAE,WAAW,KAAK,OAAO,CAAC;CACnE;AAED;;;;GAIG;AACH,MAAM,MAAM,iBAAiB,GAAG,OAAO,CAAC,gBAAgB,EAAE,cAAc,CAAC,CAAC;AAE1E,yFAAyF;AACzF,MAAM,MAAM,WAAW,GAAG,UAAU,CACnC,MAAM,EACN,OAAO,CAAC,IAAI,GAAG,KAAK,CAAC,gBAAgB,EAAE,cAAc,GAAG,iBAAiB,CAAC,CAAC,CAC3E,CAAC;AAEF;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,WAAW,CAAC,OAAO,GAAE,kBAAuB,GAAG,WAAW,CASzE;AA2BD,OAAO,EAAE,QAAQ,EAAE,CAAC"}
package/dist/index.js CHANGED
@@ -29,6 +29,9 @@ import {
29
29
  statusOf
30
30
  } from "@nxgt/janus";
31
31
  function bodyOf(error) {
32
+ if (error.code === "MAIL_THROTTLED") {
33
+ return error.retryAfter === undefined ? { code: error.code } : { code: error.code, retryAfter: error.retryAfter };
34
+ }
32
35
  switch (error.code) {
33
36
  case "USER_INVALID":
34
37
  return { code: error.code, issues: error.issues ?? [] };
@@ -220,5 +223,5 @@ export {
220
223
  statusOf
221
224
  };
222
225
 
223
- //# debugId=E839E5A61B58A76164756E2164756E21
226
+ //# debugId=CC7FB948F0D1EAE664756E2164756E21
224
227
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -3,14 +3,14 @@
3
3
  "sources": ["../src/device.ts", "../src/errors.ts", "../src/permission.ts", "../src/refused.ts", "../src/send.ts", "../src/session.ts", "../src/lookup.ts"],
4
4
  "sourcesContent": [
5
5
  "import type { ResponseSettings } from '@alxia/core';\n\n/** `@nxgt/janus-hono`'s name for it, so a device remembered by one is by the other. */\nexport const DEVICE_COOKIE = 'janus-device';\n\nconst MAX_AGE = 400 * 24 * 60 * 60;\n\nexport interface DeviceCookieOptions {\n\treadonly name?: string;\n\treadonly domain?: string;\n\treadonly path?: string;\n\treadonly sameSite?: 'lax' | 'strict' | 'none';\n\treadonly secure?: boolean;\n\t/** Seconds. Four hundred days, the most a browser keeps, by default. */\n\treadonly maxAge?: number;\n}\n\n/** The device token the request carries, for `signIn(…, { device })`: `null` when none. */\nexport function deviceOf(\n\tctx: { readonly request: Request },\n\toptions: Pick<DeviceCookieOptions, 'name'> = {},\n): string | null {\n\tconst cookies = new Bun.CookieMap(ctx.request.headers.get('cookie') ?? '');\n\tconst value = cookies.get(options.name ?? DEVICE_COOKIE);\n\treturn value === null || value === '' ? null : value;\n}\n\n/** Sets the device cookie, or sets it again so it lasts another `maxAge`. */\nexport function sendDevice(\n\tctx: { readonly set: ResponseSettings },\n\ttoken: string,\n\toptions: DeviceCookieOptions = {},\n): void {\n\tctx.set.cookies.set(options.name ?? DEVICE_COOKIE, token, {\n\t\thttpOnly: true,\n\t\tpath: options.path ?? '/',\n\t\tsameSite: options.sameSite ?? 'lax',\n\t\tsecure: options.secure ?? true,\n\t\tmaxAge: options.maxAge ?? MAX_AGE,\n\t\t...(options.domain === undefined ? {} : { domain: options.domain }),\n\t});\n}\n",
6
- "import {\n\ttype BaseContext,\n\tdefineMiddleware,\n\terrorFormat,\n\ttype Middleware,\n\tmarkFactory,\n\ttype Next,\n\ttype Problem,\n\tproblem,\n\tproblemOf,\n\ttype Reply,\n} from '@alxia/core';\nimport {\n\tJanusError,\n\ttype JanusErrorCode,\n\ttype JanusErrorStatus,\n\tstatusOf,\n} from '@nxgt/janus';\n\n/**\n * The body a refusal is answered with: its `code`, and only what the client\n * can act on. Never `reason`, `login`, a hash prefix or a cause: those are\n * for your logs. `@nxgt/janus-hono`'s `bodyOf`, kept twice on purpose.\n */\nexport interface JanusErrorBody {\n\treadonly code: JanusErrorCode;\n\treadonly issues?: JanusError['issues'];\n\treadonly minLength?: number;\n\treadonly attemptsLeft?: number;\n\treadonly retryAfter?: number;\n}\n\nexport function bodyOf(error: JanusError): JanusErrorBody {\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\tcase 'CODE_INVALID':\n\t\t\treturn error.attemptsLeft === undefined\n\t\t\t\t? { code: error.code }\n\t\t\t\t: { code: error.code, attemptsLeft: error.attemptsLeft };\n\t\tcase 'CREDENTIALS_INVALID':\n\t\t\treturn error.retryAfter === undefined\n\t\t\t\t? { code: error.code }\n\t\t\t\t: { code: error.code, retryAfter: error.retryAfter };\n\t\tdefault:\n\t\t\treturn { code: error.code };\n\t}\n}\n\nexport interface JanusErrorsOptions {\n\t/**\n\t * Called with every `JanusError` answered 5xx — `STORE_FAILED` and the\n\t * like, the server's to fix — as it is answered: started, not awaited,\n\t * so a slow report never holds the response. It cannot change the\n\t * answer: one that throws or rejects is a warning.\n\t */\n\treadonly report?: (error: JanusError, ctx: BaseContext) => unknown;\n}\n\n/**\n * A `JanusError` answered under `alxia({ errors: 'problem' })`: an RFC\n * 9457 problem, its `code` and the members of `JanusErrorBody` its\n * extensions.\n */\nexport type JanusErrorProblem = Problem<JanusErrorStatus, JanusErrorBody>;\n\n/** What `janusErrors()` makes: a middleware that answers what janus throws behind it. */\nexport type JanusErrors = Middleware<\n\tobject,\n\tPromise<Next | Reply<JanusErrorStatus, JanusErrorBody | JanusErrorProblem>>\n>;\n\n/**\n * Janus's errors answered, as a middleware: every `JanusError` thrown\n * behind it — by `session()`, `permission()`, a route — a sign-in\n * refused, a login taken, a store down — is answered with janus's status\n * and `bodyOf(error)` — under `alxia({ errors: 'problem' })`, a problem\n * whose extensions are that body — typed on the routes declared after it. A throttled\n * sign-in carries `Retry-After`. Anything else goes on, thrown, to the\n * middlewares before it, or to the route boundary's 500.\n *\n * Give it to `use` before `session()`, so that a store down while the\n * session is read is answered too:\n *\n * ```ts\n * app.use(janusErrors(), session(accounts)).post('/sign-in', ...);\n * ```\n *\n * **`STORE_FAILED` is a 503**, never a 401 or a 404: an outage is not an\n * answer.\n */\nexport function janusErrors(options: JanusErrorsOptions = {}): JanusErrors {\n\treturn defineMiddleware(async function janusErrors(ctx, next) {\n\t\ttry {\n\t\t\treturn await next();\n\t\t} catch (error) {\n\t\t\tif (!(error instanceof JanusError)) throw error;\n\t\t\treturn answer(error, ctx, options);\n\t\t}\n\t});\n}\n\n/** A `JanusError`, answered: its status, its body, reported when a 5xx. */\nfunction answer(\n\terror: JanusError,\n\tctx: BaseContext,\n\toptions: JanusErrorsOptions,\n): Reply<JanusErrorStatus, JanusErrorBody | JanusErrorProblem> {\n\tconst status: JanusErrorStatus = statusOf(error.code);\n\tif (status >= 500 && options.report !== undefined) {\n\t\tPromise.resolve()\n\t\t\t.then(() => options.report?.(error, ctx))\n\t\t\t.catch((failure: unknown) =>\n\t\t\t\tprocess.emitWarning(`janusErrors(): report failed: ${String(failure)}`),\n\t\t\t);\n\t}\n\tconst body = bodyOf(error);\n\tconst init =\n\t\terror.retryAfter === undefined\n\t\t\t? {}\n\t\t\t: { headers: { 'retry-after': String(error.retryAfter) } };\n\tif (errorFormat(ctx) === 'problem') {\n\t\treturn problem(problemOf(ctx, { status, ...body }), init);\n\t}\n\treturn ctx.reply(status, body, init);\n}\n\nexport { statusOf };\n\nmarkFactory(janusErrors);\n",
6
+ "import {\n\ttype BaseContext,\n\tdefineMiddleware,\n\terrorFormat,\n\ttype Middleware,\n\tmarkFactory,\n\ttype Next,\n\ttype Problem,\n\tproblem,\n\tproblemOf,\n\ttype Reply,\n} from '@alxia/core';\nimport {\n\tJanusError,\n\ttype JanusErrorCode,\n\ttype JanusErrorStatus,\n\tstatusOf,\n} from '@nxgt/janus';\n\n/**\n * The body a refusal is answered with: its `code`, and only what the client\n * can act on. Never `reason`, `login`, a hash prefix or a cause: those are\n * for your logs. `@nxgt/janus-hono`'s `bodyOf`, kept twice on purpose.\n */\nexport interface JanusErrorBody {\n\treadonly code: JanusErrorCode;\n\treadonly issues?: JanusError['issues'];\n\treadonly minLength?: number;\n\treadonly attemptsLeft?: number;\n\treadonly retryAfter?: number;\n}\n\nexport function bodyOf(error: JanusError): JanusErrorBody {\n\t// `MAIL_THROTTLED` (429) exists from @nxgt/janus 0.18: compared as a\n\t// string so that the range still admits 0.17, where the code is not typed.\n\tif ((error.code as string) === 'MAIL_THROTTLED') {\n\t\treturn error.retryAfter === undefined\n\t\t\t? { code: error.code }\n\t\t\t: { code: error.code, retryAfter: error.retryAfter };\n\t}\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\tcase 'CODE_INVALID':\n\t\t\treturn error.attemptsLeft === undefined\n\t\t\t\t? { code: error.code }\n\t\t\t\t: { code: error.code, attemptsLeft: error.attemptsLeft };\n\t\tcase 'CREDENTIALS_INVALID':\n\t\t\treturn error.retryAfter === undefined\n\t\t\t\t? { code: error.code }\n\t\t\t\t: { code: error.code, retryAfter: error.retryAfter };\n\t\tdefault:\n\t\t\treturn { code: error.code };\n\t}\n}\n\nexport interface JanusErrorsOptions {\n\t/**\n\t * Called with every `JanusError` answered 5xx — `STORE_FAILED` and the\n\t * like, the server's to fix — as it is answered: started, not awaited,\n\t * so a slow report never holds the response. It cannot change the\n\t * answer: one that throws or rejects is a warning.\n\t */\n\treadonly report?: (error: JanusError, ctx: BaseContext) => unknown;\n}\n\n/**\n * A `JanusError` answered under `alxia({ errors: 'problem' })`: an RFC\n * 9457 problem, its `code` and the members of `JanusErrorBody` its\n * extensions.\n */\nexport type JanusErrorProblem = Problem<JanusErrorStatus, JanusErrorBody>;\n\n/** What `janusErrors()` makes: a middleware that answers what janus throws behind it. */\nexport type JanusErrors = Middleware<\n\tobject,\n\tPromise<Next | Reply<JanusErrorStatus, JanusErrorBody | JanusErrorProblem>>\n>;\n\n/**\n * Janus's errors answered, as a middleware: every `JanusError` thrown\n * behind it — by `session()`, `permission()`, a route — a sign-in\n * refused, a login taken, a store down — is answered with janus's status\n * and `bodyOf(error)` — under `alxia({ errors: 'problem' })`, a problem\n * whose extensions are that body — typed on the routes declared after it. A throttled\n * sign-in or e-mail (`MAIL_THROTTLED`) carries `Retry-After`. Anything else goes on, thrown, to the\n * middlewares before it, or to the route boundary's 500.\n *\n * Give it to `use` before `session()`, so that a store down while the\n * session is read is answered too:\n *\n * ```ts\n * app.use(janusErrors(), session(accounts)).post('/sign-in', ...);\n * ```\n *\n * **`STORE_FAILED` is a 503**, never a 401 or a 404: an outage is not an\n * answer.\n */\nexport function janusErrors(options: JanusErrorsOptions = {}): JanusErrors {\n\treturn defineMiddleware(async function janusErrors(ctx, next) {\n\t\ttry {\n\t\t\treturn await next();\n\t\t} catch (error) {\n\t\t\tif (!(error instanceof JanusError)) throw error;\n\t\t\treturn answer(error, ctx, options);\n\t\t}\n\t});\n}\n\n/** A `JanusError`, answered: its status, its body, reported when a 5xx. */\nfunction answer(\n\terror: JanusError,\n\tctx: BaseContext,\n\toptions: JanusErrorsOptions,\n): Reply<JanusErrorStatus, JanusErrorBody | JanusErrorProblem> {\n\tconst status: JanusErrorStatus = statusOf(error.code);\n\tif (status >= 500 && options.report !== undefined) {\n\t\tPromise.resolve()\n\t\t\t.then(() => options.report?.(error, ctx))\n\t\t\t.catch((failure: unknown) =>\n\t\t\t\tprocess.emitWarning(`janusErrors(): report failed: ${String(failure)}`),\n\t\t\t);\n\t}\n\tconst body = bodyOf(error);\n\tconst init =\n\t\terror.retryAfter === undefined\n\t\t\t? {}\n\t\t\t: { headers: { 'retry-after': String(error.retryAfter) } };\n\tif (errorFormat(ctx) === 'problem') {\n\t\treturn problem(problemOf(ctx, { status, ...body }), init);\n\t}\n\treturn ctx.reply(status, body, init);\n}\n\nexport { statusOf };\n\nmarkFactory(janusErrors);\n",
7
7
  "import {\n\ttype BaseContext,\n\tdefineMiddleware,\n\tmarkFactory,\n\ttype Problem,\n\ttype RequiresOf,\n} from '@alxia/core';\nimport type {\n\tCheckableOf,\n\tModelConfig,\n\tObjectTypeOf,\n\tPermissions,\n\tSubjectRef,\n} from '@nxgt/janus/permissions';\nimport type { Awaitable, ObjectData, OptionsArgs } from './permission-options';\nimport { refused } from './refused';\n\n/** The body of each refusal. */\nexport interface PermissionRefusedBody {\n\treadonly error: 'unauthenticated' | 'not_found' | 'forbidden';\n}\n\n/** A refusal under `alxia({ errors: 'problem' })`: its 401, 404 or 403 as a problem. */\nexport type PermissionRefusedProblem = Problem<401 | 403 | 404>;\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 * A guard, as a middleware: the routes declared after it run only if the\n * subject holds `permission` on an object of `type`. It loads the object\n * once, checks it with `access.can`, and hands it to them as `object`.\n *\n * | the request | answered |\n * | --- | --- |\n * | anonymous | 401 — before the object is loaded |\n * | `load` answers `null` | 404 |\n * | a denial | 403 |\n * | allowed | the route runs, `object` set |\n *\n * Each refusal is typed on those routes. **A failure throws**: a store that\n * cannot answer is `STORE_FAILED`, never a 403 — `janusErrors()` answers\n * it 503. Scope it with `group`, so it guards only its routes — given to\n * the app's `use`, it would refuse a request no route matches too:\n *\n * ```ts\n * app.use(janusErrors(), session(accounts)).group('/records/:id', (records) =>\n * records.use(permission(access, 'view', 'record', byParam('id', findRecord)))\n * .get('/', ({ object, reply }) => reply(200, object)));\n * ```\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\tLoadCtx extends object = BaseContext,\n\tSubjectCtx extends object = BaseContext,\n\tCheckCtx extends object = BaseContext,\n>(\n\taccess: Pick<Permissions<C>, 'can'>,\n\tpermission: P,\n\ttype: T,\n\tload: (ctx: BaseContext & LoadCtx) => Awaitable<O | null>,\n\t...options: OptionsArgs<C, T, P, O, SubjectCtx, CheckCtx>\n) {\n\t// `app.use` has checked that the app gives what `load`, `subject` and `ctx` read.\n\tconst loadOf = load as (ctx: BaseContext) => Awaitable<O | null>;\n\tconst { subject, ctx: ctxOf } = (options[0] ?? {}) as {\n\t\treadonly subject?: (\n\t\t\tctx: BaseContext,\n\t\t) => Awaitable<SubjectRef<ModelConfig> | null>;\n\t\treadonly ctx?: (ctx: BaseContext, object: unknown) => Awaitable<unknown>;\n\t};\n\tconst can = access.can as LooseCan;\n\tconst refuse = <Status extends 401 | 403 | 404>(\n\t\tctx: BaseContext,\n\t\tstatus: Status,\n\t\terror: PermissionRefusedBody['error'],\n\t\tdetail: string,\n\t) => refused(ctx, status, { error } as PermissionRefusedBody, detail);\n\treturn defineMiddleware<\n\t\tRequiresOf<\n\t\t\tLoadCtx & SubjectCtx & CheckCtx,\n\t\t\tAnnotatedAny<LoadCtx, SubjectCtx>\n\t\t>\n\t>()(async function permissionGuard(ctx, next) {\n\t\tconst who = subject === undefined ? userOf(ctx) : await subject(ctx);\n\t\tif (who === null) {\n\t\t\treturn refuse(ctx, 401, 'unauthenticated', 'The request has no session');\n\t\t}\n\t\tconst object = await loadOf(ctx);\n\t\tif (object === null) {\n\t\t\treturn refuse(ctx, 404, 'not_found', `No ${type} is found here`);\n\t\t}\n\t\tconst allowed = await can(\n\t\t\twho,\n\t\t\tpermission,\n\t\t\tview(object, type),\n\t\t\tctxOf === undefined ? undefined : { ctx: await ctxOf(ctx, object) },\n\t\t);\n\t\tif (!allowed) {\n\t\t\treturn refuse(\n\t\t\t\tctx,\n\t\t\t\t403,\n\t\t\t\t'forbidden',\n\t\t\t\t`The ${permission} permission on this ${type} is not granted`,\n\t\t\t);\n\t\t}\n\t\treturn next({ object });\n\t});\n}\n\n/**\n * The callback to name when `RequiresOf` refuses one annotated `any`: `load`\n * or `subject` when it is that one, `ctx` otherwise. An `any` in any of the\n * three makes their intersection `any`, so `RequiresOf` cannot tell which;\n * a `Record<string, any>` passes the same test.\n */\ntype AnnotatedAny<LoadCtx, SubjectCtx> = [LoadCtx] extends [\n\t{ readonly '~any': 1 },\n]\n\t? 'load'\n\t: [SubjectCtx] extends [{ readonly '~any': 1 }]\n\t\t? 'subject'\n\t\t: 'ctx';\n\n/**\n * A `load` reading one path parameter, as it arrived: `find(id)`, or `null`\n * — a 404 — when the route has no such parameter.\n */\nexport function byParam<O>(\n\tname: string,\n\tfind: (id: string) => Awaitable<O | null>,\n): (ctx: BaseContext) => Awaitable<O | null> {\n\treturn (ctx) => {\n\t\tconst id = ctx.pathParams[name];\n\t\treturn id === undefined ? null : find(id);\n\t};\n}\n\n/** The object as `can()` reads it: `type` added, every other field read from the object itself. */\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/** The `user` `session()` derived — or a wiring error when nothing did. */\nfunction userOf(ctx: BaseContext): SubjectRef<ModelConfig> | null {\n\tconst user = (ctx as { readonly user?: unknown }).user;\n\tif (user === undefined) {\n\t\tthrow new TypeError(\n\t\t\t'permission(): no user in the context — use session(auth) before it, or pass { subject }',\n\t\t);\n\t}\n\treturn user as SubjectRef<ModelConfig> | null;\n}\n\nmarkFactory(permission);\n",
8
8
  "/**\n * A refusal of `session()` or `permission()`, in the format of the app\n * that serves the request: its `{ error }` body by default, an RFC 9457\n * problem under `alxia({ errors: 'problem' })`.\n */\nimport {\n\ttype BaseContext,\n\terrorFormat,\n\ttype Problem,\n\tproblem,\n\tproblemOf,\n\ttype Reply,\n} from '@alxia/core';\n\n/** `body` with `status`, or the problem of `status` saying `detail`. */\nexport function refused<Status extends 401 | 403 | 404, Body>(\n\tctx: BaseContext,\n\tstatus: Status,\n\tbody: Body,\n\tdetail: string,\n): Reply<Status, Body | Problem<Status>> {\n\tif (errorFormat(ctx) === 'problem') {\n\t\treturn problem(problemOf(ctx, { status, detail }));\n\t}\n\treturn ctx.reply(status, body);\n}\n",
9
9
  "import type { ResponseSettings } from '@alxia/core';\nimport { type DeviceCookieOptions, sendDevice } from './device';\nimport type { Auth, SessionOpened } from './types';\n\n/** What `sendSession` takes besides: the device cookie's options. */\nexport interface SendSessionOptions {\n\treadonly device?: DeviceCookieOptions;\n}\n\n/**\n * Sends the session cookie — after `signUp`, `signIn`, anything that\n * answered a token and its session — and answers the user. The token is in\n * the cookie, never in the body. With a `deviceToken`, the device cookie\n * too.\n *\n * ```ts\n * const signedIn = await auth.signIn(body, { device: deviceOf(ctx) });\n * return reply(200, { id: sendSession(ctx, auth, signedIn).id });\n * ```\n */\nexport function sendSession<U>(\n\tctx: { readonly set: ResponseSettings },\n\tauth: Pick<Auth<{ readonly type: string }>, 'cookie'>,\n\tsignedIn: SessionOpened<U>,\n\toptions: SendSessionOptions = {},\n): U {\n\tctx.set.headers.append(\n\t\t'set-cookie',\n\t\tauth.cookie.serialize(signedIn.token, signedIn.session),\n\t);\n\tif (typeof signedIn.deviceToken === 'string') {\n\t\tsendDevice(ctx, signedIn.deviceToken, options.device);\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 */\nexport async function signOut(\n\tctx: { readonly request: Request; readonly set: ResponseSettings },\n\tauth: Pick<Auth<{ readonly type: string }>, 'signOut' | 'cookie'>,\n): Promise<boolean> {\n\tconst revoked = await auth.signOut(ctx.request);\n\tctx.set.headers.append('set-cookie', auth.cookie.clear());\n\treturn revoked;\n}\n",
10
10
  "import {\n\tdefineMiddleware,\n\ttype Empty,\n\ttype Middleware,\n\tmarkFactory,\n\ttype Next,\n\ttype Problem,\n\ttype Reply,\n\tsettle,\n\twithHeaders,\n} from '@alxia/core';\nimport type { Session } from '@nxgt/janus';\nimport { type DeviceCookieOptions, deviceOf } from './device';\nimport { authenticateOnce, type Found } from './lookup';\nimport { refused } from './refused';\nimport { sendSession, signOut } from './send';\nimport type { Auth, RequestAuth, UserOfAuth } from './types';\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 and the route never runs,\n\t * so `user` is never `null` in it — and the 401 is in its type. A\n\t * `boolean` known only at runtime keeps the 401 in the type, and `user`\n\t * may be `null`.\n\t */\n\treadonly required?: boolean;\n\t/** The device cookie `auth.device` reads and `auth.send` sets. */\n\treadonly device?: DeviceCookieOptions;\n}\n\n/** The body of the 401 a required session answers. */\nexport interface UnauthenticatedBody {\n\treadonly error: 'unauthenticated';\n}\n\n/** The 401 a required session answers under `alxia({ errors: 'problem' })`. */\nexport type UnauthenticatedProblem = Problem<401>;\n\ntype UserOf<A, T> = Extract<UserOfAuth<A>, { readonly type: T }>;\n\n/** What a session gives the routes after it. */\ntype Given<User, S> = {\n\treadonly user: User;\n\treadonly session: S;\n\treadonly auth: RequestAuth;\n};\n\n/** What `session()` makes: a middleware that gives `Added`, or answers `Refused`. */\nexport type SessionMiddleware<\n\tAdded extends object,\n\tRefused = never,\n> = Middleware<Empty, Promise<Refused | Next<Added>>>;\n\n/**\n * Who a request belongs to, as a middleware: `auth.authenticate(request)`,\n * and the routes declared after it read `user` and `session` — typed by\n * the user schema, narrowed by `type`. Given to `app.use`, it runs on a\n * request no route matches too: a required session answers it 401 before\n * its 404.\n *\n * **An outage is not anonymous**: a store that cannot answer throws\n * `STORE_FAILED`, which `janusErrors()` — given to `use` before it —\n * answers 503, never 401.\n *\n * A session renewed in passing is sent again as a cookie, after the route —\n * only to a request that presented it as one (a `Bearer` client is never\n * handed a cookie), and never over a session cookie the route set itself.\n *\n * ```ts\n * app.use(janusErrors(), session(accounts, { required: true }))\n * .get('/me', ({ user, reply }) => reply.ok(user));\n * ```\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): SessionMiddleware<\n\tGiven<UserOf<A, T>, Session>,\n\tReply<401, UnauthenticatedBody | UnauthenticatedProblem>\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?: false },\n): SessionMiddleware<Given<UserOf<A, T> | null, Session | null>>;\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>,\n): SessionMiddleware<\n\tGiven<UserOf<A, T> | null, Session | null>,\n\tReply<401, UnauthenticatedBody | UnauthenticatedProblem>\n>;\nexport function session(\n\tauth: Auth<{ readonly type: string }>,\n\toptions: SessionOptions<string> = {},\n): unknown {\n\tconst unauthenticated: UnauthenticatedBody = { error: 'unauthenticated' };\n\treturn defineMiddleware(async function session(ctx, next) {\n\t\tconst { request } = ctx;\n\t\tconst found = await authenticateOnce(auth, request, options.type);\n\t\tif (found === null && options.required === true) {\n\t\t\treturn refused(ctx, 401, unauthenticated, 'The request has no session');\n\t\t}\n\t\tconst bound: RequestAuth = {\n\t\t\tdevice: deviceOf(ctx, options.device),\n\t\t\tsend: (signedIn) => sendSession(ctx, auth, signedIn, options),\n\t\t\tsignOut: () => signOut(ctx, auth),\n\t\t};\n\t\t// Settled: a refusal's 400, an error's answer, still carry the renewal.\n\t\tconst response = await settle(\n\t\t\tctx,\n\t\t\tnext({\n\t\t\t\tuser: found?.user ?? null,\n\t\t\t\tsession: found?.session ?? null,\n\t\t\t\tauth: bound,\n\t\t\t}),\n\t\t);\n\t\treturn found?.renewed === true\n\t\t\t? renewed(auth, request, found, response)\n\t\t\t: response;\n\t});\n}\n\n/**\n * A session renewed in passing, sent again as a cookie: only to a request\n * that presented it as one, and never over a cookie the route set itself —\n * a sign-in or a sign-out behind it must not be undone by the session it\n * replaced.\n */\nfunction renewed<R extends Response>(\n\tauth: Auth<{ readonly type: string }>,\n\trequest: Request,\n\tfound: NonNullable<Found>,\n\tresponse: R,\n): R {\n\tconst name = `${auth.cookie.name}=`;\n\tconst presented = new Bun.CookieMap(request.headers.get('cookie') ?? '').get(\n\t\tauth.cookie.name,\n\t);\n\tif (\n\t\tpresented !== found.token ||\n\t\tresponse.headers.getSetCookie().some((value) => value.startsWith(name))\n\t) {\n\t\treturn response;\n\t}\n\treturn withHeaders(response, (headers) =>\n\t\theaders.append(\n\t\t\t'set-cookie',\n\t\t\tauth.cookie.serialize(found.token, found.session),\n\t\t),\n\t) as R;\n}\n\nmarkFactory(session);\n",
11
11
  "import type { Authenticated } from '@nxgt/janus';\nimport type { Auth } from './types';\n\nexport type Found = Authenticated<{ readonly type: string }> | null;\n\n/**\n * Each request's lookups, by instance and by `type`: an open `session()`\n * and a required one behind it, of the same `type`, look the session up\n * once. They live as long as the `Request` object; a lookup that failed is\n * forgotten, so the next dispatch of that object asks again.\n */\nconst lookups = new WeakMap<\n\tobject,\n\tWeakMap<Request, Map<string, Promise<Found>>>\n>();\n\nexport function authenticateOnce(\n\tauth: Auth<{ readonly type: string }>,\n\trequest: Request,\n\ttype: string | undefined,\n): Promise<Found> {\n\tlet byRequest = lookups.get(auth);\n\tif (byRequest === undefined) {\n\t\tbyRequest = new WeakMap();\n\t\tlookups.set(auth, byRequest);\n\t}\n\tlet byType = byRequest.get(request);\n\tif (byType === undefined) {\n\t\tbyType = new Map();\n\t\tbyRequest.set(request, byType);\n\t}\n\tconst key = type ?? '';\n\tlet found = byType.get(key);\n\tif (found === undefined) {\n\t\tfound = auth.authenticate(\n\t\t\trequest,\n\t\t\ttype === undefined ? undefined : { type },\n\t\t);\n\t\tbyType.set(key, found);\n\t\tconst asked = found;\n\t\tasked.catch(() => {\n\t\t\tif (byType.get(key) === asked) byType.delete(key);\n\t\t});\n\t}\n\treturn found;\n}\n"
12
12
  ],
13
- "mappings": "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",
14
- "debugId": "E839E5A61B58A76164756E2164756E21",
13
+ "mappings": "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",
14
+ "debugId": "CC7FB948F0D1EAE664756E2164756E21",
15
15
  "names": []
16
16
  }
@@ -85,6 +85,7 @@ const app = alxia()
85
85
  | 403 | `USER_INACTIVE`, `STEP_UP_REQUIRED` |
86
86
  | 404 | `NOT_FOUND` |
87
87
  | 409 | `LOGIN_TAKEN`, `VERSION_CONFLICT`, `SECOND_FACTOR_NOT_ENROLLED`, `SECOND_FACTOR_ACTIVE` |
88
+ | 429 | `MAIL_THROTTLED` — too many e-mails asked for one address or user (`@nxgt/janus` 0.18 or later) |
88
89
  | 500 | `PERMISSION_DEPTH` — a permission check walked past `maxDepth`: not a denial |
89
90
  | 501 | `UNSUPPORTED` — a wiring mistake: a store lacks a method a flow needs |
90
91
  | 503 | `STORE_FAILED` — **an outage, never a 401 or a 404** |
@@ -108,6 +109,7 @@ your logs, and stay on the `JanusError`.
108
109
  | `PASSWORD_TOO_SHORT` | `{ code, minLength? }` |
109
110
  | `CODE_INVALID` | `{ code, attemptsLeft? }` |
110
111
  | `CREDENTIALS_INVALID` | `{ code, retryAfter? }` — seconds; also sent as `Retry-After` |
112
+ | `MAIL_THROTTLED` | `{ code, retryAfter? }` — seconds; also sent as `Retry-After` (needs `@nxgt/janus` 0.18 or later, which adds the code) |
111
113
  | every other code | `{ code }` |
112
114
 
113
115
  ```ts
@@ -121,7 +123,8 @@ bodyOf(new NotFoundError('user')); // { code: 'NOT_FOUND' }
121
123
  a login throttled for too many attempts: a client cannot tell a login exists
122
124
  from it. `retryAfter` is set only once `janus()`'s throttle trips — ten
123
125
  passwords in fifteen minutes by default — and the middleware then adds the
124
- `Retry-After` header.
126
+ `Retry-After` header. It does so for any refusal that carries `retryAfter`: a
127
+ throttled sign-in, and `MAIL_THROTTLED`.
125
128
 
126
129
  ## Reporting the 5xx
127
130
 
package/docs/roadmap.md CHANGED
@@ -7,17 +7,7 @@ number on it. Every release, with each change it made, is in
7
7
 
8
8
  ## Now
9
9
 
10
- - **Middlewares, not hooks.** `session()`, `permission()` and `janusErrors()`
11
- are middlewares given to `use`. `janusErrors()` is a try/catch: it answers
12
- the refusals thrown behind it, so it goes to `use` before `session()`. A
13
- required `session()` on the app answers an anonymous request to a missing
14
- path with its 401: scope it with a `group`. Lands in 0.4. Their deprecated
15
- plugin form, `app.plugin(session(…))`, was removed with alxia 0.5.
16
- - **Refusals as problems.** On an app with `@alxia/core`'s
17
- `alxia({ errors: 'problem' })`, `janusErrors()`, a required `session()`
18
- and `permission()` answer RFC 9457 problems — `janusErrors()`'s with its
19
- `code` as an extension — and keep their bodies otherwise
20
- ([guide](guide/errors.md#as-problems)).
10
+ Nothing in progress.
21
11
 
22
12
  ## Next
23
13
 
@@ -39,6 +29,20 @@ Nothing scheduled yet.
39
29
 
40
30
  ## Shipped
41
31
 
32
+ ### 0.3.0
33
+
34
+ - **Middlewares, not hooks.** `session()`, `permission()` and `janusErrors()`
35
+ are middlewares given to `use`. `janusErrors()` is a try/catch: it answers
36
+ the refusals thrown behind it, so it goes to `use` before `session()`. A
37
+ required `session()` on the app answers an anonymous request to a missing
38
+ path with its 401: scope it with a `group`. Lands in 0.4. Their deprecated
39
+ plugin form, `app.plugin(session(…))`, was removed with alxia 0.5.
40
+ - **Refusals as problems.** On an app with `@alxia/core`'s
41
+ `alxia({ errors: 'problem' })`, `janusErrors()`, a required `session()`
42
+ and `permission()` answer RFC 9457 problems — `janusErrors()`'s with its
43
+ `code` as an extension — and keep their bodies otherwise
44
+ ([guide](guide/errors.md#as-problems)).
45
+
42
46
  ### 0.1.0
43
47
 
44
48
  - **The user in the context.** `plugin(session(accounts))` hands the routes
@@ -36,6 +36,7 @@ symptom, under [Traps](#traps).
36
36
  - [`401 {"error":"unauthenticated"}` on a path that does not exist](#401-errorunauthenticated-on-a-path-that-does-not-exist)
37
37
  - [`logger()` or `secureHeaders()` never shows the reply of `janusErrors()`](#logger-or-secureheaders-never-shows-the-reply-of-januserrors)
38
38
  - [`401 {"code":"CREDENTIALS_INVALID","retryAfter":900}` with the right password](#401-codecredentials_invalidretryafter900-with-the-right-password)
39
+ - [`429 {"code":"MAIL_THROTTLED","retryAfter":600}`](#429-codemail_throttledretryafter600)
39
40
 
40
41
  **Traps**
41
42
 
@@ -553,6 +554,24 @@ next window, the right password included. Nothing is locked.
553
554
  **Fix:** wait `retryAfter` seconds, and show it to the user. The throttle is
554
555
  `janus({ signIn: { throttle } })`'s, in `@nxgt/janus`.
555
556
 
557
+ ### `429 {"code":"MAIL_THROTTLED","retryAfter":600}`
558
+
559
+ **When:** asking for too many e-mails for one address or one user: a magic
560
+ link, a sign-in code, a password reset, an address verification, a
561
+ change-of-address e-mail (`changeEmail.send`) or a step-up code. The
562
+ response carries `Retry-After: 600`.
563
+
564
+ **Why:** `@nxgt/janus` 0.18 and later throttles the requests that send
565
+ e-mail, on by default: past five in ten minutes for one address or user, it
566
+ refuses until the next window. The count is per flow, per address or user,
567
+ per window: five magic links and five reset e-mails count separately. The
568
+ last e-mail sent still works.
569
+
570
+ **Fix:** wait `retryAfter` seconds, which `Retry-After` carries too, and tell
571
+ the visitor to use the last e-mail they received or to wait. To tune it, pass
572
+ `janus({ mail: { throttle: { attempts, window } } })`, in `@nxgt/janus`;
573
+ `mail: { throttle: false }` turns it off. Needs `@nxgt/janus` 0.18 or later.
574
+
556
575
  ## Traps
557
576
 
558
577
  ### Signed in, and the next request is anonymous
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alxia/janus",
3
- "version": "0.3.8",
3
+ "version": "0.3.10",
4
4
  "description": "Identities and sessions for alxia on @nxgt/janus: the user typed in the context, the session cookie kept and renewed, a typed 401",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -41,17 +41,17 @@
41
41
  ]
42
42
  },
43
43
  "devDependencies": {
44
- "@alxia/core": "^0.13.0",
45
- "@alxia/i18n": "^0.3.8",
46
- "@alxia/language": "^0.3.8",
47
- "@nxgt/i18n": "^2.0.0",
48
- "@nxgt/janus": "^0.17.1",
44
+ "@alxia/core": "^0.14.2",
45
+ "@alxia/i18n": "^0.4.1",
46
+ "@alxia/language": "^0.3.9",
47
+ "@nxgt/i18n": "^3.1.0",
48
+ "@nxgt/janus": "^0.27.0",
49
49
  "@types/bun": "^1.4.2",
50
50
  "zod": "^4.6.5"
51
51
  },
52
52
  "peerDependencies": {
53
- "@alxia/core": "^0.13.0",
54
- "@nxgt/janus": "^0.17.1",
53
+ "@alxia/core": "^0.14.2",
54
+ "@nxgt/janus": ">=0.17.1 <0.28.0",
55
55
  "typescript": "^6.0.3 || ^7.0.0"
56
56
  }
57
57
  }