Public pages deployed, and one credential across two products

Item 7: the comparison, landing and donation pages were written on a branch and
never deployed, so the live site described a product that stopped being accurate
several changes ago. Cherry-picked and deployed; the comparison page now says
relays federate, that an address is derived rather than issued, and why
federating is a trade rather than a win.

Donation flow verified end to end against production keys: out-of-range amounts
are refused before Stripe is contacted at all, and a valid amount creates a real
Checkout Session and redirects to it. No payment was made -- an unpaid session
expires on its own. ⚠️ I could NOT verify with test keys: there is no Stripe
test key on the box, and inventing one proves nothing.

Item 9, which is a narrower claim than it looks and worth testing exactly:
**hosting only Messenger leaves nothing missing.** Proven three ways -- a local
relay names no account service at all, runs a full sign-up and sign-in cycle
with nothing else involved, and never probes for Files.

And when the shared service IS used, one handle and passphrase signs in to both
Messenger and Files. The two sessions carry DIFFERENT pseudonyms, which is the
point rather than a detail: same-pseudonym would let the two services correlate
their users, which is exactly what a shared account service must not enable. A
Files-scoped token is also refused by the Messenger relay -- the product scope
is a boundary, not a label.

⚠️ The shared-account tests now back off and retry on the account service's rate
limit, because a dozen registrations in a burst is something any sane service
should refuse. They still fail if it never clears: a rate limit that does not
lift is a real problem, and skipping on it would hide one.
This commit is contained in:
Paul Hitt
2026-08-24 05:40:01 -04:00
parent 9ced7dc3f0
commit ca626b3368
4 changed files with 153 additions and 5 deletions
@@ -31,8 +31,15 @@ class AccountsClient(
private val base = baseUrl.trimEnd('/').toHttpUrl()
/** The product name this client asks for a session under. */
private val product = "messenger"
/**
* The product a session is scoped to.
*
* ⚠️ **Sessions are per product, and that is the privacy property.** The
* token carries a pseudonym derived for this product, so Messenger and
* Files receive different opaque ids for the same person and cannot
* correlate their users with each other.
*/
private val defaultProduct = "messenger"
/**
* What the service advertises for a handle.
@@ -66,7 +73,7 @@ class AccountsClient(
)
}
fun signIn(handle: String, passphrase: String): Session {
fun signIn(handle: String, passphrase: String, product: String = defaultProduct): Session {
val params = parameters(handle)
// ⚠️ The ADVERTISED iteration count wins over the local constant. An
// account created under an older count must still be derivable, and the
@@ -67,6 +67,30 @@ class LiveShapesTest {
private fun handle(tag: String) = "shape$tag" + (System.nanoTime() % 10_000_000)
/**
* Register against the shared account service, pacing for its rate limit.
*
* ⚠️ The throttle is the service working correctly, not a client failure —
* three rounds across two shapes is a dozen registrations in a burst, and
* any sane account service refuses that. So this backs off and retries
* rather than reporting a product bug, and still FAILS if the service never
* lets us through: a rate limit that never clears is a real problem, and
* skipping on it would hide one.
*/
private fun registerPaced(accounts: AccountsClient, name: String) {
var wait = 15_000L
repeat(5) { attempt ->
try {
accounts.register(name, PASSPHRASE)
return
} catch (e: AccountsClient.Failure.RateLimited) {
if (attempt == 4) throw e
Thread.sleep(wait)
wait = (wait * 2).coerceAtMost(120_000)
}
}
}
// -- What each shape claims about itself ---------------------------------
@Test
@@ -200,7 +224,7 @@ class LiveShapesTest {
val name = handle(tag)
val descriptor = RelayDiscovery.describe(base)!!
val accounts = AccountsClient(descriptor.accountsUrl!!)
accounts.register(name, PASSPHRASE)
registerPaced(accounts, name)
return RelayAccounts(base).sharedSession(accounts.signIn(name, PASSPHRASE).token, name)
}
@@ -230,7 +254,7 @@ class LiveShapesTest {
assertTrue(descriptor?.usesSharedAccounts == true, "$base is not a shared-identity relay")
val accounts = AccountsClient(descriptor!!.accountsUrl!!)
accounts.register(name, PASSPHRASE)
registerPaced(accounts, name)
val session = accounts.signIn(name, PASSPHRASE)
assertTrue(session.token.isNotEmpty(), "no session token from the account service")
@@ -0,0 +1,117 @@
package io.flamenet.client
import okhttp3.Request
import org.junit.jupiter.api.Assumptions.assumeTrue
import org.json.JSONObject
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
/**
* One credential, two products — and whether either is optional.
*
* The claim the product makes is narrow and worth testing exactly: **hosting
* only Messenger leaves nothing missing.** Flamenet Files is not a dependency,
* a fallback, or a hidden requirement. What the shared account service buys is
* one credential across both, and nothing else.
*
* ⚠️ Creates a real account on `accounts.flamenet.io`. Enabled by
* `FN_LIVE_MANAGED=1`.
*/
class LiveTwoProductsTest {
private val accountsUrl = "https://accounts.flamenet.io"
private val messengerRelay = "https://relay.flamenet.io"
private val filesHost = "https://files.flamenet.io"
private val localRelay = "https://relay1.flamenet.io"
private fun needManaged() = assumeTrue(
System.getenv("FN_LIVE_MANAGED") == "1",
"set FN_LIVE_MANAGED=1 — this creates a real account")
private fun needFleet() = assumeTrue(
System.getenv("FN_LIVE_FLEET") != null, "set FN_LIVE_FLEET")
/**
* ⚠️ **The Messenger-only case, which is the one that matters.** A relay
* keeping its own accounts must work with no account service and no Files
* anywhere — no probe of either, no degraded mode, no missing feature.
*/
@Test
fun `a messenger-only self-hoster needs no account service and no files`() {
needFleet()
val descriptor = RelayDiscovery.describe(localRelay)
assertTrue(descriptor != null, "the relay served no descriptor")
assertEquals(RelayDiscovery.Accounts.LOCAL, descriptor!!.accounts)
assertTrue(descriptor.accountsUrl == null,
"a local relay named an account service: ${descriptor.accountsUrl}")
// And it can actually run an account, start to finish, with nothing
// else involved.
val handle = "solo" + (System.nanoTime() % 10_000_000)
val account = RelayAccounts(localRelay)
.signUp(handle, "$handle@example.invalid", PASSPHRASE)
assertEquals(handle, account.username)
assertTrue(account.appPassword.isNotEmpty())
assertEquals(account.userId, RelayAccounts(localRelay).signIn(handle, PASSPHRASE).userId)
}
/** Files names the same account service Messenger's managed relay does. */
@Test
fun `files and the managed relay point at the same account service`() {
needManaged()
val http = RelayClient.defaultHttpClient()
val filesConfig = http.newCall(
Request.Builder().url("$filesHost/api/config").get().build()
).execute().use { JSONObject(it.body?.string().orEmpty()) }
assertEquals(accountsUrl, filesConfig.getString("accountsURL"),
"Files uses a different account service than Messenger")
assertEquals(accountsUrl, RelayDiscovery.describe(messengerRelay)?.accountsUrl)
}
/**
* One handle and passphrase, signed in to both products.
*
* ⚠️ And the two tokens must carry **different** pseudonyms. That is the
* whole reason sessions are product-scoped: if Messenger and Files received
* the same opaque id, the two services could correlate their users, which
* is exactly what a shared account service must not enable.
*/
@Test
fun `one account signs in to both products without letting them correlate`() {
needManaged()
val accounts = AccountsClient(accountsUrl)
val handle = "both" + (System.nanoTime() % 10_000_000)
accounts.register(handle, PASSPHRASE)
val messengerSession = accounts.signIn(handle, PASSPHRASE, product = "messenger")
val filesSession = accounts.signIn(handle, PASSPHRASE, product = "files")
assertTrue(messengerSession.token.isNotEmpty(), "no messenger session")
assertTrue(filesSession.token.isNotEmpty(), "no files session")
assertNotEquals(messengerSession.pseudonym, filesSession.pseudonym,
"the two products got the SAME pseudonym — they could correlate their users")
// The messenger half is not merely issued, it is accepted: exchanged at
// the relay for a working relay credential.
val relayAccount = RelayAccounts(messengerRelay)
.sharedSession(messengerSession.token, handle)
assertTrue(relayAccount.appPassword.isNotEmpty(),
"the relay refused a session the account service issued")
// ⚠️ A files token must NOT be accepted by the messenger relay. The
// product scope is a boundary, not a label; if the relay took it, a
// token minted for one product would work on another.
val refused = runCatching {
RelayAccounts(messengerRelay).sharedSession(filesSession.token, handle)
}
assertTrue(refused.isFailure,
"the messenger relay accepted a token scoped to Files")
}
private companion object {
const val PASSPHRASE = "a-long-enough-passphrase"
}
}