From 4eb880087214f37793d5eb9613172a7119c1537e Mon Sep 17 00:00:00 2001 From: carsten Date: Mon, 28 Sep 2026 20:50:24 +0200 Subject: [PATCH] Add Postal webhook receiver for delivery status (task 7c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 3 + app/core/config.py | 1 + app/main.py | 12 +++- app/models/mail_log.py | 10 +++- app/routers/webhooks.py | 55 +++++++++++++++++++ app/services/postal_webhook.py | 28 ++++++++++ ...5a3039e_add_mail_log_status_values_and_.py | 35 ++++++++++++ pyproject.toml | 1 + uv.lock | 52 ++++++++++++++++++ 9 files changed, 194 insertions(+), 3 deletions(-) create mode 100644 app/routers/webhooks.py create mode 100644 app/services/postal_webhook.py create mode 100644 migrations/versions/5935c5a3039e_add_mail_log_status_values_and_.py diff --git a/.env.example b/.env.example index 283a6a1..8d63cf1 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,6 @@ ACCESS_TOKEN_EXPIRE_MINUTES=120 ELEVATION_EXPIRE_MINUTES=5 POSTAL_BASE_URL=https://postal.example.com POSTAL_API_KEY=change-me +# Find med: dig +short TXT ._domainkey. +# og brug værdien af "p=" (base64 DER, ingen PEM-indpakning nødvendig) +POSTAL_WEBHOOK_PUBLIC_KEY_B64=change-me diff --git a/app/core/config.py b/app/core/config.py index 9a461b5..ffd1ba6 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -11,6 +11,7 @@ class Settings(BaseSettings): elevation_expire_minutes: int = 5 postal_base_url: str postal_api_key: str + postal_webhook_public_key_b64: str model_config = SettingsConfigDict( env_file=".env", diff --git a/app/main.py b/app/main.py index 00ec5ab..cb0f4bd 100644 --- a/app/main.py +++ b/app/main.py @@ -2,7 +2,16 @@ from fastapi import FastAPI from sqlalchemy import text 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.include_router(auth.router) @@ -12,6 +21,7 @@ app.include_router(wine_offerings.router) app.include_router(wine_categories.router) app.include_router(mail_templates.router) app.include_router(mail_logs.router) +app.include_router(webhooks.router) @app.get("/health") diff --git a/app/models/mail_log.py b/app/models/mail_log.py index a5ccb74..feb4c48 100644 --- a/app/models/mail_log.py +++ b/app/models/mail_log.py @@ -16,8 +16,10 @@ if TYPE_CHECKING: class MailLogStatus(str, Enum): SENT = "sent" FAILED = "failed" - DELIVERED = "delivered" # udfyldes først af webhook i opgave 7c - BOUNCED = "bounced" # udfyldes først af webhook i opgave 7c + DELIVERED = "delivered" + BOUNCED = "bounced" + HELD = "held" + DELAYED = "delayed" class MailLog(SQLModel, table=True): @@ -55,6 +57,8 @@ class MailLog(SQLModel, table=True): postal_token: Optional[str] = Field(default=None) error_message: Optional[str] = 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)) participant: "Participant" = Relationship(back_populates="mail_logs") @@ -70,4 +74,6 @@ class MailLogPublic(SQLModel): status: MailLogStatus error_message: Optional[str] sent_at: Optional[datetime] + opened_at: Optional[datetime] + clicked_at: Optional[datetime] created_at: datetime diff --git a/app/routers/webhooks.py b/app/routers/webhooks.py new file mode 100644 index 0000000..d95fc55 --- /dev/null +++ b/app/routers/webhooks.py @@ -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"} diff --git a/app/services/postal_webhook.py b/app/services/postal_webhook.py new file mode 100644 index 0000000..3949d19 --- /dev/null +++ b/app/services/postal_webhook.py @@ -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 diff --git a/migrations/versions/5935c5a3039e_add_mail_log_status_values_and_.py b/migrations/versions/5935c5a3039e_add_mail_log_status_values_and_.py new file mode 100644 index 0000000..64b3fc7 --- /dev/null +++ b/migrations/versions/5935c5a3039e_add_mail_log_status_values_and_.py @@ -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 ### diff --git a/pyproject.toml b/pyproject.toml index bf3d629..fcc5dbd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ description = "Add your description here" requires-python = ">=3.13" dependencies = [ "alembic>=1.20.0", + "cryptography>=50.0.1", "fastapi[standard]>=0.141.1", "httpx>=0.28.1", "psycopg[binary]>=3.3.6", diff --git a/uv.lock b/uv.lock index 1add891..116e32f 100644 --- a/uv.lock +++ b/uv.lock @@ -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 }, ] +[[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]] name = "detect-installer" version = "0.2.1" @@ -1237,6 +1287,7 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "alembic" }, + { name = "cryptography" }, { name = "fastapi", extra = ["standard"] }, { name = "httpx" }, { name = "psycopg", extra = ["binary"] }, @@ -1255,6 +1306,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.20.0" }, + { name = "cryptography", specifier = ">=50.0.1" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.141.1" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.3.6" },