Custom Integrations¶
UserHarbor integrations should live outside the core package. The core library owns account-management rules, token generation, token hashing, and password hashing. Integrations provide infrastructure-specific behavior behind small protocols.
Common integration types include:
- database or ORM-backed
UserStoreimplementations - email-provider
EmailSenderimplementations - framework packages that wire UserHarbor into routes, dependencies, or request handling
- provider adapters for services such as SendGrid, Resend, Mailgun, Redis, or MongoDB
Before creating a new adapter, review the official implementations:
Package structure¶
Keep custom integrations as separate packages. A typical package can be small:
userharbor-myadapter/
pyproject.toml
README.md
src/
userharbor_myadapter/
__init__.py
py.typed
store.py
sender.py
tests/
Only include the files your adapter needs. A storage adapter does not need an email sender, and an email adapter does not need database code.
UserStore integrations¶
Implement UserStore when your adapter is responsible for persistence.
UserStore is one required contract, but it is composed from smaller protocols
for readability:
UserAccountStoreTokenStoreRoleStorePermissionStore
Adapter packages should still pass one store object to UserHarbor.
from contextlib import AbstractContextManager
from dataclasses import dataclass
from datetime import datetime
from userharbor.interfaces import CreateUserRequest, UserStore, UserToken
@dataclass
class MyUser:
username: str
email: str
verified: bool
class MyUserStore(UserStore[MyUser]):
def transaction(self) -> AbstractContextManager[None]:
...
def create_user(self, user: CreateUserRequest) -> None:
...
def set_user_verified(self, username: str) -> None:
...
def delete_user(self, username: str) -> None:
...
def get_user_by_username(self, username: str) -> MyUser | None:
...
def get_user_by_email(self, email: str) -> MyUser | None:
...
def get_password_hash(self, username: str) -> str:
...
def set_password_hash(self, username: str, password_hash: str) -> None:
...
# TokenStore
def get_email_verification(self, token_hash: str) -> UserToken | None:
...
def set_email_verification(self, verification: UserToken) -> None:
...
def remove_email_verification(self, token_hash: str) -> None:
...
def get_session(self, token_hash: str) -> UserToken | None:
...
def add_session(self, session: UserToken) -> None:
...
def remove_session(self, token_hash: str) -> None:
...
def remove_all_sessions(self, username: str) -> None:
...
def refresh_session(self, token_hash: str, new_expires_at: datetime) -> None:
...
def get_password_reset(self, token_hash: str) -> UserToken | None:
...
def set_password_reset(self, reset: UserToken) -> None:
...
def remove_password_reset(self, token_hash: str) -> None:
...
# RoleStore
def create_role(self, role: str) -> None:
...
def delete_role(self, role: str) -> None:
...
def list_roles(self) -> set[str]:
...
def role_exists(self, role: str) -> bool:
...
def grant_role_to_user(self, username: str, role: str) -> None:
...
def revoke_role_from_user(self, username: str, role: str) -> None:
...
def get_user_roles(self, username: str) -> set[str]:
...
# PermissionStore
def create_permission(self, permission: str) -> None:
...
def delete_permission(self, permission: str) -> None:
...
def list_permissions(self) -> set[str]:
...
def permission_exists(self, permission: str) -> bool:
...
def grant_permission_to_role(self, role: str, permission: str) -> None:
...
def revoke_permission_from_role(self, role: str, permission: str) -> None:
...
def get_role_permissions(self, role: str) -> set[str]:
...
def get_user_permissions(self, username: str) -> set[str]:
...
A UserStore is responsible for:
- creating and deleting users
- storing password hashes
- storing email verification token hashes
- storing session token hashes
- storing password reset token hashes
- storing role and permission definitions
- storing role-to-permission assignments
- storing user-to-role assignments
- removing sessions and tokens
- updating session expiration when sliding session refresh is enabled
- providing transaction boundaries for multi-step updates
The store should never store raw tokens. UserHarbor passes token hashes to the store and keeps raw token handling in the core flow.
The user type returned by get_user_by_username() and get_user_by_email() can
be any object that provides username, email, and verified. Parameterize
UserStore with that concrete type so UserHarbor.get_current_user() preserves
it in type checkers.
Contract semantics¶
A conforming UserStore must preserve the behavior expected by UserHarbor, not
only provide methods with matching signatures. In particular:
- usernames and email addresses are unique
- creating a user and their initial email verification token is atomic
- a newly created user is not verified
- setting a new email verification token removes the user's previous verification token
- setting a new password reset token removes the user's previous reset token
- deleting a user removes their verification tokens, password reset tokens, and sessions
- deleting a missing user, token, session, role, or permission is a no-op
- refreshing a missing session and marking a missing user as verified are no-ops
- reading a missing user or token returns
None - reading roles or effective permissions for a missing user returns an empty set
- reading or updating a password hash for a missing user raises
KeyError - granting or revoking the same relationship more than once is idempotent
- deleting roles and permissions removes their relationship assignments
The shared contract tests are the executable specification for these rules. Backend-specific exception types and persistence details remain the adapter's responsibility.
Transactions¶
transaction() should return a context manager. UserHarbor uses it around
operations that update multiple related records, such as email verification,
password reset, password change, account deletion, and role or permission
assignment.
A conforming store must commit when the block finishes successfully and roll
back every change from the block when an exception is raised. Nested
transaction() calls must participate in the outer transaction.
A nullcontext() can be useful in an early prototype or a deliberately simple
test double, but it does not satisfy the complete UserStore contract and will
not pass the shared transaction tests.
Testing¶
Every UserStore integration should run the shared contract tests provided by
UserHarbor. Import the complete suite in one test module:
# tests/test_user_store_contract.py
from userharbor.testing.user_store_contract import * # noqa: F403
Then provide a function-scoped user_store fixture in the integration:
# tests/conftest.py
import pytest
@pytest.fixture
def user_store():
store = create_user_store()
try:
yield store
finally:
dispose_user_store(store)
The fixture must provide a clean store for every test and release any database
connections or other resources afterwards. The shared suite verifies users,
password hashes, verification and reset tokens, sessions, roles, permissions,
assignments, and transaction behavior through the public UserStore interface.
Keep backend-specific tests in the integration repository. Examples include database schema and migration tests, provider-specific errors, custom model mapping, connection handling, and backend-specific transaction behavior.
See UserStore contract tests for instructions on developing contracts and testing them against a local UserHarbor checkout before a new version is published.
Start from the template¶
userharbor-inmemory is a minimal, contract-tested UserStore implementation and an official GitHub template for building custom storage integrations. It contains a complete in-memory store, package metadata, test and publishing workflows, and the shared contract setup described above.
Select Use this template in the repository or open Create a new repository from the template. The generated project starts with a passing contract suite, so the in-memory implementation can be replaced incrementally while the tests continue to verify the required behavior.
After creating a repository:
- Rename the distribution, import package, store class, and project metadata.
- Replace the in-memory dictionaries and snapshot transaction with the target backend.
- Update the
user_storefixture to create a clean backend and dispose of its resources after every test. - Keep the shared contract import and add separate tests for backend-specific schema, errors, mapping, connection, and transaction behavior.
The package can also be installed directly for application tests and examples:
Each store instance starts empty and retains data only for its own lifetime. It does not provide persistent storage, cross-process sharing, durable transactions, or concurrency guarantees, so it should not be used as a production database.
EmailSender integrations¶
Implement EmailSender when your adapter is responsible for message delivery.
from userharbor.interfaces import EmailSender
class MyEmailSender(EmailSender):
def send_verification(
self,
username: str,
email: str,
verification_token: str,
) -> None:
...
def send_password_reset(
self,
username: str,
email: str,
reset_token: str,
) -> None:
...
def send_email_verified(self, username: str, email: str) -> None:
...
def send_password_changed(self, username: str, email: str) -> None:
...
def send_account_deleted(self, username: str, email: str) -> None:
...
An EmailSender should only send messages. It should not decide whether a token
is valid, hash tokens, verify users, or reset passwords. Those responsibilities
belong to UserHarbor core.
Testing¶
For EmailSender, cover:
- verification messages
- password reset messages
- email-verified messages
- password-changed messages
- account-deleted messages
- subject and sender configuration
- template rendering, if templates are supported
- provider authentication or API calls using fakes
Framework integrations¶
Framework integrations should compose UserHarbor with framework-specific tools instead of moving domain behavior into the framework package.
A framework adapter may provide:
- dependency helpers
- route or router factories
- request-to-command mapping
- response models
- examples for configuring stores and email senders
It should avoid hard-coding one database or email provider unless that is the
explicit purpose of the package. Prefer accepting a configured UserHarbor
instance or accepting UserStore and EmailSender implementations from the
application.
Public API¶
Export the main adapter class from the package root:
If the package is typed, include py.typed and configure packaging so it is
included in distributions.
Naming¶
Use a clear package name that identifies the target integration:
Keep the adapter focused. If one package starts combining unrelated storage, email, and framework behavior, split it into smaller packages.