From 414bed231c0cb351533dff0038cbb8c00153ca3d Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Sun, 4 Oct 2026 08:56:29 -0400 Subject: [PATCH 01/11] Serve the Today file at /today.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /today.json (§8.11) serves one public, signed file, the same bytes for everybody. It comes from FN_TODAY_FILE, read again when the file changes, or from FN_TODAY_SOURCE, fetched every 15 minutes give or take a minute, with a 10 second deadline over the whole answer and a 512 KB cap. Nothing is served until it verifies against a key in FN_TODAY_KEYS (kid = first eight hex characters of the key's SHA-256), and a file that fails the check never replaces the last one that passed. FN_TODAY_CACHE keeps that copy across a restart. 404 {"error":"not_offered"} with no source set, 503 while nothing has verified. A 200 is the bytes unchanged as application/json, public for 900 seconds, with the quoted SHA-256 of those bytes as the ETag, so every client gets the same one; If-None-Match gets a 304. The route reads no credential, cookie or query, writes no log line per request, and adds no table. Documented in docs/CONFIGURATION.md, docs/API.md and .env.example. The flamenet.io builder's dev-signed sample is a test fixture; the dev key's private half is not in the repository. Co-Authored-By: Claude Opus 5.5 --- .env.example | 20 + Resources/Views/api.leaf | 27 +- Resources/Views/configuration.leaf | 69 +- Sources/FlamenetRelay/Config.swift | 11 +- Sources/FlamenetRelay/Entrypoint.swift | 28 +- Sources/FlamenetRelay/TodayFile.swift | 475 ++++++++++++++ Tests/Fixtures/today-sample.json | 1 + Tests/FlamenetRelayTests/TodayFileTests.swift | 613 ++++++++++++++++++ docs/API.md | 15 + docs/CONFIGURATION.md | 30 + 10 files changed, 1284 insertions(+), 5 deletions(-) create mode 100644 Sources/FlamenetRelay/TodayFile.swift create mode 100644 Tests/Fixtures/today-sample.json create mode 100644 Tests/FlamenetRelayTests/TodayFileTests.swift diff --git a/.env.example b/.env.example index 67c969e..3d51003 100644 --- a/.env.example +++ b/.env.example @@ -312,3 +312,23 @@ FN_RELAY_REPLICAS=1 # verification hash is worse than none. # FN_ANDROID_SHA256= # FN_ANDROID_NOTES=What changed in this build. + +# ---- The Today file (optional) ---------------------------------------------- +# +# One public file at /today.json, the same bytes for everybody: headlines, +# markets and weather, built and signed elsewhere. The relay serves it only +# after the signature checks against FN_TODAY_KEYS, and a newer copy that fails +# the check never replaces one that passed. With no source set, /today.json +# answers 404 "not_offered". +# +# Set ONE source. FN_TODAY_FILE is a path (inside the container, so mount it), +# read again whenever the file changes; replace it by renaming a new file over +# it. FN_TODAY_SOURCE is an http(s) URL fetched every fifteen minutes; to mirror +# another relay's file, point it at that relay's /today.json. +FN_TODAY_FILE= +FN_TODAY_SOURCE= +# Comma-separated base64 Ed25519 public keys the file may be signed with. +FN_TODAY_KEYS= +# Where the last good copy is kept, so a restart can serve it at once. +# /data/today-kept.json sits on the volume the compose files keep. +FN_TODAY_CACHE= diff --git a/Resources/Views/api.leaf b/Resources/Views/api.leaf index 59379ba..c718f8b 100644 --- a/Resources/Views/api.leaf +++ b/Resources/Views/api.leaf @@ -3,7 +3,7 @@ + source-sha256: 6ae48dfa762b0ccc -->

Relay API

@@ -73,6 +73,31 @@ relay's users could read would be useless to the peers it exists to inform, and a transparency log only its own users could audit would not be a transparency log.


+

The Today file

+ + + + + + + + + + + + + + + +
RouteAuthPurpose
GET /today.jsonnoneThe signed Today file, when this relay offers one (§8.11)
+

The same bytes for everybody, served exactly as they were signed and +only after the signature checks against FN_TODAY_KEYS. 404 +{"error":"not_offered"} when no source is configured, 503 +while nothing has verified yet. A 200 carries an ETag and +Cache-Control: public, max-age=900, and +If-None-Match gets a 304. Nothing else is read from the +request: no credential, no cookie, no query.

+

Accounts

Present only when this relay runs the built-in identity provider (FN_ISSUER_PRIVATE_KEY set).

diff --git a/Resources/Views/configuration.leaf b/Resources/Views/configuration.leaf index b51e6e4..3ea9e44 100644 --- a/Resources/Views/configuration.leaf +++ b/Resources/Views/configuration.leaf @@ -3,7 +3,7 @@ + source-sha256: e42db453382e80c4 -->

Configuring a relay

@@ -722,6 +722,73 @@ looking wrong. With this set, the publishable key stays the +

The Today file

+

Off. When it is on, the relay serves one public file at +GET /today.json (§8.11): headlines, markets, weather and +the like, built and signed somewhere else, and the same bytes for +everybody. The relay checks the signature and serves the file exactly as +it arrived.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultNotes
FN_TODAY_FILEunsetPath to the signed file. Read again whenever it changes (its +modification time, size or inode). Write a new file and rename it over +the old one, so the relay never reads a half-written one. Relative paths +resolve under FN_WORKDIR.
FN_TODAY_SOURCEunsetAn http or https URL to fetch the file +from instead, every 15 minutes give or take a minute, with a 10 second +limit and a 512 KB cap. Set this or FN_TODAY_FILE, not +both: with both set nothing is served and the log says why. A +self-hosted relay mirrors the official file by pointing this at another +relay's /today.json.
FN_TODAY_KEYSunsetComma-separated base64 Ed25519 public keys the file may be signed +with. An envelope names its key by the first eight hex characters of the +key's SHA-256. A file signed by any other key, or by none, is never +served.
FN_TODAY_CACHEunsetPath where the last file that verified is kept, so a restart can +serve it straight away. It is checked against FN_TODAY_KEYS +again when it is read back. Relative paths resolve under +FN_WORKDIR.
+

Nothing is served until it verifies, and a file that fails the check +never replaces one that passed. A bad signature, a key that is not +listed, a file over 512 KB, a source that is down or slow: the relay +goes on serving the last good file, and logs the refusal, once for each +new reason. With no source set, /today.json answers 404 +{"error":"not_offered"}; with a source set and nothing +verified yet, 503.

+

The route reads no credential, no cookie and no query, and writes no +log line per request. A good answer carries +Cache-Control: public, max-age=900 and an +ETag, and If-None-Match gets a 304.

+

FN_TODAY_SOURCE makes the relay open a connection of its +own: one GET to that URL every fifteen minutes. Whatever comes back +still has to verify, so the host at that URL cannot change what the file +says. It can only hold back a newer file or hand back an older signed +one.

iPhone push (APNs)

Only the iOS app needs this; Android is woken directly. All five must be set together, and the relay logs that push is disabled if any is diff --git a/Sources/FlamenetRelay/Config.swift b/Sources/FlamenetRelay/Config.swift index 7f6c86c..d123991 100644 --- a/Sources/FlamenetRelay/Config.swift +++ b/Sources/FlamenetRelay/Config.swift @@ -168,6 +168,10 @@ struct Config: Sendable { let issuerSigningKeyRaw: Data? let tokenLifetime: Int + /// The Today file (§8.11): its source, the keys that may sign it, and where + /// a copy is kept across restarts. Unset, the relay does not offer it. + var today = TodayConfig(file: nil, source: nil, keys: nil, cache: nil) + // `atRestSecret` is gone with the plaintext transport (relay#50). enum ConfigError: Error, CustomStringConvertible { @@ -327,7 +331,12 @@ struct Config: Sendable { turnTTL: int("FN_TURN_TTL", 7200), issuerSigningKeyRaw: env["FN_ISSUER_PRIVATE_KEY"] .flatMap { $0.isEmpty ? nil : Data(base64Encoded: $0) }, - tokenLifetime: int("FN_TOKEN_LIFETIME", Issuer.defaultLifetime) + tokenLifetime: int("FN_TOKEN_LIFETIME", Issuer.defaultLifetime), + today: TodayConfig( + file: env["FN_TODAY_FILE"], + source: env["FN_TODAY_SOURCE"], + keys: env["FN_TODAY_KEYS"], + cache: env["FN_TODAY_CACHE"]) ) } diff --git a/Sources/FlamenetRelay/Entrypoint.swift b/Sources/FlamenetRelay/Entrypoint.swift index 3afa160..098517d 100644 --- a/Sources/FlamenetRelay/Entrypoint.swift +++ b/Sources/FlamenetRelay/Entrypoint.swift @@ -241,6 +241,25 @@ func configure(_ app: Application) async throws { try register(routes: routes, on: app) + // §8.11, the Today file. The route exists either way: a relay that does not + // offer the file answers 404 not_offered in JSON, which a client can tell + // apart from a missing page. + TodayFile.describe(config.today, logger: app.logger) + var todayFetch: (@Sendable (URL) async throws -> Data)? = nil + if case .url = config.today.source { + let client = app.http.client.shared + todayFetch = { url in try await TodayFile.fetch(url, using: client) } + } + let today = TodayFile( + config: config.today, + workingDirectory: app.directory.workingDirectory, + fetch: todayFetch, + logger: app.logger) + register(today: today, on: app) + if today.offered { + app.lifecycle.use(BootTask { await today.run() }) + } + // The built-in identity provider, only when a signing key is configured. if config.federationPolicy != "closed" { guard let seed = config.federationKeyRaw, @@ -2041,7 +2060,10 @@ struct SecurityHeadersMiddleware: AsyncMiddleware { ) // API answers are per-credential and per-moment: a token, a device // list, a 401. No cache between here and the phone may keep one. - if response.headers.contentType?.subType == "json" { + // The Today file is not one of them: the same bytes for everybody, and + // its route sets its own Cache-Control (§8.11). + let todayFile = request.url.path.split(separator: "/") == ["today.json"] + if response.headers.contentType?.subType == "json", !todayFile { response.headers.replaceOrAdd(name: .cacheControl, value: "no-store") } return response @@ -2094,7 +2116,9 @@ private func textFromHTML(_ html: String) -> String { /// told exactly what they were told before. struct HTMLNotFoundMiddleware: AsyncMiddleware { /// Everything under these answers machines, whatever it sets in Accept. - private static let apiPrefixes = ["e2e", "api", "v1", ".well-known", "health", "metrics"] + private static let apiPrefixes = [ + "e2e", "api", "v1", ".well-known", "health", "metrics", "today.json", + ] func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response { diff --git a/Sources/FlamenetRelay/TodayFile.swift b/Sources/FlamenetRelay/TodayFile.swift new file mode 100644 index 0000000..6f1ebcd --- /dev/null +++ b/Sources/FlamenetRelay/TodayFile.swift @@ -0,0 +1,475 @@ +import AsyncHTTPClient +import Crypto +import Foundation +import NIOCore +import Vapor + +/// Where the Today file comes from and which keys may sign it (§8.11). Read +/// once at startup with the rest of `Config`. +struct TodayConfig: Sendable { + enum Source: Sendable, Equatable { + /// Neither variable is set: this relay does not offer the file. + case none + /// `FN_TODAY_FILE`, re-read whenever it changes. + case file(String) + /// `FN_TODAY_SOURCE`, fetched every fifteen minutes or so. + case url(URL) + /// Set, but in a way nothing can be served from. The reason goes to the + /// boot log and the route answers 503, not 404: the file was asked for, + /// and "not offered" would tell the apps it never was. + case unusable(String) + } + + let source: Source + /// Raw 32-byte Ed25519 public keys, in the order given. + let keys: [Data] + /// How many entries of `FN_TODAY_KEYS` were not keys. Counted, never + /// echoed: a secret pasted into the wrong variable should not reach a log. + let rejectedKeys: Int + /// Where the last good envelope is kept across restarts, if anywhere. + let cachePath: String? + + var offered: Bool { source != .none } + + init(file: String?, source: String?, keys: String?, cache: String?) { + func clean(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespaces), !trimmed.isEmpty + else { return nil } + return trimmed + } + switch (clean(file), clean(source)) { + case (nil, nil): + self.source = .none + case (let path?, nil): + self.source = .file(path) + case (nil, let raw?): + if let url = URL(string: raw), + let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https", + let host = url.host, !host.isEmpty + { + self.source = .url(url) + } else { + self.source = .unusable("FN_TODAY_SOURCE is not an http or https URL") + } + case (.some, .some): + self.source = .unusable("FN_TODAY_FILE and FN_TODAY_SOURCE are both set; set one") + } + + var accepted: [Data] = [] + var rejected = 0 + for entry in (keys ?? "").split(separator: ",") { + let value = entry.trimmingCharacters(in: .whitespaces) + guard !value.isEmpty else { continue } + if let raw = Data(base64Encoded: value), raw.count == 32, + (try? Curve25519.Signing.PublicKey(rawRepresentation: raw)) != nil + { + accepted.append(raw) + } else { + rejected += 1 + } + } + self.keys = accepted + self.rejectedKeys = rejected + self.cachePath = clean(cache) + } +} + +/// The one public file a relay can offer its apps (§8.11): the same bytes for +/// everybody, signed by a key this relay was configured with, served exactly +/// as they arrived. +/// +/// Nothing is served until it verifies, and a newer file that does not verify +/// never displaces an older one that did. Nothing here is per person: the route +/// reads no credential, no cookie and no query, and writes no log line per +/// request. +actor TodayFile { + + /// The envelope as the builder writes it. Only these four fields are read; + /// anything else in the object is ignored, and the bytes go out unchanged. + struct Envelope: Decodable { + let v: Int + let kid: String + let body: String + let sig: String + } + + /// A key the relay accepts, with the id an envelope names it by: the first + /// eight hex characters of the SHA-256 of the 32 raw bytes. + struct Key: Sendable { + let kid: String + let key: Curve25519.Signing.PublicKey + + init?(raw: Data) { + guard let key = try? Curve25519.Signing.PublicKey(rawRepresentation: raw) else { + return nil + } + self.key = key + self.kid = TodayFile.kid(of: raw) + } + } + + /// What the route hands out. + struct Served: Sendable, Equatable { + let bytes: ByteBuffer + /// The quoted SHA-256 of `bytes`. See `etag(of:)`. + let etag: String + } + + enum Refusal: Error, Equatable, CustomStringConvertible { + case tooLarge + case malformed + case unsupportedVersion(Int) + case unknownKey(String) + case badSignature(String) + case unreadable + case status(UInt) + case timedOut + + var description: String { + switch self { + case .tooLarge: return "it is larger than 512 KB" + case .malformed: return "it is not a Today envelope" + case .unsupportedVersion(let v): return "it is envelope version \(v), not 1" + case .unknownKey(let kid): return "it names key \(kid), which is not in FN_TODAY_KEYS" + case .badSignature(let kid): return "its signature does not verify against key \(kid)" + case .unreadable: return "it could not be read" + case .status(let code): return "the source answered \(code)" + case .timedOut: return "the source took longer than 10 seconds" + } + } + } + + /// The same cap the apps hold the file to. + static let maxBytes = 512 * 1024 + static let refreshSeconds = 15 * 60 + /// Either way, so relays mirroring one source do not all ask at once. + static let jitterSeconds = 60 + static let fetchTimeout: TimeAmount = .seconds(10) + /// Fifteen minutes, the builder's own cadence. + static let cacheControl = "public, max-age=900" + + let source: TodayConfig.Source + let keys: [Key] + let cachePath: String? + private let fetch: (@Sendable (URL) async throws -> Data)? + private let logger: Logger + + private var served: Served? + /// The file's identity the last time it was looked at, so it is read again + /// only once it has changed. + private var stamp: FileStamp? + private var looked = false + private var cacheLoaded = false + /// The refusal last logged at error level. A source that stays broken is + /// reported there once, and its repeats go to info. + private var lastComplaint: String? + private var cacheComplained = false + + /// Paths that are not absolute are taken relative to `workingDirectory`, + /// the same rule as `FN_ATTACHMENTS_DIR`. + init( + config: TodayConfig, + workingDirectory: String, + fetch: (@Sendable (URL) async throws -> Data)?, + logger: Logger + ) { + func resolved(_ path: String) -> String { + path.hasPrefix("/") ? path : workingDirectory + path + } + switch config.source { + case .file(let path): self.source = .file(resolved(path)) + default: self.source = config.source + } + self.keys = config.keys.compactMap(Key.init(raw:)) + self.cachePath = config.cachePath.map(resolved) + self.fetch = fetch + self.logger = logger + } + + nonisolated var offered: Bool { source != .none } + + // MARK: - Serving + + /// The envelope to serve, or nil while nothing has verified. + func current() -> Served? { + loadCacheOnce() + if case .file(let path) = source { readIfChanged(path) } + return served + } + + /// Started once the relay has booted. A file is read now and then again on + /// any request after it changes; a URL is fetched now and then every + /// fifteen minutes, give or take a minute, until the relay stops. + func run() async { + loadCacheOnce() + switch source { + case .file(let path): + readIfChanged(path) + case .url(let url): + while !Task.isCancelled { + await refresh(from: url) + let seconds = Self.refreshSeconds + Int.random(in: -Self.jitterSeconds...Self.jitterSeconds) + try? await Task.sleep(nanoseconds: UInt64(seconds) * 1_000_000_000) + } + case .none, .unusable: + return + } + } + + /// One fetch of a URL source. + func refresh(from url: URL) async { + guard let fetch else { return } + loadCacheOnce() + do { + try take(try await fetch(url), from: Self.shown(url)) + } catch { + // The relay is stopping; that is not the source's fault. + if Task.isCancelled { return } + complain(error, from: Self.shown(url)) + } + } + + private func readIfChanged(_ path: String) { + let now = FileStamp.of(path) + if looked && now == stamp { return } + looked = true + stamp = now + do { + guard now != nil else { throw Refusal.unreadable } + try take(try Self.read(path), from: path) + } catch { + complain(error, from: path) + } + } + + private func take(_ data: Data, from origin: String) throws { + try Self.verify(data, keys: keys) + let etag = Self.etag(of: data) + if served?.etag != etag { + served = Served(bytes: ByteBuffer(bytes: data), etag: etag) + persist(data) + logger.info("Today file from \(origin) accepted, \(etag)") + } + if lastComplaint != nil { + logger.notice("Today file from \(origin) verifies again") + lastComplaint = nil + } + } + + private func complain(_ error: any Error, from origin: String) { + let reason = (error as? Refusal)?.description ?? "it could not be fetched: \(error)" + let holding = served.map { "still serving \($0.etag)" } ?? "nothing to serve yet" + let line = "Today file from \(origin) refused: \(reason); \(holding)" + if reason == lastComplaint { + logger.info("\(line)") + } else { + logger.error("\(line)") + lastComplaint = reason + } + } + + // MARK: - The copy kept across restarts + + private func loadCacheOnce() { + guard !cacheLoaded else { return } + cacheLoaded = true + guard let path = cachePath, FileManager.default.fileExists(atPath: path) else { return } + do { + let data = try Self.read(path) + try Self.verify(data, keys: keys) + if served == nil { + served = Served(bytes: ByteBuffer(bytes: data), etag: Self.etag(of: data)) + } + } catch { + // Most often the keys changed since it was written, and then not + // serving it is the point. + logger.notice("the kept Today file at \(path) was not served: \(error)") + } + } + + private func persist(_ data: Data) { + guard let path = cachePath else { return } + do { + try data.write(to: URL(fileURLWithPath: path), options: .atomic) + cacheComplained = false + } catch { + if !cacheComplained { + logger.error("could not keep a copy of the Today file at \(path): \(error)") + } + cacheComplained = true + } + } + + // MARK: - Checking an envelope + + /// The kid a key is named by. + static func kid(of raw: Data) -> String { + SHA256.hash(data: raw).prefix(4).map { String(format: "%02x", $0) }.joined() + } + + /// Throws unless `data` is an envelope signed by one of `keys`. + @discardableResult + static func verify(_ data: Data, keys: [Key]) throws -> Envelope { + guard data.count <= maxBytes else { throw Refusal.tooLarge } + guard let envelope = try? JSONDecoder().decode(Envelope.self, from: data) else { + throw Refusal.malformed + } + guard envelope.v == 1 else { throw Refusal.unsupportedVersion(envelope.v) } + // Checked before it is named in a log line: it came from the source. + let hex = Set("0123456789abcdef") + guard envelope.kid.count == 8, envelope.kid.allSatisfy(hex.contains) else { + throw Refusal.malformed + } + let named = keys.filter { $0.kid == envelope.kid } + guard !named.isEmpty else { throw Refusal.unknownKey(envelope.kid) } + guard let body = Data(base64Encoded: envelope.body), !body.isEmpty, + let sig = Data(base64Encoded: envelope.sig), sig.count == 64 + else { throw Refusal.malformed } + guard named.contains(where: { $0.key.isValidSignature(sig, for: body) }) else { + throw Refusal.badSignature(envelope.kid) + } + return envelope + } + + /// The quoted lower-case hex SHA-256 of exactly the bytes served, and + /// nothing else. Every client gets the same one, and an app can compute it + /// from its own cached copy, so a validator cannot be used to tell one + /// device from another (DESIGN 19). + static func etag(of data: Data) -> String { + "\"" + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + "\"" + } + + /// RFC 9110 §13.1.2: a list, `*`, and the weak comparison. + static func matches(_ ifNoneMatch: [String], etag: String) -> Bool { + for value in ifNoneMatch { + for candidate in value.split(separator: ",") { + var tag = candidate.trimmingCharacters(in: .whitespaces) + if tag == "*" { return true } + if tag.hasPrefix("W/") { tag.removeFirst(2) } + if tag == etag { return true } + } + } + return false + } + + /// At most one byte past the cap is read, so a file that is far too large + /// costs no more than one that is slightly too large. + static func read(_ path: String) throws -> Data { + guard let handle = FileHandle(forReadingAtPath: path) else { throw Refusal.unreadable } + defer { try? handle.close() } + guard let data = try handle.read(upToCount: maxBytes + 1) else { return Data() } + guard data.count <= maxBytes else { throw Refusal.tooLarge } + return data + } + + /// GET a URL source with the cap applied while the body arrives, not after. + /// + /// The deadline covers the whole answer. AsyncHTTPClient's own timeout stops + /// counting once the headers are in, so a source that sends its headers + /// and then trickles the body would otherwise hold the fetch open for as + /// long as it liked. + static func fetch( + _ url: URL, using client: HTTPClient, + timeout: TimeAmount = fetchTimeout, cap: Int = maxBytes + ) async throws -> Data { + try await withThrowingTaskGroup(of: Data.self) { group in + group.addTask { + var request = HTTPClientRequest(url: url.absoluteString) + request.headers.add(name: "Accept", value: "application/json") + let response = try await client.execute(request, timeout: timeout) + guard response.status == .ok else { throw Refusal.status(response.status.code) } + do { + let buffer = try await response.body.collect(upTo: cap) + return Data(buffer.readableBytesView) + } catch is NIOTooManyBytesError { + throw Refusal.tooLarge + } + } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(timeout.nanoseconds)) + throw Refusal.timedOut + } + defer { group.cancelAll() } + guard let data = try await group.next() else { throw Refusal.timedOut } + return data + } + } + + /// A URL as the log shows it: no credentials, no query. + static func shown(_ url: URL) -> String { + "\(url.scheme ?? "https")://\(url.host ?? "")\(url.port.map { ":\($0)" } ?? "")\(url.path)" + } + + /// Said once at startup. + static func describe(_ config: TodayConfig, logger: Logger) { + let kids = config.keys.map(kid(of:)).joined(separator: ", ") + switch config.source { + case .none: + return + case .unusable(let reason): + logger.critical("\(reason). /today.json answers 503 until that is fixed.") + return + case .file(let path): + logger.notice("Today file from \(path), checked against key(s) \(kids)") + case .url(let url): + logger.notice("Today file fetched from \(shown(url)), checked against key(s) \(kids)") + } + if config.rejectedKeys > 0 { + let what = config.rejectedKeys == 1 ? "1 entry is" : "\(config.rejectedKeys) entries are" + logger.critical( + "FN_TODAY_KEYS: \(what) not a base64 32-byte Ed25519 public key, and ignored") + } + if config.keys.isEmpty { + logger.critical( + "FN_TODAY_KEYS has no usable key, so no Today file can verify and /today.json answers 503") + } + } +} + +/// What a file looked like the last time it was read: modification time, +/// size and inode, so an in-place edit and a rename over it are both seen. +struct FileStamp: Equatable { + let modified: Date? + let size: UInt64? + let inode: UInt64? + + static func of(_ path: String) -> FileStamp? { + guard let a = try? FileManager.default.attributesOfItem(atPath: path) else { return nil } + return FileStamp( + modified: a[.modificationDate] as? Date, + size: (a[.size] as? NSNumber)?.uint64Value, + inode: (a[.systemFileNumber] as? NSNumber)?.uint64Value) + } +} + +/// `GET /today.json` (§8.11). +/// +/// Reads `If-None-Match` and nothing else from the request. No credential, no +/// cookie, no query, and no log line: the apps ask for this at most hourly, and +/// a line per fetch would be a record of who opened the app when. +func register(today: TodayFile, on app: Application) { + app.get("today.json") { req async -> Response in + guard today.offered else { + return todayRefusal(.notFound, #"{"error":"not_offered"}"#) + } + guard let served = await today.current() else { + return todayRefusal(.serviceUnavailable, #"{"error":"not_ready"}"#) + } + var headers = HTTPHeaders() + headers.replaceOrAdd(name: .eTag, value: served.etag) + headers.replaceOrAdd(name: .cacheControl, value: TodayFile.cacheControl) + if TodayFile.matches(req.headers[.ifNoneMatch], etag: served.etag) { + return Response(status: .notModified, headers: headers) + } + headers.replaceOrAdd(name: .contentType, value: "application/json") + return Response(status: .ok, headers: headers, body: .init(buffer: served.bytes)) + } +} + +/// A refusal is never cached: the next fetch might find a file. +private func todayRefusal(_ status: HTTPResponseStatus, _ body: String) -> Response { + Response( + status: status, + headers: ["Content-Type": "application/json", "Cache-Control": "no-store"], + body: .init(string: body)) +} diff --git a/Tests/Fixtures/today-sample.json b/Tests/Fixtures/today-sample.json new file mode 100644 index 0000000..81b74e8 --- /dev/null +++ b/Tests/Fixtures/today-sample.json @@ -0,0 +1 @@ +{"v":1,"kid":"a9a94e74","body":"eyJ2IjoxLCJkYXRlIjoiMjAyNi0xMC0wNCIsIm1hZGUiOjE3OTExMTYyMzgsImhlYWRsaW5lcyI6W3sidCI6IuKAmExldmVsIG9mIHBldHRpbmVzc+KAmTogTlJM4oCZcyBlcGljIGZpbmFsIHNlcnZlIGluIEdyYW5kIEZpbmFsIHNhZ2EgYXMgdmVyZGljdCBpbiBvbiBlbnRlcnRhaW5tZW50IiwidSI6Imh0dHBzOi8vZmxhbWVuZXQuaW8vMjAyNi8xMC8wNC9sZXZlbC1vZi1wZXR0aW5lc3MtbnJscy1lcGljLWZpbmFsLXNlcnZlLWluLWdyYW5kLWZpbmFsLXNhZ2EtYXMtdmVyZGljdC1pbi1vbi1lbnRlcnRhaW5tZW50LWZveC1zcG9ydHMvIiwic3JjIjoiRm94IFNwb3J0cyJ9LHsidCI6IlNjaWVudGlzdHMganVzdCBmb3VuZCBhIG5ldyB3YXkgdG8gbWFrZSBoeWRyb2dlbiBmcm9tIHdhdGVyIiwidSI6Imh0dHBzOi8vZmxhbWVuZXQuaW8vMjAyNi8xMC8wNC9zY2llbnRpc3RzLWp1c3QtZm91bmQtYS1uZXctd2F5LXRvLW1ha2UtaHlkcm9nZW4tZnJvbS13YXRlci1zY2llbmNlZGFpbHkvIiwic3JjIjoiU2NpZW5jZURhaWx5In0seyJ0IjoiVHJ1bXAgZGVmaWFudCBhYm91dCBtaWR0ZXJtIGNoYW5jZXMgYXMgaGUgcmFsbGllcyBmb3IgUmVwdWJsaWNhbnMgaW4gT2hpbyIsInUiOiJodHRwczovL2ZsYW1lbmV0LmlvLzIwMjYvMTAvMDQvdHJ1bXAtZGVmaWFudC1hYm91dC1taWR0ZXJtLWNoYW5jZXMtYXMtaGUtcmFsbGllcy1mb3ItcmVwdWJsaWNhbnMtaW4tb2hpby1hbC1qYXplZXJhLyIsInNyYyI6IkFsIEphemVlcmEifSx7InQiOiJXaGF0IHNjaWVudGlzdHMgZGlzY292ZXJlZCBhYm91dCBkb2dzIGFuZCBodW1hbiBlbW90aW9ucyIsInUiOiJodHRwczovL2ZsYW1lbmV0LmlvLzIwMjYvMTAvMDQvd2hhdC1zY2llbnRpc3RzLWRpc2NvdmVyZWQtYWJvdXQtZG9ncy1hbmQtaHVtYW4tZW1vdGlvbnMteWFob28vIiwic3JjIjoiWWFob28ifSx7InQiOiJTY2llbnRpc3RzIFRlc3RlZCBXaGV0aGVyIExlZnRvdmVycyBSZWFsbHkgVGFzdGUgQmV0dGVyIFRoZSBOZXh0IERheS4gT25lIERpc2ggU3Rvb2QgT3V0LiIsInUiOiJodHRwczovL2ZsYW1lbmV0LmlvLzIwMjYvMTAvMDQvc2NpZW50aXN0cy10ZXN0ZWQtd2hldGhlci1sZWZ0b3ZlcnMtcmVhbGx5LXRhc3RlLWJldHRlci10aGUtbmV4dC1kYXktb25lLWRpc2gtc3Rvb2Qtb3V0LXNjaWVuY2VhbGVydC8iLCJzcmMiOiJTY2llbmNlQWxlcnQifSx7InQiOiJBdXJvcmFzIGFwcGVhciBpbiBicm9hZCBkYXlsaWdodCBhcyBTbWlsZSBzcGFjZWNyYWZ0IHNoYXJlcyBlZXJpZSBmaXJzdCBpbWFnZXMgb2YgRWFydGgiLCJ1IjoiaHR0cHM6Ly9mbGFtZW5ldC5pby8yMDI2LzEwLzA0L2F1cm9yYXMtYXBwZWFyLWluLWJyb2FkLWRheWxpZ2h0LWFzLXNtaWxlLXNwYWNlY3JhZnQtc2hhcmVzLWVlcmllLWZpcnN0LWltYWdlcy1vZi1lYXJ0aC1saXZlLXNjaWVuY2UvIiwic3JjIjoiTGl2ZSBTY2llbmNlIn0seyJ0IjoiSG93IGFuIFV6YmVrIHRydWNrIGRyaXZlcuKAmXMgZmF0YWwgY3Jhc2ggZXhwb3NlZCB0cm91YmxpbmcgZnJhdWQgdHJlbmQgaW4gTWlkZGxlIEFtZXJpY2E6IOKAmENoYW1lbGVvbiBjYXJyaWVyc+KAmSIsInUiOiJodHRwczovL2ZsYW1lbmV0LmlvLzIwMjYvMTAvMDQvaG93LWFuLXV6YmVrLXRydWNrLWRyaXZlcnMtZmF0YWwtY3Jhc2gtZXhwb3NlZC10cm91YmxpbmctZnJhdWQtdHJlbmQtaW4tbWlkZGxlLWFtZXJpY2EtY2hhbWVsZW9uLWNhcnJpZXJzLWZveC1uZXdzLyIsInNyYyI6IkZveCBOZXdzIn0seyJ0IjoiTGF0dGVyLWRheSBTYWludHMgd29tZW4gdHVybiBhd2F5IGZyb20gc2NhbmRhbC1wbGFndWVkIFJlcHVibGljYW4gY2FuZGlkYXRlIGluIEFyaXpvbmEiLCJ1IjoiaHR0cHM6Ly9mbGFtZW5ldC5pby8yMDI2LzEwLzA0L2xhdHRlci1kYXktc2FpbnRzLXdvbWVuLXR1cm4tYXdheS1mcm9tLXNjYW5kYWwtcGxhZ3VlZC1yZXB1YmxpY2FuLWNhbmRpZGF0ZS1pbi1hcml6b25hLW5wci8iLCJzcmMiOiJOUFIifSx7InQiOiJVLlMuIE1hcmluZSBhcnJlc3RlZCBvbiBzdXNwaWNpb24gb2Yga2lsbGluZyBKYXBhbmVzZSB3b21hbiBpbiBPa2luYXdhIiwidSI6Imh0dHBzOi8vZmxhbWVuZXQuaW8vMjAyNi8xMC8wNC91LXMtbWFyaW5lLWFycmVzdGVkLW9uLXN1c3BpY2lvbi1vZi1raWxsaW5nLWphcGFuZXNlLXdvbWFuLWluLW9raW5hd2EtbmJjLW5ld3MvIiwic3JjIjoiTkJDIE5ld3MifSx7InQiOiJGbHlkdWJhaSBjYXB0YWluIHNheXMgY29waWxvdCBhc2tlZCB0byBwcmF5LCB0aGVuIHN0cnVjayBoaW0gZnJvbSBiZWhpbmQ7IGRlc2NyaWJlcyBleGFjdGx5IGhvdyBoZSBvcGVuZWQgY29ja3BpdCBkb29yIiwidSI6Imh0dHBzOi8vZmxhbWVuZXQuaW8vMjAyNi8xMC8wNC9mbHlkdWJhaS1jYXB0YWluLXNheXMtY29waWxvdC1hc2tlZC10by1wcmF5LXRoZW4tc3RydWNrLWhpbS1mcm9tLWJlaGluZC1kZXNjcmliZXMtZXhhY3RseS1ob3ctaGUtb3BlbmVkLWNvY2twaXQtZG9vci10aGUtdGltZXMtb2YtaXNyYWVsLyIsInNyYyI6IlRoZSBUaW1lcyBvZiBJc3JhZWwifSx7InQiOiJTZWFyY2hlcnMgZmluZCBkZWJyaXMgb2YgQm9zdG9uLWJvdW5kIG1lZGljYWwgcGxhbmUgdGhhdCB3ZW50IG1pc3Npbmcgb2ZmIE5hbnR1Y2tldCIsInUiOiJodHRwczovL2ZsYW1lbmV0LmlvLzIwMjYvMTAvMDQvc2VhcmNoZXJzLWZpbmQtZGVicmlzLW9mLWJvc3Rvbi1ib3VuZC1tZWRpY2FsLXBsYW5lLXRoYXQtd2VudC1taXNzaW5nLW9mZi1uYW50dWNrZXQtdGhlLWJvc3Rvbi1nbG9iZS8iLCJzcmMiOiJUaGUgQm9zdG9uIEdsb2JlIn0seyJ0IjoiVGVubmVzc2VlIENvbW1pc3Npb25lciBSZXNpZ25zIEFmdGVyIEZhaWxlZCBFeGVjdXRpb24gb2YgQ2hyaXN0YSBQaWtlIiwidSI6Imh0dHBzOi8vZmxhbWVuZXQuaW8vMjAyNi8xMC8wNC90ZW5uZXNzZWUtY29tbWlzc2lvbmVyLXJlc2lnbnMtYWZ0ZXItZmFpbGVkLWV4ZWN1dGlvbi1vZi1jaHJpc3RhLXBpa2UtdGhlLW5ldy15b3JrLXRpbWVzLyIsInNyYyI6IlRoZSBOZXcgWW9yayBUaW1lcyJ9LHsidCI6IkFsaWVuIFRlY2hub2xvZ3kgRGVzZXJ2ZXMgYW4gWFBSSVpFIiwidSI6Imh0dHBzOi8vZmxhbWVuZXQuaW8vMjAyNi8xMC8wNC9hbGllbi10ZWNobm9sb2d5LWRlc2VydmVzLWFuLXhwcml6ZS1hdmktbG9lYi1tZWRpdW0vIiwic3JjIjoiQXZpIExvZWIg4oCTIE1lZGl1bSJ9LHsidCI6IllvdSBDYW4gTm93IFVzZSBZb3VyIEFuZHJvaWQgVGFibGV0IEFzIEEgR2FtaW5nIEhhbmRoZWxkIC0gSGVyZSdzIEhvdyIsInUiOiJodHRwczovL2ZsYW1lbmV0LmlvLzIwMjYvMTAvMDQveW91LWNhbi1ub3ctdXNlLXlvdXItYW5kcm9pZC10YWJsZXQtYXMtYS1nYW1pbmctaGFuZGhlbGQtaGVyZXMtaG93LWJnci1jb20vIiwic3JjIjoiYmdyLmNvbSJ9LHsidCI6IkNvcm5lbGwgcHJlc2lkZW50IHNheXMgcmFwZSBhbGxlZ2F0aW9ucyByYWlzZSDigJhoZWFydGJyZWFraW5nIGFuZCB0cm91YmxpbmcgaXNzdWVz4oCZIiwidSI6Imh0dHBzOi8vZmxhbWVuZXQuaW8vMjAyNi8xMC8wNC9jb3JuZWxsLXByZXNpZGVudC1zYXlzLXJhcGUtYWxsZWdhdGlvbnMtcmFpc2UtaGVhcnRicmVha2luZy1hbmQtdHJvdWJsaW5nLWlzc3Vlcy10aGUtd2FzaGluZ3Rvbi1wb3N0LyIsInNyYyI6IlRoZSBXYXNoaW5ndG9uIFBvc3QifSx7InQiOiLigJhGMSAyNeKAmSBBbmQgT3RoZXIgR2FtZXMgSm9pbmluZyBQUyBQbHVzIEluIE9jdG9iZXIgMjAyNiIsInUiOiJodHRwczovL2ZsYW1lbmV0LmlvLzIwMjYvMTAvMDQvZjEtMjUtYW5kLW90aGVyLWdhbWVzLWpvaW5pbmctcHMtcGx1cy1pbi1vY3RvYmVyLTIwMjYtYXVndXN0bWFuLXNpbmdhcG9yZS8iLCJzcmMiOiJBdWd1c3RNYW4gU2luZ2Fwb3JlIn0seyJ0IjoiRXhjbHVzaXZlIHwgTmV3IEFJIEN6YXIgVW52ZWlscyBHb2FscywgTWVtYmVycyBvZiBXaGl0ZSBIb3VzZSBUYXNrIEZvcmNlIiwidSI6Imh0dHBzOi8vZmxhbWVuZXQuaW8vMjAyNi8xMC8wNC9leGNsdXNpdmUtbmV3LWFpLWN6YXItdW52ZWlscy1nb2Fscy1tZW1iZXJzLW9mLXdoaXRlLWhvdXNlLXRhc2stZm9yY2Utd3NqLyIsInNyYyI6IldTSiJ9LHsidCI6IlBsYXkgUGxheVN0YXRpb24gMiBBbmQgWEJveCAxIEdhbWVzIE9uIFlvdXIgSmFpbGJyb2tlbiBQUzUiLCJ1IjoiaHR0cHM6Ly9mbGFtZW5ldC5pby8yMDI2LzEwLzA0L3BsYXktcGxheXN0YXRpb24tMi1hbmQteGJveC0xLWdhbWVzLW9uLXlvdXItamFpbGJyb2tlbi1wczUtaGFja2FkYXkvIiwic3JjIjoiSGFja2FkYXkifSx7InQiOiJUcnVtcCBhbm5vdW5jZXMgJDkwIHBheW1lbnRzIGZvciBzZW5pb3JzIG9uIE1lZGljYXJlIGFoZWFkIG9mIG1pZHRlcm0gZWxlY3Rpb25zIGZvciBwcmVtaXVtIGNvc3RzIiwidSI6Imh0dHBzOi8vZmxhbWVuZXQuaW8vMjAyNi8xMC8wNC90cnVtcC1hbm5vdW5jZXMtOTAtcGF5bWVudHMtZm9yLXNlbmlvcnMtb24tbWVkaWNhcmUtYWhlYWQtb2YtbWlkdGVybS1lbGVjdGlvbnMtZm9yLXByZW1pdW0tY29zdHMtYXAtbmV3cy8iLCJzcmMiOiJBUCBOZXdzIn0seyJ0IjoiTHVsYSwgQm9sc29uYXJvIGluIERlYWQgSGVhdCBhcyBCcmF6aWwgUHJlcGFyZXMgdG8gVm90ZSIsInUiOiJodHRwczovL2ZsYW1lbmV0LmlvLzIwMjYvMTAvMDQvbHVsYS1ib2xzb25hcm8taW4tZGVhZC1oZWF0LWFzLWJyYXppbC1wcmVwYXJlcy10by12b3RlLWJsb29tYmVyZy1jb20vIiwic3JjIjoiQmxvb21iZXJnLmNvbSJ9XSwibWFya2V0cyI6W3sic3ltIjoiXkRKSSIsIm5hbWUiOiJEb3cgSm9uZXMgSW5kdXN0cmlhbCBBdmVyYWdlIiwibGFzdCI6IjUxLDE3Ni45NiIsImNoZyI6IisyNTAuNDAiLCJwY3QiOiIrMC40OSJ9LHsic3ltIjoiXkdTUEMiLCJuYW1lIjoiUyZQIDUwMCIsImxhc3QiOiI3LDcyMi43MiIsImNoZyI6Iis1Ni4yNyIsInBjdCI6IiswLjczIn0seyJzeW0iOiJeSVhJQyIsIm5hbWUiOiJOQVNEQVEgQ29tcG9zaXRlIiwibGFzdCI6IjI3LDE5MC44NiIsImNoZyI6IiszMTkuMjciLCJwY3QiOiIrMS4xOSJ9LHsic3ltIjoiXkZUU0UiLCJuYW1lIjoiRlRTRSAxMDAgKFVLKSIsImxhc3QiOiIxMCw0NjEuOTUiLCJjaGciOiIrMzMuNjgiLCJwY3QiOiIrMC4zMiJ9LHsic3ltIjoiXkdEQVhJIiwibmFtZSI6IkRBWCAoR2VybWFueSkiLCJsYXN0IjoiMjUsMjMxLjIwIiwiY2hnIjoiKzI5MS44NSIsInBjdCI6IisxLjE3In0seyJzeW0iOiJeRkNISSIsIm5hbWUiOiJDQUMgNDAgKEZyYW5jZSkiLCJsYXN0IjoiNyw4OTcuMTkiLCJjaGciOiIrNjEuODgiLCJwY3QiOiIrMC43OSJ9LHsic3ltIjoiXk4yMjUiLCJuYW1lIjoiTmlra2VpIDIyNSAoSmFwYW4pIiwibGFzdCI6IjY4LDMwOS40NiIsImNoZyI6Ii02NDcuMjYiLCJwY3QiOiItMC45NCJ9LHsic3ltIjoiXkhTSSIsIm5hbWUiOiJIYW5nIFNlbmcgKEhvbmcgS29uZykiLCJsYXN0IjoiMjMsOTcyLjI5IiwiY2hnIjoiLTY0MC45OCIsInBjdCI6Ii0yLjYwIn0seyJzeW0iOiIwMDAwMDEuU1MiLCJuYW1lIjoiU2hhbmdoYWkgQ29tcG9zaXRlIChDaGluYSkiLCJsYXN0IjoiMyw4NDIuMTkiLCJjaGciOiIrMTEuNzQiLCJwY3QiOiIrMC4zMSJ9XSwid2VhdGhlciI6W3siY2l0eSI6Ik5ldyBZb3JrIiwiY2MiOiJVUyIsInR6IjoiQW1lcmljYS9OZXdfWW9yayIsImxhdCI6NDAuNzEsImxvbiI6LTc0LjAxLCJub3ciOnsiYyI6MTQsInNreSI6ImNsZWFyIn0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjE2LCJsbyI6MTMsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjIwLCJsbyI6MTEsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MTgsImxvIjoxMCwic2t5IjoiY2xlYXIifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjIyLCJsbyI6MTMsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjUsImxvIjoxNiwic2t5IjoiY2xvdWR5In1dfSx7ImNpdHkiOiJMb3MgQW5nZWxlcyIsImNjIjoiVVMiLCJ0eiI6IkFtZXJpY2EvTG9zX0FuZ2VsZXMiLCJsYXQiOjM0LjA1LCJsb24iOi0xMTguMjQsIm5vdyI6eyJjIjoyNSwic2t5IjoiY2xlYXIifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MzgsImxvIjoyNCwic2t5IjoiY2xlYXIifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjQwLCJsbyI6MjQsInNreSI6ImNsZWFyIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjozNiwibG8iOjI4LCJza3kiOiJjbGVhciJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MzUsImxvIjoyNywic2t5IjoiY2xlYXIifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjMzLCJsbyI6MjcsInNreSI6ImNsZWFyIn1dfSx7ImNpdHkiOiJDaGljYWdvIiwiY2MiOiJVUyIsInR6IjoiQW1lcmljYS9DaGljYWdvIiwibGF0Ijo0MS44OCwibG9uIjotODcuNjMsIm5vdyI6eyJjIjoxMCwic2t5IjoiY2xlYXIifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MTksImxvIjoxMCwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoxNSwibG8iOjksInNreSI6ImNsZWFyIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyMiwibG8iOjExLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI0LCJsbyI6MTYsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MTksImxvIjoxNCwic2t5IjoiY2xvdWR5In1dfSx7ImNpdHkiOiJIb3VzdG9uIiwiY2MiOiJVUyIsInR6IjoiQW1lcmljYS9DaGljYWdvIiwibGF0IjoyOS43NiwibG9uIjotOTUuMzcsIm5vdyI6eyJjIjoyNCwic2t5IjoiY2xvdWR5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjI2LCJsbyI6MjMsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjI5LCJsbyI6MjIsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MzMsImxvIjoyNCwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjozMywibG8iOjI1LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoyNywibG8iOjI0LCJza3kiOiJzdG9ybSJ9XX0seyJjaXR5IjoiUGhvZW5peCIsImNjIjoiVVMiLCJ0eiI6IkFtZXJpY2EvUGhvZW5peCIsImxhdCI6MzMuNDUsImxvbiI6LTExMi4wNywibm93Ijp7ImMiOjI2LCJza3kiOiJjbGVhciJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjozNywibG8iOjI1LCJza3kiOiJjbGVhciJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MzYsImxvIjoyNCwic2t5IjoiY2xlYXIifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjM1LCJsbyI6MjksInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MzcsImxvIjozMSwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjozNywibG8iOjI5LCJza3kiOiJjbGVhciJ9XX0seyJjaXR5IjoiU2FuIEZyYW5jaXNjbyIsImNjIjoiVVMiLCJ0eiI6IkFtZXJpY2EvTG9zX0FuZ2VsZXMiLCJsYXQiOjM3Ljc3LCJsb24iOi0xMjIuNDIsIm5vdyI6eyJjIjoyMCwic2t5IjoiY2xvdWR5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjM0LCJsbyI6MTgsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjcsImxvIjoxNCwic2t5IjoicGFydGx5In0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyOCwibG8iOjIwLCJza3kiOiJjbGVhciJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjcsImxvIjoxOCwic2t5IjoiY2xlYXIifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjI2LCJsbyI6MTksInNreSI6ImNsZWFyIn1dfSx7ImNpdHkiOiJTZWF0dGxlIiwiY2MiOiJVUyIsInR6IjoiQW1lcmljYS9Mb3NfQW5nZWxlcyIsImxhdCI6NDcuNjEsImxvbiI6LTEyMi4zMywibm93Ijp7ImMiOjEwLCJza3kiOiJjbGVhciJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyMCwibG8iOjksInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MTksImxvIjoxMCwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoxOSwibG8iOjExLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjIwLCJsbyI6MTAsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjEsImxvIjoxMCwic2t5IjoiY2xvdWR5In1dfSx7ImNpdHkiOiJEZW52ZXIiLCJjYyI6IlVTIiwidHoiOiJBbWVyaWNhL0RlbnZlciIsImxhdCI6MzkuNzQsImxvbiI6LTEwNC45OSwibm93Ijp7ImMiOjEwLCJza3kiOiJjbGVhciJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyOSwibG8iOjksInNreSI6ImNsZWFyIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjozMSwibG8iOjExLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjI5LCJsbyI6MjAsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjgsImxvIjoxOSwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoyOSwibG8iOjE5LCJza3kiOiJjbG91ZHkifV19LHsiY2l0eSI6IkRhbGxhcyIsImNjIjoiVVMiLCJ0eiI6IkFtZXJpY2EvQ2hpY2FnbyIsImxhdCI6MzIuNzgsImxvbiI6LTk2LjgsIm5vdyI6eyJjIjoyMiwic2t5IjoiY2xvdWR5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjI1LCJsbyI6MjIsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjI3LCJsbyI6MTgsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjksImxvIjoyMCwic2t5IjoiY2xlYXIifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjMxLCJsbyI6MjIsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MzAsImxvIjoyMywic2t5IjoiZHJpenpsZSJ9XX0seyJjaXR5IjoiQXRsYW50YSIsImNjIjoiVVMiLCJ0eiI6IkFtZXJpY2EvTmV3X1lvcmsiLCJsYXQiOjMzLjc1LCJsb24iOi04NC4zOSwibm93Ijp7ImMiOjIyLCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjcsImxvIjoyMiwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjcsImxvIjoyMSwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjcsImxvIjoxNywic2t5IjoiY2xlYXIifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI4LCJsbyI6MTcsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MzAsImxvIjoxOSwic2t5IjoiY2xvdWR5In1dfSx7ImNpdHkiOiJNaWFtaSIsImNjIjoiVVMiLCJ0eiI6IkFtZXJpY2EvTmV3X1lvcmsiLCJsYXQiOjI1Ljc2LCJsb24iOi04MC4xOSwibm93Ijp7ImMiOjI3LCJza3kiOiJjbGVhciJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyOSwibG8iOjI2LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjI5LCJsbyI6MjQsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MzAsImxvIjoyNSwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MzAsImxvIjoyNywic2t5Ijoic3Rvcm0ifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjMxLCJsbyI6MjcsInNreSI6ImRyaXp6bGUifV19LHsiY2l0eSI6IkJvc3RvbiIsImNjIjoiVVMiLCJ0eiI6IkFtZXJpY2EvTmV3X1lvcmsiLCJsYXQiOjQyLjM2LCJsb24iOi03MS4wNiwibm93Ijp7ImMiOjEwLCJza3kiOiJwYXJ0bHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MTYsImxvIjo4LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoxOSwibG8iOjksInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MTUsImxvIjo3LCJza3kiOiJjbGVhciJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjAsImxvIjo4LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjIyLCJsbyI6MTUsInNreSI6ImRyaXp6bGUifV19LHsiY2l0eSI6Ildhc2hpbmd0b24sIEQuQy4iLCJjYyI6IlVTIiwidHoiOiJBbWVyaWNhL05ld19Zb3JrIiwibGF0IjozOC45MSwibG9uIjotNzcuMDQsIm5vdyI6eyJjIjoxNiwic2t5IjoiY2xvdWR5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjE4LCJsbyI6MTUsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjIyLCJsbyI6MTIsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjEsImxvIjoxMSwic2t5IjoiY2xlYXIifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI1LCJsbyI6MTEsInNreSI6ImNsZWFyIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjozMCwibG8iOjE1LCJza3kiOiJjbG91ZHkifV19LHsiY2l0eSI6IkFuY2hvcmFnZSIsImNjIjoiVVMiLCJ0eiI6IkFtZXJpY2EvQW5jaG9yYWdlIiwibGF0Ijo2MS4yMiwibG9uIjotMTQ5LjksIm5vdyI6eyJjIjo0LCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6NiwibG8iOjMsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjEwLCJsbyI6NCwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6OSwibG8iOjMsInNreSI6InJhaW4ifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjgsImxvIjo1LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjo2LCJsbyI6NCwic2t5IjoiY2xvdWR5In1dfSx7ImNpdHkiOiJIb25vbHVsdSIsImNjIjoiVVMiLCJ0eiI6IlBhY2lmaWMvSG9ub2x1bHUiLCJsYXQiOjIxLjMxLCJsb24iOi0xNTcuODYsIm5vdyI6eyJjIjoyMywic2t5IjoiY2xlYXIifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjgsImxvIjoyMywic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjgsImxvIjoyMiwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjksImxvIjoyMiwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjgsImxvIjoyMiwic2t5IjoicGFydGx5In0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoyNywibG8iOjI0LCJza3kiOiJyYWluIn1dfSx7ImNpdHkiOiJUb3JvbnRvIiwiY2MiOiJDQSIsInR6IjoiQW1lcmljYS9Ub3JvbnRvIiwibGF0Ijo0My42NSwibG9uIjotNzkuMzgsIm5vdyI6eyJjIjoxMSwic2t5IjoicGFydGx5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjIwLCJsbyI6MTAsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MTQsImxvIjo2LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjE1LCJsbyI6Nywic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjoyNCwibG8iOjE0LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjE5LCJsbyI6MTIsInNreSI6ImRyaXp6bGUifV19LHsiY2l0eSI6Ik1vbnRyZWFsIiwiY2MiOiJDQSIsInR6IjoiQW1lcmljYS9Ub3JvbnRvIiwibGF0Ijo0NS41LCJsb24iOi03My41Nywibm93Ijp7ImMiOjUsInNreSI6ImNsb3VkeSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoxNiwibG8iOjQsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjEyLCJsbyI6NCwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MTIsImxvIjo0LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjE1LCJsbyI6Niwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MTcsImxvIjoxMSwic2t5IjoiZHJpenpsZSJ9XX0seyJjaXR5IjoiVmFuY291dmVyIiwiY2MiOiJDQSIsInR6IjoiQW1lcmljYS9WYW5jb3V2ZXIiLCJsYXQiOjQ5LjI4LCJsb24iOi0xMjMuMTIsIm5vdyI6eyJjIjoxMSwic2t5IjoiY2xlYXIifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MTgsImxvIjoxMSwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoxOCwibG8iOjEwLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjE2LCJsbyI6MTIsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MTcsImxvIjoxMiwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoxOCwibG8iOjEzLCJza3kiOiJjbG91ZHkifV19LHsiY2l0eSI6Ik1leGljbyBDaXR5IiwiY2MiOiJNWCIsInR6IjoiQW1lcmljYS9NZXhpY29fQ2l0eSIsImxhdCI6MTkuNDMsImxvbiI6LTk5LjEzLCJub3ciOnsiYyI6MTUsInNreSI6ImNsb3VkeSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyMCwibG8iOjE1LCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjIsImxvIjoxNCwic2t5Ijoic3Rvcm0ifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjIyLCJsbyI6MTQsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjoyMSwibG8iOjE1LCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjEsImxvIjoxNSwic2t5Ijoic3Rvcm0ifV19LHsiY2l0eSI6Ikd1YXRlbWFsYSBDaXR5IiwiY2MiOiJHVCIsInR6IjoiQW1lcmljYS9HdWF0ZW1hbGEiLCJsYXQiOjE0LjYzLCJsb24iOi05MC41MSwibm93Ijp7ImMiOjE3LCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjMsImxvIjoxNywic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjQsImxvIjoxNiwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjYsImxvIjoxNiwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjUsImxvIjoxNiwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjYsImxvIjoxNiwic2t5IjoicmFpbiJ9XX0seyJjaXR5IjoiUGFuYW1hIENpdHkiLCJjYyI6IlBBIiwidHoiOiJBbWVyaWNhL1BhbmFtYSIsImxhdCI6OC45OCwibG9uIjotNzkuNTIsIm5vdyI6eyJjIjoyNSwic2t5IjoiY2xvdWR5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjMxLCJsbyI6MjQsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjMyLCJsbyI6MjUsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjozMSwibG8iOjI1LCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjksImxvIjoyNCwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjksImxvIjoyNCwic2t5IjoiZHJpenpsZSJ9XX0seyJjaXR5IjoiSGF2YW5hIiwiY2MiOiJDVSIsInR6IjoiQW1lcmljYS9IYXZhbmEiLCJsYXQiOjIzLjExLCJsb24iOi04Mi4zNywibm93Ijp7ImMiOjI1LCJza3kiOiJjbGVhciJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjozMiwibG8iOjI1LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjozMiwibG8iOjI1LCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjozMiwibG8iOjI0LCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MzEsImxvIjoyNCwic2t5Ijoic3Rvcm0ifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjI5LCJsbyI6MjQsInNreSI6InN0b3JtIn1dfSx7ImNpdHkiOiJLaW5nc3RvbiIsImNjIjoiSk0iLCJ0eiI6IkFtZXJpY2EvSmFtYWljYSIsImxhdCI6MTguMDIsImxvbiI6LTc2LjgsIm5vdyI6eyJjIjoyNiwic2t5IjoicGFydGx5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjMxLCJsbyI6MjMsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjozMCwibG8iOjIzLCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MzIsImxvIjoyNCwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MzEsImxvIjoyMywic2t5Ijoic3Rvcm0ifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjI5LCJsbyI6MjIsInNreSI6InN0b3JtIn1dfSx7ImNpdHkiOiJTYW50byBEb21pbmdvIiwiY2MiOiJETyIsInR6IjoiQW1lcmljYS9TYW50b19Eb21pbmdvIiwibGF0IjoxOC40OSwibG9uIjotNjkuOTMsIm5vdyI6eyJjIjoyMywic2t5IjoiY2xvdWR5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjI5LCJsbyI6MjMsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjozMCwibG8iOjIzLCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MzEsImxvIjoyMywic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjozMiwibG8iOjI0LCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MzAsImxvIjoyNCwic2t5Ijoic3Rvcm0ifV19LHsiY2l0eSI6IlNhbiBKdWFuIiwiY2MiOiJQUiIsInR6IjoiQW1lcmljYS9QdWVydG9fUmljbyIsImxhdCI6MTguNDcsImxvbiI6LTY2LjExLCJub3ciOnsiYyI6MjUsInNreSI6ImNsb3VkeSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyOSwibG8iOjIzLCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjksImxvIjoyNSwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjksImxvIjoyNiwic2t5Ijoic3Rvcm0ifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI5LCJsbyI6MjYsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoyOSwibG8iOjI2LCJza3kiOiJkcml6emxlIn1dfSx7ImNpdHkiOiJTw6NvIFBhdWxvIiwiY2MiOiJCUiIsInR6IjoiQW1lcmljYS9TYW9fUGF1bG8iLCJsYXQiOi0yMy41NSwibG9uIjotNDYuNjMsIm5vdyI6eyJjIjoyMywic2t5IjoiY2xlYXIifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjQsImxvIjoxNywic2t5Ijoic3Rvcm0ifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjI4LCJsbyI6MTcsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyNCwibG8iOjE5LCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjYsImxvIjoxOCwic2t5Ijoic3Rvcm0ifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjMwLCJsbyI6MTcsInNreSI6ImRyaXp6bGUifV19LHsiY2l0eSI6IlJpbyBkZSBKYW5laXJvIiwiY2MiOiJCUiIsInR6IjoiQW1lcmljYS9TYW9fUGF1bG8iLCJsYXQiOi0yMi45MSwibG9uIjotNDMuMTcsIm5vdyI6eyJjIjoyNCwic2t5IjoicGFydGx5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjI4LCJsbyI6MTksInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjI5LCJsbyI6MjIsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjI5LCJsbyI6MjIsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI1LCJsbyI6MjIsInNreSI6InJhaW4ifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjI1LCJsbyI6MjIsInNreSI6ImNsb3VkeSJ9XX0seyJjaXR5IjoiQnJhc8OtbGlhIiwiY2MiOiJCUiIsInR6IjoiQW1lcmljYS9TYW9fUGF1bG8iLCJsYXQiOi0xNS43OSwibG9uIjotNDcuODgsIm5vdyI6eyJjIjoyMywic2t5IjoicGFydGx5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjI2LCJsbyI6MTksInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjI2LCJsbyI6MTgsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyNywibG8iOjE4LCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjozMCwibG8iOjE5LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjMwLCJsbyI6MjAsInNreSI6InBhcnRseSJ9XX0seyJjaXR5IjoiQnVlbm9zIEFpcmVzIiwiY2MiOiJBUiIsInR6IjoiQW1lcmljYS9BcmdlbnRpbmEvQnVlbm9zX0FpcmVzIiwibGF0IjotMzQuNiwibG9uIjotNTguMzgsIm5vdyI6eyJjIjoxNiwic2t5IjoiY2xlYXIifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjMsImxvIjoxMSwic2t5IjoicGFydGx5In0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyMCwibG8iOjE0LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjIwLCJsbyI6MTMsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjE4LCJsbyI6MTUsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjE4LCJsbyI6MTUsInNreSI6ImRyaXp6bGUifV19LHsiY2l0eSI6IlNhbnRpYWdvIiwiY2MiOiJDTCIsInR6IjoiQW1lcmljYS9TYW50aWFnbyIsImxhdCI6LTMzLjQ1LCJsb24iOi03MC42Nywibm93Ijp7ImMiOjExLCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MTksImxvIjo5LCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyMSwibG8iOjExLCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoxNiwibG8iOjEyLCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjoxNywibG8iOjExLCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoxOCwibG8iOjgsInNreSI6ImRyaXp6bGUifV19LHsiY2l0eSI6IkxpbWEiLCJjYyI6IlBFIiwidHoiOiJBbWVyaWNhL0xpbWEiLCJsYXQiOi0xMi4wNSwibG9uIjotNzcuMDQsIm5vdyI6eyJjIjoyMCwic2t5IjoicGFydGx5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjI1LCJsbyI6MjAsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjUsImxvIjoyMCwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyNiwibG8iOjIwLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI0LCJsbyI6MjAsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjUsImxvIjoyMSwic2t5IjoiY2xvdWR5In1dfSx7ImNpdHkiOiJCb2dvdMOhIiwiY2MiOiJDTyIsInR6IjoiQW1lcmljYS9Cb2dvdGEiLCJsYXQiOjQuNzEsImxvbiI6LTc0LjA3LCJub3ciOnsiYyI6MTMsInNreSI6ImNsb3VkeSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyMSwibG8iOjEwLCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyMywibG8iOjksInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjIyLCJsbyI6MTIsInNreSI6InJhaW4ifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjIxLCJsbyI6MTIsInNreSI6InJhaW4ifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjIwLCJsbyI6MTEsInNreSI6InJhaW4ifV19LHsiY2l0eSI6IkNhcmFjYXMiLCJjYyI6IlZFIiwidHoiOiJBbWVyaWNhL0NhcmFjYXMiLCJsYXQiOjEwLjQ4LCJsb24iOi02Ni45LCJub3ciOnsiYyI6MjQsInNreSI6ImNsb3VkeSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyNywibG8iOjE4LCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjksImxvIjoxOSwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjgsImxvIjoyMCwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjgsImxvIjoyMSwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjgsImxvIjoyMCwic2t5IjoiZHJpenpsZSJ9XX0seyJjaXR5IjoiUXVpdG8iLCJjYyI6IkVDIiwidHoiOiJBbWVyaWNhL0d1YXlhcXVpbCIsImxhdCI6LTAuMTgsImxvbiI6LTc4LjQ3LCJub3ciOnsiYyI6MTEsInNreSI6ImNsb3VkeSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoxOCwibG8iOjEwLCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoxOSwibG8iOjEwLCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyMCwibG8iOjEwLCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjoxOCwibG8iOjEwLCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoxNywibG8iOjEwLCJza3kiOiJyYWluIn1dfSx7ImNpdHkiOiJNb250ZXZpZGVvIiwiY2MiOiJVWSIsInR6IjoiQW1lcmljYS9Nb250ZXZpZGVvIiwibGF0IjotMzQuOSwibG9uIjotNTYuMTYsIm5vdyI6eyJjIjoxNCwic2t5IjoiY2xlYXIifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MTcsImxvIjoxMywic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoxNywibG8iOjEyLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjE2LCJsbyI6MTEsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MTYsImxvIjoxNCwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MTUsImxvIjoxNSwic2t5IjoiY2xvdWR5In1dfSx7ImNpdHkiOiJMYSBQYXoiLCJjYyI6IkJPIiwidHoiOiJBbWVyaWNhL0xhX1BheiIsImxhdCI6LTE2LjQ5LCJsb24iOi02OC4xMiwibm93Ijp7ImMiOjEwLCJza3kiOiJjbGVhciJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyMCwibG8iOjIsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjEsImxvIjoyLCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjIxLCJsbyI6MSwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjoyMiwibG8iOjEsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjMsImxvIjo0LCJza3kiOiJwYXJ0bHkifV19LHsiY2l0eSI6IkFzdW5jacOzbiIsImNjIjoiUFkiLCJ0eiI6IkFtZXJpY2EvQXN1bmNpb24iLCJsYXQiOi0yNS4yNiwibG9uIjotNTcuNTgsIm5vdyI6eyJjIjoyMywic2t5IjoicGFydGx5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjI4LCJsbyI6MTgsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjI1LCJsbyI6MjEsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyNywibG8iOjIwLCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjozMSwibG8iOjIzLCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjksImxvIjoyMiwic2t5Ijoic3Rvcm0ifV19LHsiY2l0eSI6IkxvbmRvbiIsImNjIjoiR0IiLCJ0eiI6IkV1cm9wZS9Mb25kb24iLCJsYXQiOjUxLjUxLCJsb24iOi0wLjEzLCJub3ciOnsiYyI6MTgsInNreSI6ImNsZWFyIn0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjIxLCJsbyI6MTIsInNreSI6InBhcnRseSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjIsImxvIjoxMywic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoxOCwibG8iOjE0LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjE4LCJsbyI6MTEsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjE1LCJsbyI6OCwic2t5IjoicGFydGx5In1dfSx7ImNpdHkiOiJEdWJsaW4iLCJjYyI6IklFIiwidHoiOiJFdXJvcGUvRHVibGluIiwibGF0Ijo1My4zNSwibG9uIjotNi4yNiwibm93Ijp7ImMiOjE4LCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MTksImxvIjoxMiwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyMCwibG8iOjE3LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjE3LCJsbyI6MTEsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjEzLCJsbyI6OSwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoxNCwibG8iOjcsInNreSI6ImRyaXp6bGUifV19LHsiY2l0eSI6IlBhcmlzIiwiY2MiOiJGUiIsInR6IjoiRXVyb3BlL1BhcmlzIiwibGF0Ijo0OC44NiwibG9uIjoyLjM1LCJub3ciOnsiYyI6MjIsInNreSI6InBhcnRseSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyNCwibG8iOjE0LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjI0LCJsbyI6MTMsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjQsImxvIjoxMywic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjoxNiwibG8iOjEyLCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MTQsImxvIjo5LCJza3kiOiJyYWluIn1dfSx7ImNpdHkiOiJCZXJsaW4iLCJjYyI6IkRFIiwidHoiOiJFdXJvcGUvQmVybGluIiwibGF0Ijo1Mi41MiwibG9uIjoxMy40LCJub3ciOnsiYyI6MjAsInNreSI6InBhcnRseSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyMCwibG8iOjEwLCJza3kiOiJmb2cifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjIxLCJsbyI6OSwic2t5IjoiZm9nIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyMiwibG8iOjExLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjIzLCJsbyI6MTAsInNreSI6ImZvZyJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MTgsImxvIjoxMSwic2t5IjoicmFpbiJ9XX0seyJjaXR5IjoiTWFkcmlkIiwiY2MiOiJFUyIsInR6IjoiRXVyb3BlL01hZHJpZCIsImxhdCI6NDAuNDIsImxvbiI6LTMuNywibm93Ijp7ImMiOjE5LCJza3kiOiJyYWluIn0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjIwLCJsbyI6MTYsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyMCwibG8iOjE2LCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjAsImxvIjoxNiwic2t5Ijoic3Rvcm0ifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjIxLCJsbyI6MTUsInNreSI6InJhaW4ifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjIwLCJsbyI6MTQsInNreSI6InBhcnRseSJ9XX0seyJjaXR5IjoiQmFyY2Vsb25hIiwiY2MiOiJFUyIsInR6IjoiRXVyb3BlL01hZHJpZCIsImxhdCI6NDEuMzksImxvbiI6Mi4xNywibm93Ijp7ImMiOjIyLCJza3kiOiJyYWluIn0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjIyLCJsbyI6MTksInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyNSwibG8iOjIwLCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjcsImxvIjoxOSwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjUsImxvIjoyMCwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjMsImxvIjoxOCwic2t5IjoicmFpbiJ9XX0seyJjaXR5IjoiTGlzYm9uIiwiY2MiOiJQVCIsInR6IjoiRXVyb3BlL0xpc2JvbiIsImxhdCI6MzguNzIsImxvbiI6LTkuMTQsIm5vdyI6eyJjIjoyMywic2t5IjoiY2xvdWR5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjI1LCJsbyI6MjAsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyNiwibG8iOjE5LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjIzLCJsbyI6MTksInNreSI6InJhaW4ifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjIzLCJsbyI6MTcsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjYsImxvIjoxNywic2t5IjoiY2xlYXIifV19LHsiY2l0eSI6IlJvbWUiLCJjYyI6IklUIiwidHoiOiJFdXJvcGUvUm9tZSIsImxhdCI6NDEuOSwibG9uIjoxMi41LCJub3ciOnsiYyI6MjcsInNreSI6InBhcnRseSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyNywibG8iOjE4LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjI2LCJsbyI6MTcsInNreSI6InJhaW4ifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjIxLCJsbyI6MTgsInNreSI6InJhaW4ifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI2LCJsbyI6MTcsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjMsImxvIjoyMCwic2t5Ijoic3Rvcm0ifV19LHsiY2l0eSI6IkFtc3RlcmRhbSIsImNjIjoiTkwiLCJ0eiI6IkV1cm9wZS9BbXN0ZXJkYW0iLCJsYXQiOjUyLjM3LCJsb24iOjQuOSwibm93Ijp7ImMiOjE5LCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjAsImxvIjoxNSwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyMSwibG8iOjE0LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjIyLCJsbyI6MTUsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MTksImxvIjoxMywic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MTMsImxvIjoxMCwic2t5IjoiZHJpenpsZSJ9XX0seyJjaXR5IjoiQnJ1c3NlbHMiLCJjYyI6IkJFIiwidHoiOiJFdXJvcGUvQnJ1c3NlbHMiLCJsYXQiOjUwLjg1LCJsb24iOjQuMzUsIm5vdyI6eyJjIjoyMiwic2t5IjoiY2xlYXIifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjMsImxvIjoxNCwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyMywibG8iOjE1LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjIzLCJsbyI6MTQsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjIsImxvIjoxMSwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MTYsImxvIjo4LCJza3kiOiJyYWluIn1dfSx7ImNpdHkiOiJadXJpY2giLCJjYyI6IkNIIiwidHoiOiJFdXJvcGUvWnVyaWNoIiwibGF0Ijo0Ny4zOCwibG9uIjo4LjU0LCJub3ciOnsiYyI6MjMsInNreSI6InBhcnRseSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyNCwibG8iOjExLCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyMiwibG8iOjE1LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjIyLCJsbyI6MTEsInNreSI6ImZvZyJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjMsImxvIjoxMSwic2t5IjoiZm9nIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoxNywibG8iOjgsInNreSI6InJhaW4ifV19LHsiY2l0eSI6IlZpZW5uYSIsImNjIjoiQVQiLCJ0eiI6IkV1cm9wZS9WaWVubmEiLCJsYXQiOjQ4LjIxLCJsb24iOjE2LjM3LCJub3ciOnsiYyI6MjIsInNreSI6ImNsZWFyIn0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjIzLCJsbyI6MTAsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjMsImxvIjoxNSwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyNCwibG8iOjEyLCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI0LCJsbyI6MTAsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MTksImxvIjoxMSwic2t5IjoicmFpbiJ9XX0seyJjaXR5IjoiUHJhZ3VlIiwiY2MiOiJDWiIsInR6IjoiRXVyb3BlL1ByYWd1ZSIsImxhdCI6NTAuMDgsImxvbiI6MTQuNDQsIm5vdyI6eyJjIjoxOSwic2t5IjoicGFydGx5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjIwLCJsbyI6OCwic2t5IjoiZm9nIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyMCwibG8iOjEwLCJza3kiOiJmb2cifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjIxLCJsbyI6OCwic2t5IjoiZm9nIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjoyMiwibG8iOjgsInNreSI6ImZvZyJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MTcsImxvIjo2LCJza3kiOiJyYWluIn1dfSx7ImNpdHkiOiJXYXJzYXciLCJjYyI6IlBMIiwidHoiOiJFdXJvcGUvV2Fyc2F3IiwibGF0Ijo1Mi4yMywibG9uIjoyMS4wMSwibm93Ijp7ImMiOjE4LCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MTksImxvIjoxMSwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MTgsImxvIjoxMiwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyMSwibG8iOjEzLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjIwLCJsbyI6OSwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoyMiwibG8iOjExLCJza3kiOiJkcml6emxlIn1dfSx7ImNpdHkiOiJCdWRhcGVzdCIsImNjIjoiSFUiLCJ0eiI6IkV1cm9wZS9CdWRhcGVzdCIsImxhdCI6NDcuNSwibG9uIjoxOS4wNCwibm93Ijp7ImMiOjIzLCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjQsImxvIjoxMSwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyNSwibG8iOjEzLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjI1LCJsbyI6MTUsInNreSI6InBhcnRseSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjMsImxvIjo5LCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjI1LCJsbyI6MTIsInNreSI6InJhaW4ifV19LHsiY2l0eSI6IkJlbGdyYWRlIiwiY2MiOiJSUyIsInR6IjoiRXVyb3BlL0JlbGdyYWRlIiwibGF0Ijo0NC43OSwibG9uIjoyMC40NSwibm93Ijp7ImMiOjIzLCJza3kiOiJjbGVhciJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyNCwibG8iOjE0LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjI0LCJsbyI6MTMsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjUsImxvIjoxNCwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjoyNCwibG8iOjExLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjI2LCJsbyI6MTMsInNreSI6InBhcnRseSJ9XX0seyJjaXR5IjoiQnVjaGFyZXN0IiwiY2MiOiJSTyIsInR6IjoiRXVyb3BlL0J1Y2hhcmVzdCIsImxhdCI6NDQuNDMsImxvbiI6MjYuMSwibm93Ijp7ImMiOjIzLCJza3kiOiJjbGVhciJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyMywibG8iOjUsInNreSI6ImNsZWFyIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyNCwibG8iOjYsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjUsImxvIjo3LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI1LCJsbyI6OSwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoyNSwibG8iOjEwLCJza3kiOiJjbG91ZHkifV19LHsiY2l0eSI6IkF0aGVucyIsImNjIjoiR1IiLCJ0eiI6IkV1cm9wZS9BdGhlbnMiLCJsYXQiOjM3Ljk4LCJsb24iOjIzLjczLCJub3ciOnsiYyI6MjIsInNreSI6InBhcnRseSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyMiwibG8iOjE1LCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjI0LCJsbyI6MTQsInNreSI6ImNsZWFyIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyNiwibG8iOjE0LCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI2LCJsbyI6MTMsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjUsImxvIjoxNCwic2t5IjoicGFydGx5In1dfSx7ImNpdHkiOiJJc3RhbmJ1bCIsImNjIjoiVFIiLCJ0eiI6IkV1cm9wZS9Jc3RhbmJ1bCIsImxhdCI6NDEuMDEsImxvbiI6MjguOTgsIm5vdyI6eyJjIjoyMCwic2t5IjoicGFydGx5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjIxLCJsbyI6MTUsInNreSI6InBhcnRseSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjEsImxvIjoxNCwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyMSwibG8iOjE1LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjIyLCJsbyI6MTUsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjAsImxvIjoxMywic2t5IjoiY2xvdWR5In1dfSx7ImNpdHkiOiJLeWl2IiwiY2MiOiJVQSIsInR6IjoiRXVyb3BlL0tpZXYiLCJsYXQiOjUwLjQ1LCJsb24iOjMwLjUyLCJub3ciOnsiYyI6MTgsInNreSI6ImNsb3VkeSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoxOCwibG8iOjYsInNreSI6ImZvZyJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MTgsImxvIjoxMSwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoxOCwibG8iOjEwLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjIxLCJsbyI6MTAsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjMsImxvIjoxMywic2t5IjoiY2xvdWR5In1dfSx7ImNpdHkiOiJNb3Njb3ciLCJjYyI6IlJVIiwidHoiOiJFdXJvcGUvTW9zY293IiwibGF0Ijo1NS43NiwibG9uIjozNy42Miwibm93Ijp7ImMiOjEyLCJza3kiOiJwYXJ0bHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MTIsImxvIjoyLCJza3kiOiJmb2cifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjEzLCJsbyI6OSwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MTMsImxvIjo4LCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjoxNCwibG8iOjksInNreSI6InBhcnRseSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MTYsImxvIjo2LCJza3kiOiJjbG91ZHkifV19LHsiY2l0eSI6IlN0b2NraG9sbSIsImNjIjoiU0UiLCJ0eiI6IkV1cm9wZS9TdG9ja2hvbG0iLCJsYXQiOjU5LjMzLCJsb24iOjE4LjA3LCJub3ciOnsiYyI6MTUsInNreSI6ImNsb3VkeSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoxNiwibG8iOjEyLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjE2LCJsbyI6MTIsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjE2LCJsbyI6MTEsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MTMsImxvIjo4LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjEzLCJsbyI6MTAsInNreSI6ImRyaXp6bGUifV19LHsiY2l0eSI6Ik9zbG8iLCJjYyI6Ik5PIiwidHoiOiJFdXJvcGUvT3NsbyIsImxhdCI6NTkuOTEsImxvbiI6MTAuNzUsIm5vdyI6eyJjIjoxNCwic2t5IjoiY2xvdWR5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjE1LCJsbyI6OCwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MTYsImxvIjoxMiwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MTcsImxvIjo5LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjEzLCJsbyI6OCwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjo5LCJsbyI6Nywic2t5IjoiZHJpenpsZSJ9XX0seyJjaXR5IjoiQ29wZW5oYWdlbiIsImNjIjoiREsiLCJ0eiI6IkV1cm9wZS9Db3BlbmhhZ2VuIiwibGF0Ijo1NS42OCwibG9uIjoxMi41Nywibm93Ijp7ImMiOjE2LCJza3kiOiJwYXJ0bHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MTcsImxvIjoxMiwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyMCwibG8iOjEzLCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoxOCwibG8iOjEyLCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjoxNSwibG8iOjExLCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoxNSwibG8iOjExLCJza3kiOiJkcml6emxlIn1dfSx7ImNpdHkiOiJIZWxzaW5raSIsImNjIjoiRkkiLCJ0eiI6IkV1cm9wZS9IZWxzaW5raSIsImxhdCI6NjAuMTcsImxvbiI6MjQuOTQsIm5vdyI6eyJjIjoxNCwic2t5IjoiY2xvdWR5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjE1LCJsbyI6MTAsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjE0LCJsbyI6OSwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MTUsImxvIjoxMiwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MTQsImxvIjo4LCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjEzLCJsbyI6NSwic2t5IjoiZHJpenpsZSJ9XX0seyJjaXR5IjoiUmV5a2phdsOtayIsImNjIjoiSVMiLCJ0eiI6IkF0bGFudGljL1JleWtqYXZpayIsImxhdCI6NjQuMTUsImxvbiI6LTIxLjk0LCJub3ciOnsiYyI6OSwic2t5IjoiY2xvdWR5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjEwLCJsbyI6Nywic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6NywibG8iOjMsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjksImxvIjoyLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjcsImxvIjoxLCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjo4LCJsbyI6NSwic2t5IjoicmFpbiJ9XX0seyJjaXR5IjoiQ2Fpcm8iLCJjYyI6IkVHIiwidHoiOiJBZnJpY2EvQ2Fpcm8iLCJsYXQiOjMwLjA0LCJsb24iOjMxLjI0LCJub3ciOnsiYyI6MzAsInNreSI6InBhcnRseSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjozMCwibG8iOjE4LCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjI5LCJsbyI6MTksInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjksImxvIjoyMCwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjozMCwibG8iOjE5LCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjMxLCJsbyI6MjAsInNreSI6InBhcnRseSJ9XX0seyJjaXR5IjoiQ2FzYWJsYW5jYSIsImNjIjoiTUEiLCJ0eiI6IkFmcmljYS9DYXNhYmxhbmNhIiwibGF0IjozMy41NywibG9uIjotNy41OSwibm93Ijp7ImMiOjI5LCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MzAsImxvIjoyMSwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjozMCwibG8iOjIzLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjI5LCJsbyI6MjIsInNreSI6InJhaW4ifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI1LCJsbyI6MjEsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjUsImxvIjoxOSwic2t5IjoiY2xvdWR5In1dfSx7ImNpdHkiOiJBbGdpZXJzIiwiY2MiOiJEWiIsInR6IjoiQWZyaWNhL0FsZ2llcnMiLCJsYXQiOjM2Ljc1LCJsb24iOjMuMDYsIm5vdyI6eyJjIjozMiwic2t5IjoiY2xvdWR5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjMzLCJsbyI6MjUsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjgsImxvIjoyMywic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyOSwibG8iOjIyLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI4LCJsbyI6MjMsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjcsImxvIjoyMSwic2t5IjoicmFpbiJ9XX0seyJjaXR5IjoiVHVuaXMiLCJjYyI6IlROIiwidHoiOiJBZnJpY2EvVHVuaXMiLCJsYXQiOjM2LjgxLCJsb24iOjEwLjE4LCJub3ciOnsiYyI6MzAsInNreSI6InBhcnRseSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjozMCwibG8iOjIxLCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjMyLCJsbyI6MjIsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MzMsImxvIjoyMiwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjozMiwibG8iOjIxLCJza3kiOiJmb2cifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjMxLCJsbyI6MjIsInNreSI6InN0b3JtIn1dfSx7ImNpdHkiOiJEYWthciIsImNjIjoiU04iLCJ0eiI6IkFmcmljYS9EYWthciIsImxhdCI6MTQuNzIsImxvbiI6LTE3LjQ3LCJub3ciOnsiYyI6MjksInNreSI6InBhcnRseSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyOSwibG8iOjIzLCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjksImxvIjoyNSwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjksImxvIjoyNiwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MzAsImxvIjoyNSwic2t5Ijoic3Rvcm0ifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjMwLCJsbyI6MjQsInNreSI6InJhaW4ifV19LHsiY2l0eSI6IkFiaWRqYW4iLCJjYyI6IkNJIiwidHoiOiJBZnJpY2EvQWJpZGphbiIsImxhdCI6NS4zNiwibG9uIjotNC4wMSwibm93Ijp7ImMiOjI5LCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjksImxvIjoyNSwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjksImxvIjoyNCwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjgsImxvIjoyNCwic2t5Ijoic3Rvcm0ifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI4LCJsbyI6MjMsInNreSI6InJhaW4ifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjI5LCJsbyI6MjQsInNreSI6ImRyaXp6bGUifV19LHsiY2l0eSI6IkFjY3JhIiwiY2MiOiJHSCIsInR6IjoiQWZyaWNhL0FjY3JhIiwibGF0Ijo1LjYsImxvbiI6LTAuMTksIm5vdyI6eyJjIjozMCwic2t5IjoicGFydGx5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjMwLCJsbyI6MjUsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjMwLCJsbyI6MjQsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjMxLCJsbyI6MjQsInNreSI6InJhaW4ifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjMwLCJsbyI6MjQsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoyOCwibG8iOjI0LCJza3kiOiJkcml6emxlIn1dfSx7ImNpdHkiOiJMYWdvcyIsImNjIjoiTkciLCJ0eiI6IkFmcmljYS9MYWdvcyIsImxhdCI6Ni41MiwibG9uIjozLjM4LCJub3ciOnsiYyI6MjgsInNreSI6InBhcnRseSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyOCwibG8iOjI1LCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyOCwibG8iOjI1LCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyOCwibG8iOjI0LCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjgsImxvIjoyNSwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjksImxvIjoyNSwic2t5IjoiZHJpenpsZSJ9XX0seyJjaXR5IjoiS2hhcnRvdW0iLCJjYyI6IlNEIiwidHoiOiJBZnJpY2EvS2hhcnRvdW0iLCJsYXQiOjE1LjUsImxvbiI6MzIuNTYsIm5vdyI6eyJjIjo0Miwic2t5IjoiY2xlYXIifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6NDIsImxvIjozMSwic2t5IjoicGFydGx5In0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjo0MSwibG8iOjMxLCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjQwLCJsbyI6MzAsInNreSI6ImNsZWFyIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjo0MSwibG8iOjMwLCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjM4LCJsbyI6MjgsInNreSI6ImRyaXp6bGUifV19LHsiY2l0eSI6IkFkZGlzIEFiYWJhIiwiY2MiOiJFVCIsInR6IjoiQWZyaWNhL0FkZGlzX0FiYWJhIiwibGF0Ijo5LjAzLCJsb24iOjM4Ljc0LCJub3ciOnsiYyI6MjMsInNreSI6ImNsZWFyIn0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjIzLCJsbyI6MTAsInNreSI6InBhcnRseSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjMsImxvIjoxMCwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjMsImxvIjoxMCwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjIsImxvIjo5LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjIyLCJsbyI6OCwic2t5IjoiY2xvdWR5In1dfSx7ImNpdHkiOiJLYW1wYWxhIiwiY2MiOiJVRyIsInR6IjoiQWZyaWNhL0thbXBhbGEiLCJsYXQiOjAuMzUsImxvbiI6MzIuNTgsIm5vdyI6eyJjIjoyNywic2t5IjoiY2xlYXIifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjgsImxvIjoxOSwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjgsImxvIjoxOSwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjksImxvIjoxOSwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjgsImxvIjoxOSwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjcsImxvIjoxOSwic2t5IjoiZHJpenpsZSJ9XX0seyJjaXR5IjoiTmFpcm9iaSIsImNjIjoiS0UiLCJ0eiI6IkFmcmljYS9OYWlyb2JpIiwibGF0IjotMS4yOSwibG9uIjozNi44Miwibm93Ijp7ImMiOjI3LCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjcsImxvIjoxNiwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjgsImxvIjoxNiwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjgsImxvIjoxNiwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjUsImxvIjoxNiwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjQsImxvIjoxNiwic2t5IjoiZHJpenpsZSJ9XX0seyJjaXR5IjoiS2luc2hhc2EiLCJjYyI6IkNEIiwidHoiOiJBZnJpY2EvS2luc2hhc2EiLCJsYXQiOi00LjQ0LCJsb24iOjE1LjI3LCJub3ciOnsiYyI6MzEsInNreSI6ImNsb3VkeSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjozMiwibG8iOjIzLCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjozMiwibG8iOjIyLCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjozMSwibG8iOjIyLCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjozMCwibG8iOjIyLCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjozMiwibG8iOjIyLCJza3kiOiJyYWluIn1dfSx7ImNpdHkiOiJEYXIgZXMgU2FsYWFtIiwiY2MiOiJUWiIsInR6IjoiQWZyaWNhL0Rhcl9lc19TYWxhYW0iLCJsYXQiOi02Ljc5LCJsb24iOjM5LjIxLCJub3ciOnsiYyI6MjksInNreSI6InBhcnRseSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjozMCwibG8iOjI1LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjozMCwibG8iOjIzLCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjozMCwibG8iOjI0LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjozMCwibG8iOjI0LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjozMSwibG8iOjIzLCJza3kiOiJkcml6emxlIn1dfSx7ImNpdHkiOiJMdWFuZGEiLCJjYyI6IkFPIiwidHoiOiJBZnJpY2EvTHVhbmRhIiwibGF0IjotOC44NCwibG9uIjoxMy4yOSwibm93Ijp7ImMiOjI1LCJza3kiOiJwYXJ0bHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjUsImxvIjoyMiwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjYsImxvIjoyMiwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyNiwibG8iOjIyLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI2LCJsbyI6MjIsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjYsImxvIjoyMiwic2t5IjoiY2xvdWR5In1dfSx7ImNpdHkiOiJIYXJhcmUiLCJjYyI6IlpXIiwidHoiOiJBZnJpY2EvSGFyYXJlIiwibGF0IjotMTcuODMsImxvbiI6MzEuMDMsIm5vdyI6eyJjIjoyMywic2t5IjoiY2xvdWR5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjIzLCJsbyI6MTIsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjQsImxvIjoxMywic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjQsImxvIjoxNiwic2t5Ijoic3Rvcm0ifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI2LCJsbyI6MTUsInNreSI6InJhaW4ifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjI2LCJsbyI6MTYsInNreSI6ImRyaXp6bGUifV19LHsiY2l0eSI6IkFudGFuYW5hcml2byIsImNjIjoiTUciLCJ0eiI6IkluZGlhbi9BbnRhbmFuYXJpdm8iLCJsYXQiOi0xOC44OCwibG9uIjo0Ny41MSwibm93Ijp7ImMiOjI2LCJza3kiOiJjbGVhciJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyNiwibG8iOjE1LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjI2LCJsbyI6MTcsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjI0LCJsbyI6MTUsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjYsImxvIjoxNiwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjUsImxvIjoxNCwic2t5IjoiZHJpenpsZSJ9XX0seyJjaXR5IjoiSm9oYW5uZXNidXJnIiwiY2MiOiJaQSIsInR6IjoiQWZyaWNhL0pvaGFubmVzYnVyZyIsImxhdCI6LTI2LjIsImxvbiI6MjguMDUsIm5vdyI6eyJjIjoxOSwic2t5IjoicGFydGx5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjE5LCJsbyI6MTAsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjE5LCJsbyI6MTEsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjIxLCJsbyI6MTEsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI0LCJsbyI6MTMsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjQsImxvIjoxMywic2t5IjoiY2xvdWR5In1dfSx7ImNpdHkiOiJDYXBlIFRvd24iLCJjYyI6IlpBIiwidHoiOiJBZnJpY2EvSm9oYW5uZXNidXJnIiwibGF0IjotMzMuOTIsImxvbiI6MTguNDIsIm5vdyI6eyJjIjoyOCwic2t5IjoiY2xlYXIifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjgsImxvIjoxMywic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyNiwibG8iOjE1LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjE3LCJsbyI6MTIsInNreSI6InJhaW4ifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjIwLCJsbyI6MTIsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MTgsImxvIjoxMiwic2t5IjoiY2xvdWR5In1dfSx7ImNpdHkiOiJUb2t5byIsImNjIjoiSlAiLCJ0eiI6IkFzaWEvVG9reW8iLCJsYXQiOjM1LjY4LCJsb24iOjEzOS42OSwibm93Ijp7ImMiOjE5LCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjMsImxvIjoxNiwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyMywibG8iOjE3LCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjI3LCJsbyI6MTksInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjUsImxvIjoxNywic2t5IjoicGFydGx5In0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoyMywibG8iOjE1LCJza3kiOiJkcml6emxlIn1dfSx7ImNpdHkiOiJTZW91bCIsImNjIjoiS1IiLCJ0eiI6IkFzaWEvU2VvdWwiLCJsYXQiOjM3LjU3LCJsb24iOjEyNi45OCwibm93Ijp7ImMiOjE5LCJza3kiOiJwYXJ0bHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjMsImxvIjoxNCwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MTksImxvIjoxNCwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjAsImxvIjoxMSwic2t5IjoiY2xlYXIifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjIyLCJsbyI6MTIsInNreSI6ImNsZWFyIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoyNCwibG8iOjEyLCJza3kiOiJjbGVhciJ9XX0seyJjaXR5IjoiQmVpamluZyIsImNjIjoiQ04iLCJ0eiI6IkFzaWEvU2hhbmdoYWkiLCJsYXQiOjM5LjksImxvbiI6MTE2LjQxLCJub3ciOnsiYyI6MTcsInNreSI6ImNsZWFyIn0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjIxLCJsbyI6MTUsInNreSI6InBhcnRseSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjUsImxvIjoxMiwic2t5IjoicGFydGx5In0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyNSwibG8iOjE0LCJza3kiOiJjbGVhciJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjcsImxvIjoxNCwic2t5IjoiY2xlYXIifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjI3LCJsbyI6MTUsInNreSI6ImNsZWFyIn1dfSx7ImNpdHkiOiJTaGFuZ2hhaSIsImNjIjoiQ04iLCJ0eiI6IkFzaWEvU2hhbmdoYWkiLCJsYXQiOjMxLjIzLCJsb24iOjEyMS40Nywibm93Ijp7ImMiOjIwLCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjEsImxvIjoxOSwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjIsImxvIjoxNiwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyMiwibG8iOjEzLCJza3kiOiJjbGVhciJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjUsImxvIjoxMywic2t5IjoiY2xlYXIifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjI1LCJsbyI6MTUsInNreSI6ImNsb3VkeSJ9XX0seyJjaXR5IjoiSG9uZyBLb25nIiwiY2MiOiJISyIsInR6IjoiQXNpYS9Ib25nX0tvbmciLCJsYXQiOjIyLjMyLCJsb24iOjExNC4xNywibm93Ijp7ImMiOjI0LCJza3kiOiJkcml6emxlIn0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjMwLCJsbyI6MjQsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjozMCwibG8iOjIyLCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjI2LCJsbyI6MjAsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjgsImxvIjoyMCwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoyOCwibG8iOjIzLCJza3kiOiJkcml6emxlIn1dfSx7ImNpdHkiOiJUYWlwZWkiLCJjYyI6IlRXIiwidHoiOiJBc2lhL1RhaXBlaSIsImxhdCI6MjUuMDMsImxvbiI6MTIxLjU3LCJub3ciOnsiYyI6MjYsInNreSI6InBhcnRseSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjozMywibG8iOjI0LCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyOCwibG8iOjIzLCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjUsImxvIjoyMiwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjYsImxvIjoyMSwic2t5IjoicGFydGx5In0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoyOCwibG8iOjIxLCJza3kiOiJwYXJ0bHkifV19LHsiY2l0eSI6IlVsYWFuYmFhdGFyIiwiY2MiOiJNTiIsInR6IjoiQXNpYS9VbGFhbmJhYXRhciIsImxhdCI6NDcuODksImxvbiI6MTA2LjkxLCJub3ciOnsiYyI6Mywic2t5IjoiY2xlYXIifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6OSwibG8iOi0yLCJza3kiOiJjbGVhciJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MTMsImxvIjoxLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjE4LCJsbyI6Miwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjoxMiwibG8iOjMsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MTcsImxvIjoyLCJza3kiOiJjbG91ZHkifV19LHsiY2l0eSI6Ik1hbmlsYSIsImNjIjoiUEgiLCJ0eiI6IkFzaWEvTWFuaWxhIiwibGF0IjoxNC42LCJsb24iOjEyMC45OCwibm93Ijp7ImMiOjI3LCJza3kiOiJkcml6emxlIn0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjMzLCJsbyI6MjcsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjozMiwibG8iOjI2LCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MzIsImxvIjoyNSwic2t5Ijoic3Rvcm0ifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjMxLCJsbyI6MjUsInNreSI6InJhaW4ifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjI5LCJsbyI6MjQsInNreSI6InN0b3JtIn1dfSx7ImNpdHkiOiJIYW5vaSIsImNjIjoiVk4iLCJ0eiI6IkFzaWEvSG9fQ2hpX01pbmgiLCJsYXQiOjIxLjAzLCJsb24iOjEwNS44Mywibm93Ijp7ImMiOjI2LCJza3kiOiJyYWluIn0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjMxLCJsbyI6MjQsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyNSwibG8iOjIyLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjI4LCJsbyI6MjIsInNreSI6InBhcnRseSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjksImxvIjoyMCwic2t5IjoiY2xlYXIifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjI5LCJsbyI6MjAsInNreSI6ImNsb3VkeSJ9XX0seyJjaXR5IjoiSG8gQ2hpIE1pbmggQ2l0eSIsImNjIjoiVk4iLCJ0eiI6IkFzaWEvSG9fQ2hpX01pbmgiLCJsYXQiOjEwLjgyLCJsb24iOjEwNi42Mywibm93Ijp7ImMiOjI2LCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MzIsImxvIjoyNSwic2t5Ijoic3Rvcm0ifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjMyLCJsbyI6MjUsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjozMSwibG8iOjI1LCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MzAsImxvIjoyNCwic2t5Ijoic3Rvcm0ifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjMxLCJsbyI6MjQsInNreSI6InN0b3JtIn1dfSx7ImNpdHkiOiJCYW5na29rIiwiY2MiOiJUSCIsInR6IjoiQXNpYS9CYW5na29rIiwibGF0IjoxMy43NiwibG9uIjoxMDAuNSwibm93Ijp7ImMiOjI5LCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MzYsImxvIjoyNiwic2t5Ijoic3Rvcm0ifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjMzLCJsbyI6MjYsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjMxLCJsbyI6MjQsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjozMSwibG8iOjI0LCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MzEsImxvIjoyNCwic2t5IjoiZHJpenpsZSJ9XX0seyJjaXR5IjoiS3VhbGEgTHVtcHVyIiwiY2MiOiJNWSIsInR6IjoiQXNpYS9LdWFsYV9MdW1wdXIiLCJsYXQiOjMuMTQsImxvbiI6MTAxLjY5LCJub3ciOnsiYyI6MjgsInNreSI6InBhcnRseSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjozMiwibG8iOjI0LCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MzEsImxvIjoyNCwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MzIsImxvIjoyMywic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MzIsImxvIjoyNCwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MzIsImxvIjoyNCwic2t5IjoicmFpbiJ9XX0seyJjaXR5IjoiU2luZ2Fwb3JlIiwiY2MiOiJTRyIsInR6IjoiQXNpYS9TaW5nYXBvcmUiLCJsYXQiOjEuMzUsImxvbiI6MTAzLjgyLCJub3ciOnsiYyI6MjYsInNreSI6InBhcnRseSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjozMiwibG8iOjI0LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjozMSwibG8iOjI0LCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MzIsImxvIjoyMywic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MzIsImxvIjoyMiwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MzIsImxvIjoyMywic2t5IjoicmFpbiJ9XX0seyJjaXR5IjoiSmFrYXJ0YSIsImNjIjoiSUQiLCJ0eiI6IkFzaWEvSmFrYXJ0YSIsImxhdCI6LTYuMjEsImxvbiI6MTA2Ljg1LCJub3ciOnsiYyI6MjUsInNreSI6ImNsb3VkeSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjozNSwibG8iOjI1LCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjozMiwibG8iOjI1LCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjozMywibG8iOjI2LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjozMiwibG8iOjI0LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjozMywibG8iOjI1LCJza3kiOiJkcml6emxlIn1dfSx7ImNpdHkiOiJEaGFrYSIsImNjIjoiQkQiLCJ0eiI6IkFzaWEvRGhha2EiLCJsYXQiOjIzLjgxLCJsb24iOjkwLjQxLCJub3ciOnsiYyI6MzIsInNreSI6InBhcnRseSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjozNSwibG8iOjI4LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjozNCwibG8iOjI3LCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjozMywibG8iOjI2LCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjozMywibG8iOjI0LCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjozMCwibG8iOjI0LCJza3kiOiJyYWluIn1dfSx7ImNpdHkiOiJLb2xrYXRhIiwiY2MiOiJJTiIsInR6IjoiQXNpYS9Lb2xrYXRhIiwibGF0IjoyMi41NywibG9uIjo4OC4zNiwibm93Ijp7ImMiOjMwLCJza3kiOiJjbGVhciJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjozMywibG8iOjI3LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjozNCwibG8iOjI2LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjozNCwibG8iOjI2LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjozMiwibG8iOjI1LCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjksImxvIjoyNCwic2t5IjoiZHJpenpsZSJ9XX0seyJjaXR5IjoiRGVsaGkiLCJjYyI6IklOIiwidHoiOiJBc2lhL0tvbGthdGEiLCJsYXQiOjI4LjYxLCJsb24iOjc3LjIxLCJub3ciOnsiYyI6MzIsInNreSI6ImNsZWFyIn0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjM0LCJsbyI6MjUsInNreSI6ImNsZWFyIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjozNSwibG8iOjI1LCJza3kiOiJjbGVhciJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MzUsImxvIjoyNCwic2t5IjoiY2xlYXIifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjMzLCJsbyI6MjMsInNreSI6InBhcnRseSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MzMsImxvIjoyNCwic2t5IjoiZHJpenpsZSJ9XX0seyJjaXR5IjoiTXVtYmFpIiwiY2MiOiJJTiIsInR6IjoiQXNpYS9Lb2xrYXRhIiwibGF0IjoxOS4wOCwibG9uIjo3Mi44OCwibm93Ijp7ImMiOjMyLCJza3kiOiJjbGVhciJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjozNSwibG8iOjI2LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjozMywibG8iOjI1LCJza3kiOiJjbGVhciJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MzMsImxvIjoyNCwic2t5IjoicGFydGx5In0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjozNSwibG8iOjI1LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjozNiwibG8iOjI2LCJza3kiOiJkcml6emxlIn1dfSx7ImNpdHkiOiJCZW5nYWx1cnUiLCJjYyI6IklOIiwidHoiOiJBc2lhL0tvbGthdGEiLCJsYXQiOjEyLjk3LCJsb24iOjc3LjU5LCJub3ciOnsiYyI6MjYsInNreSI6InN0b3JtIn0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjMwLCJsbyI6MjEsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjI4LCJsbyI6MjAsInNreSI6InJhaW4ifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjI4LCJsbyI6MjAsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjoyOCwibG8iOjIwLCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoyOCwibG8iOjIwLCJza3kiOiJjbG91ZHkifV19LHsiY2l0eSI6IkNoZW5uYWkiLCJjYyI6IklOIiwidHoiOiJBc2lhL0tvbGthdGEiLCJsYXQiOjEzLjA4LCJsb24iOjgwLjI3LCJub3ciOnsiYyI6MzAsInNreSI6ImNsZWFyIn0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjM0LCJsbyI6MjgsInNreSI6InBhcnRseSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MzQsImxvIjoyNywic2t5Ijoic3Rvcm0ifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjMzLCJsbyI6MjYsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjMxLCJsbyI6MjYsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjMyLCJsbyI6MjYsInNreSI6ImRyaXp6bGUifV19LHsiY2l0eSI6IkNvbG9tYm8iLCJjYyI6IkxLIiwidHoiOiJBc2lhL0NvbG9tYm8iLCJsYXQiOjYuOTMsImxvbiI6NzkuODYsIm5vdyI6eyJjIjoyNywic2t5IjoiY2xvdWR5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjMxLCJsbyI6MjUsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjI5LCJsbyI6MjUsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjMwLCJsbyI6MjQsInNreSI6InJhaW4ifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjMwLCJsbyI6MjQsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjozMCwibG8iOjI0LCJza3kiOiJyYWluIn1dfSx7ImNpdHkiOiJLYXRobWFuZHUiLCJjYyI6Ik5QIiwidHoiOiJBc2lhL0thdGhtYW5kdSIsImxhdCI6MjcuNzIsImxvbiI6ODUuMzIsIm5vdyI6eyJjIjoyMCwic2t5IjoicGFydGx5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjI0LCJsbyI6MTcsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyNCwibG8iOjE2LCJza3kiOiJzdG9ybSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjQsImxvIjoxNSwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjMsImxvIjoxNSwic2t5Ijoic3Rvcm0ifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjI0LCJsbyI6MTQsInNreSI6InN0b3JtIn1dfSx7ImNpdHkiOiJLYXJhY2hpIiwiY2MiOiJQSyIsInR6IjoiQXNpYS9LYXJhY2hpIiwibGF0IjoyNC44NiwibG9uIjo2Ny4wMSwibm93Ijp7ImMiOjI5LCJza3kiOiJjbGVhciJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjozMSwibG8iOjI3LCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjMwLCJsbyI6MjcsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjMwLCJsbyI6MjcsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjMwLCJsbyI6MjYsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjozMSwibG8iOjI1LCJza3kiOiJjbG91ZHkifV19LHsiY2l0eSI6IlRhc2hrZW50IiwiY2MiOiJVWiIsInR6IjoiQXNpYS9UYXNoa2VudCIsImxhdCI6NDEuMywibG9uIjo2OS4yNCwibm93Ijp7ImMiOjIwLCJza3kiOiJkcml6emxlIn0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjI1LCJsbyI6MTMsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjE3LCJsbyI6MTAsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjE5LCJsbyI6OCwic2t5IjoiY2xlYXIifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjIxLCJsbyI6OSwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoyNCwibG8iOjExLCJza3kiOiJwYXJ0bHkifV19LHsiY2l0eSI6IkFsbWF0eSIsImNjIjoiS1oiLCJ0eiI6IkFzaWEvQWxtYXR5IiwibGF0Ijo0My4yMiwibG9uIjo3Ni44NSwibm93Ijp7ImMiOjIzLCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjcsImxvIjoxNCwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MTcsImxvIjoxMCwic2t5IjoicmFpbiJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MTMsImxvIjo5LCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjE2LCJsbyI6OCwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoxOCwibG8iOjksInNreSI6ImRyaXp6bGUifV19LHsiY2l0eSI6IlRlaHJhbiIsImNjIjoiSVIiLCJ0eiI6IkFzaWEvVGVocmFuIiwibGF0IjozNS42OSwibG9uIjo1MS4zOSwibm93Ijp7ImMiOjI2LCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjcsImxvIjoxNCwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyNiwibG8iOjEzLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjI2LCJsbyI6MTQsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjgsImxvIjoxNywic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoyOSwibG8iOjE1LCJza3kiOiJjbG91ZHkifV19LHsiY2l0eSI6IkJhZ2hkYWQiLCJjYyI6IklRIiwidHoiOiJBc2lhL0JhZ2hkYWQiLCJsYXQiOjMzLjMyLCJsb24iOjQ0LjM3LCJub3ciOnsiYyI6MzMsInNreSI6ImNsb3VkeSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjozMywibG8iOjIzLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjM0LCJsbyI6MjQsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MzcsImxvIjoyNiwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjozMCwibG8iOjI2LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjM1LCJsbyI6MjUsInNreSI6ImNsb3VkeSJ9XX0seyJjaXR5IjoiUml5YWRoIiwiY2MiOiJTQSIsInR6IjoiQXNpYS9SaXlhZGgiLCJsYXQiOjI0LjcxLCJsb24iOjQ2LjY4LCJub3ciOnsiYyI6MzgsInNreSI6ImNsZWFyIn0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjM4LCJsbyI6MjksInNreSI6ImNsZWFyIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjozOCwibG8iOjI4LCJza3kiOiJjbGVhciJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MzgsImxvIjoyOCwic2t5IjoiY2xlYXIifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjM5LCJsbyI6MjcsInNreSI6ImNsZWFyIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjozOSwibG8iOjI4LCJza3kiOiJjbGVhciJ9XX0seyJjaXR5IjoiRG9oYSIsImNjIjoiUUEiLCJ0eiI6IkFzaWEvUWF0YXIiLCJsYXQiOjI1LjI5LCJsb24iOjUxLjUzLCJub3ciOnsiYyI6MzYsInNreSI6ImNsZWFyIn0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjM5LCJsbyI6MzAsInNreSI6ImNsZWFyIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjo0MSwibG8iOjI5LCJza3kiOiJjbGVhciJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6NDAsImxvIjozMSwic2t5IjoiY2xlYXIifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjM2LCJsbyI6MjksInNreSI6ImNsZWFyIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjozOCwibG8iOjI2LCJza3kiOiJjbGVhciJ9XX0seyJjaXR5IjoiRHViYWkiLCJjYyI6IkFFIiwidHoiOiJBc2lhL0R1YmFpIiwibGF0IjoyNS4yLCJsb24iOjU1LjI3LCJub3ciOnsiYyI6MzQsInNreSI6ImNsZWFyIn0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjM2LCJsbyI6MzAsInNreSI6ImNsZWFyIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjozNSwibG8iOjI5LCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjM3LCJsbyI6MjksInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6NDAsImxvIjoyNywic2t5IjoicGFydGx5In0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjozOCwibG8iOjI5LCJza3kiOiJjbGVhciJ9XX0seyJjaXR5IjoiVGVsIEF2aXYiLCJjYyI6IklMIiwidHoiOiJBc2lhL0plcnVzYWxlbSIsImxhdCI6MzIuMDksImxvbiI6MzQuNzgsIm5vdyI6eyJjIjoyOSwic2t5IjoicGFydGx5In0sImRheXMiOlt7ImQiOiIyMDI2LTEwLTA0IiwiaGkiOjI5LCJsbyI6MjAsInNreSI6InBhcnRseSJ9LHsiZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjcsImxvIjoyMSwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyNiwibG8iOjIxLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI2LCJsbyI6MTksInNreSI6InBhcnRseSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjgsImxvIjoyMCwic2t5IjoicGFydGx5In1dfSx7ImNpdHkiOiJCZWlydXQiLCJjYyI6IkxCIiwidHoiOiJBc2lhL0JlaXJ1dCIsImxhdCI6MzMuODksImxvbiI6MzUuNSwibm93Ijp7ImMiOjI3LCJza3kiOiJwYXJ0bHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjgsImxvIjoyMywic2t5IjoicGFydGx5In0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyNSwibG8iOjIyLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjI1LCJsbyI6MjEsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjUsImxvIjoyMSwic2t5IjoicGFydGx5In0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoyNywibG8iOjIxLCJza3kiOiJwYXJ0bHkifV19LHsiY2l0eSI6IlZsYWRpdm9zdG9rIiwiY2MiOiJSVSIsInR6IjoiQXNpYS9WbGFkaXZvc3RvayIsImxhdCI6NDMuMTIsImxvbiI6MTMxLjg5LCJub3ciOnsiYyI6MTMsInNreSI6ImNsb3VkeSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoxNSwibG8iOjEyLCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoxNiwibG8iOjExLCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoxNywibG8iOjEwLCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjE4LCJsbyI6MTEsInNreSI6InBhcnRseSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MTgsImxvIjoxMiwic2t5IjoicGFydGx5In1dfSx7ImNpdHkiOiJTeWRuZXkiLCJjYyI6IkFVIiwidHoiOiJBdXN0cmFsaWEvU3lkbmV5IiwibGF0IjotMzMuODcsImxvbiI6MTUxLjIxLCJub3ciOnsiYyI6MTYsInNreSI6InBhcnRseSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyNCwibG8iOjE0LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyMCwibG8iOjE0LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyNywibG8iOjE1LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjoxNywibG8iOjE1LCJza3kiOiJyYWluIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoxOCwibG8iOjEzLCJza3kiOiJkcml6emxlIn1dfSx7ImNpdHkiOiJNZWxib3VybmUiLCJjYyI6IkFVIiwidHoiOiJBdXN0cmFsaWEvTWVsYm91cm5lIiwibGF0IjotMzcuODEsImxvbiI6MTQ0Ljk2LCJub3ciOnsiYyI6MTEsInNreSI6InBhcnRseSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoxNSwibG8iOjExLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjE4LCJsbyI6OSwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoxOCwibG8iOjEwLCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjoxNSwibG8iOjEwLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjIyLCJsbyI6Nywic2t5IjoicGFydGx5In1dfSx7ImNpdHkiOiJCcmlzYmFuZSIsImNjIjoiQVUiLCJ0eiI6IkF1c3RyYWxpYS9CcmlzYmFuZSIsImxhdCI6LTI3LjQ3LCJsb24iOjE1My4wMywibm93Ijp7ImMiOjIwLCJza3kiOiJwYXJ0bHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjYsImxvIjoxNSwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyOCwibG8iOjE3LCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjI5LCJsbyI6MTgsInNreSI6ImRyaXp6bGUifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI2LCJsbyI6MTgsInNreSI6InN0b3JtIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoyMSwibG8iOjE2LCJza3kiOiJjbG91ZHkifV19LHsiY2l0eSI6IlBlcnRoIiwiY2MiOiJBVSIsInR6IjoiQXVzdHJhbGlhL1BlcnRoIiwibGF0IjotMzEuOTUsImxvbiI6MTE1Ljg2LCJub3ciOnsiYyI6MTYsInNreSI6InBhcnRseSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoxOSwibG8iOjEyLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA1IiwiaGkiOjIwLCJsbyI6MTIsInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjMsImxvIjoxMiwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjoyMCwibG8iOjE1LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoxOCwibG8iOjEyLCJza3kiOiJyYWluIn1dfSx7ImNpdHkiOiJBdWNrbGFuZCIsImNjIjoiTloiLCJ0eiI6IlBhY2lmaWMvQXVja2xhbmQiLCJsYXQiOi0zNi44NSwibG9uIjoxNzQuNzYsIm5vdyI6eyJjIjoxMSwic2t5IjoiY2xlYXIifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDUiLCJoaSI6MTUsImxvIjo5LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoxNywibG8iOjExLCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjoyMCwibG8iOjEwLCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoxOSwibG8iOjEyLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA5IiwiaGkiOjE5LCJsbyI6MTQsInNreSI6ImRyaXp6bGUifV19LHsiY2l0eSI6IldlbGxpbmd0b24iLCJjYyI6Ik5aIiwidHoiOiJQYWNpZmljL0F1Y2tsYW5kIiwibGF0IjotNDEuMjksImxvbiI6MTc0Ljc4LCJub3ciOnsiYyI6OCwic2t5IjoiY2xlYXIifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDUiLCJoaSI6MTMsImxvIjo3LCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjE0LCJsbyI6MTEsInNreSI6InBhcnRseSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MTYsImxvIjoxMiwic2t5IjoiY2xvdWR5In0seyJkIjoiMjAyNi0xMC0wOCIsImhpIjoxMywibG8iOjExLCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wOSIsImhpIjoxMywibG8iOjExLCJza3kiOiJjbG91ZHkifV19LHsiY2l0eSI6IlBvcnQgTW9yZXNieSIsImNjIjoiUEciLCJ0eiI6IlBhY2lmaWMvUG9ydF9Nb3Jlc2J5IiwibGF0IjotOS40NCwibG9uIjoxNDcuMTgsIm5vdyI6eyJjIjoyNCwic2t5IjoiY2xlYXIifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDQiLCJoaSI6MjgsImxvIjoyMiwic2t5IjoicGFydGx5In0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyNywibG8iOjIxLCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA2IiwiaGkiOjI3LCJsbyI6MjEsInNreSI6ImNsZWFyIn0seyJkIjoiMjAyNi0xMC0wNyIsImhpIjoyNywibG8iOjIxLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA4IiwiaGkiOjI3LCJsbyI6MjIsInNreSI6ImNsb3VkeSJ9XX0seyJjaXR5IjoiU3V2YSIsImNjIjoiRkoiLCJ0eiI6IlBhY2lmaWMvRmlqaSIsImxhdCI6LTE4LjEyLCJsb24iOjE3OC40NSwibm93Ijp7ImMiOjIxLCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDUiLCJoaSI6MjYsImxvIjoyMCwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDYiLCJoaSI6MjcsImxvIjoyMSwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6MjcsImxvIjoyMiwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjcsImxvIjoyMiwic2t5IjoiZHJpenpsZSJ9LHsiZCI6IjIwMjYtMTAtMDkiLCJoaSI6MjYsImxvIjoyMSwic2t5IjoiZHJpenpsZSJ9XX0seyJjaXR5IjoiTm91bcOpYSIsImNjIjoiTkMiLCJ0eiI6IlBhY2lmaWMvTm91bWVhIiwibGF0IjotMjIuMjgsImxvbiI6MTY2LjQ2LCJub3ciOnsiYyI6MTksInNreSI6ImNsb3VkeSJ9LCJkYXlzIjpbeyJkIjoiMjAyNi0xMC0wNCIsImhpIjoyNCwibG8iOjE3LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNSIsImhpIjoyNCwibG8iOjE4LCJza3kiOiJkcml6emxlIn0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjoyNCwibG8iOjE5LCJza3kiOiJwYXJ0bHkifSx7ImQiOiIyMDI2LTEwLTA3IiwiaGkiOjI1LCJsbyI6MTgsInNreSI6InBhcnRseSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6MjQsImxvIjoxOCwic2t5IjoiZHJpenpsZSJ9XX0seyJjaXR5IjoiTWNNdXJkbyBTdGF0aW9uIiwiY2MiOiJBUSIsInR6IjoiQW50YXJjdGljYS9NY011cmRvIiwibGF0IjotNzcuODUsImxvbiI6MTY2LjY4LCJub3ciOnsiYyI6LTIxLCJza3kiOiJjbG91ZHkifSwiZGF5cyI6W3siZCI6IjIwMjYtMTAtMDUiLCJoaSI6LTEzLCJsbyI6LTIxLCJza3kiOiJzbm93In0seyJkIjoiMjAyNi0xMC0wNiIsImhpIjotNiwibG8iOi0xOCwic2t5Ijoic25vdyJ9LHsiZCI6IjIwMjYtMTAtMDciLCJoaSI6LTgsImxvIjotMTksInNreSI6ImNsb3VkeSJ9LHsiZCI6IjIwMjYtMTAtMDgiLCJoaSI6LTE3LCJsbyI6LTIzLCJza3kiOiJjbG91ZHkifSx7ImQiOiIyMDI2LTEwLTA5IiwiaGkiOi0xOSwibG8iOi0yNCwic2t5IjoiY2xvdWR5In1dfV0sInNpdGUiOnsidCI6IkNoaWxkcmVuc0Jvb2tTaG9wLmNvbSIsInUiOiJodHRwOi8vd3d3LmNoaWxkcmVuc2Jvb2tzaG9wLmNvbS8iLCJkIjoiVXNlZCBhbmQgc2Vjb25kaGFuZCBjaGlsZHJlbidzIGJvb2tzaG9wIG5lYXIgSGF5IG9uIFd5ZSwgd2l0aCBhIHNlYXJjaGFibGUgb25saW5lIGNhdGFsb2d1ZSBhbmQgd29ybGR3aWRlIG9yZGVyaW5nLiJ9LCJub3RlIjoiRHJhZnRlZCBmcm9tIHRoaXMgc2l0ZeKAmXMgb3duIHBhZ2VzIHdpdGggc29mdHdhcmUsIHRoZW4gcmV3cml0dGVuIGFuZCBjaGVja2VkLiBOb3QgdGhlIHdvcmRzIG9mIHdob2V2ZXIgcnVucyBpdC4iLCJub3RlcyI6WyJIZWFkbGluZXMgbGluayB0byBGbGFtZU5ldCBOZXdzLCB3aGljaCBsaW5rcyB0byBlYWNoIHB1Ymxpc2hlciIsIk1hcmtldCBwcmljZXMgYXJlIGRlbGF5ZWQgYW5kIGZvciBpbmZvcm1hdGlvbiBvbmx5IiwiV2VhdGhlciBkYXRhIGJ5IE9wZW4tTWV0ZW8uY29tIl19","sig":"asyh/AzjogJ1IBxpWQAOTxRfOoFYAF3Y67UEIlIGx0rJMjUM8X5Ume0InYqjuthd+76pTgkRxIWIhzJcEEGsCg=="} \ No newline at end of file diff --git a/Tests/FlamenetRelayTests/TodayFileTests.swift b/Tests/FlamenetRelayTests/TodayFileTests.swift new file mode 100644 index 0000000..b6bb14c --- /dev/null +++ b/Tests/FlamenetRelayTests/TodayFileTests.swift @@ -0,0 +1,613 @@ +import AsyncHTTPClient +import Crypto +import Foundation +import NIOCore +import Testing +import Vapor +import VaporTesting + +@testable import FlamenetRelay + +/// The Today file (§8.11): what the relay serves, what it refuses, and that a +/// refusal never takes the last good file down with it. +@Suite("today file", .serialized) +struct TodayFileTests { + + // MARK: - Fixtures + + /// The dev key the flamenet.io builder signs with in testing. Public: its + /// private half never leaves the build machine and is not in this repo. + static let devPublicKey = "o6fwSoRjw0zldgUOpSClF9/Z2ySQcf0bTDtmtoDgFoA=" + + static var root: URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent() + } + + /// The builder's own output, byte for byte, signed with the dev key. + static func builderSample() throws -> Data { + try Data(contentsOf: root.appendingPathComponent("Tests/Fixtures/today-sample.json")) + } + + /// A key made for one test, and envelopes it signs. + struct Signer { + let key = Curve25519.Signing.PrivateKey() + var publicBase64: String { key.publicKey.rawRepresentation.base64EncodedString() } + var kid: String { TodayFile.kid(of: key.publicKey.rawRepresentation) } + + func envelope( + _ body: String, kid: String? = nil, v: Int = 1, sig: Data? = nil, extra: String = "" + ) throws -> Data { + let bytes = Data(body.utf8) + let signature = try sig ?? key.signature(for: bytes) + return Data( + (#"{"v":\#(v),"kid":"\#(kid ?? self.kid)","body":"\#(bytes.base64EncodedString())","# + + #""sig":"\#(signature.base64EncodedString())"\#(extra)}"#).utf8) + } + } + + /// The validator DESIGN 19 fixes, computed here and not by the code + /// under test: the quoted lower-case hex SHA-256 of the bytes. + static func sha256(_ data: Data) -> String { + "\"" + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + "\"" + } + + static func keys(_ base64: String...) -> [TodayFile.Key] { + base64.compactMap { Data(base64Encoded: $0) }.compactMap(TodayFile.Key.init(raw:)) + } + + static var quietLogger: Logger { + var logger = Logger(label: "today-test") + logger.logLevel = .critical + return logger + } + + static func temporaryDirectory() throws -> String { + let dir = NSTemporaryDirectory() + "fn-today-\(UUID().uuidString)/" + try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + return dir + } + + /// Answers a URL source gives, one per fetch, in order. + actor Answers { + private var queue: [Result] + init(_ answers: [Result]) { queue = answers } + func next() throws -> Data { try queue.removeFirst().get() } + } + + // MARK: - Keys and envelopes + + @Test("a key's id is the first eight hex characters of its SHA-256") + func kidOfTheDevKey() throws { + let raw = try #require(Data(base64Encoded: Self.devPublicKey)) + #expect(TodayFile.kid(of: raw) == "a9a94e74") + } + + /// The builder signs in PHP with libsodium; this checks it in Swift with + /// swift-crypto. If the two ever disagree about the bytes, this is where + /// it shows. + @Test("the builder's own signed file verifies against the dev key, and against no other") + func theBuildersFileVerifies() throws { + let sample = try Self.builderSample() + let envelope = try TodayFile.verify(sample, keys: Self.keys(Self.devPublicKey)) + #expect(envelope.kid == "a9a94e74") + #expect(throws: TodayFile.Refusal.unknownKey("a9a94e74")) { + try TodayFile.verify(sample, keys: Self.keys(Signer().publicBase64)) + } + } + + @Test("every way an envelope can be wrong is refused, and unknown fields are not one of them") + func refusals() throws { + let signer = Signer() + let keys = Self.keys(signer.publicBase64) + let body = #"{"v":1,"date":"2026-10-04"}"# + + try TodayFile.verify(signer.envelope(body), keys: keys) + try TodayFile.verify(signer.envelope(body, extra: #","note":"ignored""#), keys: keys) + + // The signature covers the body, so a different body under it fails. + let other = try signer.key.signature(for: Data(#"{"v":1}"#.utf8)) + #expect(throws: TodayFile.Refusal.badSignature(signer.kid)) { + try TodayFile.verify(signer.envelope(body, sig: other), keys: keys) + } + var flipped = try signer.key.signature(for: Data(body.utf8)) + flipped[0] ^= 0x01 + #expect(throws: TodayFile.Refusal.badSignature(signer.kid)) { + try TodayFile.verify(signer.envelope(body, sig: flipped), keys: keys) + } + #expect(throws: TodayFile.Refusal.unknownKey("00000000")) { + try TodayFile.verify(signer.envelope(body, kid: "00000000"), keys: keys) + } + #expect(throws: TodayFile.Refusal.unsupportedVersion(2)) { + try TodayFile.verify(signer.envelope(body, v: 2), keys: keys) + } + // A kid that is not eight lower-case hex characters is refused before + // it can be written into a log line. + for kid in ["A9A94E74", "a9a94e7", "a9a94e74\\n", "zzzzzzzz"] { + #expect(throws: TodayFile.Refusal.malformed) { + try TodayFile.verify(signer.envelope(body, kid: kid), keys: keys) + } + } + #expect(throws: TodayFile.Refusal.malformed) { + try TodayFile.verify(signer.envelope(body, sig: Data(repeating: 1, count: 32)), keys: keys) + } + #expect(throws: TodayFile.Refusal.malformed) { + try TodayFile.verify(signer.envelope(""), keys: keys) + } + for junk in ["", "not json", "[]", #"{"v":1}"#, #"{"v":"1","kid":"a","body":"","sig":""}"#] { + #expect(throws: TodayFile.Refusal.malformed) { + try TodayFile.verify(Data(junk.utf8), keys: keys) + } + } + // Over the cap is refused before it is parsed at all. + let padded = try signer.envelope( + body, extra: #","pad":""# + String(repeating: "x", count: TodayFile.maxBytes) + #"""#) + #expect(throws: TodayFile.Refusal.tooLarge) { + try TodayFile.verify(padded, keys: keys) + } + } + + @Test("FN_TODAY_KEYS keeps the keys and counts the rest without repeating them") + func keyList() { + let a = Signer() + let b = Signer() + let config = TodayConfig( + file: "/x", source: nil, + keys: " \(a.publicBase64) , not-a-key,, \(b.publicBase64)", cache: nil) + #expect(config.keys == [a.key.publicKey.rawRepresentation, b.key.publicKey.rawRepresentation]) + #expect(config.rejectedKeys == 1) + } + + @Test("one source or none: both at once, or a URL that is not http, serve nothing") + func sources() { + func source(_ file: String?, _ url: String?) -> TodayConfig.Source { + TodayConfig(file: file, source: url, keys: nil, cache: nil).source + } + #expect(source(nil, nil) == .none) + #expect(source(" ", "") == .none) + #expect(source("/srv/today.json", nil) == .file("/srv/today.json")) + #expect(source(nil, "https://flamenet.io/api/flamenet/v1/messenger-today") + == .url(URL(string: "https://flamenet.io/api/flamenet/v1/messenger-today")!)) + let unusable: [(String?, String?)] = [ + ("/srv/today.json", "https://flamenet.io/x"), (nil, "ftp://flamenet.io/x"), + (nil, "flamenet.io/today.json"), (nil, "https://"), + ] + for (file, url) in unusable { + guard case .unusable = source(file, url) else { + Issue.record("\(String(describing: file)) / \(String(describing: url)) should be unusable") + continue + } + } + #expect(TodayConfig(file: "/x", source: "https://y/z", keys: nil, cache: nil).offered) + } + + @Test("If-None-Match matches a list, a weak tag and a star, and nothing else") + func ifNoneMatch() { + let etag = "\"0123456789abcdef0123456789abcdef\"" + #expect(TodayFile.matches([etag], etag: etag)) + #expect(TodayFile.matches(["\"aaaa\", \(etag)"], etag: etag)) + #expect(TodayFile.matches(["\"aaaa\"", "W/\(etag)"], etag: etag)) + #expect(TodayFile.matches(["*"], etag: etag)) + #expect(!TodayFile.matches([], etag: etag)) + #expect(!TodayFile.matches(["\"aaaa\""], etag: etag)) + #expect(!TodayFile.matches(["0123456789abcdef0123456789abcdef"], etag: etag)) + } + + // MARK: - A URL source + + @Test("a URL source that turns bad, or goes away, leaves the last good file serving") + func urlSourceKeepsTheLastGood() async throws { + let signer = Signer() + let stranger = Signer() + let good = try signer.envelope(#"{"v":1,"n":1}"#) + let newer = try signer.envelope(#"{"v":1,"n":2}"#) + let forged = try signer.envelope( + #"{"v":1,"n":3}"#, sig: signer.key.signature(for: Data("other".utf8))) + let strangers = try stranger.envelope(#"{"v":1,"n":4}"#) + let answers = Answers([ + .success(good), .success(forged), .failure(.timedOut), + .success(strangers), .failure(.tooLarge), .success(newer), + ]) + let url = try #require(URL(string: "https://source.example/today")) + let today = TodayFile( + config: TodayConfig(file: nil, source: url.absoluteString, keys: signer.publicBase64, cache: nil), + workingDirectory: "/", fetch: { _ in try await answers.next() }, logger: Self.quietLogger) + + #expect(await today.current() == nil, "nothing has verified yet") + await today.refresh(from: url) + let first = try #require(await today.current()) + #expect(first.bytes == ByteBuffer(bytes: good)) + for _ in 0..<4 { + await today.refresh(from: url) + #expect(await today.current() == first) + } + await today.refresh(from: url) + #expect(await today.current()?.bytes == ByteBuffer(bytes: newer)) + } + + @Test("the last good file is kept on disk and served after a restart, while its key is still listed") + func keptAcrossARestart() async throws { + let dir = try Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(atPath: dir) } + let signer = Signer() + let good = try signer.envelope(#"{"v":1,"n":1}"#) + let url = try #require(URL(string: "https://source.example/today")) + func life(keys: String, fetch: @escaping @Sendable (URL) async throws -> Data) -> TodayFile { + TodayFile( + config: TodayConfig(file: nil, source: url.absoluteString, keys: keys, cache: "today-kept.json"), + workingDirectory: dir, fetch: fetch, logger: Self.quietLogger) + } + + await life(keys: signer.publicBase64, fetch: { _ in good }).refresh(from: url) + #expect(FileManager.default.contents(atPath: dir + "today-kept.json") == good) + + // The source is down when the relay comes back: the kept copy serves. + let restarted = life(keys: signer.publicBase64, fetch: { _ in throw TodayFile.Refusal.timedOut }) + #expect(await restarted.current()?.bytes == ByteBuffer(bytes: good)) + await restarted.refresh(from: url) + #expect(await restarted.current()?.bytes == ByteBuffer(bytes: good)) + + // The key was taken off the list: the kept copy no longer verifies. + let rotated = life(keys: Signer().publicBase64, fetch: { _ in throw TodayFile.Refusal.timedOut }) + #expect(await rotated.current() == nil) + } + + /// A source on 127.0.0.1 answering `GET /today` with whatever `answer` + /// returns, and the HTTP client to reach it with. + private func withSource( + _ answer: @escaping @Sendable (Request) async throws -> Response, + _ body: (URL, HTTPClient) async throws -> Void + ) async throws { + let server = try await Application.make(.testing) + server.logger.logLevel = .critical + server.get("today", use: answer) + do { + try await server.asyncBoot() + try await server.server.start(address: .hostname("127.0.0.1", port: 0)) + let port = try #require(server.http.server.shared.localAddress?.port) + let url = try #require(URL(string: "http://127.0.0.1:\(port)/today")) + try await body(url, server.http.client.shared) + } catch { + await server.server.shutdown() + try? await server.asyncShutdown() + throw error + } + await server.server.shutdown() + try await server.asyncShutdown() + } + + @Test("a fetch brings the bytes back exactly as the source sent them") + func fetchIsExact() async throws { + let sample = try Self.builderSample() + try await withSource({ _ in Response(status: .ok, body: .init(data: sample)) }) { url, client in + let fetched = try await TodayFile.fetch(url, using: client) + #expect(fetched == sample) + try TodayFile.verify(fetched, keys: Self.keys(Self.devPublicKey)) + } + } + + @Test("a source over 512 KB is refused, whether or not it says so up front") + func fetchCapsTheSize() async throws { + let big = String(repeating: "x", count: TodayFile.maxBytes + 1) + try await withSource({ _ in Response(status: .ok, body: .init(string: big)) }) { url, client in + await #expect(throws: TodayFile.Refusal.tooLarge) { + try await TodayFile.fetch(url, using: client) + } + } + // No Content-Length: the cap has to hold while the body streams in. + try await withSource({ _ in + Response( + status: .ok, + body: .init(managedAsyncStream: { writer in + for _ in 0..<12 { + try await writer.writeBuffer(ByteBuffer(repeating: UInt8(ascii: "x"), count: 64 * 1024)) + } + })) + }) { url, client in + await #expect(throws: TodayFile.Refusal.tooLarge) { + try await TodayFile.fetch(url, using: client) + } + } + } + + @Test("a source that answers anything but 200 is refused") + func fetchNeedsAnOK() async throws { + try await withSource({ _ in Response(status: .serviceUnavailable) }) { url, client in + await #expect(throws: TodayFile.Refusal.status(503)) { + try await TodayFile.fetch(url, using: client) + } + } + } + + /// The headers arrive at once and the body never finishes. The client's + /// own timeout has stopped counting by then, so this is the deadline that + /// has to end it. + @Test("a body that trickles in past the deadline is a timeout, not a short file") + func fetchDeadlineCoversTheBody() async throws { + try await withSource({ _ in + Response( + status: .ok, + body: .init(managedAsyncStream: { writer in + try await writer.writeBuffer(ByteBuffer(string: "{")) + try await Task.sleep(nanoseconds: 4_000_000_000) + try await writer.writeBuffer(ByteBuffer(string: "}")) + })) + }) { url, client in + let began = Date() + await #expect(throws: TodayFile.Refusal.timedOut) { + try await TodayFile.fetch(url, using: client, timeout: .seconds(1)) + } + #expect(Date().timeIntervalSince(began) < 3, "the deadline did not end the fetch") + } + } + + // MARK: - The route, through the whole relay + + /// A relay booted the way production boots it, with `extra` in its + /// environment. + private func withRelayApp( + _ extra: [String: String] = [:], + _ 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-today-\(UUID().uuidString).sqlite" + let attachments = NSTemporaryDirectory() + "fn-today-att-\(UUID().uuidString)" + var 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", + "FN_TODAY_FILE": "", "FN_TODAY_SOURCE": "", "FN_TODAY_KEYS": "", "FN_TODAY_CACHE": "", + ] + for (k, v) in extra { env[k] = v } + 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() + } + } + + private func get( + _ app: Application, _ path: String = "/today.json", headers: HTTPHeaders = [:] + ) async throws -> (status: HTTPStatus, headers: HTTPHeaders, body: Data) { + var answer: (HTTPStatus, HTTPHeaders, Data) = (.internalServerError, [:], Data()) + try await app.testing().test(.GET, path, headers: headers) { res in + answer = (res.status, res.headers, Data(res.body.readableBytesView)) + } + return answer + } + + @Test("a relay that does not offer the file says so, in JSON, to a browser too") + func notOffered() async throws { + try await withRelayApp { app in + for accept in ["application/json", "text/html"] { + let res = try await get(app, headers: ["Accept": accept]) + #expect(res.status == .notFound) + #expect(String(decoding: res.body, as: UTF8.self) == #"{"error":"not_offered"}"#) + #expect(res.headers.first(name: .contentType) == "application/json") + #expect(res.headers.first(name: .cacheControl) == "no-store") + #expect(res.headers.first(name: .setCookie) == nil) + } + } + } + + @Test("configured with nothing good yet: 503, until a good file appears") + func configuredButEmpty() async throws { + let dir = try Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(atPath: dir) } + let path = dir + "today.json" + try await withRelayApp(["FN_TODAY_FILE": path, "FN_TODAY_KEYS": Self.devPublicKey]) { app in + let missing = try await get(app) + #expect(missing.status == .serviceUnavailable) + #expect(String(decoding: missing.body, as: UTF8.self) == #"{"error":"not_ready"}"#) + #expect(missing.headers.first(name: .cacheControl) == "no-store") + + try Self.builderSample().write(to: URL(fileURLWithPath: path)) + #expect(try await get(app).status == .ok) + } + // A file is there, but no key to check it with. + try Self.builderSample().write(to: URL(fileURLWithPath: path)) + try await withRelayApp(["FN_TODAY_FILE": path]) { app in + let res = try await get(app) + #expect(res.status == .serviceUnavailable) + } + // Two sources at once is a mistake, and nothing is served from either. + try await withRelayApp([ + "FN_TODAY_FILE": path, "FN_TODAY_SOURCE": "https://flamenet.io/x", + "FN_TODAY_KEYS": Self.devPublicKey, + ]) { app in + let res = try await get(app) + #expect(res.status == .serviceUnavailable) + } + } + + @Test("200 is the source's bytes unchanged, public for fifteen minutes, with a strong ETag and no cookie") + func servesTheBytes() async throws { + let dir = try Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(atPath: dir) } + let sample = try Self.builderSample() + try sample.write(to: URL(fileURLWithPath: dir + "today.json")) + try await withRelayApp([ + "FN_TODAY_FILE": dir + "today.json", "FN_TODAY_KEYS": "\(Signer().publicBase64),\(Self.devPublicKey)", + ]) { app in + let res = try await get(app) + #expect(res.status == .ok) + #expect(res.body == sample) + #expect(res.headers.first(name: .contentType) == "application/json") + #expect(res.headers.first(name: .cacheControl) == "public, max-age=900") + #expect(res.headers.first(name: .setCookie) == nil) + let etag = try #require(res.headers.first(name: .eTag)) + #expect(etag == Self.sha256(sample), "a strong tag, the quoted hash of the bytes") + } + } + + @Test("If-None-Match gets a 304 with the validator and the cache rule, and no body") + func notModified() async throws { + let dir = try Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(atPath: dir) } + let sample = try Self.builderSample() + try sample.write(to: URL(fileURLWithPath: dir + "today.json")) + let etag = TodayFile.etag(of: sample) + try await withRelayApp(["FN_TODAY_FILE": dir + "today.json", "FN_TODAY_KEYS": Self.devPublicKey]) { app in + for value in [etag, "\"other\", \(etag)", "W/\(etag)", "*"] { + let res = try await get(app, headers: ["If-None-Match": value]) + #expect(res.status == .notModified, "\(value)") + #expect(res.body.isEmpty) + #expect(res.headers.first(name: .eTag) == etag) + #expect(res.headers.first(name: .cacheControl) == "public, max-age=900") + } + let stale = try await get(app, headers: ["If-None-Match": "\"0000\""]) + #expect(stale.status == .ok) + #expect(stale.body == sample) + } + } + + /// A validator that differed by client would be a way to tell devices + /// apart: hand each one its own tag and see it come back. This one is + /// the hash of the bytes, so it cannot differ. + @Test("every client gets the same ETag, and it is the SHA-256 of the bytes served") + func sameETagForEveryone() async throws { + let dir = try Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(atPath: dir) } + let sample = try Self.builderSample() + try sample.write(to: URL(fileURLWithPath: dir + "today.json")) + try await withRelayApp(["FN_TODAY_FILE": dir + "today.json", "FN_TODAY_KEYS": Self.devPublicKey]) { app in + let iphone = try await get(app, headers: [ + "User-Agent": "FlamenetMessenger/0.32.0", "X-Forwarded-For": "198.51.100.7", + "Accept-Language": "en-US", + ]) + let android = try await get(app, headers: [ + "User-Agent": "FlamenetMessenger/0.32 (Android)", "X-Forwarded-For": "203.0.113.9", + "Accept-Language": "es-MX", "Cookie": "a=b", + ]) + let a = try #require(iphone.headers.first(name: .eTag)) + let b = try #require(android.headers.first(name: .eTag)) + #expect(a == b) + #expect(a == Self.sha256(iphone.body)) + #expect(a == Self.sha256(sample)) + #expect(a.count == 66 && a == a.lowercased()) + } + } + + @Test("nothing in the query changes the answer") + func queryIgnored() async throws { + let dir = try Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(atPath: dir) } + let sample = try Self.builderSample() + try sample.write(to: URL(fileURLWithPath: dir + "today.json")) + try await withRelayApp(["FN_TODAY_FILE": dir + "today.json", "FN_TODAY_KEYS": Self.devPublicKey]) { app in + let plain = try await get(app) + for path in ["/today.json?city=Paris", "/today.json?device=abc&user=1", "/today.json?v=2"] { + let res = try await get(app, path) + #expect(res.status == .ok) + #expect(res.body == plain.body) + #expect(res.headers.first(name: .eTag) == plain.headers.first(name: .eTag)) + } + } + } + + /// Same size and the same inode, written in place: only the modification + /// time says it changed, and that has to be enough. + @Test("a file that changes on disk is read again") + func readsAChangedFile() async throws { + let dir = try Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(atPath: dir) } + let path = dir + "today.json" + let signer = Signer() + let first = try signer.envelope(#"{"v":1,"n":1}"#) + let second = try signer.envelope(#"{"v":1,"n":2}"#) + #expect(first.count == second.count) + try first.write(to: URL(fileURLWithPath: path)) + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSince1970: 1_700_000_000)], ofItemAtPath: path) + + try await withRelayApp(["FN_TODAY_FILE": path, "FN_TODAY_KEYS": signer.publicBase64]) { app in + #expect(try await get(app).body == first) + + let handle = try #require(FileHandle(forWritingAtPath: path)) + try handle.write(contentsOf: second) + try handle.close() + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSince1970: 1_700_000_100)], ofItemAtPath: path) + + let res = try await get(app) + #expect(res.body == second) + #expect(res.headers.first(name: .eTag) == TodayFile.etag(of: second)) + } + } + + @Test("a bad signature, an unknown key or an oversized file never replaces the last good one") + func keepsTheLastGood() async throws { + let dir = try Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(atPath: dir) } + let path = dir + "today.json" + let signer = Signer() + let good = try signer.envelope(#"{"v":1,"n":1}"#) + let later = try signer.envelope(#"{"v":1,"n":2}"#) + try good.write(to: URL(fileURLWithPath: path)) + + try await withRelayApp(["FN_TODAY_FILE": path, "FN_TODAY_KEYS": signer.publicBase64]) { app in + #expect(try await get(app).body == good) + + var stamp = 1_700_000_000.0 + for bad in [ + try signer.envelope(#"{"v":1,"n":9}"#, sig: signer.key.signature(for: Data("x".utf8))), + try Signer().envelope(#"{"v":1,"n":9}"#), + try signer.envelope(#"{"v":1,"n":9}"#, kid: "00000000"), + Data(repeating: UInt8(ascii: " "), count: TodayFile.maxBytes + 1), + Data("{}".utf8), + ] { + try bad.write(to: URL(fileURLWithPath: path)) + stamp += 10 + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSince1970: stamp)], ofItemAtPath: path) + let res = try await get(app) + #expect(res.status == .ok) + #expect(res.body == good, "a refused file displaced the good one") + } + + // Gone altogether: still the last good one. + try FileManager.default.removeItem(atPath: path) + #expect(try await get(app).body == good) + + try later.write(to: URL(fileURLWithPath: path)) + #expect(try await get(app).body == later) + } + } + + @Test("an oversized file is refused from the start") + func oversizedFromTheStart() async throws { + let dir = try Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(atPath: dir) } + let signer = Signer() + let big = try signer.envelope( + #"{"v":1}"#, extra: #","pad":""# + String(repeating: "x", count: TodayFile.maxBytes) + #"""#) + try big.write(to: URL(fileURLWithPath: dir + "today.json")) + try await withRelayApp(["FN_TODAY_FILE": dir + "today.json", "FN_TODAY_KEYS": signer.publicBase64]) { app in + let res = try await get(app) + #expect(res.status == .serviceUnavailable) + } + } +} diff --git a/docs/API.md b/docs/API.md index 87aa80d..c9ae2fd 100644 --- a/docs/API.md +++ b/docs/API.md @@ -54,6 +54,21 @@ transparency log only its own users could audit would not be a transparency log. --- +## The Today file + +| Route | Auth | Purpose | +|---|---|---| +| `GET /today.json` | none | The signed Today file, when this relay offers one (§8.11) | + +The same bytes for everybody, served exactly as they were signed and only +after the signature checks against `FN_TODAY_KEYS`. 404 +`{"error":"not_offered"}` when no source is configured, 503 while nothing has +verified yet. A 200 carries an `ETag` and `Cache-Control: public, max-age=900`, +and `If-None-Match` gets a 304. Nothing else is read from the request: no +credential, no cookie, no query. + +--- + ## Accounts Present only when this relay runs the built-in identity provider diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f38b9a6..467108c 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -240,6 +240,36 @@ All optional. Each is absent rather than half-enabled when unconfigured. | `FN_DONATE_PUBLISHABLE_KEY` | Required alongside the two above: the payment fields are Stripe's own, mounted on the page, and they cannot mount without it. Set the secret and not this and the donate page shows no form at all. | | `FN_DONATE_ACCOUNT` | The connected account to charge, if `FN_DONATE_SECRET` belongs to a Stripe *platform* rather than to the account meant to receive the money. Sent as `Stripe-Account`, which makes the payment a direct charge there. Blank charges whatever account the key itself belongs to, the right answer when you are self-hosting with your own key, and the wrong one with a platform key, where Stripe answers 200 and the money lands on the platform with nothing on the page looking wrong. With this set, the publishable key stays the **platform's**. | +## The Today file + +Off. When it is on, the relay serves one public file at `GET /today.json` +(§8.11): headlines, markets, weather and the like, built and signed somewhere +else, and the same bytes for everybody. The relay checks the signature and +serves the file exactly as it arrived. + +| Variable | Default | Notes | +|---|---|---| +| `FN_TODAY_FILE` | unset | Path to the signed file. Read again whenever it changes (its modification time, size or inode). Write a new file and rename it over the old one, so the relay never reads a half-written one. Relative paths resolve under `FN_WORKDIR`. | +| `FN_TODAY_SOURCE` | unset | An `http` or `https` URL to fetch the file from instead, every 15 minutes give or take a minute, with a 10 second limit and a 512 KB cap. Set this or `FN_TODAY_FILE`, not both: with both set nothing is served and the log says why. A self-hosted relay mirrors the official file by pointing this at another relay's `/today.json`. | +| `FN_TODAY_KEYS` | unset | Comma-separated base64 Ed25519 public keys the file may be signed with. An envelope names its key by the first eight hex characters of the key's SHA-256. A file signed by any other key, or by none, is never served. | +| `FN_TODAY_CACHE` | unset | Path where the last file that verified is kept, so a restart can serve it straight away. It is checked against `FN_TODAY_KEYS` again when it is read back. Relative paths resolve under `FN_WORKDIR`. | + +Nothing is served until it verifies, and a file that fails the check never +replaces one that passed. A bad signature, a key that is not listed, a file +over 512 KB, a source that is down or slow: the relay goes on serving the last +good file, and logs the refusal, once for each new reason. With no source set, `/today.json` +answers 404 `{"error":"not_offered"}`; with a source set and nothing verified +yet, 503. + +The route reads no credential, no cookie and no query, and writes no log line +per request. A good answer carries `Cache-Control: public, max-age=900` and an +`ETag`, and `If-None-Match` gets a 304. + +`FN_TODAY_SOURCE` makes the relay open a connection of its own: one GET to +that URL every fifteen minutes. Whatever comes back still has to verify, so the +host at that URL cannot change what the file says. It can only hold back a +newer file or hand back an older signed one. + ## iPhone push (APNs) -- 2.54.0 From 534f8513a391bf84baf61e24e6be56d8445af638 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Sun, 4 Oct 2026 09:06:44 -0400 Subject: [PATCH 02/11] The /hi page a hello link opens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hello link is https:///hi#

(§6.34). The address is in the fragment, which no browser sends, so /hi and /es/hi are one static page for every link, and Public/hi.js reads the address on the device: fnid: and 52 characters of the apps' base32 alphabet, then @ and a host of letters, digits, hyphens and dots, the rule both apps use. A good address is shown as text with "Open in Flamenet Messenger" (flamenetmessenger://hi#
) and a copy button; anything else gets a plain explanation. Both carry the install links and the iPhone App Store or TestFlight switch from /get. The script makes no request and builds no URL but the app link. A test reads it for every way a page can send something, and on macOS runs it in JavaScriptCore against stand-ins for the page's elements. Both pages ask not to be indexed (header and meta tag), stay out of the sitemap, load nothing from another host, and are in the staging gate's page list. Co-Authored-By: Claude Opus 5.5 --- Public/hi.js | 87 ++++ Public/portal.css | 12 + Resources/Views/api.leaf | 6 +- Resources/Views/es/hi.leaf | 68 ++++ Resources/Views/hi.leaf | 65 +++ Resources/Views/layout.leaf | 1 + Scripts/staging-gate.sh | 5 +- Sources/FlamenetRelay/Entrypoint.swift | 5 +- Sources/FlamenetRelay/RoutesHello.swift | 84 ++++ Tests/FlamenetRelayTests/HelloPageTests.swift | 381 ++++++++++++++++++ docs/API.md | 2 +- 11 files changed, 710 insertions(+), 6 deletions(-) create mode 100644 Public/hi.js create mode 100644 Resources/Views/es/hi.leaf create mode 100644 Resources/Views/hi.leaf create mode 100644 Sources/FlamenetRelay/RoutesHello.swift create mode 100644 Tests/FlamenetRelayTests/HelloPageTests.swift diff --git a/Public/hi.js b/Public/hi.js new file mode 100644 index 0000000..095503d --- /dev/null +++ b/Public/hi.js @@ -0,0 +1,87 @@ +// The hello-link page (§6.34). +// +// A hello link carries an address after the #, the part of a link a browser +// keeps to itself. This reads it there, checks that it is an address, and +// shows it. The address is written to the page and to the app link, +// flamenetmessenger://hi#
, and to nothing else: no request, no +// beacon, no image, no other link. +(function (global) { + // fnid: and 52 characters of the apps' Crockford base32 alphabet (no i, l, + // o or u), then @ and the relay's host: the same rule both apps apply. + var FNID = /^fnid:[0-9a-hjkmnp-tv-z]{52}$/; + var HOST = /^[a-z0-9.-]+$/; + + // The address in a fragment, or null. Percent-decoded once. The host is + // lower-cased, as both apps do; the fnid is not, because the apps read it + // only in lower case. 400 characters is far past any real address. + function parseHelloAddress(fragment) { + var raw = String(fragment || ''); + if (raw.charAt(0) === '#') raw = raw.slice(1); + if (raw.length === 0 || raw.length > 400) return null; + try { + raw = decodeURIComponent(raw); + } catch (e) { + return null; + } + var at = raw.indexOf('@'); + if (at < 0 || raw.indexOf('@', at + 1) >= 0) return null; + var fnid = raw.slice(0, at); + var host = raw.slice(at + 1).toLowerCase(); + if (!FNID.test(fnid) || !HOST.test(host) || host.indexOf('.') < 0) return null; + return fnid + '@' + host; + } + + global.parseHelloAddress = parseHelloAddress; + if (typeof document === 'undefined') return; + + var found = document.getElementById('hi-found'); + var missing = document.getElementById('hi-missing'); + var shown = document.getElementById('hi-address'); + var open = document.getElementById('hi-open'); + var copy = document.getElementById('hi-copy'); + var note = document.getElementById('hi-copy-note'); + if (!found || !missing || !shown || !open || !copy || !note) return; + var address = null; + + function render() { + address = parseHelloAddress(location.hash); + note.textContent = ''; + // textContent, never markup: whatever the link says is shown as text. + shown.textContent = address || ''; + open.setAttribute('href', address ? 'flamenetmessenger://hi#' + address : 'flamenetmessenger://hi'); + found.hidden = !address; + missing.hidden = !!address; + } + + copy.addEventListener('click', function () { + if (!address) return; + function say(key) { + note.textContent = copy.getAttribute(key) || ''; + } + // Selected, so a long press or a keyboard shortcut can take it from there. + function byHand() { + var range = document.createRange(); + range.selectNodeContents(shown); + var selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + var copied = false; + try { + copied = document.execCommand('copy'); + } catch (e) { + copied = false; + } + say(copied ? 'data-copied' : 'data-select'); + } + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(address).then(function () { say('data-copied'); }, byHand); + } else { + byHand(); + } + }); + + // A second hello link opened in the same tab changes only the fragment, and + // the page does not reload. Without this it would go on showing the first. + window.addEventListener('hashchange', render); + render(); +})(this); diff --git a/Public/portal.css b/Public/portal.css index a405ca5..e29fab6 100644 --- a/Public/portal.css +++ b/Public/portal.css @@ -861,3 +861,15 @@ a.platform:hover { background: linear-gradient(180deg, #FFFFFF 0%, #F6F3E9 52%, } /* The front page's throwaway poll. */ + +/* The hello-link page. An address is some sixty characters with no space in + them, so it may break at any character; unbroken, it would push the column + sideways. The hidden rule is here because .btn and friends set their own + display. */ +.hi-address { display: inline-block; word-break: break-all; font-size: 12px; padding: 2px 5px; } +.hi [hidden] { display: none; } + +/* Open and Copy stay in one row on a phone as well: the row is a flex box, + and the longer label wraps inside its own button. */ +.hi .cta-row { display: flex; gap: 6px; } +.hi .cta-row .btn { margin: 0; text-align: center; } diff --git a/Resources/Views/api.leaf b/Resources/Views/api.leaf index c718f8b..905321d 100644 --- a/Resources/Views/api.leaf +++ b/Resources/Views/api.leaf @@ -3,7 +3,7 @@ + source-sha256: e65044145fe74622 -->

Relay API

@@ -573,8 +573,8 @@ list the relay can read this document says a route not in it is not served: /, /es, /status, /status.json, /health, /releases, /join/{code}, -/donate, /es/donate, -POST /donate/intent, +/hi, /es/hi, /donate, +/es/donate, POST /donate/intent, /downloads/android-latest.json, /robots.txt, /sitemap.xml, /llms.txt, /llms-full.txt, /.well-known/security.txt, and diff --git a/Resources/Views/es/hi.leaf b/Resources/Views/es/hi.leaf new file mode 100644 index 0000000..9a74ae0 --- /dev/null +++ b/Resources/Views/es/hi.leaf @@ -0,0 +1,68 @@ +#extend("layout"): +#export("content"): + + +
+
+ Enlace de saludo +

Saluda en Flamenet Messenger

+ + + + + + +
+
+ +
+
+

Descarga la aplicación

+

Si todavía no la tienes en el teléfono, instálala y vuelve a abrir este enlace.

+ +
+
+ + + +#endexport +#endextend diff --git a/Resources/Views/hi.leaf b/Resources/Views/hi.leaf new file mode 100644 index 0000000..4326351 --- /dev/null +++ b/Resources/Views/hi.leaf @@ -0,0 +1,65 @@ +#extend("layout"): +#export("content"): + + +
+
+ Hello link +

Say hello on Flamenet Messenger

+ + + + + + +
+
+ +
+
+

Get the app

+

If it is not on your phone yet, install it, then open this link again.

+ +
+
+ + + +#endexport +#endextend diff --git a/Resources/Views/layout.leaf b/Resources/Views/layout.leaf index ee8d07b..0c72a43 100644 --- a/Resources/Views/layout.leaf +++ b/Resources/Views/layout.leaf @@ -5,6 +5,7 @@ #(title) +#if(noindex):#endif diff --git a/Scripts/staging-gate.sh b/Scripts/staging-gate.sh index b7b923c..3be49f5 100755 --- a/Scripts/staging-gate.sh +++ b/Scripts/staging-gate.sh @@ -162,7 +162,10 @@ fi # and the deploy that templated the contact addresses shipped /es/safety # answering 500 through a gate that rendered only the English table. ES_PAGES=$(ls "$GATE_SRC/Resources/Views/es/" 2>/dev/null | sed 's/\.leaf$//' | grep -v '^landing$' | sed 's|^|es/|' | tr '\n' ' ') -ALL_PAGES="donate releases $PAGES es/ $ES_PAGES" +# `hi` is named for the same reason as `releases` below: its route is outside +# the page table, so it stays out of the sitemap, and the derived list cannot +# see it. Its Spanish twin is es/hi.leaf, which the listing above picks up. +ALL_PAGES="donate releases hi $PAGES es/ $ES_PAGES" echo "GATE: rendering $(echo "$ALL_PAGES" | wc -w | tr -d ' ') public pages" # `releases` is named here for the same reason `donate` is: it is NOT in # the table above. Its route is deliberately outside `pages` because its diff --git a/Sources/FlamenetRelay/Entrypoint.swift b/Sources/FlamenetRelay/Entrypoint.swift index 098517d..70cf938 100644 --- a/Sources/FlamenetRelay/Entrypoint.swift +++ b/Sources/FlamenetRelay/Entrypoint.swift @@ -481,6 +481,9 @@ func register(routes r: Routes, on app: Application) throws { return response } + // Where a hello link lands (§6.34). Not in the page table, so it stays out + // of the sitemap. + registerHelloPages(on: app, config: r.config) app.get("status.json") { req async -> StatusReport in await StatusPage.report( @@ -1664,7 +1667,7 @@ enum AssetVersion { static let value: String = { // The brand images are in here too, and belong here. let paths = [ - "Public/portal.css", "Public/landing.js", "Public/donate.js", + "Public/portal.css", "Public/landing.js", "Public/donate.js", "Public/hi.js", "Public/brand/mark-128.png", "Public/brand/og.png", "Public/brand/apple-touch-icon.png", "Public/shots/conversation.png", "Public/shots/conversation@3x.png", diff --git a/Sources/FlamenetRelay/RoutesHello.swift b/Sources/FlamenetRelay/RoutesHello.swift new file mode 100644 index 0000000..7a6fdc1 --- /dev/null +++ b/Sources/FlamenetRelay/RoutesHello.swift @@ -0,0 +1,84 @@ +import Foundation +import Vapor + +/// The page a hello link opens (§6.34): `https:///hi#
`. +/// +/// The address is in the fragment, which a browser never sends, so this route +/// never sees it. Every hello link gets the same page, and `Public/hi.js` +/// reads the address on the device. Nothing here reads the request beyond the +/// host it arrived on, and the page asks not to be indexed. +func registerHelloPages(on app: Application, config: Config) { + app.get("hi") { req async throws -> Response in + var page = HelloContext( + title: "Say hello on Flamenet Messenger", + description: "Somebody shared their Flamenet Messenger address with you. " + + "Open it in the app, or get the app first.", + audience: config.audience, + iosLive: config.iosLive, + iosStoreURL: config.iosStoreURL) + page.altLang = "es" + page.altHref = publicOrigin(req) + "/es/hi" + page.defaultHref = publicOrigin(req) + "/hi" + page.canonical = publicOrigin(req) + "/hi" + page.origin = publicOrigin(req) + let view: View = try await req.view.render("hi", page) + return HelloContext.noindex(view) + } + + app.get("es", "hi") { req async throws -> Response in + var page = HelloContext( + title: "Saluda en Flamenet Messenger", + description: "Alguien compartió contigo su dirección de Flamenet Messenger. " + + "Ábrela en la aplicación, o descarga la aplicación primero.", + audience: config.audience, + iosLive: config.iosLive, + iosStoreURL: config.iosStoreURL) + page.lang = "es" + page.chrome = .spanish + page.altLang = "en" + page.altHref = publicOrigin(req) + "/hi" + page.defaultHref = publicOrigin(req) + "/hi" + page.canonical = publicOrigin(req) + "/es/hi" + page.origin = publicOrigin(req) + let view: View = try await req.view.render("es/hi", page) + return HelloContext.noindex(view) + } +} + +/// The hello-link page's context. The layout's fields, as `JoinContext` +/// carries them, and nothing about any address. +struct HelloContext: Encodable { + var lang: String = "en" + var section: String = "" + var hasAlternate: Bool = true + var altLang: String = "" + var altHref: String = "" + var defaultHref: String = "" + var chrome: LandingContext.Chrome = LandingContext.Chrome() + var canonical: String = "" + var origin: String = "" + /// Read by the layout, which then writes a robots meta tag. + var noindex: Bool = true + let title: String + let description: String + let audience: String + let assetVersion: String = AssetVersion.value + let iosLive: Bool + let iosStoreURL: String + + init(title: String, description: String, audience: String, iosLive: Bool, iosStoreURL: String) { + self.title = title + self.description = description + self.audience = audience + self.iosLive = iosLive + self.iosStoreURL = iosStoreURL + } + + /// Built by hand, as the invite page is, so the header can say noindex too. + static func noindex(_ view: View) -> Response { + let response = Response(status: .ok, body: .init(buffer: view.data)) + response.headers.contentType = .html + response.headers.replaceOrAdd(name: "X-Robots-Tag", value: "noindex") + return response + } +} diff --git a/Tests/FlamenetRelayTests/HelloPageTests.swift b/Tests/FlamenetRelayTests/HelloPageTests.swift new file mode 100644 index 0000000..8135d09 --- /dev/null +++ b/Tests/FlamenetRelayTests/HelloPageTests.swift @@ -0,0 +1,381 @@ +import Crypto +import Foundation +import Testing +import Vapor +import VaporTesting + +#if canImport(JavaScriptCore) + import JavaScriptCore +#endif + +@testable import FlamenetRelay + +/// The page a hello link opens (§6.34), and the script on it that reads the +/// address out of the fragment and must send it nowhere. +@Suite("hello link page", .serialized) +struct HelloPageTests { + + /// A real-shaped address: `fnid:` and the Crockford base32 of 32 bytes, + /// as both apps encode them. Not anybody's key. + static let fnid = "fnid:hc6m2jr7wrjqs8ytp12z5pn42rqrc7m4depk52h29k3r0j2sgnn0" + static let address = fnid + "@relay.flamenet.io" + + static var root: URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent() + } + + static func script() throws -> String { + try String(contentsOf: root.appendingPathComponent("Public/hi.js"), encoding: .utf8) + } + + 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-hi-\(UUID().uuidString).sqlite" + let attachments = NSTemporaryDirectory() + "fn-hi-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() + } + } + + // MARK: - The pages + + @Test("both pages render, ask not to be indexed, and stay out of the sitemap") + func pagesRender() async throws { + try await withRelayApp { app in + for (path, lang) in [("/hi", "en"), ("/es/hi", "es")] { + try await app.testing().test(.GET, path) { res in + #expect(res.status == .ok, "\(path)") + #expect(res.headers.first(name: "X-Robots-Tag") == "noindex") + #expect(res.headers.first(name: .setCookie) == nil) + let html = res.body.string + #expect(html.contains(#""#)) + #expect(html.contains(#""#)) + #expect(html.contains(#"", + "#", + "#" + fnid + "@.io", + "#javascript:alert(1)", + "#" + fnid + "%40relay.flamenet.io%22%3E%3Cscript%3Ealert(1)%3C%2Fscript%3E", + "#" + address + "%0Aevil.example", + "#%E0%A4%A", + ] + + @Test("a fragment is an address only in the shape the apps show") + func addressCheck() throws { + for (fragment, expected) in Self.valid { + #expect(try Self.parse(fragment) == expected, "\(fragment)") + } + for fragment in Self.invalid { + #expect(try Self.parse(fragment) == nil, "\(fragment)") + } + } + + /// The page's own wiring, run against stand-ins for the elements it + /// touches, so what reaches the page is checked, not only the check. + static func render(_ hash: String, then next: String? = nil) throws -> [String: String] { + let context = try #require(JSContext()) + var thrown: String? + context.exceptionHandler = { _, exception in thrown = exception?.toString() } + context.evaluateScript( + """ + var elements = {}; + ['hi-found', 'hi-missing', 'hi-address', 'hi-open', 'hi-copy', 'hi-copy-note'] + .forEach(function (id) { + elements[id] = { + hidden: true, textContent: 'unset', attributes: {}, listeners: {}, + setAttribute: function (k, v) { this.attributes[k] = String(v); }, + getAttribute: function (k) { return this.attributes[k] || null; }, + addEventListener: function (t, f) { this.listeners[t] = f; } + }; + }); + var document = { getElementById: function (id) { return elements[id] || null; } }; + var location = { hash: '' }; + var windowListeners = {}; + var window = { addEventListener: function (t, f) { windowListeners[t] = f; } }; + var navigator = {}; + """) + context.objectForKeyedSubscript("location").setObject(hash, forKeyedSubscript: "hash" as NSString) + context.evaluateScript(try script()) + if let next { + context.objectForKeyedSubscript("location").setObject(next, forKeyedSubscript: "hash" as NSString) + context.evaluateScript("windowListeners.hashchange();") + } + #expect(thrown == nil, "\(thrown ?? "")") + func read(_ js: String) -> String { context.evaluateScript(js).toString() } + return [ + "found.hidden": read("String(elements['hi-found'].hidden)"), + "missing.hidden": read("String(elements['hi-missing'].hidden)"), + "address": read("elements['hi-address'].textContent"), + "href": read("elements['hi-open'].attributes.href"), + "attributes": read("Object.keys(elements['hi-open'].attributes).join(',')"), + ] + } + + @Test("a good link shows the address and links the app to it; anything else shows neither") + func pageWiring() throws { + let good = try Self.render("#" + Self.address) + #expect(good["found.hidden"] == "false") + #expect(good["missing.hidden"] == "true") + #expect(good["address"] == Self.address) + #expect(good["href"] == "flamenetmessenger://hi#" + Self.address) + #expect(good["attributes"] == "href") + + for fragment in ["", "#", "#" + Self.address + "\">x"] { + let bad = try Self.render(fragment) + #expect(bad["found.hidden"] == "true", "\(fragment)") + #expect(bad["missing.hidden"] == "false", "\(fragment)") + #expect(bad["address"] == "", "\(fragment)") + #expect(bad["href"] == "flamenetmessenger://hi", "\(fragment)") + } + + // A second link opened in the same tab replaces the first, and a + // bad one clears it: the old address does not stay up. + let other = Self.fnid + "@relay2.flamenet.io" + #expect(try Self.render("#" + Self.address, then: "#" + other)["address"] == other) + let cleared = try Self.render("#" + Self.address, then: "#nonsense") + #expect(cleared["address"] == "") + #expect(cleared["found.hidden"] == "true") + } + #endif +} diff --git a/docs/API.md b/docs/API.md index c9ae2fd..d0855ed 100644 --- a/docs/API.md +++ b/docs/API.md @@ -294,7 +294,7 @@ plaintext message transport that once lived here was removed (relay#50, and Served as HTML or plain text to a browser, not as API. Listed because this document says a route not in it is not served: `/`, `/es`, `/status`, -`/status.json`, `/health`, `/releases`, `/join/{code}`, `/donate`, +`/status.json`, `/health`, `/releases`, `/join/{code}`, `/hi`, `/es/hi`, `/donate`, `/es/donate`, `POST /donate/intent`, `/downloads/android-latest.json`, `/robots.txt`, `/sitemap.xml`, `/llms.txt`, `/llms-full.txt`, `/.well-known/security.txt`, and the generated documentation pages. -- 2.54.0 From 4a20f39ef5ae84390e57ac72e87e883417221039 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Sun, 4 Oct 2026 09:07:00 -0400 Subject: [PATCH 03/11] Draft: one /metadata line for the Today file, for the owner to approve DRAFT. Not for deploy until the owner approves the wording, and a native speaker the Spanish. One line at the end of "The short version" on /metadata and /es/metadata saying the server offers one public Today file, the same for everybody, and that asking for it tells the server nothing it does not already see. It renders only on a relay that offers the file, because the page describes the running configuration, and each copy carries an HTML comment marking it as a draft. Co-Authored-By: Claude Opus 5.5 --- Resources/Views/es/metadata.leaf | 1 + Resources/Views/metadata.leaf | 1 + Sources/FlamenetRelay/Entrypoint.swift | 9 +++++-- Tests/FlamenetRelayTests/TodayFileTests.swift | 25 +++++++++++++++++++ 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/Resources/Views/es/metadata.leaf b/Resources/Views/es/metadata.leaf index 260cf9a..10f3188 100644 --- a/Resources/Views/es/metadata.leaf +++ b/Resources/Views/es/metadata.leaf @@ -16,6 +16,7 @@
  • Guarda los datos de tu cuenta: nombre de usuario, correo, contraseña (guardada revuelta) y la mitad pública de tus claves.
  • No guarda cuándo añadiste a un amigo, bloqueaste a alguien o entraste en un grupo. Eso se quitó a propósito.
  • Sus propios registros no contienen ninguna cuenta, dispositivo ni dirección, salvo que un operador active a propósito el registro detallado.
  • + #if(todayOffered):
  • También sirve un único archivo público de Hoy, el mismo para todo el mundo. Tu aplicación lo pide sin iniciar sesión, así que pedirlo no le dice al servidor nada que no vea ya.
  • #endif

    El resto de esta página es la misma respuesta con cada campo escrito uno por uno.

    diff --git a/Resources/Views/metadata.leaf b/Resources/Views/metadata.leaf index 74b8a2f..22ba100 100644 --- a/Resources/Views/metadata.leaf +++ b/Resources/Views/metadata.leaf @@ -17,6 +17,7 @@
  • It keeps your account details: username and the public half of your keys. On this server your password check and any recovery code live with the shared account service, and there is no email. A server with its own accounts also keeps your email and your password, stored scrambled.
  • It does not keep a record of when you added a friend, blocked someone, or joined a group. Those were removed on purpose.
  • Its own logs contain no account, device or address unless an operator deliberately turns on detailed logging.
  • + #if(todayOffered):
  • It also serves one public Today file, the same for everybody. Your app asks for it without signing in, so asking tells the server nothing it does not already see.
  • #endif

    The rest of this page is the same answer with every field spelled out.

    diff --git a/Sources/FlamenetRelay/Entrypoint.swift b/Sources/FlamenetRelay/Entrypoint.swift index 70cf938..b486009 100644 --- a/Sources/FlamenetRelay/Entrypoint.swift +++ b/Sources/FlamenetRelay/Entrypoint.swift @@ -1067,7 +1067,8 @@ func register(routes r: Routes, on app: Application) throws { reportDays: r.config.reportRetentionDays, // Read per-request: the storage flag is set later in // configure than this loop runs. - deleteOnAck: req.application.storage[Routes.DeleteOnAckKey.self] ?? false)) + deleteOnAck: req.application.storage[Routes.DeleteOnAckKey.self] ?? false), + todayOffered: r.config.today.offered) } for (slug, title, description) in pages { @@ -1135,7 +1136,8 @@ func register(routes r: Routes, on app: Application) throws { deviceExpiryDays: r.config.deviceExpiryDays, maxDevices: r.config.maxDevicesPerAccount, reportDays: r.config.reportRetentionDays, - deleteOnAck: req.application.storage[Routes.DeleteOnAckKey.self] ?? false))) + deleteOnAck: req.application.storage[Routes.DeleteOnAckKey.self] ?? false), + todayOffered: r.config.today.offered)) } } @@ -1892,6 +1894,9 @@ struct LandingContext: Encodable { /// The numbers /metadata states, taken from the running configuration so /// the page cannot drift from what this instance actually does. var retention: RetentionFacts? = nil + /// Whether this relay offers the Today file (§8.11), so /metadata only + /// describes it where it is served. + var todayOffered: Bool = false } /// The public origin a request arrived on, for canonical and Open Graph URLs. diff --git a/Tests/FlamenetRelayTests/TodayFileTests.swift b/Tests/FlamenetRelayTests/TodayFileTests.swift index b6bb14c..a05463c 100644 --- a/Tests/FlamenetRelayTests/TodayFileTests.swift +++ b/Tests/FlamenetRelayTests/TodayFileTests.swift @@ -610,4 +610,29 @@ struct TodayFileTests { #expect(res.status == .serviceUnavailable) } } + + /// /metadata is read from the running configuration, so it describes the + /// file only where the file is served. + @Test("the metadata page mentions the Today file only on a relay that serves one") + func metadataLine() async throws { + let english = "one public Today file" + let spanish = "archivo público de Hoy" + try await withRelayApp { app in + for path in ["/metadata", "/es/metadata"] { + let res = try await get(app, path) + #expect(res.status == .ok) + let html = String(decoding: res.body, as: UTF8.self) + #expect(!html.contains(english) && !html.contains(spanish), "\(path)") + } + } + let dir = try Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(atPath: dir) } + try Self.builderSample().write(to: URL(fileURLWithPath: dir + "today.json")) + try await withRelayApp(["FN_TODAY_FILE": dir + "today.json", "FN_TODAY_KEYS": Self.devPublicKey]) { app in + let en = try await get(app, "/metadata") + #expect(String(decoding: en.body, as: UTF8.self).contains(english)) + let es = try await get(app, "/es/metadata") + #expect(String(decoding: es.body, as: UTF8.self).contains(spanish)) + } + } } -- 2.54.0 From e7345230bceef7d45fc284c899b81fd70aab6db5 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Sun, 4 Oct 2026 09:46:18 -0400 Subject: [PATCH 04/11] The iPhone badge counts announced envelopes only The badge was the number of undelivered envelopes for the device, and delivery keys, receipts and answers wait in the same mailbox as messages, so one message read as 2. Each envelope now carries one bit, announced, set from the send's silent flag (urgent wins, as it does for the push), and the badge counts envelopes that are undelivered and announced. A deposit from another relay sends no push, so it is not announced. The bit lives in the row and goes with it. AddEnvelopeAnnounced adds the column NOT NULL DEFAULT false in one ADD, which SQLite accepts because of the default; on Postgres a constant default is a catalogue change. Rows already waiting take false: nothing recorded whether they were announced, false can only leave such a message out of the count until its device collects it, and true would count the silent envelopes already queued, the over-count being fixed. A new Envelope starts unannounced, so a row written by any other path can only leave the badge short. The /metadata row about the silent flag says it is never written down, which this makes false. Replacement wording is drafted for the owner and nothing public is changed here. Co-Authored-By: Claude Opus 5.5 --- Sources/FlamenetRelay/Migrations.swift | 25 +++ Sources/FlamenetRelay/Models.swift | 12 +- Sources/FlamenetRelay/Routes.swift | 32 ++- Sources/FlamenetRelay/RoutesFederation.swift | 3 + .../FlamenetRelayTests/BadgeCountTests.swift | 204 ++++++++++++++++++ .../FlamenetRelayTests/DeleteOnAckTests.swift | 2 + .../PostgresMigrationTests.swift | 42 ++++ 7 files changed, 309 insertions(+), 11 deletions(-) create mode 100644 Tests/FlamenetRelayTests/BadgeCountTests.swift diff --git a/Sources/FlamenetRelay/Migrations.swift b/Sources/FlamenetRelay/Migrations.swift index 8e9a330..ef79b7b 100644 --- a/Sources/FlamenetRelay/Migrations.swift +++ b/Sources/FlamenetRelay/Migrations.swift @@ -574,6 +574,29 @@ struct AddEnvelopeDeliveredAt: AsyncMigration { } } +/// One bit per waiting envelope: did the relay announce it. See +/// `Envelope.announced`. +/// +/// Rows already waiting take false. Nothing recorded whether they were +/// announced, and the badge is the count of announced envelopes, so false can +/// only leave a message that was already waiting at the upgrade out of the +/// count until its device collects it. True would count every silent envelope +/// already queued, which is the over-count this column exists to stop. +/// +/// One ADD with a constant default: SQLite accepts NOT NULL there only with a +/// default, and on Postgres 11 or later a constant default is a catalogue +/// change, not a rewrite of the table. +struct AddEnvelopeAnnounced: AsyncMigration { + func prepare(on database: any Database) async throws { + try await database.schema(Envelope.schema) + .field("announced", .bool, .required, .sql(.default(false))) + .update() + } + func revert(on database: any Database) async throws { + try await database.schema(Envelope.schema).deleteField("announced").update() + } +} + /// Bound how fast dead tuples accumulate in the tables holding user data. /// /// Postgres defaults are 50 rows plus 20% of the table, so `prekeys` needs @@ -789,6 +812,8 @@ enum RelayMigrations { GuardKeyLogAppendOnly(), CreateKeyLogWatermark(), // Last, so it can name every table above it. TuneUserDataAutovacuum(), + // A column, not a table, so it comes after the list above. + AddEnvelopeAnnounced(), ] } } diff --git a/Sources/FlamenetRelay/Models.swift b/Sources/FlamenetRelay/Models.swift index 4d38fd0..6fb76f1 100644 --- a/Sources/FlamenetRelay/Models.swift +++ b/Sources/FlamenetRelay/Models.swift @@ -106,9 +106,19 @@ final class Envelope: Model, @unchecked Sendable { /// Swept with the envelope it belongs to, because it is part of the same /// row and not a separate record of anybody. @OptionalField(key: "delivered_at") var deliveredAt: Date? + /// Whether the relay announced this envelope with a visible push, from the + /// send's `silent` flag. The iPhone badge counts only these, so the control + /// traffic around a message (delivery keys, receipts, answers) does not + /// show as more messages. It goes with the row, as the rest of it does. + @Field(key: "announced") var announced: Bool @Timestamp(key: "created_at", on: .create) var createdAt: Date? - init() {} + /// Unannounced until a send says otherwise, so a row written by any other + /// path can leave the badge short but never push it up. SQLite writes an + /// unset field as NULL, which the column refuses. + init() { + announced = false + } } /// A device's push token (§8.11). diff --git a/Sources/FlamenetRelay/Routes.swift b/Sources/FlamenetRelay/Routes.swift index 6ba121b..dd135c4 100644 --- a/Sources/FlamenetRelay/Routes.swift +++ b/Sources/FlamenetRelay/Routes.swift @@ -636,6 +636,11 @@ struct Routes: Sendable { request.logger.notice("\(detail)") } + // §6.14, the sender says whether this is time-critical, and whether it + // is to be announced at all. Urgent wins if a sender somehow sets both. + let urgent = input.urgent == true + let silent = input.silent == true && !urgent + let ids: [Int] = try await request.db.retryingTransaction { db in var written: [Int] = [] for m in input.messages where owned.contains(m.to_device) { @@ -647,6 +652,7 @@ struct Routes: Sendable { e.payload = m.payload e.envelopeID = m.envelope_id e.delivered = false + e.announced = !silent try await e.create(on: db) if let id = e.id { written.append(id) } } @@ -662,10 +668,6 @@ struct Routes: Sendable { // Detached, so the sender is not made to wait on Apple. let app = request.application if let apns = app.storage[APNs.EnabledKey.self] { - // §6.14, the sender says whether this is time-critical. - let urgent = input.urgent == true - // Urgent wins if a sender somehow sets both. - let silent = input.silent == true && !urgent Task.detached { await Self.pushToDevices( woken, apns: apns, urgent: urgent, @@ -733,12 +735,11 @@ struct Routes: Sendable { } continue } - // The badge is this device's undelivered count, a number the relay - // holds by necessity, carrying no content and no sender. - let waiting = - (try? await Envelope.query(on: app.db) - .filter(\.$toDevice == device) - .filter(\.$delivered == false).count()) ?? 0 + // A count the relay holds by necessity, carrying no content and no + // sender. Unknown counts as none: a badge that says too little is + // put right by the next push, and one that says too much is the + // complaint this count was narrowed for. + let waiting = (try? await Self.badgeCount(forDevice: device, on: app.db)) ?? 0 // An empty one is a device that has a VoIP token and no alert // token yet, not a device to push at. guard !row.token.isEmpty else { continue } @@ -753,6 +754,17 @@ struct Routes: Sendable { } } + /// The number on the iPhone badge: this device's waiting envelopes that + /// were announced. Silent ones (delivery keys, receipts, answers) wait in + /// the same mailbox and are not messages, so they are not counted. + static func badgeCount(forDevice device: String, on db: any Database) async throws -> Int { + try await Envelope.query(on: db) + .filter(\.$toDevice == device) + .filter(\.$delivered == false) + .filter(\.$announced == true) + .count() + } + /// Delete the envelopes a poll cursor has acknowledged. static func deleteAcknowledged( toDevice deviceID: String, after: Int, diff --git a/Sources/FlamenetRelay/RoutesFederation.swift b/Sources/FlamenetRelay/RoutesFederation.swift index 48ea1c4..75bce41 100644 --- a/Sources/FlamenetRelay/RoutesFederation.swift +++ b/Sources/FlamenetRelay/RoutesFederation.swift @@ -117,6 +117,9 @@ extension Routes { row.payload = e.payload row.envelopeID = e.envelope_id row.delivered = false + // A deposit sends no push, so nothing announced it, and it stays + // out of the badge count. + row.announced = false try await row.create(on: db) accepted += 1 } diff --git a/Tests/FlamenetRelayTests/BadgeCountTests.swift b/Tests/FlamenetRelayTests/BadgeCountTests.swift new file mode 100644 index 0000000..c7f30e0 --- /dev/null +++ b/Tests/FlamenetRelayTests/BadgeCountTests.swift @@ -0,0 +1,204 @@ +import Crypto +import Fluent +import Foundation +import SQLKit +import Testing +import Vapor + +@testable import FlamenetRelay + +/// The iPhone badge counts messages, not the traffic that travels with them. +/// +/// It counted every undelivered envelope, and delivery keys, receipts and +/// answers wait in the same mailbox as messages. One real message showed as 2. +/// The sender marks that traffic silent; the relay now keeps one bit per +/// waiting envelope saying whether it announced it, and counts only those. +@Suite("the badge counts announced envelopes only") +struct BadgeCountTests { + + private let now = 1_700_000_000 + private let ip = "203.0.113.9" + + private func pair(_ h: RelayHarness) async throws + -> (sender: RelayHarness.TestDevice, recipient: RelayHarness.TestDevice) + { + let s = h.device(user: 1, id: "aaaa-0001") + let r = h.device(user: 2, id: "bbbb-0002") + try await h.register(s, on: h.db) + try await h.register(r, on: h.db) + return (s, r) + } + + /// `count` envelopes to the recipient's device in one identified send. + private func send( + _ h: RelayHarness, from s: RelayHarness.TestDevice, to r: RelayHarness.TestDevice, + silent: Bool? = nil, urgent: Bool? = nil, count: Int = 1 + ) async throws { + let envelopes = (0.. Int { + try await Routes.badgeCount(forDevice: d.deviceID, on: h.db) + } + + @Test("one message with silent traffic around it is a badge of 1") + func oneMessageIsOne() async throws { + try await withRelay { h in + let (s, r) = try await pair(h) + try await send(h, from: s, to: r) + // A receipt, a delivery key and an answer, say. + try await send(h, from: s, to: r, silent: true, count: 3) + #expect(try await badge(h, r) == 1) + // All four are still waiting: the count changed, not the mailbox. + let waiting = try await Envelope.query(on: h.db) + .filter(\.$toDevice == r.deviceID).filter(\.$delivered == false).count() + #expect(waiting == 4) + } + } + + @Test("collecting the mail clears the count") + func collectingClears() async throws { + try await withRelay { h in + let (s, r) = try await pair(h) + try await send(h, from: s, to: r, count: 2) + try await send(h, from: s, to: r, silent: true) + #expect(try await badge(h, r) == 2) + + _ = try await h.routes.poll( + h.request(.GET, "/e2e/messages", as: r, now: now), now: now) + #expect(try await badge(h, r) == 0) + } + } + + @Test("silent sends never count, however many; a call is announced") + func silentNeverCounts() async throws { + try await withRelay { h in + let (s, r) = try await pair(h) + for _ in 0..<5 { try await send(h, from: s, to: r, silent: true) } + try await send(h, from: s, to: r, silent: true, count: 10) + #expect(try await badge(h, r) == 0) + + // Urgent wins over silent, as it does for the push itself: the + // relay rings the phone, so the envelope was announced. + try await send(h, from: s, to: r, silent: true, urgent: true) + #expect(try await badge(h, r) == 1) + } + } + + @Test("a sealed send carries the flag the same way") + func sealedSendsToo() async throws { + try await withRelay { h in + let (_, r) = try await pair(h) + let raw = Data((0..<32).map { UInt8($0) }) + let published = DeliveryKey() + published.id = r.userID + published.digest = Data(SHA256.hash(data: raw)).base64EncodedString() + try await published.create(on: h.db) + + for silent in [true, false, true] { + let body = Data( + (#"{"to":\#(r.userID),"messages":[{"to_device":"\#(r.deviceID)","payload":"ct"}],"# + + #""delivery_key":"\#(raw.base64EncodedString())","silent":\#(silent)}"#).utf8) + let req = h.anonymousRequest(.POST, "/e2e/messages", body: body) + _ = try await h.routes.send(req, body: body, clientIP: ip, now: now) + } + #expect(try await badge(h, r) == 1) + } + } + + @Test("a deposit from another relay is not announced, so it is not counted") + func depositsAreNotCounted() async throws { + try await withRelay { h in + let (_, r) = try await pair(h) + let user = User() + user.id = r.userID + user.username = "bob" + user.email = "bob@example.com" + user.passwordHash = "x" + user.displayName = "Bob" + user.chosenStatus = ChosenStatus.online.rawValue + user.fnid = "fnid:bob" + try await user.create(on: h.db) + + let placed = try await Routes.placeDeposits( + [ + FederationDepositRequest.Item( + to_fnid: "fnid:bob", to_device: r.deviceID, payload: "afar", + envelope_id: nil) + ], on: h.db) + #expect(placed.accepted == 1) + #expect(try await badge(h, r) == 0) + } + } + + // MARK: - The migration + + /// Everything before the migration under test, then rows written the way + /// the old schema wrote them, then the migration. + private func migrateAcross(_ app: Application) async throws { + let all = RelayMigrations.all() + let cut = try #require(all.firstIndex { $0 is AddEnvelopeAnnounced }) + for m in all[.. Date: Sun, 4 Oct 2026 10:01:46 -0400 Subject: [PATCH 05/11] /metadata: the silent flag is kept on the waiting envelope The row said the flag is never written down. With the badge counting announced envelopes only, it is kept, inverted, as one bit on the envelope until the envelope goes. The English row is the owner's approved wording. The Spanish row is the draft, marked in the page as waiting for a native speaker's check before any deploy, so the old Spanish line, false once this ships, does not go out. Co-Authored-By: Claude Opus 5.5 --- Resources/Views/es/metadata.leaf | 3 ++- Resources/Views/metadata.leaf | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Resources/Views/es/metadata.leaf b/Resources/Views/es/metadata.leaf index 260cf9a..c1b2306 100644 --- a/Resources/Views/es/metadata.leaf +++ b/Resources/Views/es/metadata.leaf @@ -33,7 +33,8 @@ El texto cifradoEs el mensaje; el servidor no tiene ninguna clave que lo abra#(retention.undeliveredDays) días si nunca se recoge Adjuntos (fotos, notas de voz, historias, todo cifrado)Los dispositivos los descargan aparte del sobre que los referencia#(retention.attachmentDays) días “Este es urgente”, una marca en el sobre, no se guardaUna llamada tiene que sonar mientras todavía está sonando, así que el remitente le dice al servidor que envíe la notificación con prioridad alta en vez de esperar a que la aplicación consulte. En la práctica, eso significa que el servidor distingue una llamada de un mensaje: no quién llama, ni sobre qué, pero sí que este es urgente. Es lo más informativo que un remitente decide revelar, y es una decisión: nada obliga a poner la marca.Nunca se escribe. Se lee para decidir la notificación y se va con la petición. - “Este no necesita atención”, la misma forma, no se guardaLa otra cara de la misma moneda: el remitente dice que un sobre es mantenimiento y no algo por lo que interrumpir a una persona, así que el servidor envía un despertar en segundo plano en vez de una alerta. Eso le dice más o menos lo mismo que la marca de arriba, al revés. La alternativa, no enviar nada y dejar que la aplicación decida tras descifrar, significa depender de notificaciones silenciosas que iOS limita, así que el trato honesto es una pequeña revelación a cambio de notificaciones que sí llegan.Nunca se escribe, igual que arriba. + + “Este no necesita atención”, la misma forma, se guarda como un sí o un no en el sobreLa otra cara de la misma moneda: el remitente dice que un sobre es mantenimiento y no algo por lo que interrumpir a una persona, así que el servidor envía un despertar en segundo plano en vez de una alerta. Eso le dice más o menos lo mismo que la marca de arriba, al revés. La alternativa, no enviar nada y dejar que la aplicación decida tras descifrar, significa depender de notificaciones silenciosas que iOS limita, así que el trato honesto es una pequeña revelación a cambio de notificaciones que sí llegan. El servidor guarda la respuesta en el sobre que espera, para que el número del icono de la aplicación en el iPhone cuente mensajes y no los acuses y las claves que viajan con ellos.Con el sobre del que forma parte: #if(retention.deleteOnAck):en cuanto tu aplicación confirma la recepción#else:#(retention.deliveredDays) día(s) después de entregarse#endif, o #(retention.undeliveredDays) días si nunca se recoge. Contadores del límite de registros (por IP)Las cuentas nuevas están limitadas por dirección y por hora, y un contador necesita la direcciónuna hora; el contador solo vive su ventana diff --git a/Resources/Views/metadata.leaf b/Resources/Views/metadata.leaf index 74b8a2f..86ab0f3 100644 --- a/Resources/Views/metadata.leaf +++ b/Resources/Views/metadata.leaf @@ -34,7 +34,7 @@ The ciphertextIt is the message; the server holds no key that opens it#(retention.undeliveredDays) days if never collected Attachment blobs (photos, voice notes, stories, all ciphertext)Devices fetch them separately from the envelope that references them#(retention.attachmentDays) days “This one is urgent”, a flag on the envelope, not storedA call has to ring while it is still ringing, so the sender tells the server to push at high priority instead of waiting for the app to poll. In practice that means the server can tell a call from a message, not who is calling, or about what, but that this one is time-critical. It is the single most informative thing a sender chooses to reveal, and it is a choice: nothing forces the flag on.Not stored as a value. But it leaves a trace, and saying “never written down” was wrong: every row in Ring attempts below is written only for an urgent envelope, so a row existing for your device is the flag. That is 7 days, with the device and the time, and no content. - “This one needs no attention”, the same shape, not storedThe other side of the same coin: the sender says an envelope is housekeeping rather than something a person should be interrupted for, so the server sends a background wake instead of an alert. That tells it roughly as much as the flag above does, inverted. The alternative, sending nothing and letting the app decide after it decrypts, means relying on silent pushes that iOS throttles, so the honest trade is a small disclosure for notifications that actually arrive.Never written down, and unlike the flag above this one really does leave no trace: it selects the kind of push and nothing records that it was set. + “This one needs no attention”, the same shape, kept as one yes-or-no on the envelopeThe other side of the same coin: the sender says an envelope is housekeeping rather than something a person should be interrupted for, so the server sends a background wake instead of an alert. That tells it roughly as much as the flag above does, inverted. The alternative, sending nothing and letting the app decide after it decrypts, means relying on silent pushes that iOS throttles, so the honest trade is a small disclosure for notifications that actually arrive. The server keeps the answer on the waiting envelope so the number on the iPhone app's icon counts messages, not the receipts and keys that travel with them.With the envelope it belongs to: #if(retention.deleteOnAck):the moment your app confirms receipt#else:#(retention.deliveredDays) day(s) once delivered#endif, or #(retention.undeliveredDays) days if never collected. Sign-up throttle counters (per IP)New accounts are limited per address per hour, and a counter needs the addressone hour, the counter lives only for its window -- 2.54.0 From d0e5cf858f487ee939c78602f5db707b4e296dc7 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Sun, 4 Oct 2026 10:02:10 -0400 Subject: [PATCH 06/11] Configuration: a closed relay fetches the Today file if told to "A closed relay opens no outbound connections at all" stopped being true once FN_TODAY_SOURCE existed. It now says the exception, and that the fetch is all it opens one for. Wording approved by the owner; the generated page is rebuilt from the markdown. Co-Authored-By: Claude Opus 5.5 --- Resources/Views/configuration.leaf | 11 ++++++----- docs/CONFIGURATION.md | 7 ++++--- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/Resources/Views/configuration.leaf b/Resources/Views/configuration.leaf index 3ea9e44..8e7551a 100644 --- a/Resources/Views/configuration.leaf +++ b/Resources/Views/configuration.leaf @@ -3,7 +3,7 @@ + source-sha256: a1c6273294cd78f3 -->

    Configuring a relay

    @@ -316,10 +316,11 @@ graph is spread across operators you do not run. That is the honest reason this is off by default. If your relay exists so one group can talk without anyone else involved, closed is the correct answer, not the cautious one.

    -

    A closed relay opens no outbound connections at all. The only -outbound code in the relay is the SMTP client below, itself disabled -unless configured. No telemetry, no update check, no directory to -register with.

    +

    A closed relay opens no outbound connections at all, unless +FN_TODAY_SOURCE is set, and then only to fetch the Today +file. The only other outbound code in the relay is the SMTP client +below, itself disabled unless configured. No telemetry, no update check, +no directory to register with.

    Shared FlameNet accounts

    Only for a relay that accepts identities from a FlameNet account service. Leaving all of these unset gives you a relay with its own user diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 467108c..1fb4acb 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -114,9 +114,10 @@ operators you do not run. That is the honest reason this is off by default. If your relay exists so one group can talk without anyone else involved, `closed` is the correct answer, not the cautious one. -A closed relay opens no outbound connections at all. The only outbound -code in the relay is the SMTP client below, itself disabled unless configured. -No telemetry, no update check, no directory to register with. +A closed relay opens no outbound connections at all, unless `FN_TODAY_SOURCE` +is set, and then only to fetch the Today file. The only other outbound code in +the relay is the SMTP client below, itself disabled unless configured. No +telemetry, no update check, no directory to register with. ## Shared FlameNet accounts -- 2.54.0 From 3230d4c748f94bac4de637fd06374ddf1d7fe929 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Sun, 4 Oct 2026 10:22:55 -0400 Subject: [PATCH 07/11] =?UTF-8?q?Cite=20the=20Today=20file=20as=20=C2=A78.?= =?UTF-8?q?12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §8.11 is push-token registration, which shipped relay and iOS code already cite, so the spec gives the Today file §8.12 (DESIGN 34). Every 0.32 citation of the Today file moves to §8.12: TodayFile.swift, Config.swift, Entrypoint.swift, TodayFileTests, docs/CONFIGURATION.md and docs/API.md, with configuration.leaf and api.leaf rebuilt from the markdown. The push-token citations stay §8.11. Co-Authored-By: Claude Opus 5.5 --- Resources/Views/api.leaf | 4 ++-- Resources/Views/configuration.leaf | 4 ++-- Sources/FlamenetRelay/Config.swift | 2 +- Sources/FlamenetRelay/Entrypoint.swift | 6 +++--- Sources/FlamenetRelay/TodayFile.swift | 6 +++--- Tests/FlamenetRelayTests/TodayFileTests.swift | 2 +- docs/API.md | 2 +- docs/CONFIGURATION.md | 2 +- 8 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Resources/Views/api.leaf b/Resources/Views/api.leaf index 905321d..ce93fda 100644 --- a/Resources/Views/api.leaf +++ b/Resources/Views/api.leaf @@ -3,7 +3,7 @@ + source-sha256: 65f886a5e92450a9 -->

    Relay API

    @@ -86,7 +86,7 @@ be a transparency log.

    GET /today.json none -The signed Today file, when this relay offers one (§8.11) +The signed Today file, when this relay offers one (§8.12) diff --git a/Resources/Views/configuration.leaf b/Resources/Views/configuration.leaf index 8e7551a..d939a30 100644 --- a/Resources/Views/configuration.leaf +++ b/Resources/Views/configuration.leaf @@ -3,7 +3,7 @@ + source-sha256: 53d6473c1a7fb6f4 -->

    Configuring a relay

    @@ -725,7 +725,7 @@ looking wrong. With this set, the publishable key stays the

    The Today file

    Off. When it is on, the relay serves one public file at -GET /today.json (§8.11): headlines, markets, weather and +GET /today.json (§8.12): headlines, markets, weather and the like, built and signed somewhere else, and the same bytes for everybody. The relay checks the signature and serves the file exactly as it arrived.

    diff --git a/Sources/FlamenetRelay/Config.swift b/Sources/FlamenetRelay/Config.swift index d123991..100e18b 100644 --- a/Sources/FlamenetRelay/Config.swift +++ b/Sources/FlamenetRelay/Config.swift @@ -168,7 +168,7 @@ struct Config: Sendable { let issuerSigningKeyRaw: Data? let tokenLifetime: Int - /// The Today file (§8.11): its source, the keys that may sign it, and where + /// The Today file (§8.12): its source, the keys that may sign it, and where /// a copy is kept across restarts. Unset, the relay does not offer it. var today = TodayConfig(file: nil, source: nil, keys: nil, cache: nil) diff --git a/Sources/FlamenetRelay/Entrypoint.swift b/Sources/FlamenetRelay/Entrypoint.swift index b486009..9f6cfff 100644 --- a/Sources/FlamenetRelay/Entrypoint.swift +++ b/Sources/FlamenetRelay/Entrypoint.swift @@ -241,7 +241,7 @@ func configure(_ app: Application) async throws { try register(routes: routes, on: app) - // §8.11, the Today file. The route exists either way: a relay that does not + // §8.12, the Today file. The route exists either way: a relay that does not // offer the file answers 404 not_offered in JSON, which a client can tell // apart from a missing page. TodayFile.describe(config.today, logger: app.logger) @@ -1894,7 +1894,7 @@ struct LandingContext: Encodable { /// The numbers /metadata states, taken from the running configuration so /// the page cannot drift from what this instance actually does. var retention: RetentionFacts? = nil - /// Whether this relay offers the Today file (§8.11), so /metadata only + /// Whether this relay offers the Today file (§8.12), so /metadata only /// describes it where it is served. var todayOffered: Bool = false } @@ -2069,7 +2069,7 @@ struct SecurityHeadersMiddleware: AsyncMiddleware { // API answers are per-credential and per-moment: a token, a device // list, a 401. No cache between here and the phone may keep one. // The Today file is not one of them: the same bytes for everybody, and - // its route sets its own Cache-Control (§8.11). + // its route sets its own Cache-Control (§8.12). let todayFile = request.url.path.split(separator: "/") == ["today.json"] if response.headers.contentType?.subType == "json", !todayFile { response.headers.replaceOrAdd(name: .cacheControl, value: "no-store") diff --git a/Sources/FlamenetRelay/TodayFile.swift b/Sources/FlamenetRelay/TodayFile.swift index 6f1ebcd..2000835 100644 --- a/Sources/FlamenetRelay/TodayFile.swift +++ b/Sources/FlamenetRelay/TodayFile.swift @@ -4,7 +4,7 @@ import Foundation import NIOCore import Vapor -/// Where the Today file comes from and which keys may sign it (§8.11). Read +/// Where the Today file comes from and which keys may sign it (§8.12). Read /// once at startup with the rest of `Config`. struct TodayConfig: Sendable { enum Source: Sendable, Equatable { @@ -74,7 +74,7 @@ struct TodayConfig: Sendable { } } -/// The one public file a relay can offer its apps (§8.11): the same bytes for +/// The one public file a relay can offer its apps (§8.12): the same bytes for /// everybody, signed by a key this relay was configured with, served exactly /// as they arrived. /// @@ -442,7 +442,7 @@ struct FileStamp: Equatable { } } -/// `GET /today.json` (§8.11). +/// `GET /today.json` (§8.12). /// /// Reads `If-None-Match` and nothing else from the request. No credential, no /// cookie, no query, and no log line: the apps ask for this at most hourly, and diff --git a/Tests/FlamenetRelayTests/TodayFileTests.swift b/Tests/FlamenetRelayTests/TodayFileTests.swift index a05463c..3fa62fb 100644 --- a/Tests/FlamenetRelayTests/TodayFileTests.swift +++ b/Tests/FlamenetRelayTests/TodayFileTests.swift @@ -8,7 +8,7 @@ import VaporTesting @testable import FlamenetRelay -/// The Today file (§8.11): what the relay serves, what it refuses, and that a +/// The Today file (§8.12): what the relay serves, what it refuses, and that a /// refusal never takes the last good file down with it. @Suite("today file", .serialized) struct TodayFileTests { diff --git a/docs/API.md b/docs/API.md index d0855ed..70f22a4 100644 --- a/docs/API.md +++ b/docs/API.md @@ -58,7 +58,7 @@ transparency log only its own users could audit would not be a transparency log. | Route | Auth | Purpose | |---|---|---| -| `GET /today.json` | none | The signed Today file, when this relay offers one (§8.11) | +| `GET /today.json` | none | The signed Today file, when this relay offers one (§8.12) | The same bytes for everybody, served exactly as they were signed and only after the signature checks against `FN_TODAY_KEYS`. 404 diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 1fb4acb..a78a325 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -244,7 +244,7 @@ All optional. Each is absent rather than half-enabled when unconfigured. ## The Today file Off. When it is on, the relay serves one public file at `GET /today.json` -(§8.11): headlines, markets, weather and the like, built and signed somewhere +(§8.12): headlines, markets, weather and the like, built and signed somewhere else, and the same bytes for everybody. The relay checks the signature and serves the file exactly as it arrived. -- 2.54.0 From e332ce83a11c3e150aaf63d636ed2f1a85752bb2 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Sun, 4 Oct 2026 11:10:43 -0400 Subject: [PATCH 08/11] /hi: lower the host by ASCII rules only, and refuse spaces DESIGN 41. JavaScript's toLowerCase folds a Kelvin sign (U+212A) into "k", so the page took relay.eample.com as relay.ekample.com, a host both apps refuse. The host is now lowered A to Z only; any other character stays as it is and fails the ASCII host check. Whitespace anywhere in the address, around the @ included, is refused outright, and the 400-character fragment cap stays. Two JavaScriptCore tests: the Kelvin case (plain and percent-encoded), which failed before this change, and spaces around the @, which the old patterns already refused and now pins. Co-Authored-By: Claude Opus 5.5 --- Public/hi.js | 15 ++++++--- Tests/FlamenetRelayTests/HelloPageTests.swift | 32 +++++++++++++++++++ 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/Public/hi.js b/Public/hi.js index 095503d..91ae7de 100644 --- a/Public/hi.js +++ b/Public/hi.js @@ -11,9 +11,13 @@ var FNID = /^fnid:[0-9a-hjkmnp-tv-z]{52}$/; var HOST = /^[a-z0-9.-]+$/; - // The address in a fragment, or null. Percent-decoded once. The host is - // lower-cased, as both apps do; the fnid is not, because the apps read it - // only in lower case. 400 characters is far past any real address. + // The address in a fragment, or null. Percent-decoded once, and no space + // anywhere in it. The host is lowered by ASCII rules only, A to Z and + // nothing else: toLowerCase folds a Kelvin sign into "k", which would turn + // a host the apps refuse into one they accept, so any non-ASCII character + // is left as it is and fails the host check. The fnid is not lowered, + // because the apps read it only in lower case. 400 characters is far past + // any real address. function parseHelloAddress(fragment) { var raw = String(fragment || ''); if (raw.charAt(0) === '#') raw = raw.slice(1); @@ -23,10 +27,13 @@ } catch (e) { return null; } + if (/\s/.test(raw)) return null; var at = raw.indexOf('@'); if (at < 0 || raw.indexOf('@', at + 1) >= 0) return null; var fnid = raw.slice(0, at); - var host = raw.slice(at + 1).toLowerCase(); + var host = raw.slice(at + 1).replace(/[A-Z]/g, function (c) { + return String.fromCharCode(c.charCodeAt(0) + 32); + }); if (!FNID.test(fnid) || !HOST.test(host) || host.indexOf('.') < 0) return null; return fnid + '@' + host; } diff --git a/Tests/FlamenetRelayTests/HelloPageTests.swift b/Tests/FlamenetRelayTests/HelloPageTests.swift index 8135d09..ab4b68a 100644 --- a/Tests/FlamenetRelayTests/HelloPageTests.swift +++ b/Tests/FlamenetRelayTests/HelloPageTests.swift @@ -311,6 +311,38 @@ struct HelloPageTests { } } + /// DESIGN 41: the host is ASCII only, lowered by ASCII rules only. + /// JavaScript's toLowerCase folds a Kelvin sign into "k", which would + /// show and link relay.ekample.com for a host both apps refuse. + @Test("a non-ASCII host is refused, never folded into ASCII") + func noFoldedHosts() throws { + for host in [ + "relay.e\u{212A}ample.com", "relay.\u{212A}.io", "r\u{0130}lay.io", + "relay.flamenet.i\u{00F6}", "relay.e%E2%84%AAample.com", + ] { + #expect(try Self.parse("#" + Self.fnid + "@" + host) == nil, "\(host)") + } + // Upper-case ASCII is still lowered. + #expect(try Self.parse("#" + Self.fnid + "@RELAY.Flamenet.IO") == Self.address) + } + + @Test("no space anywhere in the address, around the @ included") + func noSpaces() throws { + for fragment in [ + "#" + Self.fnid + " @relay.flamenet.io", + "#" + Self.fnid + "@ relay.flamenet.io", + "#" + Self.fnid + " @ relay.flamenet.io", + "#" + Self.fnid + "%20@relay.flamenet.io", + "#" + Self.fnid + "@%20relay.flamenet.io", + "#" + Self.address + "%20", + "# " + Self.address, + "#" + Self.fnid + "\u{00A0}@relay.flamenet.io", + "#" + Self.fnid + "@relay.flamenet.io%09", + ] { + #expect(try Self.parse(fragment) == nil, "\(fragment)") + } + } + /// The page's own wiring, run against stand-ins for the elements it /// touches, so what reaches the page is checked, not only the check. static func render(_ hash: String, then next: String? = nil) throws -> [String: String] { -- 2.54.0 From 1ff23af40dc82488e6339d06050149fe32d31856 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Sun, 4 Oct 2026 20:02:14 -0400 Subject: [PATCH 09/11] Pin flamenet-e2e at the 0.32 spec and regenerate its pages .e2e-ref moves to d07ed14 (docs/spec-0-32). spec.leaf and engine.leaf are rebuilt from that commit with Scripts/build-doc-pages.sh (pandoc 3.11); the other seven pages come out unchanged, and check-doc-pages.py reports all nine current. The pinned commit has to be on the remote before this pipeline runs, so the spec branch is pushed first. Co-Authored-By: Claude Opus 5.5 --- .e2e-ref | 2 +- Resources/Views/engine.leaf | 10 +- Resources/Views/spec.leaf | 1120 ++++++++++++++++++++++++++++++++--- 3 files changed, 1060 insertions(+), 72 deletions(-) diff --git a/.e2e-ref b/.e2e-ref index fbd7083..6de46f8 100644 --- a/.e2e-ref +++ b/.e2e-ref @@ -1 +1 @@ -522560769b6b838c1081e8e0a4db1be66383675d +d07ed14ac4daace8fb959187b2df181f8f6152be diff --git a/Resources/Views/engine.leaf b/Resources/Views/engine.leaf index 90b40ce..690431c 100644 --- a/Resources/Views/engine.leaf +++ b/Resources/Views/engine.leaf @@ -3,7 +3,7 @@ + source-sha256: af6749af70a1d2c0 -->

    flamenet-e2e

    @@ -33,7 +33,7 @@ one:

    iOS (CryptoKit) -test/interop.mjs, 77 assertions, both directions +test/interop.mjs, 92 assertions, both directions v1 + post-quantum @@ -43,7 +43,7 @@ one:

    iOS ⇄ Android directly -test/interop-swift-kotlin.mjs, 48 assertions +test/interop-swift-kotlin.mjs, 77 assertions v1 + post-quantum @@ -198,7 +198,9 @@ every change, because each needs something npm test cannot assume: federation and multihoming want a second relay, interop-kotlin and interop-swift-kotlin an Android toolchain, -fleet and load a throwaway stack.

    +fleet and load a throwaway stack, and +relay-today a relay built with the Today file route +(§8.12).

    Server-side behaviour is covered by the relay's own Swift test target alongside test/relay.mjs.

    Protocol versions

    diff --git a/Resources/Views/spec.leaf b/Resources/Views/spec.leaf index 2a9ba77..ee0a05c 100644 --- a/Resources/Views/spec.leaf +++ b/Resources/Views/spec.leaf @@ -3,7 +3,7 @@ + source-sha256: e1a531ce9b3bf30d -->

    Flamenet @@ -371,6 +371,15 @@ a voice note (dur required, w/h sent as 0; RECOMMENDED encoding AAC in an MPEG-4 container, mime: "audio/mp4", so every platform's native decoder opens it), and story.

    +

    A sticker names a picture by pack and +id and never carries the picture: +{"fnc":1,"kind":"sticker","stk":{"pack":"flamenet.retro","id":"flame"}}. +Each app draws it from the packs it ships. There are several packs, and +some draw pictures where others draw a glyph. What a pack file holds, +where its pictures live, and the order a client falls back through when +it lacks a pack or an id are set out in STICKERS.md +in the iOS repository. None of it changes the wire.

    Stories. A story is usually an image attachment addressed to every buddy (a story with no attachment at all is §6.21): the sender seals and uploads the blob @@ -463,6 +472,55 @@ coerce a number to a boolean, so a receiver reading only the boolean form answers false and silently files a story reaction against a message that does not exist. The same rule applies to fwd (§6.16).

    +

    Kinds added in 0.32. §6.31 to §6.35 add +card, event, rsvp, +game, gmove, gend and +unpost, and these rules hold for every one of them:

    +
      +
    • card, event and game are +renderable. Each carries mid (§6.9), and in a group +gid and gname as a group text does (§6.7). +They can be replied to, reacted to, pinned and deleted. They follow the +conversation's timer (§6.11), and a timer of their own +(secs, §6.17) is ignored in 0.32. A build that predates +them draws the placeholder (§6.10).
    • +
    • rsvp, gmove, gend and +unpost are control traffic and set "ctl":1 +(§6.10). A card, event or game +that carries ctl is consumed as control and never drawn. An +envelope carries ctl when it has the key with any value but +0, false or null.
    • +
    • The other flags (fwd, st, +cancel) are on when they are 1 or +true, and off for 0, false, +absence or anything else. An integer field takes any JSON number, +rounded down when it has a fraction; a boolean or a string there leaves +the field absent.
    • +
    • Every string is plain text: drawn as text, never as markup, and cut +to its bound when drawn, never refused for its length. An unknown field +is ignored, and is never a reason to drop an envelope: a +gmove that also carries an r is still a move. +An optional field with no value is left out, never written as +null.
    • +
    • The examples in those sections are the bytes both apps write, and +both apps' tests pin them. Keys come in the order shown, and strings are +escaped as Swift's JSONEncoder escapes them with slashes +left alone: " and \ are escaped, control +characters become \b, \f, \n, +\r, \t or \u00xx in lower case, +and every other character, / included, is written as +itself. A receiver MUST NOT depend on key order or on escaping, beyond +the rule above that the plaintext starts with {"fnc":. +test/vectors/content-kinds-v1.json holds the examples and +the cases a receiver has to handle.
    • +
    • What a client keeps for these kinds belongs to a conversation, so +§6.28 applies: off the record, it is held in memory and never written +down.
    • +
    • None of them is sent as a sent-copy (§17.2). An envelope from the +reader's own account, its sender proven (§19), is shown as the reader's +own where it can be placed, in a group by its gid, and is +dropped where it cannot.
    • +

    6.7 Groups (pairwise fan-out)

    Groups add no new cryptography. A group message is the same content envelope, encrypted independently to every member @@ -697,15 +755,17 @@ else.

    Builds predating the flag still show the placeholder. Nothing fixes those; the flag stops the set of affected releases from growing.

    The flag is the number 1, and a receiver MUST also -accept true. Early Kotlin builds wrote -"ctl":true, and those builds are on phones and cannot be -recalled. The cost of the disagreement was far larger than the field: -the Swift engine decodes it as a number through a synthesised -Codable, so a boolean did not fail to set the flag, it -failed the whole envelope's decode and dropped the message onto the -plain-text path. An Android user turning on disappearing messages sent -an iPhone a bubble containing raw JSON, and the timer was never applied -at that end.

    +accept true. An envelope carries ctl when it +has the key with any value but 0, false or +null, so a "1" written as a string makes it +control too. Early Kotlin builds wrote "ctl":true, and +those builds are on phones and cannot be recalled. The cost of the +disagreement was far larger than the field: the Swift engine decodes it +as a number through a synthesised Codable, so a boolean did +not fail to set the flag, it failed the whole envelope's decode and +dropped the message onto the plain-text path. An Android user turning on +disappearing messages sent an iPhone a bubble containing raw JSON, and +the timer was never applied at that end.

    Nothing caught it because each engine parsed what it had written itself, the piece-level tests passed on both sides, and no vector covers this envelope. It is the standing argument for the interop harness: @@ -785,6 +845,23 @@ another is a phishing primitive, so clients SHOULD show the host.

    Everything here is optional. A client that does not implement §6.12 shows the message text, URL included, and loses nothing but the card.

    +

    A site from the directory. Both apps ship +flamenet.io's directory of small websites, and a person can send a site +from it to a chat. That is an ordinary text whose words are the site's +address and nothing else, with a preview built from the directory entry, +so nobody fetches anything, to make the preview or to draw it:

    +
    {"fnc":1,"kind":"text","mid":"<id>","caption":"https://example.com/","lnk":{"url":"https://example.com/","title":"Example","desc":""}}
    +

    title is the site's title in the directory, at most 120 +characters. desc is present and empty. The directory's +description of a site is FlameNet's words, and a preview reads as the +site's own, so the description never travels. The key itself has to be +there, because iPhone builds up to 0.31 refuse a preview without it, and +the message with it. A sender with previews turned off sends the address +alone. In 0.32 a site can be sent only to a one-to-one chat with +somebody on the same relay. The two apps order and escape this envelope +differently, which is allowed, so its vectors in +test/vectors/content-kinds-v1.json compare what it parses +to, not its bytes.

    6.13 Embedded live streams in stories

    A story MAY carry a live stream URL alongside its blob:

    @@ -900,6 +977,16 @@ reason it cannot be reacted to: there is nothing to name it by.

    gid applies as elsewhere: an edit of a group message carries the group id and routes to the group thread.

    +

    A story's caption. Specified for 0.32; +implementation in progress. An edit applies to a story when it carries +"st":1, with re.mid naming the story the way +§6.35 does:

    +
    {"fnc":1,"kind":"edit","st":1,"caption":"<new words>","re":{"mid":"<story id>","author":0,"preview":""}}
    +

    Everything above holds for it. It applies only to a story the +receiver holds and attributes to the sender of the edit, it is shown as +edited, it does not notify, and it never empties a caption: taking a +story away is §6.35. A text story (§6.21) has its words edited the same +way.

    Status. Implemented on both clients. iOS implements this section end to end. The Android app records a mid for every line and applies an inbound edit to the message it names, refusing @@ -1131,27 +1218,32 @@ that, because a stored copy is the shortcut that looks identical until the message changes.

    6.20 Story replies

    A story has no comment thread and no viewer list, and MUST NOT grow -one. A reply to a story is an ordinary direct message to its author, -carrying a reference to the story it answers:

    -
    {"fnc":1,"kind":"text","caption":"where is this?","sre":{"sid":"<story id>"}}
    -

    sre.sid is the story's id as the replier received it. -The envelope is a normal text message in every other respect: it rides -the pairwise ratchet to the author, lands in the existing conversation, -and the relay sees nothing it does not already see for any message.

    -

    sre carries no copy of the story, no caption, no blob, -no thumbnail. This diverges from re in §6.9, and the two -differ for a reason. A quoted message may be one the recipient never -had: they joined late or cleared their history, so §6.9 sends a -preview and accepts that it is the replier's assertion. A -story's author, by definition, had the story, the only way they no -longer hold it is that it EXPIRED. Sending a copy back would therefore -do exactly one thing: resurrect content the author's own timer had -already removed, at a moment they did not choose. That is the trap §6.19 -avoids for pins, and it is the same trap here.

    -

    A receiver whose copy of sid has expired or was never -held MUST render the reply as an ordinary message, and MAY note that it -answers a story that is no longer available. It MUST NOT render a -placeholder that implies the content could be recovered.

    +one. A reply to a story is an ordinary direct message to its author: a +text whose re (§6.9) names the story, carrying +"st":1 to say that re points at a story and +not at a message.

    +
    {"fnc":1,"kind":"text","mid":"<id>","re":{"mid":"<story key>","author":9,"preview":"at the beach"},"caption":"where is this?","st":1}
    +

    re.mid is the story's key as the replier received it: +the attachment id of a story with a picture or a video, or the +mid of a text story, the value a backup calls the story's +key (§16.2.1). re.author is the story's +author. The envelope is a normal text message in every other respect: it +rides the pairwise ratchet to the author, lands in the existing +conversation, and the relay sees nothing it does not already see for any +message. test/vectors/content-v1.json pins this envelope +and the reaction in §6.22. A separate sre reference object +is not part of the wire: no released build sends or reads one.

    +

    re.preview carries the first 80 characters of the +story's caption, and nothing of its picture: no blob and no thumbnail. A +story's author had the story by definition, and the only way they no +longer hold it is that it expired, so a reply that arrives after the +story has gone puts up to 80 characters of their own words back on their +phone, under the reply, after their timer removed them. Nothing else of +the story comes back.

    +

    A receiver whose copy of the story has expired or was never held MUST +render the reply as an ordinary message with its quote, and MAY note +that it answers a story that is no longer available. It MUST NOT render +a placeholder that implies the content could be recovered.

    Replying tells the author you watched, and that is a disclosure the replier makes. Stories deliberately have no viewer list: the author cannot otherwise learn who opened one. A reply necessarily reveals it, @@ -1161,9 +1253,9 @@ effect: a client MUST tell the user, before the first story reply that account sends, that replying reveals they watched. A client MUST NOT send any automatic acknowledgement, a read receipt, a delivery marker, a typing indicator, in response to a story being opened.

    -

    sid is a local identifier, not a global one. It is -meaningful to the pair of devices that exchanged the story and MUST NOT -be treated as a handle the relay or any third party can resolve.

    +

    A story's key is meaningful only to the devices that exchanged the +story, and MUST NOT be treated as a handle the relay or any third party +can resolve.

    A story MAY carry no attachment. The caption is then the whole story:

    @@ -1202,22 +1294,23 @@ nature of story expiry stated in §6.6 the only mechanism here rather than a backstop to one.

    6.22 Story reactions

    A one-tap acknowledgement of a story. It is a sealed direct message -to the author, exactly as a story reply is (§6.20), and carries the same -reference:

    -
    {"fnc":1,"kind":"react","caption":"\ud83d\udc4d","sre":{"sid":"<story id>"}}
    +to the author, as a story reply is (§6.20), and names the story the same +way, with re and "st":1:

    +
    {"fnc":1,"kind":"react","re":{"mid":"<story key>","author":9,"preview":""},"caption":"\ud83d\udc4d","st":1}

    kind is react and caption is -the emoji, matching §6.9's reaction envelope; sre names the -story, matching §6.20. An empty caption withdraws a -reaction already sent, removal must be a message rather than an absence, -because the author's device has already rendered the first one and there -is no other way to reach it.

    -

    re is absent and MUST be. §6.9's -reaction names a message by mid and folds onto it; a story -is not in the conversation and has no mid there, so a story -reaction that carried re would fold onto whatever message -happened to share the id, or onto nothing. A receiver MUST route on -sre and MUST NOT render a story reaction into the thread as -a message.

    +the emoji, matching §6.9's reaction envelope, and +re.preview is empty. An empty caption +withdraws a reaction already sent: removal is a message because the +author's device has already rendered the first one and there is no other +way to reach it.

    +

    st decides where it lands. §6.9's +reaction names a message by mid and folds onto it. A story +is not in the conversation and has no mid there, so a +reaction carrying "st":1 folds onto the story whose key is +re.mid, never onto a message that happens to share the id. +A receiver MUST NOT render a story reaction into the thread as a +message. st is read as 1 or true +(§6.6).

    The disclosure is the same as §6.20's, and MUST be made separately. A reaction reveals that you watched, exactly as a reply does. What differs is the deliberation behind it: a reply is typed, and a reaction is one @@ -1570,6 +1663,16 @@ thought of as messages. What each client filters:

    persistableOutbox(outbox) Vault.saveOutbox + +Cards, events and games (0.32) +on the message, in threads +on the row, in ephemeral + + +Answers that arrive before their event (0.32) +earlyAnswers, memory only +heldAnswers, memory only +

    The queued send is the one that carries the message text rather than @@ -1707,6 +1810,539 @@ stays where it is.

    The relay learns nothing new. These ride the ratchet like every other call signal, and the same envelopes to this account's own devices already exist for stopping a ring.

    +

    6.31 Greeting cards

    +

    A card is one of flamenet.io's card designs with a few lines of +words, sent inside a message as a text is. Only the design's id travels; +each app draws the front from its own copy.

    +
    {"fnc":1,"kind":"card","mid":"<id>","card":{"d":"birthday","to":"Sam","msg":"Have a lovely day.","from":"Pat","open":1767225600}}
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    FieldRequiredMeaning
    dyesThe design: birthday, thanks, +thinking, congrats, getwell, +love, season, hello or +sympathy.
    msgyesThe words inside. May be empty. Drawn to at most 2000 +characters.
    tonoThe salutation line. Drawn to at most 60 characters.
    fromnoThe signature line. Drawn to at most 60 characters.
    opennoUnix seconds. Until then the receiver keeps the card sealed.
    +

    A card is renderable and follows the rules §6.6 gives for the kinds +added in 0.32. In a group it carries gid and +gname after the card, as a group text does:

    +
    {"fnc":1,"kind":"card","mid":"m1","card":{"d":"love","msg":"x"},"gid":"g1","gname":"Family"}
    +

    A card that fails to read is a message lost, so a receiver reads the +card object leniently. An unknown d draws as +hello and is kept as sent, so a forward or a backup carries +what the sender chose; a missing d is hello +and a missing msg is empty; a field of the wrong type is +absent ("to":7 has no salutation, +"open":"soon" does not seal the card). A card +that is absent or not a JSON object draws the placeholder (§6.10).

    +

    The front is a picture each app ships for each design, +cards/<d>.png, 600 by 450 pixels, the same bytes in +both apps, with the design's title drawn by the app as text: Happy +Birthday, Thank You, Thinking of You, Congratulations, Get Well Soon, +With Love, Season's Greetings, Hello!, With Sympathy. These are the +site's own titles. Nothing is fetched to draw a card.

    +

    Sealed until. A sender MAY set open to +a moment in the future, at most 400 days ahead. Until then the receiver +shows the front with "Opens on " and MUST NOT show +to, msg or from. At that moment +the card opens by itself, and no message is needed to open it. The +sender's own copy is never sealed, and says who it is sealed for and +until when. A receiver opens a card at the earlier of open +and 400 days after the card arrived, by the device's own record time +(§6.11), so neither a hostile value nor a wrong clock keeps a card +shut.

    +

    Sealing is a courtesy between apps that honour it. The words are on +the receiver's phone from the moment the card arrives, and an older or +modified build shows them at once. A client MUST describe it that way +and MUST NOT call a sealed card locked or hidden. It has the same +standing as a story's expiry (§6.6).

    +

    With previews on, a card notifies " sent you a card", or +" sent you a card to open on " while it is sealed. A +notification MUST NOT carry a card's words, sealed or not.

    +

    A forward (§6.16) carries a new mid, +"fwd":1, and no open:

    +
    {"fnc":1,"kind":"card","fwd":1,"mid":"m-2","card":{"d":"birthday","to":"Sam","msg":"Have a lovely day.","from":"Pat"}}
    +

    A client MUST NOT offer to forward a card that is still sealed for +the person holding it.

    +

    In a backup the message carries the card object as it +travelled, and its text is the empty string, so a sealed +card's words are never written outside the card (§16.2.1).

    +

    What the relay learns: nothing new. A card is a +content envelope inside the ratchet, submitted and announced as a text +is.

    +

    Status. Built in both apps for 0.32, not released +(§18.1).

    +

    6.32 Events and answers

    +

    An invitation to something at a time and a place, which everyone in +the conversation can answer.

    +
    {"fnc":1,"kind":"event","mid":"<id>","ev":{"id":"<uuid>","rev":1,"title":"Picnic","start":1767225600,"end":1767232800,"tz":"America/New_York","place":"Riverside park, by the boathouse","note":"Bring a blanket"}}
    +

    An event that lasts all day carries day, and +days when it runs longer than one day, in place of +start, end and tz:

    +
    {"fnc":1,"kind":"event","mid":"<id>","ev":{"id":"<uuid>","rev":1,"title":"Picnic","day":"2026-12-25","days":2}}
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    FieldRule
    idRequired. A non-empty string, the same in every revision. Whitespace +counts as non-empty.
    revRequired. An integer from 1. A higher rev replaces the +whole event; an equal or lower one is ignored.
    titleRequired, not blank. Drawn to at most 120 characters.
    start, endUnix seconds. end is optional, and is not shown when it +is before start.
    day, daysA day the calendar has, written yyyy-MM-dd, and how +many days, written only when more than 1. days runs 1 to +31: a larger value is shown as 31 and a smaller one as 1.
    tzThe organizer's IANA zone. Timed events only.
    place, noteOptional. Drawn to at most 200 and 1000 characters. Never geocoded, +never fetched.
    cancel1 on a cancellation; true is read the +same.
    +

    An event is renderable and follows the rules §6.6 gives for the kinds +added in 0.32. Both apps write ev's keys in the order +id, rev, title, then +start, end, tz or +day, days, then place, +note, cancel. When an ev carries +both start and day, the timed form wins.

    +

    An ev that is not an object, or lacks a non-empty string +id, an integer rev of at least 1, a title that +is not blank, or either an integer start or a valid +day, draws the placeholder (§6.10). It is never dropped: +somebody meant to invite somebody.

    +

    Revisions. Only the organizer can change an event, +and the organizer is whoever sent rev 1, by ratchet identity, never by +anything the envelope says. A revision is a whole event envelope with +the same ev.id, a higher rev and a fresh +mid. A cancellation is a revision that carries the whole +event and "cancel":1:

    +
    {"fnc":1,"kind":"event","mid":"<id2>","ev":{"id":"<uuid>","rev":2,"title":"Picnic","start":1767225600,"end":1767232800,"tz":"America/New_York","place":"Riverside park, by the boathouse","note":"Bring a blanket","cancel":1}}
    +

    A receiver folds every revision into the card it already shows, by +ev.id. The card keeps the mid it first arrived +with, so replies and reactions still find it, and a revision never adds +a second bubble. When rev 1 has not arrived, the first sender seen +stands in as organizer; a rev 1 from somebody else then replaces the +stand-in, and the event with it. Anyone else reusing a known +ev.id is ignored.

    +

    Answers. An answer is control traffic, sent to +everyone the event went to:

    +
    {"fnc":1,"kind":"rsvp","ctl":1,"rsvp":{"eid":"<uuid>","r":"yes","n":3}}
    +

    eid names the event and r is +yes, maybe or no. n +orders one person's answers: it is the unix second the answer was given, +or one more than the last n this device sent for the event +when that is already at or past the present second. A receiver keeps the +answer with the highest n from each person. On an equal +n, the r that sorts higher as a string wins +(yes, then no, then maybe), so +every device settles on the same answer whatever order they arrive in. +An answer without a non-empty eid, with an r +outside those three, or without an integer n of at least 1, +is dropped. An answer that arrives before its event is held in memory +only, never written down. In a group an answer carries gid +and no gname, as a poll's control traffic does (§6.29):

    +
    {"fnc":1,"kind":"rsvp","ctl":1,"rsvp":{"eid":"e","r":"maybe","n":1},"gid":"g1"}
    +

    Answers are not private. Every member of the conversation receives +every answer, and the card shows names beside the counts. A client MUST +say so where the answer is given.

    +

    With previews on, an event notifies " invited you: ". A +revision notifies "<title> changed", in the title the reader had before +it, and a cancellation "<title> was cancelled", after which the card +reads "Cancelled: <title>". A rev 1 that settles or replaces a stand-in +organizer, a duplicate and a stale revision do not notify, and answers +never do. When the organizer's zone has a different UTC offset from the +reader's at the start, the card also shows the organizer's time, with +the weekday when the organizer's date differs ("Fri 7:00 PM New York +time"). An event that lasts all day never shows it.</p> +<p>Add to calendar opens the system's own screen for adding an event. +Nothing is read from the calendar.</p> +<p>A forwarded event (§6.16) is a new event: a fresh <code>ev.id</code>, +rev 1, <code>"fwd":1</code>, no answers, and the person forwarding it as +its organizer. A client MUST NOT offer to forward a cancelled event.</p> +<p>In a backup the message carries <code>ev</code>, the wire object of +the latest accepted revision, and beside it <code>org</code>, the +organizer's user id as an integer, and <code>rsvp</code>, +<code>{"<user id>":{"r":"yes","n":3}}</code>. Its +<code>text</code> is the empty string (§16.2.1).</p> +<p><strong>What the relay learns: nothing new.</strong> Events are +submitted and announced as texts are, and answers are submitted +<code>silent</code> (§8.4), as other control traffic is.</p> +<p><strong>Status.</strong> Built in both apps for 0.32, not released +(§18.1).</p> +<h3 id="633-games-in-a-chat">6.33 Games in a chat</h3> +<p>Chess, checkers, reversi and tic-tac-toe, played in a one-to-one +conversation. Only moves travel. Each phone keeps the whole game as its +list of moves and works the board out from it, so a game can be rebuilt +from any one envelope, and neither phone has to trust the other's +board.</p> +<pre class="json"><code>{"fnc":1,"kind":"game","mid":"<id>","game":{"id":"<uuid>","t":"chess","mine":"first"}} +{"fnc":1,"kind":"gmove","ctl":1,"game":{"id":"<uuid>","t":"chess","mine":"second","log":["e2e4","e7e5"]}} +{"fnc":1,"kind":"gend","ctl":1,"game":{"id":"<uuid>","t":"chess","mine":"first","log":["e2e4","e7e5"],"r":"resign"}}</code></pre> +<p><code>game</code> invites the other person and is renderable: the +board is drawn on that message, so it carries <code>mid</code>. +<code>gmove</code> and <code>gend</code> are control traffic. All three +follow the rules §6.6 gives for the kinds added in 0.32.</p> +<table> +<thead> +<tr> +<th>Key</th> +<th>Meaning</th> +</tr> +</thead> +<tbody> +<tr> +<td><code>id</code></td> +<td>The game. A non-empty string, the same in every envelope of the +game.</td> +</tr> +<tr> +<td><code>t</code></td> +<td><code>chess</code>, <code>checkers</code>, <code>reversi</code> or +<code>tictactoe</code>.</td> +</tr> +<tr> +<td><code>mine</code></td> +<td>The side the sender of this envelope plays: <code>first</code> or +<code>second</code>. First is white in chess, dark in checkers, black in +reversi and X in tic-tac-toe.</td> +</tr> +<tr> +<td><code>log</code></td> +<td>Every ply of the game so far, in order, in the notation below. +Required and not empty on <code>gmove</code>; required on +<code>gend</code>, where it may be empty. A <code>game</code> carries +none.</td> +</tr> +<tr> +<td><code>r</code></td> +<td>On <code>gend</code> only: <code>resign</code>, +<code>draw-offer</code>, <code>draw-accept</code>, +<code>draw-decline</code>, <code>decline</code> or +<code>cancel</code>.</td> +</tr> +</tbody> +</table> +<p>Keys are written in the order <code>id</code>, <code>t</code>, +<code>mine</code>, <code>log</code>, <code>r</code>. A <code>game</code> +whose object does not read (no <code>id</code> or an empty one, a +<code>t</code> or a <code>mine</code> outside the lists, a +<code>log</code> that is not a list of strings, a <code>game</code> that +is not an object) draws the placeholder. A <code>gmove</code> or +<code>gend</code> that does not read is dropped, and so is a +<code>gmove</code> without a <code>log</code> or with an empty one, and +a <code>gend</code> without a <code>log</code> or with an <code>r</code> +outside the list. Games are one-to-one in this release: a +<code>game</code> carrying <code>gid</code> draws the placeholder, a +<code>gmove</code> or <code>gend</code> carrying <code>gid</code> is +dropped, and a client does not offer a game in a group. A game is never +forwarded.</p> +<p><code>resign</code> ends the game, lost by the side that sent it. +<code>decline</code> is the invitee saying no before their first move, +and <code>cancel</code> is the starter withdrawing before the invitee's +first move. <code>draw-offer</code> offers a draw, +<code>draw-accept</code> takes one and ends the game drawn, and +<code>draw-decline</code> turns one down. Endings on the board (mate, no +moves, a full board, the automatic draws) are worked out by both phones +from the log and need no message.</p> +<p><strong>Receiving a <code>gmove</code> or a +<code>gend</code>.</strong> In this order:</p> +<ol type="1"> +<li><p>A game the conversation does not know is built from the envelope: +its <code>t</code>, the sender's side from <code>mine</code>, and its +log replayed from the start. The <code>game</code> may have been lost, +or the device restored. A log that does not replay still makes the card, +already out of step. A <code>decline</code> for a game not known makes +the receiver the starter, since only the invitee can decline.</p></li> +<li><p>A game that has ended, or is out of step, takes nothing +more.</p></li> +<li><p>The sender must play the other side of the same game. A +<code>t</code> or a <code>mine</code> that disagrees with the game held +puts it out of step.</p></li> +<li><p>A log no longer than the one held must be a prefix of it. Then +the envelope is a duplicate and is ignored, except that a +<code>gend</code>'s ending is still judged. A log that is not a prefix +puts the game out of step.</p></li> +<li><p>A longer log must start with the log held, and every ply it adds +must belong to the sender's side and be legal in turn, replayed from the +start within the game's cap, so a peer cannot play the receiver's moves. +Otherwise the envelope is refused and the game is out of step.</p></li> +<li><p>A <code>gend</code>'s ending is then judged at the log it +carried, which is by now a prefix of the log held:</p> +<ul> +<li><code>resign</code> counts.</li> +<li><code>draw-offer</code> counts only from the side that has just +moved, only at the receiver's whole log, only once the invitee has made +a first move, and only when no offer is open. The next move by either +side lapses it.</li> +<li><code>draw-accept</code> and <code>draw-decline</code> count only +against an open offer from the receiver, and <code>draw-accept</code> +only at the receiver's whole log, so an acceptance cannot race a +move.</li> +<li><code>decline</code> counts only from the invitee and +<code>cancel</code> only from the starter, and each only while the log +it carried holds no move by the invitee.</li> +</ul> +<p>An ending that does not count is ignored. Moves the same +<code>gend</code> carried still count.</p></li> +</ol> +<p>Out of step is final for that game. The card says "Your boards +disagree" and offers "Start a new game", which sends a fresh +<code>game</code> of the same type with the same sides, and anything +that arrives later for the old game is ignored.</p> +<p>A client offers a move only when it is legal and it is this person's +turn. In reversi a side with no placing move sends <code>pass</code> as +an ordinary <code>gmove</code>, from a Pass button that is then the only +move offered, so every move is one a person chose. "Offer a draw" is +offered only while it is the other person's move. <code>gmove</code> and +<code>gend</code> are never queued: when a submission fails for any +reason, a contact with no device yet included, the move or the ending is +taken back and the person is told. The <code>game</code> itself is +queued like any message.</p> +<p><strong>Notation and rules.</strong> Both apps' engines must give the +result <code>test/vectors/games-v1.json</code> gives for every case in +it, and must match its perft counts, which are the published ones.</p> +<ul> +<li><strong>chess</strong>: UCI long algebraic notation, the promotion +piece in lower case (<code>e7e8q</code>), castling as the king's move +(<code>e1g1</code>). All of FIDE's moves: castling, en passant, +promotion. The game ends at checkmate, at stalemate, on insufficient +material, and by automatic draw at threefold repetition and under the +fifty-move rule (100 plies with no capture and no pawn move). +Insufficient material is exactly four cases: king against king, king and +bishop against king, king and knight against king, and king and bishop +against king and bishop with both bishops on squares of one colour. A +position repeats when the placement, the side to move and the castling +rights are the same, and the en passant square too, counted only when an +en passant capture is legal.</li> +<li><strong>checkers</strong>: English draughts on 8 by 8, squares 1 to +32 in the standard PDN numbering. The first player's men (dark) start on +1 to 12 and move toward 32; the second player's men (light) start on 21 +to 32. A move is written <code>11-15</code> (a step), <code>15x24</code> +(a jump) or <code>15x24x31</code> (a multiple jump, every landing +square). Capturing is compulsory, but any capturing sequence may be +chosen, not only the longest; a jump continues while the piece that +moved can jump again; a man that reaches the far row is crowned and the +move ends there. Kings move and jump one square diagonally in all four +directions. The side to move with no legal move loses. A game is drawn +automatically after 80 plies with no capture and no man moved, or at +threefold repetition of placement and side to move.</li> +<li><strong>reversi</strong>: 8 by 8, files <code>a</code> to +<code>h</code> left to right and ranks 1 to 8 top to bottom as drawn for +the first player. The second player (white) starts on d4 and e5, the +first player (black) on d5 and e4, and the first player moves first. A +move is a square (<code>d3</code>), or <code>pass</code>, which is legal +only when the side to move has no placing move and the other side has +one. The game ends when neither side can move; the side with more discs +wins, and equal discs are a draw.</li> +<li><strong>tictactoe</strong>: cells <code>1</code> to <code>9</code> +by rows from the top left. The first player is X. The game ends at three +in a row or a full board.</li> +</ul> +<p>Checkmate and stalemate are judged before the automatic draws, and a +checkers side with no move loses even on the 80th quiet ply. A log is +capped: 600 plies in chess, 400 in checkers, 130 entries in reversi +(passes included) and 9 in tic-tac-toe. A longer log is refused before +any of it is played, and a receiver always rebuilds a position by +replaying the log within the cap.</p> +<p>With previews on, an invitation is "<name> invited you to play +chess", with a sound, and it counts as unread. A move is "Your move: +chess with <name>", without a sound, and it does not count as unread. A +draw offer is "<name> offers a draw: Chess", without a sound. An ending +is "Chess with <name>: <result>", for example "Chess with Sam: They +resigned. You won". A declined draw offer does not notify. +<code>game</code>, <code>gmove</code> and <code>gend</code> are all +submitted announced (§8.4), so the other phone wakes. The iPhone app has +no notification extension in 0.32, so a move that arrives while it is +not running shows as the relay's standard alert, "New message" with a +sound (§8.11). Inside the app, and on Android, the notice is the quiet +"Your move".</p> +<p>Chess, checkers and reversi turn round for the second player. +Tic-tac-toe never does, so its cells keep their places on both +phones.</p> +<p>In a backup, the message that started the game carries +<code>"game":{"id","t","me","log","r","rby"}</code>: <code>me</code> is +the side this person plays, <code>log</code> the whole log, +<code>r</code> the ending a <code>gend</code> brought +(<code>resign</code>, <code>draw-accept</code>, <code>decline</code> or +<code>cancel</code>) and <code>rby</code> the side that sent it, written +only with <code>r</code>. An open draw offer and an out-of-step game are +not carried: a restored game is replayed from its log. The message's +<code>text</code> is the empty string (§16.2.1).</p> +<p><strong>What the relay learns.</strong> Moves are sealed envelopes +like any other. Because they are announced, the count on an iPhone's +icon includes them until the app next runs (§9).</p> +<p><strong>Status.</strong> Built in both apps for 0.32, not released +(§18.1).</p> +<h3 id="634-hello-links">6.34 Hello links</h3> +<p>A link a person can publish so that whoever opens it can ask to +message them:</p> +<pre><code>https://flamenetmessenger.com/hi#("#")fnid:<52 characters>@<relay host></code></pre> +<p>After the <code>#("#")</code> is the person's address as the app shows it +on the address card: their fnid, the account key in the alphabet of +§19.2, and their home relay.</p> +<p><strong>Why the fragment.</strong> A browser does not send the part +of a link after <code>#("#")</code> to any server, so the web server that +serves the page never learns whose link was opened. The page reads the +fragment on the device and hands it to the app.</p> +<p>A receiver accepts exactly these forms:</p> +<ul> +<li><code>https://flamenetmessenger.com/hi#("#")<address></code> and +<code>https://flamenetmessenger.com/es/hi#("#")<address></code>, the +published links;</li> +<li><code>flamenetmessenger://hi#("#")<address></code>, the app's own +scheme, which the page links to;</li> +<li>in a scanner or a paste, a bare <code><address></code>, or +<code>flamenet:<address></code> as older address cards hold it, +ignoring anything from a <code>?</code> on.</li> +</ul> +<p>Before the <code>#("#")</code>, the scheme and the host are compared +without regard to case. The path is exactly <code>/hi</code> or +<code>/es/hi</code>, in lower case and with no trailing slash, and the +app link has no path at all, so <code>flamenetmessenger://hi/#("#")…</code> +is refused. A link with a query, a port or a user name is not a hello +link. Whitespace around the whole text is ignored.</p> +<p>The fragment is at most 400 characters, as the relay's page caps it, +and is percent-decoded once. What is left must be exactly +<code>fnid:</code> and 52 characters of <code>0-9</code>, +<code>a-h</code>, <code>j</code>, <code>k</code>, <code>m</code>, +<code>n</code>, <code>p-t</code> and <code>v-z</code>, then one +<code>@</code>, then a host made of the ASCII letters <code>a</code> to +<code>z</code>, digits, hyphens and dots, with at least one dot. There +is no space anywhere in an address, around the <code>@</code> included. +The host is lowercased by ASCII rules alone: <code>A</code> to +<code>Z</code> become <code>a</code> to <code>z</code> and no other +character is changed, so a character outside that set refuses the +address and is never folded into it, a Kelvin sign that would lowercase +to <code>k</code> included. The fnid is never lowercased: a key in +another case is another key. A refused link changes nothing, and the app +says it holds no address it can use.</p> +<p><code>test/vectors/hello-link-v1.json</code> gives inputs in each +form and the address or refusal each must give.</p> +<p>Opening one fills in the app's add-by-address screen and stops there. +Nothing is sent until the person chooses to start a conversation, and +first contact then goes as it always does. A scanner binds what it reads +by the fnid, and ignores a <code>?u=</code> on an older card.</p> +<p><strong>The page.</strong> <code>GET /hi</code> and +<code>GET /es/hi</code> on a relay serve a static page, marked noindex +and left out of the sitemap, that loads nothing from any other host. Its +script reads <code>location.hash</code>, checks it by the rule above, +and shows the address with "Open in Flamenet Messenger" +(<code>flamenetmessenger://hi#("#")<address></code>), a copy button and +the install links. The script MUST NOT send the fragment anywhere: no +request, no beacon, no image, and no link but the app link carries it. +The fragment is written into the page as text, never as markup. A +fragment that is not an address shows a plain explanation and the +install links.</p> +<p>The address card shows the hello link with copy and share, and its QR +code holds the <code>https</code> link.</p> +<p><strong>What it gives away.</strong> Anyone who has a hello link can +ask to message its owner, and the link names the owner's account key and +home relay. Publishing it is the owner's choice, and the app says what +it gives away where it offers the link. FlameNet's profile can show one, +as an opt-in field: the messenger never calls flamenet.io and +flamenet.io never calls a relay on a member's behalf, so neither learns +the other's name for the person from it.</p> +<p><strong>Status.</strong> Built in both apps and on the relay for +0.32, not released (§18.1). The vector file's <code>history</code> +records the inputs on which an app's feature branch did not follow this +section.</p> +<h3 id="635-taking-a-story-down">6.35 Taking a story down</h3> +<p>Specified for 0.32; implementation in progress.</p> +<p>The author of a story MAY take it down for everyone it went to:</p> +<pre class="json"><code>{"fnc":1,"kind":"unpost","ctl":1,"st":1,"sid":"<story id>"} +{"fnc":1,"kind":"unpost","ctl":1,"st":1,"set":"<set id>"}</code></pre> +<p><code>sid</code> names the story the way replies and reactions name +it (§6.20, §6.22): the attachment id of a story with a picture or a +video, or the <code>mid</code> of a text story. It is the value a backup +calls the story's <code>key</code> (§16.2.1). A story of several items +(§6.23) comes down whole, with <code>set</code> in place of +<code>sid</code>; in this envelope <code>set</code> is the set's id as a +string, not §6.23's object. An <code>unpost</code> carries exactly one +of the two, and one carrying both or neither is dropped. It carries no +<code>gid</code>, even for a story posted to a group: the receiver finds +the story by its author and its id. It is sent to everyone the story was +sent to, the author's story audience or the group's roster (§6.24), and +to the author's own devices (§6.22.1). It is control traffic and follows +the rules §6.6 gives for the kinds added in 0.32.</p> +<p>A receiver applies it only to a story it holds and attributes to the +sender of the <code>unpost</code>, the rule §6.15 applies to an edit. It +removes the story from the strip, the feed and the archive, and deletes +its cached copy and the decrypted bytes; with <code>set</code>, every +item of that set from that author goes. An <code>unpost</code> naming a +story the receiver does not hold is dropped. It never notifies. The +author's own copy goes at once.</p> +<p><strong>What it does not do.</strong> It removes the story from the +phones whose app receives the message and honours it. Anyone who has +already seen the story may have kept a copy, and a build that predates +this section keeps it until it expires. A client MUST say both when the +person confirms, and MUST NOT describe taking a story down as deleting +it everywhere. The relay offers no way for an uploader to delete a blob, +so a story's blob stays until the retention purge (§9); it cannot be +read without its key, which only the devices that held the story +had.</p> <h2 id="7-safety-numbers-verification">7. Safety numbers (verification)</h2> <p>For a local identity <code>IK_sig_local_pub</code> and remote @@ -1820,6 +2456,18 @@ device and submits them together. Request:</p> <p>Server validates <code>to</code> is a real user, then stores one row per envelope. It does <strong>not</strong> inspect <code>payload</code>. Response: <code>{ "ok": true, "ids": [101, 102] }</code>.</p> +<p>A submission MAY also carry <code>urgent</code> (§6.14) or +<code>silent</code>:</p> +<pre class="json"><code>{ "to": 12, "silent": true, "messages": [ ... ] }</code></pre> +<p><code>silent</code> asks the relay to deliver without announcing: an +iPhone gets a background wake that shows nothing (§8.11), and the +envelopes stay out of the count on its icon (§9). A client sets it on +traffic that is not a message to a person: delivery keys, receipts, +answers to events (§6.32). Renderable content, and game moves and +endings (§6.33), are submitted announced. <code>urgent</code> wins when +both are set. Like <code>urgent</code>, it tells the relay something +about envelopes it cannot read, roughly whether they are housekeeping, +and the relay keeps that answer on each waiting envelope (§9).</p> <h3 id="85-get-e2emessagesdevice_iduuidafteridlimitn-poll-my-inbox">8.5 <code>GET /e2e/messages?device_id={uuid}&after={id}&limit={n}</code>, poll my inbox</h3> @@ -2135,29 +2783,252 @@ hours. Response:</p> <p>The relay never sees plaintext, it forwards DTLS-SRTP ciphertext between peers that authenticated each other through the ratchet (§6.8).</p> +<h3 id="811-post-e2epushtoken-waking-an-iphone">8.11 +<code>POST /e2e/push/token</code>, waking an iPhone</h3> +<p>iOS suspends an app in the background and gives it no connection of +its own, so a relay that wants an iPhone to collect its mail has to ask +Apple to wake it. This route tells the relay how. An Android app keeps +its own connection and does not use it.</p> +<pre class="json"><code>{ "device_id": "<uuid>", "platform": "apns", "token": "<APNs device token>", "voip_token": "<PushKit token>" }</code></pre> +<ul> +<li><code>device_id</code> MUST be the caller's own device. A request +naming another device is refused.</li> +<li><code>platform</code> is <code>apns</code>, the only platform +stored. Anything else is <code>400</code>.</li> +<li><code>voip_token</code> is optional. It is the token that can ring a +locked phone for a call (§6.14).</li> +<li>Registering again replaces what was stored, one token at a time: a +token is replaced only when one is sent, so the two can arrive in either +order.</li> +<li>An empty <code>token</code> with no <code>voip_token</code> forgets +the device. The app sends this when it signs out.</li> +</ul> +<p>Response: <code>{ "ok": true }</code>.</p> +<p><strong>What the relay sends.</strong> On each submission (§8.4) to a +device it holds a row for:</p> +<ul> +<li><code>urgent</code> (§6.14): a VoIP push to <code>voip_token</code>, +which rings the phone. Each attempt is recorded for seven days, with the +account, the device, the time and the outcome.</li> +<li><code>silent</code> (§8.4): a background push that wakes the app and +shows nothing.</li> +<li>otherwise: an alert push with fixed words, "Flamenet" and "New +message", a sound, and as its badge the number of envelopes waiting for +that device that were announced (§9).</li> +</ul> +<p>No push carries anything from the envelope: no content, no sender, no +conversation. The alert is the same for every announced envelope, a game +move (§6.33) included. When the app next runs it collects and decrypts +what arrived, and any notification it shows then uses the wording each +section gives. A token Apple reports gone is forgotten.</p> +<p><strong>What it stores and reveals.</strong> One row per device: the +device id, the user id, the platform and the two tokens. Apple learns +when a relay wakes a device, and from which relay. The relay learns +nothing about an envelope by pushing it that it did not know by holding +it.</p> +<p><strong>Status.</strong> Implemented on the relay and in the iPhone +app.</p> +<h3 id="812-get-todayjson-the-today-file">8.12 +<code>GET /today.json</code>, the Today file</h3> +<p>One public file from FlameNet for the apps' Today tab: headlines, +market prices, weather for a list of cities, a site from the directory +and the latest FlameNet blog post. Every reader gets the same bytes, and +asking for it carries nothing that identifies the reader. The route is +at the relay's root, not under <code>/e2e</code>.</p> +<p>The envelope:</p> +<pre class="json"><code>{"v":1,"kid":"a9a94e74","body":"<base64 of the body&#("#")39;s UTF-8 JSON>","sig":"<base64 Ed25519 signature over the body bytes>"}</code></pre> +<p><code>kid</code> is the first 8 hex characters, in lower case, of the +SHA-256 of the 32-byte public key that made the signature. The apps pin +FlameNet's keys by kid. The development key, public +<code>o6fwSoRjw0zldgUOpSClF9/Z2ySQcf0bTDtmtoDgFoA=</code> and kid +<code>a9a94e74</code>, is compiled into debug builds only, so a release +build shows nothing it signed. The production key is made at release +time, and a build pins its public half then.</p> +<p>The body:</p> +<pre class="json"><code>{"v":1,"date":"2026-10-04","made":1791116238, + "headlines":[{"t":"Title","u":"https://flamenet.io/...","src":"Publisher"}], + "markets":[{"sym":"^GSPC","name":"S&P 500","last":"6712.40","chg":"+12.10","pct":"+0.18"}], + "weather":[{"city":"New York","cc":"US","tz":"America/New_York","lat":40.71,"lon":-74.01, + "now":{"c":18,"sky":"rain"}, + "days":[{"d":"2026-10-04","hi":21,"lo":14,"sky":"rain"}]}], + "site":{"t":"Name","u":"https://...","d":"One line"}, + "note":"Whose words the site&#("#")39;s line is", + "post":{"t":"Title","u":"https://flamenet.io/...","d":"First line","by":"Name"}, + "notes":["Weather: Open-Meteo","Market prices are delayed"]}</code></pre> +<ul> +<li><code>date</code>, a day written <code>yyyy-MM-dd</code>, and +<code>made</code>, the unix second the file was made, are required. +Everything else is optional, and a section that is absent or does not +read is not shown.</li> +<li>A headline needs <code>t</code> and <code>u</code>; its +<code>u</code> is the story's page on flamenet.io and <code>src</code> +names the publisher. A market needs <code>name</code> and +<code>last</code>, and <code>sym</code> is optional. A city needs +<code>city</code>. A day needs <code>d</code>, a real day written +<code>yyyy-MM-dd</code>, and <code>hi</code> and <code>lo</code>. +<code>site</code> and <code>post</code> need <code>t</code> and +<code>u</code>. An entry without what it needs is left out, and so is +one whose link is not <code>http</code> or <code>https</code> with a +host.</li> +<li>Bounds: 20 headlines, 12 markets, 150 cities, 5 days to a city and 5 +notes. <code>t</code> is drawn to at most 200 characters, <code>d</code> +and the site's <code>note</code> to 300, each of <code>notes</code> to +200, <code>src</code>, <code>by</code>, <code>name</code> and +<code>city</code> to 80, and <code>sym</code>, <code>last</code>, +<code>chg</code> and <code>pct</code> to 24. A list is cut to its bound +and a string is cut when it is drawn. Neither is a reason to refuse the +file.</li> +<li><code>cc</code> is two ASCII letters, and is ignored otherwise. +<code>tz</code> is an IANA zone name of at most 64 characters, and is +ignored otherwise; a zone the phone does not know shows the days as +given. <code>lat</code> and <code>lon</code> are optional, and are +ignored when out of range.</li> +<li><code>sky</code> is <code>clear</code>, <code>partly</code>, +<code>cloudy</code>, <code>fog</code>, <code>drizzle</code>, +<code>rain</code>, <code>snow</code> or <code>storm</code>, and another +value draws no sky. Temperatures are whole degrees Celsius, shown in °C +or °F by the device's locale.</li> +<li>Prices are strings exactly as they are to be shown. The app does no +arithmetic on them.</li> +<li><code>note</code> labels <code>site.d</code>, saying whose words it +is. <code>post</code> is absent until FlameNet's own blog has a +post.</li> +<li>A link opens only when a person taps it, in the system browser, with +its host shown. Nothing in the file is fetched ahead of a tap, and no +preview is fetched.</li> +</ul> +<p><strong>Checking it.</strong> A client refuses a file for one of six +reasons, checked in this order:</p> +<ul> +<li><code>too-large</code>: the envelope is over 512 KB.</li> +<li><code>malformed</code>: it is not a JSON object, the envelope has no +<code>v</code> (of any type), its <code>kid</code>, <code>body</code> or +<code>sig</code> is missing or not a string, or its <code>body</code> is +not base64.</li> +<li><code>unknown-version</code>: a <code>v</code> is present and is not +exactly the integer 1, in the envelope or in the body.</li> +<li><code>unknown-key</code>: no pinned key has that +<code>kid</code>.</li> +<li><code>bad-signature</code>: <code>sig</code> is not base64 of 64 +bytes, or does not verify over the body bytes.</li> +<li><code>bad-body</code>: the body is not a JSON object, has no +<code>v</code>, or has no real day written <code>yyyy-MM-dd</code> in +<code>date</code> or no integer <code>made</code>.</li> +</ul> +<p>The reasons are for logs and tests; a person sees one plain line +whatever the reason. A client never takes a verified file whose +<code>made</code> is older than the one it holds, so a relay cannot wind +the page back. A refused file changes nothing, and the last good one +stays on screen.</p> +<p><code>test/vectors/today-v1.json</code> holds the sample FlameNet's +builder signed with the development key, and a signed case for each +refusal.</p> +<p><strong>The route.</strong> <code>GET /today.json</code> serves the +last envelope the relay verified, its bytes unchanged:</p> +<ul> +<li><code>200</code>, with <code>Content-Type: application/json</code>, +<code>Cache-Control: public, max-age=900</code> and an <code>ETag</code> +that is the quoted lower-case hex SHA-256 of the bytes served. A request +whose <code>If-None-Match</code> holds that tag, or <code>*</code>, gets +<code>304</code>.</li> +<li><code>404</code> and <code>{"error":"not_offered"}</code> when the +relay offers no Today file. <code>503</code> and +<code>{"error":"not_ready"}</code> when it offers one and nothing has +verified yet. Both carry <code>Cache-Control: no-store</code>.</li> +</ul> +<p>The route reads no credential, no cookie and no query string, and the +relay writes no log line for a request.</p> +<p>The relay reads the file from <code>FN_TODAY_FILE</code>, a path it +reads again whenever the file changes, or fetches it from +<code>FN_TODAY_SOURCE</code>, an <code>http</code> or <code>https</code> +URL, about every fifteen minutes (a minute of jitter either way, a +10-second limit, a 512 KB cap). It checks the envelope against the keys +in <code>FN_TODAY_KEYS</code> before serving it, and a file that cannot +be fetched or does not verify never replaces one that did. A self-hosted +relay can mirror FlameNet's file by pointing +<code>FN_TODAY_SOURCE</code> at it. The relay's configuration document +has the rest.</p> +<p><strong>Fetching it.</strong> An app asks only its home relay, over +the transport it uses for everything else, a bridge included, with no +<code>Authorization</code>, no cookie, no device or account id and no +query string. It MUST NOT follow a redirect: the file comes from the +home relay or not at all. It asks at most once an hour while the app is +in use, when the tab opens or the app comes to the front, counted from +the relay's last HTTP answer, and after a request that got no answer it +waits at least five minutes. It keeps the last verified file on the +device, which is public data, and shows it offline with the date it is +from. If the relay answers <code>404</code>, the app shows only the +directory, with the line "Today's page is not offered by this +relay."</p> +<p>A client sends <code>If-None-Match</code> only when the tag it stored +is exactly the hash of the copy it holds, which every reader of that +file computes alike. Without this rule the one header that varies could +identify a phone: a relay could hand each phone a tag of its own and see +it come back. With it, a tag that is not the file's hash is never +sent.</p> +<p><strong>What the relay learns.</strong> That a request came from an +address at a time, with nothing in it to say whose phone made it. A +phone asks at most hourly, at times when it also collects its mail, +which the relay sees anyway. The request carries the User-Agent the app +sends with everything else, which names the platform, and from Android +the app's version. What the person reads, which cities they choose for +the weather and what they tap stay on the device, and the choice of +cities is never sent anywhere. Signing out and a lost-device wipe +(§6.26) clear the cached file and the chosen cities.</p> +<p><strong>Status.</strong> Built in both apps and on the relay for +0.32, not released (§18.1). FlameNet's builder runs on flamenet.io and +is not part of this repository.</p> <h3 id="error-shape">Error shape</h3> <p>Standard WP REST error: -<code>{ "code": "...", "message": "...", "data": { "status": 4xx } }</code>.</p> +<code>{ "code": "...", "message": "...", "data": { "status": 4xx } }</code>, +with <code>retry_after</code> in seconds on a <code>429</code>. Two +answers have another shape: a path the relay has no route for answers +<code>{"error": true, "reason": "Not Found"}</code>, and +<code>GET /today.json</code> (§8.12) refuses with +<code>{"error": "not_offered"}</code> or +<code>{"error": "not_ready"}</code>.</p> <hr /> <h2 id="9-storage-server-tables-all-opaque">9. Storage (server tables, all opaque)</h2> <ul> -<li><code>{p}fnmsg_e2e_devices</code>, one row per device (identity + -current signed prekey).</li> -<li><code>{p}fnmsg_e2e_prekeys</code>, one-time prekey pool, -<code>used</code> flag.</li> -<li><code>{p}fnmsg_e2e_envelopes</code>, sealed message queue, -<code>delivered</code> flag.</li> -<li><code>{p}fnmsg_e2e_attachments</code>, sealed blob index: -<code>token</code>, uploader, size, file path, <code>created_at</code>. -The blob bytes live on disk under <code>uploads/fnmsg-e2e/</code>, -direct web access denied; the REST route is the only reader.</li> +<li><code>devices</code>, one row per device (identity + current signed +prekey).</li> +<li><code>prekeys</code> and <code>pq_prekeys</code>, the one-time +prekey pools, each with a <code>used</code> flag.</li> +<li><code>envelopes</code>, the sealed message queue, with a +<code>delivered</code> flag and an <code>announced</code> one +(below).</li> +<li><code>attachments</code>, the sealed blob index: <code>token</code>, +<code>bytes</code> and <code>created_at</code>, and no uploader. The +blob bytes live in files in the relay's attachments directory +(<code>FN_ATTACHMENTS_DIR</code>), with no direct web access; the REST +route is the only reader.</li> +<li><code>push_tokens</code>, one row per iPhone that asked to be woken +(§8.11).</li> </ul> +<p>These are the tables the message path uses.</p> <p>No table contains plaintext or a private key. Envelope retention: delivered envelopes MAY be purged by a cron after 7 days; undelivered are retained until fetched. Attachment blobs are purged after <strong>30 days</strong> regardless of download state, a receiver that wants to keep an image keeps the decrypted copy locally, not the server blob.</p> +<p><strong>One bit on each waiting envelope: was it announced.</strong> +Each waiting envelope also records whether its submission was announced, +which is the reverse of §8.4's <code>silent</code>. This is a relay +change made for 0.32 and not yet deployed. It is kept for the iPhone's +icon: the badge on an alert push (§8.11) counts that device's waiting +envelopes that were announced, so the delivery keys, receipts and +answers waiting beside a message do not show as more messages. It +reveals what <code>silent</code> already told the relay when the +envelope was submitted, roughly whether it is housekeeping, but now that +answer stays in the database for as long as the envelope waits, where a +copy of the database shows it too. It is deleted with the envelope. An +envelope another relay deposits is stored as not announced, because +nothing announced it.</p> +<p>The count includes game moves (§6.33), which are announced, so a run +of moves raises the number on the icon until the app next runs. The app +then sets the badge to its own unread count, in which moves do not +count.</p> <hr /> <h2 id="10-cross-engine-interop-status">10. Cross-engine interop status</h2> @@ -2692,7 +3563,7 @@ open</td> <p>Client wiring has 167 tests (<code>test/client.mjs</code>) run against a deliberately <strong>hostile</strong> in-process relay, one that strips PQ fields and forges capability signatures, which the real -server will not do. Server behaviour has 265 tests against the real +server will not do. Server behaviour has 272 tests against the real route methods (<code>test/relay.mjs</code>), driven against the running relay rather than a stub.</p> <p>Cross-engine interop has 92 tests (<code>test/interop.mjs</code>) @@ -3445,6 +4316,22 @@ written only when true, so a story nobody has opened carries nothing rather than a false. One engine keeps it per story and the other as a set of story keys; both shapes are the same fact and the per-story form is what travels.</p> +<p>Cards, events and games (§6.31 to §6.33) ride on the message they +arrived on. <code>card</code> is the card object as it travelled. +<code>ev</code> is the wire object of the latest accepted revision, with +<code>org</code>, the organizer's user id as an integer, and +<code>rsvp</code>, +<code>{"<user id>": {"r": "yes", "n": 3}}</code>, beside it on the +message. <code>game</code> is +<code>{"id", "t", "me", "log", "r", "rby"}</code> on the message that +started the game: <code>me</code> is the side this person plays, +<code>r</code> an ending a <code>gend</code> brought, and +<code>rby</code> the side that sent it, written only with +<code>r</code>. Each of these messages carries <code>"text": ""</code>, +so a sealed card's words are never written outside the card. The +<code>canonical</code> set in +<code>test/vectors/backup-payload-v1.json</code> carries a card, two +events, a game and a group card.</p> <p>An engine MUST carry a field it does not itself use. This app's pins show only which message is pinned and not <code>at</code> or <code>by</code>, and dropping those on the way through made a backup @@ -3595,7 +4482,7 @@ status for §16 and §17</h2> <td><code>exportBackup</code> / <code>importBackup</code></td> <td>n/a, no server route, by design</td> <td>export and import, in Settings</td> -<td>no</td> +<td>export and import, in Settings</td> </tr> <tr> <td>§17 multi-device</td> @@ -3614,11 +4501,20 @@ chat history…" and "Import chat history…", the file is sealed with a passphrase the person chooses, and <code>createBackup</code> has two call sites. Nothing continuous and no server copy, which is §16 as written rather than a lesser version of it.</p> -<p>That is the third time this repository has understated iOS, after -§15's two Android cells and §10's interop counts. All three were found -the same way, by looking for the call site, and the rule holds in both -directions: a cell is evidence about behaviour, and <code>no</code> -needs checking exactly as hard as <code>yes</code>.</p> +<p><strong>The Android cell read <code>no</code> until 2026-09-29, and +had been wrong since 2026-08-28</strong> (<code>6a0f642</code> in +flamenet-messenger-android). Settings has a +<code>HistoryBackupSection</code> that exports and imports, through +<code>MessengerViewModel.exportHistory</code> / +<code>importHistory</code>, over the engine's <code>Backup.create</code> +/ <code>Backup.open</code>, and <code>BackupVectorTest</code> checks +that engine against <code>test/vectors/backup-v1.json</code> from this +repository.</p> +<p>That is the fourth time this repository has understated an app, after +§15's two Android cells, §10's interop counts and the iOS cell above. +All four were found the same way, by looking for the call site, and the +rule holds in both directions: a cell is evidence about behaviour, and +<code>no</code> needs checking exactly as hard as <code>yes</code>.</p> <p>§17 on iOS was checked at the same time and is correctly <code>no</code>: no device linking, no sent-copy handling, and no UI for either.</p> @@ -3645,6 +4541,96 @@ today.</p> sent-copies are per-client; nothing about them is negotiated, so a v3 iOS device simply has no backup export and ignores <code>sent</code> content envelopes it does not implement.</p> +<h3 id="181-messenger-032">18.1 Messenger 0.32</h3> +<table> +<thead> +<tr> +<th></th> +<th>Web engine</th> +<th>Relay</th> +<th>iOS</th> +<th>Android</th> +</tr> +</thead> +<tbody> +<tr> +<td>§6.12 a site from the directory</td> +<td>none</td> +<td>n/a</td> +<td>built</td> +<td>built</td> +</tr> +<tr> +<td>§6.15 a story's caption, §6.35 taking a story down</td> +<td>none</td> +<td>n/a</td> +<td>in progress</td> +<td>in progress</td> +</tr> +<tr> +<td>§6.31 greeting cards</td> +<td>none</td> +<td>n/a</td> +<td>built</td> +<td>built</td> +</tr> +<tr> +<td>§6.32 events and answers</td> +<td>none</td> +<td>n/a</td> +<td>built</td> +<td>built</td> +</tr> +<tr> +<td>§6.33 games in a chat</td> +<td>none</td> +<td>n/a</td> +<td>built</td> +<td>built</td> +</tr> +<tr> +<td>§6.34 hello links</td> +<td>none</td> +<td>the <code>/hi</code> page, built</td> +<td>built</td> +<td>built</td> +</tr> +<tr> +<td>§8.12 the Today file</td> +<td>none</td> +<td>built</td> +<td>built</td> +<td>built</td> +</tr> +<tr> +<td>§9 the announced bit</td> +<td>n/a</td> +<td>built</td> +<td>n/a</td> +<td>n/a</td> +</tr> +<tr> +<td>§16.2.1 cards, events and games in a backup</td> +<td>none</td> +<td>n/a</td> +<td>built</td> +<td>built</td> +</tr> +</tbody> +</table> +<p>Built means built and tested on the 0.32 branches, and not released: +iOS 0.32.0 and Android 0.32 are in no store, and no relay runs this +code. The reference engine implements none of these sections. +<code>test/conformance.mjs</code> checks their vectors with readers +written from the spec, and checks the tic-tac-toe and reversi cases with +a referee of its own.</p> +<p>Where an app's 0.32 feature branch did not follow these sections, the +vector file records the input under <code>history</code>, with what each +app did and the decision that settled it, and the same input is an +ordinary case in that file. The release branches are being brought into +line with this text. <code>test/relay-today.mjs</code> drives +<code>/today.json</code> on a relay built with §8.12, and skips until a +relay answers that route.</p> <h2 id="19-key-transparency">19. Key transparency</h2> <p>Status: <strong>IMPLEMENTED</strong> on the web engine, the relay and iOS. <strong>NOT on Android</strong>, which carries -- 2.54.0 From ba68984eab786c4e2165072617f8415bdfdc329c Mon Sep 17 00:00:00 2001 From: Paul Hitt <contact@extant2000.com> Date: Wed, 14 Oct 2026 12:41:10 -0400 Subject: [PATCH 10/11] TodayFile.Key holds the raw bytes, because CryptoKit's PublicKey is not Sendable on Linux The relay's `swift` job failed on this and `swift build` on macOS did not: TodayFile.swift:100: error: stored property 'key' of 'Sendable'-conforming struct 'Key' has non-sendable type 'Curve25519.Signing.PublicKey' `Curve25519.Signing.PublicKey` is Sendable in Apple's CryptoKit and is not in swift-crypto, which is what the Linux build uses. The struct now holds the 32 raw bytes, which are Sendable everywhere, validates them in `init` exactly as before, and rebuilds the verifying key at the one place a signature is checked. A key that passed `init` rebuilds, so the `?? false` is unreachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- Sources/FlamenetRelay/TodayFile.swift | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/Sources/FlamenetRelay/TodayFile.swift b/Sources/FlamenetRelay/TodayFile.swift index 2000835..0e5e9e0 100644 --- a/Sources/FlamenetRelay/TodayFile.swift +++ b/Sources/FlamenetRelay/TodayFile.swift @@ -97,15 +97,28 @@ actor TodayFile { /// eight hex characters of the SHA-256 of the 32 raw bytes. struct Key: Sendable { let kid: String - let key: Curve25519.Signing.PublicKey + /// The 32 raw bytes, not a `Curve25519.Signing.PublicKey`. + /// + /// CryptoKit's `PublicKey` is Sendable on Apple's platforms and is + /// NOT in swift-crypto, which is what Linux builds against. Holding + /// one here compiled on macOS and failed the Linux build with "stored + /// property 'key' of 'Sendable'-conforming struct 'Key' has + /// non-sendable type". The bytes are Sendable everywhere, and the key + /// is rebuilt where a signature is checked. + let raw: Data init?(raw: Data) { - guard let key = try? Curve25519.Signing.PublicKey(rawRepresentation: raw) else { + guard (try? Curve25519.Signing.PublicKey(rawRepresentation: raw)) != nil else { return nil } - self.key = key + self.raw = raw self.kid = TodayFile.kid(of: raw) } + + /// The verifying key. Non-nil: `init` refused anything else. + var signing: Curve25519.Signing.PublicKey? { + try? Curve25519.Signing.PublicKey(rawRepresentation: raw) + } } /// What the route hands out. @@ -325,7 +338,7 @@ actor TodayFile { guard let body = Data(base64Encoded: envelope.body), !body.isEmpty, let sig = Data(base64Encoded: envelope.sig), sig.count == 64 else { throw Refusal.malformed } - guard named.contains(where: { $0.key.isValidSignature(sig, for: body) }) else { + guard named.contains(where: { $0.signing?.isValidSignature(sig, for: body) ?? false }) else { throw Refusal.badSignature(envelope.kid) } return envelope -- 2.54.0 From dca5333e9b4df287673f73ec0ce1a46f0b80bc92 Mon Sep 17 00:00:00 2001 From: Paul Hitt <contact@extant2000.com> Date: Wed, 14 Oct 2026 12:47:33 -0400 Subject: [PATCH 11/11] BadgeCountTests follows the deposit path wave 2 left behind `Routes.placeDeposits` is gone: wave 2 split it into `resolveDeposits`, which turns a peer's envelopes into `Delivery.Item`s, and `Delivery.write`, which inserts them and then wakes and pushes. The test still called the old one and the Linux test build stopped on "type 'Routes' has no member 'placeDeposits'". It now does what the route does, in the same two steps, and still asserts the same thing: a deposit is written with `announced` false, so the badge does not count it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../FlamenetRelayTests/BadgeCountTests.swift | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/Tests/FlamenetRelayTests/BadgeCountTests.swift b/Tests/FlamenetRelayTests/BadgeCountTests.swift index c7f30e0..76c8c7e 100644 --- a/Tests/FlamenetRelayTests/BadgeCountTests.swift +++ b/Tests/FlamenetRelayTests/BadgeCountTests.swift @@ -127,13 +127,24 @@ struct BadgeCountTests { user.fnid = "fnid:bob" try await user.create(on: h.db) - let placed = try await Routes.placeDeposits( - [ - FederationDepositRequest.Item( - to_fnid: "fnid:bob", to_device: r.deviceID, payload: "afar", - envelope_id: nil) - ], on: h.db) - #expect(placed.accepted == 1) + // The route's own two steps since wave 2: `resolveDeposits` turns + // a peer's envelopes into `Delivery.Item`s, and `Delivery.write` + // inserts them. It used to be one `placeDeposits` call that did + // both. The point of the test is unchanged: a deposit is written + // with `announced` false, so the badge does not count it. + let (ids, unknown) = try await Delivery.write( + db: h.db, app: h.app + ) { db in + let resolved = try await Routes.resolveDeposits( + [ + FederationDepositRequest.Item( + to_fnid: "fnid:bob", to_device: r.deviceID, payload: "afar", + envelope_id: nil) + ], on: db) + return (resolved.items, resolved.unknown) + } + #expect(ids.count == 1) + #expect(unknown.isEmpty) #expect(try await badge(h, r) == 0) } } -- 2.54.0