correspond.channels.webinbox#
The web inbox: a small ASGI collector web pages post reports to, and the channel that reads them.
The collector (mk_app(); or uvicorn --factory correspond.channels.webinbox:app_from_env)
accepts POST /<site>/reports with a JSON body:
{"text": "The export drops the last row.",
"name": "Ada", "email": "ada@example.org",
"page": "https://app.example.org/export",
"context": {"viewport": "1280x800"},
"identity": {"user": "u-42", "name": "Ada", "email": null, "issued_at": 1757592000, "sig": "<hex>"},
"attachments": [{"name": "screen.png", "media_type": "image/png", "data": "<base64>"}]}
Only text is required.
Identity. Without
identitya report isclaimed(nameandemailare whatever was typed). With one, the host application’s server has signed its logged-in user with the site’s secret (sign_identity()); a valid, fresh signature makes the reportbound. An invalid or expired signature is refused with 401 and nothing is stored.Origins. A browser’s
Originmust be on the site’s allowlist (403 otherwise); a request without one is refused unless the site allows server-to-server posts. CORS preflight is answered for allowed origins only. An origin check stops other pages from posting through a visitor’s browser; it authenticates no one.Limits. A per-client token bucket (429 with
Retry-After), the body size (413), caps on text, context and attachments, and a media-type allowlist.Storage. A report is JSON in
storeunder<site>/<sortable id>.json; attachments are bytes inblobsunder their SHA-256, referenced and never inlined.
The app binds nothing: run it on localhost behind your own server, and tell it how many
reverse proxies stand in front (trusted_proxies) so the rate limit applies per visitor
rather than to the proxy. The limiter lives in memory, per process. Each site has its own
secret, so a server that can sign for one site cannot sign for another.
The channel (WebInbox): webinbox:<site> reads and listens to what the
collector stored. It has no writer: a reply to a reporter goes out on another channel.
The identity payload is plain lines, so any server language can sign it:
>>> identity_payload("example-site", "u-42", 1757592000, name="Ada")
b'v1\nexample-site\n1757592000\nu-42\nAda\n'
Functions
The collector configured from the environment and the |
|
|
The bytes a host application signs for its logged-in user: |
|
The collector as an ASGI app, storing reports in |
What would stop the collector starting from this configuration; nothing when no site is configured, since reading stored reports needs none. |
|
|
What a host application's server gives its page for the logged-in user; the page sends it as |
|
The environment variable holding one site's own secret. |
|
|
Classes
|
One inbox the collector accepts reports for: who may post from where, and the key signed identities are checked with. |
|
Per-key token buckets: |
|
Reports the collector stored, as a channel: read and listen per site. |
- class correspond.channels.webinbox.Site(*, name, origins=(), secret=None, allow_no_origin=False, max_age_s=86400)[source]#
Bases:
objectOne inbox the collector accepts reports for: who may post from where, and the key signed identities are checked with.
- class correspond.channels.webinbox.TokenBucket(*, rate_per_minute, burst, clock=<built-in function monotonic>)[source]#
Bases:
objectPer-key token buckets:
rate_per_minutesustained,burstat once. In memory, one process.>>> bucket = TokenBucket(rate_per_minute=60, burst=1, clock=lambda: 0.0) >>> bucket.take("client"), bucket.take("client") (None, 1.0)
- class correspond.channels.webinbox.WebInbox(*, store=None, blobs=None)[source]#
Bases:
objectReports the collector stored, as a channel: read and listen per site.
- audience(ref, *, draft=None)[source]#
Only the operator: reports are stored where the collector runs, and nothing is sent back to a page.
- Return type:
- property blobs: MutableMapping[str, bytes]#
The attachment store, keyed by SHA-256.
- property capabilities: Capabilities#
Read and listen; no writer.
- poll(ref, *, cursor=None, limit=None)[source]#
Reports stored after the one
cursornames (a report id).
- property store: MutableMapping[str, Any]#
The report store (JSON files under the data root by default).
- correspond.channels.webinbox.app_from_env()[source]#
The collector configured from the environment and the
[webinbox]config table (foruvicorn --factory).
- correspond.channels.webinbox.identity_payload(site, user, issued_at, *, name=None, email=None)[source]#
The bytes a host application signs for its logged-in user:
v1, site, issued-at, user, name, email, one per line.- Return type:
- correspond.channels.webinbox.mk_app(sites, *, store=None, blobs=None, rate_per_minute=10, burst=5, max_body_bytes=5000000, trusted_proxies=0, clock=<built-in function time>)[source]#
The collector as an ASGI app, storing reports in
storeand attachments inblobs(files under the data root by default).Behind reverse proxies of your own, set
trusted_proxiesto how many stand in front, or every visitor shares the proxy’s rate limit.
- correspond.channels.webinbox.requirements_problems()[source]#
What would stop the collector starting from this configuration; nothing when no site is configured, since reading stored reports needs none.
- correspond.channels.webinbox.sign_identity(secret, site, user, *, name=None, email=None, issued_at=None)[source]#
What a host application’s server gives its page for the logged-in user; the page sends it as
identity.- Return type:
- correspond.channels.webinbox.site_secret_env(site)[source]#
The environment variable holding one site’s own secret.
>>> site_secret_env("example-site") 'CORRESPOND_WEBINBOX_SECRET_EXAMPLE_SITE'
- Return type: