Instalador web (/install)
Deep-dive: token timing-safe, picker de driver de banco (+ modo avançado), fail-closed lock, mad:install-reset, Mad\Install\**.
Deep-dive de referência do instalador web (/install) — o wizard de 3 passos
que prepara um clone fresco do template: configura a conexão de banco, roda
migrations + seed baseline, cria o administrador e fecha a própria rota
(fail-closed) pra nunca mais rodar. Para o passo a passo resumido de "subir o
projeto", veja Instalação.
Quando usar o wizard
| Caminho | Quando usar |
|---|---|
Wizard web (/install) |
Deploy novo (staging/produção/demo) — configura banco, roda migrations/seed e cria o admin pelo navegador, sem SSH. |
Setup manual via CLI (composer setup / php artisan migrate) |
Desenvolvimento local — você já sabe as credenciais do banco e prefere rodar tudo num comando. |
A rota roda fora do grupo web: o hook then: em bootstrap/app.php
registra routes/install.php com sua própria pilha de middleware
(InstallGuard → ForceFileSession → cookies/sessão/CSRF), porque o
SESSION_DRIVER=database padrão depende de uma tabela sessions que ainda
não existe antes da primeira migration:
// bootstrap/app.php
then: function (): void {
Route::middleware([
\App\Http\Middleware\InstallGuard::class,
\App\Http\Middleware\ForceFileSession::class,
\Illuminate\Cookie\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
])->group(base_path('routes/install.php'));
},
App\Http\Middleware\ForceFileSession força session.driver para file
(sessão em disco em storage/framework/sessions/) antes do
StartSession rodar, então o wizard ganha sessão + CSRF sem tocar no banco.
Acessar num clone fresco
Pré-requisitos mínimos — o passo 1 do wizard checa isso (informativo, não
bloqueia): PHP ≥ 8.2, extensões pdo/mbstring/openssl/json/tokenizer/
ctype/fileinfo, pelo menos um driver PDO (pdo_sqlite/pdo_mysql/
pdo_pgsql) e storage/, storage/framework/, app/database/ e .env
graváveis.
git clone <repo> && cd mad-framework-laravel
composer install
cp .env.example .env
php artisan key:generate
php artisan serve # http://127.0.0.1:8000
Abra /install. Não precisa rodar php artisan migrate na mão — é o que
o wizard faz. Se você já migrou/seedou por fora, o instalador detecta e
se fecha sozinho (ver Fail-closed).
Token de instalação
/install é protegido por um token timing-safe que prova posse do
servidor antes de liberar qualquer mutação. Implementado em
Mad\Install\InstallToken (packages/mad-framework/src/mad/install/InstallToken.php):
INSTALL_TOKENno.env— se definido, é o token fixo (ensure()devolve direto, nunca regenera).- Senão,
ensure()gerabin2hex(random_bytes(16))no primeiro acesso e grava emstorage/app/install-token.txtcom permissão0600.
cat storage/app/install-token.txt
O campo do gate é mascarado — o valor não aparece na tela, você lê do
servidor e cola. A comparação é constant-time (hash_equals), token
errado dispara sleep(1) antes do erro (throttle anti brute-force). Ao
concluir a instalação, Installer::seal() chama InstallToken::forget() e
o arquivo do token é descartado.
O gate de token é independente da numeração de passos do wizard:
InstallForm::onVerifyToken() seta $authenticated = true uma única vez;
toda ação subsequente (onStep, onTestConnection, onInstall, ...)
re-valida o guard fail-closed via assertInstallable() antes de prosseguir.
Fluxo (3 passos)
Componente: App\Control\Install\InstallForm (app/control/Install/InstallForm.php),
um MadComponent com $wrapper = self::INTERNAL, montado por
App\Http\Controllers\InstallController::show() num casco próprio
(resources/views/install/shell.blade.php) e servido por um endpoint wire
dedicado (POST /install/_wire, fora de mad.auth).
-
Requisitos —
InstallForm::requirements()reporta versão de PHP, extensões, drivers PDO disponíveis e permissões de escrita. Informativo — itens em vermelho podem quebrar a instalação, mas não bloqueiam o avanço. -
Banco de dados — escolha o driver (
public string $driver = 'sqlite'):- SQLite (padrão) — caminho do arquivo (
Installer::ensureSqliteFiles()cria o arquivo se não existir). - MySQL / MariaDB ou PostgreSQL — host, porta, banco, usuário,
senha. O banco já deve existir — o instalador não roda
CREATE DATABASE/CREATE USER.
Testar conexão (
onTestConnection→Installer::testConnection()) valida antes de avançar — SQLite checa só o diretório gravável; servidor abre um PDO descartável contra cada alvo distinto.Modo avançado (toggle
onToggleAdvanced, só disponível em driver ≠sqlite) permite um banco por conexão lógica no mesmo servidor — gravaDB_<CONN>_DATABASEindividual no.envpara cada uma das 6 conexões (campo vazio = herda o alvo base). Modo simples (default): as 6 conexões + a conexão default do Laravel (sessions/cache/jobs) herdam um único alvo. - SQLite (padrão) — caminho do arquivo (
-
Aplicação e administrador — nome/URL do app + nome/e-mail/senha do admin (mínimo 6 caracteres, confirmação obrigatória). Instalar (
onInstall→Installer::run()) então:- grava o
.env(EnvWriter::upsert, preserva comentários e ordem); - aplica as conexões em memória no mesmo request (
applyRuntimeConnections) e purga o PDO cacheado; - roda
migrate --force(cobre as 6 conexões numa passada) edb:seed --class=Database\Seeders\DatabaseSeeder(que dispara oMadReferenceSeeder— identidade/referência baseline — e, em apps materializados pelo MadBuilder, os seeders de permissão/papel gerados); - atualiza o usuário
admin(bcrypt, cost 12) com nome/e-mail/senha informados; - persiste um resumo durável (ver abaixo) e sela a instalação.
set_time_limit(0)+ignore_user_abort(true)blindam essa etapa contra timeout — migrate+seed num banco de servidor "frio" pode passar domax_execution_timedefault; o trabalho segue mesmo se o cliente desconectar. - grava o
Tela de sucesso mostra o resumo (app, banco, e-mail do admin, log de passos) e o link pro login.
Landing durável — GET /install/done
Como o passo de instalação roda inline e pode estourar timeout do
client antes do response chegar, Installer::run() persiste um resumo em
JSON (Mad\Install\InstallSummary, padrão storage/mad-install-done.json,
escrita atômica temp+rename, nunca grava senha) antes de selar.
GET /install/done é a única rota sempre liberada pelo InstallGuard
(mesmo pós-install): se o resumo existe, InstallController::done()
renderiza a tela de sucesso a partir dele; senão redireciona pro login. Isso
torna a confirmação idempotente — um refresh ou um retorno à URL depois
de instalado ainda mostra a confirmação em vez de cair direto no login sem
feedback.
Fail-closed (pós-instalação)
Depois de instalado, qualquer acesso ao instalador é bloqueado pelo
middleware App\Http\Middleware\InstallGuard:
GET /install→ 302 redirect pro login (ou pra/install/donese ainda existir um resumo durável).POST /install/_wire→ 403{"error":"Application already installed."}.
Detecção (InstallGuard::isInstalled()), nesta ordem:
instalado = lockExists() // selo durável, ZERO query
|| (mad_iam_user migrada && usuário admin existe) // heurística, auto-sela
- O lock (
storage/mad-installed.lock, override porconfig('mad.install.lock_path')) é gravado porInstallGuard::seal()ao concluir — sinal monotônico, independente do banco. Lock presente ⇒ bloqueia sem nenhuma query. - Sem lock, a heurística (
mad_iam_usermigrada + loginadminexiste) detecta uma instalação feita por fora (migratemanual) e auto-sela na hora (grava o lock). - Banco inalcançável e sem lock ⇒ tratado como fresh (abre o wizard); a mutação continua protegida pelo token.
O guard é a fonte única de verdade: o middleware (defesa-em-profundidade
no GET) e InstallForm::assertInstallable() (autoritativo, re-checado a
cada ação do wizard — não só no GET inicial) chamam o mesmo
InstallGuard::isInstalled().
Reabrir para QA — mad:install-reset
Pra testar o wizard de novo sem decorar caminhos, use o comando — só roda
em APP_ENV=local:
php artisan mad:install-reset
# apaga storage/mad-installed.lock, o(s) SQLite alvo das conexões MAD,
# o resumo durável (mad-install-done.json) e o token,
# e imprime um token NOVO.
php artisan mad:install-reset --keep-db
# preserva o(s) banco(s) SQLite; zera só lock + resumo + token
# (reabre o gate sem perder dados).
Fora de local o comando falha sem apagar nada. Bancos MySQL/PostgreSQL
não são apagados pelo comando (só arquivos SQLite locais detectados nas
6 conexões MAD + a conexão default) — limpe o servidor à mão se precisar.
// app/Console/Commands/InstallReset.php
protected $signature = 'mad:install-reset {--keep-db : Preserva o banco; apaga só o lock + token}';
Referências de código
| Peça | Arquivo |
|---|---|
| Componente do wizard (3 passos + gate) | app/control/Install/InstallForm.php |
| Orquestração (env/migrate/seed/admin/selo) | app/Service/Install/Installer.php |
Entry point HTTP (show/done/wire) |
app/Http/Controllers/InstallController.php |
| Guard fail-closed (lock + heurística) | app/Http/Middleware/InstallGuard.php |
Sessão file pré-banco |
app/Http/Middleware/ForceFileSession.php |
| Reset de QA | app/Console/Commands/InstallReset.php |
| Token timing-safe | packages/mad-framework/src/mad/install/InstallToken.php |
Upsert atômico do .env |
packages/mad-framework/src/mad/install/EnvWriter.php |
| Resumo durável pós-install | packages/mad-framework/src/mad/install/InstallSummary.php |
Rotas (fora do web) |
routes/install.php + hook then: em bootstrap/app.php |
| Casco + UI | resources/views/install/* (shell.blade.php, install-form.blade.php, partials/success-page.blade.php) |
Gotchas
- Não confunda com o setup manual.
composer setup/php artisan migratenão passam pelo token, pelo lock ou pelo resumo durável — são o caminho de desenvolvimento local, sem o wizard. modelHasColumn-style fail-open não existe aqui — ao contrário de outros guards do framework,InstallGuardé estritamente fail-closed: qualquer ambiguidade (banco inalcançável, lock ausente) tende a reabrir o wizard, nunca a expor uma instalação já feita sem o token.- Modo avançado só existe no servidor. O toggle de "um banco por
conexão" (
$advanced) é forçado afalsesempre quedriver === 'sqlite'— SQLite é sempre um arquivo único compartilhado pelas 6 conexões. INSTALL_TOKENfixo no.envnunca é regenerado pormad:install-reset— o comando avisa e mantém o valor fixo.