Contact links open the app: /add, apple-app-site-association, assetlinks.json

SPEC 8.12: a contact link's web form is https://<relay>/add#u=..&t|l=..&h=..
and its payload is in the fragment, which no browser sends.

- GET /add: one page for every link, rendered with nothing about anybody.
  add.js reads the fragment in the browser and points "Open in the app" at
  the flamenet: form both apps open; without a fragment it says the link is
  incomplete. Install links as on /join. noindex, no-store, no-referrer;
  the script is a file, so the CSP's default-src 'self' allows it.
- /.well-known/apple-app-site-association: M5923Y84P5.net.flamenet.messenger,
  components /add* only.
- /.well-known/assetlinks.json: io.flamenet.messenger and the Messenger
  release certificate's SHA-256 (the public fingerprint, 79:1F:..:3A:1D).
- Both name the published app, so they are constants, not configuration.

ContactLinkPageTests: 3 red, then 3/3 green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-10-09 14:58:13 -04:00
co-authored by Claude Opus 5.5
parent 8ebf83f62c
commit 30a987d5e1
5 changed files with 280 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
// SPEC 8.12: https://<relay>/add#u=<id>&t=<token>|l=<link>&h=<handle>.
// The fragment never reaches the server; this script hands it to the app as
// the flamenet: form, flamenet:@<relay>?<the same fields>, which both apps
// open. Nothing here is sent anywhere.
(function () {
var open = document.getElementById('add-open');
var missing = document.getElementById('add-missing');
var who = document.getElementById('add-who');
var fields = (location.hash || '').replace(/^#/, '');
var params = new URLSearchParams(fields);
if (!fields || !params.get('u') || (!params.get('t') && !params.get('l'))) {
if (missing) missing.hidden = false;
if (open) open.hidden = true;
return;
}
if (open) open.href = 'flamenet:@' + location.hostname + '?' + fields;
var handle = params.get('h');
if (handle && who) {
// A hint the link carries, not checked by anybody yet: textContent only.
who.textContent =
'The link says it is from ' + handle + '. Open it in Flamenet Messenger to start a ' +
'conversation. The app checks the name when they reply.';
}
})();
+53
View File
@@ -0,0 +1,53 @@
#extend("layout"):
#export("content"):
<section class="band band-hero">
<div class="wrap">
<h1>Somebody shared a contact link</h1>
<p class="band-lede" id="add-who">
It lets you start a conversation with them in
<span class="hl">Flamenet Messenger</span>, without knowing their username.
</p>
<p id="add-missing" hidden>
This link has nothing after the <code>#</code>, so there is nobody to open.
Ask the person who sent it for the whole link.
</p>
<div class="platforms">
<a class="platform" id="add-open" href="/get" rel="nofollow">
<span class="platform-name">Open in the app</span>
<span class="platform-state">Flamenet Messenger</span>
</a>
</div>
</div>
</section>
<section class="band">
<div class="wrap">
<h2>No app yet?</h2>
<div class="platforms">
#if(iosLive && iosStoreURL != ""):<a class="platform" href="#(iosStoreURL)" rel="noopener"><span class="platform-name">iPhone</span><span class="platform-state">Download on the App Store</span></a>#endif
<a class="platform" href="/downloads/flamenet-messenger.apk" download>
<span class="platform-name">Android</span>
<span class="platform-state" data-apk-version>Download the app (APK)</span>
</a>
<a class="platform" href="/get">
<span class="platform-name">Other ways</span>
<span class="platform-state">Every download, explained</span>
</a>
</div>
<p>Install it, come back to this link, and open it again.</p>
<p class="fineprint">
Everything about the person is after the <code>#</code> in the address, a
part your browser never sends, so this server received a request for
<code>/add</code> and nothing else. The page passes it to the app on this
phone. It records nothing about you, and it asks search engines not to
index it. What the app keeps is written down at
<a href="/metadata">what we keep</a>.
</p>
</div>
</section>
<script src="/add.js?v=#(assetVersion)"></script>
#endexport
#endextend
+47
View File
@@ -0,0 +1,47 @@
import Foundation
import Vapor
/// The two files that let a phone open a contact link (SPEC 8.12) in the
/// app: Apple's universal links and Android's verified app links. Both name
/// the published app, so they are constants rather than configuration.
enum AppLinks {
/// Team M5923Y84P5, bundle net.flamenet.messenger; only the link path.
static var apple: [String: Any] {
[
"applinks": [
"details": [
[
"appIDs": ["M5923Y84P5.net.flamenet.messenger"],
"components": [["/": "/add*", "comment": "contact links, SPEC 8.12"]],
]
]
]
]
}
/// io.flamenet.messenger, signed with the Messenger release key. This is
/// the certificate's public SHA-256 fingerprint, not key material.
static var android: [[String: Any]] {
[
[
"relation": ["delegate_permission/common.handle_all_urls"],
"target": [
"namespace": "android_app",
"package_name": "io.flamenet.messenger",
"sha256_cert_fingerprints": [
"79:1F:C8:26:D5:72:A7:0A:BF:AF:7D:B7:FA:77:84:DA:D0:F8:6F:20:4B:B3:13:F0:D7:5E:E6:76:DF:8D:3A:1D"
],
],
]
]
}
/// Served as JSON with no redirect, which both platforms require.
static func json(_ object: Any) throws -> Response {
let data = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])
let response = Response(status: .ok, body: .init(data: data))
response.headers.contentType = .json
response.headers.replaceOrAdd(name: .cacheControl, value: "public, max-age=3600")
return response
}
}
+45
View File
@@ -465,6 +465,25 @@ func register(routes r: Routes, on app: Application) throws {
}
// SPEC 8.12: the web form of a contact link. The payload is in the
// fragment, which no browser sends, so this is one page for every link;
// `add.js` hands the fragment to the app. Not indexed, not cached, and the
// address is not passed on to anything the page links to.
app.get("add") { req async throws -> Response in
var page = AddContext(
audience: r.config.audience, iosLive: r.config.iosLive,
iosStoreURL: r.config.iosStoreURL)
page.canonical = publicOrigin(req) + "/add"
page.origin = publicOrigin(req)
let view: View = try await req.view.render("add", page)
let response = Response(status: .ok, body: .init(buffer: view.data))
response.headers.contentType = .html
response.headers.replaceOrAdd(name: "X-Robots-Tag", value: "noindex")
response.headers.replaceOrAdd(name: .cacheControl, value: "no-store")
response.headers.replaceOrAdd(name: "Referrer-Policy", value: "no-referrer")
return response
}
app.get("status.json") { req async -> StatusReport in
await StatusPage.report(
draining: req.application.drain.isDraining,
@@ -1137,6 +1156,15 @@ func register(routes r: Routes, on app: Application) throws {
}
// RFC 9116.
// App links for contact links (SPEC 8.12): a phone with the app opens
// https://<this host>/add#... in the app rather than the browser. The
// app's identity, not the operator's, so every relay serves the same two.
app.get(".well-known", "apple-app-site-association") { _ -> Response in
try AppLinks.json(AppLinks.apple)
}
app.get(".well-known", "assetlinks.json") { _ -> Response in
try AppLinks.json(AppLinks.android)
}
app.get(".well-known", "security.txt") { req -> Response in
// RFC 9116 requires Expires, and a stale one is worse than none: it
// tells a researcher the file is abandoned.
@@ -2017,6 +2045,23 @@ struct JoinContext: Encodable {
}
}
/// The contact-link page (SPEC 8.12).
struct AddContext: Encodable {
var lang: String = "en"
var section: String = ""
var hasAlternate: Bool = false
var chrome: LandingContext.Chrome = LandingContext.Chrome()
var canonical: String = ""
var origin: String = ""
var title: String = "A contact link. Flamenet Messenger"
var description: String =
"Somebody shared a link to start a conversation with them in Flamenet Messenger."
let audience: String
let assetVersion: String = AssetVersion.value
let iosLive: Bool
let iosStoreURL: String
}
struct StatusCheckRow: Encodable {
let name: String
let detail: String
@@ -0,0 +1,111 @@
import Crypto
import Foundation
import Testing
import VaporTesting
@testable import FlamenetRelay
/// SPEC §8.12: the web form of a contact link, `https://<relay>/add#…`, and the
/// two files that let a phone open it in the app instead of a browser.
@Suite("contact link page and app links", .serialized)
struct ContactLinkPageTests {
private func withRelayApp(_ body: (Application) async throws -> Void) async throws {
// Serialised against the other suites that boot a relay this way.
// `setenv` is process-global; see ProcessEnvironmentLock.
try await ProcessEnvironmentLock.withExclusiveEnvironment {
let issuer = Curve25519.Signing.PrivateKey()
let dbPath = NSTemporaryDirectory() + "fn-add-\(UUID().uuidString).sqlite"
let attachments = NSTemporaryDirectory() + "fn-add-att-\(UUID().uuidString)"
let env: [String: String] = [
"FN_ISSUER_PUBLIC_KEY": issuer.publicKey.rawRepresentation.base64EncodedString(),
"FN_ISSUER_PRIVATE_KEY": issuer.rawRepresentation.base64EncodedString(),
"FN_RELAY_AUDIENCE": "test.relay",
"FN_DB": dbPath,
"FN_ATTACHMENTS_DIR": attachments,
"FN_LOG_LEVEL": "critical",
]
let previous = env.keys.reduce(into: [String: String?]()) {
$0[$1] = ProcessInfo.processInfo.environment[$1]
}
for (k, v) in env { setenv(k, v, 1) }
defer {
for (k, v) in previous { if let v { setenv(k, v, 1) } else { unsetenv(k) } }
try? FileManager.default.removeItem(atPath: dbPath)
try? FileManager.default.removeItem(atPath: attachments)
}
let app = try await Application.make(.testing)
do {
try await configure(app)
try await body(app)
} catch {
try? await app.asyncShutdown()
throw error
}
try? await app.asyncShutdown()
}
}
/// The payload is in the fragment, which a browser never sends, so the page
/// is the same for every link and hands the fragment to the app itself.
@Test("/add is one page for every link, not indexed, and reads the fragment in the browser")
func addPage() async throws {
try await withRelayApp { app in
try await app.testing().test(.GET, "/add") { res in
#expect(res.status == .ok)
#expect(res.headers.contentType == .html)
#expect(res.headers.first(name: "X-Robots-Tag") == "noindex")
#expect(res.headers.first(name: "Referrer-Policy") == "no-referrer")
#expect(res.headers.first(name: "Cache-Control")?.contains("no-store") == true)
let body = res.body.string
#expect(body.contains("src=\"/add.js"), "the page does not load its script")
#expect(body.contains("id=\"add-open\""), "no button to open the app")
#expect(!body.contains("<script>"), "an inline script, which the CSP refuses")
}
try await app.testing().test(.GET, "/add.js") { res in
#expect(res.status == .ok)
let js = res.body.string
#expect(js.contains("location.hash"), "the script does not read the fragment")
#expect(js.contains("flamenet:"), "the script does not build the app form")
}
}
}
/// iOS universal links: team M5923Y84P5, bundle net.flamenet.messenger,
/// and only the contact-link path.
@Test("apple-app-site-association names the app and only /add")
func appleAssociation() async throws {
try await withRelayApp { app in
try await app.testing().test(.GET, "/.well-known/apple-app-site-association") { res in
#expect(res.status == .ok)
#expect(res.headers.contentType == .json)
let json = try JSONSerialization.jsonObject(with: Data(res.body.string.utf8)) as? [String: Any]
let details = (json?["applinks"] as? [String: Any])?["details"] as? [[String: Any]]
#expect(details?.count == 1)
#expect(details?.first?["appIDs"] as? [String] == ["M5923Y84P5.net.flamenet.messenger"])
let components = details?.first?["components"] as? [[String: Any]]
#expect(components?.compactMap { $0["/"] as? String } == ["/add*"])
}
}
}
/// Android app links: the package and the release certificate's SHA-256.
@Test("assetlinks.json names the package and the release certificate")
func assetLinks() async throws {
try await withRelayApp { app in
try await app.testing().test(.GET, "/.well-known/assetlinks.json") { res in
#expect(res.status == .ok)
#expect(res.headers.contentType == .json)
let rows = try JSONSerialization.jsonObject(with: Data(res.body.string.utf8)) as? [[String: Any]]
let target = rows?.first?["target"] as? [String: Any]
#expect(rows?.first?["relation"] as? [String] == ["delegate_permission/common.handle_all_urls"])
#expect(target?["namespace"] as? String == "android_app")
#expect(target?["package_name"] as? String == "io.flamenet.messenger")
#expect(
target?["sha256_cert_fingerprints"] as? [String] == [
"79:1F:C8:26:D5:72:A7:0A:BF:AF:7D:B7:FA:77:84:DA:D0:F8:6F:20:4B:B3:13:F0:D7:5E:E6:76:DF:8D:3A:1D"
])
}
}
}
}