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:
parent
34ed5c28d7
commit
4eb8800872
9 changed files with 194 additions and 3 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
12
app/main.py
12
app/main.py
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
55
app/routers/webhooks.py
Normal 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"}
|
||||||
28
app/services/postal_webhook.py
Normal file
28
app/services/postal_webhook.py
Normal 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
|
||||||
|
|
@ -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 ###
|
||||||
|
|
@ -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
52
uv.lock
generated
|
|
@ -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" },
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue