{"id":51405290,"url":"https://github.com/alyldas/uniauth-core","last_synced_at":"2026-07-04T10:31:41.936Z","repository":{"id":360697950,"uuid":"1250377707","full_name":"alyldas/uniauth-core","owner":"alyldas","description":"Headless UniAuth core contracts, policies, facades, and orchestration.","archived":false,"fork":false,"pushed_at":"2026-05-27T14:11:30.000Z","size":291,"stargazers_count":0,"open_issues_count":1,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-27T15:04:41.385Z","etag":null,"topics":["auth","authentication","headless","identity","security","typescript","uniauth"],"latest_commit_sha":null,"homepage":"https://github.com/alyldas/uniauth-core#readme","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"other","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/alyldas.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":"docs/support-inspection.md","governance":null,"roadmap":"docs/roadmap.md","authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":"NOTICE","maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null}},"created_at":"2026-05-26T15:12:38.000Z","updated_at":"2026-05-27T13:30:17.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/alyldas/uniauth-core","commit_stats":null,"previous_names":["alyldas/uniauth-core"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/alyldas/uniauth-core","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/alyldas%2Funiauth-core","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/alyldas%2Funiauth-core/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/alyldas%2Funiauth-core/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/alyldas%2Funiauth-core/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/alyldas","download_url":"https://codeload.github.com/alyldas/uniauth-core/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/alyldas%2Funiauth-core/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35118970,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-04T02:00:05.987Z","response_time":113,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["auth","authentication","headless","identity","security","typescript","uniauth"],"created_at":"2026-07-04T10:31:41.726Z","updated_at":"2026-07-04T10:31:41.914Z","avatar_url":"https://github.com/alyldas.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# UniAuth Core\n\n[![CI](https://github.com/alyldas/uniauth-core/actions/workflows/ci.yml/badge.svg)](https://github.com/alyldas/uniauth-core/actions/workflows/ci.yml)\n[![Version](https://img.shields.io/github/package-json/v/alyldas/uniauth-core?label=version)](package.json)\n[![License](https://img.shields.io/badge/license-PolyForm%20Strict%201.0.0-blue)](LICENSE)\n[![TypeScript](https://img.shields.io/badge/TypeScript-ready-3178c6?logo=typescript\u0026logoColor=white)](https://www.typescriptlang.org/)\n[![GitHub Packages](https://img.shields.io/static/v1?label=GitHub%20Packages\u0026message=%40alyldas%2Funiauth-core\u0026color=24292f\u0026logo=github)](https://github.com/users/alyldas/packages/npm/package/uniauth-core)\n\n`@alyldas/uniauth-core` is an ESM-only headless identity orchestration core for TypeScript and Node.js.\n\nIt models users, identities, credentials, verifications, sessions, account linking policy, and\nstorage/provider ports without owning UI, HTTP routes, cookies, an ORM, or a hosted auth service.\n\nThe package is source-available under the PolyForm Strict License 1.0.0. Commercial use,\nredistribution, making changes, or creating new works based on the software require a separate paid\nlicense, subscription, private contract, or other written permission.\n\n## What This Package Does\n\n- Models `User`, `AuthIdentity`, `Credential`, `Verification`, and `Session` separately.\n- Treats email and phone as optional identity attributes, not mandatory user fields.\n- Orchestrates `signIn`, `link`, `unlink`, `mergeAccounts`, verification, and session revocation.\n- Starts and finishes generic OTP challenges over email or phone through sender ports.\n- Resends OTP challenges, magic links, and password-recovery links through explicit execution APIs.\n- Starts and finishes email magic-link sign-in on the shared verification lifecycle.\n- Hashes password credentials through a password hasher port, stores them through a credential repo,\n  and signs in with a local password identity.\n- Starts and finishes email password recovery on the shared verification lifecycle.\n- Validates signed Telegram Mini App and MAX WebApp `initData` without provider SDK lock-in.\n- Maps SDK-free OAuth/OIDC provider profiles into the existing provider sign-in pipeline.\n- Creates local session records after successful sign-in.\n- Exposes local session read-side helpers for token resolution, trusted session-context resolution,\n  activity touch, and user session listing.\n- Exposes a narrow `getUser(userId)` helper for loading the active local user snapshot by id.\n- Exposes read-side helpers for credential and verification lookups through the public service\n  layer.\n- Exposes trusted resend/cooldown reads for verification-backed OTP, magic-link, and recovery\n  flows.\n- Exposes trusted cancellation APIs for verification-backed OTP, magic-link, and recovery flows.\n- Exposes trusted resend execution APIs for verification-backed OTP, magic-link, and recovery\n  flows.\n- Exposes public audit-event slice and page read-side APIs for trusted security timelines and\n  support tooling.\n- Exposes safe projection helpers for account-security and verification-status read-side flows.\n- Exposes an aggregated `getAccountSecuritySnapshot(userId)` read-side API for account-security\n  screens.\n- Exposes a current-account aggregate helper and token-based self-service session revoke helpers for\n  trusted account-security routes after transport resolution.\n- Exposes current-account inspection aggregate and audit-page helpers for self-service security\n  routes that already trust a local session token.\n- Exposes token-based current-account write-side helpers for sign-in-method link/unlink,\n  selected-session revoke, verified contact changes, account closure, and local password setup or\n  change after transport resolution.\n- Exposes current-account OTP and password re-auth helpers for self-service recent-auth bootstrapping\n  on the trusted local session-token boundary.\n- Exposes a trusted `getAccountInspectionSnapshot({ userId, audit? })` aggregate read-side\n  API for backend support and admin inspection flows.\n- Supports bulk local session revocation for sign-out-all-devices style account-security flows.\n- Uses explicit policy for auto-linking, unlinking, re-auth, and account merge decisions.\n- Runs transaction-aware account merge over identities, credentials, sessions, and audit decisions\n  when the configured `UnitOfWork` supports atomic rollback.\n- Exposes ports for repositories, providers, sender infrastructure, rate limits, password hashing,\n  audit logs, and transactions.\n- Exposes public rate-limit helpers for key composition and typed rate-limit error inspection.\n- Ships an in-memory testing implementation through `@alyldas/uniauth-core/testing`.\n\n## What It Does Not Do\n\n- It is not a hosted auth service.\n- It does not ship frontend pages or UI components.\n- It does not include Express, Fastify, Nest, Nuxt, or Next handlers in core.\n- It does not generate one mandatory ORM schema.\n- It does not include SMTP, SMS gateway, OAuth/OIDC, Telegram, or MAX SDKs, bot setup, webhook\n  handlers, or frontend bridge code in core.\n- It does not embed Better Auth or Auth.js runtime, session stores, cookies, callbacks, or plugin\n  wiring into core.\n- It does not send messages by itself; OTP, magic-link, and recovery delivery use sender ports you\n  provide.\n- It does not bundle a password hashing runtime; password hashing uses a `PasswordHasher` adapter you\n  provide.\n- It does not ship a production Redis, database, or edge rate limiter.\n- It does not silently merge two existing users by email.\n\n## Diagrams\n\n```mermaid\nflowchart LR\n  Provider[\"AuthProvider\u003cbr/\u003eexternal assertion\"] --\u003e Registry[\"ProviderRegistry\u003cbr/\u003eSDK-free lookup\"]\n  Registry --\u003e Service[\"AuthService\u003cbr/\u003epublic / account / admin\"]\n  Policy[\"AuthPolicy\u003cbr/\u003eauto-link / merge / re-auth\"] --\u003e Service\n  RateLimiter[\"RateLimiter\u003cbr/\u003eattempt buckets\"] --\u003e Service\n  Service --\u003e Repos[\"Repositories\u003cbr/\u003eusers / identities / credentials / sessions / verifications\"]\n  Service --\u003e Audit[\"AuditLog\u003cbr/\u003esecurity events\"]\n  Service --\u003e Senders[\"Sender ports\u003cbr/\u003eemail / SMS\"]\n  Testing[\"@alyldas/uniauth-core/testing\u003cbr/\u003ein-memory kit\"] --\u003e Service\n```\n\n`DefaultAuthService` keeps the flat method surface for compatibility, but the canonical route\nsurface is grouped by security boundary:\n\n```ts\nawait auth.public.password.signIn({ email, password })\nawait auth.public.otp.start({ target, channel })\nawait auth.public.magicLink.finish({ verificationId, secret })\nawait auth.public.passwordRecovery.start({ email, createLink })\n\nawait auth.account.profile.update({ sessionToken, displayName })\nawait auth.account.contact.start({ sessionToken, target, channel })\nawait auth.account.password.change({ sessionToken, currentPassword, newPassword })\nawait auth.account.reAuth.assert({ sessionToken, reAuthenticatedAt })\nawait auth.account.sessions.revokeOther({ sessionToken })\nawait auth.account.security.snapshot({ sessionToken })\n\nawait auth.admin.users.get(userId)\nawait auth.admin.users.sessions(userId)\nawait auth.admin.accounts.merge({ sourceUserId, targetUserId, sourceSessionToken })\nawait auth.admin.verifications.get(verificationId)\nawait auth.admin.audit.page({ userId })\n```\n\n`auth.public` sign-in methods return `PublicAuthResult`: user, identity, and session views plus the\none-time `sessionToken`, without server-only hashes such as `tokenHash`, `passwordHash`, or\n`secretHash`.\n\n```mermaid\nsequenceDiagram\n  participant App\n  participant Provider as AuthProvider\n  participant Service as AuthService\n  participant Policy as AuthPolicy\n  participant Store as Repositories\n  participant Audit\n\n  App-\u003e\u003eProvider: finish provider flow\n  Provider--\u003e\u003eApp: ProviderIdentityAssertion\n  App-\u003e\u003eService: signIn(assertion)\n  Service-\u003e\u003eStore: find exact (provider, providerUserId)\n  alt identity exists\n    Store--\u003e\u003eService: existing user and identity\n  else new provider identity\n    Service-\u003e\u003ePolicy: canAutoLink(assertion, candidates)\n    Policy--\u003e\u003eService: allow or deny\n    Service-\u003e\u003eStore: create user or link identity\n  end\n  Service-\u003e\u003eStore: create local session\n  Service-\u003e\u003eAudit: auth.session_created\n  Service--\u003e\u003eApp: neutral PublicAuthResult\n```\n\n```mermaid\nflowchart TB\n  Exact[\"Exact provider identity wins\u003cbr/\u003e(provider, providerUserId)\"]:::safe\n  Merge[\"No silent merge\u003cbr/\u003eemail/profile hints are not ownership\"]:::danger\n  PolicyGate[\"Auto-link only through explicit policy\"]:::warn\n  LastIdentity[\"Cannot unlink the last active sign-in method\"]:::safe\n  Secrets[\"Verification secrets are stored as hashes\"]:::safe\n  Neutral[\"Public responses stay neutral\"]:::safe\n\n  Exact --\u003e PolicyGate\n  Merge --\u003e PolicyGate\n  PolicyGate --\u003e LastIdentity\n  PolicyGate --\u003e Secrets\n  Secrets --\u003e Neutral\n\n  classDef safe fill:#ecfdf5,stroke:#059669,color:#064e3b\n  classDef warn fill:#fffbeb,stroke:#d97706,color:#78350f\n  classDef danger fill:#fef2f2,stroke:#dc2626,color:#7f1d1d\n```\n\n## Install\n\nInstall from GitHub Packages:\n\nConfigure the GitHub Packages registry for the package scope before installing:\n\n```text\n@alyldas:registry=https://npm.pkg.github.com\n```\n\nGitHub Packages can require authentication for package reads. Use a token with `read:packages` in\nlocal npm config or CI secrets; do not commit tokens.\n\n```sh\nnpm install @alyldas/uniauth-core\n```\n\n## Runtime Contract\n\nThe package targets modern ESM TypeScript consumers on Node.js 22 or newer.\nThe repository keeps `.node-version` as the local Node.js 22 runtime marker.\n\nCore imports come from the root entry point:\n\n```ts\nimport {\n  DefaultAuthService,\n  EMAIL_MAGIC_LINK_PROVIDER_ID,\n  EMAIL_OTP_PROVIDER_ID,\n  OtpChannel,\n  PASSWORD_PROVIDER_ID,\n  ProviderTrustLevel,\n  RateLimitAction,\n  UniAuthError,\n  UniAuthErrorCode,\n  VerificationPurpose,\n  compatibilityAuthNormalizer,\n  createAuthNormalizer,\n  createDefaultAuthPolicy,\n  createHmacSecretHasher,\n  createScryptSecretHasher,\n  getRateLimitedErrorDetails,\n  isUniAuthError,\n  rateLimitKey,\n  toAccountSecuritySnapshot,\n  toVerificationResendWindow,\n  toVerificationStatusView,\n  type AuthNormalizer,\n  type AuthProvider,\n  type AuthService,\n  type EmailMagicLink,\n  type EmailPasswordRecoveryLink,\n  type PasswordHasher,\n  type PasswordPolicy,\n  type ProviderIdentityAssertion,\n  type RateLimiter,\n  type SecretHasher,\n} from '@alyldas/uniauth-core'\n```\n\nTesting helpers come from the explicit testing entry point:\n\n```ts\nimport {\n  InMemoryEmailSender,\n  InMemoryPasswordHasher,\n  InMemoryRateLimiter,\n  InMemorySmsSender,\n  StaticAuthProvider,\n  createInMemoryAuthKit,\n} from '@alyldas/uniauth-core/testing'\n```\n\nShared runtime and infrastructure contracts can come from a dedicated contracts entry point:\n\n```ts\nimport type {\n  AuthNormalizer,\n  AuthProvider,\n  AuthServiceInfrastructure,\n  Clock,\n  IdGenerator,\n  PasswordHasher,\n  PasswordPolicy,\n  ProviderRegistry,\n  RateLimiter,\n  SecretHasher,\n  UnitOfWork,\n} from '@alyldas/uniauth-core/contracts'\n```\n\nThere are no root side effects. Importing the package does not register providers, touch storage,\ncreate sessions, read environment variables, or mutate global state.\n\nNormalization can be shared through one optional runtime boundary:\n\n```ts\nconst strictNormalizer: AuthNormalizer = createAuthNormalizer({\n  normalizeEmail(email) {\n    const normalized = compatibilityAuthNormalizer.normalizeEmail(email)\n\n    if (!/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/u.test(normalized)) {\n      throw new UniAuthError(UniAuthErrorCode.InvalidInput, 'Email is invalid.')\n    }\n\n    return normalized\n  },\n  normalizePhone(phone) {\n    const digits = phone.replace(/\\D+/g, '')\n    const normalized = digits.length === 10 ? `+1${digits}` : `+${digits}`\n\n    if (!/^\\+[1-9]\\d{7,14}$/u.test(normalized)) {\n      throw new UniAuthError(UniAuthErrorCode.InvalidInput, 'Phone is invalid.')\n    }\n\n    return normalized\n  },\n})\n\nconst { service } = createInMemoryAuthKit({\n  normalizer: strictNormalizer,\n})\n```\n\nThe service contract is policy-driven:\n\n```ts\nconst policy = {\n  ...createDefaultAuthPolicy({\n    allowAutoLink: true,\n    allowMergeAccounts: false,\n  }),\n  canAutoLink(context) {\n    return (\n      context.assertion.trust?.level === ProviderTrustLevel.Trusted \u0026\u0026\n      context.existingIdentities.every(\n        (identity) =\u003e identity.trust?.level !== ProviderTrustLevel.Untrusted,\n      )\n    )\n  },\n}\n\nconst { service } = createInMemoryAuthKit({\n  policy,\n})\n\nconst result = await service.public.provider.signIn({\n  assertion: {\n    provider: 'email-otp',\n    providerUserId: 'alice@example.com',\n    email: 'alice@example.com',\n    emailVerified: true,\n    trust: {\n      level: ProviderTrustLevel.Trusted,\n      signals: ['first-party-email'],\n    },\n  },\n})\n```\n\nFor account-security or verification-status pages, prefer the built-in read-side and projection\nhelpers instead of serializing raw entities directly:\n\n```ts\nconst snapshot = await service.admin.users.securitySnapshot(userId)\n\nconst verificationStatus = toVerificationStatusView(verification)\n```\n\nWhen the caller is already authenticated by a trusted local `sessionToken`, prefer the aggregate\nhelper instead of manually composing `resolveSessionContext(...)` and `getAccountSecuritySnapshot(...)`:\n\n```ts\nconst current = await service.account.security.snapshot({\n  sessionToken,\n  touch: true,\n})\n```\n\nWhen the current account page also needs a bounded self-service audit timeline, prefer the current\ninspection aggregate instead of mixing current-account and admin-oriented helpers:\n\n```ts\nconst currentInspection = await service.account.inspection.snapshot({\n  sessionToken,\n  touch: true,\n  audit: {\n    limit: 20,\n  },\n})\n```\n\nBefore destructive account closure, a self-service route can also stay on the trusted token\nboundary and return a safe pre-closure auth snapshot:\n\n```ts\nconst closureExport = await service.account.inspection.closureExport({\n  sessionToken,\n  audit: {\n    limit: 50,\n  },\n})\n```\n\nTrusted backend tooling can start from one trusted aggregate inspection helper:\n\n```ts\nconst inspection = await service.admin.users.inspectionSnapshot({\n  userId,\n  audit: {\n    limit: 20,\n  },\n})\n```\n\nIf the surrounding tooling truly needs raw audit entities, custom filters, or metadata-aware\nserialization, `getAuditEvents(...)` and `getAuditEventPage(...)` remain available as the narrower\nread-side primitives. Self-service current-account routes that need timeline pagination can stay on\nthe trusted token boundary through `account.inspection.auditPage(...)`.\n\nThe same trusted token boundary can also own self-service account mutations:\n\n```ts\nawait service.account.profile.update({\n  sessionToken,\n  displayName,\n  reAuthenticatedAt,\n})\n\nconst contactChange = await service.account.contact.start({\n  sessionToken,\n  channel: OtpChannel.Email,\n  target: newEmail,\n  reAuthenticatedAt,\n})\n\nawait service.account.contact.finish({\n  sessionToken,\n  verificationId: contactChange.verificationId,\n  secret: code,\n})\n\nawait service.account.sessions.revokeOwned({\n  sessionToken,\n  targetSessionId,\n})\n\nawait service.account.identities.unlink({\n  sessionToken,\n  identityId,\n  reAuthenticatedAt,\n})\n\nawait service.account.identities.link({\n  sessionToken,\n  assertion,\n  reAuthenticatedAt,\n})\n\nawait service.account.closure.close({\n  sessionToken,\n  reAuthenticatedAt,\n})\n```\n\nThe same trusted boundary can bootstrap recent-auth proof for those sensitive current-account\nmutations:\n\n```ts\nconst challenge = await service.account.reAuth.startOtp({\n  sessionToken,\n  identityId,\n  channel: OtpChannel.Email,\n})\n\nconst resent = await service.account.reAuth.resendOtp({\n  sessionToken,\n  verificationId: challenge.verificationId,\n})\n\nconst otpConfirmation = await service.account.reAuth.finishOtp({\n  sessionToken,\n  verificationId: resent.verificationId,\n  secret: codeFromUserInput,\n})\n\nconst passwordConfirmation = await service.account.reAuth.confirmPassword({\n  sessionToken,\n  currentPassword,\n})\n\npasswordConfirmation.currentSessionId\npasswordConfirmation.reAuthenticatedAt\n\nconst reAuthStatus = await service.account.reAuth.status({\n  sessionToken,\n  action: AuthPolicyAction.ChangePassword,\n  reAuthenticatedAt: otpConfirmation.reAuthenticatedAt,\n})\n```\n\nTrusted resend and cooldown polling can use one read-side helper per verification:\n\n```ts\nconst resendWindow = await service.admin.verifications.resendWindow({\n  verificationId,\n  cooldownSeconds: 60,\n})\n```\n\nProvider adapters are external integration code. They should return a validated\n`ProviderIdentityAssertion`, while SDK setup, OAuth/OIDC callbacks, messenger launch validation,\ntoken persistence, cookies, and redirects stay application-owned or adapter-package-owned.\n\nPersistence lives in external adapter packages or application-owned repository implementations so\nthe core API stays ORM-free and does not take a hard runtime dependency on a database driver.\n\nPublic error helpers use the `UniAuth` brand casing:\n\n```ts\ntry {\n  await service.public.provider.signIn({})\n} catch (error) {\n  if (isUniAuthError(error) \u0026\u0026 error.code === UniAuthErrorCode.InvalidInput) {\n    throw new UniAuthError(UniAuthErrorCode.InvalidInput, error.message)\n  }\n}\n```\n\nRate limiting is optional and app-owned. Core calls a `RateLimiter` port before security-sensitive\nattempts and turns a denied decision into a stable `UniAuthErrorCode.RateLimited` error without\ncreating users, sessions, or consuming OTP verifications. Production bootstraps can set\n`requireRateLimiter: true` so local auth flows fail fast if a limiter was not wired. Password\nstrength checks are also optional and app-owned through `PasswordPolicy`; production bootstraps can\nset `requirePasswordPolicy: true` to fail fast when password flows are enabled without that policy.\n\n```ts\nconst rateLimiter: RateLimiter = {\n  async consume(input) {\n    if (input.action === RateLimitAction.OtpStart) {\n      return { allowed: false, retryAfterSeconds: 60 }\n    }\n\n    return { allowed: true }\n  },\n}\n\nconst { service } = createInMemoryAuthKit({ rateLimiter })\n```\n\nOTP sign-in is still headless: `public.otp.start` creates a hashed verification secret and sends the\nplain code through the configured sender port; `public.otp.signIn` consumes the code once and creates\na local session. Email and phone OTP both use this unified API.\n\n```ts\nconst { service } = createInMemoryAuthKit()\n\nconst challenge = await service.public.otp.start({\n  purpose: VerificationPurpose.SignIn,\n  channel: OtpChannel.Email,\n  target: 'alice@example.com',\n  secret: '123456',\n})\n\nconst result = await service.public.otp.signIn({\n  verificationId: challenge.verificationId,\n  secret: '123456',\n  channel: OtpChannel.Email,\n})\n\nconsole.log(result.identity.provider === EMAIL_OTP_PROVIDER_ID)\n```\n\nCore-owned verification routing fields, such as `provider` and `channel`, are stored separately\nfrom app-owned `metadata`.\n\nOTP generation and the built-in email subject are configurable through service options. A\nper-request `secret` still wins over the configured generator.\n\n```ts\nconst { service } = createInMemoryAuthKit({\n  emailOtpSubject: 'Your Example App code',\n  otpSecretLength: 8,\n  otpSecretGenerator: ({ target }) =\u003e `app-owned-code-for:${target}`,\n})\n```\n\nEmail magic links also use the same hashed verification lifecycle. Core does not own your route,\ndomain, redirect handling, or cookie transport; the application provides a `createLink` function and\nan `EmailSender`.\n\n```ts\nconst magicLink = await service.public.magicLink.start({\n  email: 'alice@example.com',\n  createLink(input: EmailMagicLink) {\n    return `/auth/magic?verification=${input.verificationId}\u0026token=${input.secret}`\n  },\n})\n\nconst magicResult = await service.public.magicLink.finish({\n  verificationId: magicLink.verificationId,\n  secret: 'token-from-request',\n})\n\nconsole.log(magicResult.identity.provider === EMAIL_MAGIC_LINK_PROVIDER_ID)\n```\n\nPassword credentials use a dedicated `CredentialRepo`, a `PasswordHasher` port, and an optional\n`PasswordPolicy` port for new password material. Production apps should provide adapters backed by\ntheir chosen password hashing runtime, parameters, and strength or breached-password policy; the\nin-memory testing kit only ships a low-cost `scrypt` test hasher.\n\n```ts\nconst passwordHasher: PasswordHasher = {\n  async hash(password) {\n    return hashPassword(password)\n  },\n  async verify(password, passwordHash) {\n    return verifyPassword(passwordHash, password)\n  },\n}\n\nconst passwordPolicy: PasswordPolicy = {\n  validate({ password }) {\n    if (password.length \u003c 12) {\n      return { allowed: false, reason: 'Password is too weak.' }\n    }\n  },\n}\n\nconst { service } = createInMemoryAuthKit({ passwordHasher, passwordPolicy })\n\nawait service.account.password.set({\n  sessionToken,\n  password: 'new password from settings form',\n  reAuthenticatedAt,\n})\n\nconst passwordResult = await service.public.password.signIn({\n  email: 'alice@example.com',\n  password: 'password from sign-in form',\n})\n\nconsole.log(passwordResult.identity.provider === PASSWORD_PROVIDER_ID)\n```\n\nPassword recovery is an email verification flow. Core creates a hashed recovery secret and calls\nyour `createLink` function; your application owns the route and reset form.\n\n```ts\nconst recovery = await service.public.passwordRecovery.start({\n  email: 'alice@example.com',\n  createLink(input: EmailPasswordRecoveryLink) {\n    return `/auth/recovery?verification=${input.verificationId}\u0026token=${input.secret}`\n  },\n})\n\nawait service.admin.credentials.finishPasswordRecovery({\n  verificationId: recovery.verificationId,\n  secret: 'token-from-request',\n  newPassword: 'new password from reset form',\n})\n```\n\nOTP delivery is outside the storage transaction. `startOtpChallenge` first persists the hashed\nverification record, then calls the configured sender port. If the sender rejects, the pending\nverification remains in storage until it is consumed, expires, or is cleaned up by your adapter.\nThis keeps external SMTP/SMS/queue side effects out of `UnitOfWork`. Queue-backed delivery should\nwrap `EmailSender` or `SmsSender` instead of introducing a new core dispatcher, and exhausted\ndelivery remains adapter-owned state rather than a new core verification status. See\n[OTP delivery boundary](docs/otp-delivery.md).\n\nEmail and phone normalization stay intentionally lightweight in the root helpers so the package does\nnot silently impose one production syntax or phone metadata policy on every consumer. Current\nexports remain compatibility-oriented; stricter validation, E.164 canonicalization guidance, and\nmigration cautions are documented in [Normalization boundary](docs/normalization.md).\n\nVerification hashing is pluggable. The default hasher is salted `scrypt` so short OTP values are not\nstored as fast hashes. Production deployments that need app-owned key material can still provide an\nHMAC pepper through `createHmacSecretHasher` or a custom `SecretHasher` implementation:\n\n```ts\nimport { createHmacSecretHasher } from '@alyldas/uniauth-core'\nimport { createInMemoryAuthKit } from '@alyldas/uniauth-core/testing'\n\nconst secretPepper = process.env.UNIAUTH_SECRET_PEPPER\n\nif (!secretPepper) {\n  throw new Error('UNIAUTH_SECRET_PEPPER is required.')\n}\n\nconst { service } = createInMemoryAuthKit({\n  secretHasher: createHmacSecretHasher({\n    pepper: secretPepper,\n  }),\n})\n```\n\nThe package never reads environment variables by itself; application bootstrap code owns secret\nloading and rotation policy.\n\n## Adapter Author Guide\n\nStorage adapters should preserve these invariants:\n\n- Keep `User` and `AuthIdentity` as separate records.\n- Enforce uniqueness for `(provider, providerUserId)`.\n- Run link, unlink, merge, session, and verification writes inside the provided transaction boundary.\n- Store verification secrets only through the configured `SecretHasher`.\n- Store client session tokens only as server-side hashes.\n- Do not infer ownership from email, phone, or provider profile metadata outside `AuthPolicy`.\n- Keep sender side effects outside storage transactions.\n\nProvider adapters should return a normalized `ProviderIdentityAssertion` from `finish()`. Raw\nprovider payloads should stay adapter-owned or be reduced to explicit `metadata`; core does not\npersist raw provider profiles.\n\n## Entry Points\n\n- `@alyldas/uniauth-core`: public domain types, service implementation, policy API, ports, errors, and utilities.\n- `@alyldas/uniauth-core/testing`: in-memory store, provider registry, static provider, in-memory email\n  and SMS senders, and test kit.\n\n## Attribution\n\nThe root entry point exposes attribution metadata and a pure helper for About, Legal, Notices, or\nacknowledgements screens:\n\n```ts\nimport { UNIAUTH_ATTRIBUTION, getUniAuthAttributionNotice } from '@alyldas/uniauth-core'\n\nconst metadata = UNIAUTH_ATTRIBUTION\nconst notice = getUniAuthAttributionNotice({ productName: 'Example App' })\n```\n\nThe helper does not send telemetry, read environment variables, touch storage, or expose anything\nautomatically.\n\nFor commercial licensing, paid subscription terms, written agreements, or attribution questions,\ncontact `alyldas@ya.ru`.\n\n## Examples\n\n- [Basic Node example](examples/basic-node/index.ts)\n- [Current-account contact change example](examples/current-account-contact-change/index.ts)\n- [Link and unlink example](examples/link-unlink/index.ts)\n\n## Ecosystem Packages\n\n- [Ecosystem architecture](docs/architecture.md)\n- [Adapter author guide](docs/adapter-author-guide.md)\n- [Migration from archived `@alyldas/uniauth`](docs/migration-from-uniauth.md)\n\n## Documentation\n\n- [Development](docs/development.md)\n- [Backend integration recipes](docs/backend-recipes.md)\n- [Account security recipes](docs/account-security.md)\n- [Security model](docs/security.md)\n- [Threat model](docs/threat-model.md)\n- [Local auth flows](docs/local-auth.md)\n- [Passwordless strategy plan](docs/passwordless-strategy-plan.md)\n- [OTP delivery boundary](docs/otp-delivery.md)\n- [OTP and magic-link abuse-control recipes](docs/abuse-control.md)\n- [Normalization boundary](docs/normalization.md)\n- [Session transport recipes](docs/session-transport.md)\n- [Support and admin inspection recipe](docs/support-inspection.md)\n- [Licensing and attribution](docs/licensing.md)\n\n## Generated Files\n\nThis repository keeps package source and documentation in git. Do not commit generated output:\n\n- `dist`\n- `coverage`\n- `node_modules`\n- `.npm-cache`\n- `*.tgz`\n\n`dist` is created by `npm run build`, `npm run test:exports`, and npm pack based commands\n(`npm run lint:package`, `npm run test:types-package`, `npm run pack:dry`, release publish)\nthrough `prepack`. `.npm-cache` stores npm cache and `_logs` locally so package checks do not\ndepend on a writable home directory.\n`npm run prepare` only installs local Husky hooks when the project is inside a git repository.\n\n`npm run lint:package` runs `publint` against the built package metadata, entry points, exports,\ntype declarations, and published file set.\n\n`npm run test:types-package` runs `attw --pack . --profile esm-only` to verify package type\nresolution from the packed tarball across modern ESM TypeScript consumer resolution modes. CommonJS\n`require` is intentionally outside the support target for this ESM-only package.\n\nTo keep generated `dist` and `coverage` output inside a Node 22 Alpine container, run:\n\n```sh\nnpm run check:docker\n```\n\nFor the same package gate through Docker Compose, run:\n\n```sh\nnpm run check:compose\n```\n\n## Release Checklist\n\nRun the package gate before publishing:\n\n```sh\nnpm run check\n```\n\nThe gate runs formatting, ESLint, typecheck, 100% coverage, export smoke tests, package lint,\npackage type-resolution checks, local Markdown file, anchor, and image link checks, dependency\naudit with `npm audit --audit-level=moderate`, and `npm pack --dry-run`.\n\nThe release workflow uses Release Please: pushes to `main` update a release PR, and merging that PR\ncreates the `v*` tag, GitHub release notes, and GitHub Packages publish. This repository uses the\n`RELEASE_PLEASE_TOKEN` secret for release PR automation and `GITHUB_TOKEN` for package publishing.\nDo not commit, amend, or force-push directly to `release-please--*` branches. Release changes must\nland only by merging the Release Please PR into `main` after required checks pass and conversations\nare resolved.\n\nNormal feature, fix, refactor, docs, and test commits do not manually change release metadata.\nRelease Please owns these files in its release PR:\n\n- `package.json`: package version.\n- `package-lock.json`: lockfile package version.\n- `CHANGELOG.md`: release section and compare links.\n\nOnly bypass that rule for an intentional manual release process.\n\nFollow the changelog and merge policy in [CONTRIBUTING.md](CONTRIBUTING.md): regular feature and API\nPRs should preserve useful Conventional Commits, while Release Please PRs can stay single release\ncommits.\n\nAfter a release is published, verify the package page and GitHub Packages artifact manually against\nthe generated release notes and local `npm pack --dry-run` output.\n\n## Contributing\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md).\n\n## Security\n\nSee [SECURITY.md](SECURITY.md).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Falyldas%2Funiauth-core","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Falyldas%2Funiauth-core","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Falyldas%2Funiauth-core/lists"}