Customize a standard module¶
Goal: make a standard module fit your product while you keep receiving its upgrades.
Standard modules are installed packages. Your code talks to them the way any module talks to another: through contracts and events. Take the first option on this page that does the job.
| You want to | Use |
|---|---|
| Change limits, keys, sign-in providers or other behaviour with a setting | Configuration |
| Store more data about a user or an organization | A module next to it |
| React when a user registers or is deleted | A consumer |
| Add values to the access token | A claims contributor |
| Deliver its email or use your own text | A notification driver |
1. Configuration¶
The settings of each module are in its page (identity, tenancy, billing) and in the configuration reference.
2. A module next to it¶
Add fields by adding a module, not by editing identity. This profiles module
stores a display name for the current user, keyed by the user_id from the
token, and removes it when identity deletes the user.
uv run sandloom add module profiles
uv run sandloom add consumer profiles identity.user_deleted.v1
Delete the generated models/item.py and add this model. A profile belongs to a
user, not to a tenant, so it inherits Base alone. Edit the generated baseline
migration to create this table, without the row-level-security calls
(Add a module):
"""One profile per user, keyed by the id identity puts in the token."""
import uuid
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from sandloom.sdk import Base, CreatedAt
class Profile(Base):
"""``Base`` alone: a profile belongs to a user, not to a tenant."""
__tablename__ = "profiles_profiles"
user_id: Mapped[uuid.UUID] = mapped_column(primary_key=True)
display_name: Mapped[str] = mapped_column(String(100))
created_at: Mapped[CreatedAt]
Replace the generated schemas.py and services.py, delete the generated
events.py (this module publishes nothing), and give it routes for the current
user:
"""Request and response shapes of the profiles module."""
from pydantic import BaseModel, Field
class ProfileIn(BaseModel):
display_name: str = Field(min_length=1, max_length=100)
class ProfileOut(BaseModel):
display_name: str
"""Use cases of the profiles module."""
from uuid import UUID
from sandloom.sdk import db
from .models.profile import Profile
from .schemas import ProfileIn, ProfileOut
async def get_profile(user_id: UUID) -> ProfileOut | None:
profile = await db().get(Profile, user_id)
return ProfileOut(display_name=profile.display_name) if profile else None
async def save_profile(user_id: UUID, body: ProfileIn) -> ProfileOut:
profile = await db().get(Profile, user_id)
if profile is None:
db().add(Profile(user_id=user_id, display_name=body.display_name))
else:
profile.display_name = body.display_name
await db().flush()
return ProfileOut(display_name=body.display_name)
async def delete_profile(user_id: UUID) -> None:
profile = await db().get(Profile, user_id)
if profile is not None:
await db().delete(profile)
await db().flush()
"""Routes of the profiles module: ``/api/v1/profiles/...``."""
from fastapi import HTTPException
from sandloom.sdk import CurrentUser, module_router
from . import services
from .schemas import ProfileIn, ProfileOut
router = module_router()
@router.get("/me")
async def read_my_profile(user: CurrentUser) -> ProfileOut:
profile = await services.get_profile(user.user_id)
if profile is None:
raise HTTPException(status_code=404, detail="No profile yet")
return profile
@router.put("/me")
async def save_my_profile(body: ProfileIn, user: CurrentUser) -> ProfileOut:
return await services.save_profile(user.user_id, body)
Replace the generated consumer. The event carries the id in its payload, with
no personal data, and the view declares only the field this module reads:
"""Consumers of the profiles module."""
from typing import Any
from uuid import UUID
from sandloom.sdk import Event, on
from . import services
class UserDeleted(Event, channel="identity.user_deleted.v1"):
"""A view: the fields of identity's event this module reads."""
payload: dict[str, Any]
# dead_letter=False: erasure keeps retrying rather than waiting in a dead-letter queue.
@on(UserDeleted, dead_letter=False)
async def on_user_deleted(event: UserDeleted) -> None:
await services.delete_profile(UUID(event.payload["user_id"]))
Replace the generated test. Pass session=db to deliver so the consumer runs
in the test's transaction and sees the row the route wrote:
"""Tests of the profiles module."""
from sandloom.sdk.testing import as_user, deliver
async def test_a_profile_is_saved_and_removed_with_its_user(client, db) -> None:
user_id, headers = as_user()
saved = await client.put(
"/api/v1/profiles/me", json={"display_name": "Ada"}, headers=headers
)
assert saved.status_code == 200
read = await client.get("/api/v1/profiles/me", headers=headers)
assert read.json() == {"display_name": "Ada"}
await deliver(
"identity.user_deleted.v1",
{
"event_id": "019a0000-0000-7000-8000-000000000001",
"event_type": "identity.user_deleted.v1",
"occurred_at": "2026-10-31T00:00:00Z",
"schema_version": 1,
"actor": {"user_id": str(user_id)},
"trace_id": "test",
"payload": {"user_id": str(user_id)},
},
session=db,
)
gone = await client.get("/api/v1/profiles/me", headers=headers)
assert gone.status_code == 404
Run uv run sandloom generate, then uv run sandloom test profiles.
Identity's contact details (identity.contact.v1) are the source for email and
name at send time: call the contract instead of copying them
(how other modules use identity).
Add values to the access token¶
A module can add claims to the tokens identity mints, with
add_claims_contributor in its setup (ModuleAPI).
Keep them small and non-secret: a token outlives the data it was built from.
3. A notification driver¶
Identity sends all mail through the notification capability, so a
driver decides how it is delivered. Each message carries its
type in notification.data["kind"]: email_verification, password_reset,
registration_attempt or security_notice.
A driver renders its own template from kind and notification.body_fields, the
values identity built the plain-text body from:
kind |
body_fields |
|---|---|
email_verification |
verification_url, full_name when known |
password_reset |
reset_url, full_name when known |
registration_attempt |
sign_in_url, reset_url, full_name when known |
security_notice |
message |
These values are as sensitive as the body: the links are secrets, so a driver never logs
them. A later identity version can add a kind, and full_name can be missing, so fall
back to notification.body whenever a template cannot be filled:
TEMPLATES = {"password_reset": "Hello! Choose a new password: {reset_url}"}
def render(notification: Notification) -> str:
template = TEMPLATES.get(notification.data.get("kind", ""))
if template is None:
return notification.body
try:
return template.format(**notification.body_fields)
except KeyError:
return notification.body
What is not supported yet¶
- Changing a standard module's own tables or routes. There are no extension points for these yet.
- Importing a standard module's code, or patching it at runtime. An import fails SANDLOOM-C001. Internals may change in any release.
- A local copy with the same name next to the package. The loader refuses it (SANDLOOM-B020).
- Ejecting a module into your application with a command. It does not exist yet.
If you need one of these, open an issue with your use case.