Add Postal webhook receiver for delivery status (task 7c)

POST /webhooks/postal (unauthenticated — RSA signature is the auth)
verifies the X-Postal-Signature-256 header: RSA-SHA256/PKCS1v15 over
the raw request body, using the same keypair as DKIM signing. Verified
directly against Postal's own source (lib/postal/http.rb, signer.rb)
rather than guessed, after the user pointed out the mechanism and that
their instance is new enough to use the -256 (SHA256) header over the
legacy SHA1 one. The public key is stored as the raw base64 DER blob
from the domain's DKIM DNS TXT record (dig TXT
postal-eWHeqb._domainkey.vinindkoeb.dk) — no PEM wrapping needed,
cryptography.load_der_public_key takes it directly.

Events are correlated to MailLog via postal_message_id. MessageSent
(actual delivery confirmation, not to be confused with task 7b's
synchronous "Postal accepted the request") maps to the DELIVERED
status already reserved for it; MessageDeliveryFailed/MessageBounced/
MessageHeld/MessageDelayed map to new terminal/transient statuses.
MessageLoaded/MessageLinkClicked set separate opened_at/clicked_at
timestamps rather than overwriting status, since engagement can happen
after delivery and shouldn't regress it. DomainDNSError and any
unrecognized event are acknowledged (200) and ignored — no message to
correlate.

Discovered along the way: the native_enum=False enum columns are
plain length-capped VARCHARs with no IN-list CHECK constraint, so
adding "held"/"delayed" needed no constraint migration, just the two
new opened_at/clicked_at columns Alembic did autogenerate correctly.

Verified: signature logic in isolation against a self-generated RSA
keypair (valid data/signature accepted, tampered data and garbage
signatures rejected), the real DKIM key parses correctly (1024-bit
RSA), the live endpoint rejects missing/invalid signatures with 401,
and the event-to-MailLog mapping logic was exercised directly (not
over HTTP, since a validly Postal-signed payload can't be forged
without their private key) against an isolated throwaway sandbox —
all cleaned up afterward, real route/participant data confirmed
unaffected throughout.

True end-to-end verification (a real Postal-originated webhook call)
requires the app to be deployed somewhere Postal can reach, plus
configuring the webhook URL in Postal's admin UI — both are deployment
steps outside this coding task.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Carsten Gram 2026-09-28 20:50:24 +02:00
parent 34ed5c28d7
commit 4eb8800872
9 changed files with 194 additions and 3 deletions

View file

@ -5,3 +5,6 @@ ACCESS_TOKEN_EXPIRE_MINUTES=120
ELEVATION_EXPIRE_MINUTES=5 ELEVATION_EXPIRE_MINUTES=5
POSTAL_BASE_URL=https://postal.example.com POSTAL_BASE_URL=https://postal.example.com
POSTAL_API_KEY=change-me POSTAL_API_KEY=change-me
# Find med: dig +short TXT <dkim-selector>._domainkey.<dit-domæne>
# og brug værdien af "p=" (base64 DER, ingen PEM-indpakning nødvendig)
POSTAL_WEBHOOK_PUBLIC_KEY_B64=change-me

View file

@ -11,6 +11,7 @@ class Settings(BaseSettings):
elevation_expire_minutes: int = 5 elevation_expire_minutes: int = 5
postal_base_url: str postal_base_url: str
postal_api_key: str postal_api_key: str
postal_webhook_public_key_b64: str
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",

View file

@ -2,7 +2,16 @@ from fastapi import FastAPI
from sqlalchemy import text from sqlalchemy import text
from app.db import SessionDep from app.db import SessionDep
from app.routers import auth, mail_logs, mail_templates, participants, purchase_rounds, wine_categories, wine_offerings from app.routers import (
auth,
mail_logs,
mail_templates,
participants,
purchase_rounds,
webhooks,
wine_categories,
wine_offerings,
)
app = FastAPI(title="Vinindkøb Admin API") app = FastAPI(title="Vinindkøb Admin API")
app.include_router(auth.router) app.include_router(auth.router)
@ -12,6 +21,7 @@ app.include_router(wine_offerings.router)
app.include_router(wine_categories.router) app.include_router(wine_categories.router)
app.include_router(mail_templates.router) app.include_router(mail_templates.router)
app.include_router(mail_logs.router) app.include_router(mail_logs.router)
app.include_router(webhooks.router)
@app.get("/health") @app.get("/health")

View file

@ -16,8 +16,10 @@ if TYPE_CHECKING:
class MailLogStatus(str, Enum): class MailLogStatus(str, Enum):
SENT = "sent" SENT = "sent"
FAILED = "failed" FAILED = "failed"
DELIVERED = "delivered" # udfyldes først af webhook i opgave 7c DELIVERED = "delivered"
BOUNCED = "bounced" # udfyldes først af webhook i opgave 7c BOUNCED = "bounced"
HELD = "held"
DELAYED = "delayed"
class MailLog(SQLModel, table=True): class MailLog(SQLModel, table=True):
@ -55,6 +57,8 @@ class MailLog(SQLModel, table=True):
postal_token: Optional[str] = Field(default=None) postal_token: Optional[str] = Field(default=None)
error_message: Optional[str] = Field(default=None) error_message: Optional[str] = Field(default=None)
sent_at: Optional[datetime] = Field(default=None) sent_at: Optional[datetime] = Field(default=None)
opened_at: Optional[datetime] = Field(default=None)
clicked_at: Optional[datetime] = Field(default=None)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
participant: "Participant" = Relationship(back_populates="mail_logs") participant: "Participant" = Relationship(back_populates="mail_logs")
@ -70,4 +74,6 @@ class MailLogPublic(SQLModel):
status: MailLogStatus status: MailLogStatus
error_message: Optional[str] error_message: Optional[str]
sent_at: Optional[datetime] sent_at: Optional[datetime]
opened_at: Optional[datetime]
clicked_at: Optional[datetime]
created_at: datetime created_at: datetime

