Compare commits
4
Commits
b7ce114677
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
beab66648c | ||
|
|
73959f9dde | ||
|
|
f04d63dd7b | ||
|
|
b58cb75cfe |
@@ -61,6 +61,14 @@ S3_BUCKET_APPVIEW=maarcadetweet-appview
|
||||
PLC_DIRECTORY_URL=https://plc.directory
|
||||
# PLC_DIRECTORY_URL=http://127.0.0.1:2582
|
||||
|
||||
# --- Registrierung ---
|
||||
# Verlangt createAccount einen Einladungscode? Default false, damit Dev-
|
||||
# Instanzen und die Integrationstests frei Konten anlegen können — eine
|
||||
# öffentlich erreichbare PDS gehört auf true gestellt, sonst kann jeder
|
||||
# beliebig viele Repos anlegen. Der Server warnt beim Start, solange es
|
||||
# aus ist. Codes erzeugen: `pds-server invite create --count 5 --uses 1`
|
||||
PDS_INVITE_REQUIRED=false
|
||||
|
||||
# --- AppView ingest auth (optional, dev ok if unset) ---
|
||||
# Wenn gesetzt, muss die PDS denselben Wert als Header
|
||||
# `X-Ingest-Secret` mitschicken; ist er nicht gesetzt, nimmt
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
name: Release Desktop Client
|
||||
|
||||
# Baut den Tauri-Client aus crates/tauri-app fuer Windows und Linux, laedt die
|
||||
# Bundles als Job-Artefakte hoch und haengt sie an ein Gitea-Release zum Tag.
|
||||
#
|
||||
# ACHTUNG — Verzeichnis-Falle (hat im Nachbarprojekt lserver einen Tag CI
|
||||
# gekostet): Sobald `.gitea/workflows/` existiert, ignoriert Gitea
|
||||
# `.github/workflows/` KOMPLETT. In diesem Repo gibt es kein `.github/`, also
|
||||
# ist heute nichts betroffen — wer aber spaeter einen Workflow unter
|
||||
# `.github/workflows/` anlegt, bekommt keinen Lauf und auch keinen roten
|
||||
# Fehler, sondern schlicht Stille. Alle Workflows gehoeren hierher.
|
||||
#
|
||||
# VOR DEM TAG VERSION BUMPEN (analog zu lserver, wo package.json gebumpt wird):
|
||||
# Die Version im Release kommt aus dem Tag, die Version IM Artefaktnamen aus
|
||||
# der Config. Beide muessen zusammenpassen, sonst heisst die Datei zu einem
|
||||
# Tag v0.2.0 weiterhin `maarcadetweet_0.1.0_x64-setup.exe`:
|
||||
# * crates/tauri-app/src-tauri/tauri.conf.json -> "version"
|
||||
# * crates/tauri-app/src-tauri/Cargo.toml -> [package] version
|
||||
# * crates/tauri-app/package.json -> "version" (Konsistenz)
|
||||
# Details: docs/tauri-release.md, Abschnitt "Release-Checkliste".
|
||||
#
|
||||
# macOS: DAFUER GIBT ES KEINEN RUNNER. Weder Gitea-Instanz noch Infrastruktur
|
||||
# haben einen macOS-Host; .dmg/.app werden lokal gebaut und von Hand an das
|
||||
# hier erzeugte Release gehaengt:
|
||||
# cd crates/tauri-app && npm ci && npm run tauri -- build --ci
|
||||
# # Artefakte: src-tauri/target/release/bundle/dmg/*.dmg und macos/*.app
|
||||
# # Universal-Build: npm run tauri -- build --ci --target universal-apple-darwin
|
||||
# Danach im Gitea-Release "Edit release" -> Dateien anhaengen. Ohne
|
||||
# Notarisierung meldet Gatekeeper die App als nicht verifiziert (siehe
|
||||
# docs/tauri-release.md, Abschnitt 9 "Offene Punkte").
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
windows:
|
||||
name: Windows (MSI + NSIS)
|
||||
# Label `windows` = act_runner auf der Build-VM winbuild (192.168.1.69),
|
||||
# Win11, cargo 1.98.1, Node 24, Tauri-CLI 2.11.4. Laut Infrastruktur-Doku
|
||||
# on-demand — laeuft die VM nicht, wird der Job nie geplant (Gitea zeigt
|
||||
# dann gar keinen Lauf an, keinen fehlgeschlagenen).
|
||||
runs-on: windows
|
||||
timeout-minutes: 60
|
||||
defaults:
|
||||
run:
|
||||
shell: powershell
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# package-lock.json liegt unter crates/tauri-app/ -> npm ci (reproduzierbar).
|
||||
# Kein `npm install`: das wuerde den Lock im Build veraendern.
|
||||
- name: Install frontend dependencies
|
||||
working-directory: crates/tauri-app
|
||||
run: npm ci
|
||||
|
||||
# package.json definiert `"tauri": "tauri"` — der npm-Umweg nutzt die
|
||||
# @tauri-apps/cli-devDependency aus dem Lock (2.x) statt einer global
|
||||
# installierten `cargo tauri`-Version, ist also an das Repo gebunden.
|
||||
# `cargo tauri build --ci` waere gleichwertig, haengt aber an dem, was
|
||||
# gerade auf dem Runner installiert ist.
|
||||
# bundle.targets in tauri.conf.json steht auf "all" -> unter Windows
|
||||
# heisst das msi + nsis.
|
||||
- name: Build Tauri bundles
|
||||
working-directory: crates/tauri-app
|
||||
run: npm run tauri -- build --ci
|
||||
|
||||
- name: Bundles auflisten
|
||||
working-directory: crates/tauri-app
|
||||
run: |
|
||||
$bundle = "src-tauri\target\release\bundle"
|
||||
Get-ChildItem -Path $bundle -Recurse -Include *.msi, *.exe |
|
||||
ForEach-Object { Write-Host "$($_.FullName) ($([math]::Round($_.Length / 1MB)) MB)" }
|
||||
|
||||
- name: Upload bundles
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: maarcadetweet-windows
|
||||
path: |
|
||||
crates/tauri-app/src-tauri/target/release/bundle/msi/*.msi
|
||||
crates/tauri-app/src-tauri/target/release/bundle/nsis/*.exe
|
||||
retention-days: 30
|
||||
|
||||
- name: Gitea-Release anlegen und Bundles anhaengen
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$api = "$env:GITHUB_SERVER_URL/api/v1/repos/$env:GITHUB_REPOSITORY"
|
||||
$tag = $env:GITHUB_REF_NAME
|
||||
$auth = "Authorization: token $env:GITEA_TOKEN"
|
||||
|
||||
# Release-Notes aus dem passenden CHANGELOG-Abschnitt ziehen.
|
||||
# CHANGELOG.md existiert in diesem Repo noch nicht — sobald es
|
||||
# angelegt wird (Format `## [0.2.0] - ...` wie bei lserver), landet
|
||||
# der Abschnitt automatisch im Release.
|
||||
$notes = "Automatisch gebaut aus $env:GITHUB_SHA."
|
||||
$version = $tag.TrimStart("v")
|
||||
if (Test-Path CHANGELOG.md) {
|
||||
# Ausdruecklich als UTF-8 lesen: Windows PowerShell 5.1 nimmt sonst
|
||||
# die ANSI-Codepage und macht aus "Aenderungen" Buchstabensalat.
|
||||
$lines = [IO.File]::ReadAllText((Resolve-Path CHANGELOG.md), [Text.UTF8Encoding]::new($false)) -split "`r?`n"
|
||||
$start = ($lines | Select-String -Pattern "^## \[$([regex]::Escape($version))\]" | Select-Object -First 1)
|
||||
if ($start) {
|
||||
$from = $start.LineNumber
|
||||
$rest = $lines[$from..($lines.Count - 1)]
|
||||
$next = ($rest | Select-String -Pattern "^## \[" | Select-Object -First 1)
|
||||
$take = if ($next) { $next.LineNumber - 2 } else { $rest.Count - 1 }
|
||||
if ($take -ge 0) { $notes = ($rest[0..$take] -join "`n").Trim() }
|
||||
}
|
||||
}
|
||||
|
||||
$body = @{ tag_name = $tag; name = "maarcadetweet $tag"; body = $notes } | ConvertTo-Json -Depth 3
|
||||
$bodyFile = Join-Path $env:RUNNER_TEMP "release.json"
|
||||
[IO.File]::WriteAllText($bodyFile, $body, [Text.UTF8Encoding]::new($false))
|
||||
|
||||
# Beide Jobs (windows + linux) haengen an DASSELBE Release und laufen
|
||||
# parallel. Deshalb: anlegen versuchen, und wenn das scheitert (der
|
||||
# andere Job war schneller, oder es ist ein Re-Run), das vorhandene
|
||||
# Release per Tag holen.
|
||||
$created = curl.exe -s -X POST -H $auth -H "Content-Type: application/json" --data-binary "@$bodyFile" "$api/releases" | ConvertFrom-Json
|
||||
if (-not $created.id) {
|
||||
$created = curl.exe -s -H $auth "$api/releases/tags/$tag" | ConvertFrom-Json
|
||||
}
|
||||
if (-not $created.id) { throw "Konnte kein Release fuer $tag anlegen oder finden." }
|
||||
|
||||
$bundle = "crates\tauri-app\src-tauri\target\release\bundle"
|
||||
$files = Get-ChildItem -Path $bundle -Recurse -Include *.msi, *.exe
|
||||
if (-not $files) { throw "Keine Windows-Bundles unter $bundle gefunden." }
|
||||
foreach ($f in $files) {
|
||||
$name = [Uri]::EscapeDataString($f.Name)
|
||||
curl.exe -s -o NUL -w "Asset-Upload $($f.Name): HTTP %{http_code}`n" -X POST -H $auth -F "attachment=@$($f.FullName)" "$api/releases/$($created.id)/assets?name=$name"
|
||||
}
|
||||
Write-Host "Release: $env:GITHUB_SERVER_URL/$env:GITHUB_REPOSITORY/releases/tag/$tag"
|
||||
|
||||
linux:
|
||||
name: Linux (deb + rpm + AppImage)
|
||||
# NICHT `ubuntu-latest` — auf diesem Runner (VM ci-runner, 192.168.1.72)
|
||||
# ist dieses Label auf `docker://node:22-bookworm` gemappt, also einen
|
||||
# Container mit Node, aber ohne Rust und ohne GTK. Der Job braeche dort
|
||||
# bei `cargo` ab. Nachgesehen in /var/lib/gitea-runner/.runner:
|
||||
# labels: ['ubuntu-latest:docker://node:22-bookworm', 'linux-amd64:host']
|
||||
# `linux-amd64` ist das Host-Label, und auf dem Host liegen Rust
|
||||
# (/root/.cargo/bin, auch ohne Login-Shell im PATH), Node 22 und die
|
||||
# Tauri-GTK-Deps. Der Runner-Dienst laeuft als root.
|
||||
runs-on: linux-amd64
|
||||
timeout-minutes: 60
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
env:
|
||||
# linuxdeploy/appimagetool werden als AppImage aus ~/.cache/tauri
|
||||
# gestartet und brauchen sonst FUSE, was in der VM nicht zuverlaessig
|
||||
# funktioniert (siehe MAARCADE-INFRASTRUKTUR.md: "AppImage scheitert an
|
||||
# linuxdeploy/FUSE"). Mit dieser Variable entpacken sie sich selbst.
|
||||
APPIMAGE_EXTRACT_AND_RUN: "1"
|
||||
# Das Release-Profil in src-tauri/Cargo.toml strippt bereits selbst;
|
||||
# linuxdeploys eigener strip-Lauf ist dann nur eine weitere Fehlerquelle.
|
||||
NO_STRIP: "true"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Toolchain melden
|
||||
run: |
|
||||
node --version
|
||||
npm --version
|
||||
cargo --version || echo "cargo fehlt im PATH — ggf. ~/.cargo/env sourcen"
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: crates/tauri-app
|
||||
run: npm ci
|
||||
|
||||
# bundle.targets in tauri.conf.json ist "all" -> unter Linux sind das
|
||||
# deb, rpm und appimage. Bewusst in zwei Aufrufe getrennt: der
|
||||
# AppImage-Schritt ist der fragile (Downloads von linuxdeploy +
|
||||
# appimagetool beim ersten Lauf, FUSE), und wenn er faellt, sollen deb
|
||||
# und rpm trotzdem im Release landen. Der zweite Aufruf ist billig — der
|
||||
# Cargo-Release-Build ist dann schon im target/-Cache.
|
||||
- name: Build Tauri bundles (deb + rpm)
|
||||
working-directory: crates/tauri-app
|
||||
run: npm run tauri -- build --ci --bundles deb,rpm
|
||||
|
||||
- name: Build Tauri bundle (AppImage)
|
||||
working-directory: crates/tauri-app
|
||||
continue-on-error: true
|
||||
run: npm run tauri -- build --ci --bundles appimage
|
||||
|
||||
- name: Bundles auflisten
|
||||
run: |
|
||||
find crates/tauri-app/src-tauri/target/release/bundle \
|
||||
\( -name '*.deb' -o -name '*.rpm' -o -name '*.AppImage' \) \
|
||||
-printf '%p (%kK)\n' || true
|
||||
|
||||
- name: Upload bundles
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: maarcadetweet-linux
|
||||
path: |
|
||||
crates/tauri-app/src-tauri/target/release/bundle/deb/*.deb
|
||||
crates/tauri-app/src-tauri/target/release/bundle/rpm/*.rpm
|
||||
crates/tauri-app/src-tauri/target/release/bundle/appimage/*.AppImage
|
||||
# AppImage darf fehlen (siehe continue-on-error oben), deb/rpm nicht —
|
||||
# ein komplett leerer Upload soll auffallen.
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
- name: Gitea-Release anlegen und Bundles anhaengen
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
version="${tag#v}"
|
||||
|
||||
# Gleiche CHANGELOG-Logik wie im Windows-Job. Die Datei existiert
|
||||
# heute nicht; ohne sie bleibt es beim Fallback-Text.
|
||||
notes="Automatisch gebaut aus ${GITHUB_SHA}."
|
||||
if [ -f CHANGELOG.md ]; then
|
||||
section="$(awk -v v="$version" '
|
||||
/^## \[/ { if (found) exit; if ($0 ~ "^## \\[" v "\\]") { found = 1; next } }
|
||||
found { print }
|
||||
' CHANGELOG.md)"
|
||||
if [ -n "$(printf '%s' "$section" | tr -d '[:space:]')" ]; then
|
||||
notes="$section"
|
||||
fi
|
||||
fi
|
||||
|
||||
# jq ist auf dem Runner nicht garantiert, Node 22 schon.
|
||||
payload="${RUNNER_TEMP}/release.json"
|
||||
NOTES="$notes" TAG="$tag" node -e '
|
||||
const fs = require("fs");
|
||||
fs.writeFileSync(process.argv[1], JSON.stringify({
|
||||
tag_name: process.env.TAG,
|
||||
name: `maarcadetweet ${process.env.TAG}`,
|
||||
body: process.env.NOTES,
|
||||
}));
|
||||
' "$payload"
|
||||
|
||||
# Anlegen oder — falls der Windows-Job schneller war bzw. das Release
|
||||
# vom Re-Run schon existiert — das vorhandene holen.
|
||||
id="$(curl -s -X POST -H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" --data-binary "@${payload}" \
|
||||
"${api}/releases" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{process.stdout.write(String(JSON.parse(s).id||""))}catch{}})')"
|
||||
if [ -z "$id" ]; then
|
||||
id="$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${api}/releases/tags/${tag}" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{process.stdout.write(String(JSON.parse(s).id||""))}catch{}})')"
|
||||
fi
|
||||
if [ -z "$id" ]; then
|
||||
echo "Konnte kein Release fuer ${tag} anlegen oder finden." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
bundle="crates/tauri-app/src-tauri/target/release/bundle"
|
||||
found=0
|
||||
while IFS= read -r f; do
|
||||
found=1
|
||||
name="$(basename "$f")"
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-F "attachment=@${f}" \
|
||||
"${api}/releases/${id}/assets?name=${name}")"
|
||||
echo "Asset-Upload ${name}: HTTP ${code}"
|
||||
done < <(find "$bundle" \( -name '*.deb' -o -name '*.rpm' -o -name '*.AppImage' \) | sort)
|
||||
[ "$found" -eq 1 ] || { echo "Keine Linux-Bundles unter ${bundle} gefunden." >&2; exit 1; }
|
||||
|
||||
echo "Release: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${tag}"
|
||||
@@ -1,6 +1,5 @@
|
||||
use anyhow::Result;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use serde_json::json;
|
||||
use futures::StreamExt;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
@@ -33,6 +32,43 @@ impl JetstreamConsumer {
|
||||
}
|
||||
}
|
||||
|
||||
/// The URL actually dialled: base URL plus `wantedCollections` and
|
||||
/// `cursor` as query parameters.
|
||||
///
|
||||
/// This used to connect to the bare URL and then send
|
||||
/// `{"type": "options", "wantedCollections": [...]}` as a text frame.
|
||||
/// Jetstream ignores that, and silently: filters are query parameters,
|
||||
/// and the only message-based path (`options_update`) requires the
|
||||
/// connection to have been opened with `requireHello=true`. So every
|
||||
/// deployment that thought it was subscribing to six collections was in
|
||||
/// fact taking the entire public firehose — measured against
|
||||
/// jetstream1.us-east: 3119 events in 8 s unfiltered versus 520 for a
|
||||
/// single collection. On the dev database that quietly grew to 3.3 M
|
||||
/// posts; on the production instance the AppView had to be switched off
|
||||
/// to stop it filling the disk.
|
||||
///
|
||||
/// Note what fixing this does *not* solve: the collections this project
|
||||
/// wants (`app.bsky.feed.post` / `like` / `repost` / `graph.follow`) are
|
||||
/// ~97 % of the firehose by volume. Correct filtering is necessary, not
|
||||
/// sufficient — an instance that does not want the whole public network
|
||||
/// in its index wants `wantedDids`, or no Jetstream at all.
|
||||
pub fn subscribe_url(&self) -> String {
|
||||
let mut url = self.url.trim_end_matches('&').to_string();
|
||||
let mut sep = if url.contains('?') { '&' } else { '?' };
|
||||
for c in &self.collections {
|
||||
url.push(sep);
|
||||
url.push_str("wantedCollections=");
|
||||
url.push_str(&urlencode(c));
|
||||
sep = '&';
|
||||
}
|
||||
if self.cursor_us > 0 {
|
||||
url.push(sep);
|
||||
url.push_str("cursor=");
|
||||
url.push_str(&self.cursor_us.to_string());
|
||||
}
|
||||
url
|
||||
}
|
||||
|
||||
/// Build a consumer that shares a connection-state flag with the caller.
|
||||
pub fn with_connected_flag(mut self, flag: Arc<AtomicBool>) -> Self {
|
||||
self.connected = Some(flag);
|
||||
@@ -81,23 +117,13 @@ impl JetstreamConsumer {
|
||||
F: FnMut(JetstreamEvent) -> Fut + Send,
|
||||
Fut: std::future::Future<Output = Result<()>> + Send,
|
||||
{
|
||||
let (mut ws, _) = tokio_tungstenite::connect_async(&self.url).await?;
|
||||
info!("connected to jetstream: {}", self.url);
|
||||
let url = self.subscribe_url();
|
||||
let (mut ws, _) = tokio_tungstenite::connect_async(&url).await?;
|
||||
info!("connected to jetstream: {url}");
|
||||
if let Some(flag) = &self.connected {
|
||||
flag.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
if !self.collections.is_empty() || self.cursor_us > 0 {
|
||||
let mut options = json!({ "type": "options" });
|
||||
if !self.collections.is_empty() {
|
||||
options["wantedCollections"] = json!(self.collections);
|
||||
}
|
||||
if self.cursor_us > 0 {
|
||||
options["cursor"] = json!(self.cursor_us);
|
||||
}
|
||||
ws.send(Message::Text(options.to_string())).await?;
|
||||
}
|
||||
|
||||
while let Some(msg) = ws.next().await {
|
||||
let msg = msg?;
|
||||
if let Message::Text(text) = msg {
|
||||
@@ -109,3 +135,61 @@ impl JetstreamConsumer {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Percent-encode everything outside the unreserved set. Collection NSIDs are
|
||||
/// dots and letters today, but a `wantedDids` value carries `:` — encoding
|
||||
/// unconditionally keeps this correct if the caller passes one.
|
||||
fn urlencode(s: &str) -> String {
|
||||
s.chars()
|
||||
.map(|c| match c {
|
||||
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => c.to_string(),
|
||||
other => {
|
||||
let mut buf = [0u8; 4];
|
||||
other
|
||||
.encode_utf8(&mut buf)
|
||||
.bytes()
|
||||
.map(|b| format!("%{b:02X}"))
|
||||
.collect()
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod url_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn collections_go_into_the_query_string() {
|
||||
let c = JetstreamConsumer::new(
|
||||
"wss://jetstream1.us-east.bsky.network/subscribe",
|
||||
vec!["app.twi.post".into(), "app.bsky.feed.like".into()],
|
||||
);
|
||||
assert_eq!(
|
||||
c.subscribe_url(),
|
||||
"wss://jetstream1.us-east.bsky.network/subscribe\
|
||||
?wantedCollections=app.twi.post&wantedCollections=app.bsky.feed.like"
|
||||
.replace(' ', "")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_is_appended_and_respects_an_existing_query() {
|
||||
let mut c = JetstreamConsumer::new("wss://host/subscribe?compress=false", vec![]);
|
||||
c.cursor_us = 1234;
|
||||
assert_eq!(c.subscribe_url(), "wss://host/subscribe?compress=false&cursor=1234");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_filters_leaves_the_url_alone() {
|
||||
let c = JetstreamConsumer::new("wss://host/subscribe", vec![]);
|
||||
assert_eq!(c.subscribe_url(), "wss://host/subscribe");
|
||||
}
|
||||
|
||||
/// A DID contains `:`, which has to survive as `%3A` in a query value.
|
||||
#[test]
|
||||
fn values_are_percent_encoded() {
|
||||
assert_eq!(urlencode("did:plc:abc"), "did%3Aplc%3Aabc");
|
||||
assert_eq!(urlencode("app.bsky.feed.post"), "app.bsky.feed.post");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,36 @@ fn default_pds_firehose_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Default for `PDS_INVITE_REQUIRED`.
|
||||
///
|
||||
/// `false` — `com.atproto.server.createAccount` stays open unless the
|
||||
/// operator says otherwise. This is the one security switch in this file
|
||||
/// that fails *open*, and it does so for a concrete reason: dozens of
|
||||
/// integration tests across `pds-server` and `appview` create throwaway
|
||||
/// accounts against a locally running PDS, and every dev instance is
|
||||
/// bootstrapped the same way. Defaulting to `true` would break all of
|
||||
/// them on the next `cargo test`, and the usual reflex to a suite that
|
||||
/// suddenly fails is to switch the new thing off — which lands you at
|
||||
/// `false` anyway, only with the flag now looking like the thing that
|
||||
/// was in the way rather than the thing that protects the server.
|
||||
///
|
||||
/// The cost of that choice is that an operator who exposes the PDS
|
||||
/// publicly without setting the variable gets an open registration
|
||||
/// endpoint. That is paid for at startup: `pds-server` logs a loud
|
||||
/// warning on every boot where this is `false`, in the same spirit as
|
||||
/// `appview`'s `log_startup_posture`. A warning you have to read once
|
||||
/// per restart is the trade for a test suite that keeps working.
|
||||
///
|
||||
/// That warning also covers the other way this fails open:
|
||||
/// [`parse_bool_env`] reads anything it doesn't recognise as `false`, so
|
||||
/// `PDS_INVITE_REQUIRED=ture` leaves registration open. The operator
|
||||
/// who typed it sees the same startup warning as the operator who never
|
||||
/// set the variable at all, which is the only signal that distinguishes
|
||||
/// "I meant to leave it open" from "I thought I had closed it".
|
||||
fn default_pds_invite_required() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
pub pds_host: String,
|
||||
@@ -99,6 +129,24 @@ pub struct AppConfig {
|
||||
/// [`default_pds_firehose_enabled`] for why.
|
||||
#[serde(default = "default_pds_firehose_enabled")]
|
||||
pub pds_firehose_enabled: bool,
|
||||
/// Whether `com.atproto.server.createAccount` demands a valid invite
|
||||
/// code. Default `false` — see [`default_pds_invite_required`] for
|
||||
/// why this switch, alone among the security switches here, fails
|
||||
/// open.
|
||||
///
|
||||
/// When `true`, a request without an `invite_code` (or its
|
||||
/// camelCase `inviteCode` spelling), or with one that is unknown,
|
||||
/// disabled or already used up, is rejected with
|
||||
/// `400 InvalidInviteCode`. The value is also what
|
||||
/// `describeServer` reports as `invite_code_required`, so a client
|
||||
/// can find out before it asks the user for a handle.
|
||||
///
|
||||
/// Codes are minted out of band with `pds-server invite create`;
|
||||
/// there is no HTTP endpoint that creates them, on purpose — an
|
||||
/// open PDS's registration gate should not come with a second
|
||||
/// public surface that hands out keys to it.
|
||||
#[serde(default = "default_pds_invite_required")]
|
||||
pub pds_invite_required: bool,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
@@ -147,6 +195,10 @@ impl AppConfig {
|
||||
.ok()
|
||||
.map(|s| parse_bool_env(&s))
|
||||
.unwrap_or_else(default_pds_firehose_enabled),
|
||||
pds_invite_required: std::env::var("PDS_INVITE_REQUIRED")
|
||||
.ok()
|
||||
.map(|s| parse_bool_env(&s))
|
||||
.unwrap_or_else(default_pds_invite_required),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,684 @@
|
||||
//! Invite codes: minting, listing, and the single redeem operation that
|
||||
//! `com.atproto.server.createAccount` calls.
|
||||
//!
|
||||
//! # Why this exists
|
||||
//!
|
||||
//! `createAccount` had no gate at all. That was fine while the PDS only
|
||||
//! answered on `127.0.0.1:2583`; it is not fine on a public name, because
|
||||
//! each accepted account allocates a repo head, a server-held key pair, an
|
||||
//! MST that grows with every write, and a stream of firehose events every
|
||||
//! subscribed AppView is obliged to index. Invite codes are the smallest
|
||||
//! gate that turns "anyone with curl" into "anyone the operator handed a
|
||||
//! string to".
|
||||
//!
|
||||
//! The gate is off by default (`PDS_INVITE_REQUIRED`, see
|
||||
//! [`at_shared::config`]) so the existing test suites and dev instances
|
||||
//! keep creating throwaway accounts; `main` warns loudly on every boot
|
||||
//! where it is off.
|
||||
//!
|
||||
//! # Where codes come from
|
||||
//!
|
||||
//! Nowhere over HTTP. Minting lives in the `pds-server invite` subcommand
|
||||
//! ([`run_cli`]), which the operator runs on the box. Adding a
|
||||
//! `createInviteCode` endpoint would mean the thing that guards
|
||||
//! registration is itself reachable by whoever can reach registration —
|
||||
//! at which point it guards nothing, and the only question left is
|
||||
//! whether *its* auth has a hole. A subcommand has no attack surface to
|
||||
//! get wrong.
|
||||
//!
|
||||
//! # The one interesting piece of code in here
|
||||
//!
|
||||
//! [`redeem`] is a single conditional `UPDATE … RETURNING`, not a
|
||||
//! `SELECT`-then-`UPDATE`. See its docs and
|
||||
//! `migrations/pds/0004_invite_codes.sql` for why that distinction is the
|
||||
//! whole feature.
|
||||
|
||||
use rand::rngs::OsRng;
|
||||
use rand::RngCore;
|
||||
use sqlx::{PgPool, Postgres, Transaction};
|
||||
|
||||
/// Alphabet the codes are drawn from: Crockford base32, lowercased.
|
||||
///
|
||||
/// Exactly 32 symbols, which is the property that matters — it lets each
|
||||
/// character consume exactly 5 bits of entropy with no modulo bias, so
|
||||
/// every code in the space is equally likely. A 31- or 36-character
|
||||
/// "human friendly" alphabet would need rejection sampling to say the
|
||||
/// same thing, and the usual `byte % len` shortcut would quietly make
|
||||
/// some characters more probable than others.
|
||||
///
|
||||
/// The excluded letters are Crockford's: `i`, `l`, `o` and `u`. The
|
||||
/// first three are the ones people mistype as `1`, `1` and `0` when
|
||||
/// copying a code out of a chat message; `u` is dropped so a random draw
|
||||
/// cannot spell something the operator has to apologise for.
|
||||
const CODE_ALPHABET: &[u8; 32] = b"0123456789abcdefghjkmnpqrstvwxyz";
|
||||
|
||||
/// Characters per group, and groups per code. Two groups of five is
|
||||
/// 50 bits of entropy — far past anything an online guesser can reach
|
||||
/// against a database round-trip per attempt, and short enough to read
|
||||
/// aloud.
|
||||
const GROUP_LEN: usize = 5;
|
||||
const GROUPS: usize = 2;
|
||||
|
||||
/// Fixed prefix so a code is recognisable as one when it turns up out of
|
||||
/// context (a support ticket, a pasted log line) and so it cannot be
|
||||
/// confused with a handle or a DID.
|
||||
const CODE_PREFIX: &str = "mt";
|
||||
|
||||
/// Why a redemption was refused.
|
||||
///
|
||||
/// Deliberately coarse. The route maps [`RedeemError::Invalid`] to a
|
||||
/// single `400 InvalidInviteCode` with one fixed message, so an
|
||||
/// unauthenticated caller cannot use the error text to distinguish
|
||||
/// "no such code" from "that code exists but is used up" — which would
|
||||
/// turn the endpoint into an oracle for probing the code space.
|
||||
#[derive(Debug)]
|
||||
pub enum RedeemError {
|
||||
/// Unknown, disabled, or already at its use limit. One variant on
|
||||
/// purpose: see the type docs.
|
||||
Invalid,
|
||||
/// The database itself failed. Distinct from [`RedeemError::Invalid`]
|
||||
/// because this is a `500`, not a `400` — refusing a legitimate code
|
||||
/// because Postgres hiccuped would be a lie to the user.
|
||||
Db(sqlx::Error),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RedeemError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
RedeemError::Invalid => write!(f, "invite code is not valid"),
|
||||
RedeemError::Db(e) => write!(f, "invite lookup failed: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalise a client-supplied code into the form stored in the table.
|
||||
///
|
||||
/// Trims surrounding whitespace (people paste codes with a trailing
|
||||
/// newline out of a terminal) and lowercases. Every generated code is
|
||||
/// already lowercase ASCII, so this is an exact normalisation — which is
|
||||
/// what lets [`redeem`] look the code up with a plain `code = $1` and
|
||||
/// hit the primary-key index, instead of `lower(code) = $1`, which
|
||||
/// would force a sequential scan on the one query that runs per
|
||||
/// registration attempt.
|
||||
///
|
||||
/// Returns `None` for a code that is empty after trimming, so "field
|
||||
/// present but blank" and "field absent" reach the route as the same
|
||||
/// case.
|
||||
pub fn normalize(raw: &str) -> Option<String> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(trimmed.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
/// Generate one cryptographically random invite code, e.g.
|
||||
/// `mt-7k3qw-z9d2m`.
|
||||
///
|
||||
/// Randomness comes from [`OsRng`] — the same source
|
||||
/// `keys::generate_user_keys` and `password::hash_password` already use
|
||||
/// in this crate, i.e. the OS CSPRNG, never a seeded or thread-local
|
||||
/// generator. A code is a bearer credential for creating an account on
|
||||
/// this server; a predictable one is the same bug as a predictable
|
||||
/// password-reset token.
|
||||
///
|
||||
/// Entropy: [`GROUPS`] × [`GROUP_LEN`] characters × 5 bits = 50 bits.
|
||||
pub fn generate_code() -> String {
|
||||
let total = GROUPS * GROUP_LEN;
|
||||
let mut bytes = vec![0u8; total];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
|
||||
let mut out = String::with_capacity(CODE_PREFIX.len() + total + GROUPS);
|
||||
out.push_str(CODE_PREFIX);
|
||||
for chunk in bytes.chunks(GROUP_LEN) {
|
||||
out.push('-');
|
||||
for b in chunk {
|
||||
// Take the low 5 bits of a uniformly random byte. The
|
||||
// alphabet is exactly 32 symbols, so this is a bijection
|
||||
// from 5 bits onto it — no bias, no rejection loop.
|
||||
out.push(CODE_ALPHABET[(*b & 0b0001_1111) as usize] as char);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Consume one use of `code` on behalf of the account `did` / `handle`.
|
||||
///
|
||||
/// **This must be called with the same transaction that creates the
|
||||
/// account.** The whole point is that a code is spent if and only if an
|
||||
/// account was actually created: if the caller's transaction rolls back
|
||||
/// — handle taken, key generation failed, anything — the counter goes
|
||||
/// back with it and the code is still redeemable. `create_account`
|
||||
/// therefore begins its transaction, redeems here, inserts `users` and
|
||||
/// `repos`, and only then commits.
|
||||
///
|
||||
/// # The race, and why there isn't one
|
||||
///
|
||||
/// The tempting implementation is: `SELECT used_count, max_uses …`,
|
||||
/// compare in Rust, then `UPDATE`. That is a check-then-act. Two
|
||||
/// registrations arriving together on a code with one use left both read
|
||||
/// `used_count = 0`, both conclude they may proceed, and both write
|
||||
/// `used_count = 1`. Two accounts, one use — and the row afterwards
|
||||
/// claims it was redeemed once, so nothing even shows up as wrong.
|
||||
///
|
||||
/// Instead the check *is* the write:
|
||||
///
|
||||
/// ```sql
|
||||
/// UPDATE invite_codes
|
||||
/// SET used_count = used_count + 1
|
||||
/// WHERE code = $1 AND NOT disabled AND used_count < max_uses
|
||||
/// RETURNING used_count, max_uses
|
||||
/// ```
|
||||
///
|
||||
/// Postgres serialises the two statements on the row lock. The loser
|
||||
/// blocks until the winner commits, and then — this is the part that
|
||||
/// makes it work — does *not* continue with its old snapshot: it
|
||||
/// re-fetches the committed row and re-evaluates the `WHERE` clause
|
||||
/// against it (EvalPlanQual). `used_count` is now `1`, the predicate is
|
||||
/// false, the row is dropped from the update set, and the statement
|
||||
/// affects zero rows. Zero rows is the rejection. This function never
|
||||
/// forms an opinion about validity that could be stale by the time it
|
||||
/// acts on it, because it never looks before it writes.
|
||||
///
|
||||
/// The `invite_code_uses` insert that follows is inside the same
|
||||
/// transaction and the same row lock, so the counter and the audit rows
|
||||
/// cannot drift apart.
|
||||
pub async fn redeem(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
code: &str,
|
||||
did: &str,
|
||||
handle: &str,
|
||||
) -> Result<(), RedeemError> {
|
||||
let normalized = match normalize(code) {
|
||||
Some(c) => c,
|
||||
None => return Err(RedeemError::Invalid),
|
||||
};
|
||||
|
||||
// One statement, and its row count is the verdict.
|
||||
let claimed: Option<(i32, i32)> = sqlx::query_as(
|
||||
r#"UPDATE invite_codes
|
||||
SET used_count = used_count + 1
|
||||
WHERE code = $1
|
||||
AND NOT disabled
|
||||
AND used_count < max_uses
|
||||
RETURNING used_count, max_uses"#,
|
||||
)
|
||||
.bind(&normalized)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await
|
||||
.map_err(RedeemError::Db)?;
|
||||
|
||||
if claimed.is_none() {
|
||||
return Err(RedeemError::Invalid);
|
||||
}
|
||||
|
||||
// Audit trail: which account this code produced. Same transaction,
|
||||
// so it lands exactly when the counter increment does.
|
||||
//
|
||||
// The `(code, did)` primary key makes a duplicate impossible; a
|
||||
// conflict here would mean the same DID redeemed the same code
|
||||
// twice in one registration, which cannot happen but would corrupt
|
||||
// the counter/uses agreement if it did — so let it be an error
|
||||
// rather than silently ignoring it.
|
||||
sqlx::query(
|
||||
r#"INSERT INTO invite_code_uses (code, did, handle)
|
||||
VALUES ($1, $2, $3)"#,
|
||||
)
|
||||
.bind(&normalized)
|
||||
.bind(did)
|
||||
.bind(handle)
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_err(RedeemError::Db)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// CLI: `pds-server invite …`
|
||||
// =====================================================
|
||||
|
||||
/// One row of `invite list`, and the shape `create` hands back.
|
||||
#[derive(Debug)]
|
||||
pub struct InviteRow {
|
||||
pub code: String,
|
||||
pub max_uses: i32,
|
||||
pub used_count: i32,
|
||||
pub disabled: bool,
|
||||
pub note: Option<String>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
/// What a parsed `invite` command line asks for.
|
||||
///
|
||||
/// Parsed out of `std::env::args` by hand. The workspace has no
|
||||
/// argument-parsing dependency and this subcommand is not worth adding
|
||||
/// one for: three verbs, three flags, and a hand-rolled parser that is
|
||||
/// small enough to unit-test exhaustively (which it is, below) beats a
|
||||
/// derive macro plus a new crate in the dependency tree of a server
|
||||
/// binary.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum InviteCommand {
|
||||
/// `invite create [--count N] [--uses N] [--note TEXT]`
|
||||
Create {
|
||||
count: u32,
|
||||
uses: i32,
|
||||
note: Option<String>,
|
||||
},
|
||||
/// `invite list [--all]` — without `--all`, spent and disabled codes
|
||||
/// are hidden, because the question the operator almost always has
|
||||
/// is "what can I still hand out".
|
||||
List { all: bool },
|
||||
/// `invite disable <code>` — stop honouring a code without losing
|
||||
/// the record of which accounts it already created.
|
||||
Disable { code: String },
|
||||
}
|
||||
|
||||
/// Usage text. Printed for `invite help`, and for anything that fails to
|
||||
/// parse.
|
||||
pub const INVITE_USAGE: &str = "\
|
||||
usage: pds-server invite <command>
|
||||
|
||||
create [--count N] [--uses N] [--note TEXT]
|
||||
Mint N codes (default 1), each good for `--uses` accounts
|
||||
(default 1). Prints one code per line and nothing else, so the
|
||||
output can be piped or pasted directly.
|
||||
|
||||
list [--all]
|
||||
Show codes that can still be redeemed. --all includes spent and
|
||||
disabled ones.
|
||||
|
||||
disable <code>
|
||||
Stop honouring a code. The record of accounts it already created
|
||||
is kept.
|
||||
|
||||
The database is the one named by DATABASE_URL_PDS (read from .env like
|
||||
the server does). Codes are only meaningful while PDS_INVITE_REQUIRED
|
||||
is true.";
|
||||
|
||||
/// Parse the arguments after the `invite` verb.
|
||||
///
|
||||
/// Returns `Err(message)` for anything malformed; the caller prints the
|
||||
/// message plus [`INVITE_USAGE`] and exits non-zero. Unknown flags are
|
||||
/// an error rather than being ignored — a typo'd `--uses` that silently
|
||||
/// became `1` would hand out the wrong codes and the operator would only
|
||||
/// find out when the second person to use one got a `400`.
|
||||
pub fn parse_invite_args(args: &[String]) -> Result<InviteCommand, String> {
|
||||
let verb = args
|
||||
.first()
|
||||
.map(|s| s.as_str())
|
||||
.ok_or_else(|| "missing invite command".to_string())?;
|
||||
let rest = &args[1..];
|
||||
|
||||
match verb {
|
||||
"create" => {
|
||||
let mut count: u32 = 1;
|
||||
let mut uses: i32 = 1;
|
||||
let mut note: Option<String> = None;
|
||||
let mut i = 0;
|
||||
while i < rest.len() {
|
||||
match rest[i].as_str() {
|
||||
"--count" => {
|
||||
let v = rest
|
||||
.get(i + 1)
|
||||
.ok_or_else(|| "--count needs a value".to_string())?;
|
||||
count = v
|
||||
.parse()
|
||||
.map_err(|_| format!("--count: not a number: {v}"))?;
|
||||
if count == 0 {
|
||||
return Err("--count must be at least 1".to_string());
|
||||
}
|
||||
i += 2;
|
||||
}
|
||||
"--uses" => {
|
||||
let v = rest
|
||||
.get(i + 1)
|
||||
.ok_or_else(|| "--uses needs a value".to_string())?;
|
||||
uses = v
|
||||
.parse()
|
||||
.map_err(|_| format!("--uses: not a number: {v}"))?;
|
||||
// Mirrors the table's CHECK (max_uses > 0). Caught
|
||||
// here so the operator gets a sentence instead of a
|
||||
// constraint-violation dump.
|
||||
if uses < 1 {
|
||||
return Err("--uses must be at least 1".to_string());
|
||||
}
|
||||
i += 2;
|
||||
}
|
||||
"--note" => {
|
||||
let v = rest
|
||||
.get(i + 1)
|
||||
.ok_or_else(|| "--note needs a value".to_string())?;
|
||||
note = Some(v.clone());
|
||||
i += 2;
|
||||
}
|
||||
other => return Err(format!("unknown option for `create`: {other}")),
|
||||
}
|
||||
}
|
||||
Ok(InviteCommand::Create { count, uses, note })
|
||||
}
|
||||
"list" => {
|
||||
let mut all = false;
|
||||
for a in rest {
|
||||
match a.as_str() {
|
||||
"--all" => all = true,
|
||||
other => return Err(format!("unknown option for `list`: {other}")),
|
||||
}
|
||||
}
|
||||
Ok(InviteCommand::List { all })
|
||||
}
|
||||
"disable" => {
|
||||
let code = rest
|
||||
.first()
|
||||
.ok_or_else(|| "disable needs a code".to_string())?;
|
||||
if rest.len() > 1 {
|
||||
return Err("disable takes exactly one code".to_string());
|
||||
}
|
||||
let code = normalize(code).ok_or_else(|| "disable needs a code".to_string())?;
|
||||
Ok(InviteCommand::Disable { code })
|
||||
}
|
||||
other => Err(format!("unknown invite command: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert `count` freshly generated codes, each good for `uses`
|
||||
/// accounts.
|
||||
///
|
||||
/// Retries on a primary-key collision. With 50 bits per code a
|
||||
/// collision is not something that will happen, but "not something that
|
||||
/// will happen" is exactly the class of event that turns into a
|
||||
/// confusing `duplicate key` traceback at 2am, and the retry costs three
|
||||
/// lines.
|
||||
pub async fn create_codes(
|
||||
db: &PgPool,
|
||||
count: u32,
|
||||
uses: i32,
|
||||
note: Option<&str>,
|
||||
) -> anyhow::Result<Vec<String>> {
|
||||
let mut out = Vec::with_capacity(count as usize);
|
||||
for _ in 0..count {
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
let code = generate_code();
|
||||
let inserted = sqlx::query(
|
||||
r#"INSERT INTO invite_codes (code, max_uses, note)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (code) DO NOTHING"#,
|
||||
)
|
||||
.bind(&code)
|
||||
.bind(uses)
|
||||
.bind(note)
|
||||
.execute(db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
if inserted == 1 {
|
||||
out.push(code);
|
||||
break;
|
||||
}
|
||||
attempt += 1;
|
||||
if attempt >= 5 {
|
||||
anyhow::bail!("could not find a free invite code after 5 attempts");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Read back codes for `invite list`.
|
||||
pub async fn list_codes(db: &PgPool, all: bool) -> anyhow::Result<Vec<InviteRow>> {
|
||||
// Two statements rather than one with a `$1`-toggled predicate:
|
||||
// the redeemable filter is exactly the redeem query's `WHERE`
|
||||
// clause, and keeping it spelled the same way makes it obvious that
|
||||
// `list` shows what `redeem` would accept.
|
||||
let sql = if all {
|
||||
r#"SELECT code, max_uses, used_count, disabled, note, created_at
|
||||
FROM invite_codes
|
||||
ORDER BY created_at DESC"#
|
||||
} else {
|
||||
r#"SELECT code, max_uses, used_count, disabled, note, created_at
|
||||
FROM invite_codes
|
||||
WHERE NOT disabled AND used_count < max_uses
|
||||
ORDER BY created_at DESC"#
|
||||
};
|
||||
let rows: Vec<(String, i32, i32, bool, Option<String>, chrono::DateTime<chrono::Utc>)> =
|
||||
sqlx::query_as(sql).fetch_all(db).await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(code, max_uses, used_count, disabled, note, created_at)| InviteRow {
|
||||
code,
|
||||
max_uses,
|
||||
used_count,
|
||||
disabled,
|
||||
note,
|
||||
created_at,
|
||||
},
|
||||
)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Flip `disabled` on one code. Returns `false` if there is no such
|
||||
/// code, so the CLI can say so instead of reporting a successful no-op.
|
||||
pub async fn disable_code(db: &PgPool, code: &str) -> anyhow::Result<bool> {
|
||||
let n = sqlx::query("UPDATE invite_codes SET disabled = TRUE WHERE code = $1")
|
||||
.bind(code)
|
||||
.execute(db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
Ok(n == 1)
|
||||
}
|
||||
|
||||
/// Run the `invite` subcommand end to end: parse, connect, act, print.
|
||||
///
|
||||
/// Connects with the same `DATABASE_URL_PDS` and runs the same
|
||||
/// migrations as [`crate::main`], so `invite create` works on a fresh
|
||||
/// checkout before the server has ever been started — otherwise the
|
||||
/// first thing an operator does after deploying would fail with
|
||||
/// "relation invite_codes does not exist".
|
||||
pub async fn run_cli(args: &[String]) -> anyhow::Result<()> {
|
||||
if matches!(args.first().map(|s| s.as_str()), None | Some("help") | Some("-h") | Some("--help"))
|
||||
{
|
||||
println!("{INVITE_USAGE}");
|
||||
return Ok(());
|
||||
}
|
||||
let cmd = match parse_invite_args(args) {
|
||||
Ok(c) => c,
|
||||
Err(msg) => {
|
||||
eprintln!("pds-server invite: {msg}\n\n{INVITE_USAGE}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
|
||||
let cfg = at_shared::config::AppConfig::from_env()?;
|
||||
let db = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(2)
|
||||
.acquire_timeout(std::time::Duration::from_secs(10))
|
||||
.connect(&cfg.database_url_pds)
|
||||
.await?;
|
||||
sqlx::migrate!("../../migrations/pds").run(&db).await?;
|
||||
|
||||
match cmd {
|
||||
InviteCommand::Create { count, uses, note } => {
|
||||
let codes = create_codes(&db, count, uses, note.as_deref()).await?;
|
||||
// Bare codes, one per line, nothing else on stdout — the
|
||||
// operator pipes this into a message or a file. Anything
|
||||
// decorative here would have to be stripped by hand.
|
||||
for c in &codes {
|
||||
println!("{c}");
|
||||
}
|
||||
if !cfg.pds_invite_required {
|
||||
// Not an error: minting codes before flipping the switch
|
||||
// is the correct order of operations. But an operator
|
||||
// who thinks they have just closed registration should
|
||||
// find out now.
|
||||
eprintln!(
|
||||
"note: PDS_INVITE_REQUIRED is not true — createAccount currently \
|
||||
accepts requests without any code."
|
||||
);
|
||||
}
|
||||
}
|
||||
InviteCommand::List { all } => {
|
||||
let rows = list_codes(&db, all).await?;
|
||||
if rows.is_empty() {
|
||||
eprintln!("no invite codes");
|
||||
}
|
||||
for r in rows {
|
||||
let state = if r.disabled {
|
||||
"disabled"
|
||||
} else if r.used_count >= r.max_uses {
|
||||
"spent"
|
||||
} else {
|
||||
"open"
|
||||
};
|
||||
println!(
|
||||
"{} {}/{} {} {} {}",
|
||||
r.code,
|
||||
r.used_count,
|
||||
r.max_uses,
|
||||
state,
|
||||
r.created_at.format("%Y-%m-%dT%H:%M:%SZ"),
|
||||
r.note.as_deref().unwrap_or("")
|
||||
);
|
||||
}
|
||||
}
|
||||
InviteCommand::Disable { code } => {
|
||||
if disable_code(&db, &code).await? {
|
||||
println!("disabled {code}");
|
||||
} else {
|
||||
eprintln!("no such invite code: {code}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -- tests -------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[test]
|
||||
fn generated_codes_use_only_the_safe_alphabet() {
|
||||
let code = generate_code();
|
||||
// `mt-xxxxx-xxxxx`
|
||||
assert!(code.starts_with("mt-"), "code = {code}");
|
||||
let groups: Vec<&str> = code.split('-').collect();
|
||||
assert_eq!(groups.len(), GROUPS + 1, "code = {code}");
|
||||
assert_eq!(groups[0], CODE_PREFIX);
|
||||
for g in &groups[1..] {
|
||||
assert_eq!(g.len(), GROUP_LEN, "group {g} in {code}");
|
||||
for ch in g.chars() {
|
||||
assert!(
|
||||
CODE_ALPHABET.contains(&(ch as u8)),
|
||||
"character {ch:?} in {code} is outside the alphabet"
|
||||
);
|
||||
}
|
||||
}
|
||||
// The letters people mistype must never appear.
|
||||
for bad in ['i', 'l', 'o', 'u'] {
|
||||
assert!(!code[3..].contains(bad), "{code} contains {bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_codes_do_not_repeat() {
|
||||
// Not a randomness test — a 1000-draw collision would mean the
|
||||
// generator is returning a constant or reusing a seeded RNG,
|
||||
// which is the failure mode that actually happens when someone
|
||||
// swaps `OsRng` for `thread_rng` with a fixed seed in a test
|
||||
// helper.
|
||||
let mut seen = HashSet::new();
|
||||
for _ in 0..1000 {
|
||||
assert!(seen.insert(generate_code()), "duplicate code in 1000 draws");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_trims_and_lowercases() {
|
||||
assert_eq!(normalize(" MT-ABCDE-FGHJK \n").as_deref(), Some("mt-abcde-fghjk"));
|
||||
assert_eq!(normalize("mt-abcde-fghjk").as_deref(), Some("mt-abcde-fghjk"));
|
||||
// Absent and blank must be indistinguishable to the route.
|
||||
assert_eq!(normalize(""), None);
|
||||
assert_eq!(normalize(" "), None);
|
||||
assert_eq!(normalize("\t\n"), None);
|
||||
}
|
||||
|
||||
fn args(v: &[&str]) -> Vec<String> {
|
||||
v.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_create_with_defaults_and_flags() {
|
||||
assert_eq!(
|
||||
parse_invite_args(&args(&["create"])).unwrap(),
|
||||
InviteCommand::Create {
|
||||
count: 1,
|
||||
uses: 1,
|
||||
note: None
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_invite_args(&args(&["create", "--count", "5", "--uses", "1"])).unwrap(),
|
||||
InviteCommand::Create {
|
||||
count: 5,
|
||||
uses: 1,
|
||||
note: None
|
||||
}
|
||||
);
|
||||
// Order must not matter, and --note takes the next argument
|
||||
// verbatim (spaces included).
|
||||
assert_eq!(
|
||||
parse_invite_args(&args(&["create", "--note", "meetup 2026", "--uses", "3"])).unwrap(),
|
||||
InviteCommand::Create {
|
||||
count: 1,
|
||||
uses: 3,
|
||||
note: Some("meetup 2026".to_string())
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_create_flags() {
|
||||
// A typo'd flag must not be silently ignored — that would hand
|
||||
// out codes with the default limits.
|
||||
assert!(parse_invite_args(&args(&["create", "--use", "3"])).is_err());
|
||||
assert!(parse_invite_args(&args(&["create", "--count"])).is_err());
|
||||
assert!(parse_invite_args(&args(&["create", "--count", "x"])).is_err());
|
||||
assert!(parse_invite_args(&args(&["create", "--count", "0"])).is_err());
|
||||
// max_uses > 0 is a table constraint; catch it before Postgres does.
|
||||
assert!(parse_invite_args(&args(&["create", "--uses", "0"])).is_err());
|
||||
assert!(parse_invite_args(&args(&["create", "--uses", "-2"])).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_list_and_disable() {
|
||||
assert_eq!(
|
||||
parse_invite_args(&args(&["list"])).unwrap(),
|
||||
InviteCommand::List { all: false }
|
||||
);
|
||||
assert_eq!(
|
||||
parse_invite_args(&args(&["list", "--all"])).unwrap(),
|
||||
InviteCommand::List { all: true }
|
||||
);
|
||||
assert!(parse_invite_args(&args(&["list", "--everything"])).is_err());
|
||||
// `disable` normalises the code the same way redeem does, so an
|
||||
// operator pasting a shouted code still disables the right row.
|
||||
assert_eq!(
|
||||
parse_invite_args(&args(&["disable", " MT-ABCDE-FGHJK "])).unwrap(),
|
||||
InviteCommand::Disable {
|
||||
code: "mt-abcde-fghjk".to_string()
|
||||
}
|
||||
);
|
||||
assert!(parse_invite_args(&args(&["disable"])).is_err());
|
||||
assert!(parse_invite_args(&args(&["disable", "a", "b"])).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_verb_and_empty_args() {
|
||||
assert!(parse_invite_args(&args(&[])).is_err());
|
||||
assert!(parse_invite_args(&args(&["destroy"])).is_err());
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ mod appview_push;
|
||||
mod car;
|
||||
mod dag_cbor;
|
||||
mod firehose;
|
||||
mod invite;
|
||||
mod jwt_issuer;
|
||||
mod keys;
|
||||
mod password;
|
||||
@@ -17,6 +18,16 @@ use serde_json::json;
|
||||
use tracing::{info, warn};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
/// Usage line for the binary itself. The subcommands are operator
|
||||
/// tooling; the no-argument form is the server, which is what every
|
||||
/// deploy script and systemd unit invokes.
|
||||
const USAGE: &str = "\
|
||||
usage: pds-server [command]
|
||||
|
||||
(no command) run the PDS server
|
||||
invite … manage invite codes (see `pds-server invite help`)
|
||||
help show this message";
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// Load `.env` from the working directory (and upwards) if present.
|
||||
@@ -25,11 +36,57 @@ async fn main() -> anyhow::Result<()> {
|
||||
// PDS_HOST`. Real environment variables always win over the file.
|
||||
let _ = dotenvy::dotenv();
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
|
||||
.init();
|
||||
// Argument dispatch, by hand.
|
||||
//
|
||||
// The workspace carries no argument-parsing crate and this does not
|
||||
// justify adding one: exactly one subcommand exists, and the
|
||||
// overwhelmingly common invocation is the bare binary. Anything we
|
||||
// do not recognise is an error rather than being ignored — a
|
||||
// mistyped `pds-server invit create` that silently booted a server
|
||||
// would look like it worked and mint no codes.
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
match args.first().map(|s| s.as_str()) {
|
||||
None => {}
|
||||
Some("invite") => {
|
||||
// CLI output is meant to be read and pasted, so keep the
|
||||
// log stream quiet unless the operator asked for it. Without
|
||||
// this, `sqlx::migrate` chatters over the codes.
|
||||
init_tracing("warn");
|
||||
return invite::run_cli(&args[1..]).await;
|
||||
}
|
||||
Some("help") | Some("-h") | Some("--help") => {
|
||||
println!("{USAGE}");
|
||||
return Ok(());
|
||||
}
|
||||
Some(other) => {
|
||||
eprintln!("pds-server: unknown command: {other}\n\n{USAGE}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
init_tracing("info");
|
||||
|
||||
let cfg = at_shared::config::AppConfig::from_env()?;
|
||||
|
||||
// Announce the relaxed security posture before we bind a port.
|
||||
//
|
||||
// `PDS_INVITE_REQUIRED` is the only switch in `AppConfig` that
|
||||
// defaults to *open* (so the integration suites and dev instances
|
||||
// can keep creating throwaway accounts), which makes this warning
|
||||
// the only thing standing between "we made the PDS public" and
|
||||
// "anyone on the internet can mint repos on our disk". Mirrors
|
||||
// `appview`'s `auth::log_startup_posture`.
|
||||
if !cfg.pds_invite_required {
|
||||
warn!(
|
||||
"PDS_INVITE_REQUIRED is not true — com.atproto.server.createAccount accepts \
|
||||
ANY caller, and every accepted account allocates a repo, a server-held key \
|
||||
pair and firehose events. Fine on a private/dev instance; on a publicly \
|
||||
reachable PDS set PDS_INVITE_REQUIRED=true and hand out codes with \
|
||||
`pds-server invite create`."
|
||||
);
|
||||
} else {
|
||||
info!("PDS_INVITE_REQUIRED=true — createAccount requires a valid invite code");
|
||||
}
|
||||
let db = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(32)
|
||||
.min_connections(2)
|
||||
@@ -71,6 +128,21 @@ async fn main() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Install the tracing subscriber, with `default_filter` as the level
|
||||
/// when `RUST_LOG` says nothing.
|
||||
///
|
||||
/// Factored out because the two entry points want different defaults:
|
||||
/// the server wants `info`, the `invite` subcommand wants `warn` so that
|
||||
/// migration chatter does not land in the middle of a list of codes the
|
||||
/// operator is about to copy.
|
||||
fn init_tracing(default_filter: &str) {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_filter)),
|
||||
)
|
||||
.init();
|
||||
}
|
||||
|
||||
pub fn router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/", get(root))
|
||||
@@ -241,7 +313,12 @@ async fn describe_server(State(state): State<AppState>) -> Json<DescribeServerRe
|
||||
.pds_handle_dns_zone
|
||||
.trim_start_matches('.')
|
||||
.to_string()],
|
||||
invite_code_required: false,
|
||||
// The real switch, not a hardcoded `false`. A client reads this
|
||||
// to decide whether to ask the user for a code *before*
|
||||
// collecting a handle and password — advertising `false` on a
|
||||
// server that then answers `400 InvalidInviteCode` sends the
|
||||
// user back to the start of a form they already filled in.
|
||||
invite_code_required: state.cfg.pds_invite_required,
|
||||
links: json!({
|
||||
"termsOfService": null,
|
||||
"privacyPolicy": null,
|
||||
|
||||
@@ -93,6 +93,55 @@ pub async fn create_account(
|
||||
|
||||
let mut tx = state.db.begin().await.map_err(|e| internal(e))?;
|
||||
|
||||
// Invite gate.
|
||||
//
|
||||
// Inside the transaction, and *first* inside it, for two reasons.
|
||||
//
|
||||
// Inside, because the code must be spent if and only if an account
|
||||
// was really created. Redeeming before `begin()` (or in a
|
||||
// transaction of its own) would burn a code every time the INSERT
|
||||
// below hit the `users.handle` unique index — a user who lost a
|
||||
// handle race would also lose their invite, with nothing to show
|
||||
// for it. Everything from here to `tx.commit()` rolls back together.
|
||||
//
|
||||
// First, because `invite::redeem` takes the code row's lock, and
|
||||
// holding it across the account INSERTs is what serialises two
|
||||
// registrations that present the same last remaining use. See
|
||||
// `invite::redeem` for how the conditional UPDATE turns that lock
|
||||
// into a correct decision rather than a stale one.
|
||||
//
|
||||
// Note this runs after the handle/password validation above, so a
|
||||
// malformed request is rejected without touching a code at all.
|
||||
if state.cfg.pds_invite_required {
|
||||
let supplied = req
|
||||
.invite_code
|
||||
.as_deref()
|
||||
.and_then(crate::invite::normalize);
|
||||
match supplied {
|
||||
None => return Err(invalid_invite_code()),
|
||||
Some(code) => {
|
||||
if let Err(e) =
|
||||
crate::invite::redeem(&mut tx, &code, &did, &req.handle).await
|
||||
{
|
||||
return match e {
|
||||
crate::invite::RedeemError::Invalid => {
|
||||
// Deliberately not logged with the code at
|
||||
// info level: a public endpoint that echoes
|
||||
// every guessed code into the log is a way
|
||||
// to fill the disk from outside.
|
||||
warn!(
|
||||
handle = %req.handle,
|
||||
"createAccount rejected: invite code invalid, disabled or spent"
|
||||
);
|
||||
Err(invalid_invite_code())
|
||||
}
|
||||
crate::invite::RedeemError::Db(db_err) => Err(internal(db_err)),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
r#"INSERT INTO users (did, handle, email, password_hash, signing_key, rotation_key)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)"#,
|
||||
@@ -306,6 +355,28 @@ pub async fn refresh_session(
|
||||
}))
|
||||
}
|
||||
|
||||
/// The one error a failed invite check produces.
|
||||
///
|
||||
/// Same `(StatusCode, Json<ErrorBody>)` shape every other route in this
|
||||
/// module returns, so a client parses it with the code it already has:
|
||||
/// `{"error": "InvalidInviteCode", "message": "..."}` under a `400`.
|
||||
///
|
||||
/// One message for every failure mode — missing, unknown, disabled,
|
||||
/// spent — on purpose. A distinct "that code exists but is used up"
|
||||
/// would let an unauthenticated caller walk the code space and learn
|
||||
/// which strings are real, which is most of the work of stealing one.
|
||||
/// The operator can tell the cases apart from `pds-server invite list`;
|
||||
/// the internet cannot.
|
||||
fn invalid_invite_code() -> (StatusCode, Json<crate::routes::types::ErrorBody>) {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(crate::routes::types::ErrorBody::new(
|
||||
"InvalidInviteCode",
|
||||
Some("a valid invite code is required to create an account on this server".into()),
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
fn internal(e: impl std::fmt::Display) -> (StatusCode, Json<crate::routes::types::ErrorBody>) {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
|
||||
@@ -6,6 +6,18 @@ pub struct CreateAccountReq {
|
||||
pub email: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub did: Option<String>,
|
||||
/// The invite code, when `PDS_INVITE_REQUIRED` is on.
|
||||
///
|
||||
/// The alias is not cosmetic. This struct — like every other type in
|
||||
/// this module — is snake_case on the wire, which is what our own
|
||||
/// clients send. The AT Protocol lexicon for
|
||||
/// `com.atproto.server.createAccount` spells the field `inviteCode`,
|
||||
/// so every off-the-shelf atproto client sends *that*, and without
|
||||
/// the alias serde would drop it into `None` silently — the account
|
||||
/// would be refused with "an invite code is required" while the user
|
||||
/// is looking at the code they just pasted. Accepting both spellings
|
||||
/// costs one attribute; debugging that report costs an afternoon.
|
||||
#[serde(alias = "inviteCode")]
|
||||
pub invite_code: Option<String>,
|
||||
pub recovery_key: Option<String>,
|
||||
}
|
||||
|
||||
@@ -314,19 +314,32 @@ async fn upload_blob_rejects_oversized() {
|
||||
// 413 from axum's body extractor.
|
||||
let payload = vec![0u8; 2 * 1024 * 1024];
|
||||
|
||||
let resp = c
|
||||
// Two legitimate outcomes, and which one happens is a race the test
|
||||
// cannot win: the limit trips while the client is still writing the
|
||||
// 2 MiB body. If the rejection reaches the socket first, the client
|
||||
// reads `413`; if the server closes its side first, the client's
|
||||
// write fails with a connection reset and never gets to read a
|
||||
// status. Asserting only on `413` made this test fail roughly one run
|
||||
// in three. What actually matters — and what both outcomes prove — is
|
||||
// that the upload was refused rather than accepted.
|
||||
match c
|
||||
.post(format!("{}/xrpc/com.atproto.uploadBlob", PDS_URL))
|
||||
.bearer_auth(&jwt)
|
||||
.header("Content-Type", "image/png")
|
||||
.body(payload)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
{
|
||||
Ok(resp) => assert_eq!(
|
||||
resp.status().as_u16(),
|
||||
413,
|
||||
"oversized upload must return 413"
|
||||
);
|
||||
"oversized upload must be refused with 413"
|
||||
),
|
||||
Err(e) => assert!(
|
||||
e.is_request(),
|
||||
"the only acceptable error is the server hanging up mid-body, got {e:?}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// `com.atproto.uploadBlob` rejects requests with no `Authorization`
|
||||
|
||||
@@ -0,0 +1,793 @@
|
||||
//! Invite-code enforcement on `com.atproto.server.createAccount`.
|
||||
//!
|
||||
//! # Why this file starts its own PDS
|
||||
//!
|
||||
//! Every other integration suite in this crate talks to whatever PDS the
|
||||
//! developer already has running on `:2583` and skips itself when there
|
||||
//! isn't one. That works because those tests only need *a* PDS. These
|
||||
//! need a PDS with `PDS_INVITE_REQUIRED=true`, and the ambient one is
|
||||
//! (correctly) started with the default `false` — otherwise every other
|
||||
//! suite, which creates throwaway accounts with no code, would fail.
|
||||
//!
|
||||
//! Asking the developer to restart their PDS with a different flag
|
||||
//! before this file passes would mean the flag's behaviour is only ever
|
||||
//! tested by hand. So each test here spawns its own `pds-server` on a
|
||||
//! free port with the flag set the way that test needs it, and kills it
|
||||
//! on the way out ([`Pds`]'s `Drop`). `env!("CARGO_BIN_EXE_pds-server")`
|
||||
//! is cargo's own path to the binary it just built for this test run, so
|
||||
//! the process under test is always the current code.
|
||||
//!
|
||||
//! The suite still fails open, in the same spirit as its neighbours: if
|
||||
//! the child never becomes healthy — no Postgres, no `.env`, no
|
||||
//! `DATABASE_URL_PDS` — the tests print why and return green rather than
|
||||
//! failing a workstation that simply isn't running the stack.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::process::{Child, Command};
|
||||
use std::time::Duration;
|
||||
|
||||
/// A `pds-server` child process bound to its own port, killed when the
|
||||
/// test that started it goes out of scope.
|
||||
///
|
||||
/// The `Drop` impl is the reason this is a struct at all: a test that
|
||||
/// panics mid-way must not leave a server holding a port and a pool of
|
||||
/// Postgres connections for the rest of the run.
|
||||
struct Pds {
|
||||
child: Child,
|
||||
port: u16,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl Drop for Pds {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
impl Pds {
|
||||
fn url(&self, path: &str) -> String {
|
||||
format!("http://127.0.0.1:{}{}", self.port, path)
|
||||
}
|
||||
|
||||
async fn create_account(&self, body: Value) -> (u16, Value) {
|
||||
let resp = self
|
||||
.http
|
||||
.post(self.url("/xrpc/com.atproto.server.createAccount"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.expect("createAccount request");
|
||||
let status = resp.status().as_u16();
|
||||
let body: Value = resp.json().await.unwrap_or(Value::Null);
|
||||
(status, body)
|
||||
}
|
||||
|
||||
async fn describe(&self) -> Value {
|
||||
self.http
|
||||
.get(self.url("/xrpc/com.atproto.server.describeServer"))
|
||||
.send()
|
||||
.await
|
||||
.expect("describeServer")
|
||||
.json()
|
||||
.await
|
||||
.expect("describeServer json")
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the OS for a port nobody is using, then let go of it.
|
||||
///
|
||||
/// There is a window between the drop and the child's `bind` in which
|
||||
/// something else could take the port; on a test machine that window is
|
||||
/// theoretical, and the alternative (a fixed port) would make two
|
||||
/// concurrently running tests in this file collide *reliably* instead of
|
||||
/// never.
|
||||
fn free_port() -> Option<u16> {
|
||||
let l = std::net::TcpListener::bind("127.0.0.1:0").ok()?;
|
||||
let p = l.local_addr().ok()?.port();
|
||||
drop(l);
|
||||
Some(p)
|
||||
}
|
||||
|
||||
/// Start a `pds-server` with `PDS_INVITE_REQUIRED` set to `required`.
|
||||
///
|
||||
/// Returns `None` when the stack this needs isn't available, which the
|
||||
/// callers turn into a skip. The child inherits the ambient environment
|
||||
/// (so `DATABASE_URL_PDS` and friends come from `.env` exactly as they
|
||||
/// do for the real server — `dotenvy` does not override real variables,
|
||||
/// so our overrides below win).
|
||||
async fn start_pds(required: bool) -> Option<Pds> {
|
||||
let port = free_port()?;
|
||||
let child = Command::new(env!("CARGO_BIN_EXE_pds-server"))
|
||||
.env("PDS_HOST", "127.0.0.1")
|
||||
.env("PDS_PORT", port.to_string())
|
||||
.env("PDS_PUBLIC_URL", format!("http://127.0.0.1:{port}"))
|
||||
.env("PDS_INVITE_REQUIRED", if required { "true" } else { "false" })
|
||||
// Point the PLC submit at a closed port. `create_account`
|
||||
// tolerates a failed submit by design (the DID is computed
|
||||
// locally), and a connection refused on loopback fails in
|
||||
// microseconds — whereas the real directory would add a network
|
||||
// round-trip to every account this file creates, and might
|
||||
// actually publish throwaway test DIDs.
|
||||
.env("PLC_DIRECTORY_URL", "http://127.0.0.1:1")
|
||||
.env("RUST_LOG", "warn")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.ok()?;
|
||||
|
||||
let http = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.ok()?;
|
||||
let mut pds = Pds { child, port, http };
|
||||
|
||||
for _ in 0..80 {
|
||||
// If the child already exited (bad/missing env, no Postgres),
|
||||
// stop waiting — there is nothing to become healthy.
|
||||
if let Ok(Some(_)) = pds.child.try_wait() {
|
||||
return None;
|
||||
}
|
||||
if let Ok(r) = pds.http.get(pds.url("/healthz")).send().await {
|
||||
if r.status().is_success() {
|
||||
return Some(pds);
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn db_pool() -> Option<sqlx::PgPool> {
|
||||
let url = std::env::var("DATABASE_URL_PDS")
|
||||
.unwrap_or_else(|_| "postgres://pds:pds@127.0.0.1:5434/pds".to_string());
|
||||
sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(4)
|
||||
.acquire_timeout(Duration::from_secs(3))
|
||||
.connect(&url)
|
||||
.await
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Put a code straight into the table in whatever state the test needs.
|
||||
///
|
||||
/// Tests seed through SQL rather than through `pds-server invite create`
|
||||
/// because the states that matter here — already spent, disabled — are
|
||||
/// not states the CLI can mint directly, and because a test that had to
|
||||
/// shell out to a second binary to arrange its fixture would be testing
|
||||
/// two things at once.
|
||||
async fn seed_code(db: &sqlx::PgPool, max_uses: i32, used: i32, disabled: bool) -> String {
|
||||
let code = format!("mt-test-{}", uuid::Uuid::new_v4().simple());
|
||||
sqlx::query(
|
||||
"INSERT INTO invite_codes (code, max_uses, used_count, disabled) VALUES ($1, $2, $3, $4)",
|
||||
)
|
||||
.bind(&code)
|
||||
.bind(max_uses)
|
||||
.bind(used)
|
||||
.bind(disabled)
|
||||
.execute(db)
|
||||
.await
|
||||
.expect("seed invite code");
|
||||
code
|
||||
}
|
||||
|
||||
async fn used_count(db: &sqlx::PgPool, code: &str) -> i32 {
|
||||
sqlx::query_scalar::<_, i32>("SELECT used_count FROM invite_codes WHERE code = $1")
|
||||
.bind(code)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.expect("read used_count")
|
||||
}
|
||||
|
||||
async fn use_rows(db: &sqlx::PgPool, code: &str) -> Vec<(String, String)> {
|
||||
sqlx::query_as::<_, (String, String)>(
|
||||
"SELECT did, handle FROM invite_code_uses WHERE code = $1 ORDER BY used_at",
|
||||
)
|
||||
.bind(code)
|
||||
.fetch_all(db)
|
||||
.await
|
||||
.expect("read invite_code_uses")
|
||||
}
|
||||
|
||||
/// A unique throwaway handle.
|
||||
///
|
||||
/// `createAccount` caps handles at 64 characters, and
|
||||
/// `<prefix>_<32 hex>.maarcadetweet.local` overshoots that for anything
|
||||
/// but the shortest prefix — a limit that shows up as a confusing
|
||||
/// `InvalidHandle` in a test that is about invite codes. Half the UUID
|
||||
/// is 64 bits of uniqueness, which is plenty for a test fixture and
|
||||
/// leaves room for a readable prefix.
|
||||
fn handle(prefix: &str) -> String {
|
||||
let uniq = uuid::Uuid::new_v4().simple().to_string();
|
||||
format!("{}_{}.maarcadetweet.local", prefix, &uniq[..16])
|
||||
}
|
||||
|
||||
/// Every invite rejection must look the same to a client: `400` with the
|
||||
/// module's usual `{error, message}` body and the name
|
||||
/// `InvalidInviteCode`.
|
||||
fn assert_invalid_invite(status: u16, body: &Value, what: &str) {
|
||||
assert_eq!(status, 400, "{what}: expected 400, body = {body}");
|
||||
assert_eq!(
|
||||
body["error"], "InvalidInviteCode",
|
||||
"{what}: wrong error name, body = {body}"
|
||||
);
|
||||
assert!(
|
||||
body["message"].is_string(),
|
||||
"{what}: error body must carry a message, body = {body}"
|
||||
);
|
||||
// No DID may have been minted on a rejected request.
|
||||
assert!(
|
||||
body["did"].is_null(),
|
||||
"{what}: rejected request returned a did, body = {body}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- enforcement ------------------------------------------------------------
|
||||
|
||||
/// The happy path, and the property that makes a single-use code
|
||||
/// single-use: after the account exists, the same code is dead.
|
||||
///
|
||||
/// Also checks the audit trail, which is the reason
|
||||
/// `invite_code_uses` exists at all — "which account did this code
|
||||
/// create" has to be answerable after the fact.
|
||||
#[tokio::test]
|
||||
async fn valid_code_admits_one_account_then_is_spent() {
|
||||
let Some(db) = db_pool().await else {
|
||||
eprintln!("no pds database, skipping");
|
||||
return;
|
||||
};
|
||||
let Some(pds) = start_pds(true).await else {
|
||||
eprintln!("could not start a pds with PDS_INVITE_REQUIRED=true, skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
// The switch must be advertised, not just enforced — a client reads
|
||||
// this before it asks the user for anything.
|
||||
assert_eq!(
|
||||
pds.describe().await["invite_code_required"],
|
||||
json!(true),
|
||||
"describeServer must report the actual PDS_INVITE_REQUIRED value"
|
||||
);
|
||||
|
||||
let code = seed_code(&db, 1, 0, false).await;
|
||||
let h = handle("inv_ok");
|
||||
let (status, body) = pds
|
||||
.create_account(json!({
|
||||
"handle": h,
|
||||
"password": "hunter2hunter2",
|
||||
"invite_code": code,
|
||||
}))
|
||||
.await;
|
||||
assert_eq!(status, 200, "valid code must create an account: {body}");
|
||||
let did = body["did"].as_str().expect("did").to_string();
|
||||
|
||||
assert_eq!(used_count(&db, &code).await, 1, "code must be counted as used");
|
||||
let uses = use_rows(&db, &code).await;
|
||||
assert_eq!(uses.len(), 1);
|
||||
assert_eq!(uses[0].0, did, "audit row must name the account it created");
|
||||
assert_eq!(uses[0].1, h, "audit row must snapshot the handle");
|
||||
|
||||
// Second attempt on the now-spent code.
|
||||
let (status2, body2) = pds
|
||||
.create_account(json!({
|
||||
"handle": handle("inv_second"),
|
||||
"password": "hunter2hunter2",
|
||||
"invite_code": code,
|
||||
}))
|
||||
.await;
|
||||
assert_invalid_invite(status2, &body2, "spent code");
|
||||
assert_eq!(
|
||||
used_count(&db, &code).await,
|
||||
1,
|
||||
"a rejected attempt must not move the counter"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every way a code can fail, and the missing-code case, all land on the
|
||||
/// same `400 InvalidInviteCode`.
|
||||
#[tokio::test]
|
||||
async fn unknown_disabled_spent_and_missing_codes_are_rejected() {
|
||||
let Some(db) = db_pool().await else {
|
||||
eprintln!("no pds database, skipping");
|
||||
return;
|
||||
};
|
||||
let Some(pds) = start_pds(true).await else {
|
||||
eprintln!("could not start a pds with PDS_INVITE_REQUIRED=true, skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
// Unknown.
|
||||
let (s, b) = pds
|
||||
.create_account(json!({
|
||||
"handle": handle("inv_unknown"),
|
||||
"password": "hunter2hunter2",
|
||||
"invite_code": "mt-zzzzz-zzzzz",
|
||||
}))
|
||||
.await;
|
||||
assert_invalid_invite(s, &b, "unknown code");
|
||||
|
||||
// Disabled, with uses left — proves `disabled` is checked and not
|
||||
// just the counter.
|
||||
let disabled = seed_code(&db, 5, 0, true).await;
|
||||
let (s, b) = pds
|
||||
.create_account(json!({
|
||||
"handle": handle("inv_disabled"),
|
||||
"password": "hunter2hunter2",
|
||||
"invite_code": disabled,
|
||||
}))
|
||||
.await;
|
||||
assert_invalid_invite(s, &b, "disabled code");
|
||||
assert_eq!(used_count(&db, &disabled).await, 0);
|
||||
|
||||
// Already at its limit.
|
||||
let spent = seed_code(&db, 2, 2, false).await;
|
||||
let (s, b) = pds
|
||||
.create_account(json!({
|
||||
"handle": handle("inv_spent"),
|
||||
"password": "hunter2hunter2",
|
||||
"invite_code": spent,
|
||||
}))
|
||||
.await;
|
||||
assert_invalid_invite(s, &b, "exhausted code");
|
||||
|
||||
// No field at all.
|
||||
let (s, b) = pds
|
||||
.create_account(json!({
|
||||
"handle": handle("inv_none"),
|
||||
"password": "hunter2hunter2",
|
||||
}))
|
||||
.await;
|
||||
assert_invalid_invite(s, &b, "missing code");
|
||||
|
||||
// Present but blank / whitespace — must be indistinguishable from
|
||||
// absent, not an attempt to look up the empty string.
|
||||
for blank in ["", " "] {
|
||||
let (s, b) = pds
|
||||
.create_account(json!({
|
||||
"handle": handle("inv_blank"),
|
||||
"password": "hunter2hunter2",
|
||||
"invite_code": blank,
|
||||
}))
|
||||
.await;
|
||||
assert_invalid_invite(s, &b, "blank code");
|
||||
}
|
||||
}
|
||||
|
||||
/// The camelCase spelling from the atproto lexicon, and the multi-use
|
||||
/// case the schema exists for.
|
||||
#[tokio::test]
|
||||
async fn camel_case_spelling_works_and_multi_use_codes_stop_at_the_limit() {
|
||||
let Some(db) = db_pool().await else {
|
||||
eprintln!("no pds database, skipping");
|
||||
return;
|
||||
};
|
||||
let Some(pds) = start_pds(true).await else {
|
||||
eprintln!("could not start a pds with PDS_INVITE_REQUIRED=true, skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
// `inviteCode` is what an off-the-shelf atproto client sends.
|
||||
let code = seed_code(&db, 3, 0, false).await;
|
||||
let (s, b) = pds
|
||||
.create_account(json!({
|
||||
"handle": handle("inv_camel"),
|
||||
"password": "hunter2hunter2",
|
||||
"inviteCode": code,
|
||||
}))
|
||||
.await;
|
||||
assert_eq!(s, 200, "inviteCode spelling must be accepted: {b}");
|
||||
|
||||
// Case and stray whitespace are normalised, so a code shouted or
|
||||
// pasted out of a chat window still works.
|
||||
let (s, b) = pds
|
||||
.create_account(json!({
|
||||
"handle": handle("inv_case"),
|
||||
"password": "hunter2hunter2",
|
||||
"invite_code": format!(" {} ", code.to_uppercase()),
|
||||
}))
|
||||
.await;
|
||||
assert_eq!(s, 200, "normalised code must be accepted: {b}");
|
||||
|
||||
// Third and last use.
|
||||
let (s, _) = pds
|
||||
.create_account(json!({
|
||||
"handle": handle("inv_third"),
|
||||
"password": "hunter2hunter2",
|
||||
"invite_code": code,
|
||||
}))
|
||||
.await;
|
||||
assert_eq!(s, 200);
|
||||
|
||||
// Fourth is one too many.
|
||||
let (s, b) = pds
|
||||
.create_account(json!({
|
||||
"handle": handle("inv_fourth"),
|
||||
"password": "hunter2hunter2",
|
||||
"invite_code": code,
|
||||
}))
|
||||
.await;
|
||||
assert_invalid_invite(s, &b, "one past max_uses");
|
||||
|
||||
assert_eq!(used_count(&db, &code).await, 3);
|
||||
assert_eq!(use_rows(&db, &code).await.len(), 3);
|
||||
}
|
||||
|
||||
/// A failed account creation must not consume the code.
|
||||
///
|
||||
/// The cheapest way to make the account creation fail *after* the
|
||||
/// redemption has already run is a handle that is already taken: the
|
||||
/// redeem happens first inside the transaction, the `users` insert then
|
||||
/// trips the unique index, and the whole transaction rolls back. If the
|
||||
/// redemption had been done outside the transaction (or committed
|
||||
/// separately) the user would have lost their code to someone else's
|
||||
/// handle.
|
||||
#[tokio::test]
|
||||
async fn a_failed_registration_does_not_burn_the_code() {
|
||||
let Some(db) = db_pool().await else {
|
||||
eprintln!("no pds database, skipping");
|
||||
return;
|
||||
};
|
||||
let Some(pds) = start_pds(true).await else {
|
||||
eprintln!("could not start a pds with PDS_INVITE_REQUIRED=true, skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let taken = handle("inv_taken");
|
||||
let first = seed_code(&db, 1, 0, false).await;
|
||||
let (s, b) = pds
|
||||
.create_account(json!({
|
||||
"handle": taken, "password": "hunter2hunter2", "invite_code": first,
|
||||
}))
|
||||
.await;
|
||||
assert_eq!(s, 200, "{b}");
|
||||
|
||||
// Now a *different* code, used on a handle that cannot be created.
|
||||
let code = seed_code(&db, 1, 0, false).await;
|
||||
let (s, _b) = pds
|
||||
.create_account(json!({
|
||||
"handle": taken, "password": "hunter2hunter2", "invite_code": code,
|
||||
}))
|
||||
.await;
|
||||
assert_eq!(s, 409, "duplicate handle is still a 409");
|
||||
assert_eq!(
|
||||
used_count(&db, &code).await,
|
||||
0,
|
||||
"the code must survive a registration that rolled back"
|
||||
);
|
||||
assert!(use_rows(&db, &code).await.is_empty());
|
||||
|
||||
// And it still works afterwards.
|
||||
let (s, b) = pds
|
||||
.create_account(json!({
|
||||
"handle": handle("inv_retry"), "password": "hunter2hunter2", "invite_code": code,
|
||||
}))
|
||||
.await;
|
||||
assert_eq!(s, 200, "unburned code must still be redeemable: {b}");
|
||||
}
|
||||
|
||||
// -- the race ---------------------------------------------------------------
|
||||
|
||||
/// Two (here: eight) registrations arriving at the same instant on the
|
||||
/// last remaining use of a code. Exactly one may get in.
|
||||
///
|
||||
/// This is the test the whole design is built around. A
|
||||
/// `SELECT`-then-`UPDATE` implementation passes every other test in this
|
||||
/// file and fails this one: all eight requests read `used_count = 0`,
|
||||
/// all eight decide they are allowed, and the server hands out eight
|
||||
/// accounts for a one-use code while the row afterwards claims a single
|
||||
/// redemption. The fix is that `invite::redeem` never reads before it
|
||||
/// writes — the `WHERE used_count < max_uses` is part of the `UPDATE`,
|
||||
/// so Postgres re-evaluates it against the committed row after the
|
||||
/// row lock is released and the losers match zero rows.
|
||||
///
|
||||
/// Every request uses a distinct handle, so nothing but the invite code
|
||||
/// can be what serialises them.
|
||||
#[tokio::test]
|
||||
async fn concurrent_registrations_cannot_share_one_use() {
|
||||
let Some(db) = db_pool().await else {
|
||||
eprintln!("no pds database, skipping");
|
||||
return;
|
||||
};
|
||||
let Some(pds) = start_pds(true).await else {
|
||||
eprintln!("could not start a pds with PDS_INVITE_REQUIRED=true, skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
const N: usize = 8;
|
||||
let code = seed_code(&db, 1, 0, false).await;
|
||||
|
||||
let mut tasks = Vec::with_capacity(N);
|
||||
for i in 0..N {
|
||||
let http = pds.http.clone();
|
||||
let url = pds.url("/xrpc/com.atproto.server.createAccount");
|
||||
let code = code.clone();
|
||||
let h = handle(&format!("inv_race{i}"));
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let resp = http
|
||||
.post(url)
|
||||
.json(&json!({
|
||||
"handle": h,
|
||||
"password": "hunter2hunter2",
|
||||
"invite_code": code,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("concurrent createAccount");
|
||||
let status = resp.status().as_u16();
|
||||
let body: Value = resp.json().await.unwrap_or(Value::Null);
|
||||
(status, body)
|
||||
}));
|
||||
}
|
||||
|
||||
let mut ok = Vec::new();
|
||||
let mut rejected = 0usize;
|
||||
for t in tasks {
|
||||
let (status, body) = t.await.unwrap();
|
||||
match status {
|
||||
200 => ok.push(body),
|
||||
400 => {
|
||||
assert_eq!(body["error"], "InvalidInviteCode", "body = {body}");
|
||||
rejected += 1;
|
||||
}
|
||||
other => panic!("unexpected status {other}: {body}"),
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
ok.len(),
|
||||
1,
|
||||
"a one-use code let {} concurrent registrations through — the redeem is racy",
|
||||
ok.len()
|
||||
);
|
||||
assert_eq!(rejected, N - 1);
|
||||
assert_eq!(used_count(&db, &code).await, 1);
|
||||
let uses = use_rows(&db, &code).await;
|
||||
assert_eq!(uses.len(), 1, "counter and audit rows disagree: {uses:?}");
|
||||
assert_eq!(uses[0].0, ok[0]["did"].as_str().unwrap());
|
||||
}
|
||||
|
||||
/// The same race with room for more than one winner: a three-use code
|
||||
/// hit by eight simultaneous registrations must admit exactly three.
|
||||
///
|
||||
/// Worth having next to the one-use case because an implementation can
|
||||
/// be "safe" by accident for a single use (e.g. by serialising every
|
||||
/// registration globally) and still lose count when several are
|
||||
/// genuinely allowed to proceed.
|
||||
#[tokio::test]
|
||||
async fn concurrent_registrations_respect_a_multi_use_limit() {
|
||||
let Some(db) = db_pool().await else {
|
||||
eprintln!("no pds database, skipping");
|
||||
return;
|
||||
};
|
||||
let Some(pds) = start_pds(true).await else {
|
||||
eprintln!("could not start a pds with PDS_INVITE_REQUIRED=true, skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
const N: usize = 8;
|
||||
const USES: i32 = 3;
|
||||
let code = seed_code(&db, USES, 0, false).await;
|
||||
|
||||
let mut tasks = Vec::with_capacity(N);
|
||||
for i in 0..N {
|
||||
let http = pds.http.clone();
|
||||
let url = pds.url("/xrpc/com.atproto.server.createAccount");
|
||||
let code = code.clone();
|
||||
let h = handle(&format!("inv_mrace{i}"));
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let resp = http
|
||||
.post(url)
|
||||
.json(&json!({
|
||||
"handle": h,
|
||||
"password": "hunter2hunter2",
|
||||
"invite_code": code,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("concurrent createAccount");
|
||||
let status = resp.status().as_u16();
|
||||
let body: Value = resp.json().await.unwrap_or(Value::Null);
|
||||
(status, body)
|
||||
}));
|
||||
}
|
||||
|
||||
let mut ok = 0usize;
|
||||
for t in tasks {
|
||||
let (status, body) = t.await.unwrap();
|
||||
match status {
|
||||
200 => ok += 1,
|
||||
400 => assert_eq!(body["error"], "InvalidInviteCode", "body = {body}"),
|
||||
other => panic!("unexpected status {other}: {body}"),
|
||||
}
|
||||
}
|
||||
assert_eq!(ok, USES as usize, "a {USES}-use code admitted {ok} accounts");
|
||||
assert_eq!(used_count(&db, &code).await, USES);
|
||||
assert_eq!(use_rows(&db, &code).await.len(), USES as usize);
|
||||
}
|
||||
|
||||
// -- the switch off ---------------------------------------------------------
|
||||
|
||||
/// With `PDS_INVITE_REQUIRED=false` — the default, and what every other
|
||||
/// test suite in this workspace relies on — nothing about `createAccount`
|
||||
/// changes.
|
||||
///
|
||||
/// This is the regression test for the whole feature's blast radius: the
|
||||
/// switch is off by default precisely so that the existing suites keep
|
||||
/// creating accounts with no code, and if that ever stopped being true
|
||||
/// the failure would show up as dozens of unrelated tests breaking. It
|
||||
/// shows up here instead.
|
||||
#[tokio::test]
|
||||
async fn switch_off_leaves_create_account_untouched() {
|
||||
let Some(_db) = db_pool().await else {
|
||||
eprintln!("no pds database, skipping");
|
||||
return;
|
||||
};
|
||||
let Some(pds) = start_pds(false).await else {
|
||||
eprintln!("could not start a pds with PDS_INVITE_REQUIRED=false, skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
pds.describe().await["invite_code_required"],
|
||||
json!(false),
|
||||
"describeServer must report the actual PDS_INVITE_REQUIRED value"
|
||||
);
|
||||
|
||||
// No code at all: the historical behaviour.
|
||||
let (s, b) = pds
|
||||
.create_account(json!({
|
||||
"handle": handle("inv_off"),
|
||||
"password": "hunter2hunter2",
|
||||
}))
|
||||
.await;
|
||||
assert_eq!(s, 200, "no-code registration must still work: {b}");
|
||||
assert!(b["did"].as_str().unwrap().starts_with("did:"));
|
||||
assert!(b["access_jwt"].is_string());
|
||||
|
||||
// A code that does not exist is simply ignored rather than becoming
|
||||
// a new way to fail — a client that was talking to an invite-only
|
||||
// PDS yesterday must not break when the operator opens the server up.
|
||||
let (s, b) = pds
|
||||
.create_account(json!({
|
||||
"handle": handle("inv_off_bogus"),
|
||||
"password": "hunter2hunter2",
|
||||
"invite_code": "mt-does-notexist",
|
||||
}))
|
||||
.await;
|
||||
assert_eq!(s, 200, "an ignored code must not fail the request: {b}");
|
||||
|
||||
// The other validations are untouched.
|
||||
let (s, b) = pds
|
||||
.create_account(json!({
|
||||
"handle": handle("inv_off_short"),
|
||||
"password": "short",
|
||||
}))
|
||||
.await;
|
||||
assert_eq!(s, 400);
|
||||
assert_eq!(b["error"], "InvalidPassword");
|
||||
}
|
||||
|
||||
// -- the CLI ----------------------------------------------------------------
|
||||
|
||||
/// `pds-server invite create` / `list` / `disable`, run as the operator
|
||||
/// would run them, against the real database.
|
||||
///
|
||||
/// The point is not that the SQL works (the tests above cover that) but
|
||||
/// that the *binary* exposes it: that `invite` short-circuits before the
|
||||
/// server starts, that `create` prints bare codes one per line so they
|
||||
/// can be pasted, and that a code it minted is actually redeemable.
|
||||
#[tokio::test]
|
||||
async fn invite_cli_mints_listable_redeemable_codes() {
|
||||
let Some(db) = db_pool().await else {
|
||||
eprintln!("no pds database, skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_pds-server"))
|
||||
.args(["invite", "create", "--count", "3", "--uses", "2", "--note", "cli test"])
|
||||
.env("RUST_LOG", "warn")
|
||||
.output()
|
||||
.expect("run invite create");
|
||||
if !out.status.success() {
|
||||
eprintln!(
|
||||
"invite create failed (no env/db?), skipping: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let codes: Vec<&str> = stdout.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||
assert_eq!(codes.len(), 3, "one code per line, nothing else: {stdout:?}");
|
||||
for c in &codes {
|
||||
// Bare, paste-ready: no labels, no quotes, no indentation.
|
||||
assert_eq!(*c, c.trim(), "code line has surrounding whitespace: {c:?}");
|
||||
assert!(c.starts_with("mt-"), "unexpected code shape: {c}");
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, i32>("SELECT max_uses FROM invite_codes WHERE code = $1")
|
||||
.bind(c)
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.expect("minted code must be in the table"),
|
||||
2,
|
||||
"--uses must reach the row"
|
||||
);
|
||||
}
|
||||
// All three distinct — a generator that returned a constant would
|
||||
// otherwise only show up as a primary-key error.
|
||||
let unique: std::collections::HashSet<&&str> = codes.iter().collect();
|
||||
assert_eq!(unique.len(), 3);
|
||||
|
||||
// `list` must show what `redeem` would accept.
|
||||
let listed = Command::new(env!("CARGO_BIN_EXE_pds-server"))
|
||||
.args(["invite", "list"])
|
||||
.env("RUST_LOG", "warn")
|
||||
.output()
|
||||
.expect("run invite list");
|
||||
assert!(listed.status.success());
|
||||
let listed = String::from_utf8_lossy(&listed.stdout);
|
||||
for c in &codes {
|
||||
assert!(listed.contains(*c), "invite list omitted {c}");
|
||||
}
|
||||
|
||||
// `disable` takes a code out without deleting it.
|
||||
let disabled = Command::new(env!("CARGO_BIN_EXE_pds-server"))
|
||||
.args(["invite", "disable", codes[0]])
|
||||
.env("RUST_LOG", "warn")
|
||||
.output()
|
||||
.expect("run invite disable");
|
||||
assert!(disabled.status.success());
|
||||
assert!(
|
||||
sqlx::query_scalar::<_, bool>("SELECT disabled FROM invite_codes WHERE code = $1")
|
||||
.bind(codes[0])
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
let listed = Command::new(env!("CARGO_BIN_EXE_pds-server"))
|
||||
.args(["invite", "list"])
|
||||
.env("RUST_LOG", "warn")
|
||||
.output()
|
||||
.expect("run invite list");
|
||||
let listed = String::from_utf8_lossy(&listed.stdout);
|
||||
assert!(
|
||||
!listed.contains(codes[0]),
|
||||
"a disabled code must not show in the default listing"
|
||||
);
|
||||
|
||||
// And a minted code really lets an account through.
|
||||
let Some(pds) = start_pds(true).await else {
|
||||
eprintln!("could not start a pds with PDS_INVITE_REQUIRED=true, skipping redeem check");
|
||||
return;
|
||||
};
|
||||
let (s, b) = pds
|
||||
.create_account(json!({
|
||||
"handle": handle("inv_cli"),
|
||||
"password": "hunter2hunter2",
|
||||
"invite_code": codes[1],
|
||||
}))
|
||||
.await;
|
||||
assert_eq!(s, 200, "CLI-minted code must be redeemable: {b}");
|
||||
}
|
||||
|
||||
/// An unknown subcommand must not silently boot a server, and `help`
|
||||
/// must not need a database.
|
||||
#[tokio::test]
|
||||
async fn unknown_subcommand_fails_instead_of_starting_a_server() {
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_pds-server"))
|
||||
.args(["invit"])
|
||||
.env("RUST_LOG", "warn")
|
||||
.output()
|
||||
.expect("run bad subcommand");
|
||||
assert!(!out.status.success(), "a typo'd subcommand must not exit 0");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stderr.contains("unknown command"), "stderr = {stderr}");
|
||||
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_pds-server"))
|
||||
.args(["invite", "help"])
|
||||
.env("RUST_LOG", "warn")
|
||||
.output()
|
||||
.expect("run invite help");
|
||||
assert!(out.status.success());
|
||||
assert!(String::from_utf8_lossy(&out.stdout).contains("pds-server invite"));
|
||||
}
|
||||
@@ -44,7 +44,32 @@ async fn describe_server() {
|
||||
let did = r["did"].as_str().expect("describeServer must return a did");
|
||||
assert!(did.starts_with("did:web:"), "did = {did}");
|
||||
assert!(r["available_user_domains"].is_array());
|
||||
assert_eq!(r["invite_code_required"], json!(false));
|
||||
// `invite_code_required` used to be a hardcoded `false` here. It is
|
||||
// now whatever `PDS_INVITE_REQUIRED` says, so this suite — which
|
||||
// talks to whatever PDS the developer happens to be running — can
|
||||
// only assert the type. That the value actually tracks the switch is
|
||||
// pinned in `invite_integration.rs`, which starts a PDS with the
|
||||
// flag set both ways and checks both answers.
|
||||
assert!(
|
||||
r["invite_code_required"].is_boolean(),
|
||||
"invite_code_required = {}",
|
||||
r["invite_code_required"]
|
||||
);
|
||||
// If this test process shares the server's environment (the
|
||||
// documented way to run the suite is
|
||||
// `set -a; . ./.env; set +a; cargo test`), hold it to the exact
|
||||
// value too.
|
||||
if let Ok(raw) = std::env::var("PDS_INVITE_REQUIRED") {
|
||||
let expected = matches!(
|
||||
raw.trim().to_ascii_lowercase().as_str(),
|
||||
"1" | "true" | "yes" | "on"
|
||||
);
|
||||
assert_eq!(
|
||||
r["invite_code_required"],
|
||||
json!(expected),
|
||||
"describeServer disagrees with PDS_INVITE_REQUIRED={raw}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /.well-known/did.json` — the document the AppView fetches to
|
||||
|
||||
@@ -34,15 +34,32 @@ async fn pds_describe(state: tauri::State<'_, AppState>) -> Result<serde_json::V
|
||||
state.pds.describe_server().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// `auth_register(handle, password, inviteCode?)` — create an account
|
||||
/// on the configured PDS and store the resulting session.
|
||||
///
|
||||
/// `invite_code` arrives from the frontend as `inviteCode` (Tauri maps
|
||||
/// camelCase JS argument keys onto snake_case Rust parameters, the same
|
||||
/// way `mark_notifications_seen` receives `seenAt`). It is `Option`
|
||||
/// because the invite gate is a *server* setting
|
||||
/// (`PDS_INVITE_REQUIRED`): the public instance at
|
||||
/// `https://tweet.maarcade.com` demands a code, a locally run dev PDS
|
||||
/// usually does not, and the client has no business deciding which.
|
||||
/// When no code is given the field is dropped from the request body
|
||||
/// rather than sent empty — see [`pds_client::CreateAccountReq`].
|
||||
///
|
||||
/// A refused code surfaces as the PDS's `400
|
||||
/// {"error":"InvalidInviteCode", …}` inside the stringified error, and
|
||||
/// `errorMessage()` in `client.ts` turns that into German copy.
|
||||
#[tauri::command]
|
||||
async fn auth_register(
|
||||
state: tauri::State<'_, AppState>,
|
||||
handle: String,
|
||||
password: String,
|
||||
invite_code: Option<String>,
|
||||
) -> Result<AccountSession, String> {
|
||||
let sess = state
|
||||
.pds
|
||||
.create_account(&handle, &password)
|
||||
.create_account(&handle, &password, invite_code.as_deref())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let s = AccountSession {
|
||||
@@ -768,16 +785,74 @@ async fn show_notification(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Default PDS base URL — the public instance.
|
||||
///
|
||||
/// This is what a *shipped* build talks to. It used to be
|
||||
/// `http://127.0.0.1:2583`, which meant a packaged `.app` handed to
|
||||
/// anyone but the developer pointed at a server that does not exist on
|
||||
/// their machine: every call failed with a connection error and the
|
||||
/// login screen could not even render `describeServer`. A default is
|
||||
/// the configuration of the people who never set one, so it has to be
|
||||
/// the production deployment.
|
||||
///
|
||||
/// No trailing slash: [`PdsHttpClient`] builds its endpoints as
|
||||
/// `{base}/xrpc/com.atproto.…`, so the base must end at the host.
|
||||
const DEFAULT_PDS_URL: &str = "https://tweet.maarcade.com";
|
||||
|
||||
/// Default AppView base URL. Same host as the PDS — the reverse proxy
|
||||
/// in front of `tweet.maarcade.com` routes by path prefix: `/xrpc/…`
|
||||
/// to the PDS, `/api/…` to the AppView. [`AppViewClient`] appends
|
||||
/// `/api/…` to this base (see the `format!("{}/api/…", self.base_url)`
|
||||
/// calls in `appview_client.rs`), so the two clients can and must
|
||||
/// share the one origin.
|
||||
const DEFAULT_APPVIEW_URL: &str = "https://tweet.maarcade.com";
|
||||
|
||||
/// Resolve one base URL from its environment variable, falling back to
|
||||
/// the compiled-in default.
|
||||
///
|
||||
/// **The environment always wins.** Development runs against a local
|
||||
/// stack — `MAARCADETWEET_PDS_URL=http://127.0.0.1:2583` and
|
||||
/// `MAARCADETWEET_APPVIEW_URL=http://127.0.0.1:2584`, which is what
|
||||
/// `scripts/` and the dev docker-compose set up — and pointing the
|
||||
/// desktop client at it must stay a matter of exporting two variables,
|
||||
/// never of rebuilding. Only an *unset* variable takes the production
|
||||
/// default.
|
||||
///
|
||||
/// A variable set to whitespace (or the empty string) counts as unset:
|
||||
/// an empty base URL would silently produce request URLs like
|
||||
/// `/xrpc/…` with no host, and `reqwest` would reject them as a
|
||||
/// relative-URL error far away from the actual mistake. Trailing
|
||||
/// slashes are trimmed because both clients append an absolute path to
|
||||
/// this string, and `https://host//api/x` is not the same route to
|
||||
/// every proxy.
|
||||
///
|
||||
/// Takes the already-performed lookup rather than the variable name so
|
||||
/// it stays a pure function — testable without mutating the process
|
||||
/// environment, and without a Tauri runtime.
|
||||
fn base_url_or_default(from_env: Result<String, std::env::VarError>, default: &str) -> String {
|
||||
let configured = from_env.ok();
|
||||
let trimmed = configured
|
||||
.as_deref()
|
||||
.map(|v| v.trim().trim_end_matches('/'))
|
||||
.filter(|v| !v.is_empty());
|
||||
trimmed.unwrap_or(default).to_string()
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
|
||||
.init();
|
||||
|
||||
let pds_url = std::env::var("MAARCADETWEET_PDS_URL")
|
||||
.unwrap_or_else(|_| "http://127.0.0.1:2583".to_string());
|
||||
let appview_url = std::env::var("MAARCADETWEET_APPVIEW_URL")
|
||||
.unwrap_or_else(|_| "http://127.0.0.1:2584".to_string());
|
||||
let pds_url = base_url_or_default(
|
||||
std::env::var("MAARCADETWEET_PDS_URL"),
|
||||
DEFAULT_PDS_URL,
|
||||
);
|
||||
let appview_url = base_url_or_default(
|
||||
std::env::var("MAARCADETWEET_APPVIEW_URL"),
|
||||
DEFAULT_APPVIEW_URL,
|
||||
);
|
||||
tracing::info!(%pds_url, %appview_url, "resolved backend base URLs");
|
||||
|
||||
let state = AppState {
|
||||
pds: PdsHttpClient::new(pds_url.clone()),
|
||||
@@ -1056,3 +1131,75 @@ async fn profile_set(
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::env::VarError;
|
||||
|
||||
/// The whole point of the change: a build with nothing configured
|
||||
/// must talk to the public instance, not to a loopback port that
|
||||
/// only exists on a developer's laptop. Pinned as a literal so a
|
||||
/// well-meant "let's default back to localhost for dev" has to
|
||||
/// argue with a red test first.
|
||||
#[test]
|
||||
fn unset_env_falls_back_to_the_public_instance() {
|
||||
assert_eq!(
|
||||
base_url_or_default(Err(VarError::NotPresent), DEFAULT_PDS_URL),
|
||||
"https://tweet.maarcade.com"
|
||||
);
|
||||
assert_eq!(
|
||||
base_url_or_default(Err(VarError::NotPresent), DEFAULT_APPVIEW_URL),
|
||||
"https://tweet.maarcade.com"
|
||||
);
|
||||
// Both services live behind the same origin — the proxy splits
|
||||
// them by path prefix (`/xrpc/` vs `/api/`), which the clients
|
||||
// append themselves.
|
||||
assert_eq!(DEFAULT_PDS_URL, DEFAULT_APPVIEW_URL);
|
||||
assert!(!DEFAULT_PDS_URL.ends_with('/'));
|
||||
}
|
||||
|
||||
/// Development against a local stack has to keep working by
|
||||
/// exporting a variable, so a set value always beats the default.
|
||||
#[test]
|
||||
fn env_var_overrides_the_default() {
|
||||
assert_eq!(
|
||||
base_url_or_default(Ok("http://127.0.0.1:2583".into()), DEFAULT_PDS_URL),
|
||||
"http://127.0.0.1:2583"
|
||||
);
|
||||
assert_eq!(
|
||||
base_url_or_default(Ok("http://127.0.0.1:2584".into()), DEFAULT_APPVIEW_URL),
|
||||
"http://127.0.0.1:2584"
|
||||
);
|
||||
}
|
||||
|
||||
/// An empty or whitespace-only variable is a misconfiguration, not
|
||||
/// a request for an empty base URL: `reqwest` would answer the
|
||||
/// resulting host-less URL with a relative-URL error nowhere near
|
||||
/// the cause. Trailing slashes go because both clients append an
|
||||
/// absolute path (`{base}/xrpc/…`, `{base}/api/…`).
|
||||
#[test]
|
||||
fn blank_env_is_ignored_and_trailing_slashes_are_trimmed() {
|
||||
assert_eq!(
|
||||
base_url_or_default(Ok("".into()), DEFAULT_PDS_URL),
|
||||
DEFAULT_PDS_URL
|
||||
);
|
||||
assert_eq!(
|
||||
base_url_or_default(Ok(" ".into()), DEFAULT_PDS_URL),
|
||||
DEFAULT_PDS_URL
|
||||
);
|
||||
assert_eq!(
|
||||
base_url_or_default(Ok("http://127.0.0.1:2584/".into()), DEFAULT_APPVIEW_URL),
|
||||
"http://127.0.0.1:2584"
|
||||
);
|
||||
assert_eq!(
|
||||
base_url_or_default(Ok(" https://tweet.maarcade.com// ".into()), DEFAULT_PDS_URL),
|
||||
"https://tweet.maarcade.com"
|
||||
);
|
||||
// A non-UTF-8 variable is as unusable as an unset one.
|
||||
assert_eq!(
|
||||
base_url_or_default(Err(VarError::NotUnicode("\u{fffd}".into())), DEFAULT_PDS_URL),
|
||||
DEFAULT_PDS_URL
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,27 @@ pub struct CreateAccountReq {
|
||||
pub handle: String,
|
||||
pub email: Option<String>,
|
||||
pub password: String,
|
||||
/// Invite code, required by the PDS whenever it runs with
|
||||
/// `PDS_INVITE_REQUIRED=true` (the public instance at
|
||||
/// `https://tweet.maarcade.com` does). Rejected codes come back as
|
||||
/// `400 {"error":"InvalidInviteCode", …}`.
|
||||
///
|
||||
/// **Wire name.** The server's `CreateAccountReq`
|
||||
/// (`crates/pds-server/src/routes/types.rs`) is snake_case with an
|
||||
/// `#[serde(alias = "inviteCode")]` for off-the-shelf atproto
|
||||
/// clients. We are not one of those: every body this client sends
|
||||
/// is snake_case (`refresh_jwt` in `refresh_session`, `handle` /
|
||||
/// `password` right here), so `invite_code` is the field name that
|
||||
/// matches the rest of the file. The alias exists for other people.
|
||||
///
|
||||
/// **Skipped when `None`.** Same reasoning as `validate` below —
|
||||
/// a PDS with the invite gate *off* must keep accepting our
|
||||
/// registrations, and sending `"invite_code": null` (let alone
|
||||
/// `""`) would be claiming the user supplied something. Omitting
|
||||
/// the key leaves the server's `Option` at `None`, which is exactly
|
||||
/// "the user gave no code".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub invite_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
@@ -130,15 +151,31 @@ impl PdsHttpClient {
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// `com.atproto.server.createAccount`.
|
||||
///
|
||||
/// `invite_code` is `None` when the user left the field empty; the
|
||||
/// key is then left out of the body entirely (see
|
||||
/// [`CreateAccountReq::invite_code`]) so a PDS running without the
|
||||
/// invite gate still registers the account. A code that only
|
||||
/// contains whitespace is treated as absent for the same reason —
|
||||
/// the server's `invite::normalize` trims before looking it up, so
|
||||
/// `" "` could never match a real code anyway, and passing it on
|
||||
/// would only turn "you forgot the field" into "your code is
|
||||
/// wrong".
|
||||
pub async fn create_account(
|
||||
&self,
|
||||
handle: &str,
|
||||
password: &str,
|
||||
invite_code: Option<&str>,
|
||||
) -> Result<AccountSession> {
|
||||
let body = CreateAccountReq {
|
||||
handle: handle.to_string(),
|
||||
email: None,
|
||||
password: password.to_string(),
|
||||
invite_code: invite_code
|
||||
.map(str::trim)
|
||||
.filter(|c| !c.is_empty())
|
||||
.map(str::to_string),
|
||||
};
|
||||
let r = self
|
||||
.client
|
||||
|
||||
@@ -545,17 +545,23 @@
|
||||
// Used in the Settings view to show which backends the client is
|
||||
// talking to. Kept as plain helpers so they can be swapped for a
|
||||
// `pds_describe`/`appview_describe` Tauri command later.
|
||||
// The fallbacks mirror `DEFAULT_PDS_URL` / `DEFAULT_APPVIEW_URL` in
|
||||
// `src-tauri/src/lib.rs`: both services sit behind the one public
|
||||
// origin, split by path prefix (`/xrpc/` → PDS, `/api/` → AppView).
|
||||
// If those constants ever move, move these with them — a Settings
|
||||
// pane that names the wrong backend is worse than one that names
|
||||
// none.
|
||||
function pdsBase(): string {
|
||||
if (typeof import.meta !== "undefined" && (import.meta as any).env?.VITE_PDS_URL) {
|
||||
return (import.meta as any).env.VITE_PDS_URL as string;
|
||||
}
|
||||
return "http://127.0.0.1:2583";
|
||||
return "https://tweet.maarcade.com";
|
||||
}
|
||||
function appviewBase(): string {
|
||||
if (typeof import.meta !== "undefined" && (import.meta as any).env?.VITE_APPVIEW_URL) {
|
||||
return (import.meta as any).env.VITE_APPVIEW_URL as string;
|
||||
}
|
||||
return "http://127.0.0.1:2584";
|
||||
return "https://tweet.maarcade.com";
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -142,6 +142,21 @@ export function isAuthFailure(e: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/// True when the PDS refused a registration because of the invite
|
||||
/// code. The public instance runs with `PDS_INVITE_REQUIRED=true` and
|
||||
/// answers `400 {"error":"InvalidInviteCode","message":…}` — the same
|
||||
/// code for a missing, misspelled, disabled and already-spent code, on
|
||||
/// purpose: the server does not tell an unauthenticated caller which
|
||||
/// of those it was, since that would make invite codes enumerable.
|
||||
///
|
||||
/// Matched on the string for the same reason as [`isTokenInvalid`]:
|
||||
/// `pds_client.rs` bails with `createAccount failed: {status} {body}`
|
||||
/// and `lib.rs` stringifies that into the command's `Err(String)`, so
|
||||
/// the code travels verbatim across the IPC boundary.
|
||||
function isInvalidInviteCode(e: unknown): boolean {
|
||||
return errorText(e).includes("InvalidInviteCode");
|
||||
}
|
||||
|
||||
/// User-facing copy for a failed call, in the app's German UI voice.
|
||||
///
|
||||
/// An auth failure gets a sentence naming the actual remedy. The raw
|
||||
@@ -155,6 +170,18 @@ export function errorMessage(e: unknown): string {
|
||||
if (isAuthFailure(e)) {
|
||||
return "Sitzung abgelaufen oder abgelehnt — bitte neu anmelden.";
|
||||
}
|
||||
if (isInvalidInviteCode(e)) {
|
||||
// Deliberately covers "no code given" too: the raw body a user
|
||||
// would otherwise read is `createAccount failed: 400 Bad Request
|
||||
// {"error":"InvalidInviteCode","message":"a valid invite code is
|
||||
// required to create an account on this server"}`. Since the
|
||||
// server refuses to say *which* way the code was wrong, the copy
|
||||
// names both plausible fixes rather than guessing one.
|
||||
return (
|
||||
"Einladungscode ungültig oder bereits verbraucht — " +
|
||||
"bitte prüfen oder einen neuen Code anfordern."
|
||||
);
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
|
||||
@@ -208,11 +235,38 @@ function createSessionStore() {
|
||||
set(s);
|
||||
return s;
|
||||
},
|
||||
async register(handle: string, password: string) {
|
||||
/// Create an account on the configured PDS.
|
||||
///
|
||||
/// `inviteCode` is optional because the invite gate lives on the
|
||||
/// *server* (`PDS_INVITE_REQUIRED`): the public instance at
|
||||
/// `https://tweet.maarcade.com` requires a code, a local dev PDS
|
||||
/// normally does not. The client therefore never refuses a
|
||||
/// registration for a missing code on its own — it would break
|
||||
/// development against localhost — it just forwards what the user
|
||||
/// typed and lets the PDS decide.
|
||||
///
|
||||
/// An empty (or whitespace-only) field is *omitted*, not sent as
|
||||
/// `""`. Those are two different statements: "I gave no code" vs.
|
||||
/// "my code is the empty string". The first is legitimate against
|
||||
/// an open server; the second is never true and would only turn
|
||||
/// into a confusing `InvalidInviteCode` on a server that has the
|
||||
/// gate switched off. Dropping the key leaves the Rust
|
||||
/// `Option<String>` at `None`, and `create_account` then leaves
|
||||
/// the field out of the JSON body entirely.
|
||||
async register(handle: string, password: string, inviteCode?: string) {
|
||||
if (!isTauri()) {
|
||||
throw new Error("register requires the Tauri desktop runtime");
|
||||
}
|
||||
const s = await safeInvoke<Session>("auth_register", { handle, password });
|
||||
const code = inviteCode?.trim();
|
||||
const s = await safeInvoke<Session>("auth_register", {
|
||||
handle,
|
||||
password,
|
||||
// Explicit `null` rather than a dropped key, matching how
|
||||
// `createPost` passes its optional `embed` / `reply`: it
|
||||
// deserialises into the Rust `Option<String>` as `None`
|
||||
// without depending on how `invoke` treats `undefined`.
|
||||
inviteCode: code ? code : null,
|
||||
});
|
||||
set(s);
|
||||
return s;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// Unit tests for the invite-code half of the registration path.
|
||||
//
|
||||
// Same setup as `notifications.test.ts`: `@tauri-apps/api/core` is
|
||||
// mocked so no Tauri shell is needed, and every assertion is about the
|
||||
// exact command name + argument bag we hand the Rust IPC layer. That
|
||||
// argument bag is the contract — `invoke` maps camelCase JS keys onto
|
||||
// the snake_case Rust command parameters (`inviteCode` → `invite_code`
|
||||
// on `auth_register`), so a typo here surfaces at runtime as a null
|
||||
// argument, not at compile time.
|
||||
//
|
||||
// Covered:
|
||||
// * `session.register` — the code is forwarded as `inviteCode`,
|
||||
// alongside the unchanged `handle` / `password`;
|
||||
// * the empty / whitespace-only field — must reach the shell as
|
||||
// `null` ("no code given"), never as `""` ("my code is the empty
|
||||
// string"), because a PDS without the invite gate has to keep
|
||||
// accepting registrations;
|
||||
// * `errorMessage` — the PDS's `InvalidInviteCode` body becomes
|
||||
// German copy instead of the raw wire string.
|
||||
//
|
||||
// Run with:
|
||||
// npx vitest run src/lib/api/invite.test.ts
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const invokeMock = vi.fn();
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: (...args: unknown[]) => invokeMock(...args),
|
||||
isTauri: () => true,
|
||||
}));
|
||||
|
||||
/// What the Rust `auth_register` command answers with on success.
|
||||
const SESSION = {
|
||||
did: "did:plc:alice",
|
||||
handle: "alice.tweet.maarcade.com",
|
||||
access_jwt: "acc",
|
||||
refresh_jwt: "ref",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
invokeMock.mockReset();
|
||||
});
|
||||
|
||||
describe("session.register", () => {
|
||||
it("forwards the invite code as `inviteCode`", async () => {
|
||||
const { session } = await import("./client");
|
||||
invokeMock.mockResolvedValueOnce(SESSION);
|
||||
|
||||
const s = await session.register(
|
||||
"alice.tweet.maarcade.com",
|
||||
"hunter2hunter2",
|
||||
"mt-7k3qw-z9d2m",
|
||||
);
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledTimes(1);
|
||||
expect(invokeMock).toHaveBeenCalledWith("auth_register", {
|
||||
handle: "alice.tweet.maarcade.com",
|
||||
password: "hunter2hunter2",
|
||||
inviteCode: "mt-7k3qw-z9d2m",
|
||||
});
|
||||
expect(s.did).toBe("did:plc:alice");
|
||||
});
|
||||
|
||||
it("trims the surrounding whitespace off a pasted code", async () => {
|
||||
const { session } = await import("./client");
|
||||
invokeMock.mockResolvedValueOnce(SESSION);
|
||||
|
||||
// Copying a code out of a chat message routinely drags a space or
|
||||
// a newline along. The server trims too (`invite::normalize`), but
|
||||
// sending the untrimmed string would mean the *client* and the
|
||||
// server disagree about whether the field is empty.
|
||||
await session.register("alice.test", "pw", " mt-7k3qw-z9d2m\n");
|
||||
|
||||
expect(invokeMock.mock.calls[0][1]).toMatchObject({
|
||||
inviteCode: "mt-7k3qw-z9d2m",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends null — never an empty string — when the field is blank", async () => {
|
||||
const { session } = await import("./client");
|
||||
|
||||
// Three ways the UI can hand us "nothing": the argument omitted
|
||||
// entirely (login-shaped call), an untouched input, and an input
|
||||
// holding only whitespace. All three mean "the user gave no code"
|
||||
// and must arrive at the Rust `Option<String>` as `None`, so that
|
||||
// a PDS running without `PDS_INVITE_REQUIRED` still registers the
|
||||
// account instead of rejecting a blank code.
|
||||
for (const blank of [undefined, "", " "]) {
|
||||
invokeMock.mockReset();
|
||||
invokeMock.mockResolvedValueOnce(SESSION);
|
||||
|
||||
await session.register("alice.test", "pw", blank);
|
||||
|
||||
const args = invokeMock.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(args.inviteCode).toBeNull();
|
||||
expect(args.inviteCode).not.toBe("");
|
||||
// The rest of the bag is unaffected.
|
||||
expect(args.handle).toBe("alice.test");
|
||||
expect(args.password).toBe("pw");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("errorMessage for InvalidInviteCode", () => {
|
||||
/// Exactly what crosses the IPC boundary when the PDS refuses the
|
||||
/// code: `pds_client.rs` bails with `createAccount failed: {status}
|
||||
/// {body}` and `lib.rs` stringifies that into `Err(String)`, which
|
||||
/// `invoke` rejects with as a bare JS string.
|
||||
const RAW =
|
||||
'createAccount failed: 400 Bad Request {"error":"InvalidInviteCode",' +
|
||||
'"message":"a valid invite code is required to create an account on this server"}';
|
||||
|
||||
it("replaces the raw 400 body with copy the user can act on", async () => {
|
||||
const { errorMessage } = await import("./client");
|
||||
const msg = errorMessage(RAW);
|
||||
|
||||
expect(msg).toContain("Einladungscode");
|
||||
// None of the wire noise survives into the UI.
|
||||
expect(msg).not.toContain("InvalidInviteCode");
|
||||
expect(msg).not.toContain("400");
|
||||
// Both shapes a rejected `invoke` can produce — a bare string and
|
||||
// an Error — go through the same `errorText` normalisation.
|
||||
expect(errorMessage(new Error(RAW))).toBe(msg);
|
||||
});
|
||||
|
||||
it("leaves unrelated registration failures verbatim", async () => {
|
||||
const { errorMessage } = await import("./client");
|
||||
// A taken handle is a different 400 and has its own message worth
|
||||
// showing; the invite branch must not swallow it.
|
||||
expect(
|
||||
errorMessage(
|
||||
'createAccount failed: 400 Bad Request {"error":"HandleNotAvailable"}',
|
||||
),
|
||||
).toContain("HandleNotAvailable");
|
||||
expect(errorMessage("createAccount failed: 500 db down")).toContain("500");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { session, describeServer, type Session } from "../api/client";
|
||||
import {
|
||||
session,
|
||||
describeServer,
|
||||
errorMessage,
|
||||
type Session,
|
||||
} from "../api/client";
|
||||
|
||||
let { onLogin }: { onLogin: (s: Session) => void } = $props();
|
||||
|
||||
@@ -10,6 +15,9 @@
|
||||
let mode: "login" | "register" = $state("login");
|
||||
let handle: string = $state("");
|
||||
let password: string = $state("");
|
||||
// Only meaningful in "register" mode — the field below is rendered
|
||||
// solely there, and `submit()` only forwards it on that branch.
|
||||
let inviteCode: string = $state("");
|
||||
let busy = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
let serverInfo: any = $state(null);
|
||||
@@ -27,16 +35,40 @@
|
||||
busy = true;
|
||||
error = null;
|
||||
try {
|
||||
// The invite code is deliberately *not* part of the guard above.
|
||||
// Whether one is required is a server setting
|
||||
// (`PDS_INVITE_REQUIRED`): the public instance demands a code, a
|
||||
// dev PDS on localhost usually does not. Refusing to submit
|
||||
// without one would make the client unusable against the second
|
||||
// kind of server for a rule it cannot see. So we forward what
|
||||
// the user typed — `session.register` drops an empty string
|
||||
// instead of sending a blank code — and let the PDS answer.
|
||||
const s = mode === "register"
|
||||
? await session.register(handle, password)
|
||||
? await session.register(handle, password, inviteCode)
|
||||
: await session.login(handle, password);
|
||||
onLogin(s);
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
// `errorMessage` translates the failures worth naming — an
|
||||
// expired session, and a rejected `InvalidInviteCode` — into
|
||||
// German copy, and passes everything else through verbatim.
|
||||
error = errorMessage(e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the form's mode-specific state when switching sides.
|
||||
///
|
||||
/// Without this, a code typed while registering would linger in the
|
||||
/// hidden field: switch to login, switch back, and the stale value
|
||||
/// is silently submitted again. The error goes too — the message
|
||||
/// from a failed registration says nothing about the login the user
|
||||
/// is now attempting.
|
||||
function toggleMode() {
|
||||
mode = mode === "register" ? "login" : "register";
|
||||
inviteCode = "";
|
||||
error = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="login">
|
||||
@@ -75,6 +107,30 @@
|
||||
autocomplete={mode === "register" ? "new-password" : "current-password"}
|
||||
/>
|
||||
</label>
|
||||
{#if mode === "register"}
|
||||
<!--
|
||||
Registration only. Logging in never carries a code, and a
|
||||
field that is present but meaningless invites people to
|
||||
fill it in. `{#if}` removes it from the DOM rather than
|
||||
hiding it, so it also drops out of the tab order.
|
||||
-->
|
||||
<label class="field">
|
||||
<span class="key">einladungscode</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={inviteCode}
|
||||
placeholder="mt-xxxxx-xxxxx"
|
||||
disabled={busy}
|
||||
onkeydown={(e) => e.key === "Enter" && submit()}
|
||||
autocomplete="off"
|
||||
autocapitalize="none"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<span class="hint">
|
||||
// von dieser Instanz verlangt — ohne Code keine Registrierung
|
||||
</span>
|
||||
</label>
|
||||
{/if}
|
||||
</form>
|
||||
{#if error}
|
||||
<div class="err">err: {error}</div>
|
||||
@@ -83,7 +139,7 @@
|
||||
<button class="btn btn--primary" onclick={submit} disabled={busy || !handle || !password}>
|
||||
{busy ? "..." : mode === "register" ? "create account" : "log in"}
|
||||
</button>
|
||||
<button class="btn btn--ghost" onclick={() => (mode = mode === "register" ? "login" : "register")} disabled={busy}>
|
||||
<button class="btn btn--ghost" onclick={toggleMode} disabled={busy}>
|
||||
{mode === "register" ? "have an account? log in" : "no account? register"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -172,6 +228,15 @@
|
||||
font-size: var(--fs-50);
|
||||
letter-spacing: var(--tracking-label);
|
||||
}
|
||||
/* Sub-label under the invite field. Same dim mono voice as the
|
||||
`// pds: …` server meta line above the form, so it reads as a
|
||||
comment on the field rather than as a second input label. */
|
||||
.hint {
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-50);
|
||||
opacity: 0.8;
|
||||
}
|
||||
.form input {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line-2);
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
// Regression guard for the invite-code field on the login screen.
|
||||
//
|
||||
// The public instance runs the PDS with `PDS_INVITE_REQUIRED=true`, so
|
||||
// `createAccount` without a code is refused with `400
|
||||
// {"error":"InvalidInviteCode", …}`. Three things have to hold for the
|
||||
// screen to be usable against it:
|
||||
//
|
||||
// 1. the field exists in "register" mode and *not* in "login" mode —
|
||||
// a code is meaningless when signing in, and an input that is
|
||||
// present but ignored invites people to fill it in;
|
||||
// 2. what the user typed reaches `session.register` as its third
|
||||
// argument, and an untouched field does not become a blank code;
|
||||
// 3. a rejected code renders as German copy, not as the raw wire
|
||||
// body, which is where the user would otherwise read
|
||||
// `createAccount failed: 400 Bad Request {"error":…}`.
|
||||
//
|
||||
// Setup follows `NotificationsView.test.ts`: the component is mounted
|
||||
// against jsdom with `../api/client` partially mocked — the real
|
||||
// `errorMessage` is kept, since the error copy is part of what we are
|
||||
// asserting on, and only the calls that would need a Tauri runtime are
|
||||
// stubbed.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mount, unmount, tick } from "svelte";
|
||||
|
||||
const registerMock = vi.fn();
|
||||
const loginMock = vi.fn();
|
||||
const describeServerMock = vi.fn();
|
||||
|
||||
vi.mock("../api/client", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../api/client")>("../api/client");
|
||||
return {
|
||||
...actual,
|
||||
// Keep the real `errorMessage` from `actual` — the German copy for
|
||||
// `InvalidInviteCode` is exactly what test 3 checks.
|
||||
describeServer: (...args: unknown[]) => describeServerMock(...args),
|
||||
session: {
|
||||
...actual.session,
|
||||
register: (...args: unknown[]) => registerMock(...args),
|
||||
login: (...args: unknown[]) => loginMock(...args),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import LoginScreen from "./LoginScreen.svelte";
|
||||
|
||||
let target: HTMLDivElement;
|
||||
let app: ReturnType<typeof mount> | null = null;
|
||||
|
||||
const SESSION = {
|
||||
did: "did:plc:alice",
|
||||
handle: "alice.tweet.maarcade.com",
|
||||
access_jwt: "acc",
|
||||
refresh_jwt: "ref",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
target = document.createElement("div");
|
||||
document.body.appendChild(target);
|
||||
registerMock.mockReset();
|
||||
loginMock.mockReset();
|
||||
describeServerMock.mockReset();
|
||||
// `onMount` calls this; a resolved stub keeps the meta line quiet.
|
||||
describeServerMock.mockResolvedValue({ did: "did:web:tweet.maarcade.com" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (app) unmount(app);
|
||||
app = null;
|
||||
target.remove();
|
||||
});
|
||||
|
||||
/// Let `onMount`, the mocked promises and Svelte's flush settle.
|
||||
async function flush(turns = 6) {
|
||||
for (let i = 0; i < turns; i++) {
|
||||
await Promise.resolve();
|
||||
await tick();
|
||||
}
|
||||
}
|
||||
|
||||
/// The screen has no test ids; the inputs are addressed the way a user
|
||||
/// would, by the label text beside them.
|
||||
function fieldByLabel(label: string): HTMLInputElement | null {
|
||||
for (const el of target.querySelectorAll("label.field")) {
|
||||
if (el.querySelector(".key")?.textContent?.trim() === label) {
|
||||
return el.querySelector("input");
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function typeInto(input: HTMLInputElement, value: string) {
|
||||
input.value = value;
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
/// Flip login ⇄ register via the ghost button under the form.
|
||||
async function toggleMode() {
|
||||
const buttons = [...target.querySelectorAll("button.btn--ghost")];
|
||||
buttons[buttons.length - 1].dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true }),
|
||||
);
|
||||
await tick();
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
target
|
||||
.querySelector("button.btn--primary")!
|
||||
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
}
|
||||
|
||||
async function mountScreen() {
|
||||
app = mount(LoginScreen, { target, props: { onLogin: vi.fn() } });
|
||||
await flush();
|
||||
}
|
||||
|
||||
describe("LoginScreen invite field", () => {
|
||||
it("shows the code field only while registering", async () => {
|
||||
await mountScreen();
|
||||
|
||||
// "login" is the default mode — no invite field, and nothing in
|
||||
// the tab order either, since `{#if}` removes it from the DOM.
|
||||
expect(fieldByLabel("einladungscode")).toBeNull();
|
||||
|
||||
await toggleMode();
|
||||
expect(fieldByLabel("einladungscode")).not.toBeNull();
|
||||
|
||||
await toggleMode();
|
||||
expect(fieldByLabel("einladungscode")).toBeNull();
|
||||
});
|
||||
|
||||
it("passes the typed code to session.register as the third argument", async () => {
|
||||
registerMock.mockResolvedValue(SESSION);
|
||||
await mountScreen();
|
||||
await toggleMode();
|
||||
|
||||
typeInto(fieldByLabel("handle")!, "alice.tweet.maarcade.com");
|
||||
typeInto(fieldByLabel("password")!, "hunter2hunter2");
|
||||
typeInto(fieldByLabel("einladungscode")!, "mt-7k3qw-z9d2m");
|
||||
await submitForm();
|
||||
|
||||
expect(loginMock).not.toHaveBeenCalled();
|
||||
expect(registerMock).toHaveBeenCalledTimes(1);
|
||||
expect(registerMock).toHaveBeenCalledWith(
|
||||
"alice.tweet.maarcade.com",
|
||||
"hunter2hunter2",
|
||||
"mt-7k3qw-z9d2m",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not turn an untouched field into a blank code", async () => {
|
||||
registerMock.mockResolvedValue(SESSION);
|
||||
await mountScreen();
|
||||
await toggleMode();
|
||||
|
||||
typeInto(fieldByLabel("handle")!, "alice.test");
|
||||
typeInto(fieldByLabel("password")!, "hunter2hunter2");
|
||||
// Invite field deliberately left alone. The submit must still go
|
||||
// through: whether a code is required is the *server's* call
|
||||
// (`PDS_INVITE_REQUIRED`), and a dev PDS on localhost runs without
|
||||
// the gate. What must not happen is a blank code travelling on as
|
||||
// if the user had entered one.
|
||||
await submitForm();
|
||||
|
||||
expect(registerMock).toHaveBeenCalledTimes(1);
|
||||
const code = registerMock.mock.calls[0][2];
|
||||
expect(code === "" || code === undefined).toBe(true);
|
||||
expect(code?.trim?.() ?? "").toBe("");
|
||||
});
|
||||
|
||||
it("clears a typed code when switching back to login", async () => {
|
||||
loginMock.mockResolvedValue(SESSION);
|
||||
await mountScreen();
|
||||
await toggleMode();
|
||||
|
||||
typeInto(fieldByLabel("handle")!, "alice.test");
|
||||
typeInto(fieldByLabel("password")!, "hunter2hunter2");
|
||||
typeInto(fieldByLabel("einladungscode")!, "mt-stale-code0");
|
||||
|
||||
// Back to login, then to register again: a stale code lingering in
|
||||
// the hidden field would be submitted silently on the next try.
|
||||
await toggleMode();
|
||||
await toggleMode();
|
||||
expect(fieldByLabel("einladungscode")!.value).toBe("");
|
||||
});
|
||||
|
||||
it("renders German copy when the PDS rejects the code", async () => {
|
||||
// Verbatim what reaches the component: `pds_client.rs` bails with
|
||||
// `createAccount failed: {status} {body}`, `lib.rs` stringifies it
|
||||
// into the command's `Err(String)`, and `invoke` rejects with that
|
||||
// bare string.
|
||||
registerMock.mockRejectedValue(
|
||||
'createAccount failed: 400 Bad Request {"error":"InvalidInviteCode",' +
|
||||
'"message":"a valid invite code is required to create an account on this server"}',
|
||||
);
|
||||
await mountScreen();
|
||||
await toggleMode();
|
||||
|
||||
typeInto(fieldByLabel("handle")!, "alice.test");
|
||||
typeInto(fieldByLabel("password")!, "hunter2hunter2");
|
||||
typeInto(fieldByLabel("einladungscode")!, "mt-wrong-code0");
|
||||
await submitForm();
|
||||
|
||||
const err = target.querySelector(".err");
|
||||
expect(err).not.toBeNull();
|
||||
expect(err!.textContent).toContain("Einladungscode");
|
||||
// The wire noise the user would otherwise be shown is gone.
|
||||
expect(err!.textContent).not.toContain("InvalidInviteCode");
|
||||
expect(err!.textContent).not.toContain("400 Bad Request");
|
||||
});
|
||||
|
||||
it("still shows an unrelated failure verbatim", async () => {
|
||||
// The invite branch must not swallow every registration error —
|
||||
// a taken handle has its own message worth reading.
|
||||
registerMock.mockRejectedValue(
|
||||
'createAccount failed: 400 Bad Request {"error":"HandleNotAvailable"}',
|
||||
);
|
||||
await mountScreen();
|
||||
await toggleMode();
|
||||
|
||||
typeInto(fieldByLabel("handle")!, "alice.test");
|
||||
typeInto(fieldByLabel("password")!, "hunter2hunter2");
|
||||
await submitForm();
|
||||
|
||||
expect(target.querySelector(".err")!.textContent).toContain(
|
||||
"HandleNotAvailable",
|
||||
);
|
||||
});
|
||||
});
|
||||
+47
-3
@@ -296,13 +296,57 @@ beiden.
|
||||
5. Mit einer älteren installierten Version gegenprüfen, dass `check()` das
|
||||
Update findet und die Signaturprüfung durchgeht.
|
||||
|
||||
## 8. Offene Punkte
|
||||
## 8. CI: Tag-Release über Gitea Actions
|
||||
|
||||
`.gitea/workflows/release.yml` baut Schritt 2 der Checkliste für **Windows und
|
||||
Linux** automatisch. Trigger ist ein Tag `v*.*.*` (zusätzlich manuell per
|
||||
`workflow_dispatch`, dann ohne Release-Anlage).
|
||||
|
||||
| Job | `runs-on` | Runner | Bundles |
|
||||
|---|---|---|---|
|
||||
| `windows` | `windows` | winbuild, 192.168.1.69 (on-demand) | `msi/*.msi`, `nsis/*-setup.exe` |
|
||||
| `linux` | `ubuntu-latest` | ci-runner, 192.168.1.72 | `deb/*.deb`, `rpm/*.rpm`, `appimage/*.AppImage` |
|
||||
|
||||
Beide Jobs laufen `npm ci` (es gibt eine `package-lock.json`) und danach den
|
||||
npm-Skript-Umweg `npm run tauri -- build --ci` aus `crates/tauri-app/`, damit
|
||||
die im Lock gepinnte `@tauri-apps/cli` benutzt wird und nicht die zufällig auf
|
||||
dem Runner installierte. Die Pfade sind die aus Abschnitt 5. Beide laden ihre
|
||||
Bundles als Job-Artefakt hoch **und** hängen sie an dasselbe Gitea-Release zum
|
||||
Tag (anlegen, und falls der andere Job schneller war, das vorhandene per Tag
|
||||
holen). Die Release-Beschreibung kommt aus dem passenden
|
||||
`## [<version>]`-Abschnitt einer `CHANGELOG.md`, sobald es eine gibt — bis
|
||||
dahin steht dort der Commit-SHA.
|
||||
|
||||
Zwei Dinge, die der Workflow *nicht* tut:
|
||||
|
||||
* **macOS.** Es gibt keinen macOS-Runner. `.dmg`/`.app` werden lokal nach
|
||||
Abschnitt 4 gebaut und im Gitea-Release von Hand angehängt.
|
||||
* **Signierte Updater-Artefakte.** Der Workflow baut ohne Release-Overlay und
|
||||
ohne `TAURI_SIGNING_PRIVATE_KEY*`; es entstehen also keine `.sig`-Dateien
|
||||
(Abschnitt 2 und 5). Für ein echtes Auto-Update müssen Overlay-Datei und
|
||||
Secrets ergänzt und der Build-Aufruf um
|
||||
`--config src-tauri/tauri.release.conf.json` erweitert werden.
|
||||
|
||||
**Vor dem Tag zu bumpen** (Schritt 1 der Checkliste): Die Release-Version kommt
|
||||
aus dem Tag, die Version im *Dateinamen* aus `src-tauri/tauri.conf.json`. Ohne
|
||||
Bump heißt das Artefakt zu `v0.2.0` weiterhin
|
||||
`maarcadetweet_0.1.0_x64-setup.exe`. `src-tauri/Cargo.toml` und `package.json`
|
||||
mitziehen — alle drei stehen aktuell auf `0.1.0`.
|
||||
|
||||
> **Falle:** Sobald `.gitea/workflows/` existiert, ignoriert Gitea
|
||||
> `.github/workflows/` vollständig — kommentarlos, ohne roten Lauf. Im
|
||||
> Nachbarprojekt `lserver` waren Tests dadurch einen Tag lang still
|
||||
> abgeschaltet. Dieses Repo hat kein `.github/`, und das soll so bleiben: neue
|
||||
> Workflows gehören nach `.gitea/workflows/`.
|
||||
|
||||
## 9. Offene Punkte
|
||||
|
||||
* Kein Release-Overlay im Repo — die Datei aus Abschnitt 2 muss angelegt
|
||||
werden. Der Endpoint `https://releases.maarcadetweet.local/…` in der aktuellen
|
||||
Config ist ein Platzhalter und existiert nicht.
|
||||
* Kein Update-Server, kein CI-Workflow, kein Skript, das `latest.json` erzeugt
|
||||
(`scripts/` ist leer).
|
||||
* Kein Update-Server und kein Skript, das `latest.json` erzeugt (`scripts/` ist
|
||||
leer). Der CI-Workflow aus Abschnitt 8 baut und veröffentlicht Installer,
|
||||
aber keine Updater-Artefakte.
|
||||
* Keine Code-Signierung/Notarisierung für macOS und keine Authenticode-Signatur
|
||||
für Windows konfiguriert (`bundle` enthält weder `macOS.signingIdentity` noch
|
||||
`windows.certificateThumbprint`). Der Tauri-Updater-Schlüssel ersetzt das
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
-- PDS database schema 0004: invite codes for `com.atproto.server.createAccount`.
|
||||
--
|
||||
-- Why
|
||||
--
|
||||
-- Until now `createAccount` had no gate of any kind: no invite code, no rate
|
||||
-- limit, and `describeServer` advertised `invite_code_required: false`. That
|
||||
-- was survivable while the only reachable instance was `127.0.0.1:2583`. It
|
||||
-- stops being survivable the moment the PDS answers on a public name, because
|
||||
-- every accepted account is not just a row in `users` — it creates a `repos`
|
||||
-- head, a key pair the server has to keep, an MST that grows with every write,
|
||||
-- and firehose events that every subscribed AppView is obliged to index. A
|
||||
-- single script could mint accounts until the disk filled up, and nothing in
|
||||
-- the write path would consider that abnormal.
|
||||
--
|
||||
-- This migration adds the smallest gate that actually closes that hole: an
|
||||
-- account may only be created by presenting a code the operator handed out.
|
||||
-- The gate is opt-in via `PDS_INVITE_REQUIRED` (default `false`, so the dozens
|
||||
-- of integration tests that create throwaway accounts keep working); the
|
||||
-- tables below exist unconditionally so that switching the flag on is a
|
||||
-- restart, not a migration.
|
||||
--
|
||||
-- Two tables, not one
|
||||
-- -------------------
|
||||
-- A code can be worth more than one account (`--uses 5` for a group of
|
||||
-- friends, a conference badge, a family). That means "who redeemed this code"
|
||||
-- is a *set*, not a single column, so it cannot live on the code row. Putting
|
||||
-- a `redeemed_by TEXT` column on `invite_codes` would have forced either
|
||||
-- one-code-one-account (losing the multi-use case the operator actually wants)
|
||||
-- or an array column that no foreign key, index or `COUNT(*)` can reason
|
||||
-- about. `invite_code_uses` is that set, one row per redemption.
|
||||
--
|
||||
-- The redemption *counter* still lives on the code row even though it is
|
||||
-- derivable from `COUNT(*)` over `invite_code_uses`. That duplication is
|
||||
-- deliberate and is the entire concurrency story — see below.
|
||||
--
|
||||
-- invite_codes
|
||||
-- ------------
|
||||
--
|
||||
-- code TEXT PRIMARY KEY — the code itself, and the natural key. No
|
||||
-- surrogate `id`: the code is what the user types, what the
|
||||
-- operator pastes into a chat window, and what the redeem query
|
||||
-- looks up, so a second identifier would only add a join.
|
||||
-- Codes are generated lowercase from a 32-character
|
||||
-- Crockford-style alphabet (`crates/pds-server/src/invite.rs`),
|
||||
-- and the server lowercases and trims what the client sends
|
||||
-- before looking it up. Because every stored code is already
|
||||
-- lowercase ASCII, that normalisation happens in Rust rather
|
||||
-- than as `WHERE lower(code) = …`, which would throw away this
|
||||
-- primary-key index on the hottest lookup this table has.
|
||||
--
|
||||
-- created_at when the operator minted it. Purely for the `invite list`
|
||||
-- output and for answering "where did this wave of signups come
|
||||
-- from" after the fact.
|
||||
--
|
||||
-- note free-text label the operator can attach at creation time
|
||||
-- (`--note "meetup 2026-09"`). Nullable, never interpreted.
|
||||
-- It exists because a bare list of random strings is unusable
|
||||
-- a month later.
|
||||
--
|
||||
-- max_uses how many accounts this code may create. `CHECK (max_uses > 0)`
|
||||
-- because a zero-use code is not a thing you would ever mean to
|
||||
-- create — it is a typo that would silently hand out a code that
|
||||
-- can never work.
|
||||
--
|
||||
-- used_count how many it has already created. Kept in sync with
|
||||
-- `invite_code_uses` inside the same transaction that writes
|
||||
-- both.
|
||||
--
|
||||
-- disabled a code the operator wants to stop honouring *without* losing
|
||||
-- the audit trail. Deleting the row would work for the future
|
||||
-- but would take the `invite_code_uses` rows with it (see the
|
||||
-- FK below) and with them the record of which accounts came
|
||||
-- from that code — which is the one question you ask when a
|
||||
-- code leaks. A boolean keeps the history and is checked in the
|
||||
-- same `WHERE` clause as the counter, so disabling costs nothing
|
||||
-- at redeem time.
|
||||
--
|
||||
-- CHECK (used_count <= max_uses) — the belt to the redeem query's braces.
|
||||
-- The application never over-redeems (the conditional UPDATE
|
||||
-- below makes that impossible), but this constraint means that
|
||||
-- *no* future query — a hand-written `UPDATE` during an
|
||||
-- incident, a bug in a later refactor — can hand out more
|
||||
-- accounts than the operator authorised. The database refuses.
|
||||
--
|
||||
-- How the redeem race is closed
|
||||
-- -----------------------------
|
||||
-- The obvious implementation is "SELECT the code, check `used_count <
|
||||
-- max_uses` in Rust, then UPDATE". That is a check-then-act, and two
|
||||
-- registrations arriving at the same instant with the same last remaining use
|
||||
-- both read `used_count = 0`, both decide they are allowed, and both write
|
||||
-- `used_count = 1` — two accounts from a one-use code, with the row still
|
||||
-- claiming a single redemption.
|
||||
--
|
||||
-- So the check and the act are one statement, and the database performs both:
|
||||
--
|
||||
-- UPDATE invite_codes
|
||||
-- SET used_count = used_count + 1
|
||||
-- WHERE code = $1
|
||||
-- AND NOT disabled
|
||||
-- AND used_count < max_uses
|
||||
-- RETURNING used_count, max_uses;
|
||||
--
|
||||
-- Under Postgres's READ COMMITTED isolation the second transaction to reach
|
||||
-- this row blocks on the row lock the first one took. When the first commits,
|
||||
-- the second does not proceed with its stale snapshot: it re-reads the updated
|
||||
-- row and re-evaluates the `WHERE` clause against it (EvalPlanQual). The
|
||||
-- counter is now `1`, `used_count < max_uses` is false, the row no longer
|
||||
-- matches, and the statement returns zero rows. Zero rows returned *is* the
|
||||
-- rejection — the route turns it into `400 InvalidInviteCode` without ever
|
||||
-- having formed an opinion of its own about whether the code was still valid.
|
||||
--
|
||||
-- If the first transaction instead rolls back — the handle turned out to be
|
||||
-- taken, key generation failed, anything — the lock is released with the
|
||||
-- counter back at `0` and the waiting transaction's re-check succeeds. The
|
||||
-- code is only consumed by a registration that actually completed, which is
|
||||
-- why the redemption is issued inside `create_account`'s existing
|
||||
-- transaction rather than before it.
|
||||
--
|
||||
-- This is also why `used_count` is stored rather than computed. A
|
||||
-- `COUNT(*) FROM invite_code_uses` has no row to lock — concurrent counters
|
||||
-- both see the same pre-insert count and both pass. The counter column gives
|
||||
-- the conditional UPDATE a single row to serialise on.
|
||||
CREATE TABLE IF NOT EXISTS invite_codes (
|
||||
code TEXT PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
note TEXT,
|
||||
max_uses INTEGER NOT NULL DEFAULT 1,
|
||||
used_count INTEGER NOT NULL DEFAULT 0,
|
||||
disabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
CONSTRAINT invite_codes_max_uses_positive CHECK (max_uses > 0),
|
||||
CONSTRAINT invite_codes_used_count_sane CHECK (used_count >= 0 AND used_count <= max_uses)
|
||||
);
|
||||
|
||||
-- `invite list` shows the newest codes first, and that is the only listing
|
||||
-- this table has. Small table, but the operator runs it interactively and an
|
||||
-- ordered index keeps the output instant even after a few thousand codes.
|
||||
CREATE INDEX IF NOT EXISTS invite_codes_created_at_idx
|
||||
ON invite_codes (created_at DESC);
|
||||
|
||||
-- =====================================================
|
||||
-- invite_code_uses — which account came from which code
|
||||
-- =====================================================
|
||||
--
|
||||
-- code FK to `invite_codes(code)` ON DELETE CASCADE. Cascading is the
|
||||
-- right call *here* (unlike `firehose_events.did` in 0003, which
|
||||
-- deliberately has no FK) because these rows are meaningless
|
||||
-- without the code they describe: they exist to answer "which
|
||||
-- accounts did code X create", and a use-row whose code has been
|
||||
-- deleted answers nothing. The operator who wants to stop a code
|
||||
-- but keep the trail sets `disabled` instead of deleting — which
|
||||
-- is precisely why that column exists.
|
||||
--
|
||||
-- did the account that was created. Intentionally NOT a foreign key
|
||||
-- to `users(did)`: this is an audit record of something that
|
||||
-- happened, and it has to survive the account being deleted. If
|
||||
-- it cascaded from `users`, deleting a spam account would erase
|
||||
-- the evidence linking it to the code that let it in — the exact
|
||||
-- moment the link matters most. The trade-off is that a `did`
|
||||
-- here may point at a user that no longer exists; that is
|
||||
-- accepted and is what an audit log looks like.
|
||||
--
|
||||
-- handle the handle as it was at creation time, denormalised on
|
||||
-- purpose. Handles can change, and `users` may be gone entirely
|
||||
-- (see above); this column is a snapshot so the listing stays
|
||||
-- readable without a join that may find nothing.
|
||||
--
|
||||
-- used_at when the redemption happened.
|
||||
--
|
||||
-- PRIMARY KEY (code, did) — one account can only consume a given code once.
|
||||
-- This is not the mechanism that enforces the use limit (the
|
||||
-- conditional UPDATE is), it is a guard against a redemption
|
||||
-- being recorded twice for one account, which would make
|
||||
-- `used_count` and this table disagree.
|
||||
CREATE TABLE IF NOT EXISTS invite_code_uses (
|
||||
code TEXT NOT NULL REFERENCES invite_codes(code) ON DELETE CASCADE,
|
||||
did TEXT NOT NULL,
|
||||
handle TEXT NOT NULL,
|
||||
used_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (code, did)
|
||||
);
|
||||
|
||||
-- The reverse lookup: "which code let this account in?". Asked per-account
|
||||
-- during abuse triage, so it needs its own index — the (code, did) primary
|
||||
-- key cannot serve a query whose only predicate is `did`.
|
||||
CREATE INDEX IF NOT EXISTS invite_code_uses_did_idx
|
||||
ON invite_code_uses (did);
|
||||
Reference in New Issue
Block a user