Files
Felitendo c1c478bf25 feat: make private mode an account rather than a code
Private mode asked for a six-digit code, minted per person and traded for a pass
that a browser kept in a cookie and the app in its keychain. Accounts arrived
later, for the boards and the setup backup, and from that point the code was a
second credential doing a worse version of the same job. It said you may look and
nothing about who was looking, so it could not carry what somebody wrote, it
could not be signed out of from another device, and six digits is a space small
enough that it had to be defended by a throttle doing most of the work.

So the account is the credential now. The landing page asks for a name and a
password instead of drawing a keypad, the feed and the token downloads want the
same session, and the store's own screen for being shut out is the sign-in it
already had. One credential, on both sides of the wire, and the same one
somebody needs before they can post or keep a backup.

What changes on the way in is who may register. Public: anybody makes an account
from the app, as before. Private: an operator mints an invitation and sends the
link, and opening it is the only way a new account happens. One link, one
account, spent the moment it works, so a forwarded link is not a second way in.
The redeem is filtered on being unspent, so two people racing one link end with
one account and one refusal. It is shown once, it can be withdrawn while nobody
has used it, and it expires if nobody does.

The token is 128 bits and stored as a digest, which is the opposite of the choice
the codes had to make: a code was a candidate list somebody could work through
offline, so it needed a slow hash and could not be looked up, where a token is no
list at all.

Withdrawing a link is deliberately not the same action as removing somebody. Once
it has been redeemed there is an account, and taking that away is muting or
deleting it on the board's own screen, where the things they wrote are in view.

This locks out every device a code had let in, and there is no way around it: a
pass records that a code was answered on some device and nothing about who held
it, which is exactly the gap accounts close. The two tables are dropped in
pre-push.sql, where every destructive change here is written out. A public
deployment is untouched.

The throttle stays and gains a second half. The ladder against the address is
what it was, minus a ceiling that came down when the codes did; the ladder
against the name being signed in to already existed for accounts and now bounds
somebody working through one person's password from anywhere they like. Only a
wrong answer climbs either, so being told a name is taken is not a step towards
a lockout.

Both consoles move together, as they have to: the invitations, the accounts and
the devices are the same five sections in the same order in the dashboard and on
the phone. The alert for a new device compares a moment rather than an id, since
a session's key is the digest of a token and there is no id to compare.
2026-09-01 09:27:34 +02:00

151 lines
5.0 KiB
Swift

//
// FetchService.swift
// Loader
//
// Created by samara on 14.03.2025.
//
import Foundation
// MARK: - Class
public class NBFetchService {
public enum NBFetchServiceError: Error, LocalizedError {
case invalidURL
case networkError(Error)
case httpError(Int)
case noData
case parsingError(Error)
/// The source is private and nobody is signed in on this device.
///
/// Its own case rather than an `httpError(403)`, because it is the one
/// refusal the user can do something about: the app answers it with a
/// sign-in screen instead of an error row. `stranded` is the operator
/// having closed the source outright, with nobody who could sign in and
/// no invitation outstanding, which is the one case the screen has
/// nothing to offer for.
case locked(stranded: Bool, note: String?)
public var errorDescription: String? {
switch self {
case .invalidURL: "The URL is invalid."
case .networkError(let error): "Network error: \(error.localizedDescription)"
case .httpError(let code): "The server returned HTTP \(code)."
case .noData: "No data received."
case .parsingError(let error): "Failed to parse data: \(error.localizedDescription)"
case .locked: "This source is private."
}
}
}
/// The shape of the refusal FTStore Admin sends when a source is private.
private struct _LockedBody: Decodable {
let code: String?
/// On, with nobody who can sign in. Omitted when somebody could, so its
/// absence reads as "there is a form worth showing".
let stranded: Bool?
let note: String?
}
public init() {}
/// Identifies the app to its repo backend. The server gates its feed on this, so it
/// must be deterministic and start with `FTStore`/`FTStore-Manager`. Derived from
/// the running app's bundle so the same code yields the right value in both apps.
public static let userAgent: String = {
let info = Bundle.main.infoDictionary
let version = (info?["CFBundleShortVersionString"] as? String) ?? "1.0"
let bundleId = Bundle.main.bundleIdentifier ?? "app.ftstore"
let product = bundleId == "app.ftstore.manager" ? "FTStore-Manager" : "FTStore"
return "\(product)/\(version) (\(bundleId))"
}()
}
// MARK: - Class extension: fetch
extension NBFetchService {
public func fetch<T: Decodable>(
from urlString: String,
headers: [String: String] = [:],
completion: @escaping (Result<T, Error>) -> Void
) {
guard let url = URL(string: urlString) else {
completion(.failure(NBFetchServiceError.invalidURL))
return
}
fetch(from: url, headers: headers, completion: completion)
}
/// - Parameter headers: Anything the *caller* has decided this particular URL
/// should carry. A plain dictionary and not a hook, because deciding which
/// hosts may see a credential is the app's business and this package has no
/// idea what FTStore's server is: it copies what it is handed onto the
/// request and knows nothing else about it.
public func fetch<T: Decodable>(
from url: URL,
headers: [String: String] = [:],
completion: @escaping (Result<T, Error>) -> Void
) {
DispatchQueue.global(qos: .userInitiated).async {
// Always pull a fresh copy: source feeds change frequently, and the
// default protocol cache policy would otherwise serve a stale JSON
// (showing versions that no longer exist in the live manifest).
var request = URLRequest(url: url)
request.cachePolicy = .reloadIgnoringLocalCacheData
request.setValue(Self.userAgent, forHTTPHeaderField: "User-Agent")
for (name, value) in headers {
request.setValue(value, forHTTPHeaderField: name)
}
let task = URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
completion(.failure(NBFetchServiceError.networkError(error)))
return
}
// Surface the status code before decoding: an error page (or a
// JSON error envelope from the repo host) would otherwise fail
// to decode and be reported as an unhelpful "parsing error".
if
let http = response as? HTTPURLResponse,
!(200..<300).contains(http.statusCode)
{
// A private source answers 403 with a code saying so. Told
// apart from every other refusal because the caller can offer
// to sign in for this one, and cannot offer anything for the
// rest.
if
http.statusCode == 403,
let data,
let body = try? JSONDecoder().decode(_LockedBody.self, from: data),
body.code == "account_required"
{
completion(.failure(NBFetchServiceError.locked(
stranded: body.stranded ?? false,
note: body.note
)))
return
}
completion(.failure(NBFetchServiceError.httpError(http.statusCode)))
return
}
guard let data = data else {
completion(.failure(NBFetchServiceError.noData))
return
}
do {
let decoder = JSONDecoder()
let decodedData = try decoder.decode(T.self, from: data)
completion(.success(decodedData))
} catch {
completion(.failure(NBFetchServiceError.parsingError(error)))
}
}
task.resume()
}
}
}