# Porting the first Invision application

2026-08-10. `eddisposable` — the Disposable Email Blocker — is the first of the
43 to come across, and it is written up because the next 42 inherit whatever
this one establishes.

Repository: `github.com/ernestdefoe/convoro-disposable`.

---

## Why this one first

It is the smallest app with a real job: 48 files, four settings, two tables,
one outbound call, one hook into registration. Nothing in Convoro overlaps it,
so nothing had to be decided about what to keep. That made it a pattern-setter
rather than a negotiation.

## What a port turns out to need from core

**One seam, and it is the interesting part of the exercise.**
`RegistrationChecks` — a registry of "reasons an account may not be created",
asked before the account exists.

🚨 It is a registry rather than an event, and that is deliberate.
`docs/extensions/events.md` says domain events are past tense and not
cancellable, because an extension silently swallowing a post is the worst bug
to debug. "May this happen" is a different question from "this happened" and
needs a different mechanism: one that returns an answer, in a defined order,
and that names who refused when it goes wrong.

A check that throws is **ignored, not fatal**. These run inside somebody's
registration, and a fault in a spam filter must never be the reason a real
person cannot join — the same fail-open rule the original held itself to, and
the same one the template hook system uses for a broken extension.

Everything else the app needed already existed: the queue for the outbound
call, `admin.nav` for its sidebar row, `Lang/` for its strings, the extension
installer for the whole package.

## What the port changed on purpose

| | |
|---|---|
| **Refuse at the door** | IPS had no pre-account seam, so the original created the account and then banned or held it. Convoro turns the address away at the form. Better for the person — they are told, and can use another address — and no half-account for the operator to clean up. |
| **No licensing phone-home** | 39 of the 43 carry `sources/Usage/Usage.php`. Its effect here was that screening paused on the basic tier, so the port always screens. Whatever Convoro does about licensing is its own decision, not something to copy across app by app. |
| **The log stores a domain** | The original recorded a member id and the action taken. There is no member here, and somebody who was refused never agreed to anything being kept about them. The domain is the useful fact anyway. |
| **Two settings dropped** | "What to do with a caught account" has no meaning when there is no account, and "refresh daily" was a second source of truth about whether a cron runs. |

## What came across unchanged, because it was already right

The original had paid for this reasoning and the port would have had to
rediscover it:

- Gmail ignores dots and everything after a `+`, so `f.i.r.s.t+forum@` and
  `first@` are one mailbox. Getting it wrong is how somebody banned walks back
  in as `name+2@`.
- Blocking a domain has to cover its subdomains, or a service adds one and is
  back in business.
- An explicit allow has to win, or a public list cannot be kept at all — the
  operator's only options become "trust it entirely" or "abandon it".
- The whole check fails **open**.

## Traps this hit, for the next 42

- **`Blueprint` has `bool()`, not `boolean()`.** The migration passed review and
  failed at install. The full list of column types is in
  `engine/Database/Schema/Blueprint.php` — read it rather than guessing from
  another framework.
- **`ext:package` requires the source directory to be named after the manifest
  key.** The repo may be `convoro-disposable`; the working directory has to be
  `disposable`.
- **The test runner already scans `extensions/*/tests`**, and `ext:package`
  ships them — so an extension's tests run with everything else, from inside
  the installed copy. Package after writing them or they are simply absent, and
  the runner reports 0 passed rather than an error.
- **`ext:install` refuses the same version twice.** `--force` during
  development; bump the version for anything real.

## Port #2: `edseo` → `convoro-seo`, the audit

Chosen second because Ernest's priority is what benefits the site, and
discovery is the bottleneck he has named repeatedly — not because it was the
next easiest.

Convoro already emits canonical links, robots.txt, a sitemap, Open Graph and
JSON-LD, so none of that was worth porting. What was missing is the half that
tells you what is wrong. The friendly-URL checks did not come across: they are
about two Invision settings Convoro does not have, and **an audit that reports
on a control nobody has is noise that teaches people to stop reading it.**

Deliberately left for later rather than half-built: per-item meta overrides and
redirects (worth porting next), and Google Search Console / GA4, which needs
OAuth credentials and its own decision.

🚨 **One more trap, and only measuring found it: `.admin-cell-meta` is
`white-space: nowrap`.** It is for a slug or a date. A sentence in it grew the
table to 1,853px inside a 1,440px window and scrolled the whole admin sideways
— with no error and nothing overflowing visibly enough to notice in a
screenshot. `.admin-hint` is the class for prose.

## Port #3: `edgit` → `convoro-signin`

Third by benefit: the audience is developers, Convoro had no OAuth at all, and
a password form is friction for exactly those people.

IPS's Login framework did the OAuth dance in the original, so the app supplied
only endpoints and scopes. Convoro has none of it, which means the flow and
every security decision inside it belong to the extension — `state` bound to
the session, single use, ten-minute expiry, one message for every way it can
fail; a linked provider account winning over the address, always, because
people change their email; and only a provider that vouches for an address
being allowed to match an existing member.

🚨 **GitLab exposes no verified flag, so the port refuses to match an existing
member on a GitLab address at all** — stricter than the original. Without a
verified flag, matching means signing up to a GitLab instance with somebody
else's address and then signing in as them.

**An extension can ship its own CSS** through the `head.after` hook rather than
editing `convoro.css`, gated on the feature actually being configured. Keep it
to layout: a theme the operator changes must still own how a button looks. That
is the pattern for any port needing styling.

🚨 **`ControllerContractTest` catches a private method that narrows an
inherited protected one** — a fatal at class load. `Controller` already has a
`settings()`; naming one in a subclass takes the whole admin panel down. Read
the base class before adding a helper.

## The bar this sets

12 tests, every string translatable from the first line, an admin screen built
from the site's own controls, and the whole thing verified by registering
against a running site rather than by reasoning about it. `TrackerTest`'s 28
for a medium app remains the density to aim at; this is a small one.
