diff --git a/.e2e-ref b/.e2e-ref index 89f7733..4df0a2b 100644 --- a/.e2e-ref +++ b/.e2e-ref @@ -1 +1 @@ -310c315155833ef964962d55d74b6bb9faa3ab49 +d0274ca05ea904d50b6f855aab3267c61cf45f6c diff --git a/.env.example b/.env.example index 49e450c..f94f4ab 100644 --- a/.env.example +++ b/.env.example @@ -315,3 +315,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/Public/hi.js b/Public/hi.js new file mode 100644 index 0000000..91ae7de --- /dev/null +++ b/Public/hi.js @@ -0,0 +1,94 @@ +// 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, 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); + if (raw.length === 0 || raw.length > 400) return null; + try { + raw = decodeURIComponent(raw); + } 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).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; + } + + 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 4605d5e..11dbc23 100644 --- a/Public/portal.css +++ b/Public/portal.css @@ -867,3 +867,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 10e323a..81fe97d 100644 --- a/Resources/Views/api.leaf +++ b/Resources/Views/api.leaf @@ -3,7 +3,7 @@ + source-sha256: bb629ec09b19747a -->

Relay API

@@ -84,6 +84,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.12)
+

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).

@@ -682,8 +707,8 @@ refused with kept_on_device: those live on the device 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/audit.leaf b/Resources/Views/audit.leaf index b591155..fc4d0bc 100644 --- a/Resources/Views/audit.leaf +++ b/Resources/Views/audit.leaf @@ -3,7 +3,7 @@ + source-sha256: 32e1c931312304d4 -->

Audit scope

@@ -20,7 +20,7 @@ directly.

flamenet-e2e +href="https://git.extant2000.com/extant2000/flamenet-e2e">flamenet-e2e src/ JavaScript Reference cryptographic engine. Normative where implementations @@ -28,7 +28,7 @@ disagree. This repository. flamenet-messenger-ios +href="https://git.extant2000.com/extant2000/flamenet-messenger-ios">flamenet-messenger-ios Sources/E2E/ Swift (CryptoKit) iOS engine and app. Checked against the reference in both @@ -36,7 +36,7 @@ directions. flamenet-messenger-android +href="https://git.extant2000.com/extant2000/flamenet-messenger-android">flamenet-messenger-android engine/ Kotlin Android engine and app. Held to the published vectors rather than to @@ -44,7 +44,7 @@ the other two engines. flamenet-relay +href="https://git.extant2000.com/extant2000/flamenet-relay">flamenet-relay Swift (Vapor) The relay. Holds no key that opens an encrypted message. diff --git a/Resources/Views/configuration.leaf b/Resources/Views/configuration.leaf index 4386db8..a66646f 100644 --- a/Resources/Views/configuration.leaf +++ b/Resources/Views/configuration.leaf @@ -3,7 +3,7 @@ + source-sha256: 990bef688fcc2b5c -->

Configuring a relay

