Privacy Policy
Privacy Policy
Last updated: 2026-05-31
This page describes what personal data Ibis Ballistic collects, how
long it is kept, and the controls users have over it. The application
is a ballistic calculator + shooting logbook; the data we hold is
minimal by design.
Data we collect
| Category | Source | Stored where | Purpose |
|---|---|---|---|
| Registration form / Google OAuth | users.email |
Account identifier, password-reset, account-state notifications | |
| Password hash | Registration form | users.password_hash (bcrypt cost 12) |
Authentication. The plaintext is never stored. |
| OAuth provider | Google OAuth | users.oauth_provider |
Indicate which third-party identity is linked |
| IP address (truncated) | Every authenticated request | audit_log.payload.ip_subnet (/24 for IPv4, /48 for IPv6) |
Rate-limit + forensic trail. Full IPs are never persisted. |
| User-Agent (truncated) | Login flow | active_sessions.device_label (first 200 chars) |
«Active devices» list on the security page |
| Ballistic profiles | User input | complex_profiles, user_calculator_profiles |
Save shooting setups |
| Shooting sessions | User input via the Logbook | sessions, groups |
Track training history |
We do not collect: full IP addresses, browser fingerprints,
trackers, analytics pixels, per-user page-view logs, third-party
ads. (We do keep anonymous, aggregate page-view counts — see
Anonymous usage analytics below — which
carry no identifier and are not personal data.)
Anonymous usage analytics
To decide which features and calculator tools are worth maintaining, the
app keeps aggregate, anonymous page-view counts — and nothing else.
- What is stored: one counter row per
(day, route)— e.g. - What is NOT stored: no user id, no IP address, no session, no
- No cookie, no consent: because the counts are anonymous aggregates
- Retention: counter rows are purged after 180 days
- Operator control: an administrator can disable the counting
«/tools/twist was viewed 42 times on 2026-05-31». The key is the
route template (not the exact URL you visited), so record ids never
enter the data.
cookie, no per-request log line. Counting works by incrementing the
aggregate on each page-view, so individual browsing behaviour is never
recorded and cannot be reconstructed.
they are not personal data under GDPR — they require no consent
and set no cookie, consistent with the «no analytics cookies» promise
under Cookies. Collection is purely server-side.
(scripts/workers/cron_usage_counters_purge.sh).
entirely (usage_telemetry_enabled).
Unique-visitor estimation (public pages only)
On public pages only (the landing page and the legal / pricing /
changelog / feedback pages — never a logged-in route), the app also keeps
a privacy-preserving estimate of how many distinct visitors a page
saw, so we can tell «20 views by 2 people» from «20 views by 20 people».
- How it works: for each visit we compute a one-way token
- What is stored: only the sketch. **Your IP address is never
- Daily salt, no cross-day linkage: the salt rotates every day and is
- Lawful basis: legitimate interest in aggregate audience
- Accuracy: this is a rough estimate, not a headcount, and tends to
- Retention: sketches are purged after 180 days
- Operator control: an administrator can disable this IP-processing
sha256(daily_salt ‖ your IP ‖ User-Agent) and fold it into a
HyperLogLog sketch per
(day, page). A HyperLogLog sketch is a small fixed-size summary from
which a distinct-count can be estimated but the individual members
can never be listed — it is non-reversible and non-enumerable by
construction.
stored** — it only ever affects the sketch transiently, in memory, then
is discarded. No cookie, no per-visitor row.
never persisted, so the same IP produces a different, unlinkable
token on a different day — there is no way to follow a visitor across
days.
measurement (Art. 6(1)(f) GDPR). The transient IP processing is the
minimum necessary and produces only a non-personal aggregate; no
consent banner is required because nothing identifying is stored or
set on your device.
read low — shared networks (NAT/CGNAT, corporate proxies) make many
people look like one, one person on two devices looks like two, the
sketch itself adds ~1 %, and under concurrent traffic some updates are
dropped (so the number is a lower bound).
(scripts/workers/cron_usage_unique_purge.sh).
path entirely (usage_unique_enabled) while still keeping the
anonymous view totals above. Design rationale: docs/adr/0006.
Retention
| Data | Retention | Mechanism |
|---|---|---|
| Audit log | 1 year | scripts/workers/cron_audit_purge.sh (#891) |
| Password-reset tokens | 1 hour | scripts/workers/cron_purge_tokens.sh (#975) |
| Email-verification tokens | 24 hours | same cron |
| Soft-deleted accounts | 30 days then hard-purge | scripts/workers/cron_purge_soft_deleted.sh (#965) |
| Active-session rows | 14 days past last_seen | scripts/workers/cron_purge_sessions.sh (#1012) |
| Anonymous usage counters | 180 days | scripts/workers/cron_usage_counters_purge.sh (#1561) |
| Anonymous unique-visitor sketches | 180 days | scripts/workers/cron_usage_unique_purge.sh (#1706) |
| User data after account deletion | 30-day grace; then full cascade-delete | hard_delete_user (#965) |
| Backups | 30 days | BACKUP_DIR retention |
Your rights
- Access — GET
/profile/exportreturns a JSON dump of every - Deletion — POST
/profile/delete-accountstarts a 30-day soft-delete - Correction — Edit any field via the profile / logbook editors.
- Withdrawal of consent — Delete the account; we do not retain
field tied to your account.
grace; after that the row + all cascading tables are dropped and the
audit_log entries are anonymised (PII stripped, security-trail
preserved).
data past the grace window.
GDPR Art. 15-17 / 21 are honoured by the above flows. Requests can also
be sent to the admin email on the contact page.
Cookies
| Cookie | Type | Purpose | Lifetime |
|---|---|---|---|
access_token |
Strictly necessary | JWT for session auth | 1 hour |
refresh_token |
Strictly necessary | Rotate access tokens | 7 days |
_csrf |
Strictly necessary | CSRF protection | Session |
All three are HttpOnly + Secure on HTTPS deploys; SameSite=Lax on the
access cookie, SameSite=Strict on refresh. There are **no analytics or
advertising cookies** — the usage analytics described above is
cookieless and anonymous.
Third parties (sub-processors)
The following third-party services receive limited personal data
when you use the app. None receive your password, ballistic
profiles, or logbook contents.
| Sub-processor | Data shared | Purpose | Operator opt-out |
|---|---|---|---|
| Google OAuth (optional) | Email + verified flag | Identity attestation when you choose «Sign in with Google» | Unset GOOGLE_OAUTH_CLIENT_ID env-var (button disappears, password-login still works) |
| Resend (transactional email) | Recipient email, subject, body, message-ID | Password reset, email verification, admin alerts. Body is constructed per-request; not stored locally. | Unset RESEND_API_KEY in non-prod (prod requires it — serve.py fail-fast) |
| ipwho.is (IP→geo fallback, #1371) | Client IP address at request-time | Fallback geo lookup for /api/weather/live when browser geolocation returns POSITION_UNAVAILABLE / TIMEOUT. Returns latitude + longitude. The full IP is not stored locally — only the /24 (IPv4) or /48 (IPv6) prefix lands in audit_log. |
Set IBIS_IP_GEO_DISABLED=1 (recommended for strict-GDPR deploys per docs/env-vars.md); or swap the resolver via IBIS_IP_GEO_PROVIDER_URL to a self-hosted MaxMind / GeoIP service |
| Railway (hosting) | Application logs (HTTP method / path / status / IP), Postgres rows, uploaded images | Hosting + runtime infra. Retention follows Railway's platform policy. | Self-host — the app is open-source (MIT, #1395) |
Google's, Resend's, ipwho.is's, and Railway's own privacy policies
apply to what they collect from that interaction. We do not share
data with these processors beyond what each row above lists.
Contact
Questions about this policy or your data: write to the operator email
listed in the deployment's ADMIN_ALERT_EMAIL (visible in the page footer).
Privacy · Terms · Back to app