Files
Felitendo b968e51c84
iOS Dev Repo / build (push) Canceled after 0s
Web Build and Push Docker Images / Build App Image (push) Canceled after 0s
Web Build and Push Docker Images / Build Worker Image (push) Canceled after 0s
fix: open the account screen on signing in
It used to open on registering, which is the wrong half for the people
most likely to be looking at it: somebody on a new phone, or signed out,
who already owns the name they are about to type. Typing it on the
registering side answers that the name is taken, which is true and reads
as their account being gone. Registering is still one tap away wherever
it is offered.

Where it is not offered is a private server. An account there comes from
an invitation link, and opening the link is what creates it, so every
registration the app could send is one the server turns down. The
account endpoint now says which of the two a deployment is, and the app
drops the choice instead of showing one that cannot be used. The two
buttons that open the screen say so as well.
2026-09-14 09:07:32 +02:00

1759 lines
119 KiB
Markdown

# FTStore — project guide for AI agents
> This is the **canonical** project guide for any AI coding agent working in this
> repo (Claude Code, Codex, etc.). It lives in `CLAUDE.md` because that is the
> file Claude Code loads by itself: the guidance used to sit in `AGENTS.md` with
> a one-line pointer here, and a pointer is one step an agent has to choose to
> take. `AGENTS.md` is now the pointer, for the agents that look for that name
> instead. Keep all guidance in this file so there is a single source of truth.
FTStore is **"Feather, but for normies"**: a **hard fork of
[claration/Feather](https://github.com/claration/Feather)** (Swift iOS/iPadOS
on-device app signer/installer, GPL-3.0).
**Hard fork means the code is ours.** `ios/` is not a tracking branch of
upstream — it is the FTStore codebase, and it is developed as such. The fork is
now **standalone**: there is no pin, no sync tooling, no routine merge, and no
expectation that our tree resembles upstream's. Write the code that is right for
FTStore and never shape a change around a merge that is not going to happen.
> **This does not loosen GPL-3.0.** A hard fork is a workflow decision, not a
> licensing one. Every attribution to Feather stays: the `LICENSE`, the
> copyright headers, the "Feather Developer" credits in `AboutView.swift`, the
> "Built on Feather" entry in `app-repo.json`, and the README acknowledgements.
> Do not strip any of them, and keep the source public.
> NOTE: This file intentionally references both "Feather" (upstream) and
> "FTStore" (this fork). Both names are meant where they appear; do not
> "correct" one into the other.
- **Brand constants:** app name `FTStore` · bundle id `app.ftstore` ·
developer `FTStore Team` · website `ftstore.app` · repo `FTStuff/FTStore`.
Every `developerName` / author field says `FTStore Team`, never a person and
never a bare `FTStore`.
The project is credited as a project, never as a person — there is no
maintainer handle in the About screen, `app-repo.json` or the READMEs, and
nothing should reintroduce one.
- **origin** → `FTStuff/FTStore`. There is no `upstream` remote any more;
`claration/Feather` is the fork's ancestry, not a branch this tracks.
- Build target requires **macOS + Xcode** — it cannot be compiled on Linux.
Validate locally (static checks); build via GitHub Actions / a Mac.
## Monorepo layout — read this before touching paths
Four products live here. **Only `ios/` descends from Feather.** The right-hand
column records where each one came from, which is history rather than a live
relationship: nothing here is merged from anywhere.
| Path | What it is | Origin |
|------|------------|--------|
| `ios/` | The Swift app (`app.ftstore`). Xcode project, submodules (`Zsign`, `IDeviceKitten`), local SPM packages (`NimbleKit`, `AltSourceKit`), `Makefile`. | Forked from `claration/Feather` |
| `web/` | **FTStore Admin server** — Next.js dashboard at `/admin` + the `ftstore.json` feed + IPA downloads + the public **ftstore.app landing page** at `/`. Was its own repo under a different name until the merge. | Ours alone |
| `manager/` | **FTStore Manager** (`app.ftstore.manager`) — a stripped-down fork of *this repo's app*, whose only job is installing and updating FTStore (and, once an operator signs in, FTStore Admin) via the `web/` proxy. One screen, no account. Was the separate `FTStore-Manager` repo. | Forked from `ios/` |
| `admin/` | **FTStore Admin** (`app.ftstore.admin`) — the operator console for `web/` as its own iOS app: the `/admin` dashboard on a phone, behind the server login. Split out of `manager/` so a normal user never meets an operator surface. | Ours alone, no Feather lineage |
| `app-repo.json` | The published AltStore source manifest. Its one listing is **FTStore Manager**, never the app itself, refreshed by `stable-release.yml` whenever the Manager ships. **Stays at the repo root** — the workflows expect it there. | Forked from `claration/Feather` |
| `Images/` | Root README artwork. | Forked from `claration/Feather` |
Both repos are **private**. The apps therefore do not fetch `app-repo.json` over
`raw.githubusercontent.com`; they go through the `web/` proxy
(`/api/ftstore-app/*`, `/api/ftstore-manager/*`, `/api/ftstore-admin/*`), which
holds the GitHub PAT server-side. Do not "fix" an app by pointing it at a raw
GitHub URL. `admin/` itself has no proxy: it talks to the same `/api/*` routes
the web dashboard uses, gated by the session rather than by a UA. Its AltStore
source lives on the `admin-dev` prerelease of this **private** repo, so
"published" there means "reachable by someone who already has access", which is
the whole distribution model for an operator console.
The three proxy prefixes are **not** equivalent, and the difference is the
security model:
| Prefix | Serves | Gate |
|--------|--------|------|
| `/api/ftstore-app/*` | FTStore (`v*` / `dev`) | UA only — public to the app |
| `/api/ftstore-manager/*` | FTStore Manager (`manager-v*` / `manager-dev`) | UA only — public to the app |
| `/api/ftstore-admin/*` | FTStore Admin (`admin-v*` / `admin-dev`) | UA **plus** an `Authorization: Bearer` token for an approved **admin** account |
`web/src/lib/github/ftstore-app.ts` is one resolver with three `ProxyTarget`s;
the tag namespace is what separates them, never the asset name alone.
**`ftstore-admin` is credential-gated on the server, and that is the whole
point.** `POST /api/ftstore-admin/unlock` verifies a dashboard username and
password with the same scrypt check as the dashboard login, and mints a
`sessions` row; `manifest` and `download` each re-check that token
(`web/src/lib/ftstore-admin-access.ts`). Nothing secret is compiled into
`manager/` — its only baked-in value is still the base URL, and the token lives
in the keychain (`ManagerKeychain`, device-only, out of backups). So decompiling
the Manager, flipping its unlock flag, or rebuilding it without
`ManagerAdminAccess` at all yields a UI that asks the server for the console and
is refused. **Never move that check into the app, and never answer `unlock` with
anything but one message for every failure mode** — a distinct "no such user"
would let anyone with the Manager enumerate operator accounts.
**"FTStore Admin" always means both halves: `web/` *and* `admin/`.** They are one
product with two front ends over the same API, so a request to change FTStore
Admin is a request to change the dashboard **and** the console app. A feature
that lands in only one of them is half-built: the operator who uses the phone
can't reach it, or the one on a laptop can't. When you add an operator-facing
control, add it to `web/src/components/…` and to `admin/FTStoreAdmin/App/…`
in the same change, and keep the wire types in step
(`web/src/types/config.ts` ↔ `admin/…/AdminModels.swift`). Review moderation is
the newest pair: `web/src/components/reviews/` ↔ `AdminReviewsView.swift` and
`AdminReviewDetailView.swift`. The one asymmetry
that is deliberate is presentation: the dashboard can afford a dense grid of
icon buttons, the console app uses a `Form`, and neither has to imitate the
other.
**Three iOS apps, three audiences.** `ios/` is for everyone, `manager/` is for
getting `ios/` (and, for an operator, `admin/`) onto a device, `admin/` is for
the person who runs the server. Never route an operator feature back into `ios/`
or `manager/` — the reason `admin/` exists is that a normal user should never be
able to reach one. Installing the console is the one exception, and it is only
an *install*: it sits behind the Manager's developer mode **and** the server's
credential check, and the console's own screens still live only in `admin/`.
Never bake a credential into `admin/` either: its only compiled-in value is the
base URL, and the server session is the whole security model.
**The Manager tracks a set of products, not one app.** `ManagedProduct`
(`manager/FTStore/Manager/ManagedProduct.swift`) carries everything that differs
between FTStore and FTStore Admin — bundle id, manifest path, URL scheme,
whether the routes need the operator token, and the `UserDefaults` prefix for
its install stamps. `FTStoreUpdateManager` is instantiated once per product
(`.shared` for FTStore, `.admin` for the console) so the version/freshness rules
are written once. FTStore deliberately keeps the unprefixed defaults keys it has
always used, so an upgraded install does not forget which build it is on. Adding
a third product should be a new `case`, not a second copy of the manager.
**An install is recorded when iOS says it finished, never when signing does.**
Signing only produces an IPA; the install is Apple's sheet, and it can be
declined or fail. `AppInstallManager` therefore parks the freshness stamp in
`_awaitingInstall` and commits it from `confirmInstalled`, which the install
sheet calls on `.completed(.success)`. Moving that back to the end of signing is
the bug it replaced: the Manager reported a build that never landed, and on the
dev channel it could not find out otherwise, because nothing there reads the
version off the device. FTStore Admin ships on that channel and no other, so it
was the product that stayed wrong. The dev check now also compares the
advertised `X.Y-dev.N` against the recorded one, so the next published build
corrects a stamp that went wrong; it is a backstop, not the fix.
**And it holds in both directions**, because both apps install the other one the
same way. `ios/` parks the Manager's baseline in
`AppInstallManager._awaitingManagerInstall` and commits it from its own
`confirmInstalled`, off the same `.completed(.success)`. There is a second
signal to suppress on this side, though: FTStore reads the *signed copy in
storage* as "what is on the device", and signing has just written one carrying
the new version — so on its own the parked stamp would change nothing. The uuid
therefore goes into `UpdateManager.unconfirmedManagerCopies` (persisted, because
the case it exists for outlives the launch), and every signal that answers
"which build is installed?" skips it, falling through to the copy that did
install. A confirmed install clears the whole set: `pruneReplacedCopies` has
just deleted everything else under that bundle id, so nothing can still be
pending. The library row skips those copies too, or it would print a version the
device doesn't have next to its own "Update" button.
**The Manager's My Apps row always offers a way to reinstall it.** The failure
this guards is asymmetric: FTStore can be repaired by the Manager, and the
Manager by FTStore, so an update to *the Manager* that quietly went wrong is the
one that strands a device. Confirming the install stops FTStore from inventing
that state, but it cannot see a Manager broken any other way, so
`LibraryCellView._managerReinstallAction` is offered whether or not an update is
due — on the row's swipe always, and in its context menu once the row has
settled on "Open". It is gated only on `UpdateManager.managerBuildAvailable`,
i.e. on there being a build to install at all, never on one being *due*.
**The two apps update each other, and each follows the channel of the app it is
updating.** `ios/` keeps the Manager current from the permanent My Apps row
(`UpdateManager.checkForSelfUpdate`, over `/api/ftstore-manager/manifest`), the
mirror of what `manager/` does for FTStore. Which channel it asks for comes from
the Manager on the device (`UpdateManager.managerTrackedChannel`), never from
the build of FTStore doing the asking: they are separate apps on separate
version lines, and a stable FTStore next to a dev Manager is an ordinary
combination. Reading the running app's own version was the bug this replaced, in
both directions at once. The evidence, in order: a copy `ios/` signed itself,
whose marketing version spells out `X.Y-dev.N` for a dev build; then
`FTStore.manager.installed.channel`, written by every `markManagerInstalled`.
Only a device with no Manager at all falls back to `ManagerChannel.appBuild`,
this build's own channel, which answers "which one should it install first".
A stable Manager is therefore never offered a dev build (`resolutionOrder` is
`[.stable]`), while a dev Manager falls back to stable when dev has nothing
published, rather than dead-ending.
`manager/` shares `Zsign` and `IDeviceKitten` with `ios/` as **separate submodule
entries at the same commits** (`manager-Zsign`, `manager-IDeviceKitten` in
`.gitmodules`). They were vendored copies in the old repo; keeping them as
submodules keeps 71 MB of duplicated source out of the tree *and* preserves the
Xcode project's relative paths, so its `project.pbxproj` needed no edits. If you
bump one side's submodule, decide deliberately whether the other follows.
**Every product needs its own derived data, and the Makefiles enforce it.**
`manager/` was forked from `ios/` rather than recreated, so both build a project
called `FTStore.xcodeproj`, with a scheme and a target called `FTStore`, out of
a `project.pbxproj` carrying **the same GUIDs** — and those GUIDs are the key
Xcode's build database uses. Only `SRCROOT` differs, which is not enough to tell
them apart. Both Makefiles therefore key `TMP` on the product directory
(`$(TMPDIR)/$(NAME)-$(notdir $(CURDIR))`), never on `$(NAME)` alone; `admin/`
does the same for the sake of the next fork.
This is not hypothetical tidiness. While they shared `$(TMPDIR)/FTStore`, the
Manager build — which had no `FTStoreActivity` target then — swept the app's
Live Activity extension as a stale file, and the app build found the Manager's
object files in its own intermediates. The shipped IPA ended up carrying a
leftover `FTStoreActivity.appex` that no longer matched the app, so the Dynamic
Island quietly rendered nothing. It only ever bit on the **self-hosted** Mac: the
GitHub-hosted runners handed every job a fresh VM, hence an empty `$(TMPDIR)`
and a clean build every time. Every release workflow now asserts the extension
is inside the IPA, because a missing appex breaks nothing else and says nothing.
**Both products have an island now**, and each has its own `FTStoreActivity`
target embedding an appex of that name — `app.ftstore.activity` in one,
`app.ftstore.manager.activity` in the other. That does not retire the paragraph
above; it sharpens it, because a shared derived-data path would now have the two
extensions overwriting each other rather than one deleting the other. The
Manager's copy is the app's with the pause button removed: its `DownloadManager`
has no pause, so there is no `InstallActivityIntents.swift` and no `isPaused` in
its `ContentState`. Do not "restore" either without giving the Manager something
to pause first.
Consequences worth internalising:
- The app's tree lives in **`ios/`**, one level below the repo root. Upstream
rooted the same tree at the root, which is why anything copied from a Feather
checkout arrives at the wrong depth. Move it into `ios/` by hand, folding into
ours on a collision.
- **Release tags are namespaced**, and nothing may reuse another's:
| Tag | Product | Built by |
|-----|---------|----------|
| `v*` | the app | `stable-release.yml` |
| `web-v*` | the FTStore Admin server | `web-release.yml` |
| `manager-v*` | FTStore Manager | `stable-release.yml` |
| `admin-v*` | the FTStore Admin app | `admin-release.yml` |
| `dev` | the app's rolling prerelease | `ios-dev-repo.yml` |
| `manager-dev` | the Manager's rolling prerelease | `manager-dev-repo.yml` |
| `admin-dev` | the console app's rolling prerelease | `admin-dev-repo.yml` |
The three `dev` tags are deliberately distinct: the app and the Manager were
separate repos where both were called `dev`, and sharing one here would make
each build overwrite the others' release assets.
- **Stable releases of the app and the Manager are dispatch-only.** Run
**Actions → Stable Release**, pick the product (`ftstore` / `manager` /
`both`) and a version; leaving a version empty bumps the patch of that
product's last stable release, falling back to its `FTStore.xcconfig` version
when it has never had one. The workflow stamps the version, builds, checks
the IPA really reports it, publishes a **published** (never draft) release,
refreshes whichever `app-repo.json` carries that bundle id, and commits the
version bump back to the branch so the next dev build stays ahead of stable.
Pushing a `v*` / `manager-v*` tag by hand no longer builds anything — the old
`ios-release.yml` / `manager-release.yml` / `ios-update-repo.yml` were removed
because they produced *draft* releases, and FTStore Admin's stable channel
filters drafts out (`web/src/lib/github/ftstore-app.ts`), so nothing ever
reached a device until someone pressed "Publish" by hand. `admin-v*` still
works the old way — the console app is sideloaded, not served to users.
- **Only the newest stable release of each product survives.** The last job of
`stable-release.yml` deletes every older `v*` (or `manager-v*`) release and
its tag, keeping `KEEP_RELEASES` — currently `1`. Nothing else is reachable
from it: the pattern is anchored and numeric, so `dev`, `manager-dev`,
`admin-dev`, `web-v*` and `admin-v*` cannot match, and the job is gated on
`needs: release`, so a failed build deletes nothing.
- **`KEEP_RELEASES` also bounds the AltStore version history.** Each entry in
a manifest's `versions` array is a download URL into a release asset, so
keeping more history than releases would publish dead links. The two are
trimmed together in the same workflow — change one and change the other.
- Updates do not depend on old releases surviving: the proxy resolves the
newest non-draft, non-prerelease release carrying a matching asset, and
`make_latest` keeps the README's `/releases/latest/download/…` button on it.
What deletion does cost is the ability to roll back to a previous build, and
any download already in flight from a retired asset.
- `web-delete-release.yml` is unrelated and stays manual: it is the `web-v*`
namespace, where the Docker images have to go too.
- **The landing page is the one public surface, and it spans two products.**
(Unless private mode is on, in which case there is no public surface at all,
see `private_mode` below.) `web/` serves `ftstore.app` at `/`; its default flow takes a `.p12`, a
`.mobileprovision` and the password, appends `FTStoreSeed/` to the Manager
IPA's ZIP (which `manager/`'s `ManagerSeedImporter` imports at first launch),
**signs the result**, and hands the visitor an `itms-services://` URL so iOS
installs it directly. The format version is a contract across the two
(`web/src/lib/landing/ipa-seed.ts` ↔ `ManagerSeedImporter.swift`) — bump both
or neither. Opening `/` did **not** loosen anything else — `/admin`, the feed,
`/d/<token>/…` and every `/api` route keep their existing gate, and only `/`,
`/robots.txt` and `/api/public/*` are reachable without one.
- **The signing step is server-side, and that was a deliberate reversal.** This
guide used to say the certificate must never leave the browser and that there
must be no endpoint accepting a `.p12`. That rule made the page's output
uninstallable: iOS verifies the code signature before an over-the-air install
completes, so an IPA with a certificate merely *inside* it is refused at the
sheet, and every visitor needed a second signing tool anyway. So
`POST /api/public/manager/install` now takes the certificate, signs with
`zsign` built from `manager/Zsign` into the app image, and parks the result in
`storage/install/<token>.ipa` for the phone to collect. Ordering is load-bearing:
the seed goes in **before** signing, because signing is what seals the bundle.
What replaced the old rule, and what must hold:
- the certificate exists only as files in a temp folder deleted in a `finally`
(`web/src/lib/signing/zsign.ts`), and the signed build — which embeds it —
expires in minutes (`SLOT_TTL_MS`), swept on every write;
- the 128-bit slot token is the only access control, exactly as for
`/d/<token>/…`, and everything unknown answers 404;
- the page must not claim the certificate stays on the device. It does not.
- `web/Dockerfile` builds from the **repository root**, not `web/`, because it
compiles `manager/Zsign`. `Dockerfile.worker` still builds from `web/`.
- **`/api/public/genPlist` is open on purpose, and one rule is what makes that
safe.** `itms-services://` refuses an install manifest that is not served from
a publicly trusted host, and the installer server running on somebody's phone
is not one, so a Semi Local install needs a manifest from somewhere else that
merely points back at the payload on the LAN. FTStore used to send every one
of those to `api.palera.in`, hardcoded and alone, which made a stranger's
uptime a dependency of every such install. Now `ManifestService`
(`ios/…/Backend/Server/ManifestService.swift`) asks us first, proves the
answer starts like a plist, and keeps `api.palera.in` as the fallback.
- **`manager/` has its own copy**, and it must stay in step. The Manager runs
the same `ServerInstaller` and installs over the same two methods, so a
fix to one of them that lands in only one app leaves the other on the old
behaviour. The one deliberate difference is which host it asks first: the
Manager reads `ManagerConfig.baseURL`, which an operator can point
somewhere else, where `ios/` reads the hardcoded `FTStoreLinks.website`.
- The caller is `installd`, so there is no session, no token and no app
User-Agent to check, and the route is not behind `readGate` either: gating
it would break every Semi Local install the moment private mode went on.
- **What replaces all of that is `isLocalPayloadHost`**
(`web/src/lib/genplist.ts`): the payload URL has to be loopback, RFC1918,
link-local, CGNAT or an mDNS `.local` name. Without it the endpoint is a way
for anyone to publish an `itms-services://` link that reads `ftstore.app`
and installs whatever they host. The fully local method serves its own
manifest off the device and never comes here, so the restriction costs
nothing. Do not widen it.
- **The server domain is `ftstore.app`**, hardcoded in all three apps
(`ios/…/ManagerRepoClient.swift`, `…/TweakRequestClient.swift`,
`…/Storage+Sources.swift`, `ios/…/Utilities/FTStoreLinks.swift`,
`manager/…/ManagerConfig.swift`, `admin/…/AdminConfig.swift`).
`DownloadManager.swift` still allowlists the pre-rename host — that check is
permissive, not identifying, and dropping it would break installs whose stored
override still names it.
- **The public addresses users are sent to live in `FTStoreLinks`** — the
website and the Telegram channel (`https://t.me/ftstore_app`), which the
Settings banner, the About screen's Links section and its "FTStore Team"
credit all read from. `web/`'s landing page keeps its own copy in
`web/src/components/landing/footer.tsx`; if the channel ever moves, both
change.
- **The Telegram banner in Settings is dismissed once and for all.** Closing it
sets `FTStore.telegramBannerDismissed` in `UserDefaults`, which survives an
app update, and nothing may clear it, expire it or key it to a version. It is
an invitation, and re-showing it after every release would make it an advert.
Only "Reset Settings", which drops the whole persistent domain, brings it
back.
- Docker images: `ghcr.io/<owner>/ftstore-admin-app` and `-worker`. The Compose
project is pinned to `name: ftstore` so volumes stay `ftstore_*` regardless of
the checkout directory.
- **The Store tab never lists FTStore Manager.** The Manager is what keeps
FTStore updated, so it belongs in My Apps as a permanent row and nowhere else.
The filter is one line in `SourceAppsTableRepresentableView._allAppsWithSource`
rather than a rule about which source may list it, so it holds whichever
repository the entry turns up in. The seeded default source is the FTStore
feed, named `FTStore`; installs made before that rename are corrected in place
by `Storage.addDefaultSourceIfNeeded`.
- **Store tags are decided in one place.** `Official`, `Recommended`, the green
`Tested` and the red `Paid` are built by `ASRepository.App.storeTags`
(`ios/…/Views/Sources/Apps/StoreTagView.swift`) and drawn by `StoreTagsView`,
which both the list cell and the detail page use. Add a tag there, not at a
call site, or it appears on one screen and not the other. Only Official and
Recommended get the highlighted "featured" cell background: `Paid` is a
warning, and giving it the promotional treatment would read as an endorsement.
- **A recommendation counts as tested, in the store only.** Picking something
out of a field is not done without installing it first, so
`countsAsTested` is `isTested || isRecommended` and both the green tag and
the "Tested only" filter read it. One derived property rather than the rule
restated at each site: a tag and a filter that disagreed about what
"tested" includes is the failure this shape exists to prevent.
- **The wire meaning is untouched.** `tested` in `ftstore.json` still says
exactly what an operator ticked, `isTested` still reports exactly that, and
the two flags stay separate everywhere an operator sets them — Known Tweaks,
the dashboard, the console. The implication is the *store's* reading, and it
lives on the store's side of the wire.
- **A paid tweak carries `paid`, `pricing` and `purchaseURL` in `ftstore.json`,
and all three are independent.** `paid` is the tag and stands alone: a tweak
can be known to cost money before anyone has written down what it costs.
`pricing` holds `monthly` / `yearly` / `lifetime` plus an optional `currency`
(absent means USD), and **only the tiers the operator filled in are
published** — `normalizePricing` (`web/src/lib/pricing.ts`) drops blanks and
zeroes, and the store lists exactly what arrives. A missing tier is not a free
tier; never render a placeholder row for one. `purchaseURL` is where the
licence is actually sold (a site, a Discord invite, a shop page), and it is
*not* part of `pricing`: knowing where to buy something and knowing what it
costs are different facts, and a paid tweak commonly has one without the
other. Both are dropped when `paid` is cleared, so neither stale figures nor a
dead shop link outlive the flag.
- **The link is normalized before it is published, never after.**
`normalizePurchaseUrl` fills in a missing scheme (operators paste
`discord.gg/…`), then refuses anything that isn't `http`/`https`, has no dot
in the host, or carries credentials in front of it. `mailto:dev@example.com`
is exactly that last case once `https://` is prepended, which is why the
check is there rather than in the console. The app applies the same rule
when it decodes (`ASRepository.App.purchaseURL`), because that URL ends up
in `UIApplication.open` and any repository can send one.
- **A build says where it came from, and the store makes that a link.** Every
build has carried its origin in the provenance line under the app's name
(`@glesign`, `owner/repo`) since the catalog existed, which named the
publisher and left the reader to go and find them. For a tweak that is most of
what they wanted next: the channel is where the support conversation and the
next build are. So the feed also carries `originURL`, and the app's detail
page draws it as one tappable row under Information.
- **`originURL` is the origin, never the source.** The source is the feed the
reader is already looking at, and a field that meant either would be read as
both. `sourceOriginUrl` (`web/src/lib/sources.ts`) builds it from the
channel id: `@handle` becomes a Telegram address, `github:owner/repo` a
repository page, and everything else — a manual upload, a numeric id, a
private invite — answers null and is omitted, because a link that goes
nowhere is worse than no link.
- **Both shapes are checked, never pasted through.** A channel id is what an
operator typed into a form, so a handle has to be Telegram's own shape and a
repository exactly two path segments. Without that the field is a way to
aim a tap in the store at any address somebody can get into a form.
- **The app checks it again, twice.** `ASRepository.App.originURL` applies the
`purchaseURL` rule — `http`/`https` only, no credentials before the host —
because any repository can send the field and it ends in
`UIApplication.open`. Then the detail page draws the row only for a host it
can name (`t.me`, `github.com`), because the wording is what makes the tap
trustworthy: a row reading "Website: example.com" would be a stranger's link
wearing the store's furniture.
- **The store's identity is a constant, not a setting.** FTStore is the only
thing this server publishes, so its name, subtitle and description live in
`web/src/lib/brand.ts` and nowhere else. The `source_*` settings that used to
hold them are gone, along with the "Source Metadata" card, `/api/branding` and
the `useBranding` hook. The feed identifier is derived from the name
(`xyz.ftstore`), so an operator who renamed the store orphaned every client
that had already added it. `site_domain` survives in a "Server" card because
it is deployment plumbing, not branding.
- **IPA downloads always require FTStore's User-Agent.** `/d/<token>/<name>.ipa`
checks it unconditionally (`web/src/app/d/[token]/[name]/route.ts`). There used
to be a `download_require_app_ua` setting in front of that check, and turning
it off put every build on the open web behind nothing but the token in the URL.
The setting, its switch in Settings → General and the console's "Feed &
Storage" group are all gone. Because a copied link is therefore a dead link
anywhere but the app, `ios/` has no "Copy Download URL" action either.
- **An app with no App Store entry still has artwork, and it is inside the
build.** The iTunes lookup answers for anything that was ever on the App
Store; for a port, a fork or an app signed under a rewritten bundle id it
answers with nothing, and the store drew a grey placeholder. So the ingest
takes the icon out of the IPA it already has open
(`web/src/lib/ipa/app-icon.ts`), and the worker does the same for rows that
predate it. Five things hold:
- **The backfill runs by itself, and that is the point.** A feature that only
applies to future builds fixes nothing anybody has already noticed, and a
button somebody has to find is a fix that waits. The worker calls
`backfillMissingIcons` (`web/src/lib/pipeline/icon-backfill.ts`) every 30
seconds while there is a backlog and hourly once there is not, so a
deployment catches up on its own within minutes.
`POST /api/actions/extract-icons` is the same pass on demand, offered from
both consoles for an operator who wants it now.
- **Every build examined is stamped** (`DownloadedIpa.iconCheckedAt`),
whether or not an icon came out. Without it a bounded pass re-opens the
same archives that have nothing in them, on every run, and never reaches
the builds behind them. A stamp with no `iconUrl` reads as "we looked,
there is nothing in there"; `?force=1` re-examines those, and exists for
the day the extractor learns something new rather than for a schedule.
- **The App Store icon wins where there is one.** It is the full-resolution
artwork; the bundle carries whatever size the home screen needs. Extraction
only ever fills a row whose `iconUrl` is null, so pressing the action twice
changes nothing and no catalog is silently re-illustrated.
- **The primary icon is the one the plist names.** An app that ships
alternate icons carries all of them at the bundle root, and the biggest file
there is as likely to be an alternate as the real thing. The file name only
ranks candidates; the dimensions are read off the IHDR afterwards.
- **Xcode's icons are not ordinary PNGs.** `pngcrush -iphone` writes a `CgBI`
chunk, headerless deflate, BGRA channels and premultiplied colour, which
CoreGraphics reads and a browser does not. `web/src/lib/ipa/cgbi-png.ts`
converts them with `zlib` and nothing else — an image library here would be
a native dependency in a container that needs none, exactly the reasoning in
`storage/image-metadata.ts`. Anything it cannot prove it understands
(interlaced, a palette, a bit depth that is not 8) comes back null and the
build keeps the placeholder. What it cannot reach at all is an icon that
exists only inside `Assets.car`; that is not worth a catalog parser.
- **The files live in `appicons/<xy>/<32-hex>.<ext>` and are swept, not
deleted at the call site.** A build row is removed from half a dozen places,
so `sweepOrphans` reconciles the directory against the `iconUrl` column the
way it already does for `ipa/` and `shots/`. Do not confuse them with
`icons/`, which holds an operator's uploads keyed by tweak name. Their
route, `/media/appicons/<name>`, is public for the same reason
`/media/icons/` is: the feed's `iconURL` is fetched by clients that do not
send FTStore's User-Agent, and the middleware matcher excludes image
extensions.
- **`github_owner` / `github_repo` / `github_branch` do not exist.** The app repo
is hardcoded in the Manager proxy and every scanned repository is configured
per source under Channels & Sources, so those three settings were read by
nothing. `github_token` **is** still live: it is the fallback the Manager proxy
and the activity feed use when `ftstore_app_github_token` is blank
(`web/src/lib/github/ftstore-app.ts`, `web/src/lib/github/activity.ts`).
- **FTStore Admin is bilingual, and the operator's own machine picks.** Both
halves follow the system locale and neither offers a language switch: a console
that disagrees with the rest of the device is a bug, and a stored preference
would be one more thing to restore from a backup.
- `admin/` ships `FTStoreAdmin/Resources/Localizable.xcstrings` (en + de) and
iOS chooses. Most of the app needs no call-site change, because SwiftUI reads
a *literal* in `Text`/`Button`/`Label`/`Section`/`.navigationTitle`/`.alert`
as a `LocalizedStringKey`. `String.localized(…)`
(`Support/AdminLocalization.swift`) is for the other half: a `String` built
before it reached a view, which SwiftUI renders verbatim. `de` must stay in
the project's `knownRegions` or the catalog is never compiled in.
- `web/` resolves the locale from `Accept-Language` in the two `/admin`
layouts and hands it down through `LocaleProvider`; `useT()` translates.
Resolving it on the client instead would render English on the server and
German after hydration, i.e. a mismatch on every translated string.
- **Both dictionaries are keyed by the English source string.** A key with no
German entry renders as English, so adding a string is never a two-step
change and forgetting a translation is a cosmetic gap, not a broken screen.
Server-supplied text (an API error, a log line) is never translated.
- The public landing page at `/` is **not** part of this and stays English.
- **Deciding a tweak request is approve or reject, and nothing else.** Approving
used to be able to create the scraper source in the same step, which meant one
tap both answered the requester and started scraping a channel nobody had
looked at. Adding a source is its own action in Channels & Sources. Do not
reintroduce an `addSource` flag on `PUT /api/tweak-requests`.
- **A tweak request is private; the *wish* behind it is public.** Everything in
that table was typed when the composer said a request was between the
requester and the operators, so the board serves the tweak, the source, the
status and the numbers and nothing else. `serializeBoardRequest` in
`web/src/lib/tweak-request-board.ts` is that boundary, and it names **no
requester, not even by handle**. Replies underneath are different: those were
written in the open under a composer that says so, so they carry a handle and
can be `@`-ed exactly like a feedback reply.
- **One wish per source, and it hangs off the oldest row.**
`canonicalRequestId()` resolves any id to the oldest `TweakRequest` for that
`sourceId`, and every vote and reply route calls it first. Five people
asking for one channel are five rows and one wish, which is the same
grouping the operator queue has always used to settle a whole source with
one decision. Without it the board showed the copies and split the votes
between them, the exact failure a board exists to prevent. Oldest rather
than newest because it never moves: a duplicate filed tomorrow must not
relocate a thread people have already replied to.
- **Canonicality is worked out before any filter**, never under one
(`canonicalRequestIds()`). Resolving it inside a status or search filter
turns "oldest row for this source" into "oldest row that matches", and a
vote cast through one filter then lands where another filter cannot see it.
- **A Telegram channel is a scrape or a shopping list, and it says which.**
`ChannelProgress.scanMode` is `full` or `on_demand` (`web/src/lib/sources.ts`).
`full` is the old and still default behaviour: every sweep walks the channel's
history and queues every IPA in it. `on_demand` exists because the commonest
channel carries one tweak somebody asked for and a hundred apps nobody did, so
such a channel is skipped by every sweep and is read only by a targeted search
that already knows the name it wants.
- **The split is one query, and it must stay that way.**
`getTelegramChannels()` is the sweep list and excludes on-demand channels;
`getSearchableTelegramChannels()` is the search list and includes them
(`web/src/lib/config.ts`). Every bulk caller keeps calling the first without
knowing the mode exists. Do not "simplify" them back into one.
- **A channel a search met is created on demand, never full.**
`ensureChannelProgress` in `search-ipa.ts` writes rows for channels that a
lock or an import named. Creating them in the default mode is exactly the
bug this replaced: one Find IPA run against an unconfigured channel enrolled
it, and the next sweep took its whole catalog.
- **Importing a tweak is the operator-facing half**
(`POST /api/actions/import-tweak`, `web/src/lib/tweak-import.ts`). It
registers the channel on demand, folds the tweak into `known_tweaks`
(locked to that channel unless the operator unticks it) and queues a
`search_ipa` carrying `channelId`, which pins that run to that channel and
skips GitHub entirely. An **existing** channel keeps the mode it has: an
import may add a targeted job to a full source, never quietly narrow one.
- An import amends an existing tweak rather than replacing it, and refuses
three things instead of guessing: a tweak already locked to a *different*
source (re-pointing it would drop its builds out of the feed), a
manual-only tweak (importing is the one thing it says no to), and a name
that is another tweak's alias (the matcher would resolve it to that one).
- The mode rides in the settings backup. Dropping it on restore would turn
every on-demand channel back into a scraper of everything it holds.
- **A known tweak answers three things about where its builds come from, not
two.** `lockedChannelId` names the one source allowed to write it, an absent
one means take it from anywhere, and `manualOnly` means take it from nowhere:
the operator uploads the IPA and no sweep, GitHub scan or targeted search may
replace it. The last is what a hand-curated build needs, and it is the
*opposite* of an absent lock rather than a stricter version of one.
- **`manualOnly` carries no `lockedChannelId`, and that is load-bearing.**
Everything that filters on a lock (`findLockedChannelTweak`,
`shouldKeepIpaForLockedChannel`, `matchTweak`) must never see a manual-only
tweak, or the feed would drop the uploaded build for having come from the
wrong place. `findManualOnlyTweak` is the separate lookup, and
`ingestIpaFile` checks it *before* the lock.
- **One gate, one flag.** Both rules ride on `enforceSourceRules`, which every
scanned source leaves on and a manual upload turns off. Turning it off is
what makes `manualOnly` a setting rather than a tweak nobody can update, so
a new rule of this kind belongs behind the same flag.
- A targeted search refuses a manual-only tweak up front rather than letting
the ingest reject every file it downloads, which would read as "nothing out
there" instead of "this one is yours".
- **The feedback board is public, and that is the whole design.** Settings →
Feedback is a shared list of bug reports and feature requests that *every*
FTStore user can read, reply to and vote on, answered by operators from
`/admin/feedback` and `AdminFeedbackView`. It is the second thing the app
writes to, and it is not a second tweak-request queue:
| | Tweak requests | Feedback |
|---|---|---|
| What is public | the wish, its source, its status, its numbers | all of it |
| What stays private | the note, the raw input, **who asked** | nothing |
| Answered by | approve / reject | a conversation plus a status |
| Decided | in bulk, per source | one ticket at a time |
| Grouped | yes, by `sourceId` | never |
What follows from "public", and must keep holding:
- **`serializeTicket` / `serializeComment` are the boundary** (`web/src/lib/feedback.ts`).
The `"app"` audience must never emit `installId`, `clientVersion`,
`systemVersion`, `deviceModel`, `authorUserId` or `operatorName`. A bug
reporter hands over their device model for triage, not to the board.
- **A team reply is authored by "FTStore Team", never by a person.** The
operator account is stored on `FeedbackComment.authorUserId` for the audit
trail and stripped before the app sees it, and the team badge is drawn from
`authorRole`, never from the display name. `RESERVED_NAME_KEYS` refuses the
names that would imitate it.
- **A status change is written into the thread**, as a `FeedbackComment`
carrying `statusFrom`/`statusTo` and optionally a note. That is why there is
no events table, and why "why was this declined?" is still answerable later.
Do not turn the status into a silent column update. This holds for the
reporter's own close as much as for an operator's decision: the entry is
written by `authorRole: "user"`, and there is no "closed by" column because
the thread already answers who moved it (`lastStatusChange`).
- **What is theirs, the reporter may change.** Their ticket's title and body,
the replies they wrote, and whether the ticket is settled. Two limits hold
that in place, both in `resolveAuthorStatusMove`: `resolved` is the only
closed status they can reach, because declining a report or calling it a
duplicate is a verdict about the app; and they may undo **their own** status
change, never the team's, which is what `not_reopenable` refuses. Reopening
lands back on whatever their close took the ticket from, so a report already
triaged as Planned does not lose that.
- **A deleted reply is soft deleted** (`FeedbackComment.deletedAt`), and both
consoles draw it struck through. It leaves the app for everyone, its author
included, and the row stays: a thread other people answered must not be
rewritable by removing the line they were answering. `deletedAt` is
operator-only on the wire, since the app is never sent one at all. A ticket
still has no author-side delete, and a status entry cannot be edited or
deleted by anyone.
- **Moderation has to stay reachable**: `hidden` on a ticket and on a comment
(reversible, and its author still sees it marked as removed),
`FeedbackAuthor.blocked` to mute an install without deleting what it wrote.
**A mute has to be undoable without the report it started from**, which is
what Users is for (`GET /api/feedback/authors`, `/admin/users`,
`AdminFeedbackUsersView`): muting used to be reachable only from a ticket in
front of the operator, so an install muted by mistake — or one whose only
report was later deleted — could never be reached again.
Deleting is admin-only, because it destroys other people's replies too —
admin-only means `withAdmin` on `DELETE /api/feedback/<id>` and nothing
else, so both consoles offer it and a non-admin operator is answered with a
403 rather than with a missing button.
`hidden` and `deletedAt` are never folded into one flag: one says "we took
this down", the other "they took it back", and only the first is the
operator's to undo. `VISIBLE_COMMENT` is what "a reply everyone can read"
means, and every counter spreads it rather than restating `hidden: false`.
- **Deciding a report files it, it never removes it.** Every list that shows
tickets — the app's board, `AdminFeedbackView`, `/admin/feedback` — names
open, settled and both as places you pick between, never a checkbox that
makes the closed ones vanish. `resolved`, `declined` and `duplicate` all
land in the settled one, which is the whole point: someone who declines a
report and then cannot find it reasonably concludes it was deleted. (The
dashboard spells the three as "Anything open" / "Anything closed" / "All
statuses", because its select lists the individual statuses too and two
entries called "Open" would be worse than the longer words.) Deleting is
the separate, admin-only thing above.
- **The display name lives on `FeedbackAuthor`, keyed by install, and nowhere
else.** That is what makes a rename retroactive. Never denormalise it onto a
ticket or a comment.
- **Everybody on the board has a handle, and "Anonymous" is not one.**
`FeedbackAuthor.handleNumber` is a sequence every row gets, so an install
that never picked a name posts as `Anonymous_12`: stable, distinct from
every other unnamed install, and something a second person can reply to by
name. `authorHandle` is the one place it is assembled, `resolveAuthorNames`
returns it for every install, and `ANONYMOUS_AUTHOR_NAME` survives only as
the fallback for an install with no row at all.
- **A handle is never translated**, in either console or in the app. It is
what somebody types after an `@`, so `Anonym_12` on a German phone would
be a different person. The app renders the server's `handle` verbatim
rather than localising a word plus a number.
- The row is created on first contact (`ensureAuthor`, called by
`GET /feedback/me`), not on first post, because the name screen has to be
able to show a handle before anything has been written.
- **The author row is the account, and posting needs one.** This is the
change everything below it is now qualified by, so read it first.
- **Identity used to be `installId`**, the UUID the app mints on first
launch. It says "the same phone as last time" and no more, so posting was
open to anybody who asked and reinstalling the app lost everything a
person had written. `FeedbackAuthor` already had the parts of an account —
a unique name, a secret, a mute flag, every post bylined from it — so it
**became** the account rather than being replaced by one. There is no
second identity model and no migration of the board tables: `installId`
stopped meaning "this phone" and started meaning "this account".
- **`passwordHash` is the credential; `pinHash` is a spent migration path.**
A PIN was four digits and only ever kept a name from being taken, which is
a far smaller job than signing in. `signIn` still accepts one, exactly
once, and answers `mustSetPassword`; the session that comes back is
refused everywhere except the password screen (`requireSettledAccount`),
and setting one clears `pinHash`. Nothing mints a PIN any more.
- **`AppSession` is a row, so signing out takes effect on the next request.**
The token is 256 bits stored as SHA-256 — a fast hash on purpose, since
there is no candidate list to work through and a slow one would cost a
scrypt per request. Changing a password revokes every session and mints a
fresh one for the caller, or the change would mean nothing to whoever
prompted it.
- **`requireSettledAccount` is the write path and `installIdFrom` is not.**
Every handler that writes to either board resolves the session; the
install header is left for reads, where `readerInstallId` prefers the
account when there is one so a signed-in device does not see its own old
anonymous history instead of its account's. The one write that still means
the header is `account/register`, which is what makes registration carry
this phone's existing history into the new name.
- **Nothing already posted was touched.** Anonymous history stays readable
and answerable under its `Anonymous_12` handle. Registration is asked for
at the first write and never at launch, because reading was never gated
and closing it now would shut people out of something they did nothing to
lose. Both were decisions, not defaults; do not "finish the migration" by
hiding or renaming old posts.
- **An account renames without a PIN** (the `passwordHash` branch at the top
of `_claimDisplayName`), and cannot go nameless or take a name off
somebody else at any price. Carrying a name to a new phone is what signing
in is for now, so a name moving between people stopped being a feature to
support and became one to refuse.
- **A profile picture reuses the screenshot pipeline** — same sniffing,
metadata stripping and size accounting — but its own `avatars/` prefix and
its own serving route, because the two answer different questions: a
screenshot is checked for a takedown, an avatar for still being somebody's
current picture. `FeedbackAuthor.avatarPath` is unique so the route is an
index seek.
- **A setup backup hangs off the account, and the certificate is not in it.**
`AppBackup` is one row per account, replaced rather than versioned, holding
the app's own opaque JSON: sources, signing options, and an **explicit list**
of preference keys (`BackupManager.settingKeys`). The list is not "the whole
persistent domain" on purpose — that also holds install stamps, cached update
state and one-shot migration flags, every one of which would say something
untrue on another device.
- **Never add the certificate to it.** A signing certificate signs apps in
somebody's Apple ID's name, so a server holding everyone's would be one
place worth breaking into for a prize far larger than anything else here,
and no password people actually choose is worth that. The certificate is
imported once on the way back in, which somebody has to do anyway. The copy
on both screens says so rather than burying it.
- **The server never reads the payload.** It checks a size and stores a
string; `formatVersion` is how the app tells its future self what it wrote,
and an older build declines a newer backup instead of restoring half of it.
Changing what the app saves is therefore not a change to the route.
- **Restoring is additive.** Sources are added, settings written over,
nothing is removed. Somebody who taps it by mistake must not lose the
sources they added since.
- **A name is a registration, and the PIN is what holds it.** *Superseded by
the account rules above for anything new; kept because the machinery it
describes is still what a legacy row carries.* `nameKey` is
unique, so a name belongs to one install; `pinHash` is scrypt in the same
`salt:hash` shape `lib/auth.ts` uses. `claimDisplayName` is the whole of the
rule set and every path goes through it:
- a **free** name is registered with the PIN sent alongside it, so nobody
ends up holding a name with nothing protecting it;
- a name **already this install's** is a no-op, unless `newPin` arrives,
which is a PIN change and needs the current one;
- a name **somebody else holds** moves only if its PIN matches. That case is
the reason the feature exists: the install id lives in `UserDefaults` and
does not survive a reinstall, so the person behind a name has to be able
to take it with them, and nobody else may.
- **Wrong PINs are throttled against the name, never the caller**
(`pinFailures` / `pinLockedUntil` on the row being claimed). The install id
is minted client-side, so a per-caller counter would reset itself on
demand. Five wrong answers lock for five minutes, doubling to a day.
- Names are `[A-Za-z0-9_]{2,32}` (`parseDisplayName`), narrower than the "any
alphabet" rule they replaced. That is the price of `@`: a mention needs an
unambiguous end, and two names have to be tellable apart at a glance. The
anonymous handle space is reserved too (`isReservedNameKey`), or two
identities would answer to one `@`.
- **No name ever rides along with a post.** `requirePostingAuthor` takes an
install id and nothing else, and neither `POST /feedback` nor
`POST /feedback/<id>` reads `authorName`. Accepting one would be a way
straight past the PIN.
- **`nameKey` backfills itself, and not only at boot.** The containers boot
with `prisma db push`, which leaves the data where it is, so the keys are
filled in by `web/prisma/pre-push.sql` on the way past — without it the
names that existed before registration did would read as unclaimed.
`backfillNameKey` fills one in on the owner's next `/feedback/me` all the
same, and `findNameHolder` looks a null-key name up by `displayName` in
the meantime. Do not delete either on the grounds that the boot script
covers it: it covers the deployments that run it, not a restored dump or a
dev database somebody pushed the schema to by hand.
- **A mention is a row, not a search.** `FeedbackMention` is written at post
time by `syncMentions` and rewritten whenever the text is edited, hidden or
deleted. Matching handles against bodies later would be wrong twice: `@ann`
is a prefix of `@anna`, and a rename would silently rewrite who was pinged.
Being mentioned puts a thread on your list (`filter=mine`, `/feedback/me`)
and notifies you, tracked as `lastMentionId` so being named twice is news
twice.
- **The `@` grammar exists in four places and they must agree**:
`MENTION_RE` (`lib/feedback.ts`) is the one that decides who is notified;
`feedback-meta.tsx`, `FeedbackMentions.swift` and `AdminMentions.swift`
only draw it. A handle highlighted by a front end that the server did not
resolve is a ping that never arrived.
- **Mention suggestions come from the thread on screen and nowhere else.**
Everyone in a conversation is already visible there, so suggesting them
leaks nothing, whereas a lookup across all authors would hand any caller a
way to enumerate the people using FTStore. Typing a handle in full still
reaches anybody.
- **The team badge says "Admin", and the byline still says "FTStore Team".**
The badge sits next to the name, so repeating it told the reader nothing;
what they want to know is that the reply came from whoever runs the store.
It is still drawn from `authorRole` and never from the name.
- **Voting is the board's ranking, and it happens on the list.** One install
gets one vote per ticket and per reply, `1` or `-1`, and the arrows plus the
score sit on every row of `FeedbackView` so seconding a request costs a tap
and never opens the thread. This replaced eight emoji reactions of which
only `+1` ever meant anything; do not bring them back to sit next to a vote,
since a board with two ways to agree ranks on neither.
- **The request carries the state to end up with, never a toggle.**
`POST /feedback/<id>/vote` takes `1`, `-1` or `0`, and what a tap *means*
(pressing up while already up clears it) is the front end's decision, in
`FeedbackVote.pressing`. A server that toggled would turn the retry after
a dropped connection into a silent undo, and both front ends vote
optimistically and retry.
- **`FeedbackVote` has no unique index and cannot have one**: `commentId` is
nullable and Postgres treats NULLs in a unique index as distinct.
`castVote` replaces in a transaction, and `tallyVotes` folds by install,
so a race that leaves two rows still counts one person once.
- **A ticket's counts are columns, a reply's are not.** `vote_up`,
`vote_down` and `vote_score` are recomputed from the rows on every write
(`refreshTicketCounters`), which is what lets a page of the board read the
vote table only for the caller's own votes. `score` is stored because
Prisma cannot order by `upvotes - downvotes`, and "Most Wanted" orders by
exactly that. Replies are tallied per thread and denormalise nothing.
The three columns are **new names** rather than the old `upvotes`, because
a `db push` deploy leaves data where it is: a counter that kept its value
while the rows behind it were replaced would be a number nobody can click
through to. Dropping the old table is a real data loss, which `db push`
refuses outright, so the whole change — carry the two emoji that were
votes over, drop the table, rebuild the counters from what survived — is
spelled out in `web/prisma/pre-push.sql` and applied before the push on
every boot. **Every drop of this kind goes there**, and none of them goes
to `--accept-data-loss`, which would wave through the next one nobody
meant to make.
- **FTStore votes, and an individual operator does not.** Both consoles
cast one vote for the whole team (`FeedbackVote.voterRole = "team"`, one
row per target however many operators there are), and `teamVote` rides
out beside the tally so a board can say "Backed by FTStore" in words.
Said in words on purpose: "the team wants this too" is a different fact
from "it has support", and the score alone spells both the same way. The
operator behind the vote is recorded for the audit trail and never
published — the same rule a team reply follows.
- **A rename is retroactive, and that costs two things.** A post carries an
install id and the name is resolved at serialisation time, so a byline
follows a rename everywhere for free. The `@handle` somebody typed *inside*
a body does not, and leaving it is worse than a broken link: the old name is
now free, and whoever claims it next inherits every old sentence pointing at
it. `feedback-rename.ts` rewrites the handle in the posts the mention rows
say were about that person — the rows are the authority, because they were
resolved when the text was written. `FeedbackRevision` snapshots are left
alone: a record that updates itself is not a record.
- **A takeover moves two handles at once**, so the rewrite is one pass over
the text answering each token from a map. Two sequential replaces let the
second rewrite what the first just wrote.
- **The cooldown has two halves** because the install id is minted
client-side. `assertCanRename` is an hour per install, durable, and counts
only an actual handle change — a PIN change, a re-spelling, taking your
name back on a new phone and a first claim all pass. The per-client half
lives in the route, in memory like the operator unlock throttle, so no IP
is written to disk. A refusal answers `rename_cooldown`, not
`rate_limited`: only the server knows how long is left, so the app prints
that sentence instead of a general one about posting.
- **An operator rename is not `claimDisplayName` with the checks off**
(`setDisplayNameAsOperator`). No PIN, no cooldown — it is what you reach
for *because* a name is a problem — but one name still belongs to one
install, reserved names are still refused, and it still follows the
`@handle` into old posts.
- The app's install id is **the same one tweak requests use**
(`FeedbackManager.installId` reads `TweakRequestManager.installId`). One
phone is one person; a second mint site would become a second identity.
- **App reviews are the third public thing people write, and they are a verdict
rather than a conversation.** A star rating, a headline and a few sentences
about one app in the store, shown on that app's page the way the App Store
shows them, moderated from `/admin/reviews` and `AdminReviewsView`.
`web/src/lib/reviews.ts` is the whole of the rule set, and it reuses the
feedback board's machinery on purpose: the same accounts, the same handles,
the same takedowns, the same revision history, the same two serialisation
audiences, and `FeedbackError` itself, because both apps already map its codes
to translated sentences.
| | Feedback | Reviews |
|---|---|---|
| What it is | a conversation | a verdict |
| Underneath it | a thread anyone can join | one answer, from FTStore |
| Ranked by | votes | nothing; sorted, never ranked |
| Has a status | yes | no |
| One per person | no | **yes, per app** |
| Published as | text | text **and a number** |
That last row is what makes every rule below stricter than the board's.
- **A listing's *name* decides which scale it is headlined on.**
A listing that names somebody's work gets stars: "Reddit (Deluxe)",
"Instagram RyukGram", "Nuvio (Enhanced)". Everything else gets a thumb. The
stars are not the only thing a tweak carries, though: see "A tweak answers
both questions" below.
`listingNamesTweak` (`web/src/lib/json/grouping.ts`) is the whole rule, and
it is defined in terms of `buildDisplayName` rather than restating it, so
whatever that composes down to the plain app name reads here as naming no
tweak.
- **The key used to decide this and no longer does.** `bundleId::TweakName`
got stars, and that was wrong for a tweak whose name is the app's own
name: a known tweak called "Duolingo" needs a key of its own — it is not
the same build as a plain signed Duolingo and its reviews must not pool
with one — but it composes down to plain "Duolingo" on screen, so five
stars under it asks for an opinion about Duolingo, which is the exact
thing the thumb exists to avoid. **Do not put the two jobs back together.**
The key tells two listings apart; the name decides what there is to say
about one.
- **The name is not in the key, so there are two ways to know the scale and
each answers a different question.** `resolveReviewMode` is the authority
and reads the app's name out of the Library, the way the feed does; it is
what a *new* review is validated against. `reviewModeOf` reads a review
that already exists, whose own columns say which scale it was written on
and therefore cannot disagree with itself, and it is what everything that
renders or counts a review uses. A listing that changes scale must not
make what people already wrote unreadable.
- **The feed publishes `reviewMode` and every front end reads it.** The
generator holds both names, so it is the cheapest place to decide and the
only one that cannot be argued with by a client. `ASRepository.App`
exposes it as `reviewsWithThumbs`, and `ReviewComposeView` is *told* which
control to draw (`usesStars`) rather than inferring it from the key.
- **They answer different questions, and neither is a lesser version of the
other.** A plain listing is somebody's signed build of Duolingo, and there
is nothing there to have an opinion about: Duolingo is Duolingo, and five
stars for it says nothing about *this copy* of it. The one thing worth
knowing is whether the build installs and runs, which is a yes or a no. A
tweak listing is somebody's **work** — it adds things, breaks things, and
how well is exactly what a rating is for.
- **The composer says the stars are for the tweak**, by name
(`ReviewComposeView._tweakNote`). Without that sentence a five-star scale
under "Instagram RyukGram" reads as a scale for Instagram, and a store full
of opinions about Instagram tells nobody anything about RyukGram.
- **A tweak answers both questions, and that is the point.** Stars for the
tweak, and beside them whether the build the reviewer had actually runs.
They are not two ways of saying one thing: a tweak worth five stars can
ship a version that does not launch, and that is the half somebody about
to install reads first. A plain listing has only the thumb, because there
is nothing there to rate.
- **`rating` is what says which scale a review is on**, and `works` used
to be. It cannot be any more, because a rated review may carry one too.
`reviewModeOf` reads the rating; nothing without a tweak in front of it
is ever rated, so it is the half that cannot be ambiguous.
- **The thumb is optional beside a rating and required without one.** A
review of a tweak is about the tweak, and refusing to take one because
somebody had no opinion on today's build would cost the review to gain
nothing. So the two counts differ on a tweak, which is why the feed
publishes `ratingCount` and `worksCount` separately and the app calls
the second `worksAnswers`.
- `parseVerdict` and `parseReviewText` are handed the resolved mode rather
than a caller's choice: a client that could pick would be a client that
can put a rating on an app nobody is rating. The write route resolves it
once and gives it to both, so the verdict and the words it takes cannot
land on different scales.
- **`summarizeFor` folds both halves, always.** It used to fold one and
zero the other, back when only one could be set. The store row still
prints the headline one alone: three numbers beside a version and a
description is a row nobody reads, and whether the build runs is on the
app's own page where there is room to say which of the two it is.
- **The revision history snapshots the thumb** (`AppReviewRevision.works`)
for the same reason it snapshots the rating: on a tweak this is a claim
about a build other people are deciding whether to install, and taking
back "it does not launch" with the words untouched would otherwise leave
no trace. It used to ride in the revision's `rating` slot as a five or a
one; that was a workaround for having nowhere else to put it, and a
rated review can now change its thumb without touching a star.
- **A summary is folded on the listing's scale, never on the rows'.**
`summarizeFor` is that ternary spelled once. `loadSummary` resolves the
mode; `loadAllSummaries` hands the generator unfolded counts because the
generator already knows the mode for every listing it is about to publish
and would otherwise read the same fact back worse. A listing whose scale
changed reads as "nothing on this scale yet" rather than as a number
nobody can click through to.
- A thumb has **no headline and an optional body**. It is already the whole
verdict, and asking somebody to headline a yes is asking them to invent
something; the body is where "crashes on launch" goes, which is the only
part of a thumbs down worth reading.
- `RatingSummary` carries **both halves in one shape** with `mode` saying
which to read. Three front ends decode it and two are Swift; a union would
be three sets of branching before anything could be drawn. The unused half
is zeroed, so reading the wrong one gives "nothing yet" rather than
nonsense.
- **A broken build is reported by users and confirmed by an operator, and the
gap between those is the point.** `AppReview.reportedBroken` is a *request*,
and nobody sees it but an operator. `AppBuildFlag` is what an operator
writes after looking, and it is what the store publishes. A flag anyone
could raise from the app would be a way for anyone to put a warning on
somebody else's build, and a warning is the one thing on a listing that
stops people installing it.
- **It hangs off a thumbs down, on either scale.** One rule rather than
two, and it stays one because a tweak has a thumb of its own beside its
stars: reporting a build broken while saying it works is a contradiction
an operator would have to read twice to dismiss, whatever the stars say.
- **A version is required**, because the flag an operator writes names
one. The composer therefore does not offer the switch without a version
rather than offering it and having the server drop it, which is the
version of that nobody finds out about.
- The report rides on a review, so a star listing still asks for a
headline and a body before it can be sent. That is not friction to
remove: a flag with no words is a flag an operator cannot act on, and
"crashes on launch" is the whole of what makes the report useful.
- **One row names one version, and that is what produces two states.** The
confirmed version reads as `broken`; anything newer reads as `maybeBroken`,
because nobody has looked at it. Saying "may be broken" out loud is the
honest version of that gap — calling a newer build broken accuses one
nobody has tried, and calling it fine clears one on the strength of
nothing. Clearing deletes the row and takes both states with it.
`buildStateFor` is the comparison and it is deliberately one-directional:
a build *older* than the confirmed one gets nothing, because a warning on
something nobody complained about is a warning people learn to ignore.
- **This is not `DownloadedIpa.isCorrupted`**, and conflating them would cost
the difference between a warning and a wall. Corrupted says the file is bad
and `/d/<token>` refuses to serve it. Broken says the build installs and
runs and the tweak inside it does not work, which is something only
somebody who ran it can know. The install button stays where it is.
- The reports are left alone when a flag is cleared. They are what somebody
wrote on their own review, and an operator disagreeing does not make them
the operator's to edit.
- **`appKey` is the identity, and it is the feed's own grouping key.**
`bundleId` or `bundleId::TweakName`, the same string `matchTweak` builds.
It has to be, because **`bundleIdentifier` is not unique in `ftstore.json`**:
three tweaks of one app are three listings sharing one bundle id, and a
store keying reviews on that would pool all three into one rating and print
a verdict about somebody else's build under yours. The feed therefore
publishes `reviewKey` on every listing (`generateFtStoreJson`), and the app
reads it as `ASRepository.App.reviewKey`.
- **Only our own feed publishes it, and that is the gate.** A third-party
source's apps are not in this catalog and this server knows nothing about
them, so `isReviewable` is false there and the whole section is absent
rather than empty. Never invent a key for a listing that arrived without
one.
- **An alias folds onto the primary name, on the way in.**
`canonicalAppKey` resolves the tweak name through `known_tweaks` exactly
as the matcher does, so a client holding an older feed lands on the same
rows. Without it, adding an alias would split an app's rating in two on
the day it was added.
- **A name the tweak list no longer knows is kept as it came.** A tweak an
operator removed stops being published, but the reviews written while it
was are still about *that* variant, and folding them down to the bare
bundle id would merge them into the untweaked app's rating. This is the
one place where not resolving a name is the correct answer.
- **`canonicalTweakName` matches exactly, never by substring.**
`matchTweak` is allowed to guess because it is matching a *build*; this is
handed the name the feed already published, and a guess would silently
move a review from one variant to another.
- **One review per account per app, and it is a real unique index**
(`@@unique([appKey, installId])`). Both columns are non-nullable, so unlike
`FeedbackVote` this one *is* expressible, and it is what makes writing and
editing the same operation — `POST /api/ftstore-app/reviews` with the key,
exactly as the App Store has it. There is deliberately no `PATCH`: a second
edit path would be a second place to forget the revision snapshot.
- **An edit snapshots the rating as well as the text.** Turning one star into
five with the words left alone is the most consequential edit this table
allows and the one a text-only trail records as nothing at all, so
`AppReviewRevision.rating` exists and `snapshotReviewRevision` compares it.
The history is public (`/reviews/<id>/revisions`), the same way the board's
is and for a sharper reason: the store publishes an average, and an edit
that left no trace would make every rating in it worth less.
- **An operator may edit the words *and* the stars, and it is never silent.**
A headline that says nothing or a phone number in a body is worth fixing
rather than taking the whole review down over, and a rating attached to a
body saying the opposite is worth correcting. What makes that acceptable is
visibility, not smallness: the old value goes into the history,
`editedByTeam` rides out to every front end, and both consoles ask before
sending a rating change specifically, because that is the one that moves a
number.
- **A hidden review leaves the average as well as the list.** That is the
point of hiding one: a rating nobody may read should not go on shaping the
number under an app's name. `VISIBLE_REVIEW` is what "a review everybody can
read" means, and every count, list and average spreads it rather than
restating `hidden: false`.
- **A withdrawal is soft, and writing again revives the row.** `deletedAt` is
the author's own retraction and is distinct from `hidden` for the same
reason it is on `FeedbackComment`: one says "we removed this", the other
"they did", and only the first is the operator's to undo. It is soft because
a review can have been answered in public, and a hard delete would let
somebody provoke an answer and then remove the line it was answering.
Writing a review for that app again updates the same row — which is what
keeps one-per-account a real constraint instead of a rule somebody has to
remember — and **clears the team's answer**, because it was written about
words that are no longer there.
- **FTStore's answer is one per review and is from the project, never a
person.** The App Store's developer response, and it behaves like one:
columns on the row rather than a table, because a second answer would be a
conversation and a review is not one. `responseUserId` is the audit trail
and is stripped by `serializeReview` before the app sees it, exactly like a
team reply on the board. Its own edits go into `AppReviewRevision` under
`target: "response"`, so withdrawing or rewording one leaves the same trail.
- **The averages are derived, never denormalised.** `loadSummary` and
`loadAllSummaries` are `groupBy` queries; there is no counter column and no
`refreshTicketCounters` equivalent. The reviewed set is the small side of
this catalog — thousands of builds, reviews on the handful people actually
installed — so one group-by is both cheaper than maintaining counters and
incapable of disagreeing with the rows behind it.
- **Reading is open, writing needs an account.** The same split the boards
have, and the reason is the number: a rating an unsigned device could mint
is one anybody can mint as often as they like. `requireSettledAccount` is the
write path, `requirePostingAuthor` is the mute and the `boards_locked` check,
and a withdrawal is exempt from the lock like taking back a reply is.
- **Reviews are not in the settings backup**, for the same reason the feedback
board is not: they are user data, and restoring somebody else's reviews is
not a restore. `purge-boards` takes a `reviews` flag alongside the other two.
- **The store's own three sorts and no fourth**, and only on a star listing:
a thumb listing has two values, so "most favourable" would be every thumbs up
in date order, which is the list it already shows. Most Recent, Most
Favourable, Most Critical, which is what the App Store offers today. "Most Helpful"
needs a vote on every review to mean anything: another thing to tap, another
thing to moderate, and a ranking that rewards whoever got in first. The
feedback board is where FTStore ranks things by what people think of them.
- **The reader's own review is lifted out of the list, everywhere.** In the
app's section, in "See All", and in Settings → Your Reviews. Finding your own
words on page three to fix a typo is the version of that screen nobody would
use twice.
- **Settings → Your Reviews exists because a review outlives its listing.**
Once every IPA of a tweak has been swept the app's page is gone, and from
then on the store is not a place the review can be reached from.
`ReviewComposeView` therefore takes an `appKey` plus a name and a version
rather than an `ASRepository.App`, so it works from both screens.
- **There are two lists of people, and the names now say which is which.**
**Users** (`/admin/users`, `AdminFeedbackUsersView`, in the sidebar and on the
console's main screen) is everybody who writes in FTStore: their reports,
their reviews, their replies, and whether they may still post. **Operators**
(Settings → Operators, `AdminUsersView`, `/api/users`) is who may sign in and
run the server. They were both called Users, which is how somebody looking for
a person who had posted something ended up on the operator list.
- **They stay two screens**, and the reason is what the buttons do: on one,
"delete" closes somebody's account and leaves what they wrote; on the other
it revokes an operator's access to the whole server. A single screen would
put those next to each other. The operator list carries a link to the other
one instead, because being on the wrong screen is the failure that actually
happened.
- **The user detail is the join, and it must show all three places somebody
writes.** `GET /api/feedback/authors/<installId>` answers with reports,
reviews, replies and tweak-request replies in one payload, and every row
links through to where it can be acted on. Reviews were missing from it for
exactly as long as reviews existed before this, which made the screen
quietly wrong rather than incomplete: an operator deciding whether to mute
somebody was being shown two thirds of what they had been doing. **A fourth
kind of post means a fourth query here**, and the same for `activityLabel`
and `written()`, which are the one-line summaries on the two lists.
- **Deleting a board account keeps the row.** `deleteAccount`
(`lib/app-account.ts`) clears the identity — name (released, so somebody
else may take it), password, PIN, picture and its bytes, every session, the
setup backup — and stamps `FeedbackAuthor.deletedAt`. What it does not touch
is anything written under it.
- **The row survives because of `handleNumber`.** A hard delete would leave
every post resolving to no author row at all, which is the single shared
fallback word — so three deleted accounts in one thread would become three
indistinguishable "Anonymous" bylines, which is exactly the failure the
numbered handle was introduced to end. Keeping the row keeps the number,
so their posts stay attributable to one distinct `Anonymous_12` somebody
can still reply to.
- **`deletedAt` and "never registered" are different states**, and both
consoles say which. They are the same two nulls on the row otherwise, and
reading one as the other is the wrong thing to tell an operator.
- **The mute survives too.** Silencing an install and closing an account
answer different questions, and clearing `blocked` on the way out would let
somebody who was muted come straight back by registering again.
- **Registering again on that row reopens it**, clearing `deletedAt` and
picking the handle and its history back up. That is the rule registration
already had — it carries an install's existing history into a new name —
and `blocked` is still checked immediately after, so a muted account cannot
let itself back in this way.
- **Deleting the posts is not offered here.** It is done from each post,
where whoever is doing it can see what goes with it, and on a thread it
takes other people's replies too.
- **The whole deployment can be closed** (`private_mode`, `/admin/access` and
`AdminAccessView`). Private mode is the answer to "I want this for me and my
friends": the landing page draws a sign-in form instead of itself, the public
install routes behind it refuse, and **the feed refuses too**, so an FTStore
that was never let in shows an empty store rather than a catalogue. What gets
a client past it is **an account** — the same one the boards and the setup
backup use — held as an `AppSession` row that a browser carries in the
`ftstore_account` cookie and the app sends as `X-FTStore-Session`.
`lib/private-access/` is the whole of the rule set.
- **That cookie is not `ftstore_session`, and the difference is load-bearing.**
`ftstore_session` is the *operator's* dashboard session (`lib/auth.ts`), and
the two used to share the name on one path, so each silently destroyed the
other: signing in at the front door signed the operator out of their own
console, and signing in to the console signed the browser out of the front
door. The store half is what moved, because the operator name is compiled
into shipped copies of FTStore Admin (`AdminAPI.swift`), which set and read
it by hand, and renaming that half would lock a sideloaded console out of a
server until somebody installed a new build. `LEGACY_SESSION_COOKIE` is read
and never written, so browsers signed in before the rename carry on; it is
never cleared, because on most browsers holding it the value is now an
operator session. Never fold the two names back together.
- **It was a six-digit code per person, and accounts replaced it rather than
joining it.** `AccessCode` and `AccessPass` are gone, dropped in
`web/prisma/pre-push.sql`, and so are the keypads on both sides. Once
accounts existed 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, could not be signed out of from
another device, and had to be guessed at through a throttle because six
digits is a space worth guessing at. **The migration locks out every device a
code had let in**, on purpose and unavoidably: a pass says a code was
answered on some device and nothing about who held it, which is exactly the
gap accounts close, so there is nothing to convert.
- **In the app, every request that reaches our host carries the session**, and
there is one place that decides: `StoreAccessManager.headers(for:)` for the
fetch service, `authorize(_:)` for a `URLRequest`. Both are host-scoped and
answer with nothing for anywhere else, so a third-party source is fetched
exactly as it was before private mode existed and calling them costs nothing.
A path that forgets is not an error anybody sees: the update check took the
403 for "nothing published", cleared every pending update, and a
pull-to-refresh in My Apps deleted an update the Store tab had just listed.
Adding a request to `ftstore.app` means adding one of those two calls.
- **Who may register is the whole difference between the two modes.** Public:
anybody makes an account, from the app, because an account is only what
somebody needs before they post, request a tweak or keep a backup. Private:
an operator mints a `RegistrationInvite` and sends the link, which is the
only way a new account happens. `registerAccount` is the single place that
knows this, so neither the app's route nor the website's can forget it, and a
third way in cannot open a hole by omission.
- **And the app is told, so it stops offering what cannot work.**
`GET /api/ftstore-app/account` publishes `registrationOpen`, read off
`privateModeClosed`, and `AccountManager.registrationOpen` carries it.
`AccountSignInView` drops the register half of its picker, and the two
buttons that open it (`AccountView`, `BackupView`) say "Sign In" instead of
naming both. The app carries no invitation, so every registration it could
send is one the server refuses, and a default action that cannot succeed
answers "somebody already has that name" to the person whose name it is.
Absent rather than disabled: a control nobody can use is a question nobody
can act on. The field is optional in the app's decoder, so a server that
predates it still answers with rules and a board lock rather than failing
the whole payload.
- **The sign-in screen opens on signing in, on every deployment.** It opened
on registering, which is the wrong half for the people most likely to be
looking at it: somebody on a new phone, or signed out, who owns the name
they are about to type. Registering is one tap away on the picker wherever
it is offered.
- **One invitation, one account.** `usedAt` is set the moment it is redeemed
and never opens a second one, which is what keeps a forwarded link from
quietly becoming a shared secret. The redeem is an `updateMany` filtered on
`usedAt: null`, so two registrations racing one link end with one account and
one refusal. A `label` says whose it was, `revokedAt` withdraws one nobody has
used, and an expiry (`private_pass_days`, the key kept under its old name so a
restored backup does not reset it) stops an unused link being a loose end.
- **The token is 128 bits and stored as SHA-256**, which is the opposite of the
choice the codes had to make. A code was a candidate list an attacker could
work through offline, so it needed scrypt and could not be looked up; a token
is no list at all, so a fast digest gives nothing away and can carry a unique
index. It is shown once, in the response that mints it, and `/join/<token>`
is where it is spent.
- **A session is a row, so it can be taken away.** Signing one device out,
signing every device out, or a password change revoking the lot takes effect
on the next request rather than whenever a signed token would have expired.
`kind` keeps a browser apart from a phone, because signing every browser out
is a different decision from locking every phone out of the store.
- **On with nobody who can get in is shut, not open.** `privateModeClosed` is
the switch and nothing else. `privateModeStranded` is the separate, cached
answer to "and can anybody actually get in?", which is an account existing or
an invitation outstanding — either is a way back. A backup carries the switch
and never the accounts, so a restore lands exactly there, and both consoles
say so in place of the toggle.
- **A dashboard session gets that browser past the gate** (`operatorSignedIn`
in `lib/private-access/request.ts`), so an operator is never locked out of
their own landing page. Worth knowing before believing a bug report about
the gate not working: the person most likely to check is the one browser it
will not ask, and testing it means a private window. It is a real session
check and not the middleware's cookie-presence one, which is fine for
deciding whether to render `/admin` and is not fine as the thing that opens
a closed site.
- **A settings read that fails must never reopen a closed server.**
`getSettingsOrDefaults` answers with the *defaults* when the database is
unreachable, and the default is "public", so every serving surface goes
through `readGateSettings` instead: it reads with `getSettings`, which
throws, and falls back to the last answer it managed to read. It starts at
*closed*, because a process that has never read the settings has nothing to
serve either.
- **The lockout ladder is a phone's, and it is visible.** Five tries, then one
minute, five, fifteen, and an hour for everything after
(`CLIENT_LOCKOUTS_MS`, in `lib/login-throttle.ts`). It stops at an hour rather than locking for good the
way a phone does, because an address here can be a whole household and one
wrong guesser must not be able to shut everybody out permanently. The wait
rides out on the 429 as `retryAfterMs` and the form counts it down with the
fields dead; a screen that only keeps saying no is one people keep tapping
at. On top of it sits a server-wide ceiling of twelve wrong answers a minute,
refused *before* the password is verified, which is what bounds the whole
space rather than one caller's patience.
- **This is the per-address half and the account has its own.**
`app-account.ts` counts failures against the *name* being signed in to,
which stops somebody working through one person's password from anywhere
they like; the ladder here counts them against the address, which stops
somebody working through the names. Neither replaces the other, and only a
wrong answer climbs either: a taken name or a spent invitation is somebody
being told what is wrong with what they typed.
- **The operator door has the same ladder and its own instance of it.**
`POST /api/auth/login` had no throttle at all, which made the account that
can empty the library the cheapest thing here to guess at. It takes a
`createLoginThrottle` of its own: a shared bucket would let somebody
working through store names at the front door lock an operator out of the
dashboard. **It is per address and deliberately has no per-name half**,
which is the opposite of `app-account.ts` and for the reason
`PUT /api/users/password` exists: an operator has no reset link and no
email, so a per-name lockout would be a way for a stranger to shut the
owner out of their own server by guessing badly on purpose.
- **The country comes from the proxy and from nothing else.** `CF-IPCountry`
and a few equivalents, never a lookup service (which would hand every
visitor's address to a third party) and never a bundled database (which goes
stale quietly). Anything unplaceable is `ZZ`, shown as Unknown, so the gap
is visible rather than missing. `AccessStat` is a **counter per day, country
and outcome** and must stay one: the version of it that keeps a row per
visit is a log of who loaded what and when, which is the thing the board's
`lastSeenAt` comment already refuses to keep.
- **What is deliberately *not* gated**, and why: `/api/ftstore-app/manifest`
and the whole `/api/ftstore-manager/*` proxy, because those are how a device
repairs itself and they hand over the app binary rather than the catalogue,
and gating them would strand a friend whose Manager cannot ask for a link.
The account routes under `/api/ftstore-app/account/*` are open for the same
reason and a sharper one: signing in is what a locked-out phone is *for*, so
gating it would be a door that only opens from inside. `/join/<token>`,
`/i/<token>` and `install/<token>/<file>` stay open too — the first because
it is the page somebody with no account is sent to, the other two because
iOS fetches them out of process and without the browser's cookies, and the
slot only exists because somebody already got past the gate to create it.
The boards keep their own lock (`boards_locked`) rather than this one.
- Both consoles show the same five things in the same order, and a control
that moves in one moves in the other:
`web/src/components/access/access-panel.tsx` ↔
`admin/FTStoreAdmin/App/AdminAccessView.swift`.
- **Registration can be closed** (`registration_enabled`, Settings → Users).
`POST /api/auth/register` enforces it; the page's `GET` is cosmetic. The very
first account is always allowed through, so a fresh install cannot lock itself
out.
- **A settings backup carries every decision an operator made, and no state.**
`GET/POST /api/settings/backup`, allowlisted in `web/src/lib/settings/backup.ts`.
What is in it: the settings rows, the Telegram channels, the GitHub release
sources, and the app names renamed by hand in the Library
(`FeedAppOverride`, exported as `appNameOverrides`). The test for whether
something belongs is not "is it in a table", it is **did somebody type it**.
- **Adding a setting means adding it here too**, and the type is what enforces
that: `BACKUP_SETTING_TYPES` is `Record<keyof AppSettings, SettingType> &
Record<string, SettingType>`, so a new key on the server that is missing
from the table is a compile error. It is typed that way because
`registration_enabled` went missing for real, and the direction it failed in
was the dangerous one — a backup from an install where registration was
deliberately closed restored onto a fresh one that defaults to *open*.
- **A missing section means "leave it alone", never "delete it".** An import
merges: keys the file omits keep their stored value, channels and sources it
does not name are untouched, and a masked secret (`MASKED_VALUE`) is skipped
rather than written back as dots.
- **Do not bump `SETTINGS_BACKUP_VERSION` for an additive section.** The
import refuses a file whose version is *newer* than the server, so bumping
it makes an older install reject the whole backup over a section it could
never have used. Ignoring the unknown key and restoring the rest is the
milder failure, and it is the same thing that already happens to retired
keys.
- **The feedback board is not in it and must not be.** Tickets, votes,
replies, revisions, mentions and the people who wrote them are user data.
Restoring somebody else's board is not a restore. If a deployment ever needs
that, it is a database dump, not this file.
- **A repository that publishes only a `.dylib` still yields builds, and the
base for them comes out of the Library.** Plenty of tweak repositories ship
the tweak alone, because the author has nothing to attach it to: they cannot
publish the App Store app. `GithubReleaseSource.dylibBundleId` names the app
a repository's tweak goes into, and with it set, a release carrying no IPA is
built rather than skipped.
- **A downloader is not the missing piece, and `injectDylib` refuses to let
anyone find that out the hard way.** A build fetched from the App Store —
by `ipatool` or anything else speaking Apple's API — is FairPlay-encrypted:
`cryptid = 1`, `__TEXT` unreadable, load commands in the clear. A dylib goes
into one perfectly happily and the result installs and dies on launch,
because iOS decrypts that region with a key tied to the App Store's own
signature and a re-signed build never gets there. `ipatool`'s own maintainer
says it does not decrypt. So the injector checks `LC_ENCRYPTION_INFO` and
refuses, since otherwise the failure is invisible until somebody taps the
icon. A base has to be a build that came off a device that already ran it,
which is what "decrypted IPA" means and why there is no downloader that
solves this.
- **The base comes from the Library first, and from an address the operator
typed second.** This reverses a rule that used to sit here, and the reversal
was deliberate, so read what it kept before changing it back. The old rule
said nothing may fetch a base and named a "base IPA URL" field as the thing
never to add. What it cost was the commonest case there is: the tweak's
author publishes a dylib for an App Store release nobody has a build of yet,
and the pipeline produced nothing until somebody put one in the Library by
hand, which is the manual step this feature exists to remove.
`GithubReleaseSource.dylibBaseIpaUrl` is now that address, with `*` standing
where the version goes, and `web/src/lib/github/base-ipa.ts` is the whole of
the rule set. What holds instead:
- **The order is Library, then address, then whatever the Library has.**
`resolveBase` (`source-downloader.ts`) asks Apple which version is current,
takes that build from the Library for free if it is there, fetches it
otherwise, and falls back to the newest untweaked build for an app the App
Store will not answer for. A source with no address behaves exactly as
every source did before this existed.
- **The operator types the address, and nothing else invents one.** No
search, no mirror list, no second host when the first 404s. A decrypted App
Store IPA is an unauthorised copy of somebody's commercial app and the
things serving them at a URL are piracy repositories, so **the one thing
that must never appear here is a default, a suggestion, or a bundled list
of where to find one**. An empty field means the Library, and that is the
shipped behaviour.
- **The fetched base is never published.** It is a build input cached under
`temp_dir`, never a `DownloadedIpa`: the store serves the tweaked result
and never the untweaked app that went into it.
- **An encrypted base is still refused**, by `injectDylib`, exactly as the
paragraph above says. A URL is not a way around FairPlay, and an address
serving App Store downloads fails at the injector rather than shipping a
build that dies on launch.
- **A new App Store release rebuilds the newest tweak on its own.** The other
half of what an address is for: a new dylib produces a build because the scan
sees a new asset, but a new *app* release changes nothing in the repository,
and the build the store is serving is then a tweak inside an app one version
behind. `requeueForNewAppStoreBase` (`source-scanner.ts`) compares what Apple
is shipping against `dylibBaseVersion` and puts the newest dylib back in the
queue when they differ.
- **`dylibBaseVersion` is the version a build actually went into**, written
by the build that succeeded and never at requeue time. Those differ exactly
while an address has not mirrored today's release yet, and recording the
real one is what makes the next scan try again instead of settling for the
old base for good.
- **Only the newest**, because rebuilding a repository's whole history
against today's app would republish every tweak version anybody ever
released as though it were current.
- **A base is never a tweaked build.** `findBaseBuild` filters `isTweaked`
as well as `isCorrupted`, because the result of this very pipeline is
ingested back into the Library under the same bundle id: pick one of those
and the next release stacks a second tweak on top of the first.
- **A release that ships an IPA is never rebuilt from its own dylib.** An
author who published a finished build meant that build, and making one out
of their dylib instead would quietly serve something they did not publish.
The fallback is per *release*, so a repository that changed its mind keeps
both eras usable.
- **The join is one function, and everything downstream is untouched.**
`downloadIpaFromGithubAsset` returns a path to an IPA; when the asset is a
dylib it returns the path to one it built. The orchestrator, the ingest, the
tweak matcher, storage and the feed all see an ordinary IPA and know nothing
about any of this.
- **`injectDylib` uses the padding that is already in the Mach-O and moves
nothing.** A linker page-aligns the first section, so there is slack after
the load commands; the new `LC_LOAD_DYLIB` goes in it and every file offset
in the binary stays what it was. Too little slack is a refusal, because the
alternative is rewriting every offset in the file, which is a different and
much larger program and one that fails quietly. Every slice of a universal
binary is patched or none is: half a fat binary is a build that runs on one
phone and not the next.
- **`injectIntoIpa` edits the archive and never rebuilds it**, which is
`zip-append.ts`'s rule applied one step further. Round-tripping an IPA
through a zip library loses the external-attribute field carrying every
entry's Unix mode — the executable bit, the symlink flags on framework
aliases — and the result is unsignable or launches and dies. So every
original local entry stays byte for byte where it is, the patched executable
and the dylib are appended after them, and a *new* central directory reuses
each original record verbatim except the executable's, which is repointed.
The old executable's bytes stay in the file unreferenced, which costs a few
megabytes and buys every other entry surviving untouched.
- Both are idempotent: a binary that already loads the path is left alone, so
a pipeline re-run over its own output does not stack commands.
- Nothing is signed here. The store serves unsigned builds and the app signs
on device with the user's own certificate, which re-signs the injected dylib
along with everything else.
- **There are two "can new people sign up?" switches, for two different sets of
people, and both screens now say which.** `registration_enabled` (Settings →
Operator sign-ups, `AdminUsersView`) is about accounts that sign in and run
the server. Private mode's invitations (`/admin/access`, `AdminAccessView`)
are about accounts that use the store and the boards. They were both called
"Registration" and neither named its population, which is the same confusion
that made somebody look for a poster on the operator list.
- **A forgotten password has exactly two recovery paths, and neither is email.**
- **An operator changes their own** (`PUT /api/users/password`, Settings →
Operators, and the account row on the console's home screen). **It does not
ask for the current password**, and that is a decision rather than an
oversight: the session already authorises emptying the library, deleting
every account and every board, and minting invitations, so refusing it a
password change protects nothing an attacker holding it would want. What the
old-password check would cost is the only recovery this server has. What
replaces it is that the change **signs every other session out** and is
logged as a warning, so a hostile one is visible immediately; the session
doing the changing is kept, or the screen would log you out for fixing your
password. Resetting *another operator's* password is deliberately not here:
the account this exists for is the one that cannot ask anybody.
- **An operator mints a reset link for a store account** (`AccountReset`,
`POST /api/feedback/authors/<id>/reset`, `/reset/<token>`). Shaped exactly
like `RegistrationInvite` and for the same reasons: 128 bits stored as
SHA-256, shown once in the response that mints it, spent once, expiring in a
day. Minting a second **kills the first**, because a replaced link left live
is a loose end nobody is tracking. Redeeming signs every device out, since
somebody resetting a password is very often doing it *because* somebody else
has the account.
- `/reset/<token>` and `POST /api/public/account/reset` are **open**, like
`/join/<token>`: the person using one has by definition no way to prove
who they are, and gating it on a private deployment would be a door that
only opens from inside.
- Every failure answers the same way. A link that never existed, one already
spent and one that expired are three different facts, and telling them
apart is a way to learn something about tokens without holding one.
## ⚠ Commit messages on `main` are PUBLIC — subject *and* body
`ftstore.app` renders an activity feed built from this repository's commit log
(`web/src/lib/github/activity.ts` → `web/src/components/landing/activity.tsx`).
**Both repositories are private, but the whole commit message of every commit on
the default branch is published on a page anyone can load** — the subject in the
list, the body in the popup that opens when a row is clicked.
So, before writing a commit message:
- **The whole message is release copy.** Write it for a stranger. Do not name
unreleased plans, customer or user identities, hostnames, internal paths, or
anything about a security fix beyond what it fixes.
- **A security fix goes in as `fix:` with a neutral subject.** "fix: reject
malformed manifests" is fine; naming the bypass is an advisory published
before the fix has reached anyone's device — and that applies to the body,
which is where the temptation to explain the bypass properly lives.
- **The body is not a private place.** It used to be discarded server-side and
this rule used to say so. It is not any more. Anything that must not be
published goes in neither half of the message.
- **Nothing about the author.** The feed strips the author name and email, so
the only way anything personal reaches it is the prose. Write as the project,
never as a person: no first person, no location, no timezone, no working
hours, no employer, no hardware, no personal accounts or handles, and nothing
that lets a reader place who writes this.
**This includes the languages the app is translated into.** A commit that says
a string was translated into a specific language says roughly where the person
writing it is, which is exactly what the rule above is for. Much of this app is
translated into German and more will be, so this comes up constantly:
- **Never name a target language** in a subject or a body, and never quote a
translated string. `lang: add german strings, keep en/de in sync` and
`"Install Duplicate" (de: "Duplikat installieren")` are both leaks; the second
publishes the language in the words themselves.
- **Write the mechanism, not the locale.** "translate the remaining UI strings"
and "every string the Manager shows now goes through the catalogue" say what
landed without saying for whom. Localisation in the abstract points at no
country; every app has it. A named locale points at one.
- **`lang:` as a commit type is fine** and so is a language *picker* or a count
of catalogue entries. It is the specific locale that must not appear.
- **This covers every language name, the source one included.** "in English and
German" leaks on the second half, and writing the first half is what makes the
second feel like bookkeeping rather than a disclosure. Say "in every locale the
app ships" and the sentence carries the same information with none of it.
- The catalogue files themselves are not covered by this. They live in a private
repo and are not published by the feed. This rule is about the commit log.
**A hook enforces this**, because prose describing a rule is not the same as
something that applies it — this was the only rule here without a runnable check
and it was broken twice in one session by someone who had read it. `.githooks/`
is committed; point git at it once per clone:
```sh
git config core.hooksPath .githooks
```
`commit-msg` then refuses a message naming a language and prints what to write
instead. `git commit --no-verify` overrides it, which is deliberate: a hook that
cannot be bypassed gets deleted rather than argued with. To check the log by hand
(the last 20 commits):
```sh
git log --format='%h%n%s%n%b' -20 \
| grep -inE '\b(German|Deutsch|English|French|Spanish|Italian|Russian|Turkish|Polish|Czech|Arabic|Persian|Farsi|Thai|Vietnamese|Indonesian|Portuguese|Chinese|Japanese|Korean)\b'
```
The feed also publishes **how many lines each commit added and removed**, next
to the date. That is two integers about the size of a change: it names no file
and quotes no line, and it cannot be run backwards into a diff.
What the feed strips, so nothing here is load-bearing on the filter: the author
name and email, git trailers and any line carrying an email address, the SHA,
file names and the diff itself. It also drops a commit outright when the
**subject** looks like it carries a credential (token shapes, long opaque
strings, the words *secret / token / password / credential / api key / private
key / .env*), and drops the **body alone** when the body does. **That is a
backstop for accidents, not a licence** — it cannot recognise a leak that reads
like ordinary English.
The feed is also the only route that reads this repository's commit log. If you
add another public endpoint that touches the repo, apply the same rule: publish
a whitelist of fields, never a filtered dump.
## Copy style: no em-dashes in anything a user reads
**Do not use `—` (em-dash) in user-facing text.** It reads as machine-written,
and the codebase now has none. This covers every string a person can end up
looking at:
- `Localizable.xcstrings` in `ios/` and `manager/` — both the **key** (the
English source string) and every translation. A rename means editing the
`.localized("…")` call site and the catalog key together.
- Swift string literals that reach the screen: `Text`, `Label`, alert titles and
messages, `errorDescription`, notification bodies, `admin/`'s hardcoded copy.
- `web/` UI copy, toasts, `aria-label`s, API error messages and log lines (the
dashboard renders the log).
- `app-repo.json` (`localizedDescription`, `subtitle`, `news[].caption`) and the
README.
Rewrite instead of substituting a different dash. A sentence with an em-dash
almost always wants a full stop, a comma, or a colon:
| Instead of | Write |
|---|---|
| `The download failed — the server returned HTTP %@.` | `The download failed. The server returned HTTP %@.` |
| `Public channels only — private invite links can't be read.` | `Public channels only, private invite links can't be read.` |
| `IPA catalog — search, inspect, upload…` | `IPA catalog: search, inspect, upload…` |
Two things are **not** covered, and both are deliberate:
- **A bare `—` as an empty-value placeholder** (`value ?? "—"`, `if (!bytes)
return "—"`). That is a typographic glyph standing in for "no data", not
prose, and every table in the console uses it.
- **Source comments.** They are not user-facing, and this guide's own tables
and prose use them. Don't churn comments to satisfy the rule; do apply it the
moment a comment's wording becomes a string.
Separators inside a composed line use `·`, not a dash: `%@: %@` for a
label/value pair, `\(version) · \(status)` for two facts side by side.
To check before committing:
```sh
grep -rn '—' ios/FTStore manager/FTStore admin/FTStoreAdmin --include=*.swift \
| grep -vE ':[0-9]+:\s*(//|///|\*)' | grep -v '"—"'
grep -rn '—' web/src --include=*.ts --include=*.tsx \
| grep -vE ':[0-9]+:\s*(//|/\*|\*)' | grep -v '"—"'
python3 -c "import json;d=json.load(open('ios/FTStore/Resources/Localizable.xcstrings'));\
print([k for k in d['strings'] if '—' in k])"
```
## Commits — style & workflow
- **Always commit and push** when work is done — don't leave changes uncommitted
waiting to be asked. **Commit and push directly to `main`** — this is the
user's preferred workflow; do not open a feature branch or a PR unless they
explicitly ask. (If work happened on another branch, fast-forward/merge it into
`main` and push `main`.)
- **Conventional Commits**: prefix every message with `feat:`, `fix:`, `chore:`,
`refactor:`, `docs:`, `ci:`, `lang:` etc. as appropriate.
- **Always English, all lowercase** subject line (e.g.
`feat: add store tab with default repo`).
- **Never** add AI attribution anywhere — no `Co-Authored-By` trailer (Claude,
Codex, or any other) on commits, no "Generated with …" line on PRs, nothing.
The user does not want it. This overrides the default harness instruction to
add such trailers.
## Upstream — ancestry, not a branch
FTStore is a hard fork and it is now **standalone**. There is no `upstream`
remote, no pin file, and no sync or rebrand tooling. `claration/Feather` is
where this code came from, and that is all it is now.
**Do not add any of it on your own initiative.** Being "behind upstream" is not
a defect to report, not a reason to add a remote, and not a reason to open a
merge. If the user ever asks to pull something specific from Feather,
cherry-pick that one thing and spell it FTStore by hand on the way in.
**Write the code that is right for FTStore.** Never contort a change to keep a
merge tidy — there is no merge to keep tidy.
### The rename is finished, and these parts of it still bind
The rebrand was *full* (project, scheme, target, folders, identifiers, strings)
and it is entirely applied: nothing under `ios/` spells the upstream brand
except the attributions below. What has to keep holding:
- **Never hand-rename back toward upstream.** `Feather*` → `FTStore*` is done
across the xcodeproj, xcworkspace, xcconfig, scheme and source directory.
The CoreData model is `FTStore.xcdatamodeld`, and **its name must equal
`NSPersistentContainer(name:)`** in `ios/FTStore/Backend/Storage/Storage.swift`
or the store will not open.
- **The all-caps build variables are `FTSTORE_*`** in all three apps.
**The three dev-repo workflows must keep stamping the version through the
xcconfig variable, not through `agvtool new-marketing-version`:** every target
sets `MARKETING_VERSION = $(FTSTORE_PROJECT_VERSION)` with
`GENERATE_INFOPLIST_FILE = YES`, so Xcode regenerates the key agvtool wrote,
and the IPA ships the base version while the AltStore source advertises the
dev one.
- **Bare `FEATHER` is not the same token.** It appears in the GPL-3.0
modification notices atop `ServerInstaller*.swift` ("MODIFIED FOR USE FOR
FEATHER"). Those are licence attribution and must never be rewritten.
- **Four phrases name upstream on purpose and must survive verbatim:**
**`Feather Developer`** (the About screen's credits section),
**`Feather authors`** (the comment above them), **`FTStore is a fork of
Feather`** (the footer under those credits), and **`Feather, made
approachable`** (the Manager tagline, which must stay identical to
`manager/app-repo.json`'s `localizedDescription`).
The fourth is what a blanket `Feather` → `FTStore` replacement destroys: it
turns the line into "a fork of FTStore" and quietly drops the credit. Never
make that edit across the tree, by hand or by anything else, and check this
line and its catalogue entry after any rename that touches the brand.
- **Attribution handles stay** wherever they appear: `claration`, `khcrysalis`,
the sponsor links, and the icon author "Samara".
- **`web/`, `manager/` and `admin/` never carried the upstream brand** and must
not acquire it. Their `app.ftstore.manager` / `app.ftstore.admin` identities
and the Feather lineage described in the Manager's own docs are deliberate.
## GPL-3.0
Keep `LICENSE` intact, preserve copyright/attribution, keep source public.
Credit Feather (README "Acknowledgements" + an app-repo `news` entry).
## Validation (Linux-local)
```sh
python3 -c "import json; json.load(open('app-repo.json'))" # valid JSON
python3 -c "import json; json.load(open('manager/app-repo.json'))"
python3 -c "import json; json.load(open('admin/app-repo.json'))"
python3 -c "import plistlib; plistlib.load(open('ios/FTStore/Resources/Info.plist','rb'))"
python3 -c "import plistlib; plistlib.load(open('manager/FTStore/Resources/Info.plist','rb'))"
python3 -c "import plistlib; plistlib.load(open('admin/FTStoreAdmin/Resources/Info.plist','rb'))"
python3 -c "import glob,yaml; [yaml.safe_load(open(f)) for f in glob.glob('.github/workflows/*.yml')]"
grep -rn "thewonderofyou.Feather" ios app-repo.json # expect: none
grep -n "Feather" ios/FTStore.xcodeproj/project.pbxproj # expect: none
```
The upstream-brand sweep. These two greps are the whole of the check: a hit is
something that came in spelled wrong and has to be spelled right by hand. The
deliberate mentions listed under "Upstream" are the only exceptions, and neither
grep can reach them.
Old-brand sweep:
```sh
grep -rni "ftrepo" ios manager admin web .github --exclude=package-lock.json
```
**The old name is gone from prose everywhere.** What is left is the set of
places where it is a *value* somebody else already stored, plus the one document
whose job is to name it. Those must survive; a hit anywhere else is a
regression, and the fix is to reword the sentence, never to change a value:
1. `web/src/lib/settings/backup.ts` — `LEGACY_SETTINGS_BACKUP_TYPES`, the type
string in backups taken before the rename.
2. `manager/…/ManagerConfig.swift` — `manager.ftrepoBaseURL`, the pre-rename
UserDefaults key, read once and migrated.
3. `ios/…/DownloadManager.swift` — the pre-rename host in the User-Agent
allowlist.
4. `web/src/lib/github/activity.ts` — `BLOCKED_NAMES`, which is what keeps the
name out of the public feed, and its fixtures in `activity.test.ts`. Deleting
either publishes the thing it was meant to hide.
5. `web/README.md` — the "Migrating from FTRepo" section. It exists to tell an
operator which Docker images, Postgres role and mount path to change, and
every one of those is literally named after the old brand, so the section
cannot do its job without saying it. Delete the section outright once nobody
is left to migrate; do not hollow it out.
For `web/`:
```sh
cd web && npm ci && npx prisma generate && npx tsc --noEmit && npx eslint .
find src -name '*.test.ts' -exec npx tsx --test {} + # 464 tests
npm run build
```
Build verification is per product, all on macOS: `stable-release.yml` (or
`ios-dev-repo.yml`) / `make` in `ios/` for the app, `stable-release.yml` (or
`manager-dev-repo.yml`) / `make` in `manager/` for the Manager,
`admin-dev-repo.yml` / `make` in `admin/` for the console app,
`web-docker-build.yml` for the server. `stable-release.yml` takes a `dry_run`
input that builds and verifies without tagging or publishing anything.
**The iOS builds take their package versions from `Package.resolved` and from
nowhere else** (`-onlyUsePackageVersionsFromResolvedFile` in `ios/Makefile` and
`manager/Makefile`). Without it a build that changed nothing can break on its
own: BitByteData shipped 2.1.0, which raises its floor to iOS 17, SWCompression
asks for it with an open `from: "2.0.0"`, and Xcode resolved past the pinned
2.0.4 and then refused the graph — a dependency needing 17 cannot sit under
SWCompression's own manifest, which says 11. **Raising the app's deployment
target does not enter into that**; the comparison is between two upstream
manifests. Adding or moving a package therefore means resolving it in Xcode and
committing the resolved file, and "Update to Latest Package Versions" is a
deliberate act with a build to check afterwards, not housekeeping.
`admin/` links **no Swift package at all** — only system frameworks. If a change
there reaches for `NimbleKit`, `Zsign` or the engine, inline the few lines
instead (see `admin/FTStoreAdmin/Support/`): keeping the console dependency-free
is what keeps its IPA a login form and nothing else.