@@ -395,6 +395,9 @@ needs FN_FEDERATION_KEY. FN_DONATE_SECRET is set.
  • Attachments, to an S3-compatible store, when FN_S3_ENDPOINT is set.
  • +
  • The Today file, to the address in FN_TODAY_SOURCE, +about every fifteen minutes. Set FN_TODAY_FILE instead to +serve one from disk and fetch nothing.
  • It also connects to the databases you point it at: Postgres at FN_POSTGRES_URL and Redis at FN_REDIS_URL. @@ -950,6 +953,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.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.

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    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/Resources/Views/engine.leaf b/Resources/Views/engine.leaf index 90b40ce..ca17919 100644 --- a/Resources/Views/engine.leaf +++ b/Resources/Views/engine.leaf @@ -3,7 +3,7 @@ + source-sha256: 5eab873f2de2e473 -->

    flamenet-e2e

    @@ -15,7 +15,7 @@ href="docs/SPEC.md">docs/SPEC.md, the engine behind Flamenet Messenger: https://flamenetmessenger.com.

    Check it yourself

    -
    git clone https://gitlab.com/extant2000/flamenet-e2e && cd flamenet-e2e
    +
    git clone https://git.extant2000.com/extant2000/flamenet-e2e && cd flamenet-e2e
     npm test
    274 passed, 0 failed

    No install step, no dependencies to fetch, nothing to trust before it @@ -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 @@ -84,7 +84,7 @@ for a sealed message. sent-copies, both client-side only: there is no server route for either, by design.
  • The server is published too, as flamenet-relay: +href="https://git.extant2000.com/extant2000/flamenet-relay">flamenet-relay: its own repository, with its own pipeline. Self-hosting is not a claim you have to take on trust: the relay is the same code, published.
  • @@ -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/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/es/metadata.leaf b/Resources/Views/es/metadata.leaf index 55a586a..b32d198 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.

    @@ -34,7 +35,8 @@ Adjuntos (fotos, notas de voz, historias, todo cifrado)Los dispositivos los descargan aparte del sobre que los referencia#(retention.attachmentDays) días PresentacionesCuando alguien que todavía no tiene forma de escribirte te busca por tu nombre de usuario, o abre uno de tus enlaces de contacto, el servidor le da un pase válido para un mensaje sellado a ti. Guarda el hash del pase y a qué cuenta da acceso. No anota quién preguntó, desde dónde ni cuándo. Las aplicaciones todavía no lo usan.Cuando se usa el pase, o 10 minutos después de crearlo “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/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 e913a76..7c9038b 100644 --- a/Resources/Views/layout.leaf +++ b/Resources/Views/layout.leaf @@ -5,6 +5,7 @@ #(title) +#if(noindex):#endif diff --git a/Resources/Views/metadata.leaf b/Resources/Views/metadata.leaf index 82c086b..e285781 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.

    @@ -35,7 +36,7 @@ Attachment blobs (photos, voice notes, stories, all ciphertext)Devices fetch them separately from the envelope that references them#(retention.attachmentDays) days IntroductionsWhen somebody with no way to reach you yet looks you up by your username, or opens one of your contact links, the server hands them a pass good for one sealed message to you. It keeps the hash of the pass and which account it opens. It does not record who asked, from where, or when. The apps do not use this yet.When the pass is used, or 10 minutes after it was made “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 diff --git a/Resources/Views/self-hosting.leaf b/Resources/Views/self-hosting.leaf index 35391d9..49d118a 100644 --- a/Resources/Views/self-hosting.leaf +++ b/Resources/Views/self-hosting.leaf @@ -3,7 +3,7 @@ + source-sha256: ffb090338af8b300 -->

    Self-hosting

    @@ -23,23 +23,23 @@ licence.

    flamenet-relay +href="https://git.extant2000.com/extant2000/flamenet-relay">flamenet-relay The server you are about to run flamenet-e2e +href="https://git.extant2000.com/extant2000/flamenet-e2e">flamenet-e2e The protocol: JavaScript engine, specification, published test vectors flamenet-messenger-ios +href="https://git.extant2000.com/extant2000/flamenet-messenger-ios">flamenet-messenger-ios The iOS app, and the source of truth for all of the above flamenet-messenger-android +href="https://git.extant2000.com/extant2000/flamenet-messenger-android">flamenet-messenger-android The Android app, with its own Kotlin engine @@ -48,7 +48,7 @@ href="https://gitlab.com/extant2000/flamenet-messenger-android">flamenet-messeng "self-hostable" is something you can check rather than something you are told.

    Quick start

    -
    git clone https://gitlab.com/extant2000/flamenet-relay
    +
    git clone https://git.extant2000.com/extant2000/flamenet-relay
     cd flamenet-relay
     cp .env.example .env

    Generate the three secrets .env asks for:

    diff --git a/Resources/Views/spec.leaf b/Resources/Views/spec.leaf index b3c1aee..e89ca92 100644 --- a/Resources/Views/spec.leaf +++ b/Resources/Views/spec.leaf @@ -3,7 +3,7 @@ + source-sha256: c2b70ec4431be8aa -->

    Flamenet @@ -379,6 +379,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 @@ -481,6 +490,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 @@ -718,15 +776,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: @@ -806,6 +866,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:

    @@ -921,6 +998,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 @@ -1152,27 +1239,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, @@ -1182,9 +1274,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:

    @@ -1223,22 +1315,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 @@ -1592,6 +1685,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 @@ -1730,6 +1833,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 @@ -1779,7 +2415,7 @@ a compatibility alias; the proof signature commits to the All routes below are under <code>/e2e</code>.</p> <p><strong>Auth:</strong> every route requires an authenticated user, except where it says otherwise (sealed submissions, §14.5; the key log, -§19.3; anonymous reads, §8.11), satisfied by <strong>either</strong> +§19.3; anonymous reads, §8.13), satisfied by <strong>either</strong> <code>Authorization: Bearer <token></code>, a short-lived token scoped to one device and one relay, (web client). No subscription tier is required. The authenticated user is "me".</p> @@ -1830,7 +2466,7 @@ and stories quietly failing. The per-requester limit is the main defence against a drain; the pair limit is the second fence.</p> <p>From stage C1 a first contact gets its bundles from <code>POST /e2e/intro</code> (§14.5), metered the same way, and from -stage D1 the bundles route has an anonymous form (§8.11). For each of +stage D1 the bundles route has an anonymous form (§8.13). For each of the target user's devices, <strong>atomically pops</strong> one unused one-time prekey (marks it used) and returns the bundle. Response:</p> </blockquote> @@ -1858,7 +2494,7 @@ authenticated member, about any account, unmetered: when each of somebody's devices registered and when it last polled, a liveness record nobody but the account needs. From D1 the times are returned only when the account asked about is the caller's own (the apps' Devices screens -read them there), and <code>GET /e2e/devices/me</code> (§8.11) is the +read them there), and <code>GET /e2e/devices/me</code> (§8.13) is the route for them.</p> <h3 id="83a-delete-e2edevicesdevice_id-revoke-one-of-my-own-devices">8.3a @@ -1894,6 +2530,18 @@ The length of the string is the only property of a payload the relay may look at: it may count lengths that are off the ladder, and once enforcing it refuses them with <code>400</code> and the code <code>unpadded</code> (§23.11).</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> @@ -2211,7 +2859,7 @@ per-account <code>sealed_inbound_only</code> setting exists, under which the relay refuses every unsealed message to that account and needs no pair to do it, together with a way for a stranger to make first contact under it. That way is §14.5's intro capability, and the setting is what -an unlisted account gets at stage B4 (§8.12); refusing named sends +an unlisted account gets at stage B4 (§8.14); refusing named sends relay-wide (§14.5, stage C4) is the same thing for everybody;</li> <li>only then does the relay's block table go.</li> </ul> @@ -2414,7 +3062,202 @@ 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-anonymous-reads">8.11 Anonymous reads</h3> +<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="813-anonymous-reads">8.13 Anonymous reads</h3> <p>Status: <strong>planned</strong>, stage D1 on the relay and D2 in the apps. Nothing here exists yet.</p> <p>Every read in §8.2 and §8.3 is authenticated and names its target in @@ -2516,7 +3359,7 @@ the gain is small, and it is described that way.</p> <p>The old authenticated routes stay until no device older than stage D2 has been seen for 30 days; then the GET routes that carry a user id in the path are retired (stage D3, decision DD4).</p> -<h3 id="812-unlisted-accounts-and-contact-links">8.12 Unlisted accounts +<h3 id="814-unlisted-accounts-and-contact-links">8.14 Unlisted accounts and contact links</h3> <p>Status: <strong>planned</strong>, stages B1 to B4; it depends on §14.5's intro capabilities. Nothing here exists yet.</p> @@ -2565,7 +3408,7 @@ for it. The recipient uploads its hash with relay holds nothing that marks it as a link. The person who scans it presents it as <code>delivery_token</code> on their first message, which spends it; no introduction is involved, and they read the bundles from -<code>POST /e2e/anon/bundles</code> (§8.11). Revoking an unused one is +<code>POST /e2e/anon/bundles</code> (§8.13). Revoking an unused one is revoking that token.</li> <li>a <strong>reusable</strong> link: 32 random bytes, good until revoked, at most five at a time, registered with @@ -2604,31 +3447,69 @@ than misreads. <code>test/vectors/links-v1.json</code> pins both forms, what a reader must accept, and what it must refuse.</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, +<li><p><code>{p}fnmsg_e2e_devices</code>, one row per device (identity + +current signed prekey).</p></li> +<li><p><code>{p}fnmsg_e2e_prekeys</code>, one-time prekey pool, +<code>used</code> flag.</p></li> +<li><p><code>{p}fnmsg_e2e_envelopes</code>, sealed message queue, <code>delivered</code> flag. Every padded payload is one of two lengths (§23.3), so a row's size says which step it landed in and nothing finer. Padding roughly doubles what the table and its write-ahead log -hold.</li> -<li><code>{p}fnmsg_e2e_attachments</code>, sealed blob index: +hold.</p></li> +<li><p><code>{p}fnmsg_e2e_attachments</code>, sealed blob index: <code>token</code>, uploader, size, file path, <code>created_at</code>. Once attachment padding is on, the size is a ladder value (§23.9). The blob bytes live on disk under <code>uploads/fnmsg-e2e/</code>, direct -web access denied; the REST route is the only reader.</li> +web access denied; the REST route is the only reader.</p></li> +<li><p><code>devices</code>, one row per device (identity + current +signed prekey).</p></li> +<li><p><code>prekeys</code> and <code>pq_prekeys</code>, the one-time +prekey pools, each with a <code>used</code> flag.</p></li> +<li><p><code>envelopes</code>, the sealed message queue, with a +<code>delivered</code> flag and an <code>announced</code> one +(below).</p></li> +<li><p><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.</p></li> +<li><p><code>push_tokens</code>, one row per iPhone that asked to be +woken (§8.11).</p></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> @@ -2661,7 +3542,7 @@ tamper rejection and safety numbers.</li> <h3 id="android-verified">Android, verified</h3> <p>The Android engine exists and is exchanged with the web engine. It lives in <a -href="https://gitlab.com/extant2000/flamenet-messenger-android">flamenet-messenger-android</a> +href="https://git.extant2000.com/extant2000/flamenet-messenger-android">flamenet-messenger-android</a> <code>engine/</code>, 18 Kotlin files, and a plain JVM library with no <code>android.*</code> imports, which is exactly the shape this section predicted it would need to be testable at all.</p> @@ -3163,10 +4044,10 @@ open</td> <p>Client wiring has 289 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 95 tests (<code>test/interop.mjs</code>) +<p>Cross-engine interop has 98 tests (<code>test/interop.mjs</code>) driving the Swift engine against the JS engine in both directions.</p> <p><strong><code>pqMode</code> is still <code>"optional"</code>, and both of the reasons it was have now gone.</strong> They were: Android is @@ -3197,7 +4078,7 @@ taken, first contact with an unpinned device remains downgradable. See </tr> <tr> <td>Relay (Swift, <a -href="https://gitlab.com/extant2000/flamenet-relay">flamenet-relay</a>)</td> +href="https://git.extant2000.com/extant2000/flamenet-relay">flamenet-relay</a>)</td> <td><strong>implemented</strong></td> </tr> <tr> @@ -3535,14 +4416,14 @@ for the first message:</p> <ul> <li><code>POST /e2e/intro</code> with <code>{"link": "<base64url>"}</code>, the capability carried by a -reusable contact link (§8.12), which the intro does not spend; or</li> +reusable contact link (§8.14), which the intro does not spend; or</li> <li><code>POST /e2e/intro</code> with <code>{"handle": "ada", "challenge": …, "nonce": …}</code>, for <strong>listed</strong> accounts only, after a proof of work -(§8.11).</li> +(§8.13).</li> </ul> <p>A one-time link needs no intro: it carries a delivery token, and the -first message presents that (§8.12).</p> +first message presents that (§8.14).</p> <p>The answer is the account, its handle certificate, its bundles (§8.2, one prekey popped per device, metered as that route is), and one capability with its expiry:</p> @@ -3557,7 +4438,7 @@ the same answer, the answer for an account that does not exist, held to the same response-time floor. Requests to this route are metered per IP and per network before anything is looked up, then per target and per (network, target), and an account may have only so many unspent intros -waiting. By handle they also cost a proof of work (§8.11).</p> +waiting. By handle they also cost a proof of work (§8.13).</p> <p>The first message rides the intro, and its prelude (§14.4) hands the recipient the sender's own key, a slice of tokens and the sender's own handle certificate, so the reply goes sealed too and nothing after the @@ -3616,7 +4497,7 @@ account, nor refuse a blocked person's message on the server, because it no longer knows who sent anything. What is left is per-recipient and per-IP metering, the meters on intros, single-use ten-minute intros, invitation codes, the proof of work, Requests, and unlisted accounts -(§8.12). Metering by recipient opens a door of its own: anybody holding +(§8.14). Metering by recipient opens a door of its own: anybody holding one valid key could spend a recipient's whole bucket and lock everybody else out. So the per-recipient bucket counts only <strong>refused</strong> attempts, and accepted ones count against a @@ -4108,6 +4989,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 @@ -4443,7 +5340,7 @@ and re-issued;</p></li> <li><p>the presence tokens this account issued (§8.9c), replaced;</p></li> <li><p>sealed groups this account owns (§8.9b), re-keyed;</p></li> -<li><p>contact links (§8.12), revoked;</p></li> +<li><p>contact links (§8.14), revoked;</p></li> <li><p>and to every contact, a <code>rekey_request</code> naming the device:</p> <pre class="json"><code>{ "fnc": 1, "kind": "rekey_request", "ctl": 1, "dev": "<device id>", "at": 1787260000000 }</code></pre> @@ -4843,6 +5740,96 @@ has sent-copies.</strong> Nothing about either is negotiated. A rather than drawing a bubble (§6.10). This paragraph said the apps had neither feature after the table had been corrected, which is the same understatement as the cells above, in a second place.</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 @@ -5508,7 +6495,7 @@ one way a blocked person can still make a phone ring. Even then a blocked person who knows a listed handle can get an intro and reach Requests, where the device drops them; the relay is not asked to refuse intros for blocked pairs, because that would need the pair (decision -DC3). An account that wants no strangers at all is unlisted (§8.12).</p> +DC3). An account that wants no strangers at all is unlisted (§8.14).</p> <h3 id="215-why-the-credential-scheme-is-still-not-implemented">21.5 Why the credential scheme is still not implemented</h3> <p>Implementing algebraic MACs and their proofs correctly is not a @@ -5999,6 +6986,474 @@ unsealed envelope not yet run</td> </tr> </tbody> </table> +<hr /> +<h2 id="24-obfs4-bridges-and-the-bridge-offer">24. obfs4 bridges and the +bridge offer</h2> +<p>Status: <strong>IMPLEMENTED</strong> in both apps from the bridge +work (flamenet-messenger-ios and flamenet-messenger-android, branch +<code>bridge/f2f-apps</code>): offers sent and received, up to 8 lines +with sequential failover, and nothing connecting around a saved bridge. +The web engine has no bridge. §24.9 records exactly what exists.</p> +<h3 id="241-what-a-bridge-is-for-and-what-it-is-not">24.1 What a bridge +is for, and what it is not</h3> +<p>A bridge is a second way to reach the relay, for a network that +blocks the first. It speaks obfs4: after a handshake, every byte looks +random, so a censor watching the wire sees traffic to an address it has +not listed, in a shape it cannot name. Inside the tunnel the app speaks +exactly what it would speak without one, TLS to the relay's own +hostname, so a bridge adds nothing to what the relay sees and takes +nothing away from it.</p> +<p>Reachability, not anonymity. The bridge's operator sees the client's +address. The relay sees it too where the deployment passes it through: +the reference deployment forwards from the bridge to its edge proxy with +a PROXY protocol v1 line, and the edge trusts that line from the +bridge's fixed address only, so country rules, bans and the relay's +per-IP limits all apply to each bridged client's own address, as they +would without the bridge.</p> +<p>A bridge is configured by pasting a <strong>bridge line</strong>. It +is off unless a line is set; no build ships with one.</p> +<h3 id="242-the-bridge-line">24.2 The bridge line</h3> +<pre><code>obfs4 198.51.100.7:8443 FBEFBEFFFFFF000102030405060708090A0B0C0D cert=++++////AAECAwQFBgcICQoLDA2FIPAJiTCnVHSLfdy0PvdaDb86DSY4GvTrpKmOqptOag iat-mode=0</code></pre> +<p>Fields are separated by any run of whitespace. A line pasted out of a +chat often wrapped, so a newline between fields is ordinary and MUST be +accepted. Parsers find each field by its shape, never by its +position:</p> +<table> +<thead> +<tr> +<th>Field</th> +<th>Shape</th> +<th>Meaning</th> +</tr> +</thead> +<tbody> +<tr> +<td>transport</td> +<td><code>obfs4</code></td> +<td>Optional. Parsers ignore it. Senders SHOULD include it, since every +other tool expects it first.</td> +</tr> +<tr> +<td>address</td> +<td>the first field containing <code>:</code> and no <code>=</code></td> +<td><code>host:port</code>. The port is the text after the +<strong>last</strong> colon, so a bracketed IPv6 literal +(<code>[2001:db8::7]:443</code>) keeps its own colons and its brackets +stay on the host. The host is an IPv4 address, a bracketed IPv6 address +or a hostname; the port is decimal digits, 1 to 65535.</td> +</tr> +<tr> +<td>fingerprint</td> +<td>40 hex characters</td> +<td>The bridge's obfs4 node ID, upper case as bridges print it. Clients +ignore it; the cert carries the same 20 bytes. Inventories and +distributors key on it.</td> +</tr> +<tr> +<td><code>cert=</code></td> +<td>standard base64 (<code>+</code> and <code>/</code>), padding +optional</td> +<td>Decodes to exactly <strong>52 bytes</strong>: the 20-byte node ID, +then the bridge's 32-byte X25519 identity public key. A bridge writes it +unpadded, 70 characters. Receivers MUST accept it with or without the +trailing <code>==</code>, and MUST NOT accept the URL-safe +alphabet.</td> +</tr> +<tr> +<td><code>iat-mode=</code></td> +<td><code>0</code>, <code>1</code> or <code>2</code></td> +<td>Inter-arrival timing obfuscation. Clients implement <code>0</code>. +A line asking for <code>1</code> or <code>2</code> is still usable, +because the bridge does not enforce it: the client says so beside the +line (a caveat), and does not refuse it.</td> +</tr> +</tbody> +</table> +<p>Any other <code>key=value</code> field is ignored.</p> +<p>Reading a line yields either a bridge (host, port, node ID, public +key, and whether there is a caveat) or one problem, checked in this +order:</p> +<ol type="1"> +<li><strong>Empty</strong>: no fields.</li> +<li><strong>NoAddress</strong>: no address field, or an empty host.</li> +<li><strong>BadPort</strong>: the port is not digits in range.</li> +<li><strong>NoCert</strong>: no <code>cert=</code> field.</li> +<li><strong>CertNotReadable</strong>: not base64. Almost always a stray +space or line break picked up while copying.</li> +<li><strong>CertWrongLength</strong>: decodes, but not to 52 bytes. +Short is by far the likeliest paste error, because the line wrapped and +only the first part was copied, and the message MUST say the line looks +cut short rather than that it is malformed.</li> +</ol> +<p>Messages are written for the person who pasted the line. They MUST +NOT contain the words obfs4, base64 or node id. Both apps carry the same +messages, and a test in the Android app +(<code>messages match the ones iOS shows</code>) holds its copy to the +iOS one.</p> +<h3 id="243-the-offer">24.3 The offer</h3> +<p>A contact abroad, or anyone already using a bridge that works, can +hand it to someone inside a conversation they already have. Nothing new +has to be run for this, and there is no server for a censor to block: +the line travels through the ratchet like a message.</p> +<pre class="json"><code>{"fnc":1,"kind":"bridge","ctl":1,"line":"obfs4 198.51.100.7:8443 … iat-mode=0"}</code></pre> +<table> +<thead> +<tr> +<th>Key</th> +<th>Type</th> +<th>Meaning</th> +</tr> +</thead> +<tbody> +<tr> +<td><code>fnc</code></td> +<td>integer <code>1</code></td> +<td>Content envelope (§6.6).</td> +</tr> +<tr> +<td><code>kind</code></td> +<td><code>"bridge"</code></td> +<td></td> +</tr> +<tr> +<td><code>ctl</code></td> +<td><code>1</code></td> +<td>Control traffic (§6.10). Receivers also accept +<code>true</code>.</td> +</tr> +<tr> +<td><code>line</code></td> +<td>string</td> +<td>The bridge line, as the sender uses it.</td> +</tr> +</tbody> +</table> +<p>No other key is defined at <code>fnc</code> 1. In particular an offer +carries no <code>gid</code>, no <code>mid</code> and no timer.</p> +<p><strong>Writing it.</strong> Both apps build the envelope by +template, keys in the order above, with the line escaped by a JSON +string writer. Three writers are in the field and they disagree about +<code>/</code>. Android 0.31 and earlier used the platform org.json, +which writes every <code>/</code> as <code>\/</code>. Android 0.32 and +later use the engine's own writer, which escapes only what JSON requires +and never <code>/</code>. The iOS builder, like org.json on the JVM, +escapes <code>/</code> only after <code><</code>. Two certs in three +have at least one slash. All three forms are the same JSON string, so +receivers MUST decode the envelope with a JSON parser and compare +decoded strings, never bytes. The vectors (§24.10) carry all three.</p> +<p><strong>Sending it.</strong></p> +<ul> +<li>The line sent is the line the sender's own app uses, as stored. A +client MUST NOT offer a line it cannot read (§24.2).</li> +<li>To one contact, through that contact's pairwise sessions, sealed +when it can be (§14) like any other message. Never through a group +fan-out (§6.7), never in a story, and never to many contacts with one +action: each offer is one person's deliberate choice about one other +person.</li> +<li>Never automatically, and never in reply to anything a peer +sends.</li> +<li>Before the first send the interface MUST say what the contact +learns: that the sender uses a bridge, and which one. Whoever runs the +bridge sees one more address connecting.</li> +</ul> +<h3 id="244-size-and-padding">24.4 Size and padding</h3> +<p>The <code>line</code> MUST NOT be longer than <strong>1024 +bytes</strong> of UTF-8, measured on the decoded string. A real line is +about 150 bytes with an IPv4 address and under 200 with an IPv6 one; +only a long hostname takes it further. The limit is generous, and it +bounds the worst case: with every byte escaped as <code>\uXXXX</code>, +the envelope stays under 7 KiB, well inside the second padding step +(§23.3). A sender MUST NOT send a longer line; a receiver MUST drop an +offer whose line is longer.</p> +<p>An offer is control traffic and takes no padding class of its own. +The pad is added at the transport layer, not in the content envelope +(§23.2), so an offer is padded like every other payload: a real one, +about 150 bytes of line, lands in the first step with ordinary text and +every other control message, and is the same length on the wire as a +short message.</p> +<p>Until a client pads, an offer is as long as its line plus a +near-constant overhead. Bridge lines have nearly constant lengths, so an +unpadded offer has a recognisable size. Sealed (§14), the relay learns +that a payload of that size went to a device; unsealed, it can read the +length of <code>ct</code> directly (§23.1). Neither reveals the line. +This is the general limit §23 exists to close, stated here because an +offer is one of the few control messages whose size alone suggests what +it is.</p> +<h3 id="245-receiving-an-offer">24.5 Receiving an offer</h3> +<p>An envelope is an offer when all of these hold:</p> +<ol type="1"> +<li><code>fnc</code> is 1 and <code>kind</code> is +<code>"bridge"</code>. The kind alone decides it; a missing +<code>ctl</code> does not stop an offer from being one.</li> +<li><code>line</code> is a string, and is not empty or whitespace after +trimming.</li> +<li>There is no <code>gid</code>. An offer that arrived through a group +is dropped.</li> +<li><code>line</code> is at most 1024 bytes (§24.4).</li> +<li>The sender is proven: the ratchet bound the envelope to an identity +the receiver already holds, the same rule as every other control +message. An offer from a federated address is dropped.</li> +</ol> +<p>An envelope that fails any of these is consumed silently, because it +carries <code>ctl</code> (§6.10). It is never shown as a message.</p> +<p>An offer is <strong>never applied</strong>. Arriving changes no +setting and opens no connection. It is stored for the person to look +at:</p> +<ul> +<li>one pending offer per contact; a newer offer from the same contact +replaces the older one. A contact who rotates a bridge sends the new +line, and a list of every line they ever had is a menu of dead +hosts;</li> +<li>what is kept is who offered it, the line exactly as received, and +when it arrived by the receiver's clock;</li> +<li>kept with the device's other secrets, and never in an encrypted +backup (§16) or a platform backup. An offer names a contact beside the +address of a bridge they use, which is the wrong thing to find on a +seized phone's cloud backup.</li> +</ul> +<p>It is shown in the app's bridge settings, never in the conversation +and never as a notification. For each offer:</p> +<ul> +<li>who offered it: their display name, else their handle, else the +account number, never "someone";</li> +<li>the whole line, as received, in a fixed-width face. It is not hidden +behind the accept button;</li> +<li>what reading it gave (§24.2): the problem in the person's words, or +where it points ("198.51.100.7 port 8443") and the caveat if there is +one. An offer that cannot be used says why when it is shown, not when it +is accepted;</li> +<li><strong>Use this bridge</strong>, enabled only when the line reads +as a bridge, and <strong>Dismiss</strong>.</li> +</ul> +<h3 id="246-accepting-and-several-lines-with-failover">24.6 Accepting, +and several lines with failover</h3> +<p>Accepting reads the line again, stores it as a line the client uses, +and removes the offer. The line joins the stored list below; it does not +replace the lines already there.</p> +<p>A client MAY hold up to <strong>8</strong> lines, in the order they +were added. A line pasted by the person and a line accepted from an +offer are stored alike; where a line came from is kept only for +display.</p> +<ul> +<li><strong>Order of attempts.</strong> The line that last completed a +connection first, then the rest in stored order. One at a time: dialling +every bridge at once is a burst a censor can see, and it hands every +stored address to an observer on the first connection.</li> +<li><strong>What counts as failure.</strong> No completed obfs4 +handshake within 15 seconds, a refused or reset connection, or TLS to +the relay's hostname failing inside the tunnel. A handshake alone is not +success; the TLS check is what shows the bridge reaches the relay.</li> +<li><strong>When every line fails, stop.</strong> Say that no bridge +could be reached, and keep trying on the usual backoff. A client with +bridges turned on MUST NOT fall back to a direct connection. For someone +using a bridge because the direct route is watched, a silent direct +connection is the worst outcome available, worse than no connection. +Direct happens only when the person turns bridges off.</li> +<li><strong>Status reads the live state</strong>, the tunnel that is up +now, never the stored list.</li> +<li>A line that keeps failing is not deleted automatically. It is marked +with when it last worked, and the person removes it. A bridge can be +blocked for a week and then reachable again.</li> +<li>Nothing about which lines failed is sent anywhere.</li> +</ul> +<p><strong>Nothing goes around the bridge.</strong> A bridge reaches the +edge in front of the Flamenet server and nothing else. While a line is +stored, every connection the client makes goes through it, before +sign-in included: relay discovery, the sign-up proof of work, the +account service, the update check. A name is not looked up on the local +network either, since the lookup alone says which service this is. A +feature that has to connect to a different host, because what it fetches +lives there, MUST be off while a line is stored, and the interface says +so in plain words where the feature would be. In both apps that is link +previews (§6.12; the sender fetches the page), calls (§6.8; media goes +to the peer or a TURN server), the wallet (it asks a public chain node), +and, on iOS, live streams in stories (§6.13). Opening a link in the +system browser is the person's own action and leaves the app; the +browser does not use the bridge.</p> +<p><strong>How the apps try lines.</strong> iOS runs a local SOCKS5 +listener that the app's sessions use; for each connection it dials the +lines in order, and a line fails when it refuses, when no handshake +completes within 15 seconds, or when it closes or stays silent with the +client's first bytes unanswered, in which case those bytes are sent +again through the next line. Android's resolver answers every name with +one placeholder address per line, in order, and its HTTP client's own +retry moves to the next line on a refused connection, a handshake +timeout, or TLS failing inside the tunnel. A server address given as an +IP literal skips the resolver, so an interceptor retries such a request +once per line on the same failures.</p> +<h3 id="247-removing-a-line">24.7 Removing a line</h3> +<ul> +<li><strong>The person removes it.</strong> It is deleted from storage, +and every connection opened through it is closed at once. A pooled HTTP +connection outlives a settings change, so a removed bridge can otherwise +keep carrying traffic after the screen says it is gone. If it was the +last line, the client is off bridges, which is the person's choice and +is the one case where it then connects directly.</li> +<li><strong>An offer</strong> goes when it is accepted, when it is +dismissed, or when the same contact sends a newer one.</li> +<li><strong>There is no retraction on the wire.</strong> A sender cannot +withdraw an offer already sent, and whoever retires a bridge cannot +reach the copies already handed out. A retired bridge's line stops +working; failover moves past it and the person removes it. This is +deliberate. A message that removed bridges remotely would let anyone +holding a contact's unlocked phone strip a person of the one route they +have left, and the setting bridges exist for is the one where that is +likely.</li> +</ul> +<h3 id="248-what-each-party-learns">24.8 What each party learns</h3> +<table> +<thead> +<tr> +<th>Party</th> +<th>Learns</th> +<th>Does not learn</th> +</tr> +</thead> +<tbody> +<tr> +<td>The relay</td> +<td>That a control payload of some size reached a device (§24.4). +Sealed, not who sent it.</td> +<td>The line.</td> +</tr> +<tr> +<td>The contact who receives an offer</td> +<td>That the sender uses a bridge, and which one.</td> +<td>Anything else about the sender's network.</td> +</tr> +<tr> +<td>The bridge's operator</td> +<td>Each client's address, when it connects and how much it sends.</td> +<td>Content: the tunnel carries TLS to the relay.</td> +</tr> +<tr> +<td>A network observer near the client</td> +<td>Traffic to the bridge's address that matches no listed +protocol.</td> +<td>That it reaches the relay, unless the bridge's address is known to +be one.</td> +</tr> +</tbody> +</table> +<p>Handing someone a bridge line also hands it to anyone who later reads +their phone. That is the same exposure as any message in the +conversation, and the reason accepted lines and pending offers stay out +of backups.</p> +<h3 id="249-implementation-status">24.9 Implementation status</h3> +<table> +<thead> +<tr> +<th></th> +<th>Web engine</th> +<th>iOS</th> +<th>Android</th> +</tr> +</thead> +<tbody> +<tr> +<td>Builds the envelope</td> +<td>no</td> +<td><code>E2EInbox.bridgeEnvelope</code>; none for a blank or oversized +line</td> +<td><code>Content.bridgeOffer</code>; null for a blank or oversized +line</td> +</tr> +<tr> +<td>Sends one</td> +<td>no</td> +<td>Network settings, "Share it with", through +<code>E2EInbox.shareBridge</code></td> +<td>Network settings, "Share it with", through +<code>Conversations.shareBridge</code></td> +</tr> +<tr> +<td>Recognises one</td> +<td>no: handed to the caller as plaintext</td> +<td><code>E2EAttachment.parse</code> sets <code>bridgeLine</code>; route +<code>bridgeOffer</code></td> +<td><code>Content.bridgeLine</code>, consumed in +<code>Conversations.consumeControl</code></td> +</tr> +<tr> +<td>Stores it</td> +<td>no</td> +<td><code>Obfs4Settings.record(offer:)</code>, from +<code>E2EInbox.keepBridgeOffer</code></td> +<td><code>Vault.saveBridgeOffer</code></td> +</tr> +<tr> +<td>Shows it</td> +<td>no</td> +<td>Network settings, "Offered to you"</td> +<td>Network settings, "Offered to you"</td> +</tr> +<tr> +<td>Drops an offer carrying <code>gid</code></td> +<td>n/a</td> +<td>yes</td> +<td>yes</td> +</tr> +<tr> +<td>Size limit (§24.4)</td> +<td>n/a</td> +<td>yes</td> +<td>yes</td> +</tr> +<tr> +<td>Reads a padded <code>cert=</code></td> +<td>n/a</td> +<td>yes</td> +<td>yes</td> +</tr> +<tr> +<td>Hosts it can dial</td> +<td>n/a</td> +<td>IPv4, IPv6, names</td> +<td>IPv4, IPv6, names</td> +</tr> +<tr> +<td>Several lines, failover (§24.6)</td> +<td>n/a</td> +<td>yes, up to 8</td> +<td>yes, up to 8</td> +</tr> +<tr> +<td>Bridge screen before sign-in</td> +<td>n/a</td> +<td>yes</td> +<td>yes</td> +</tr> +<tr> +<td>Nothing around the bridge (§24.6)</td> +<td>n/a</td> +<td>yes</td> +<td>yes</td> +</tr> +</tbody> +</table> +<p>An envelope whose known key has the wrong type +(<code>"line":5</code>) is still an envelope (§6.6). iOS used to fall +back to text for it and show the raw JSON; it now consumes control kinds +silently and shows any other kind's caption, or the unknown-kind +placeholder.</p> +<h3 id="2410-vectors">24.10 Vectors</h3> +<p><code>test/vectors/bridge-offer-v1.json</code>, generated by +<code>test/vectors/generate-bridge-offer.mjs</code>. Eighteen offers +built with the apps' template, each as Android 0.31 and earlier write it +(<code>envelope</code>), as org.json on the JVM and iOS write it +(<code>envelope_jvm</code>), and as Android 0.32 and later write it +(<code>envelope_plain</code>), with the receive decision (§24.5) and the +reading of the line (§24.2) written out by hand. Seven more envelopes no +current builder writes, for the receive rules alone. Both apps decide +every case as written.</p> +<p>The bytes were checked on 9 October 2026 against the real libraries: +Android 15's framework org.json and org.json 20240303, each fed every +line, outputs compared byte for byte. <code>envelope_plain</code> +transcribes the Android engine's writer, and the Android vector test +holds its output to those bytes. <code>test/conformance.mjs</code> +checks every case against the rules above, and +<code>test/interop.mjs</code> runs every envelope, in all three +encodings, through the iOS app's own parser.</p> </div> </article> diff --git a/Scripts/staging-gate.sh b/Scripts/staging-gate.sh index 76e36c2..1558939 100755 --- a/Scripts/staging-gate.sh +++ b/Scripts/staging-gate.sh @@ -165,7 +165,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/Config.swift b/Sources/FlamenetRelay/Config.swift index 67a3e3e..0566ba3 100644 --- a/Sources/FlamenetRelay/Config.swift +++ b/Sources/FlamenetRelay/Config.swift @@ -220,6 +220,10 @@ struct Config: Sendable { let issuerSigningKeyRaw: Data? let tokenLifetime: Int + /// 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) + // `atRestSecret` is gone with the plaintext transport (relay#50). enum ConfigError: Error, CustomStringConvertible { @@ -377,7 +381,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"]) ) config.federationPeerKeys = peers.keys config.reportRatePerHour = int("FN_REPORT_RATE", 10) diff --git a/Sources/FlamenetRelay/Delivery.swift b/Sources/FlamenetRelay/Delivery.swift index 63881fc..788c2a3 100644 --- a/Sources/FlamenetRelay/Delivery.swift +++ b/Sources/FlamenetRelay/Delivery.swift @@ -18,6 +18,11 @@ enum Delivery { var fromDevice: String var payload: String var envelopeID: String? + /// Whether the relay announced this one with a visible push (§6.14). + /// The iPhone badge counts only these. False by default, as + /// `Envelope.init` is, so a path that does not say leaves the badge + /// short and can never push it up. + var announced = false } /// How the push after the commit alerts (§6.14). @@ -64,6 +69,7 @@ enum Delivery { e.payload = item.payload e.envelopeID = item.envelopeID e.delivered = false + e.announced = item.announced try await e.create(on: db) if let id = e.id { ids.append(id) } } diff --git a/Sources/FlamenetRelay/Entrypoint.swift b/Sources/FlamenetRelay/Entrypoint.swift index 2374d82..e5a0d2b 100644 --- a/Sources/FlamenetRelay/Entrypoint.swift +++ b/Sources/FlamenetRelay/Entrypoint.swift @@ -265,6 +265,25 @@ func configure(_ app: Application) async throws { try register(routes: routes, on: app) + // §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) + 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, @@ -523,6 +542,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) // SPEC 8.12: the web form of a contact link. The payload is in the // fragment, which no browser sends, so this is one page for every link; @@ -1125,7 +1147,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 { @@ -1193,7 +1216,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)) } } @@ -1804,7 +1828,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", @@ -2029,6 +2053,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.12), 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. @@ -2217,7 +2244,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.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") } return response @@ -2270,7 +2300,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/Migrations.swift b/Sources/FlamenetRelay/Migrations.swift index 01ad231..66525a4 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 @@ -848,6 +871,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(), ] // Pairs stage 2-A (2026-10-07). Each irreversible, each on its own, // and each dropping only data nothing reads. After the tuning above, 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 e820eac..3b88eec 100644 --- a/Sources/FlamenetRelay/Routes.swift +++ b/Sources/FlamenetRelay/Routes.swift @@ -773,15 +773,22 @@ 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 sets both. + // Carried onto each row, because the iPhone badge counts announced + // envelopes only, so the control traffic around a message does not + // show as more messages. + let urgent = input.urgent == true + let silent = input.silent == true && !urgent let items = input.messages.filter { owned.contains($0.to_device) }.map { m in Delivery.Item( toUser: input.to, toDevice: m.to_device, fromUser: senderUser, fromDevice: senderDevice, - payload: m.payload, envelopeID: m.envelope_id) + payload: m.payload, envelopeID: m.envelope_id, + announced: !silent) } - // §6.14, the sender says whether this is time-critical. let ids = try await Delivery.write( - items, push: .init(urgent: input.urgent == true, silent: input.silent == true), + items, push: .init(urgent: urgent, silent: silent), db: request.db, app: request.application) await sendMetrics.record(sealed: sealed, count: input.messages.count) await sendMetrics.recordPayloads(payloadLengths, build: build) @@ -844,12 +851,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 } @@ -864,6 +870,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/RoutesAnonymous.swift b/Sources/FlamenetRelay/RoutesAnonymous.swift index c6d5143..d9eb0f4 100644 --- a/Sources/FlamenetRelay/RoutesAnonymous.swift +++ b/Sources/FlamenetRelay/RoutesAnonymous.swift @@ -19,7 +19,7 @@ import Vapor /// The authenticated routes stay until the fleet has moved (DD4). extension Routes { - /// Tokens per anonymous presence request: eight, the batch size §8.11 + /// Tokens per anonymous presence request: eight, the batch size §8.13 /// gives clients (DD3), so one request cannot be a whole contact list. static let maxAnonymousPresenceTokens = 8 /// Presence tokens per address per hour. diff --git a/Sources/FlamenetRelay/RoutesDiscovery.swift b/Sources/FlamenetRelay/RoutesDiscovery.swift index 4074247..5ab729d 100644 --- a/Sources/FlamenetRelay/RoutesDiscovery.swift +++ b/Sources/FlamenetRelay/RoutesDiscovery.swift @@ -43,9 +43,9 @@ extension Routes { let perDeviceDigests: Bool /// Wave 0b (2026-10-07), served on every relay of this build: /// `POST /e2e/intro`, its challenge, and `GET /e2e/handle-cert` - /// (§14.5); `/e2e/anon/*` and `GET /e2e/devices/me` (§8.11); - /// `POST /e2e/contact-links` (§8.12); and `discoverable` on - /// `/messenger/me` (§8.12). The 0.32 apps turn sealed first + /// (§14.5); `/e2e/anon/*` and `GET /e2e/devices/me` (§8.13); + /// `POST /e2e/contact-links` (§8.14); and `discoverable` on + /// `/messenger/me` (§8.14). The 0.32 apps turn sealed first /// contact, anonymous reads, reusable links and the Listed setting /// on only when these say so. let introductions: Bool diff --git a/Sources/FlamenetRelay/RoutesFederation.swift b/Sources/FlamenetRelay/RoutesFederation.swift index c091f08..f5a22b1 100644 --- a/Sources/FlamenetRelay/RoutesFederation.swift +++ b/Sources/FlamenetRelay/RoutesFederation.swift @@ -117,7 +117,10 @@ extension Routes { items.append( Delivery.Item( toUser: userID, toDevice: e.to_device, fromUser: 0, fromDevice: "", - payload: e.payload, envelopeID: e.envelope_id)) + payload: e.payload, envelopeID: e.envelope_id, + // A deposit sends no push, so nothing announced it, and it + // stays out of the badge count. + announced: false)) } return (items, unknown) } 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://<this host>/hi#<address>`. +/// +/// 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/Sources/FlamenetRelay/TodayFile.swift b/Sources/FlamenetRelay/TodayFile.swift new file mode 100644 index 0000000..0e5e9e0 --- /dev/null +++ b/Sources/FlamenetRelay/TodayFile.swift @@ -0,0 +1,488 @@ +import AsyncHTTPClient +import Crypto +import Foundation +import NIOCore +import Vapor + +/// 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 { + /// 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.12): 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 + /// 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 (try? Curve25519.Signing.PublicKey(rawRepresentation: raw)) != nil else { + return nil + } + 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. + 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.signing?.isValidSignature(sig, for: body) ?? false }) 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.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 +/// 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/Sources/FlamenetRelay/Wire.swift b/Sources/FlamenetRelay/Wire.swift index 50d7c50..cce07b2 100644 --- a/Sources/FlamenetRelay/Wire.swift +++ b/Sources/FlamenetRelay/Wire.swift @@ -332,7 +332,7 @@ struct DeviceListResponse: Content { let ik_sig: String let proto: Int /// The device's signed capability block, when it published one. Part - /// of its identity, as in a bundle (§8.11 lists it in the directory). + /// of its identity, as in a bundle (§8.13 lists it in the directory). var caps_sig: String? = nil /// ISO-8601. let created_at: String? 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/AnonymousReadsTests.swift b/Tests/FlamenetRelayTests/AnonymousReadsTests.swift index a526741..96ba240 100644 --- a/Tests/FlamenetRelayTests/AnonymousReadsTests.swift +++ b/Tests/FlamenetRelayTests/AnonymousReadsTests.swift @@ -63,7 +63,7 @@ struct AnonymousReadsTests { @Test("anonymous presence is capped per request and metered per address") func presenceLimits() async throws { try await withRelay { h in - // Nine, written out: §8.11 says batches of at most eight, and a + // Nine, written out: §8.13 says batches of at most eight, and a // test that read the constant would move with it. let many = (0..<9).map { _ in "\"\(W0B.random32().base64EncodedString())\"" @@ -103,7 +103,7 @@ struct AnonymousReadsTests { } #expect(a.status == 200) #expect(a.body.contains("aaaa-0007") && a.body.contains("ik_sig")) - #expect(a.body.contains("caps-signature"), "§8.11 lists caps_sig in the directory") + #expect(a.body.contains("caps-signature"), "§8.13 lists caps_sig in the directory") #expect(!a.body.contains("created_at") && !a.body.contains("last_seen")) } } diff --git a/Tests/FlamenetRelayTests/BadgeCountTests.swift b/Tests/FlamenetRelayTests/BadgeCountTests.swift new file mode 100644 index 0000000..76c8c7e --- /dev/null +++ b/Tests/FlamenetRelayTests/BadgeCountTests.swift @@ -0,0 +1,215 @@ +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..<count).map { #"{"to_device":"\#(r.deviceID)","payload":"ct\#($0)"}"# } + var parts = ["\"to\":\(r.userID)", "\"messages\":[\(envelopes.joined(separator: ","))]"] + if let silent { parts.append("\"silent\":\(silent)") } + if let urgent { parts.append("\"urgent\":\(urgent)") } + let body = Data("{\(parts.joined(separator: ","))}".utf8) + let req = h.request(.POST, "/e2e/messages", as: s, body: body, now: now) + let res = try await h.routes.send(req, body: body, clientIP: ip, now: now) + #expect(res.status == .ok) + } + + private func badge(_ h: RelayHarness, _ d: RelayHarness.TestDevice) async throws -> 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) + + // 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) + } + } + + // 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[..<cut] { app.migrations.add(m) } + try await app.autoMigrate() + + let sql = try #require(app.db as? any SQLDatabase) + try await sql.raw( + """ + INSERT INTO envelopes (to_user, to_device, from_user, from_device, payload, delivered) + VALUES (2, 'bbbb-0002', 1, 'aaaa-0001', 'a', false), + (2, 'bbbb-0002', 1, 'aaaa-0001', 'b', false), + (2, 'bbbb-0002', 1, 'aaaa-0001', 'c', true) + """ + ).run() + + try await AddEnvelopeAnnounced().prepare(on: app.db) + } + + /// Rows waiting at the upgrade were written before anybody recorded + /// whether they were announced. Counting them would put back the + /// over-count, so they come out false. + @Test("rows already waiting at the upgrade come out unannounced, on SQLite") + func migrationOnSQLite() async throws { + let app = try await Application.make(.testing) + app.logger.logLevel = .critical + app.databases.use(.sqlite(.memory), as: .sqlite) + do { + try await migrateAcross(app) + let rows = try await Envelope.query(on: app.db).all() + #expect(rows.count == 3) + #expect(rows.allSatisfy { !$0.announced }) + #expect(try await Routes.badgeCount(forDevice: "bbbb-0002", on: app.db) == 0) + + // New rows after the upgrade still count. + let e = Envelope() + e.toUser = 2 + e.toDevice = "bbbb-0002" + e.fromUser = 1 + e.fromDevice = "aaaa-0001" + e.payload = "d" + e.delivered = false + e.announced = true + try await e.create(on: app.db) + #expect(try await Routes.badgeCount(forDevice: "bbbb-0002", on: app.db) == 1) + + // And it comes back out. + try await AddEnvelopeAnnounced().revert(on: app.db) + let sql = try #require(app.db as? any SQLDatabase) + let columns = try await sql.raw("PRAGMA table_info(envelopes)").all() + .map { try $0.decode(column: "name", as: String.self) } + #expect(!columns.contains("announced")) + #expect(columns.contains("delivered"), "the column check read nothing") + } catch { + try? await app.asyncShutdown() + throw error + } + try await app.asyncShutdown() + } +} diff --git a/Tests/FlamenetRelayTests/ContactLinkPageTests.swift b/Tests/FlamenetRelayTests/ContactLinkPageTests.swift index 7383f45..c300aa2 100644 --- a/Tests/FlamenetRelayTests/ContactLinkPageTests.swift +++ b/Tests/FlamenetRelayTests/ContactLinkPageTests.swift @@ -5,7 +5,7 @@ import VaporTesting @testable import FlamenetRelay -/// SPEC §8.12: the web form of a contact link, `https://<relay>/add#…`, and the +/// SPEC §8.14: the web form of a contact link, `https://<relay>/add#…`, and the /// two files that let a phone open it in the app instead of a browser. @Suite("contact link page and app links", .serialized) struct ContactLinkPageTests { diff --git a/Tests/FlamenetRelayTests/DeleteOnAckTests.swift b/Tests/FlamenetRelayTests/DeleteOnAckTests.swift index 0d51440..e572011 100644 --- a/Tests/FlamenetRelayTests/DeleteOnAckTests.swift +++ b/Tests/FlamenetRelayTests/DeleteOnAckTests.swift @@ -21,6 +21,8 @@ struct DeleteOnAckTests { // new column show up as five red tests rather than as nothing, which // is the trade this harness is deliberately making. app.migrations.add(AddEnvelopeDeliveredAt()) + // And announced, the bit the badge counts. + app.migrations.add(AddEnvelopeAnnounced()) try await app.autoMigrate() // Shut down on BOTH paths, and AWAITED. // diff --git a/Tests/FlamenetRelayTests/HelloPageTests.swift b/Tests/FlamenetRelayTests/HelloPageTests.swift new file mode 100644 index 0000000..ab4b68a --- /dev/null +++ b/Tests/FlamenetRelayTests/HelloPageTests.swift @@ -0,0 +1,413 @@ +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(#"<html lang="\#(lang)">"#)) + #expect(html.contains(#"<meta name="robots" content="noindex">"#)) + #expect(html.contains(#"<script src="/hi.js?v="#)) + #expect(html.contains(#"id="hi-found" hidden"#)) + #expect(html.contains(#"id="hi-missing" hidden"#)) + #expect(html.contains(#"href="https://testflight.apple.com/join/SZF8uKU6""#)) + #expect(html.contains(#"href="/downloads/flamenet-messenger.apk""#)) + // landing.js fetches the release manifest for any page + // carrying one of these, and this page fetches nothing. + #expect(!html.contains("data-apk-"), "\(path) carries a slot landing.js would fetch for") + } + } + try await app.testing().test(.GET, "/sitemap.xml") { res in + #expect(res.status == .ok) + #expect(!res.body.string.contains("/hi</loc>")) + } + // The robots tag is this page's, not every page's. + try await app.testing().test(.GET, "/about") { res in + #expect(!res.body.string.contains(#"name="robots""#)) + #expect(res.headers.first(name: "X-Robots-Tag") == nil) + } + } + } + + /// The URLs a page loads by itself: scripts, stylesheets, icons, images, + /// frames and media. A link somebody chooses to follow is not a load. + static func loads(in html: String) throws -> [String] { + let tag = try NSRegularExpression( + pattern: #"<(script|link|img|iframe|frame|source|video|audio|embed|object|track)\b[^>]*>"#, + options: [.caseInsensitive]) + let attribute = try NSRegularExpression( + pattern: #"\b(src|href|srcset|data|poster)\s*=\s*"([^"]*)""#, options: [.caseInsensitive]) + var found: [String] = [] + for match in tag.matches(in: html, range: NSRange(html.startIndex..., in: html)) { + guard let range = Range(match.range, in: html) else { continue } + let element = String(html[range]) + // A canonical or an alternate names a page; nothing fetches it. + if element.lowercased().hasPrefix("<link"), + element.contains(#"rel="canonical""#) || element.contains(#"rel="alternate""#) + { + continue + } + for value in attribute.matches(in: element, range: NSRange(element.startIndex..., in: element)) { + if let r = Range(value.range(at: 2), in: element) { found.append(String(element[r])) } + } + } + return found + } + + @Test("nothing on either page is loaded from another host, and nothing runs inline") + func noThirdPartyLoads() async throws { + try await withRelayApp { app in + for path in ["/hi", "/es/hi"] { + try await app.testing().test(.GET, path) { res in + let html = res.body.string + let loads = try Self.loads(in: html) + #expect(loads.contains { $0.hasPrefix("/hi.js") }, "the scan did not see the page's own script") + for url in loads { + #expect(url.hasPrefix("/") && !url.hasPrefix("//"), "\(path) loads \(url)") + } + #expect(!html.contains("url("), "\(path) loads something from a style") + // Every script is a file of ours or a data block; inline + // code is refused by the policy below in any case. + for script in html.components(separatedBy: "<script").dropFirst() { + let open = script.prefix(while: { $0 != ">" }) + #expect( + open.contains("src=\"/") || open.contains("application/ld+json"), + "\(path) has an inline script: <script\(open)>") + } + let policy = try #require(res.headers.first(name: .contentSecurityPolicy)) + #expect(policy.hasPrefix("default-src 'self'")) + #expect(!policy.contains("https:") && !policy.contains("http:")) + } + } + } + } + + // MARK: - The script, read + + /// The script with its comments removed. Quote-aware, so a word in a + /// comment cannot fail the check and the `//` in a string is not taken for + /// one. + static func code(_ js: String) -> String { + enum State { case code, line, block, single, double, template } + var state = State.code + var out = "" + let chars = Array(js) + var i = 0 + while i < chars.count { + let c = chars[i] + let next: Character = i + 1 < chars.count ? chars[i + 1] : "\0" + switch state { + case .code: + if c == "/" && next == "/" { state = .line; i += 2; continue } + if c == "/" && next == "*" { state = .block; i += 2; continue } + if c == "'" { state = .single } else if c == "\"" { state = .double } else if c == "`" { state = .template } + out.append(c) + case .line: + if c == "\n" { state = .code; out.append(c) } + case .block: + if c == "*" && next == "/" { state = .code; i += 2; continue } + case .single, .double, .template: + out.append(c) + if c == "\\", i + 1 < chars.count { out.append(next); i += 2; continue } + if (state == .single && c == "'") || (state == .double && c == "\"") + || (state == .template && c == "`") + { + state = .code + } + } + i += 1 + } + return out + } + + @Test("the script makes no request of any kind, and builds no link but the app's") + func theScriptSendsNothing() throws { + let code = Self.code(try Self.script()) + #expect(code.contains("location.hash"), "the comment stripper ate the code") + for forbidden in [ + "fetch", "XMLHttpRequest", "sendBeacon", "Image", "new URL", "URL(", "WebSocket", + "EventSource", "import(", "importScripts", "Worker", "createElement", ".src", "srcset", + "innerHTML", "outerHTML", "insertAdjacentHTML", "document.write", "location.href", + "location.assign", "location.replace", "window.open", "submit", "postMessage", "cookie", + "Storage", "indexedDB", "RTCPeerConnection", "eval(", "Function(", "setTimeout(", + "setInterval(", + ] { + #expect(!code.contains(forbidden), "hi.js uses \(forbidden)") + } + func count(_ needle: String) -> Int { code.components(separatedBy: needle).count - 1 } + // The fragment is read, and nothing else about the page's address is. + #expect(count("location.") == count("location.hash")) + // The only scheme anywhere in the code is the app's own. + #expect(count("://") == count("'flamenetmessenger://hi")) + #expect(count("'flamenetmessenger://hi#' + address") == 1) + // The only attribute it sets is that link's href. + #expect(count("setAttribute(") == 1) + #expect(count("setAttribute('href'") == 1) + } + + @Test("the address check uses the apps' alphabet and length") + func theScriptUsesTheAppsAlphabet() throws { + let script = try Self.script() + // Both apps: "0123456789abcdefghjkmnpqrstvwxyz", 52 characters. + #expect(script.contains("var FNID = /^fnid:[0-9a-hjkmnp-tv-z]{52}$/;")) + let alphabet = Array("0123456789abcdefghjkmnpqrstvwxyz") + let ranges = "0123456789" + "abcdefgh" + "jk" + "mn" + "pqrst" + "vwxyz" + #expect(Array(ranges) == alphabet) + } + + @Test("the gate renders both pages before a deploy") + func theGateRendersBoth() throws { + let gate = try String( + contentsOf: Self.root.appendingPathComponent("Scripts/staging-gate.sh"), encoding: .utf8) + #expect(gate.contains(#"ALL_PAGES="donate releases hi "#)) + // The Spanish twin comes from the listing of Resources/Views/es. + #expect(gate.contains(#"ES_PAGES=$(ls "$GATE_SRC/Resources/Views/es/""#)) + #expect( + FileManager.default.fileExists( + atPath: Self.root.appendingPathComponent("Resources/Views/es/hi.leaf").path)) + } + + // MARK: - The script, run + + #if canImport(JavaScriptCore) + /// The check alone, in a context with no document. + static func parse(_ fragment: String) throws -> String? { + let context = try #require(JSContext()) + var thrown: String? + context.exceptionHandler = { _, exception in thrown = exception?.toString() } + context.evaluateScript(try script()) + let function = try #require(context.objectForKeyedSubscript("parseHelloAddress")) + let result = try #require(function.call(withArguments: [fragment])) + #expect(thrown == nil, "\(thrown ?? "")") + return result.isNull ? nil : result.toString() + } + + static let valid: [(String, String)] = [ + ("#" + address, address), + (address, address), + ("#" + fnid + "@Relay.Flamenet.IO", address), + ("#" + fnid + "%40relay.flamenet.io", address), + ("#" + fnid + "@127.0.0.1", fnid + "@127.0.0.1"), + ("#" + fnid + "@a-b.example", fnid + "@a-b.example"), + // Letters, digits, hyphens and dots with one dot at least is the + // apps' rule, so the page takes what they take. + ("#" + fnid + "@relay.flamenet.io.", fnid + "@relay.flamenet.io."), + ] + + static let invalid: [String] = [ + "", "#", "#fnid:", "#@relay.flamenet.io", + // Missing the prefix, or the wrong case for it. + "#" + fnid.dropFirst(5) + "@relay.flamenet.io", + "#FNID:" + fnid.dropFirst(5) + "@relay.flamenet.io", + // i, l, o and u are not in the alphabet; upper case is not either. + "#fnid:i" + fnid.dropFirst(6) + "@relay.flamenet.io", + "#fnid:l" + fnid.dropFirst(6) + "@relay.flamenet.io", + "#fnid:o" + fnid.dropFirst(6) + "@relay.flamenet.io", + "#fnid:u" + fnid.dropFirst(6) + "@relay.flamenet.io", + "#" + fnid.uppercased().replacingOccurrences(of: "FNID:", with: "fnid:") + "@relay.flamenet.io", + // One short, one long. + "#" + fnid.dropLast() + "@relay.flamenet.io", + "#" + fnid + "0@relay.flamenet.io", + // No host, or not a host. + "#" + fnid, "#" + fnid + "@", "#" + fnid + "@localhost", + "#" + fnid + "@relay.flamenet.io:443", "#" + fnid + "@relay.flamenet.io/x", + "#" + fnid + "@relay_1.flamenet.io", "#" + fnid + "@relay.flamenet.io?x=1", + "#" + address + "@relay.flamenet.io", "#" + fnid + " @relay.flamenet.io", + "#" + fnid + "@relay.flamenet.io ", "#" + fnid + "@relay.flamenet.io#x", + "#" + fnid + "@réla y.io", + // Longer than any real address. + "#" + address + String(repeating: "a", count: 400), + // Markup and script in the fragment, plain and percent-encoded. + "#" + address + "\"><script>alert(1)</script>", + "#<img src=x onerror=alert(1)>", + "#" + fnid + "@<script>alert(1)</script>.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)") + } + } + + /// 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] { + 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 ["", "#<img src=x onerror=alert(1)>", "#" + Self.address + "\"><b>x</b>"] { + 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/Tests/FlamenetRelayTests/PostgresMigrationTests.swift b/Tests/FlamenetRelayTests/PostgresMigrationTests.swift index 8dc020b..edae1cd 100644 --- a/Tests/FlamenetRelayTests/PostgresMigrationTests.swift +++ b/Tests/FlamenetRelayTests/PostgresMigrationTests.swift @@ -369,6 +369,47 @@ struct PostgresMigrationTests { #expect(values == [2, 2]) } } + /// The badge's bit: a NOT NULL boolean with a default, added to a table + /// that already holds rows. Rows already waiting come out false, so the + /// count cannot jump at the upgrade. + @Test("the announced column adds to a populated envelopes table") + func announcedAddsWithData() async throws { + try await withPostgres { app in + let all = RelayMigrations.all() + guard let cut = all.firstIndex(where: { $0 is AddEnvelopeAnnounced }) else { + Issue.record("the migration under test is not in the boot list") + return + } + for m in all[..<cut] { app.migrations.add(m) } + try await app.autoMigrate() + + guard let sql = app.db as? any SQLDatabase else { return } + try await sql.raw( + """ + INSERT INTO envelopes (to_user, to_device, from_user, from_device, payload, delivered) + VALUES (2, 'bbbb-0002', 1, 'aaaa-0001', 'a', FALSE), + (2, 'bbbb-0002', 1, 'aaaa-0001', 'b', TRUE) + """ + ).run() + + try await AddEnvelopeAnnounced().prepare(on: app.db) + + let announced = try await sql.raw("SELECT announced FROM envelopes").all() + .map { try $0.decode(column: "announced", as: Bool.self) } + #expect(announced == [false, false]) + #expect(try await Routes.badgeCount(forDevice: "bbbb-0002", on: app.db) == 0) + + // Reversible, and applies again after the revert. + try await AddEnvelopeAnnounced().revert(on: app.db) + try await AddEnvelopeAnnounced().prepare(on: app.db) + let names = + try await sql.raw( + "SELECT column_name FROM information_schema.columns " + + "WHERE table_name = 'envelopes'" + ).all().map { try $0.decode(column: "column_name", as: String.self) } + #expect(names.contains("announced")) + } + } } /// Per-key counters shared between the two sides of a race. diff --git a/Tests/FlamenetRelayTests/TodayFileTests.swift b/Tests/FlamenetRelayTests/TodayFileTests.swift new file mode 100644 index 0000000..3fa62fb --- /dev/null +++ b/Tests/FlamenetRelayTests/TodayFileTests.swift @@ -0,0 +1,638 @@ +import AsyncHTTPClient +import Crypto +import Foundation +import NIOCore +import Testing +import Vapor +import VaporTesting + +@testable import FlamenetRelay + +/// 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 { + + // 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<Data, TodayFile.Refusal>] + init(_ answers: [Result<Data, TodayFile.Refusal>]) { 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) + } + } + + /// /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)) + } + } +} diff --git a/docs/API.md b/docs/API.md index c5e53e4..6c9aece 100644 --- a/docs/API.md +++ b/docs/API.md @@ -64,6 +64,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.12) | + +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 @@ -326,7 +341,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. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 101db8f..c89d29b 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -152,6 +152,9 @@ on. Each one, and the setting that turns it on: Sending to a peer also needs `FN_FEDERATION_KEY`. - Donations, to `api.stripe.com`, when `FN_DONATE_SECRET` is set. - Attachments, to an S3-compatible store, when `FN_S3_ENDPOINT` is set. +- The Today file, to the address in `FN_TODAY_SOURCE`, about every fifteen + minutes. Set `FN_TODAY_FILE` instead to serve one from disk and fetch + nothing. It also connects to the databases you point it at: Postgres at `FN_POSTGRES_URL` and Redis at `FN_REDIS_URL`. Nothing reports to this @@ -303,6 +306,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.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. + +| 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)