55
app/routers/webhooks.py Normal file
View file

@ -0,0 +1,55 @@
import json
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException, Request, status
from sqlmodel import select
from app.db import SessionDep
from app.models.mail_log import MailLog, MailLogStatus
from app.services.postal_webhook import verify_signature
router = APIRouter(prefix="/webhooks", tags=["webhooks"])
_STATUS_BY_EVENT = {
"MessageSent": MailLogStatus.DELIVERED,
"MessageDelayed": MailLogStatus.DELAYED,
"MessageDeliveryFailed": MailLogStatus.FAILED,
"MessageBounced": MailLogStatus.BOUNCED,
"MessageHeld": MailLogStatus.HELD,
}
@router.post("/postal")
async def postal_webhook(request: Request, session: SessionDep) -> dict:
raw_body = await request.body()
signature = request.headers.get("X-Postal-Signature-256")
if not signature or not verify_signature(raw_body, signature):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid signature")
payload = json.loads(raw_body)
event = payload.get("event")
event_payload = payload.get("payload", {})
message_info = event_payload.get("message")
if not message_info or "id" not in message_info:
return {"status": "ignored"} # fx DomainDNSError — intet at korrelere mod
mail_log = session.exec(
select(MailLog).where(MailLog.postal_message_id == message_info["id"])
).first()
if mail_log is None:
return {"status": "ignored"}
if event == "MessageLoaded":
mail_log.opened_at = datetime.now(timezone.utc)
elif event == "MessageLinkClicked":
mail_log.clicked_at = datetime.now(timezone.utc)
elif event in _STATUS_BY_EVENT:
mail_log.status = _STATUS_BY_EVENT[event]
if mail_log.status == MailLogStatus.FAILED:
mail_log.error_message = event_payload.get("output") or event_payload.get("details")
else:
return {"status": "ignored"}
session.add(mail_log)
session.commit()
return {"status": "ok"}

View file

@ -0,0 +1,28 @@
import base64
from functools import lru_cache
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.serialization import load_der_public_key
from app.core.config import get_settings
@lru_cache
def _get_public_key():
settings = get_settings()
der_bytes = base64.b64decode(settings.postal_webhook_public_key_b64)
return load_der_public_key(der_bytes)
def verify_signature(raw_body: bytes, signature_b64: str) -> bool:
try:
signature = base64.b64decode(signature_b64)
except Exception:
return False
try:
_get_public_key().verify(signature, raw_body, padding.PKCS1v15(), hashes.SHA256())
return True
except InvalidSignature:
return False

View file

@ -0,0 +1,35 @@
"""add mail log status values and engagement timestamps
Revision ID: 5935c5a3039e
Revises: 25684eb2aed5
Create Date: 2026-09-28 20:48:04.178487
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '5935c5a3039e'
down_revision: Union[str, Sequence[str], None] = '25684eb2aed5'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('mail_log', sa.Column('opened_at', sqlmodel.sql.sqltypes.UTCDateTime(), nullable=True))
op.add_column('mail_log', sa.Column('clicked_at', sqlmodel.sql.sqltypes.UTCDateTime(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('mail_log', 'clicked_at')
op.drop_column('mail_log', 'opened_at')
# ### end Alembic commands ###

View file

@ -5,6 +5,7 @@ description = "Add your description here"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
"alembic>=1.20.0", "alembic>=1.20.0",
"cryptography>=50.0.1",
"fastapi[standard]>=0.141.1", "fastapi[standard]>=0.141.1",
"httpx>=0.28.1", "httpx>=0.28.1",
"psycopg[binary]>=3.3.6", "psycopg[binary]>=3.3.6",

52
uv.lock generated
View file

@ -215,6 +215,56 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
] ]
[[package]]
name = "cryptography"
version = "50.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153 },
{ url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133 },
{ url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478 },
{ url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726 },
{ url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524 },
{ url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720 },
{ url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866 },
{ url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028 },
{ url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405 },
{ url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230 },
{ url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596 },
{ url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082 },
{ url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826 },
{ url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525 },
{ url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817 },
{ url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300 },
{ url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039 },
{ url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388 },
{ url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293 },
{ url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031 },
{ url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344 },
{ url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201 },
{ url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023 },
{ url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362 },
{ url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247 },
{ url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806 },
{ url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307 },
{ url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900 },
{ url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357 },
{ url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474 },
{ url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862 },
{ url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942 },
{ url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347 },
{ url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050 },
{ url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955 },
{ url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694 },
{ url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413 },
{ url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355 },
{ url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429 },
]
[[package]] [[package]]
name = "detect-installer" name = "detect-installer"
version = "0.2.1" version = "0.2.1"
@ -1237,6 +1287,7 @@ version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "alembic" }, { name = "alembic" },
{ name = "cryptography" },
{ name = "fastapi", extra = ["standard"] }, { name = "fastapi", extra = ["standard"] },
{ name = "httpx" }, { name = "httpx" },
{ name = "psycopg", extra = ["binary"] }, { name = "psycopg", extra = ["binary"] },
@ -1255,6 +1306,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "alembic", specifier = ">=1.20.0" }, { name = "alembic", specifier = ">=1.20.0" },
{ name = "cryptography", specifier = ">=50.0.1" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.141.1" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.141.1" },
{ name = "httpx", specifier = ">=0.28.1" }, { name = "httpx", specifier = ">=0.28.1" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.3.6" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.3.6" },