@nxgt/janus 0.8.0 → 0.8.2
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 +2 -0
- package/dist/auth/config.d.ts +2 -2
- package/dist/index.js.map +1 -1
- package/docs/guide/events.md +3 -1
- package/docs/roadmap.md +5 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -602,6 +602,8 @@ await auth.signUp({ email, password }); // the listener has the event before thi
|
|
|
602
602
|
`JANUS_EVENT_FAILED` warning naming the event's type, its id and the user's
|
|
603
603
|
id, never the failure's message.
|
|
604
604
|
|
|
605
|
+
To post them as signed webhooks:
|
|
606
|
+
[`@nxgt/janus-webhooks`](https://www.npmjs.com/package/@nxgt/janus-webhooks).
|
|
605
607
|
[The user events guide](docs/guide/events.md) has the listener, the four
|
|
606
608
|
types, what a failure costs, and a test.
|
|
607
609
|
|
package/dist/auth/config.d.ts
CHANGED
|
@@ -149,8 +149,8 @@ interface SharedConfig {
|
|
|
149
149
|
/**
|
|
150
150
|
* Called with every user event — `user.created`, `user.emailVerified`,
|
|
151
151
|
* `user.passwordReset`, `user.deleted` — once the write landed,
|
|
152
|
-
* and awaited before the flow answers. Any function will do;
|
|
153
|
-
* `@nxgt/janus-webhooks`
|
|
152
|
+
* and awaited before the flow answers. Any function will do;
|
|
153
|
+
* `webhooks({ … })` from `@nxgt/janus-webhooks` signs and delivers them.
|
|
154
154
|
*/
|
|
155
155
|
readonly events?: UserEventListener;
|
|
156
156
|
}
|
package/dist/index.js.map
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"/**\n * Where `now` comes from.\n *\n * Injected rather than read from `Date` directly, for one practical reason:\n * every expiry in this package — sessions, one-time tokens, refresh windows —\n * is a comparison against the current time, and a spec that has to move the\n * real clock to test one is a spec that needs fake timers, leaks them, and\n * fails differently under load. A `Clock` makes those specs synchronous and\n * exact.\n *\n * It is also the seam an application needs if it ever has to reason about a\n * store's clock rather than its own: MongoDB stamps a TTL against the server's\n * time, not the process's.\n */\nexport interface Clock {\n\tnow(): Date;\n}\n\n/** The process's own clock. The default everywhere. */\nexport const systemClock: Clock = {\n\tnow: () => new Date(),\n};\n\n/**\n * A clock a spec drives by hand.\n *\n * Shipped rather than kept in `test/`: a consumer writing their own tests\n * against this package needs exactly this, and writing it again is how two\n * subtly different versions of \"advance time\" come to exist.\n */\nexport function fixedClock(start: Date | number = 0): Clock & {\n\tadvance(ms: number): void;\n\tset(at: Date | number): void;\n} {\n\tlet ms = start instanceof Date ? start.getTime() : start;\n\n\treturn {\n\t\tnow: () => new Date(ms),\n\t\tadvance: (by) => {\n\t\t\tms += by;\n\t\t},\n\t\tset: (at) => {\n\t\t\tms = at instanceof Date ? at.getTime() : at;\n\t\t},\n\t};\n}\n",
|
|
7
7
|
"/**\n * A span of time, written the way a configuration file writes one: `'15m'`,\n * `'720h'`, `'30d'`. A plain number is milliseconds.\n *\n * Strings rather than milliseconds everywhere, because `2592000000` in a config\n * object is a number nobody reads back correctly, and Ory's own configuration\n * uses exactly this notation — so a team moving across recognises it.\n */\nexport type Duration = number | `${number}${'ms' | 's' | 'm' | 'h' | 'd'}`;\n\nconst UNITS = {\n\tms: 1,\n\ts: 1_000,\n\tm: 60_000,\n\th: 3_600_000,\n\td: 86_400_000,\n} as const;\n\nconst PATTERN = /^(\\d+(?:\\.\\d+)?)(ms|s|m|h|d)$/;\n\n/**\n * The duration in milliseconds.\n *\n * `where` names the option the consumer wrote — `session.lifespan`, not\n * `parseDuration` — because this package has half a dozen durations and a\n * message that does not say which one leaves the reader to guess.\n *\n * A bare `TypeError`: durations are written when the application is wired, so\n * this cannot come from a request, and no handler should answer it. The refusal\n * reports the **shape** expected and not the value's own bytes beyond echoing\n * it, which is the rule this repository inherits.\n */\nexport function parseDuration(value: Duration, where: string): number {\n\tif (typeof value === 'number') {\n\t\tif (!Number.isFinite(value) || value <= 0) {\n\t\t\tthrow new TypeError(\n\t\t\t\t`${where}: a duration in milliseconds must be a finite number above zero`,\n\t\t\t);\n\t\t}\n\t\treturn value;\n\t}\n\n\tconst match = PATTERN.exec(value);\n\n\tif (!match) {\n\t\tthrow new TypeError(\n\t\t\t`${where}: \"${value}\" is not a duration; write a number followed by ms, s, m, h or d — for example \"15m\" or \"720h\"`,\n\t\t);\n\t}\n\n\tconst amount = Number(match[1]);\n\tconst unit = match[2] as keyof typeof UNITS;\n\tconst ms = amount * UNITS[unit];\n\n\tif (ms <= 0) {\n\t\tthrow new TypeError(`${where}: a duration must be above zero`);\n\t}\n\n\treturn ms;\n}\n",
|
|
8
8
|
"import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';\n\n/**\n * A second factor's secret, sealed before any store sees it: AES-256-GCM\n * under a key the application holds, so a dump of the users is not a dump of\n * everyone's authenticator.\n *\n * The sealed form is `v1.<key id>.<iv>.<ciphertext and tag>`, the last two in\n * base64url. It names its key, so keys rotate: the first key seals, every key\n * opens, and a secret sealed under an older one is sealed again under the\n * first the next time a code is accepted. The user's id is the additional\n * data, so a sealed secret copied onto another user does not open.\n */\n\n/** One key, as `janus({ secondFactor: { keys } })` takes it. */\nexport interface SealingKey {\n\t/** Names the key in every secret it seals: letters, digits, `_` and `-`. */\n\treadonly id: string;\n\t/** 32 random bytes, in base64 or base64url: `openssl rand -base64 32`. */\n\treadonly key: string;\n}\n\n/** The keys, decoded and checked: the first seals. */\nexport interface Sealer {\n\treadonly sealWith: string;\n\treadonly keys: ReadonlyMap<string, Buffer>;\n}\n\n/** GCM's tag, always the full 16 bytes: a shorter one is easier to forge. */\nconst TAG_BYTES = 16;\n\nconst KEY_ID = /^[A-Za-z0-9_-]{1,64}$/;\nconst KEY = /^[A-Za-z0-9+/_-]{43}=?$/;\n\n/** Decodes the keys and refuses what cannot seal. `where` names the option. */\nexport function resolveSealer(keys: unknown, where: string): Sealer {\n\tif (!Array.isArray(keys) || keys.length === 0) {\n\t\tthrow new TypeError(\n\t\t\t`${where}: expected at least one key — [{ id, key }], the first seals`,\n\t\t);\n\t}\n\tconst decoded = new Map<string, Buffer>();\n\tfor (const entry of keys as readonly SealingKey[]) {\n\t\tconst id = entry?.id;\n\t\tif (typeof id !== 'string' || !KEY_ID.test(id)) {\n\t\t\tthrow new TypeError(\n\t\t\t\t`${where}: every key needs an id of letters, digits, _ and -, at most 64 of them`,\n\t\t\t);\n\t\t}\n\t\tif (decoded.has(id)) {\n\t\t\tthrow new TypeError(`${where}: two keys have the id \"${id}\"`);\n\t\t}\n\t\tconst key = entry.key;\n\t\tif (typeof key !== 'string' || !KEY.test(key)) {\n\t\t\tthrow new TypeError(\n\t\t\t\t`${where}: the key \"${id}\" is not 32 bytes in base64 — make one with openssl rand -base64 32`,\n\t\t\t);\n\t\t}\n\t\tdecoded.set(id, Buffer.from(key, 'base64'));\n\t}\n\treturn { sealWith: (keys[0] as SealingKey).id, keys: decoded };\n}\n\n/** Seals `plain` under the first key, bound to `boundTo` — the user's id. */\nexport function seal(sealer: Sealer, plain: string, boundTo: string): string {\n\tconst key = sealer.keys.get(sealer.sealWith) as Buffer;\n\tconst iv = randomBytes(12);\n\tconst cipher = createCipheriv('aes-256-gcm', key, iv);\n\tcipher.setAAD(Buffer.from(boundTo));\n\tconst sealed = Buffer.concat([\n\t\tcipher.update(plain, 'utf8'),\n\t\tcipher.final(),\n\t\tcipher.getAuthTag(),\n\t]);\n\treturn `v1.${sealer.sealWith}.${iv.toString('base64url')}.${sealed.toString('base64url')}`;\n}\n\n/**\n * Opens a sealed secret, and says which key sealed it.\n *\n * Every failure is a **wiring** mistake, a bare `TypeError`: a key taken out\n * of `keys` while secrets it sealed are still stored, or a key changed under\n * the same id. None of them comes from a request.\n */\nexport function unseal(\n\tsealer: Sealer,\n\tsealed: string,\n\tboundTo: string,\n\twhere: string,\n): { readonly plain: string; readonly keyId: string } {\n\tconst parts = sealed.split('.');\n\tconst [version, keyId, iv, body] = parts;\n\tconst bytes = Buffer.from(body ?? '', 'base64url');\n\t// Exactly four parts, and a body longer than its 16-byte tag.\n\tif (\n\t\tparts.length !== 4 ||\n\t\tversion !== 'v1' ||\n\t\tkeyId === undefined ||\n\t\tiv === undefined ||\n\t\tbytes.length <= TAG_BYTES\n\t) {\n\t\tthrow new TypeError(`${where}: the stored secret is not a sealed one`);\n\t}\n\tconst key = sealer.keys.get(keyId);\n\tif (key === undefined) {\n\t\tthrow new TypeError(\n\t\t\t`${where}: the secret is sealed with the key \"${keyId}\", which secondFactor.keys no longer holds — keep a key until no secret is sealed with it`,\n\t\t);\n\t}\n\ttry {\n\t\tconst decipher = createDecipheriv(\n\t\t\t'aes-256-gcm',\n\t\t\tkey,\n\t\t\tBuffer.from(iv, 'base64url'),\n\t\t\t{ authTagLength: TAG_BYTES },\n\t\t);\n\t\tdecipher.setAAD(Buffer.from(boundTo));\n\t\tdecipher.setAuthTag(bytes.subarray(bytes.length - TAG_BYTES));\n\t\tconst plain = Buffer.concat([\n\t\t\tdecipher.update(bytes.subarray(0, bytes.length - TAG_BYTES)),\n\t\t\tdecipher.final(),\n\t\t]).toString('utf8');\n\t\treturn { plain, keyId };\n\t} catch (cause) {\n\t\tthrow new TypeError(\n\t\t\t`${where}: the secret does not open with the key \"${keyId}\" — was that key changed under the same id, or the secret copied from another user?`,\n\t\t\t{ cause },\n\t\t);\n\t}\n}\n",
|
|
9
|
-
"/**\n * What `janus()` accepts, and how it is resolved at run time.\n *\n * The types refuse first, on the offending key (`checks.ts`). What follows at\n * run time is the net for JavaScript callers, and every refusal is a bare\n * `TypeError`: a configuration is written when the application is wired, never\n * from a request.\n */\n\nimport type { RelationStore } from '../permissions/port/types';\nimport type { Clock } from '../time/clock';\nimport { type Duration, parseDuration } from '../time/duration';\nimport type { UserEventListener } from './events';\nimport type { JanusStores } from './port/types';\nimport { resolveSealer, type Sealer, type SealingKey } from './sealing';\nimport type { StandardSchemaV1 } from './standard-schema';\n\n/**\n * How a login is normalised before any store sees it. `'lowercaseTrim'` when\n * absent — right for an e-mail and for most usernames.\n *\n * A function is accepted for anything else. It must be deterministic: the same\n * rule normalises at sign-up and at sign-in.\n */\nexport type Normalize =\n\t| 'none'\n\t| 'lowercase'\n\t| 'lowercaseTrim'\n\t| 'nfkcLowercaseTrim'\n\t| ((value: string) => string);\n\n/**\n * What a user schema may produce: JSON, where an object property may also be\n * `undefined` — which is what every validator's optional field produces.\n *\n * The core drops an `undefined` property before a store sees the fields, so the\n * port stays strict JSON: an optional field left out is **absent** in the\n * store, and reads back absent, which the schema's output type already allows.\n */\nexport type FieldsJson =\n\t| string\n\t| number\n\t| boolean\n\t| null\n\t| readonly FieldsJson[]\n\t| { readonly [key: string]: FieldsJson | undefined };\n\n/**\n * A user type's schema: any Standard Schema — Zod 4, Valibot, ArkType — whose\n * output is an object of JSON. A `Date` round-trips through one store and not\n * the next, so a schema that produces one is refused at compile time.\n */\nexport type UserSchema = StandardSchemaV1<\n\tunknown,\n\t{ readonly [key: string]: FieldsJson | undefined }\n>;\n\n/**\n * A password hashing scheme — the second port.\n *\n * Hashes are self-describing, so **every wired hasher can verify, and exactly\n * one hashes** new passwords. A database written under Bun reads under Node and\n * back, as long as a verifier for each prefix is wired.\n */\nexport interface PasswordHasher {\n\t/** The prefix every hash it writes starts with: `'$argon2id$'`, `'$scrypt$'`. */\n\treadonly prefix: string;\n\thash(plain: string): Promise<string>;\n\tverify(plain: string, hash: string): Promise<boolean>;\n\t/**\n\t * Whether a hash **this hasher** wrote should be written again: its\n\t * parameters are not the ones `hash` uses now — a raised `cost`, say.\n\t * Optional; without it only a hash from another hasher (a `verifiers` one)\n\t * is rewritten. Called with hashes carrying this hasher's prefix only.\n\t */\n\tneedsRehash?(hash: string): boolean;\n}\n\n/** Signing in with a password. */\nexport interface PasswordConfig {\n\t/**\n\t * The field users sign in with: a **top-level, required string** field of\n\t * the schema — `'email'`, `'username'`. A typo is a compile error.\n\t */\n\treadonly login: string;\n\t/** `'lowercaseTrim'` when absent. */\n\treadonly normalize?: Normalize;\n\t/** At least 1. `8` when absent. */\n\treadonly minLength?: number;\n}\n\n/** How long a session lives, and when it is renewed. */\nexport interface SessionConfig {\n\t/** `'7d'` when absent. */\n\treadonly lifespan?: Duration;\n\t/**\n\t * `authenticate` renews a session once this much has passed since it was\n\t * opened or last renewed — a sliding session, written at most once per\n\t * period. `'1d'` when absent; `false` for a fixed lifespan.\n\t */\n\treadonly renewAfter?: Duration | false;\n}\n\n/** One user type: its schema, and how it signs in. */\nexport interface UserTypeConfig {\n\treadonly schema: UserSchema;\n\treadonly password?: PasswordConfig;\n\t/**\n\t * The field holding the user's e-mail, which `verifyEmail` and\n\t * `resetPassword` send to. `'email'` when absent — and when the schema has\n\t * no required string `email` either, those two flows do not exist on the\n\t * type.\n\t */\n\treadonly email?: string;\n\treadonly session?: SessionConfig;\n\t/**\n\t * Recorded on every user written, and read by nothing yet. Bump it when the\n\t * schema tightens, and the users validated against the old one can be found.\n\t * `'1'` when absent.\n\t */\n\treadonly schemaVersion?: string;\n}\n\n/** The session cookie. Every default is the strict one. */\nexport interface CookieConfig {\n\t/** `'janus-session'` when absent. A cookie-name token: no space, no `;`, no `=`. */\n\treadonly name?: string;\n\treadonly domain?: string;\n\t/** `'/'` when absent. */\n\treadonly path?: string;\n\t/** `'lax'` when absent. */\n\treadonly sameSite?: 'lax' | 'strict' | 'none';\n\t/** `true` when absent. `sameSite: 'none'` requires it. */\n\treadonly secure?: boolean;\n}\n\ninterface SharedConfig {\n\t/** The three stores: `createMemoryStores()`, or an adapter's. */\n\treadonly store: JanusStores;\n\t/**\n\t * The relation store `permissions()` is given, when the application has\n\t * one. Wired here, deleting a user deletes every tuple naming them too — as\n\t * a subject, and through no one else's memory of it.\n\t */\n\treadonly relations?: RelationStore;\n\t/**\n\t * Hashes new passwords. **Required as soon as a type signs in with a\n\t * password** — there is no silent fallback. `scryptHasher()` runs on Node and\n\t * Bun; `bunHasher()` is argon2id, on Bun only.\n\t */\n\treadonly hasher?: PasswordHasher;\n\t/** Hashers that only verify: those a database was written with before. */\n\treadonly verifiers?: readonly PasswordHasher[];\n\treadonly clock?: Clock;\n\treadonly cookie?: CookieConfig;\n\treadonly tokens?: {\n\t\t/** `'24h'` when absent. */\n\t\treadonly verifyEmail?: Duration;\n\t\t/** `'1h'` when absent. */\n\t\treadonly resetPassword?: Duration;\n\t\t/** How long an e-mailed sign-in code waits. `'10m'` when absent. */\n\t\treadonly signInCode?: Duration;\n\t};\n\t/**\n\t * A TOTP second factor, for every user type with a password. Absent, no\n\t * `secondFactor` flows exist and `signIn` answers a session directly.\n\t */\n\treadonly secondFactor?: SecondFactorConfig;\n\t/**\n\t * Called with every user event — `user.created`, `user.emailVerified`,\n\t * `user.passwordReset`, `user.deleted` — once the write landed,\n\t * and awaited before the flow answers. Any function will do; the coming\n\t * `@nxgt/janus-webhooks` will sign and deliver them.\n\t */\n\treadonly events?: UserEventListener;\n}\n\n/** What a TOTP second factor needs: a name for the app, and the keys that seal. */\nexport interface SecondFactorConfig {\n\t/** Shown in the authenticator app beside the account: your product's name. */\n\treadonly issuer: string;\n\t/**\n\t * The keys every TOTP secret is sealed with before a store sees it. **The\n\t * first seals, every one opens**: to rotate, put the new key first and keep\n\t * the old one until no secret is sealed with it.\n\t */\n\treadonly keys: readonly [SealingKey, ...SealingKey[]];\n\t/** How long `signIn`'s challenge waits for a code. `'5m'` when absent. */\n\treadonly challenge?: Duration;\n}\n\n/** An application with one user type: `user` is its schema. */\nexport interface SingleTypeConfig\n\textends SharedConfig,\n\t\tOmit<UserTypeConfig, 'schema'> {\n\treadonly user: UserSchema;\n\treadonly users?: never;\n}\n\n/** An application with several user types — patients and staff. */\nexport interface MultiTypeConfig extends SharedConfig {\n\treadonly users: { readonly [type: string]: UserTypeConfig };\n\treadonly user?: never;\n\treadonly password?: never;\n\treadonly email?: never;\n\treadonly session?: never;\n\treadonly schemaVersion?: never;\n}\n\nexport type JanusConfig = SingleTypeConfig | MultiTypeConfig;\n\n/** The type name the single-type form gives its users. */\nexport const SINGLE_TYPE = 'user';\n\n/**\n * Keys `janus` sets on every user, so a schema may not declare them.\n * `password` too: it is taken beside the fields, and never stored among them.\n */\nexport const RESERVED_FIELDS = [\n\t'id',\n\t'type',\n\t'emailVerified',\n\t'active',\n\t'hasPassword',\n\t'hasSecondFactor',\n\t'version',\n\t'createdAt',\n\t'updatedAt',\n\t'password',\n] as const;\n\n/** Names on `janus()`'s answer, so a user type may not take one. */\nexport const RESERVED_TYPES = [\n\t'authenticate',\n\t'signOut',\n\t'signOutEverywhere',\n\t'findUser',\n\t'getUser',\n\t'cookie',\n\t'collectExpired',\n\t'types',\n] as const;\n\n/** One user type, with every default applied and every duration in milliseconds. */\nexport interface ResolvedType {\n\treadonly name: string;\n\treadonly schema: UserSchema;\n\treadonly schemaVersion: string;\n\treadonly password: {\n\t\treadonly login: string;\n\t\treadonly normalize: (value: string) => string;\n\t\treadonly minLength: number;\n\t} | null;\n\treadonly email: string;\n\treadonly lifespanMs: number;\n\treadonly renewAfterMs: number | null;\n}\n\nexport interface ResolvedConfig {\n\treadonly single: boolean;\n\treadonly types: ReadonlyMap<string, ResolvedType>;\n\treadonly tokenTtlMs: {\n\t\treadonly verifyEmail: number;\n\t\treadonly resetPassword: number;\n\t\treadonly signInCode: number;\n\t};\n\treadonly secondFactor: {\n\t\treadonly issuer: string;\n\t\treadonly sealer: Sealer;\n\t\treadonly challengeTtlMs: number;\n\t} | null;\n\treadonly cookie: {\n\t\treadonly name: string;\n\t\treadonly domain: string | null;\n\t\treadonly path: string;\n\t\treadonly sameSite: 'lax' | 'strict' | 'none';\n\t\treadonly secure: boolean;\n\t};\n}\n\nconst NORMALIZERS = {\n\tnone: (value: string) => value,\n\tlowercase: (value: string) => value.toLowerCase(),\n\tlowercaseTrim: (value: string) => value.toLowerCase().trim(),\n\tnfkcLowercaseTrim: (value: string) =>\n\t\tvalue.normalize('NFKC').toLowerCase().trim(),\n} as const satisfies Record<string, (value: string) => string>;\n\n/** How an e-mail is compared: always the same rule, whatever the login's. */\nexport const normalizeEmail = NORMALIZERS.lowercaseTrim;\n\n/** RFC 6265's cookie-name token: no control character, space, or separator. */\nconst COOKIE_NAME = /^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$/;\n\n/** A type name, or a field name: camelCase, as every key in this package. */\nconst NAME = /^[A-Za-z][A-Za-z0-9]*$/;\n\n/** Applies the defaults and refuses what cannot be wired. `where` names the call. */\nexport function resolveConfig(\n\tconfig: JanusConfig,\n\twhere: string,\n): ResolvedConfig {\n\tif (typeof config !== 'object' || config === null) {\n\t\tthrow new TypeError(`${where}: expected a configuration object`);\n\t}\n\n\tconst hasUser = config.user !== undefined;\n\tconst hasUsers = config.users !== undefined;\n\tif (hasUser === hasUsers) {\n\t\tthrow new TypeError(\n\t\t\t`${where}: pass either user (one user type) or users (several user types), and exactly one of them`,\n\t\t);\n\t}\n\n\tconst entries: [string, UserTypeConfig][] = hasUsers\n\t\t? Object.entries(config.users as MultiTypeConfig['users'])\n\t\t: [\n\t\t\t\t[\n\t\t\t\t\tSINGLE_TYPE,\n\t\t\t\t\t{\n\t\t\t\t\t\t...(config as SingleTypeConfig),\n\t\t\t\t\t\tschema: config.user as UserSchema,\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t];\n\n\tif (entries.length === 0) {\n\t\tthrow new TypeError(`${where}: users declares no user type`);\n\t}\n\n\tconst types = new Map<string, ResolvedType>();\n\tfor (const [name, type] of entries) {\n\t\tconst at = hasUsers ? `${where}: users.${name}` : where;\n\n\t\tif (!NAME.test(name)) {\n\t\t\tthrow new TypeError(\n\t\t\t\t`${where}: the user type \"${name}\" must be a camelCase name — letters and digits, starting with a letter`,\n\t\t\t);\n\t\t}\n\t\tif ((RESERVED_TYPES as readonly string[]).includes(name)) {\n\t\t\tthrow new TypeError(\n\t\t\t\t`${where}: \"${name}\" cannot name a user type — janus() answers a method of that name`,\n\t\t\t);\n\t\t}\n\t\ttypes.set(name, resolveType(name, type, at, hasUsers ? 'schema' : 'user'));\n\t}\n\n\tconst hashing = [...types.values()].some((type) => type.password !== null);\n\tif (hashing && config.hasher === undefined) {\n\t\tthrow new TypeError(\n\t\t\t`${where}: a user type signs in with a password and no hasher is wired — pass hasher: scryptHasher(), or bunHasher() on Bun. There is no silent fallback`,\n\t\t);\n\t}\n\n\treturn {\n\t\tsingle: !hasUsers,\n\t\ttypes,\n\t\ttokenTtlMs: {\n\t\t\tverifyEmail: parseDuration(\n\t\t\t\tconfig.tokens?.verifyEmail ?? '24h',\n\t\t\t\t`${where}: tokens.verifyEmail`,\n\t\t\t),\n\t\t\tresetPassword: parseDuration(\n\t\t\t\tconfig.tokens?.resetPassword ?? '1h',\n\t\t\t\t`${where}: tokens.resetPassword`,\n\t\t\t),\n\t\t\tsignInCode: parseDuration(\n\t\t\t\tconfig.tokens?.signInCode ?? '10m',\n\t\t\t\t`${where}: tokens.signInCode`,\n\t\t\t),\n\t\t},\n\t\tcookie: resolveCookie(config.cookie ?? {}, where),\n\t\tsecondFactor: resolveSecondFactor(config.secondFactor, where),\n\t};\n}\n\nfunction resolveSecondFactor(\n\tconfig: SecondFactorConfig | undefined,\n\twhere: string,\n): ResolvedConfig['secondFactor'] {\n\tif (config === undefined) return null;\n\tconst at = `${where}: secondFactor`;\n\tif (typeof config?.issuer !== 'string' || config.issuer.trim() === '') {\n\t\tthrow new TypeError(\n\t\t\t`${at}.issuer must name your application — the authenticator app shows it beside the account`,\n\t\t);\n\t}\n\treturn {\n\t\tissuer: config.issuer,\n\t\tsealer: resolveSealer(config.keys, `${at}.keys`),\n\t\tchallengeTtlMs: parseDuration(config.challenge ?? '5m', `${at}.challenge`),\n\t};\n}\n\nfunction resolveType(\n\tname: string,\n\ttype: UserTypeConfig,\n\tat: string,\n\tschemaKey: string,\n): ResolvedType {\n\tif (\n\t\ttypeof type?.schema !== 'object' ||\n\t\ttype.schema === null ||\n\t\ttypeof type.schema['~standard']?.validate !== 'function'\n\t) {\n\t\tthrow new TypeError(\n\t\t\t`${at}: ${schemaKey} must be a Standard Schema — a Zod 4, Valibot or ArkType schema`,\n\t\t);\n\t}\n\n\tlet password: ResolvedType['password'] = null;\n\tif (type.password !== undefined) {\n\t\tconst minLength = type.password.minLength ?? 8;\n\t\tif (!Number.isInteger(minLength) || minLength < 1) {\n\t\t\tthrow new TypeError(\n\t\t\t\t`${at}: password.minLength must be an integer of at least 1`,\n\t\t\t);\n\t\t}\n\t\tpassword = {\n\t\t\tlogin: fieldName(type.password.login, `${at}: password.login`),\n\t\t\tnormalize: normalizer(\n\t\t\t\ttype.password.normalize ?? 'lowercaseTrim',\n\t\t\t\t`${at}: password.normalize`,\n\t\t\t),\n\t\t\tminLength,\n\t\t};\n\t}\n\n\tconst renewAfter = type.session?.renewAfter ?? '1d';\n\n\treturn {\n\t\tname,\n\t\tschema: type.schema,\n\t\tschemaVersion: type.schemaVersion ?? '1',\n\t\tpassword,\n\t\temail: fieldName(type.email ?? 'email', `${at}: email`),\n\t\tlifespanMs: parseDuration(\n\t\t\ttype.session?.lifespan ?? '7d',\n\t\t\t`${at}: session.lifespan`,\n\t\t),\n\t\trenewAfterMs:\n\t\t\trenewAfter === false\n\t\t\t\t? null\n\t\t\t\t: parseDuration(renewAfter, `${at}: session.renewAfter`),\n\t};\n}\n\nfunction resolveCookie(\n\tcookie: CookieConfig,\n\twhere: string,\n): ResolvedConfig['cookie'] {\n\tconst name = cookie.name ?? 'janus-session';\n\tif (!COOKIE_NAME.test(name)) {\n\t\tthrow new TypeError(\n\t\t\t`${where}: cookie.name must be a cookie-name token — letters, digits and !#$%&'*+-.^_\\`|~, with no space, \";\" or \"=\"`,\n\t\t);\n\t}\n\tconst sameSite = cookie.sameSite ?? 'lax';\n\tconst secure = cookie.secure ?? true;\n\tif (sameSite === 'none' && !secure) {\n\t\tthrow new TypeError(\n\t\t\t`${where}: cookie.sameSite \"none\" requires cookie.secure — browsers refuse the cookie otherwise`,\n\t\t);\n\t}\n\n\treturn {\n\t\tname,\n\t\tdomain: cookie.domain ?? null,\n\t\tpath: cookie.path ?? '/',\n\t\tsameSite,\n\t\tsecure,\n\t};\n}\n\nfunction fieldName(field: unknown, where: string): string {\n\tif (typeof field !== 'string' || !NAME.test(field)) {\n\t\tthrow new TypeError(\n\t\t\t`${where} must name a top-level field of the schema, such as \"email\"`,\n\t\t);\n\t}\n\tif ((RESERVED_FIELDS as readonly string[]).includes(field)) {\n\t\tthrow new TypeError(`${where}: \"${field}\" is a field janus sets itself`);\n\t}\n\treturn field;\n}\n\nfunction normalizer(rule: Normalize, where: string): (value: string) => string {\n\tif (typeof rule === 'function') return rule;\n\tif (typeof rule === 'string' && Object.hasOwn(NORMALIZERS, rule)) {\n\t\treturn NORMALIZERS[rule];\n\t}\n\n\tthrow new TypeError(\n\t\t`${where} must be \"none\", \"lowercase\", \"lowercaseTrim\", \"nfkcLowercaseTrim\" or a function`,\n\t);\n}\n",
|
|
9
|
+
"/**\n * What `janus()` accepts, and how it is resolved at run time.\n *\n * The types refuse first, on the offending key (`checks.ts`). What follows at\n * run time is the net for JavaScript callers, and every refusal is a bare\n * `TypeError`: a configuration is written when the application is wired, never\n * from a request.\n */\n\nimport type { RelationStore } from '../permissions/port/types';\nimport type { Clock } from '../time/clock';\nimport { type Duration, parseDuration } from '../time/duration';\nimport type { UserEventListener } from './events';\nimport type { JanusStores } from './port/types';\nimport { resolveSealer, type Sealer, type SealingKey } from './sealing';\nimport type { StandardSchemaV1 } from './standard-schema';\n\n/**\n * How a login is normalised before any store sees it. `'lowercaseTrim'` when\n * absent — right for an e-mail and for most usernames.\n *\n * A function is accepted for anything else. It must be deterministic: the same\n * rule normalises at sign-up and at sign-in.\n */\nexport type Normalize =\n\t| 'none'\n\t| 'lowercase'\n\t| 'lowercaseTrim'\n\t| 'nfkcLowercaseTrim'\n\t| ((value: string) => string);\n\n/**\n * What a user schema may produce: JSON, where an object property may also be\n * `undefined` — which is what every validator's optional field produces.\n *\n * The core drops an `undefined` property before a store sees the fields, so the\n * port stays strict JSON: an optional field left out is **absent** in the\n * store, and reads back absent, which the schema's output type already allows.\n */\nexport type FieldsJson =\n\t| string\n\t| number\n\t| boolean\n\t| null\n\t| readonly FieldsJson[]\n\t| { readonly [key: string]: FieldsJson | undefined };\n\n/**\n * A user type's schema: any Standard Schema — Zod 4, Valibot, ArkType — whose\n * output is an object of JSON. A `Date` round-trips through one store and not\n * the next, so a schema that produces one is refused at compile time.\n */\nexport type UserSchema = StandardSchemaV1<\n\tunknown,\n\t{ readonly [key: string]: FieldsJson | undefined }\n>;\n\n/**\n * A password hashing scheme — the second port.\n *\n * Hashes are self-describing, so **every wired hasher can verify, and exactly\n * one hashes** new passwords. A database written under Bun reads under Node and\n * back, as long as a verifier for each prefix is wired.\n */\nexport interface PasswordHasher {\n\t/** The prefix every hash it writes starts with: `'$argon2id$'`, `'$scrypt$'`. */\n\treadonly prefix: string;\n\thash(plain: string): Promise<string>;\n\tverify(plain: string, hash: string): Promise<boolean>;\n\t/**\n\t * Whether a hash **this hasher** wrote should be written again: its\n\t * parameters are not the ones `hash` uses now — a raised `cost`, say.\n\t * Optional; without it only a hash from another hasher (a `verifiers` one)\n\t * is rewritten. Called with hashes carrying this hasher's prefix only.\n\t */\n\tneedsRehash?(hash: string): boolean;\n}\n\n/** Signing in with a password. */\nexport interface PasswordConfig {\n\t/**\n\t * The field users sign in with: a **top-level, required string** field of\n\t * the schema — `'email'`, `'username'`. A typo is a compile error.\n\t */\n\treadonly login: string;\n\t/** `'lowercaseTrim'` when absent. */\n\treadonly normalize?: Normalize;\n\t/** At least 1. `8` when absent. */\n\treadonly minLength?: number;\n}\n\n/** How long a session lives, and when it is renewed. */\nexport interface SessionConfig {\n\t/** `'7d'` when absent. */\n\treadonly lifespan?: Duration;\n\t/**\n\t * `authenticate` renews a session once this much has passed since it was\n\t * opened or last renewed — a sliding session, written at most once per\n\t * period. `'1d'` when absent; `false` for a fixed lifespan.\n\t */\n\treadonly renewAfter?: Duration | false;\n}\n\n/** One user type: its schema, and how it signs in. */\nexport interface UserTypeConfig {\n\treadonly schema: UserSchema;\n\treadonly password?: PasswordConfig;\n\t/**\n\t * The field holding the user's e-mail, which `verifyEmail` and\n\t * `resetPassword` send to. `'email'` when absent — and when the schema has\n\t * no required string `email` either, those two flows do not exist on the\n\t * type.\n\t */\n\treadonly email?: string;\n\treadonly session?: SessionConfig;\n\t/**\n\t * Recorded on every user written, and read by nothing yet. Bump it when the\n\t * schema tightens, and the users validated against the old one can be found.\n\t * `'1'` when absent.\n\t */\n\treadonly schemaVersion?: string;\n}\n\n/** The session cookie. Every default is the strict one. */\nexport interface CookieConfig {\n\t/** `'janus-session'` when absent. A cookie-name token: no space, no `;`, no `=`. */\n\treadonly name?: string;\n\treadonly domain?: string;\n\t/** `'/'` when absent. */\n\treadonly path?: string;\n\t/** `'lax'` when absent. */\n\treadonly sameSite?: 'lax' | 'strict' | 'none';\n\t/** `true` when absent. `sameSite: 'none'` requires it. */\n\treadonly secure?: boolean;\n}\n\ninterface SharedConfig {\n\t/** The three stores: `createMemoryStores()`, or an adapter's. */\n\treadonly store: JanusStores;\n\t/**\n\t * The relation store `permissions()` is given, when the application has\n\t * one. Wired here, deleting a user deletes every tuple naming them too — as\n\t * a subject, and through no one else's memory of it.\n\t */\n\treadonly relations?: RelationStore;\n\t/**\n\t * Hashes new passwords. **Required as soon as a type signs in with a\n\t * password** — there is no silent fallback. `scryptHasher()` runs on Node and\n\t * Bun; `bunHasher()` is argon2id, on Bun only.\n\t */\n\treadonly hasher?: PasswordHasher;\n\t/** Hashers that only verify: those a database was written with before. */\n\treadonly verifiers?: readonly PasswordHasher[];\n\treadonly clock?: Clock;\n\treadonly cookie?: CookieConfig;\n\treadonly tokens?: {\n\t\t/** `'24h'` when absent. */\n\t\treadonly verifyEmail?: Duration;\n\t\t/** `'1h'` when absent. */\n\t\treadonly resetPassword?: Duration;\n\t\t/** How long an e-mailed sign-in code waits. `'10m'` when absent. */\n\t\treadonly signInCode?: Duration;\n\t};\n\t/**\n\t * A TOTP second factor, for every user type with a password. Absent, no\n\t * `secondFactor` flows exist and `signIn` answers a session directly.\n\t */\n\treadonly secondFactor?: SecondFactorConfig;\n\t/**\n\t * Called with every user event — `user.created`, `user.emailVerified`,\n\t * `user.passwordReset`, `user.deleted` — once the write landed,\n\t * and awaited before the flow answers. Any function will do;\n\t * `webhooks({ … })` from `@nxgt/janus-webhooks` signs and delivers them.\n\t */\n\treadonly events?: UserEventListener;\n}\n\n/** What a TOTP second factor needs: a name for the app, and the keys that seal. */\nexport interface SecondFactorConfig {\n\t/** Shown in the authenticator app beside the account: your product's name. */\n\treadonly issuer: string;\n\t/**\n\t * The keys every TOTP secret is sealed with before a store sees it. **The\n\t * first seals, every one opens**: to rotate, put the new key first and keep\n\t * the old one until no secret is sealed with it.\n\t */\n\treadonly keys: readonly [SealingKey, ...SealingKey[]];\n\t/** How long `signIn`'s challenge waits for a code. `'5m'` when absent. */\n\treadonly challenge?: Duration;\n}\n\n/** An application with one user type: `user` is its schema. */\nexport interface SingleTypeConfig\n\textends SharedConfig,\n\t\tOmit<UserTypeConfig, 'schema'> {\n\treadonly user: UserSchema;\n\treadonly users?: never;\n}\n\n/** An application with several user types — patients and staff. */\nexport interface MultiTypeConfig extends SharedConfig {\n\treadonly users: { readonly [type: string]: UserTypeConfig };\n\treadonly user?: never;\n\treadonly password?: never;\n\treadonly email?: never;\n\treadonly session?: never;\n\treadonly schemaVersion?: never;\n}\n\nexport type JanusConfig = SingleTypeConfig | MultiTypeConfig;\n\n/** The type name the single-type form gives its users. */\nexport const SINGLE_TYPE = 'user';\n\n/**\n * Keys `janus` sets on every user, so a schema may not declare them.\n * `password` too: it is taken beside the fields, and never stored among them.\n */\nexport const RESERVED_FIELDS = [\n\t'id',\n\t'type',\n\t'emailVerified',\n\t'active',\n\t'hasPassword',\n\t'hasSecondFactor',\n\t'version',\n\t'createdAt',\n\t'updatedAt',\n\t'password',\n] as const;\n\n/** Names on `janus()`'s answer, so a user type may not take one. */\nexport const RESERVED_TYPES = [\n\t'authenticate',\n\t'signOut',\n\t'signOutEverywhere',\n\t'findUser',\n\t'getUser',\n\t'cookie',\n\t'collectExpired',\n\t'types',\n] as const;\n\n/** One user type, with every default applied and every duration in milliseconds. */\nexport interface ResolvedType {\n\treadonly name: string;\n\treadonly schema: UserSchema;\n\treadonly schemaVersion: string;\n\treadonly password: {\n\t\treadonly login: string;\n\t\treadonly normalize: (value: string) => string;\n\t\treadonly minLength: number;\n\t} | null;\n\treadonly email: string;\n\treadonly lifespanMs: number;\n\treadonly renewAfterMs: number | null;\n}\n\nexport interface ResolvedConfig {\n\treadonly single: boolean;\n\treadonly types: ReadonlyMap<string, ResolvedType>;\n\treadonly tokenTtlMs: {\n\t\treadonly verifyEmail: number;\n\t\treadonly resetPassword: number;\n\t\treadonly signInCode: number;\n\t};\n\treadonly secondFactor: {\n\t\treadonly issuer: string;\n\t\treadonly sealer: Sealer;\n\t\treadonly challengeTtlMs: number;\n\t} | null;\n\treadonly cookie: {\n\t\treadonly name: string;\n\t\treadonly domain: string | null;\n\t\treadonly path: string;\n\t\treadonly sameSite: 'lax' | 'strict' | 'none';\n\t\treadonly secure: boolean;\n\t};\n}\n\nconst NORMALIZERS = {\n\tnone: (value: string) => value,\n\tlowercase: (value: string) => value.toLowerCase(),\n\tlowercaseTrim: (value: string) => value.toLowerCase().trim(),\n\tnfkcLowercaseTrim: (value: string) =>\n\t\tvalue.normalize('NFKC').toLowerCase().trim(),\n} as const satisfies Record<string, (value: string) => string>;\n\n/** How an e-mail is compared: always the same rule, whatever the login's. */\nexport const normalizeEmail = NORMALIZERS.lowercaseTrim;\n\n/** RFC 6265's cookie-name token: no control character, space, or separator. */\nconst COOKIE_NAME = /^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$/;\n\n/** A type name, or a field name: camelCase, as every key in this package. */\nconst NAME = /^[A-Za-z][A-Za-z0-9]*$/;\n\n/** Applies the defaults and refuses what cannot be wired. `where` names the call. */\nexport function resolveConfig(\n\tconfig: JanusConfig,\n\twhere: string,\n): ResolvedConfig {\n\tif (typeof config !== 'object' || config === null) {\n\t\tthrow new TypeError(`${where}: expected a configuration object`);\n\t}\n\n\tconst hasUser = config.user !== undefined;\n\tconst hasUsers = config.users !== undefined;\n\tif (hasUser === hasUsers) {\n\t\tthrow new TypeError(\n\t\t\t`${where}: pass either user (one user type) or users (several user types), and exactly one of them`,\n\t\t);\n\t}\n\n\tconst entries: [string, UserTypeConfig][] = hasUsers\n\t\t? Object.entries(config.users as MultiTypeConfig['users'])\n\t\t: [\n\t\t\t\t[\n\t\t\t\t\tSINGLE_TYPE,\n\t\t\t\t\t{\n\t\t\t\t\t\t...(config as SingleTypeConfig),\n\t\t\t\t\t\tschema: config.user as UserSchema,\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t];\n\n\tif (entries.length === 0) {\n\t\tthrow new TypeError(`${where}: users declares no user type`);\n\t}\n\n\tconst types = new Map<string, ResolvedType>();\n\tfor (const [name, type] of entries) {\n\t\tconst at = hasUsers ? `${where}: users.${name}` : where;\n\n\t\tif (!NAME.test(name)) {\n\t\t\tthrow new TypeError(\n\t\t\t\t`${where}: the user type \"${name}\" must be a camelCase name — letters and digits, starting with a letter`,\n\t\t\t);\n\t\t}\n\t\tif ((RESERVED_TYPES as readonly string[]).includes(name)) {\n\t\t\tthrow new TypeError(\n\t\t\t\t`${where}: \"${name}\" cannot name a user type — janus() answers a method of that name`,\n\t\t\t);\n\t\t}\n\t\ttypes.set(name, resolveType(name, type, at, hasUsers ? 'schema' : 'user'));\n\t}\n\n\tconst hashing = [...types.values()].some((type) => type.password !== null);\n\tif (hashing && config.hasher === undefined) {\n\t\tthrow new TypeError(\n\t\t\t`${where}: a user type signs in with a password and no hasher is wired — pass hasher: scryptHasher(), or bunHasher() on Bun. There is no silent fallback`,\n\t\t);\n\t}\n\n\treturn {\n\t\tsingle: !hasUsers,\n\t\ttypes,\n\t\ttokenTtlMs: {\n\t\t\tverifyEmail: parseDuration(\n\t\t\t\tconfig.tokens?.verifyEmail ?? '24h',\n\t\t\t\t`${where}: tokens.verifyEmail`,\n\t\t\t),\n\t\t\tresetPassword: parseDuration(\n\t\t\t\tconfig.tokens?.resetPassword ?? '1h',\n\t\t\t\t`${where}: tokens.resetPassword`,\n\t\t\t),\n\t\t\tsignInCode: parseDuration(\n\t\t\t\tconfig.tokens?.signInCode ?? '10m',\n\t\t\t\t`${where}: tokens.signInCode`,\n\t\t\t),\n\t\t},\n\t\tcookie: resolveCookie(config.cookie ?? {}, where),\n\t\tsecondFactor: resolveSecondFactor(config.secondFactor, where),\n\t};\n}\n\nfunction resolveSecondFactor(\n\tconfig: SecondFactorConfig | undefined,\n\twhere: string,\n): ResolvedConfig['secondFactor'] {\n\tif (config === undefined) return null;\n\tconst at = `${where}: secondFactor`;\n\tif (typeof config?.issuer !== 'string' || config.issuer.trim() === '') {\n\t\tthrow new TypeError(\n\t\t\t`${at}.issuer must name your application — the authenticator app shows it beside the account`,\n\t\t);\n\t}\n\treturn {\n\t\tissuer: config.issuer,\n\t\tsealer: resolveSealer(config.keys, `${at}.keys`),\n\t\tchallengeTtlMs: parseDuration(config.challenge ?? '5m', `${at}.challenge`),\n\t};\n}\n\nfunction resolveType(\n\tname: string,\n\ttype: UserTypeConfig,\n\tat: string,\n\tschemaKey: string,\n): ResolvedType {\n\tif (\n\t\ttypeof type?.schema !== 'object' ||\n\t\ttype.schema === null ||\n\t\ttypeof type.schema['~standard']?.validate !== 'function'\n\t) {\n\t\tthrow new TypeError(\n\t\t\t`${at}: ${schemaKey} must be a Standard Schema — a Zod 4, Valibot or ArkType schema`,\n\t\t);\n\t}\n\n\tlet password: ResolvedType['password'] = null;\n\tif (type.password !== undefined) {\n\t\tconst minLength = type.password.minLength ?? 8;\n\t\tif (!Number.isInteger(minLength) || minLength < 1) {\n\t\t\tthrow new TypeError(\n\t\t\t\t`${at}: password.minLength must be an integer of at least 1`,\n\t\t\t);\n\t\t}\n\t\tpassword = {\n\t\t\tlogin: fieldName(type.password.login, `${at}: password.login`),\n\t\t\tnormalize: normalizer(\n\t\t\t\ttype.password.normalize ?? 'lowercaseTrim',\n\t\t\t\t`${at}: password.normalize`,\n\t\t\t),\n\t\t\tminLength,\n\t\t};\n\t}\n\n\tconst renewAfter = type.session?.renewAfter ?? '1d';\n\n\treturn {\n\t\tname,\n\t\tschema: type.schema,\n\t\tschemaVersion: type.schemaVersion ?? '1',\n\t\tpassword,\n\t\temail: fieldName(type.email ?? 'email', `${at}: email`),\n\t\tlifespanMs: parseDuration(\n\t\t\ttype.session?.lifespan ?? '7d',\n\t\t\t`${at}: session.lifespan`,\n\t\t),\n\t\trenewAfterMs:\n\t\t\trenewAfter === false\n\t\t\t\t? null\n\t\t\t\t: parseDuration(renewAfter, `${at}: session.renewAfter`),\n\t};\n}\n\nfunction resolveCookie(\n\tcookie: CookieConfig,\n\twhere: string,\n): ResolvedConfig['cookie'] {\n\tconst name = cookie.name ?? 'janus-session';\n\tif (!COOKIE_NAME.test(name)) {\n\t\tthrow new TypeError(\n\t\t\t`${where}: cookie.name must be a cookie-name token — letters, digits and !#$%&'*+-.^_\\`|~, with no space, \";\" or \"=\"`,\n\t\t);\n\t}\n\tconst sameSite = cookie.sameSite ?? 'lax';\n\tconst secure = cookie.secure ?? true;\n\tif (sameSite === 'none' && !secure) {\n\t\tthrow new TypeError(\n\t\t\t`${where}: cookie.sameSite \"none\" requires cookie.secure — browsers refuse the cookie otherwise`,\n\t\t);\n\t}\n\n\treturn {\n\t\tname,\n\t\tdomain: cookie.domain ?? null,\n\t\tpath: cookie.path ?? '/',\n\t\tsameSite,\n\t\tsecure,\n\t};\n}\n\nfunction fieldName(field: unknown, where: string): string {\n\tif (typeof field !== 'string' || !NAME.test(field)) {\n\t\tthrow new TypeError(\n\t\t\t`${where} must name a top-level field of the schema, such as \"email\"`,\n\t\t);\n\t}\n\tif ((RESERVED_FIELDS as readonly string[]).includes(field)) {\n\t\tthrow new TypeError(`${where}: \"${field}\" is a field janus sets itself`);\n\t}\n\treturn field;\n}\n\nfunction normalizer(rule: Normalize, where: string): (value: string) => string {\n\tif (typeof rule === 'function') return rule;\n\tif (typeof rule === 'string' && Object.hasOwn(NORMALIZERS, rule)) {\n\t\treturn NORMALIZERS[rule];\n\t}\n\n\tthrow new TypeError(\n\t\t`${where} must be \"none\", \"lowercase\", \"lowercaseTrim\", \"nfkcLowercaseTrim\" or a function`,\n\t);\n}\n",
|
|
10
10
|
"/**\n * What every store can keep. PostgreSQL refuses `\\u0000` in `text` and\n * `jsonb`, and a lone surrogate in `jsonb`; MongoDB and the memory store keep\n * both. The core refuses them before a store is asked, so a request that sends\n * one is answered the same way on every adapter — not `STORE_FAILED` on one of\n * them, a 503 for a database that is up.\n *\n * Shared by both sides of the package, like `guard.ts`: a string from a\n * request reaches the identity stores and the relation store alike.\n */\n\n/** Whether every store can keep this string. */\nexport const isStorable = (value: string): boolean =>\n\t!value.includes('\\u0000') && value.isWellFormed();\n\n/** What an issue says about a string no store can keep. */\nexport const UNSTORABLE =\n\t'holds a NUL character or a lone surrogate, which no store can keep';\n",
|
|
11
11
|
"/**\n * **The identity side's two conversions**, and the one place a `null` becomes\n * an error.\n *\n * > An absence is `null`. A failure throws.\n *\n * {@link guardStores} puts the three identity stores behind the shared guard\n * (`src/stores/guard.ts`), the one place a store's answer is caught.\n * {@link required} turns an absence the caller cannot accept into its refusal,\n * and {@link unlessVersionConflict} absorbs one named conflict and rethrows\n * everything else.\n *\n * `outage.spec.ts` reads every other file of `src/auth/`, `src/permissions/`\n * and `src/stores/` and fails if a `catch` appears in one, because the failure\n * this design exists to prevent is a single careless `catch { return null }`.\n */\n\nimport { type JanusError, StoreConflict } from '../errors/janus-error';\nimport { guardStore } from '../stores/guard';\nimport type { JanusStores } from './port/types';\n\n/**\n * The stores, with every method guarded.\n *\n * - A `JanusError` the adapter threw — `StoreFailure`, `StoreConflict`,\n * `NotFoundError` — passes through untouched, so `instanceof` still holds.\n * - **Anything else it threw is a failure**: a driver error, a `TypeError` from\n * a bug in the adapter, a string. It becomes `StoreFailure` with the original\n * as `cause`, naming the slot and the method. It is never an absence.\n * - A method that answers `undefined` where the port says `null` is a store\n * that forgot to answer, and becomes `StoreFailure` rather than \"not found\".\n * Rule 2 of the port, enforced at run time for the JavaScript adapter the\n * compiler never saw.\n *\n * The optional `deleteExpiredSessions` is guarded when present and left absent\n * when absent, so capability detection still reads the truth.\n */\nexport function guardStores(stores: JanusStores): JanusStores {\n\treturn {\n\t\tusers: guardStore('users', stores.users),\n\t\tsessions: guardStore('sessions', stores.sessions),\n\t\ttokens: guardStore('tokens', stores.tokens),\n\t};\n}\n\n/**\n * A value the caller requires, or the refusal an absence deserves.\n *\n * The only place in the core where a `null` from a store turns into an error —\n * `get` becoming `NOT_FOUND`. Written as a function so that the conversion is\n * visible at every call site and nowhere else.\n */\nexport function required<T>(value: T | null, absent: () => JanusError): T {\n\tif (value === null) throw absent();\n\treturn value;\n}\n\n/**\n * A write that may lose a race, and is allowed to: its answer, or `null` when\n * the record's version moved under it.\n *\n * **Only `StoreConflict('version')` is absorbed.** A failure still throws, a\n * taken login still throws, `NOT_FOUND` still throws: an outage never reads as\n * \"somebody else wrote first\". It exists for writes the caller did not ask for\n * — rewriting a password hash on sign-in — where losing to a concurrent update\n * means only that the next sign-in tries again.\n */\nexport async function unlessVersionConflict<T>(\n\twrite: Promise<T>,\n): Promise<T | null> {\n\ttry {\n\t\treturn await write;\n\t} catch (error) {\n\t\tif (error instanceof StoreConflict && error.on === 'version') return null;\n\t\tthrow error;\n\t}\n}\n",
|
|
12
12
|
"/**\n * What every operation of the core shares: the resolved configuration, the\n * guarded stores, the clock, the hashers — and the handful of steps every\n * operation takes the same way.\n *\n * Internal and degenericised: fields are a `JsonObject` here, and `janus()`\n * casts once, at the boundary, after the schema has validated them.\n */\n\nimport {\n\tCredentialError,\n\ttype Issue,\n\tNotFoundError,\n\tStoreConflict,\n\tUserInvalidError,\n} from '../errors/janus-error';\nimport { isId, mintId } from '../ids/id';\nimport type { RelationStore } from '../permissions/port/types';\nimport { isStorable, UNSTORABLE } from '../stores/storable';\nimport type { Clock } from '../time/clock';\nimport {\n\tnormalizeEmail,\n\ttype PasswordHasher,\n\tRESERVED_FIELDS,\n\ttype ResolvedConfig,\n\ttype ResolvedType,\n} from './config';\nimport type { UserEventListener } from './events';\nimport { required, unlessVersionConflict } from './outage';\nimport type {\n\tJanusStores,\n\tJson,\n\tJsonObject,\n\tStoreCapabilities,\n\tUserPatch,\n\tUserRecord,\n} from './port/types';\nimport type { StandardSchemaV1 } from './standard-schema';\nimport type { User, UserRef, WriteOptions } from './types';\n\nexport interface Context {\n\treadonly config: ResolvedConfig;\n\t/** Already guarded, by `src/stores/guard.ts`. */\n\treadonly store: JanusStores;\n\t/** Already guarded. `null` when no relation store is wired. */\n\treadonly relations: RelationStore | null;\n\treadonly capabilities: StoreCapabilities;\n\treadonly clock: Clock;\n\treadonly hasher: PasswordHasher | null;\n\t/** The hasher first, then every verifier: whoever claims a prefix verifies it. */\n\treadonly verifiers: readonly PasswordHasher[];\n\t/** Hashed once, lazily: what a missing user's password is compared against. */\n\tdummyHash(): Promise<string>;\n\t/** What `janus({ events })` was given, or `null`. */\n\treadonly events: UserEventListener | null;\n}\n\nexport function createContext(\n\tconfig: ResolvedConfig,\n\tstore: JanusStores,\n\trelations: RelationStore | null,\n\tcapabilities: StoreCapabilities,\n\tclock: Clock,\n\thasher: PasswordHasher | null,\n\tverifiers: readonly PasswordHasher[],\n\tevents: UserEventListener | null = null,\n): Context {\n\tlet dummy: Promise<string> | null = null;\n\n\treturn {\n\t\tconfig,\n\t\tstore,\n\t\trelations,\n\t\tcapabilities,\n\t\tclock,\n\t\thasher,\n\t\tverifiers,\n\t\tevents,\n\t\tdummyHash: () => {\n\t\t\tif (hasher === null) {\n\t\t\t\tthrow new TypeError('janus: no hasher to compare a dummy hash with');\n\t\t\t}\n\t\t\tdummy ??= hasher.hash(`janus-dummy-${mintId()}`);\n\t\t\treturn dummy;\n\t\t},\n\t};\n}\n\n/** A user of any type, as the degenericised core handles them. */\nexport type AnyUser = User<string, Record<string, unknown>>;\n\n/**\n * A user as application code sees it: their fields at the top level, then\n * what `janus` sets — written last, so no stored key can shadow it. No password\n * hash, ever.\n */\nexport function toUser(record: UserRecord): AnyUser {\n\treturn {\n\t\t...record.fields,\n\t\tid: record.id,\n\t\ttype: record.type,\n\t\temailVerified: record.emailVerifiedAt !== null,\n\t\tactive: record.active,\n\t\thasPassword: record.password !== null,\n\t\thasSecondFactor: record.secondFactor?.confirmedAt != null,\n\t\tversion: record.version,\n\t\tcreatedAt: record.createdAt,\n\t\tupdatedAt: record.updatedAt,\n\t};\n}\n\n/** The id a {@link UserRef} names. */\nexport const idOf = (user: UserRef): string =>\n\ttypeof user === 'string' ? user : user.id;\n\n/**\n * Validates fields against the type's schema, and answers them as the port\n * holds them: an `undefined` property dropped, at every depth.\n */\nexport async function validateFields(\n\ttype: ResolvedType,\n\tinput: unknown,\n\twhere: string,\n): Promise<JsonObject> {\n\tconst result = await type.schema['~standard'].validate(input);\n\n\tconst issues: Issue[] =\n\t\tresult.issues === undefined\n\t\t\t? []\n\t\t\t: result.issues.map((issue) => ({\n\t\t\t\t\tpath: storablePrefix((issue.path ?? []).map(segmentKey)),\n\t\t\t\t\tmessage: issue.message,\n\t\t\t\t}));\n\n\t// A schema that passes unknown keys through would let a request set `id` or\n\t// `active` among the fields. `toUser` would shadow them anyway; refusing\n\t// them says so to whoever sent them.\n\tif (result.issues === undefined) {\n\t\tconst value = result.value as Record<string, unknown>;\n\t\tfor (const key of RESERVED_FIELDS) {\n\t\t\tif (Object.hasOwn(value, key)) {\n\t\t\t\tissues.push({ path: [key], message: 'set by janus, not by a request' });\n\t\t\t}\n\t\t}\n\t\tunstorableIn(value, [], issues);\n\t}\n\n\tif (issues.length > 0) {\n\t\t// The message reports how many and where, never the values: a field may\n\t\t// be anything the application chose to store, including something\n\t\t// private.\n\t\tthrow new UserInvalidError(\n\t\t\t`${where}: the fields do not match the ${type.name} schema (${issues.length} issue${issues.length === 1 ? '' : 's'}, at ${issues.map((i) => i.path.join('.') || '(root)').join(', ')})`,\n\t\t\t{ issues, userType: type.name },\n\t\t);\n\t}\n\n\treturn withoutUndefined((result as { value: unknown }).value) as JsonObject;\n}\n\n/** Pushes an issue for every key and every string no store can keep. */\nfunction unstorableIn(\n\tvalue: unknown,\n\tpath: (string | number)[],\n\tissues: Issue[],\n): void {\n\tif (typeof value === 'string') {\n\t\tif (!isStorable(value)) issues.push({ path, message: UNSTORABLE });\n\t\treturn;\n\t}\n\tif (Array.isArray(value)) {\n\t\tfor (const [index, inner] of value.entries()) {\n\t\t\tunstorableIn(inner, [...path, index], issues);\n\t\t}\n\t\treturn;\n\t}\n\tif (typeof value === 'object' && value !== null) {\n\t\tfor (const [key, inner] of Object.entries(value)) {\n\t\t\t// The key itself stays out of the path: the path reaches the message,\n\t\t\t// and a NUL has no business in a log line.\n\t\t\tif (!isStorable(key)) {\n\t\t\t\tissues.push({ path, message: `a key ${UNSTORABLE}` });\n\t\t\t} else {\n\t\t\t\tunstorableIn(inner, [...path, key], issues);\n\t\t\t}\n\t\t}\n\t}\n}\n\n/**\n * A path up to its first key no store can keep: the path reaches the message,\n * and a NUL has no business in a log line.\n */\nfunction storablePrefix(path: (string | number)[]): (string | number)[] {\n\tconst cut = path.findIndex(\n\t\t(segment) => typeof segment === 'string' && !isStorable(segment),\n\t);\n\treturn cut === -1 ? path : path.slice(0, cut);\n}\n\nfunction segmentKey(\n\tsegment: PropertyKey | StandardSchemaV1.PathSegment,\n): string | number {\n\tconst key = typeof segment === 'object' ? segment.key : segment;\n\treturn typeof key === 'number' ? key : String(key);\n}\n\nfunction withoutUndefined(value: unknown): Json {\n\tif (Array.isArray(value)) return value.map(withoutUndefined);\n\n\tif (typeof value === 'object' && value !== null) {\n\t\tconst out: Record<string, Json> = {};\n\t\tfor (const [key, inner] of Object.entries(value)) {\n\t\t\tif (inner !== undefined) out[key] = withoutUndefined(inner);\n\t\t}\n\t\treturn out;\n\t}\n\n\treturn value as Json;\n}\n\n/** The user's e-mail, as stored, or `null` when the schema let it be absent. */\nexport function emailOf(type: ResolvedType, fields: JsonObject): string | null {\n\tconst value = fields[type.email];\n\treturn typeof value === 'string' ? value : null;\n}\n\n/**\n * What a user signs in with, normalised: the login field, by its own rule,\n * and the e-mail, by the e-mail rule — so a user whose login is a username can\n * still be found by the e-mail a reset is requested for.\n */\nexport function loginsOf(\n\ttype: ResolvedType,\n\tfields: JsonObject,\n\twhere: string,\n): string[] {\n\tconst logins = new Set<string>();\n\n\tif (type.password !== null) {\n\t\tconst login = fields[type.password.login];\n\t\tif (typeof login !== 'string') {\n\t\t\tthrow new TypeError(\n\t\t\t\t`janus: password.login \"${type.password.login}\" did not name a string in validated ${type.name} fields — it must name a required string field`,\n\t\t\t);\n\t\t}\n\t\tconst normalized = type.password.normalize(login);\n\t\t// The fields were checked; a function normaliser can still cut a\n\t\t// surrogate pair in half. Refused here, or the user is written with a\n\t\t// login nobody can sign in with — or not written at all, on PostgreSQL.\n\t\tif (!isStorable(normalized)) {\n\t\t\tconst path = [type.password.login];\n\t\t\tthrow new UserInvalidError(\n\t\t\t\t`${where}: the fields do not match the ${type.name} schema (1 issue, at ${path.join('.')})`,\n\t\t\t\t{\n\t\t\t\t\tissues: [\n\t\t\t\t\t\t{ path, message: `normalises to a login that ${UNSTORABLE}` },\n\t\t\t\t\t],\n\t\t\t\t\tuserType: type.name,\n\t\t\t\t},\n\t\t\t);\n\t\t}\n\t\tlogins.add(normalized);\n\t}\n\n\tconst email = emailOf(type, fields);\n\tif (email !== null) logins.add(normalizeEmail(email));\n\n\treturn [...logins];\n}\n\n/** The type's password rule, or a wiring refusal for a JavaScript caller. */\nexport function passwordRule(type: ResolvedType, where: string) {\n\tif (type.password === null) {\n\t\tthrow new TypeError(\n\t\t\t`${where}: the ${type.name} type does not sign in with a password — add password: { login } to it`,\n\t\t);\n\t}\n\treturn type.password;\n}\n\n/** Refuses a password shorter than the policy. Reports the policy, never the password. */\nexport function checkPassword(\n\ttype: ResolvedType,\n\tpassword: string,\n\twhere: string,\n): void {\n\tconst minLength = type.password?.minLength ?? 8;\n\tif (typeof password !== 'string' || password.length < minLength) {\n\t\tthrow new CredentialError(\n\t\t\t'PASSWORD_TOO_SHORT',\n\t\t\t`${where}: the password is shorter than the policy's ${minLength} characters`,\n\t\t\t{ minLength, userType: type.name },\n\t\t);\n\t}\n}\n\n/** The hasher. `janus()` refused a password type without one, so this is a wiring net. */\nexport function requireHasher(context: Context, where: string): PasswordHasher {\n\tif (context.hasher === null) {\n\t\tthrow new TypeError(\n\t\t\t`${where}: no password hasher is wired — pass hasher: scryptHasher(), or bunHasher() on Bun`,\n\t\t);\n\t}\n\treturn context.hasher;\n}\n\n/**\n * Whether `password` matches the stored hash. A hash whose prefix no wired\n * verifier claims is `HASH_UNSUPPORTED`, reporting the prefix, never the hash.\n */\nexport async function passwordMatches(\n\tcontext: Context,\n\trecord: UserRecord,\n\tpassword: string,\n\twhere: string,\n): Promise<boolean> {\n\tconst stored = record.password;\n\tif (stored === null) return false;\n\n\tconst verifier = context.verifiers.find((candidate) =>\n\t\tstored.hash.startsWith(candidate.prefix),\n\t);\n\tif (verifier === undefined) {\n\t\tconst hashPrefix = /^\\$[^$]*\\$/.exec(stored.hash)?.[0] ?? '(none)';\n\t\tthrow new CredentialError(\n\t\t\t'HASH_UNSUPPORTED',\n\t\t\t`${where}: no wired verifier claims the prefix \"${hashPrefix}\"`,\n\t\t\t{ hashPrefix, userId: record.id, userType: record.type },\n\t\t);\n\t}\n\n\treturn verifier.verify(password, stored.hash);\n}\n\n/**\n * The record, with its password hash rewritten by the current hasher when the\n * stored one is stale: written by another hasher — a `verifiers` one — or by\n * this one with other parameters. Called only once `password` has matched.\n *\n * The rewrite is conditioned on the version just read. Losing that race to a\n * concurrent update is not the sign-in's failure: the record is answered as\n * read, and the next sign-in tries again. A store failure still throws.\n */\nexport async function rehashed(\n\tcontext: Context,\n\trecord: UserRecord,\n\tpassword: string,\n): Promise<UserRecord> {\n\tconst { hasher } = context;\n\tconst stored = record.password;\n\tif (hasher === null || stored === null) return record;\n\n\tconst stale =\n\t\t!stored.hash.startsWith(hasher.prefix) ||\n\t\thasher.needsRehash?.(stored.hash) === true;\n\tif (!stale) return record;\n\n\tconst written = await unlessVersionConflict(\n\t\tcontext.store.users.updateUser(\n\t\t\trecord.id,\n\t\t\t{\n\t\t\t\tupdatedAt: context.clock.now(),\n\t\t\t\t// The same password, so the same `updatedAt`: when it was *set*.\n\t\t\t\tpassword: {\n\t\t\t\t\thash: await hasher.hash(password),\n\t\t\t\t\tupdatedAt: stored.updatedAt,\n\t\t\t\t},\n\t\t\t},\n\t\t\trecord.version,\n\t\t),\n\t);\n\treturn written ?? record;\n}\n\nconst notFound = (where: string, id: string, type: string | null) =>\n\tnew NotFoundError(\n\t\t`${where}: no ${type ?? 'user'} has this id`,\n\t\ttype === null\n\t\t\t? { userId: id, operation: where }\n\t\t\t: { userId: id, userType: type, operation: where },\n\t);\n\n/**\n * The user — of this type, when one is given — or `null`. A malformed id is an\n * absence decided without reaching the store: it arrives off a URL, and it is\n * \"no such user\", not a query and not an outage. A user of another type is\n * absent too: `auth.staff.find(patientId)` finds nobody.\n */\nexport async function findRecord(\n\tcontext: Context,\n\tid: string,\n\ttype: string | null,\n): Promise<UserRecord | null> {\n\tif (!isId(id)) return null;\n\tconst record = await context.store.users.findUser(id);\n\treturn record !== null && (type === null || record.type === type)\n\t\t? record\n\t\t: null;\n}\n\n/** The user, or `NOT_FOUND`. */\nexport async function getRecord(\n\tcontext: Context,\n\tid: string,\n\ttype: string | null,\n\twhere: string,\n): Promise<UserRecord> {\n\treturn required(await findRecord(context, id, type), () =>\n\t\tnotFound(where, id, type),\n\t);\n}\n\n/**\n * One write that follows a read, under a version: the core reads the user,\n * checks `ifVersion` against what it read, computes the patch from the record,\n * and writes under the version it read. A user who changed in between is\n * `VERSION_CONFLICT`, and nothing is written.\n */\nexport async function writeUser(\n\tcontext: Context,\n\tuser: UserRef,\n\ttype: ResolvedType,\n\toptions: WriteOptions | undefined,\n\twhere: string,\n\tpatchOf: (\n\t\trecord: UserRecord,\n\t\tnow: Date,\n\t) => Omit<UserPatch, 'updatedAt'> | Promise<Omit<UserPatch, 'updatedAt'>>,\n): Promise<UserRecord> {\n\tconst id = idOf(user);\n\tconst record = await getRecord(context, id, type.name, where);\n\tconst ifVersion = options?.ifVersion;\n\n\tif (ifVersion !== undefined && ifVersion !== record.version) {\n\t\tthrow new StoreConflict(\n\t\t\t'version',\n\t\t\t`${where}: expected version ${ifVersion}, found ${record.version}`,\n\t\t\t{\n\t\t\t\tuserId: id,\n\t\t\t\texpectedVersion: ifVersion,\n\t\t\t\tactualVersion: record.version,\n\t\t\t\toperation: where,\n\t\t\t},\n\t\t);\n\t}\n\n\tconst now = context.clock.now();\n\tconst patch = await patchOf(record, now);\n\treturn context.store.users.updateUser(\n\t\trecord.id,\n\t\t{ ...patch, updatedAt: now },\n\t\trecord.version,\n\t);\n}\n\n/**\n * The user of this type whose e-mail this is, or `null`. Looked up as a\n * login — every e-mail is one — and then compared, so a username that only\n * looks like an e-mail is nobody's.\n */\nexport async function holderOfEmail(\n\tcontext: Context,\n\ttype: ResolvedType,\n\temail: string,\n): Promise<UserRecord | null> {\n\tconst wanted = normalizeEmail(email);\n\tif (!isStorable(wanted)) return null;\n\tconst record = await context.store.users.findUserByLogin(type.name, wanted);\n\tconst held = record === null ? null : emailOf(type, record.fields);\n\treturn held !== null && normalizeEmail(held) === wanted ? record : null;\n}\n",
|
package/docs/guide/events.md
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
This page is for hearing what happens to a user once it is written: created,
|
|
4
4
|
e-mail verified, password reset, deleted. Another service can then follow
|
|
5
5
|
without polling. `janus` hands each event to one function you give it;
|
|
6
|
-
**delivering it is yours
|
|
6
|
+
**delivering it is yours** — or
|
|
7
|
+
[`@nxgt/janus-webhooks`](https://www.npmjs.com/package/@nxgt/janus-webhooks)'s:
|
|
8
|
+
`webhooks({ endpoints })` signs and posts each event.
|
|
7
9
|
|
|
8
10
|
```ts
|
|
9
11
|
import { z } from 'zod';
|
package/docs/roadmap.md
CHANGED
|
@@ -36,13 +36,6 @@ Nothing between releases.
|
|
|
36
36
|
catalogue, or replace any one template with your own function of the same
|
|
37
37
|
shape — built with the same toolkit, React Email or a plain string — and
|
|
38
38
|
keep the defaults for the rest.
|
|
39
|
-
- **Webhooks** — in a package of its own, `@nxgt/janus-webhooks`: the user
|
|
40
|
-
events `janus({ events })` already hands over, signed and sent over HTTP,
|
|
41
|
-
so another service can follow without polling. A signature it can check —
|
|
42
|
-
the Standard Webhooks headers, HMAC-SHA256, secrets that rotate — retries
|
|
43
|
-
with backoff on failure, and retries that run out reported to a function
|
|
44
|
-
you give, never dropped in silence. The payload is the event: the user
|
|
45
|
-
named by id, every key camelCase.
|
|
46
39
|
|
|
47
40
|
## Later
|
|
48
41
|
|
|
@@ -94,6 +87,11 @@ Nothing between releases.
|
|
|
94
87
|
The last ten, newest first, each with the version it came in. Everything
|
|
95
88
|
before is in the [CHANGELOG](../CHANGELOG.md).
|
|
96
89
|
|
|
90
|
+
- **Webhooks, `@nxgt/janus-webhooks` v0.1.0** — a package of its own: the
|
|
91
|
+
user events `janus({ events })` hands over, signed by the Standard
|
|
92
|
+
Webhooks specification (HMAC-SHA256, secrets that rotate) and posted to
|
|
93
|
+
your endpoints, retried with backoff, and reported to `onGivingUp` when
|
|
94
|
+
given up — never dropped in silence. `verifyWebhook` is the receiving side.
|
|
97
95
|
- **User events, v0.8.0** — `janus({ events })` takes one listener, called
|
|
98
96
|
with `user.created`, `user.emailVerified`, `user.passwordReset` and
|
|
99
97
|
`user.deleted` once the write landed, and awaited before the flow answers.
|
|
@@ -162,6 +160,3 @@ before is in the [CHANGELOG](../CHANGELOG.md).
|
|
|
162
160
|
`defineModel`, with a message naming the new key —
|
|
163
161
|
`types.team.relations is now related: rename the key`. The `permissions()`
|
|
164
162
|
function and `janus({ relations })` keep their names. — v0.2.0
|
|
165
|
-
- **`LOGIN_TAKEN` no longer quotes the login in its message**, in the memory
|
|
166
|
-
store and in both adapters; `error.login` still names it, and the
|
|
167
|
-
conformance suite checks it. — v0.2.0
|