439 lines
19 KiB
Markdown
439 lines
19 KiB
Markdown
|
|
# Plausible Dashboard — Android App Plan
|
||
|
|
|
||
|
|
**Name:** Implausibly
|
||
|
|
**Stack:** Kotlin · Jetpack Compose · Material 3
|
||
|
|
**License:** GPL-3.0-only
|
||
|
|
**Target:** API 26+ (Android 8.0, covers ~97% of devices)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 1. Why This Exists
|
||
|
|
|
||
|
|
The existing third-party apps (Applausible, Plausible Analytics Mobile) are all closed-source.
|
||
|
|
If you're running self-hosted Plausible CE specifically *because* you care about control and
|
||
|
|
transparency, a closed-source dashboard app is an odd fit. This app is:
|
||
|
|
|
||
|
|
- **Fully open source** — audit it, fork it, contribute
|
||
|
|
- **Self-hosted first** — configurable base URL, no Plausible.io assumptions baked in
|
||
|
|
- **No account/subscription required in the app itself** — just your Plausible API key
|
||
|
|
|
||
|
|
The Plausible Stats API v2 (`POST /api/v2/query`) is well-designed and gives us everything
|
||
|
|
we need. Rate limit is 600 req/hr which is generous for a dashboard viewer.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 2. Architecture
|
||
|
|
|
||
|
|
### 2.1 High-Level Layers
|
||
|
|
|
||
|
|
```
|
||
|
|
┌─────────────────────────────────────┐
|
||
|
|
│ UI (Jetpack Compose) │
|
||
|
|
│ Material 3 · Dynamic Color │
|
||
|
|
├─────────────────────────────────────┤
|
||
|
|
│ ViewModel (per screen) │
|
||
|
|
│ StateFlow · UiState sealed class │
|
||
|
|
├─────────────────────────────────────┤
|
||
|
|
│ Repository Layer │
|
||
|
|
│ Coordinates API + cache │
|
||
|
|
├──────────────┬──────────────────────┤
|
||
|
|
│ Remote │ Local Cache │
|
||
|
|
│ (Ktor) │ (SQLDelight) │
|
||
|
|
└──────────────┴──────────────────────┘
|
||
|
|
```
|
||
|
|
|
||
|
|
### 2.2 Key Technology Choices (with rationale)
|
||
|
|
|
||
|
|
| Choice | Why |
|
||
|
|
|--------|-----|
|
||
|
|
| **Ktor** for HTTP | Pure Kotlin, no annotation processing, lighter than Retrofit for a simple POST-only API. We literally have one endpoint. |
|
||
|
|
| **kotlinx.serialization** | Compiler plugin, no reflection. Pairs naturally with Ktor. |
|
||
|
|
| **SQLDelight** for local cache | SQL-first: you write `.sq` files, it generates typesafe Kotlin. KMP-native, so the entire data layer can be shared with a future iOS app. Lighter than Room, no annotation processing. |
|
||
|
|
| **Hilt** for DI | Standard for Android, reduces boilerplate vs manual DI. Could argue for Koin but Hilt has better compile-time safety. |
|
||
|
|
| **DataStore** for preferences | Non-sensitive prefs: selected site, theme, UI state. Replaces SharedPreferences. |
|
||
|
|
| **EncryptedSharedPreferences** for secrets | API keys stored with MasterKey + AES-256-GCM. EncryptedDataStore never graduated to stable; ESP is solid and well-tested. |
|
||
|
|
| **Vico** for charts | Compose-native charting library, actively maintained. Alternative: compose-charts or rolling our own with Canvas. |
|
||
|
|
|
||
|
|
**F-Droid compliance:** Every dependency is Apache 2.0 or compatible. Zero Play Services
|
||
|
|
dependencies anywhere in the tree. AndroidX Security Crypto uses Tink (Apache 2.0, pure
|
||
|
|
Java crypto, no Play linkage). Nothing phones home.
|
||
|
|
|
||
|
|
### 2.3 Package Structure
|
||
|
|
|
||
|
|
```
|
||
|
|
no.naiv.implausibly/
|
||
|
|
├── data/
|
||
|
|
│ ├── remote/
|
||
|
|
│ │ ├── PlausibleApi.kt # Ktor client, single /api/v2/query call
|
||
|
|
│ │ ├── dto/ # API request/response models
|
||
|
|
│ │ └── interceptor/ # Auth header injection (per-instance)
|
||
|
|
│ ├── local/
|
||
|
|
│ │ ├── ImplausiblyDatabase.sq # SQLDelight schema + queries
|
||
|
|
│ │ └── adapters/ # Column adapters (enums, etc.)
|
||
|
|
│ └── repository/
|
||
|
|
│ ├── StatsRepository.kt # Single source of truth pattern
|
||
|
|
│ ├── SiteRepository.kt
|
||
|
|
│ └── InstanceRepository.kt # CRUD for Plausible instances
|
||
|
|
├── domain/
|
||
|
|
│ ├── model/ # Domain models (decoupled from API DTOs)
|
||
|
|
│ └── usecase/ # Optional, only if logic warrants it
|
||
|
|
├── ui/
|
||
|
|
│ ├── theme/ # Material 3 theme, typography, colors
|
||
|
|
│ ├── navigation/ # NavHost, routes
|
||
|
|
│ ├── setup/ # Onboarding + instance management
|
||
|
|
│ │ └── instances/ # Add/edit/delete/switch instances
|
||
|
|
│ ├── sites/ # Site list / site picker
|
||
|
|
│ ├── dashboard/ # Main stats dashboard
|
||
|
|
│ │ ├── DashboardScreen.kt
|
||
|
|
│ │ ├── DashboardViewModel.kt
|
||
|
|
│ │ ├── components/ # StatCard, VisitorChart, etc.
|
||
|
|
│ │ └── sections/ # TopPages, Sources, Locations, Devices
|
||
|
|
│ ├── filter/ # Filter sheet (date range, dimensions)
|
||
|
|
│ └── common/ # Shared composables
|
||
|
|
└── di/ # Hilt modules
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 3. API Integration
|
||
|
|
|
||
|
|
**API Reference:** The Plausible Stats API v2 documentation lives at
|
||
|
|
<https://plausible.io/docs/stats-api>. The query endpoint reference is at
|
||
|
|
<https://plausible.io/docs/stats-api/query>. For self-hosted instances the
|
||
|
|
base URL changes but the path and schema are identical.
|
||
|
|
|
||
|
|
### 3.1 The One Endpoint
|
||
|
|
|
||
|
|
Everything goes through `POST /api/v2/query`. The request body looks like:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"site_id": "example.com",
|
||
|
|
"metrics": ["visitors", "pageviews", "bounce_rate", "visit_duration"],
|
||
|
|
"date_range": "7d",
|
||
|
|
"dimensions": ["visit:source"],
|
||
|
|
"filters": [["is_not", "visit:country_name", [""]]],
|
||
|
|
"order_by": [["visitors", "desc"]],
|
||
|
|
"limit": 10
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 3.2 Query Strategy
|
||
|
|
|
||
|
|
The dashboard needs multiple queries to populate all sections. We batch these with
|
||
|
|
`coroutineScope { }` to run them concurrently:
|
||
|
|
|
||
|
|
| Section | metrics | dimensions | date_range |
|
||
|
|
|---------|---------|------------|------------|
|
||
|
|
| Top stats row | visitors, pageviews, bounce_rate, visit_duration | (none) | user-selected |
|
||
|
|
| Visitor graph | visitors | time:day (or time:hour for "today") | user-selected |
|
||
|
|
| Top sources | visitors | visit:source | user-selected |
|
||
|
|
| Top pages | visitors | event:page | user-selected |
|
||
|
|
| Countries | visitors | visit:country_name | user-selected |
|
||
|
|
| Devices | visitors | visit:device | user-selected |
|
||
|
|
| Browsers | visitors | visit:browser | user-selected |
|
||
|
|
| OS | visitors | visit:os | user-selected |
|
||
|
|
|
||
|
|
That's ~8 concurrent requests per dashboard load. At 600/hr rate limit, a user can
|
||
|
|
refresh every ~50 seconds continuously without hitting it. Fine for a dashboard.
|
||
|
|
|
||
|
|
### 3.3 Realtime
|
||
|
|
|
||
|
|
Plausible's realtime visitor count is available via the same v2 endpoint with
|
||
|
|
`"date_range": "realtime"`. We poll this on a configurable interval (default: 30s)
|
||
|
|
when the app is in foreground. No WebSocket available.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 4. Screens & UX
|
||
|
|
|
||
|
|
### 4.1 Setup / Onboarding
|
||
|
|
|
||
|
|
Three ways to connect to a Plausible instance:
|
||
|
|
|
||
|
|
```
|
||
|
|
┌──────────────────────────────┐
|
||
|
|
│ │
|
||
|
|
│ Add Instance │
|
||
|
|
│ │
|
||
|
|
│ ○ API Key (full access) │
|
||
|
|
│ ○ Stats API only │
|
||
|
|
│ ○ Public dashboard (shared │
|
||
|
|
│ link, no key needed) │
|
||
|
|
│ │
|
||
|
|
│ Instance URL │
|
||
|
|
│ ┌────────────────────┐ │
|
||
|
|
│ │ https://... │ │
|
||
|
|
│ └────────────────────┘ │
|
||
|
|
│ │
|
||
|
|
│ API Key (if applicable) │
|
||
|
|
│ ┌────────────────────┐ │
|
||
|
|
│ │ ●●●●●●●●●●●●● │ │ ← password field, paste-friendly
|
||
|
|
│ └────────────────────┘ │
|
||
|
|
│ │
|
||
|
|
│ Friendly name (optional) │
|
||
|
|
│ ┌────────────────────┐ │
|
||
|
|
│ │ │ │ ← auto-filled on successful test
|
||
|
|
│ └────────────────────┘ │
|
||
|
|
│ │
|
||
|
|
│ [ Test Connection ] │ ← minimal query to validate
|
||
|
|
│ [ Save & Continue ] │
|
||
|
|
│ │
|
||
|
|
└──────────────────────────────┘
|
||
|
|
```
|
||
|
|
|
||
|
|
Key decisions:
|
||
|
|
- **Multiple instances from day one** — stored as a list, each with its own
|
||
|
|
base URL, credentials, and friendly name. Switching is a top-level action.
|
||
|
|
- **Friendly name:** editable at all times. If left blank when "Test Connection"
|
||
|
|
succeeds, auto-populate from the response (e.g. the first site_id, or the
|
||
|
|
instance hostname as fallback). User can always override before or after saving.
|
||
|
|
Editable later from instance settings.
|
||
|
|
- API keys in EncryptedSharedPreferences (MasterKey + AES-256-GCM)
|
||
|
|
- Non-sensitive prefs (selected instance, theme) in regular DataStore
|
||
|
|
- Test connection fires a minimal `/api/v2/query` (visitors, 1d) to validate
|
||
|
|
- Public shared links just need the URL — no key at all
|
||
|
|
|
||
|
|
### 4.2 Site Picker
|
||
|
|
|
||
|
|
Simple list of sites the API key has access to. Pull from the Sites API if available
|
||
|
|
(requires Sites API key), otherwise let users manually add site_ids. Show realtime
|
||
|
|
visitor count next to each site as a live badge.
|
||
|
|
|
||
|
|
### 4.3 Main Dashboard
|
||
|
|
|
||
|
|
Maps closely to the Plausible web dashboard — users already know this layout.
|
||
|
|
|
||
|
|
```
|
||
|
|
┌──────────────────────────────────────┐
|
||
|
|
│ example.com ⚡ 3 live │
|
||
|
|
│ ┌──────┬──────┬──────┬──────┐ │
|
||
|
|
│ │ 1.2k │ 3k │ 45% │ 52s │ │
|
||
|
|
│ │visit.│views │bounce│ dur. │ │
|
||
|
|
│ └──────┴──────┴──────┴──────┘ │
|
||
|
|
│ │
|
||
|
|
│ [Today] [7d] [30d] [custom] │
|
||
|
|
│ │
|
||
|
|
│ ┌──────────────────────────────┐ │
|
||
|
|
│ │ 📈 visitor graph │ │
|
||
|
|
│ │ (Vico line/bar chart) │ │
|
||
|
|
│ └──────────────────────────────┘ │
|
||
|
|
│ │
|
||
|
|
│ ▸ Top Sources │
|
||
|
|
│ Google ████████░░ 842 │
|
||
|
|
│ Direct █████░░░░░ 512 │
|
||
|
|
│ twitter.com ██░░░░░░░░ 201 │
|
||
|
|
│ │
|
||
|
|
│ ▸ Top Pages │
|
||
|
|
│ / ████████░░ 1.1k │
|
||
|
|
│ /blog/post-1 ████░░░░░░ 423 │
|
||
|
|
│ │
|
||
|
|
│ ▸ Countries · Devices · Browsers │
|
||
|
|
│ (expandable sections) │
|
||
|
|
└──────────────────────────────────────┘
|
||
|
|
```
|
||
|
|
|
||
|
|
### 4.4 Filter System
|
||
|
|
|
||
|
|
Bottom sheet with combinable filters matching the API's filter syntax:
|
||
|
|
- Country, source, page, device, browser, OS
|
||
|
|
- `is`, `is_not`, `contains`, `matches` operators
|
||
|
|
- Multiple filters compose with AND (matching API behavior)
|
||
|
|
|
||
|
|
This is where we can genuinely beat Applausible — deep filtering is powerful
|
||
|
|
and the v2 API supports it well.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 5. Offline & Caching Strategy
|
||
|
|
|
||
|
|
### 5.1 Cache Model
|
||
|
|
|
||
|
|
SQLDelight stores the last successful response for each query signature.
|
||
|
|
A query signature is the hash of `(instanceId, site_id, query_body)`.
|
||
|
|
|
||
|
|
The schema lives in `.sq` files — SQL-first, generates typesafe Kotlin.
|
||
|
|
This is KMP-native, so the entire data layer can be shared with iOS later.
|
||
|
|
|
||
|
|
```sql
|
||
|
|
-- src/commonMain/sqldelight/no/naiv/implausibly/CachedStats.sq
|
||
|
|
|
||
|
|
CREATE TABLE cached_stats (
|
||
|
|
query_hash TEXT NOT NULL PRIMARY KEY, -- hash of (instanceId, siteId, queryBody)
|
||
|
|
instance_id TEXT NOT NULL,
|
||
|
|
site_id TEXT NOT NULL,
|
||
|
|
response_json TEXT NOT NULL,
|
||
|
|
fetched_at INTEGER NOT NULL, -- epoch millis
|
||
|
|
date_range TEXT NOT NULL -- for TTL decisions
|
||
|
|
);
|
||
|
|
|
||
|
|
selectByHash:
|
||
|
|
SELECT * FROM cached_stats WHERE query_hash = ?;
|
||
|
|
|
||
|
|
upsert:
|
||
|
|
INSERT OR REPLACE INTO cached_stats VALUES (?, ?, ?, ?, ?, ?);
|
||
|
|
|
||
|
|
deleteStaleForInstance:
|
||
|
|
DELETE FROM cached_stats WHERE instance_id = ? AND fetched_at < ?;
|
||
|
|
|
||
|
|
-- src/commonMain/sqldelight/no/naiv/implausibly/Instance.sq
|
||
|
|
|
||
|
|
CREATE TABLE instances (
|
||
|
|
id TEXT NOT NULL PRIMARY KEY, -- UUID
|
||
|
|
name TEXT NOT NULL, -- friendly name
|
||
|
|
base_url TEXT NOT NULL, -- e.g. https://plausible.example.com
|
||
|
|
access_mode TEXT NOT NULL, -- FULL_API | STATS_ONLY | PUBLIC_LINK
|
||
|
|
api_key_ref TEXT, -- key into EncryptedSharedPreferences (null for public)
|
||
|
|
created_at INTEGER NOT NULL
|
||
|
|
);
|
||
|
|
|
||
|
|
selectAll:
|
||
|
|
SELECT * FROM instances ORDER BY created_at ASC;
|
||
|
|
|
||
|
|
selectById:
|
||
|
|
SELECT * FROM instances WHERE id = ?;
|
||
|
|
|
||
|
|
insert:
|
||
|
|
INSERT INTO instances VALUES (?, ?, ?, ?, ?, ?);
|
||
|
|
|
||
|
|
updateName:
|
||
|
|
UPDATE instances SET name = ? WHERE id = ?;
|
||
|
|
|
||
|
|
delete:
|
||
|
|
DELETE FROM instances WHERE id = ?;
|
||
|
|
```
|
||
|
|
|
||
|
|
Domain models remain decoupled from the generated DB types:
|
||
|
|
|
||
|
|
```kotlin
|
||
|
|
enum class AccessMode { FULL_API, STATS_ONLY, PUBLIC_LINK }
|
||
|
|
```
|
||
|
|
|
||
|
|
### 5.2 TTL Strategy
|
||
|
|
|
||
|
|
| date_range | Cache TTL | Rationale |
|
||
|
|
|------------|-----------|-----------|
|
||
|
|
| realtime | 0 (never cache) | Stale realtime is worse than no data |
|
||
|
|
| today | 5 min | Active day, changes frequently |
|
||
|
|
| 7d, 30d | 30 min | Reasonably stable |
|
||
|
|
| 6mo, 12mo | 2 hours | Historical, barely changes |
|
||
|
|
| custom (past) | 24 hours | Completed periods don't change |
|
||
|
|
|
||
|
|
On app open: show cached data immediately, then refresh in background.
|
||
|
|
On network error: show cached data with a subtle "last updated X ago" indicator.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 6. Home Screen Widgets (Future / Maybe)
|
||
|
|
|
||
|
|
Deferred. If added later, Glance (Compose-based widget framework) is the right
|
||
|
|
choice. WorkManager for periodic updates (minimum 15 min on Android). No Play
|
||
|
|
Services needed — WorkManager is pure AndroidX.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 7. Security Considerations
|
||
|
|
|
||
|
|
- **API key storage:** EncryptedSharedPreferences (MasterKey + AES-256-GCM).
|
||
|
|
Uses AndroidX Security Crypto → Tink (Apache 2.0, pure Java, no Play Services).
|
||
|
|
- **Network:** HTTPS enforced. Certificate pinning optional (self-hosted users
|
||
|
|
might use custom CAs, so we can't pin by default — offer it as a setting).
|
||
|
|
- **No analytics in the analytics app.** Zero telemetry.
|
||
|
|
- **No Google Play dependencies.** Not as a library, not as a transitive dep,
|
||
|
|
not anywhere. Audit `./gradlew :app:dependencies` before every release.
|
||
|
|
- **No third-party dependencies that phone home.** Audit all transitive deps.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 8. Build & Release
|
||
|
|
|
||
|
|
### 8.1 Build System
|
||
|
|
- Gradle with version catalogs (libs.versions.toml)
|
||
|
|
- Convention plugins for shared config
|
||
|
|
- Forgejo Actions CI: lint, unit test, instrumented test, APK build
|
||
|
|
|
||
|
|
### 8.2 Distribution
|
||
|
|
- **F-Droid** (primary) — fits the open source + privacy ethos perfectly
|
||
|
|
- **Forgejo Releases** (kode.naiv.no) — signed APKs for direct install
|
||
|
|
|
||
|
|
No Google Play. Play App Signing requires handing Google a signing key, and
|
||
|
|
there's no reason to pull in any Play components. The target audience
|
||
|
|
(self-hosted Plausible users who care about privacy) overwhelmingly overlaps
|
||
|
|
with F-Droid users. Anyone else can sideload the APK.
|
||
|
|
|
||
|
|
### 8.3 Reproducible Builds
|
||
|
|
Target F-Droid reproducible build verification from day one.
|
||
|
|
Pin all dependency versions, use a locked Gradle wrapper.
|
||
|
|
|
||
|
|
### 8.4 License — GPL-3.0-only
|
||
|
|
|
||
|
|
The app is released under the **GNU General Public License v3.0 only** (SPDX: `GPL-3.0-only`).
|
||
|
|
|
||
|
|
Key implications:
|
||
|
|
- **Copyleft**: Any distributed fork or derivative work must also be released under GPL-3.0.
|
||
|
|
This ensures the open-source intent cannot be stripped by downstream distributors.
|
||
|
|
- **Source availability**: Anyone who distributes a compiled APK must make the corresponding
|
||
|
|
source code available under the same terms (or provide a written offer to do so).
|
||
|
|
- **F-Droid compatibility**: GPL-3.0 is fully accepted by F-Droid. All bundled dependencies
|
||
|
|
must be Apache 2.0, MIT, LGPL, or otherwise GPL-compatible (no AGPL, no proprietary).
|
||
|
|
- **No CLA required**: Contributions are accepted under GPL-3.0 — contributors retain their
|
||
|
|
copyright. A `CONTRIBUTORS` file will track significant contributors.
|
||
|
|
- **Binary distribution**: Signed APKs on Forgejo Releases are GPL-3.0 binaries. The signing
|
||
|
|
key is never required to be published; only the source code is.
|
||
|
|
|
||
|
|
Files to include in the repository:
|
||
|
|
- `LICENSE` — full GPL-3.0 license text
|
||
|
|
- `SPDX-License-Identifier: GPL-3.0-only` header in every source file (or `.reuse/` tree)
|
||
|
|
- `NOTICE` — attributions for Apache-2.0 / LGPL transitive dependencies
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 9. Development Phases
|
||
|
|
|
||
|
|
### Phase 1: Core (MVP)
|
||
|
|
- [ ] Project setup (Gradle, Hilt, Navigation)
|
||
|
|
- [ ] Instance management (add/edit/delete, multi-instance)
|
||
|
|
- [ ] Setup screen with three access modes (full API, stats-only, public shared link)
|
||
|
|
- [ ] Site list screen
|
||
|
|
- [ ] Basic dashboard (top stats + visitor graph + top sources/pages)
|
||
|
|
- [ ] Date range selector (today, 7d, 30d, 12mo)
|
||
|
|
- [ ] Pull-to-refresh
|
||
|
|
- [ ] Basic SQLDelight caching
|
||
|
|
|
||
|
|
**Ship this.** It's already useful.
|
||
|
|
|
||
|
|
### Phase 2: Polish
|
||
|
|
- [ ] All dashboard sections (countries, devices, browsers, OS)
|
||
|
|
- [ ] Filter system (bottom sheet, combinable filters)
|
||
|
|
- [ ] Material You dynamic color
|
||
|
|
- [ ] Landscape layout (two-column dashboard)
|
||
|
|
- [ ] Error handling & offline indicator
|
||
|
|
- [ ] F-Droid submission + reproducible builds
|
||
|
|
|
||
|
|
### Phase 3: Nice to Have
|
||
|
|
- [ ] Comparison mode (this period vs previous)
|
||
|
|
- [ ] Traffic spike alerts (WorkManager polling, local notification)
|
||
|
|
- [ ] Goal conversions section
|
||
|
|
- [ ] Custom date ranges
|
||
|
|
- [ ] Segments support (API v2 supports them)
|
||
|
|
- [ ] Tablet layout
|
||
|
|
- [ ] Home screen widgets (Glance) — if there's demand
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 10. Open Questions
|
||
|
|
|
||
|
|
1. **Kotlin Multiplatform extraction:** With SQLDelight + Ktor + kotlinx.serialization,
|
||
|
|
the data layer is already KMP-ready. When the time comes, extract `data/` and
|
||
|
|
`domain/` into a shared KMP module, add an iOS app with SwiftUI consuming it,
|
||
|
|
and implement `expect`/`actual` for platform secrets (Keychain vs ESP).
|
||
|
|
The only Android-specific pieces are Hilt, DataStore, and EncryptedSharedPreferences.
|
||
|
|
|
||
|
|
2. **Sites API auto-discovery:** The Stats API key and Sites API key are separate
|
||
|
|
permissions. For users with only a Stats API key, we fall back to manual
|
||
|
|
site_id entry. Should we try the Sites API optimistically and fall back,
|
||
|
|
or ask the user upfront what kind of key they have?
|
||
|
|
|
||
|
|
3. **Shared link auth:** Plausible shared links can optionally be
|
||
|
|
password-protected. Need to verify how auth works for those — likely
|
||
|
|
a query parameter or header.
|