Preserva base local aceite de identidade e acessos

This commit is contained in:
Codex
2026-10-25 21:29:22 +02:00
commit 26b80df545
112 changed files with 13259 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[{compose,docker-compose}.{yml,yaml}]
indent_size = 4
+21
View File
@@ -0,0 +1,21 @@
APP_NAME="SGF Tempo"
APP_DEBUG=false
APP_URL=http://127.0.0.1:8000
APP_LOCALE=pt
APP_FALLBACK_LOCALE=pt
LOG_CHANNEL=single
LOG_LEVEL=warning
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=55432
DB_URL=
SESSION_DRIVER=file
CACHE_STORE=file
QUEUE_CONNECTION=sync
MAIL_MAILER=log
BROADCAST_CONNECTION=log
APP_KEY=
APP_ENV=local
DB_DATABASE=sgf_tempo_dev
DB_USERNAME=sgf_dev_app
DB_PASSWORD=
+11
View File
@@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore
+32
View File
@@ -0,0 +1,32 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.env.testing
.env.browser-testing
.env.*.local
/storage/app/private/*
/storage/framework/browser-proof/
.phpactor.json
.phpunit.result.cache
/.codex
/.cursor/
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/fonts-manifest.dev.json
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
_ide_helper.php
Homestead.json
Homestead.yaml
Thumbs.db
+2
View File
@@ -0,0 +1,2 @@
ignore-scripts=true
audit=true
+18
View File
@@ -0,0 +1,18 @@
# SGF Tempo — instruções locais
- Projecto exclusivo em `C:\laragon\www\sgf-tempo`; não alterar InvesteMoz, os seus dados, configurações, ferramentas ou serviços.
- Base de requisitos: SGF_TEMPO_Roteiro_v0_9.docx. Distinguir decisões acordadas de propostas técnicas e autorização de execução.
- Usar `scripts/sgf.ps1` para ferramentas e arranque. Não usar o PHP/Composer do PATH por omissão nem alterar o PATH global.
- Versões directas fixadas nos manifests; dependências completas nos lockfiles. Qualquer substituição de versão exige apresentação prévia ao utilizador; não instalar ferramentas adicionais automaticamente.
- Monólito modular em `app/Modules`, contexto comum em `app/Shared/Context`. Separar tenant, empresa fiscal e unidade desde o primeiro modelo empresarial.
- Testes apenas na base `sgf_tempo_test`, cluster próprio 127.0.0.1:55432. Manter TestingDatabaseGuard e usar dados sintéticos.
- Aplicação usa sgf_dev_app; migrações de desenvolvimento usam wrapper e proprietário separado. Nunca usar o administrador PostgreSQL na aplicação.
- Nunca apresentar segredos, ler configurações privadas do portal ou versionar .env/.env.testing. .env.example deve permanecer sem segredos.
- Verificar portas antes de iniciar; não parar processos de outros projectos. Não publicar nem implementar módulos financeiros sem nova autorização de âmbito.
- PostGIS e Python adiados até haver função concreta. Não instalar Laravel Boost, IA, serviços externos ou módulos adicionais como preparação automática.
- Validar mudanças da fundação com `sgf.ps1 test`; mudanças React com `sgf.ps1 npm run build`. Documentar limites e comandos de arranque no README.
- Primeiro percurso: login/logout por sessão, contexto de empresa/unidade, nomes e concessões explícitas. Não criar tenants/empresas pela interface nem conceder todas as unidades implicitamente.
- Não criar contas reais ou primeiros acessos sem pré-visualização concreta e confirmação do utilizador. Palavras-passe apenas em entrada oculta no terminal local, nunca no chat, argumentos, logs ou documentação.
- As alterações usam o token do contexto revisto, validação de permissões no servidor e auditoria na mesma transacção. Não remover os triggers de imutabilidade ou de auditoria append-only.
- Provas de navegador usam browser-testing na porta 8001 e apenas sgf_tempo_test, com sessões/cache isolados e CSRF activo. Parar a prova antes de executar testes que recriam a base de testes.
- Em 25 de Outubro de 2026, o utilizador aceitou e deu por concluído o incremento local de entrada, contexto e permissões no âmbito demonstrado e com os limites de docs/IDENTITY_DELIVERY.md. Preservar essa entrega como base. Serviços permanece apenas em especificação; não há autorização para implementação financeira ou publicação.
+1
View File
@@ -0,0 +1 @@
Consultar AGENTS.md para as instruções locais deste projecto.
+63
View File
@@ -0,0 +1,63 @@
# SGF TEMPO
Primeiro percurso funcional local, sobre a fundação existente e o roteiro v0.9 de 25 de Outubro de 2026. Laravel modular com React, independente do InvesteMoz. Versões e lockfiles preservados; sem módulos financeiros, integrações externas ou publicação.
**Estado:** incremento local de entrada, contexto e permissões aceite e concluído pelo utilizador em 25 de Outubro de 2026, com os limites de [IDENTITY_DELIVERY.md](docs/IDENTITY_DELIVERY.md). Preservar esta base. Serviços mantém-se apenas em especificação; implementação financeira e publicação não estão autorizadas.
## Abrir e parar
Em `C:\laragon\www\sgf-tempo`:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 start
```
Abrir http://127.0.0.1:8000. Se a porta estiver ocupada, o arranque recusa sem terminar processos. `status` mostra o estado; `stop` pára apenas os processos próprios registados e o cluster exclusivo. Não existe serviço Windows novo nem alteração global da política de execução.
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 status
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 stop
```
## Primeira conta
**Estado actual:** a conta inicial de Osmane Nala já foi criada e verificada na Tempo Lda. Não repetir o comando de criação abaixo; conserva-se como registo do procedimento. Entrar com o email indicado e a palavra-passe definida localmente.
Sem utilizadores, o ecrã mostra «Falta concluir a primeira conta» e o procedimento local. A existência da aplicação não significa que haja uma conta criada. Não há palavras-passe por omissão nem administrador universal.
A operação inicial de Osmane Nala / Tempo Lda foi pré-visualizada e confirmada na sessão. Para a concluir, o utilizador deve executar no seu terminal local:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\laragon\data\sgf-tempo\run\complete-first-admin.ps1
```
O procedimento apresenta o âmbito já confirmado nesta sessão e solicita uma nova palavra-passe duas vezes com Read-Host -AsSecureString do PowerShell, mostrando apenas asteriscos. Não é necessário responder yes novamente. Não introduzir palavras-passe no chat, em argumentos ou no motivo. Só depois da confirmação e das duas entradas válidas são criados, atomicamente, o tenant, empresa, utilizador e concessões. Se for interrompido ou falhar, a criação não fica parcialmente aplicada. Não repetir depois de a conta existir.
Concluído o procedimento, actualizar o estado no navegador e entrar com a credencial definida localmente. A conta inicial tem apenas consulta/edição do nome da empresa, criação de unidades e gestão delimitada de acessos na empresa aprovada. Não se criam unidades iniciais: o utilizador pode criá-las com a permissão específica. Ao criá-las, recebe concessões explícitas de consulta e edição apenas para cada nova unidade, conforme decisão aprovada.
## Percurso disponível
1. Entrar por email/palavra-passe; tentativas repetidas são limitadas.
2. Seleccionar uma empresa autorizada e uma unidade com consulta explicitamente concedida.
3. Consultar a área inicial com empresa e unidade visíveis.
4. Consultar/editar nomes segundo as concessões; criar unidades apenas com `unit.create`.
5. Gerir acessos de utilizadores existentes apenas na empresa activa e sem delegar direitos superiores aos próprios.
6. Sair: a sessão anterior é invalidada.
Não existe criação de tenants ou empresas pela interface, auto-registo, opção «todas as unidades», recuperação por email ou fornecedor externo. Consultar [utilização e recuperação](docs/IDENTITY_WORKFLOW.md) para os procedimentos locais de utilizadores e recuperação.
## Testes e ferramentas
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 test
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 npm run build
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 php scripts/verify-foundation.php
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 php scripts/identity-status.php
```
Os testes destrutivos usam exclusivamente `sgf_tempo_test`, verificada antes da execução. Não usar fresh, reset ou wipe em desenvolvimento. Migrações futuras aprovadas: `sgf.ps1 migrate`, com proprietário de migrações separado; a aplicação continua a usar `sgf_dev_app` sem administração ou criação de tabelas.
Para Vite, `sgf.ps1 frontend` (porta 5173, estrita). A aplicação continua a ser aberta na porta 8000. As ferramentas usam caminhos explícitos em `C:\laragon\tools\sgf-tempo`; o cluster fica em `C:\laragon\data\sgf-tempo\postgres-17`, porta 55432. Não alterar as versões ou o ambiente do InvesteMoz.
[Relatório do incremento e provas](docs/IDENTITY_DELIVERY.md) · [Registo histórico da preparação inicial](docs/LOCAL_ENVIRONMENT.md)
@@ -0,0 +1,123 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use App\Modules\Identity\Services\Audit;
use App\Modules\Identity\Services\Permissions;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
final class ProvisionIdentityUser extends Command
{
protected $signature = 'identity:provision {--email=} {--name=} {--company=} {--tenant-name=} {--company-name=} {--company-permission=*} {--unit-permission=*} {--operator=} {--reason=} {--existing-user} {--protected-stdin} {--apply}';
protected $description = 'Pré-visualizar e, com confirmação local, criar utilizador e primeiros acessos delimitados.';
public function handle(): int
{
if (! $this->safeLocalDatabase()) { return self::FAILURE; }
$email = mb_strtolower(trim((string) $this->option('email')));
$name = trim((string) $this->option('name'));
if (! filter_var($email, FILTER_VALIDATE_EMAIL) || $name === '' || mb_strlen($name) > 255 || mb_strlen($email) > 255 || ! $this->option('operator') || ! $this->option('reason')) {
$this->error('Indique email, nome, operador local e motivo. Não forneça palavras-passe por argumentos.'); return self::FAILURE;
}
$existing = User::where('email', $email)->first();
if ($existing && ! $this->option('existing-user')) { $this->error('O utilizador já existe. Use recuperação local ou gestão de acessos.'); return self::FAILURE; }
if ($this->option('existing-user') && (! $existing || $existing->name !== $name || ! $this->option('company'))) {
$this->error('A recuperação de concessões exige utilizador/nome existentes e --company explícito.'); return self::FAILURE;
}
$company = $this->option('company') ? DB::table('companies')->where('id', $this->option('company'))->first() : null;
if ($this->option('company') && ! $company) { $this->error('Empresa inexistente; não será criada automaticamente.'); return self::FAILURE; }
$newScope = $this->option('tenant-name') || $this->option('company-name');
if ($newScope) {
if ($existing) { $this->error('A recuperação não cria um novo âmbito.'); return self::FAILURE; }
$tenantName = trim((string) $this->option('tenant-name'));
$companyName = trim((string) $this->option('company-name'));
if ($company || $tenantName === '' || $companyName === '' || mb_strlen($tenantName) > 255 || mb_strlen($companyName) > 255
|| DB::table('tenants')->where('name', $tenantName)->exists()) {
$this->error('Para novo âmbito, indique nomes explícitos, distintos de um tenant existente, sem --company. Nada será reutilizado por inferência.'); return self::FAILURE;
}
$company = (object) ['id' => 0, 'tenant_id' => 0, 'name' => $companyName];
}
$grants = [];
foreach ($this->option('company-permission') as $permission) {
if (! $company || ! in_array($permission, Permissions::COMPANY, true)) { $this->error('Concessão de empresa inválida.'); return self::FAILURE; }
$grants[] = ['permission' => $permission, 'unit_id' => null];
}
foreach ($this->option('unit-permission') as $raw) {
[$unit, $permission] = array_pad(explode(':', $raw, 2), 2, null);
if (! $company || ! ctype_digit((string) $unit) || ! in_array($permission, Permissions::UNIT, true)
|| ! DB::table('units')->where('id', $unit)->where('company_id', $company->id)->where('tenant_id', $company->tenant_id)->exists()) {
$this->error('Concessão de unidade inválida.'); return self::FAILURE;
}
$grants[] = ['permission' => $permission, 'unit_id' => (int) $unit];
}
if ($grants && ! in_array('company.view', array_column($grants, 'permission'), true)) { $this->error('Os primeiros acessos exigem company.view explícito.'); return self::FAILURE; }
if ($newScope && ! $grants) { $this->error('O novo âmbito exige concessões iniciais explícitas.'); return self::FAILURE; }
foreach ($grants as $grant) {
if ($grant['permission'] === 'unit.edit' && ! in_array(['permission' => 'unit.view', 'unit_id' => $grant['unit_id']], $grants)) {
$this->error('unit.edit exige unit.view explícito para a mesma unidade.'); return self::FAILURE;
}
}
if ($grants && DB::table('permission_grants')->where('company_id', $company->id)->where('permission', 'access.manage')->exists()) {
$this->error('Esta empresa já tem gestor. Crie apenas o utilizador sem concessões e use a gestão de acessos.'); return self::FAILURE;
}
$this->table(['Operação', 'Valor'], [['Base', 'sgf_tempo_dev'], ['Utilizador', "$name <$email>"],
['Tenant', $newScope ? 'CRIAR: '.$tenantName : ($company?->tenant_id ?? 'sem acesso')], ['Empresa', $newScope ? 'CRIAR: '.$companyName : ($company ? "$company->id — $company->name" : 'sem acesso')],
['Operador', $this->option('operator')], ['Motivo', $this->option('reason')]]);
$this->table(['Permissão', 'Unidade'], array_map(fn ($g) => [$g['permission'], $g['unit_id'] ?? 'âmbito da empresa'], $grants));
if (! $this->option('apply')) { $this->info('Pré-visualização apenas. Obtenha confirmação desta operação antes de repetir com --apply.'); return self::SUCCESS; }
if ($existing && ! $grants) { $this->error('Indique as concessões a recuperar.'); return self::FAILURE; }
$protected = null;
if ($this->option('protected-stdin')) {
if ($existing) { $this->error('Esta entrada protegida destina-se apenas à criação de uma nova conta.'); return self::FAILURE; }
try { $protected = \App\Modules\Identity\Services\ProtectedProvisioningInput::read(STDIN, $email); }
catch (\RuntimeException $exception) { $this->error($exception->getMessage()); return self::FAILURE; }
} elseif (! $this->input->isInteractive() || ! $this->confirm($existing ? 'Confirma recuperar apenas as concessões acima, mantendo a palavra-passe?' : 'Confirma a criação exacta acima, sem outros acessos?', false)) { return self::FAILURE; }
$password = null;
if (! $existing) {
$password = $protected ? $protected[0] : $this->secret('Nova palavra-passe (mínimo 12 caracteres; não será apresentada)', false);
$repeat = $protected ? $protected[1] : $this->secret('Repita a palavra-passe', false);
if (! is_string($password) || $password === '') { $this->error('A entrada oculta não recebeu uma palavra-passe. Nada alterado.'); return self::FAILURE; }
if (mb_strlen($password) < 12) { $this->error('A palavra-passe precisa de pelo menos 12 caracteres. Nada alterado.'); return self::FAILURE; }
if (strlen($password) > 72) { $this->error('A palavra-passe excede o limite de 72 bytes do algoritmo actual. Nada alterado.'); return self::FAILURE; }
if ($password !== $repeat) { $this->error('As duas entradas ocultas não coincidem. Nada alterado.'); return self::FAILURE; }
}
try {
DB::transaction(function () use ($email, $name, $password, $company, $grants, $newScope, $existing): void {
if ($newScope) {
// Serialize local bootstrap commands; never silently reuse or duplicate the named scope.
DB::statement('SELECT pg_advisory_xact_lock(72644101)');
if (DB::table('tenants')->where('name', trim($this->option('tenant-name')))->exists()) { throw new \RuntimeException('Scope changed'); }
$tenantId = DB::table('tenants')->insertGetId(['name' => trim($this->option('tenant-name')), 'created_at' => now(), 'updated_at' => now()]);
$companyId = DB::table('companies')->insertGetId(['tenant_id' => $tenantId, 'name' => trim($this->option('company-name')), 'created_at' => now(), 'updated_at' => now()]);
$company = (object) ['id' => $companyId, 'tenant_id' => $tenantId];
Audit::record('local.scope_created', null, $tenantId, $companyId,
details: ['operator' => $this->option('operator'), 'reason' => $this->option('reason')]);
}
if ($company && $grants) {
DB::table('companies')->where('id', $company->id)->lockForUpdate()->first();
if (DB::table('permission_grants')->where('company_id', $company->id)->where('permission', 'access.manage')->exists()) { throw new \RuntimeException('Scope changed'); }
}
$user = $existing ?? User::create(['name' => $name, 'email' => $email, 'password' => $password]);
if ($company && $grants) {
DB::table('company_memberships')->insertOrIgnore(['user_id' => $user->id, 'tenant_id' => $company->tenant_id, 'company_id' => $company->id]);
foreach ($grants as $grant) {
DB::table('permission_grants')->insertOrIgnore($grant + ['user_id' => $user->id, 'tenant_id' => $company->tenant_id, 'company_id' => $company->id, 'created_at' => now(), 'updated_at' => now()]);
}
}
Audit::record($existing ? 'local.access_restored' : 'local.user_provisioned', null, $company?->tenant_id, $company?->id, subject: $user->id,
details: ['operator' => $this->option('operator'), 'reason' => $this->option('reason'), 'grants' => $grants]);
});
} catch (\Throwable) { $this->error('Operação não concluída. Nenhum detalhe sensível foi registado. Verifique o estado e repita a pré-visualização.'); return self::FAILURE; }
$this->info($existing ? 'Concessões recuperadas no âmbito apresentado; credencial preservada.' : 'Utilizador criado com os acessos apresentados. Nenhuma palavra-passe foi registada.'); return self::SUCCESS;
}
private function safeLocalDatabase(): bool
{
if (! \App\Modules\Identity\Services\LocalDatabaseGuard::allows()) {
$this->error('Procedimento permitido apenas no desenvolvimento local próprio, com sgf_dev_app.'); return false;
}
return true;
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use App\Modules\Identity\Services\Audit;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
final class RecoverIdentityUser extends Command
{
protected $signature = 'identity:recover {--email=} {--operator=} {--reason=} {--apply}';
protected $description = 'Recuperação local assistida, com confirmação e invalidação das sessões existentes.';
public function handle(): int
{
if (! \App\Modules\Identity\Services\LocalDatabaseGuard::allows()) { $this->error('Destino local inválido.'); return self::FAILURE; }
$user = User::where('email', mb_strtolower(trim((string) $this->option('email'))))->first();
if (! $user || ! $this->option('operator') || ! $this->option('reason')) { $this->error('Indique utilizador existente, operador e motivo.'); return self::FAILURE; }
$this->table(['Operação', 'Valor'], [['Base', 'sgf_tempo_dev'], ['Utilizador', "$user->id — $user->name <$user->email>"],
['Efeito', 'Alterar palavra-passe e invalidar todas as sessões; manter concessões existentes'], ['Operador', $this->option('operator')], ['Motivo', $this->option('reason')]]);
if (! $this->option('apply')) { $this->info('Pré-visualização apenas. Confirme a identidade e autorização da pessoa antes de --apply.'); return self::SUCCESS; }
if (! $this->input->isInteractive() || ! $this->confirm('Confirma a recuperação exacta apresentada?', false)) { return self::FAILURE; }
$password = $this->secret('Nova palavra-passe (mínimo 12 caracteres)', false);
$repeat = $this->secret('Repita a palavra-passe', false);
if (! is_string($password) || $password === '') { $this->error('A entrada oculta não recebeu uma palavra-passe. Nada alterado.'); return self::FAILURE; }
if (mb_strlen($password) < 12) { $this->error('A palavra-passe precisa de pelo menos 12 caracteres. Nada alterado.'); return self::FAILURE; }
if (strlen($password) > 72) { $this->error('A palavra-passe excede o limite de 72 bytes do algoritmo actual. Nada alterado.'); return self::FAILURE; }
if ($password !== $repeat) { $this->error('As duas entradas ocultas não coincidem. Nada alterado.'); return self::FAILURE; }
try {
DB::transaction(function () use ($user, $password): void {
$current = User::whereKey($user->id)->lockForUpdate()->firstOrFail();
$current->password = $password;
$current->auth_version++;
$current->remember_token = bin2hex(random_bytes(30));
$current->save();
Audit::record('local.password_recovered', null, subject: $current->id,
details: ['operator' => $this->option('operator'), 'reason' => $this->option('reason')]);
});
} catch (\Throwable) { $this->error('Recuperação não concluída; nenhum detalhe sensível foi apresentado.'); return self::FAILURE; }
$this->info('Recuperação concluída. As sessões anteriores serão recusadas no pedido seguinte.'); return self::SUCCESS;
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
protected $attributes = ['auth_version' => 1];
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'auth_version' => 'integer',
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}
@@ -0,0 +1,75 @@
<?php
namespace App\Modules\Identity\Http;
use App\Http\Controllers\Controller;
use App\Modules\Identity\Services\Audit;
use App\Modules\Identity\Services\IdentityWorkflow;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
final class IdentityController extends Controller
{
public function __construct(private IdentityWorkflow $workflow) {}
public function session(Request $request)
{
if ($request->user() && (int) $request->session()->get('auth_version', 0) !== (int) $request->user()->auth_version) {
Auth::logout(); $request->session()->invalidate(); $request->session()->regenerateToken();
}
return response()->json($this->workflow->snapshot($request))->header('Cache-Control', 'no-store');
}
public function login(Request $request)
{
$data = $request->validate(['email' => 'required|email|max:255', 'password' => 'required|string|max:1024']);
$email = mb_strtolower(trim($data['email']));
if (strlen($data['password']) > 72) { throw ValidationException::withMessages(['email' => 'Email ou palavra-passe inválidos.']); }
$key = 'sgf-login:'.hash('sha256', $email.'|'.$request->ip());
$ipKey = 'sgf-login-ip:'.hash('sha256', (string) $request->ip());
abort_if(RateLimiter::tooManyAttempts($key, config('identity.login_attempts')) || RateLimiter::tooManyAttempts($ipKey, config('identity.login_ip_attempts')), 429, 'Demasiadas tentativas. Aguarde antes de tentar novamente.');
if (! Auth::attempt(['email' => $email, 'password' => $data['password']], false)) {
RateLimiter::hit($key, config('identity.login_decay_seconds'));
RateLimiter::hit($ipKey, config('identity.login_decay_seconds'));
Audit::record('auth.failed', null);
throw ValidationException::withMessages(['email' => 'Email ou palavra-passe inválidos.']);
}
RateLimiter::clear($key);
$request->session()->regenerate();
$request->session()->forget('company_context');
$request->session()->put('auth_version', (int) $request->user()->auth_version);
Audit::record('auth.login', $request->user()->id);
return $this->session($request);
}
public function logout(Request $request)
{
Audit::record('auth.logout', $request->user()->id);
Auth::logout(); $request->session()->invalidate(); $request->session()->regenerateToken();
return response()->json(['csrf' => csrf_token()])->header('Cache-Control', 'no-store');
}
public function select(Request $request) { return response()->json($this->workflow->select($request)); }
public function company(Request $request, int $company)
{
$ids = app(\App\Shared\Context\CompanyContext::class)->identifiers();
abort_unless($company === $ids['company_id'], 403);
return response()->json(\Illuminate\Support\Facades\DB::table('companies')->where('id', $company)->where('tenant_id', $ids['tenant_id'])->first(['id', 'name']));
}
public function unit(Request $request, int $unit)
{
$ids = app(\App\Shared\Context\CompanyContext::class)->identifiers();
app(\App\Modules\Identity\Services\Permissions::class)->require($request->user()->id, $ids['tenant_id'], $ids['company_id'], 'unit.view', $unit);
$record = \Illuminate\Support\Facades\DB::table('units')->where('id', $unit)->where('company_id', $ids['company_id'])->where('tenant_id', $ids['tenant_id'])->first(['id', 'name']);
abort_unless($record, 403);
return response()->json($record);
}
public function renameCompany(Request $request, int $company) { $this->workflow->renameCompany($request, $company); return response()->json(['ok' => true]); }
public function renameUnit(Request $request, int $unit) { $this->workflow->renameUnit($request, $unit); return response()->json(['ok' => true]); }
public function createUnit(Request $request) { return response()->json(['id' => $this->workflow->createUnit($request)], 201); }
public function accesses(Request $request) { return response()->json($this->workflow->accessList($request)); }
public function grant(Request $request) { $this->workflow->changeAccess($request, true); return response()->json(['ok' => true]); }
public function revoke(Request $request) { $this->workflow->changeAccess($request, false); return response()->json(['ok' => true]); }
}
@@ -0,0 +1,33 @@
<?php
namespace App\Modules\Identity\Http;
use App\Shared\Context\CompanyContext;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class RequireCompanyContext
{
public function __construct(private CompanyContext $context) {}
public function handle(Request $request, Closure $next): Response
{
$this->context->clear();
try {
$selection = $request->session()->get('company_context', []);
$tenant = $selection['tenant_id'] ?? null;
$company = $selection['company_id'] ?? null;
abort_unless($request->user() && $tenant && $company, 403, 'Contexto de empresa obrigatório.');
$this->context->selectFor((int) $request->user()->getKey(), $tenant, $company, $selection['unit_id'] ?? null);
if (! $request->isMethodSafe()) {
$token = $request->header('X-SGF-Context');
abort_unless(is_string($token) && hash_equals($selection['token'] ?? '', $token), 409, 'O contexto mudou. Reabra o formulário antes de guardar.');
}
return $next($request);
} finally {
$this->context->clear();
}
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Modules\Identity\Http;
use Closure;
use Illuminate\Http\Request;
final class RequireValidSession
{
public function handle(Request $request, Closure $next)
{
abort_unless($request->user(), 401);
if ((int) $request->session()->get('auth_version', 0) !== (int) $request->user()->auth_version) {
auth()->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
abort(401, 'Sessão terminada. Entre novamente.');
}
$response = $next($request);
$response->headers->set('Cache-Control', 'no-store');
return $response;
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace App\Modules\Identity\Models;
use Illuminate\Database\Eloquent\Model;
// Identity catalogue; company selection is authorized by CompanyContext.
final class Company extends Model
{
protected $fillable = ['tenant_id', 'name'];
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Modules\Identity\Models;
use Illuminate\Database\Eloquent\Model;
final class Tenant extends Model
{
protected $fillable = ['name'];
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Modules\Identity\Models;
use App\Shared\Context\BelongsToCompany;
use Illuminate\Database\Eloquent\Model;
final class Unit extends Model
{
use BelongsToCompany;
protected $fillable = ['name'];
protected static function booted(): void
{
static::addGlobalScope('unit-access', function (\Illuminate\Database\Eloquent\Builder $query): void {
$query->whereIn('units.id', app(\App\Shared\Context\CompanyContext::class)->permittedUnitIds());
});
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Modules\Identity\Services;
use Illuminate\Support\Facades\DB;
final class Audit
{
public static function record(string $action, ?int $actor, ?int $tenant = null, ?int $company = null, ?int $unit = null, ?int $subject = null, array $details = []): void
{
// Explicit allowlist: credentials, session/context tokens and request bodies never enter audit.
$safe = array_intersect_key($details, array_flip(['before_name', 'after_name', 'permission', 'reason', 'operator', 'grants']));
DB::table('identity_audit')->insert([
'action' => $action, 'actor_id' => $actor, 'tenant_id' => $tenant,
'company_id' => $company, 'unit_id' => $unit, 'subject_id' => $subject,
'details' => json_encode($safe, JSON_THROW_ON_ERROR), 'created_at' => now(),
]);
}
}
@@ -0,0 +1,183 @@
<?php
namespace App\Modules\Identity\Services;
use App\Models\User;
use App\Shared\Context\CompanyContext;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class IdentityWorkflow
{
public function __construct(private Permissions $permissions, private CompanyContext $context) {}
public function companies(int $user): array
{
return DB::table('companies as c')->join('permission_grants as g', 'g.company_id', '=', 'c.id')
->where('g.user_id', $user)->where('g.permission', 'company.view')->whereNull('g.unit_id')
->select('c.id', 'c.tenant_id', 'c.name')->orderBy('c.name')->get()->all();
}
public function units(int $user, int $tenant, int $company): array
{
return DB::table('units')->where('tenant_id', $tenant)->where('company_id', $company)
->whereIn('id', $this->permissions->unitIds($user, $tenant, $company))
->select('id', 'name')->orderBy('name')->get()->all();
}
public function snapshot(Request $request): array
{
$user = $request->user();
$result = ['user' => $user ? ['id' => $user->id, 'name' => $user->name, 'email' => $user->email] : null,
'csrf' => csrf_token(), 'companies' => [], 'context' => null,
'setup_pending' => app()->environment('local') && ! DB::table('users')->exists()];
if (! $user) { return $result; }
$result['companies'] = $this->companies($user->id);
$selected = $request->session()->get('company_context');
if (! $selected) { return $result; }
try {
$this->context->selectFor($user->id, $selected['tenant_id'], $selected['company_id'], $selected['unit_id']);
$company = DB::table('companies')->where('id', $selected['company_id'])->first(['id', 'name', 'tenant_id']);
$unit = $selected['unit_id'] ? DB::table('units')->where('id', $selected['unit_id'])->first(['id', 'name']) : null;
$grants = DB::table('permission_grants')->where('user_id', $user->id)->where('company_id', $company->id)
->get(['permission', 'unit_id'])->map(fn ($grant) => ['permission' => $grant->permission, 'unit_id' => $grant->unit_id ? (int) $grant->unit_id : null])->all();
$result['context'] = ['company' => $company, 'unit' => $unit, 'units' => $this->units($user->id, $company->tenant_id, $company->id),
'token' => $selected['token'], 'grants' => $grants];
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $exception) {
if ($exception->getStatusCode() !== 403) { throw $exception; }
$request->session()->forget('company_context');
$result['notice'] = 'O acesso ao contexto anterior mudou. Seleccione um contexto autorizado.';
} finally { $this->context->clear(); }
return $result;
}
public function select(Request $request): array
{
$data = $request->validate(['tenant_id' => 'required|integer|min:1', 'company_id' => 'required|integer|min:1', 'unit_id' => 'nullable|integer|min:1']);
$unit = isset($data['unit_id']) ? (int) $data['unit_id'] : null;
// Selection is explicit, never silently carries the old unit into another company.
$this->context->selectFor($request->user()->id, (int) $data['tenant_id'], (int) $data['company_id'], $unit);
$request->session()->put('company_context', ['tenant_id' => (int) $data['tenant_id'], 'company_id' => (int) $data['company_id'],
'unit_id' => $unit, 'token' => bin2hex(random_bytes(24))]);
Audit::record('context.selected', $request->user()->id, (int) $data['tenant_id'], (int) $data['company_id'], $unit);
return $this->snapshot($request);
}
public function renameCompany(Request $request, int $company): void
{
$data = $request->validate(['name' => 'required|string|max:255', 'tenant_id' => 'prohibited', 'company_id' => 'prohibited', 'unit_id' => 'prohibited', 'id' => 'prohibited']);
$this->mutate($request, function ($ids, $user) use ($company, $data): void {
abort_unless($company === $ids['company_id'], 403);
$this->permissions->require($user, $ids['tenant_id'], $company, 'company.edit');
$before = DB::table('companies')->where('id', $company)->value('name');
DB::table('companies')->where('id', $company)->update(['name' => trim($data['name']), 'updated_at' => now()]);
Audit::record('company.renamed', $user, $ids['tenant_id'], $company, details: ['before_name' => $before, 'after_name' => trim($data['name'])]);
});
}
public function renameUnit(Request $request, int $unit): void
{
$data = $request->validate(['name' => 'required|string|max:255', 'tenant_id' => 'prohibited', 'company_id' => 'prohibited', 'unit_id' => 'prohibited', 'id' => 'prohibited']);
$this->mutate($request, function ($ids, $user) use ($unit, $data): void {
abort_unless($this->context->unitId() === $unit, 409, 'Seleccione esta unidade antes de editar.');
$this->permissions->require($user, $ids['tenant_id'], $ids['company_id'], 'unit.edit', $unit);
$before = DB::table('units')->where('id', $unit)->where('company_id', $ids['company_id'])->value('name');
abort_if($before === null, 403);
DB::table('units')->where('id', $unit)->where('company_id', $ids['company_id'])->update(['name' => trim($data['name']), 'updated_at' => now()]);
Audit::record('unit.renamed', $user, $ids['tenant_id'], $ids['company_id'], $unit, details: ['before_name' => $before, 'after_name' => trim($data['name'])]);
});
}
public function createUnit(Request $request): int
{
$data = $request->validate(['name' => 'required|string|max:255', 'tenant_id' => 'prohibited', 'company_id' => 'prohibited', 'unit_id' => 'prohibited', 'id' => 'prohibited']);
return $this->mutate($request, function ($ids, $user) use ($data): int {
$this->permissions->require($user, $ids['tenant_id'], $ids['company_id'], 'unit.create');
$unit = DB::table('units')->insertGetId($ids + ['name' => trim($data['name']), 'created_at' => now(), 'updated_at' => now()]);
Audit::record('unit.created', $user, $ids['tenant_id'], $ids['company_id'], $unit, details: ['after_name' => trim($data['name'])]);
foreach (Permissions::UNIT as $permission) {
$this->insertGrant($user, $ids, $unit, $permission);
Audit::record('access.granted_on_creation', $user, $ids['tenant_id'], $ids['company_id'], $unit, $user, ['permission' => $permission]);
}
return $unit;
});
}
public function accessList(Request $request): array
{
$ids = $this->context->identifiers();
$this->permissions->require($request->user()->id, $ids['tenant_id'], $ids['company_id'], 'access.manage');
$allowedUnits = $this->context->permittedUnitIds();
$members = DB::table('company_memberships as m')->join('users as u', 'u.id', '=', 'm.user_id')
->where('m.tenant_id', $ids['tenant_id'])->where('m.company_id', $ids['company_id'])->select('u.id', 'u.name', 'u.email')->orderBy('u.name')->get();
$grants = DB::table('permission_grants')->where('tenant_id', $ids['tenant_id'])->where('company_id', $ids['company_id'])
->where(fn ($q) => $q->whereNull('unit_id')->orWhereIn('unit_id', $allowedUnits))->get(['id', 'user_id', 'unit_id', 'permission']);
return ['members' => $members, 'grants' => $grants, 'catalogue' => ['company' => Permissions::COMPANY, 'unit' => Permissions::UNIT]];
}
public function changeAccess(Request $request, bool $grant): void
{
$data = $request->validate(['email' => 'required|email|max:255', 'permission' => 'required|string', 'unit_id' => 'nullable|integer|min:1',
'reason' => 'required|string|max:500', 'tenant_id' => 'prohibited', 'company_id' => 'prohibited', 'user_id' => 'prohibited']);
$this->mutate($request, function ($ids, $actor) use ($data, $grant): void {
$company = $ids['company_id']; $tenant = $ids['tenant_id'];
$this->permissions->require($actor, $tenant, $company, 'access.manage');
$unit = isset($data['unit_id']) ? (int) $data['unit_id'] : null;
$catalogue = $unit === null ? Permissions::COMPANY : Permissions::UNIT;
abort_unless(in_array($data['permission'], $catalogue, true), 422, 'Permissão ou âmbito inválido.');
$this->permissions->require($actor, $tenant, $company, $data['permission'], $unit);
if ($unit !== null) {
$this->permissions->require($actor, $tenant, $company, 'unit.view', $unit);
abort_unless(DB::table('units')->where('id', $unit)->where('tenant_id', $tenant)->where('company_id', $company)->exists(), 403);
}
$target = User::query()->where('email', mb_strtolower($data['email']))->first();
if (! $target) { throw ValidationException::withMessages(['email' => 'Não foi possível atribuir este acesso. Confirme o utilizador pelo procedimento local.']); }
$membership = DB::table('company_memberships')->where($ids)->where('user_id', $target->id);
if (! $membership->exists()) {
abort_unless($grant && $data['permission'] === 'company.view' && $unit === null, 403, 'Conceda primeiro consulta da empresa.');
DB::table('company_memberships')->insert($ids + ['user_id' => $target->id]);
}
if ($grant && $data['permission'] !== 'company.view') {
$this->permissions->require($target->id, $tenant, $company, 'company.view');
if ($unit && $data['permission'] === 'unit.edit') { $this->permissions->require($target->id, $tenant, $company, 'unit.view', $unit); }
}
$removed = [];
if ($grant) { $this->insertGrant($target->id, $ids, $unit, $data['permission']); }
else {
$query = DB::table('permission_grants')->where($ids)->where('user_id', $target->id);
if ($data['permission'] === 'company.view') {
// Revocation deactivates all derived grants; reject partial authority rather than silently exceeding it.
foreach ($query->get() as $existing) { $this->permissions->require($actor, $tenant, $company, $existing->permission, $existing->unit_id ? (int) $existing->unit_id : null); }
$removed = $query->get(['permission', 'unit_id'])->map(fn ($row) => (array) $row)->all();
$query->delete();
} else {
if ($data['permission'] === 'unit.view') {
if ($this->permissions->has($target->id, $tenant, $company, 'unit.edit', $unit)) { $this->permissions->require($actor, $tenant, $company, 'unit.edit', $unit); }
$query->where('unit_id', $unit)->whereIn('permission', Permissions::UNIT);
} else { $query->where('unit_id', $unit)->where('permission', $data['permission']); }
$removed = $query->get(['permission', 'unit_id'])->map(fn ($row) => (array) $row)->all();
$query->delete();
}
}
Audit::record($grant ? 'access.granted' : 'access.revoked', $actor, $tenant, $company, $unit, $target->id,
['permission' => $data['permission'], 'reason' => $data['reason'], 'grants' => $grant ? [['permission' => $data['permission'], 'unit_id' => $unit]] : $removed]);
});
}
private function insertGrant(int $user, array $ids, ?int $unit, string $permission): void
{
DB::table('permission_grants')->insertOrIgnore($ids + ['user_id' => $user, 'unit_id' => $unit, 'permission' => $permission, 'created_at' => now(), 'updated_at' => now()]);
}
private function mutate(Request $request, callable $operation): mixed
{
$selected = $this->context->identifiers();
return DB::transaction(function () use ($request, $operation, $selected) {
// All access changes and business mutations serialize on the same company row.
DB::table('companies')->where('id', $selected['company_id'])->where('tenant_id', $selected['tenant_id'])->lockForUpdate()->firstOrFail();
$this->context->selectFor($request->user()->id, $selected['tenant_id'], $selected['company_id'], $this->context->unitId());
return $operation($selected, $request->user()->id);
});
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Modules\Identity\Services;
use Illuminate\Support\Facades\DB;
final class LocalDatabaseGuard
{
public static function allows(): bool
{
if (! app()->environment('local')) { return false; }
$connection = config('database.default');
$config = config("database.connections.$connection", []);
foreach (['driver' => 'pgsql', 'host' => '127.0.0.1', 'port' => '55432', 'database' => 'sgf_tempo_dev', 'username' => 'sgf_dev_app'] as $key => $expected) {
if ((string) ($config[$key] ?? '') !== $expected) { return false; }
}
if (! empty($config['url']) || ! empty($config['read']) || ! empty($config['write'])) { return false; }
try {
$identity = DB::selectOne('SELECT current_database() AS db, current_user AS role, rolsuper, rolcreatedb, rolcreaterole, rolbypassrls FROM pg_roles WHERE rolname = current_user');
return $identity->db === 'sgf_tempo_dev' && $identity->role === 'sgf_dev_app'
&& ! $identity->rolsuper && ! $identity->rolcreatedb && ! $identity->rolcreaterole && ! $identity->rolbypassrls;
} catch (\Throwable) { return false; }
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Modules\Identity\Services;
use Illuminate\Support\Facades\DB;
final class Permissions
{
public const COMPANY = ['company.view', 'company.edit', 'unit.create', 'access.manage'];
public const UNIT = ['unit.view', 'unit.edit'];
public function has(int $user, int $tenant, int $company, string $permission, ?int $unit = null): bool
{
return DB::table('permission_grants')->where('user_id', $user)->where('tenant_id', $tenant)
->where('company_id', $company)->where('permission', $permission)->where('unit_id', $unit)->exists();
}
public function require(int $user, int $tenant, int $company, string $permission, ?int $unit = null): void
{
abort_unless($this->has($user, $tenant, $company, $permission, $unit), 403, 'Acção não autorizada.');
}
public function unitIds(int $user, int $tenant, int $company): array
{
return DB::table('permission_grants')->where('user_id', $user)->where('tenant_id', $tenant)
->where('company_id', $company)->where('permission', 'unit.view')->pluck('unit_id')->map(fn ($id) => (int) $id)->all();
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Modules\Identity\Services;
use RuntimeException;
final class ProtectedProvisioningInput
{
public static function read($stream, string $email): array
{
try {
$raw = stream_get_contents($stream, 16385);
if (! is_string($raw) || strlen($raw) > 16384) { throw new RuntimeException(); }
$data = json_decode($raw, true, 16, JSON_THROW_ON_ERROR);
if (! is_array($data) || ($data['confirmed'] ?? null) !== true || ($data['email'] ?? null) !== $email
|| ! is_string($data['password'] ?? null) || ! is_string($data['confirmation'] ?? null)) {
throw new RuntimeException();
}
return [$data['password'], $data['confirmation']];
} catch (\Throwable) {
// Never include payload, parser errors, credentials or tokens in diagnostics.
throw new RuntimeException('Entrada protegida inválida ou não confirmada. Nada alterado.');
}
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use App\Shared\Context\CompanyContext;
use App\Shared\Testing\TestingDatabaseGuard;
use Illuminate\Console\Events\CommandStarting;
use Illuminate\Support\Facades\Event;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
$this->app->scoped(CompanyContext::class, fn () => new CompanyContext());
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
if ($this->app->environment(['testing', 'browser-testing']) || defined('PHPUNIT_COMPOSER_INSTALL')) {
TestingDatabaseGuard::assertSafe();
}
Event::listen(CommandStarting::class, function (CommandStarting $event): void {
if (in_array($event->command, ['migrate:fresh', 'migrate:refresh', 'migrate:reset', 'migrate:rollback', 'db:wipe'], true)) {
$connection = $event->input->hasOption('database') ? $event->input->getOption('database') : null;
TestingDatabaseGuard::assertSafe($connection);
}
});
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Shared\Context;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
trait BelongsToCompany
{
protected static function bootBelongsToCompany(): void
{
static::addGlobalScope('company', function (Builder $query): void {
foreach (app(CompanyContext::class)->identifiers() as $column => $value) {
$query->where($query->getModel()->qualifyColumn($column), $value);
}
});
static::saving(function (Model $model): void {
foreach (app(CompanyContext::class)->identifiers() as $column => $value) {
if ($model->getAttribute($column) !== null && (int) $model->getAttribute($column) !== $value) {
throw new AccessDeniedHttpException('Contexto de empresa incompatível.');
}
$model->setAttribute($column, $value);
}
});
static::deleting(function (Model $model): void {
foreach (app(CompanyContext::class)->identifiers() as $column => $value) {
if ((int) $model->getAttribute($column) !== $value) {
throw new AccessDeniedHttpException('Contexto de empresa incompatível.');
}
}
});
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace App\Shared\Context;
use App\Modules\Identity\Models\Company;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use App\Modules\Identity\Services\Permissions;
final class CompanyContext
{
private ?array $identifiers = null;
private ?int $userId = null;
private ?int $unitId = null;
public function selectFor(int $userId, int $tenantId, int $companyId, ?int $unitId = null): void
{
$this->clear();
$allowed = DB::table('company_memberships')
->where('user_id', $userId)->where('tenant_id', $tenantId)
->where('company_id', $companyId)->exists();
if (! $allowed || ! Company::query()->where('tenant_id', $tenantId)->whereKey($companyId)->exists()) {
throw new AccessDeniedHttpException('Empresa não autorizada.');
}
app(Permissions::class)->require($userId, $tenantId, $companyId, 'company.view');
if ($unitId !== null) {
app(Permissions::class)->require($userId, $tenantId, $companyId, 'unit.view', $unitId);
abort_unless(DB::table('units')->where('tenant_id', $tenantId)->where('company_id', $companyId)->where('id', $unitId)->exists(), 403);
}
$this->identifiers = ['tenant_id' => $tenantId, 'company_id' => $companyId];
$this->userId = $userId;
$this->unitId = $unitId;
}
public function identifiers(): array
{
return $this->identifiers ?? throw new AccessDeniedHttpException('Contexto de empresa obrigatório.');
}
public function clear(): void
{
$this->identifiers = null;
$this->userId = null;
$this->unitId = null;
}
public function userId(): int { $this->identifiers(); return $this->userId; }
public function unitId(): ?int { $this->identifiers(); return $this->unitId; }
public function permittedUnitIds(): array
{
$ids = $this->identifiers();
return app(Permissions::class)->unitIds($this->userId(), $ids['tenant_id'], $ids['company_id']);
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Shared\Testing;
use Illuminate\Support\Facades\DB;
use RuntimeException;
final class TestingDatabaseGuard
{
public static function assertSafe(?string $connection = null): void
{
$name = $connection ?: config('database.default');
$config = config("database.connections.{$name}", []);
$expected = [
'driver' => 'pgsql', 'host' => '127.0.0.1', 'port' => '55432',
'database' => 'sgf_tempo_test', 'username' => 'sgf_test_runner',
];
foreach ($expected as $key => $value) {
if ((string) ($config[$key] ?? '') !== $value) {
throw new RuntimeException('SGF_TEST_DATABASE_REFUSED: apenas sgf_tempo_test no cluster próprio é permitido.');
}
}
if (! empty($config['url']) || ! empty($config['read']) || ! empty($config['write']) || ($config['search_path'] ?? 'public') !== 'public') {
throw new RuntimeException('SGF_TEST_DATABASE_REFUSED: redireccionamento de ligação não permitido.');
}
$identity = DB::connection($name)->selectOne('SELECT current_database() AS db, current_user AS role');
if ($identity->db !== 'sgf_tempo_test' || $identity->role !== 'sgf_test_runner') {
throw new RuntimeException('SGF_TEST_DATABASE_REFUSED: destino efectivo inválido.');
}
}
}
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);
+21
View File
@@ -0,0 +1,21 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
//
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
);
})->create();
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+7
View File
@@ -0,0 +1,7 @@
<?php
use App\Providers\AppServiceProvider;
return [
AppServiceProvider::class,
];
+65
View File
@@ -0,0 +1,65 @@
{
"$schema": "https://getcomposer.org/schema.json",
"name": "tempo/sgf-tempo",
"type": "project",
"description": "SGF Tempo local modular foundation",
"keywords": [
"laravel",
"framework"
],
"license": "MIT",
"require": {
"php": "~8.4.0",
"ext-pdo_pgsql": "*",
"ext-intl": "*",
"ext-sodium": "*",
"laravel/framework": "13.38.0",
"brick/math": "0.14.8"
},
"require-dev": {
"mockery/mockery": "1.6.12",
"phpunit/phpunit": "12.5.40"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"test": "@php vendor/phpunit/phpunit/phpunit",
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
]
},
"extra": {
"laravel": {
"dont-discover": [
]
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
},
"platform": {
"php": "8.4.27"
}
},
"minimum-stability": "stable",
"prefer-stable": true
}
Generated
+7795
View File
File diff suppressed because it is too large Load Diff
+126
View File
@@ -0,0 +1,126 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
|
*/
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| the application so that it's available within Artisan commands.
|
*/
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. The timezone
| is set to "UTC" by default as it is suitable for most use cases.
|
*/
'timezone' => 'UTC',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by Laravel's translation / localization methods. This option can be
| set to any locale for which you plan to have translation strings.
|
*/
'locale' => env('APP_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is utilized by Laravel's encryption services and should be set
| to a random, 32 character string to ensure that all encrypted values
| are secure. You should do this prior to deploying the application.
|
*/
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],
/*
|--------------------------------------------------------------------------
| Maintenance Mode Driver
|--------------------------------------------------------------------------
|
| These configuration options determine the driver used to determine and
| manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines.
|
| Supported drivers: "file", "cache", "array"
|
*/
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
];
+117
View File
@@ -0,0 +1,117 @@
<?php
use App\Models\User;
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| which utilizes session storage plus the Eloquent user provider.
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| Supported: "session"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| If you have multiple user tables or models you may configure multiple
| providers to represent the model / table. These providers may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', User::class),
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| These configuration options specify the behavior of Laravel's password
| reset functionality, including the table utilized for token storage
| and the user provider that is invoked to actually retrieve users.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the number of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];
+136
View File
@@ -0,0 +1,136 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache store that will be used by the
| framework. This connection is utilized if another isn't explicitly
| specified when running a cache operation inside the application.
|
*/
'default' => env('CACHE_STORE', 'database'),
/*
|--------------------------------------------------------------------------
| Cache Stores
|--------------------------------------------------------------------------
|
| Here you may define all of the cache "stores" for your application as
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
| Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "storage", "octane",
| "session", "failover", "null"
|
*/
'stores' => [
'array' => [
'driver' => 'array',
'serialize' => false,
],
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],
'file' => [
'driver' => 'file',
'path' => env('CACHE_FILE_PATH', storage_path('framework/cache/data')),
'lock_path' => env('CACHE_LOCK_PATH', storage_path('framework/cache/locks')),
],
'storage' => [
'driver' => 'storage',
'disk' => env('CACHE_STORAGE_DISK'),
'path' => env('CACHE_STORAGE_PATH', 'framework/cache/data'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
'sasl' => [
env('MEMCACHED_USERNAME'),
env('MEMCACHED_PASSWORD'),
],
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
],
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
'endpoint' => env('DYNAMODB_ENDPOINT'),
],
'octane' => [
'driver' => 'octane',
],
'failover' => [
'driver' => 'failover',
'stores' => [
'database',
'array',
],
],
],
/*
|--------------------------------------------------------------------------
| Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
| stores, there might be other applications using the same cache. For
| that reason, you may prefix every cache key to avoid collisions.
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
/*
|--------------------------------------------------------------------------
| Serializable Classes
|--------------------------------------------------------------------------
|
| This value determines the classes that can be unserialized from cache
| storage. By default, no PHP classes will be unserialized from your
| cache to prevent gadget chain attacks if your APP_KEY is leaked.
|
*/
'serializable_classes' => false,
];
+184
View File
@@ -0,0 +1,184 @@
<?php
use Illuminate\Support\Str;
use Pdo\Mysql;
return [
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for database operations. This is
| the connection which will be utilized unless another connection
| is explicitly specified when you execute a query / statement.
|
*/
'default' => env('DB_CONNECTION', 'sqlite'),
/*
|--------------------------------------------------------------------------
| Database Connections
|--------------------------------------------------------------------------
|
| Below are all of the database connections defined for your application.
| An example configuration is provided for each database system which
| is supported by Laravel. You're free to add / remove connections.
|
*/
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
'transaction_mode' => 'DEFERRED',
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'mariadb' => [
'driver' => 'mariadb',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'),
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DB_URL'),
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
],
],
/*
|--------------------------------------------------------------------------
| Migration Repository Table
|--------------------------------------------------------------------------
|
| This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run on the database.
|
*/
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
/*
|--------------------------------------------------------------------------
| Redis Databases
|--------------------------------------------------------------------------
|
| Redis is an open source, fast, and advanced key-value store that also
| provides a richer body of commands than a typical key-value system
| such as Memcached. You may define your connection settings here.
|
*/
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
'persistent' => env('REDIS_PERSISTENT', false),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
],
];
+80
View File
@@ -0,0 +1,80 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application for file storage.
|
*/
'default' => env('FILESYSTEM_DISK', 'local'),
/*
|--------------------------------------------------------------------------
| Filesystem Disks
|--------------------------------------------------------------------------
|
| Below you may configure as many filesystem disks as necessary, and you
| may even configure multiple disks for the same driver. Examples for
| most supported storage drivers are configured here for reference.
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
*/
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true,
'throw' => false,
'report' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
'report' => false,
],
],
/*
|--------------------------------------------------------------------------
| Symbolic Links
|--------------------------------------------------------------------------
|
| Here you may configure the symbolic links that will be created when the
| `storage:link` Artisan command is executed. The array keys should be
| the locations of the links and the values should be their targets.
|
*/
'links' => [
public_path('storage') => storage_path('app/public'),
],
];
+7
View File
@@ -0,0 +1,7 @@
<?php
return [
'login_attempts' => 5,
'login_decay_seconds' => 60,
'login_ip_attempts' => 30,
];
+140
View File
@@ -0,0 +1,140 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
return [
/*
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that is utilized to write
| messages to your logs. The value provided here should match one of
| the channels present in the list of "channels" configured below.
|
*/
'default' => env('LOG_CHANNEL', 'stack'),
/*
|--------------------------------------------------------------------------
| Deprecations Log Channel
|--------------------------------------------------------------------------
|
| This option controls the log channel that should be used to log warnings
| regarding deprecated PHP and library features. This allows you to get
| your application ready for upcoming major versions of dependencies.
|
*/
'deprecations' => [
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
],
/*
|--------------------------------------------------------------------------
| Log Channels
|--------------------------------------------------------------------------
|
| Here you may configure the log channels for your application. Laravel
| utilizes the Monolog PHP logging library, which includes a variety
| of powerful log handlers and formatters that you're free to use.
|
| Available drivers: "single", "daily", "monthly", "slack", "syslog",
| "errorlog", "monolog", "custom", "stack"
|
*/
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'max_files' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],
'monthly' => [
'driver' => 'monthly',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'max_files' => 3,
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
],
'papertrail' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'),
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
],
'processors' => [PsrLogMessageProcessor::class],
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'handler_with' => [
'stream' => 'php://stderr',
],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
],
];
+118
View File
@@ -0,0 +1,118 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Mailer
|--------------------------------------------------------------------------
|
| This option controls the default mailer that is used to send all email
| messages unless another mailer is explicitly specified when sending
| the message. All additional mailers can be configured within the
| "mailers" array. Examples of each type of mailer are provided.
|
*/
'default' => env('MAIL_MAILER', 'log'),
/*
|--------------------------------------------------------------------------
| Mailer Configurations
|--------------------------------------------------------------------------
|
| Here you may configure all of the mailers used by your application plus
| their respective settings. Several examples have been configured for
| you and you are free to add your own as your application requires.
|
| Laravel supports a variety of mail "transport" drivers that can be used
| when delivering an email. You may specify which one you're using for
| your mailers below. You may also add additional mailers if needed.
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| "postmark", "resend", "log", "array",
| "failover", "roundrobin"
|
*/
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'scheme' => env('MAIL_SCHEME'),
'url' => env('MAIL_URL'),
'host' => env('MAIL_HOST', '127.0.0.1'),
'port' => env('MAIL_PORT', 2525),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
],
'ses' => [
'transport' => 'ses',
],
'postmark' => [
'transport' => 'postmark',
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
// 'client' => [
// 'timeout' => 5,
// ],
],
'resend' => [
'transport' => 'resend',
],
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
],
'log' => [
'transport' => 'log',
'channel' => env('MAIL_LOG_CHANNEL'),
],
'array' => [
'transport' => 'array',
],
'failover' => [
'transport' => 'failover',
'mailers' => [
'smtp',
'log',
],
'retry_after' => 60,
],
'roundrobin' => [
'transport' => 'roundrobin',
'mailers' => [
'ses',
'postmark',
],
'retry_after' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Global "From" Address
|--------------------------------------------------------------------------
|
| You may wish for all emails sent by your application to be sent from
| the same address. Here you may specify a name and address that is
| used globally for all emails that are sent by your application.
|
*/
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
],
];
+129
View File
@@ -0,0 +1,129 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Queue Connection Name
|--------------------------------------------------------------------------
|
| Laravel's queue supports a variety of backends via a single, unified
| API, giving you convenient access to each backend using identical
| syntax for each. The default queue connection is defined below.
|
*/
'default' => env('QUEUE_CONNECTION', 'database'),
/*
|--------------------------------------------------------------------------
| Queue Connections
|--------------------------------------------------------------------------
|
| Here you may configure the connection options for every queue backend
| used by your application. An example configuration is provided for
| each backend supported by Laravel. You're also free to add more.
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
| "deferred", "background", "failover", "null"
|
*/
'connections' => [
'sync' => [
'driver' => 'sync',
],
'database' => [
'driver' => 'database',
'connection' => env('DB_QUEUE_CONNECTION'),
'table' => env('DB_QUEUE_TABLE', 'jobs'),
'queue' => env('DB_QUEUE', 'default'),
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
'after_commit' => false,
],
'beanstalkd' => [
'driver' => 'beanstalkd',
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
'queue' => env('BEANSTALKD_QUEUE', 'default'),
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
'block_for' => 0,
'after_commit' => false,
],
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
'queue' => env('SQS_QUEUE', 'default'),
'suffix' => env('SQS_SUFFIX'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'after_commit' => false,
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null,
'after_commit' => false,
],
'deferred' => [
'driver' => 'deferred',
],
'background' => [
'driver' => 'background',
],
'failover' => [
'driver' => 'failover',
'connections' => [
'database',
'deferred',
],
],
],
/*
|--------------------------------------------------------------------------
| Job Batching
|--------------------------------------------------------------------------
|
| The following options configure the database and table that store job
| batching information. These options can be updated to any database
| connection and table which has been defined by your application.
|
*/
'batching' => [
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'job_batches',
],
/*
|--------------------------------------------------------------------------
| Failed Queue Jobs
|--------------------------------------------------------------------------
|
| These options configure the behavior of failed queue job logging so you
| can control how and where failed jobs are stored. Laravel ships with
| support for storing failed jobs in a simple file or in a database.
|
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
*/
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'failed_jobs',
],
];
+38
View File
@@ -0,0 +1,38 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Third Party Services
|--------------------------------------------------------------------------
|
| This file is for storing the credentials for third party services such
| as Resend, Postmark, AWS, and more. This file provides the de facto
| location for this type of information, allowing packages to have
| a conventional file to locate the various service credentials.
|
*/
'postmark' => [
'key' => env('POSTMARK_API_KEY'),
],
'resend' => [
'key' => env('RESEND_API_KEY'),
],
'ses' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
'slack' => [
'notifications' => [
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
],
],
];
+233
View File
@@ -0,0 +1,233 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Session Driver
|--------------------------------------------------------------------------
|
| This option determines the default session driver that is utilized for
| incoming requests. Laravel supports a variety of storage options to
| persist session data. Database storage is a great default choice.
|
| Supported: "file", "cookie", "database", "memcached",
| "redis", "dynamodb", "array"
|
*/
'driver' => env('SESSION_DRIVER', 'database'),
/*
|--------------------------------------------------------------------------
| Session Lifetime
|--------------------------------------------------------------------------
|
| Here you may specify the number of minutes that you wish the session
| to be allowed to remain idle before it expires. If you want them
| to expire immediately when the browser is closed then you may
| indicate that via the expire_on_close configuration option.
|
*/
'lifetime' => (int) env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
/*
|--------------------------------------------------------------------------
| Session Encryption
|--------------------------------------------------------------------------
|
| This option allows you to easily specify that all of your session data
| should be encrypted before it's stored. All encryption is performed
| automatically by Laravel and you may use the session like normal.
|
*/
'encrypt' => env('SESSION_ENCRYPT', false),
/*
|--------------------------------------------------------------------------
| Session File Location
|--------------------------------------------------------------------------
|
| When utilizing the "file" session driver, the session files are placed
| on disk. The default storage location is defined here; however, you
| are free to provide another location where they should be stored.
|
*/
'files' => env('SESSION_FILES', storage_path('framework/sessions')),
/*
|--------------------------------------------------------------------------
| Session Database Connection
|--------------------------------------------------------------------------
|
| When using the "database" or "redis" session drivers, you may specify a
| connection that should be used to manage these sessions. This should
| correspond to a connection in your database configuration options.
|
*/
'connection' => env('SESSION_CONNECTION'),
/*
|--------------------------------------------------------------------------
| Session Database Table
|--------------------------------------------------------------------------
|
| When using the "database" session driver, you may specify the table to
| be used to store sessions. Of course, a sensible default is defined
| for you; however, you're welcome to change this to another table.
|
*/
'table' => env('SESSION_TABLE', 'sessions'),
/*
|--------------------------------------------------------------------------
| Session Cache Store
|--------------------------------------------------------------------------
|
| When using one of the framework's cache driven session backends, you may
| define the cache store which should be used to store the session data
| between requests. This must match one of your defined cache stores.
|
| Affects: "dynamodb", "memcached", "redis"
|
*/
'store' => env('SESSION_STORE'),
/*
|--------------------------------------------------------------------------
| Session Sweeping Lottery
|--------------------------------------------------------------------------
|
| Some session drivers must manually sweep their storage location to get
| rid of old sessions from storage. Here are the chances that it will
| happen on a given request. By default, the odds are 2 out of 100.
|
*/
'lottery' => [2, 100],
/*
|--------------------------------------------------------------------------
| Session Cookie Name
|--------------------------------------------------------------------------
|
| Here you may change the name of the session cookie that is created by
| the framework. Typically, you should not need to change this value
| since doing so does not grant a meaningful security improvement.
|
*/
'cookie' => env(
'SESSION_COOKIE',
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
),
/*
|--------------------------------------------------------------------------
| Session Cookie Path
|--------------------------------------------------------------------------
|
| The session cookie path determines the path for which the cookie will
| be regarded as available. Typically, this will be the root path of
| your application, but you're free to change this when necessary.
|
*/
'path' => env('SESSION_PATH', '/'),
/*
|--------------------------------------------------------------------------
| Session Cookie Domain
|--------------------------------------------------------------------------
|
| This value determines the domain and subdomains the session cookie is
| available to. By default, the cookie will be available to the root
| domain without subdomains. Typically, this shouldn't be changed.
|
*/
'domain' => env('SESSION_DOMAIN'),
/*
|--------------------------------------------------------------------------
| HTTPS Only Cookies
|--------------------------------------------------------------------------
|
| By setting this option to true, session cookies will only be sent back
| to the server if the browser has a HTTPS connection. This will keep
| the cookie from being sent to you when it can't be done securely.
|
*/
'secure' => env('SESSION_SECURE_COOKIE'),
/*
|--------------------------------------------------------------------------
| HTTP Access Only
|--------------------------------------------------------------------------
|
| Setting this value to true will prevent JavaScript from accessing the
| value of the cookie and the cookie will only be accessible through
| the HTTP protocol. It's unlikely you should disable this option.
|
*/
'http_only' => env('SESSION_HTTP_ONLY', true),
/*
|--------------------------------------------------------------------------
| Same-Site Cookies
|--------------------------------------------------------------------------
|
| This option determines how your cookies behave when cross-site requests
| take place, and can be used to mitigate CSRF attacks. By default, we
| will set this value to "lax" to permit secure cross-site requests.
|
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
| Supported: "lax", "strict", "none", null
|
*/
'same_site' => env('SESSION_SAME_SITE', 'lax'),
/*
|--------------------------------------------------------------------------
| Partitioned Cookies
|--------------------------------------------------------------------------
|
| Setting this value to true will tie the cookie to the top-level site for
| a cross-site context. Partitioned cookies are accepted by the browser
| when flagged "secure" and the Same-Site attribute is set to "none".
|
*/
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
/*
|--------------------------------------------------------------------------
| Session Serialization
|--------------------------------------------------------------------------
|
| This value controls the serialization strategy for session data, which
| is JSON by default. Setting this to "php" allows the storage of PHP
| objects in the session but can make an application vulnerable to
| "gadget chain" serialization attacks if the APP_KEY is leaked.
|
| Supported: "json", "php"
|
*/
'serialization' => 'json',
];
+1
View File
@@ -0,0 +1 @@
*.sqlite*
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends Factory<User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
];
}
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
}
@@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
}
};
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->bigInteger('expiration')->index();
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->bigInteger('expiration')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};
@@ -0,0 +1,59 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedSmallInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->string('connection');
$table->string('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
$table->index(['connection', 'queue', 'failed_at']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
Schema::dropIfExists('job_batches');
Schema::dropIfExists('failed_jobs');
}
};
@@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('tenants', function (Blueprint $table): void {
$table->id();
$table->string('name');
$table->timestamps();
});
Schema::create('companies', function (Blueprint $table): void {
$table->id();
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
$table->string('name');
$table->timestamps();
$table->unique(['tenant_id', 'id']);
});
Schema::create('company_memberships', function (Blueprint $table): void {
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->unsignedBigInteger('tenant_id');
$table->unsignedBigInteger('company_id');
$table->foreign(['tenant_id', 'company_id'])->references(['tenant_id', 'id'])->on('companies')->restrictOnDelete();
$table->primary(['user_id', 'tenant_id', 'company_id']);
});
Schema::create('units', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('tenant_id');
$table->unsignedBigInteger('company_id');
$table->foreign(['tenant_id', 'company_id'])->references(['tenant_id', 'id'])->on('companies')->restrictOnDelete();
$table->string('name');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('units');
Schema::dropIfExists('company_memberships');
Schema::dropIfExists('companies');
Schema::dropIfExists('tenants');
}
};
@@ -0,0 +1,77 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', fn (Blueprint $table) => $table->unsignedInteger('auth_version')->default(1));
Schema::table('units', fn (Blueprint $table) => $table->unique(['tenant_id', 'company_id', 'id']));
Schema::create('permission_grants', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('user_id');
$table->unsignedBigInteger('tenant_id');
$table->unsignedBigInteger('company_id');
$table->unsignedBigInteger('unit_id')->nullable();
$table->string('permission', 40);
$table->timestamps();
$table->foreign(['user_id', 'tenant_id', 'company_id'])->references(['user_id', 'tenant_id', 'company_id'])->on('company_memberships')->restrictOnDelete();
$table->foreign(['tenant_id', 'company_id', 'unit_id'])->references(['tenant_id', 'company_id', 'id'])->on('units')->restrictOnDelete();
});
DB::statement("CREATE UNIQUE INDEX permission_grants_company_unique ON permission_grants(user_id, company_id, permission) WHERE unit_id IS NULL");
DB::statement("CREATE UNIQUE INDEX permission_grants_unit_unique ON permission_grants(user_id, company_id, unit_id, permission) WHERE unit_id IS NOT NULL");
DB::statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK ((permission IN ('company.view','company.edit','unit.create','access.manage') AND unit_id IS NULL) OR (permission IN ('unit.view','unit.edit') AND unit_id IS NOT NULL))");
Schema::create('identity_audit', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('actor_id')->nullable();
$table->unsignedBigInteger('tenant_id')->nullable();
$table->unsignedBigInteger('company_id')->nullable();
$table->unsignedBigInteger('unit_id')->nullable();
$table->unsignedBigInteger('subject_id')->nullable();
$table->string('action', 80);
$table->jsonb('details');
$table->timestampTz('created_at')->useCurrent();
});
DB::unprepared(<<<'SQL'
CREATE OR REPLACE FUNCTION sgf_immutable_ownership() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.id IS DISTINCT FROM OLD.id OR NEW.tenant_id IS DISTINCT FROM OLD.tenant_id THEN
RAISE EXCEPTION 'SGF_IMMUTABLE_OWNERSHIP';
END IF;
IF TG_TABLE_NAME IN ('units', 'permission_grants') THEN
IF NEW.company_id IS DISTINCT FROM OLD.company_id THEN
RAISE EXCEPTION 'SGF_IMMUTABLE_OWNERSHIP';
END IF;
END IF;
IF TG_TABLE_NAME = 'permission_grants' THEN
IF NEW.user_id IS DISTINCT FROM OLD.user_id OR NEW.unit_id IS DISTINCT FROM OLD.unit_id OR NEW.permission IS DISTINCT FROM OLD.permission THEN
RAISE EXCEPTION 'SGF_IMMUTABLE_OWNERSHIP';
END IF;
END IF;
RETURN NEW;
END; $$;
CREATE TRIGGER companies_immutable BEFORE UPDATE ON companies FOR EACH ROW EXECUTE FUNCTION sgf_immutable_ownership();
CREATE TRIGGER units_immutable BEFORE UPDATE ON units FOR EACH ROW EXECUTE FUNCTION sgf_immutable_ownership();
CREATE TRIGGER grants_immutable BEFORE UPDATE ON permission_grants FOR EACH ROW EXECUTE FUNCTION sgf_immutable_ownership();
CREATE OR REPLACE FUNCTION sgf_append_only_audit() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN RAISE EXCEPTION 'SGF_APPEND_ONLY_AUDIT'; END; $$;
CREATE TRIGGER audit_append_only BEFORE UPDATE OR DELETE ON identity_audit FOR EACH ROW EXECUTE FUNCTION sgf_append_only_audit();
SQL);
}
public function down(): void
{
Schema::dropIfExists('identity_audit');
Schema::dropIfExists('permission_grants');
DB::statement('DROP TRIGGER IF EXISTS units_immutable ON units');
DB::statement('DROP TRIGGER IF EXISTS companies_immutable ON companies');
DB::statement('DROP FUNCTION IF EXISTS sgf_immutable_ownership()');
DB::statement('DROP FUNCTION IF EXISTS sgf_append_only_audit()');
Schema::table('units', fn (Blueprint $table) => $table->dropUnique(['tenant_id', 'company_id', 'id']));
Schema::table('users', fn (Blueprint $table) => $table->dropColumn('auth_version'));
}
};
@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::unprepared(<<<'SQL'
CREATE OR REPLACE FUNCTION sgf_immutable_membership() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.user_id IS DISTINCT FROM OLD.user_id OR NEW.tenant_id IS DISTINCT FROM OLD.tenant_id OR NEW.company_id IS DISTINCT FROM OLD.company_id THEN
RAISE EXCEPTION 'SGF_IMMUTABLE_OWNERSHIP';
END IF;
RETURN NEW;
END; $$;
CREATE TRIGGER membership_immutable BEFORE UPDATE ON company_memberships FOR EACH ROW EXECUTE FUNCTION sgf_immutable_membership();
SQL);
}
public function down(): void
{
DB::statement('DROP TRIGGER IF EXISTS membership_immutable ON company_memberships');
DB::statement('DROP FUNCTION IF EXISTS sgf_immutable_membership()');
}
};
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
use WithoutModelEvents;
/**
* Seed the application's database.
*/
public function run(): void
{
// No default accounts, passwords or scopes. Use confirmed local provisioning.
}
}
+105
View File
@@ -0,0 +1,105 @@
# Entrega do primeiro percurso funcional
Implementação local sobre o SGF_TEMPO_Roteiro_v0_9.docx e a avaliação anterior. Destino: `C:\laragon\www\sgf-tempo`. Sem reinstalação, mudança de versões, publicação, acesso ao InvesteMoz ou módulos financeiros.
## Funcional
- Autenticação real por email/palavra-passe com sessão, limitação de tentativas por identidade/IP, regeneração na entrada e invalidação na saída.
- Se não existir utilizador no ambiente local, o ecrã explica a preparação pendente; não apresenta um login que ainda não pode funcionar.
- Selecção explícita de empresa e unidade, contexto visível, pertenças e permissões revalidadas no servidor a cada pedido.
- Consulta e edição dos nomes existentes; criação de unidades exige concessão própria e atribui ao criador consulta e edição explícitas apenas da unidade criada (consequência aprovada pelo utilizador).
- Concessões persistidas como dados, separadas de tenant/empresa/unidade, sem privilégio por nome/email e sem «todas as unidades».
- Gestão de acessos limitada à empresa activa e aos direitos detidos pelo gestor, incluindo os direitos dependentes abrangidos por revogação.
- Tokens de contexto recusam gravações de formulários anteriores à mudança de empresa/unidade. Identificadores manipulados são rejeitados.
- Triggers PostgreSQL impedem transferir identificadores de empresas, unidades, pertenças e concessões em operações em massa. Auditoria append-only conserva alterações de nomes e acessos, com actor/âmbito/motivo e sem credenciais/tokens.
- Procedimentos locais de pré-visualização, criação de utilizador/primeiro âmbito e recuperação de credencial/concessões. Exigem confirmação concreta, operador e motivo; não usam administração PostgreSQL.
## Delta de base de dados aplicado
Base de destino: **sgf_tempo_dev**, cluster próprio **127.0.0.1:55432**, migrações executadas por **sgf_dev_owner**. A aplicação continua a usar **sgf_dev_app**.
1. `2026_10_25_010000_add_identity_workflow.php`: `users.auth_version`, chave composta de unidades, tabelas `permission_grants` e `identity_audit`, índices/constraints de âmbito e triggers de propriedade/auditoria.
2. `2026_10_25_020000_lock_membership_identifiers.php`: trigger adicional de imutabilidade das chaves de `company_memberships`.
Alterações aditivas, ensaiadas primeiro em sgf_tempo_test. Não houve reset/fresh/wipe ou eliminação de dados em desenvolvimento. A base estava sem utilizadores, tenants, empresas ou unidades antes da operação inicial; nenhum dado sintético foi colocado nela. Eventos de utilização local permanecem na auditoria.
## Ficheiros principais
| Grupo | Ficheiros |
| --- | --- |
| Autenticação e HTTP | `routes/web.php`, `IdentityController.php`, `RequireValidSession.php`, `RequireCompanyContext.php` |
| Regras e auditoria | `IdentityWorkflow.php`, `Permissions.php`, `Audit.php`, `LocalDatabaseGuard.php` |
| Fundação reutilizada | `CompanyContext.php`, `Unit.php`, `User.php`, `AppServiceProvider.php`; sem recriação de modelos |
| Procedimentos locais | `ProvisionIdentityUser.php`, `RecoverIdentityUser.php`, `scripts/identity-status.php` |
| Interface | `resources/js/app.jsx`, `resources/css/app.css` |
| Configuração local | `config/identity.php`, caminhos opcionais de sessões/cache, ignores dos artefactos de prova |
| Provas | `tests/Support/IdentityFixture.php`, `IdentityWorkflowTest.php`, adaptação de `CompanyIsolationTest.php`, `browser-proof.ps1`, `http-proof.mjs`, `prepare-browser-fixture.php` |
| Documentação | README, AGENTS, `IDENTITY_WORKFLOW.md` e este relatório |
O seeder de exemplo deixou de criar uma conta com credencial previsível. Os lockfiles e as versões directas mantiveram-se.
## Resultados
**PHPUnit: 27 testes, 112 asserções aprovadas.** Compilação Vite concluída. `composer check-platform-reqs` aprovado. O incremento não instalou dependências nem alterou versões: PHP 8.4.27 x64 NTS, Laravel 13.38.0, React/React DOM 19.2.9, Node 24.22.1 ARM64, npm 11.19.1, Vite 8.3.5, PHPUnit 12.5.40, PostgreSQL 17.11.
| Critério | Evidência |
| --- | --- |
| Entrada inválida/válida e saída | Testes com credenciais; login real no Edge com conta sintética e saída verificada |
| Sessão antiga não reutilizável | Prova HTTP real, replay do cookie anterior à saída: 401 |
| Empresa/unidade não autorizadas | Testes e HTTP: 403; navegador abriu directamente unidade não autorizada e mostrou recusa |
| Consulta sem edição | Testes/API: 403; navegador mostrou apenas leitura, sem botão de gravação |
| Revogação no pedido seguinte | Dois clientes HTTP independentes: concessão permitiu edição (200), revogação recusou o pedido seguinte (403) |
| Formulário de contexto anterior | Testes e prova HTTP: 409 |
| Identificadores e operações em massa | Testes de payload manipulado e triggers de empresa, unidade, pertença e concessão |
| Delegação superior à própria | Testes e prova HTTP com gestor limitado: 403 |
| Protecção dos pedidos | Prova HTTP sem CSRF/origem válida: 419; ambiente de navegador mantém protecção activa |
| Destino dos testes | Guard existente preservado; comando destrutivo contra desenvolvimento recusado; procedimentos locais recusam base de testes |
As provas visuais usam **apenas sgf_tempo_test**, na porta 8001, com sessões/cache/cookie separados. Não se confundem com a conta real nem com os testes `actingAs`. O servidor temporário foi parado e os dados sintéticos da prova foram eliminados pela recriação posterior exclusivamente da base de testes. A credencial temporária foi removida do ficheiro local; não foi incluída nos relatórios.
Capturas:
- [Entrada inválida](proofs/01-invalid-login.png)
- [Empresa e unidade autorizadas](proofs/02-authorized-context.png)
- [Gestão de acessos](proofs/03-access-management.png)
- [Consulta sem edição](proofs/04-read-only-access.png)
- [URL directa recusada](proofs/05-direct-url-refused.png)
- [Saída](proofs/06-logged-out.png)
- [Resultados HTTP sem segredos](proofs/http-results.json)
## Primeiro administrador
Foi confirmada a operação concreta: tenant Tempo Lda, empresa Tempo Lda e Osmane Nala (`nala@tempo.co.mz`), com `company.view`, `company.edit`, `unit.create` e `access.manage` exclusivamente nessa empresa. Sem unidades iniciais e sem acessos universais.
A palavra-passe só pode ser definida pelo utilizador no terminal local, com entrada oculta. O assistente não a recebe no chat nem a define por omissão. O procedimento aprovado está em `C:\laragon\data\sgf-tempo\run\complete-first-admin.ps1`. A confirmação da operação não equivale à conta já criada: verificar a conclusão com `scripts/identity-status.php` e a mensagem de sucesso do terminal.
Uma primeira tentativa de abertura automática do terminal foi rejeitada pela revisão automática; uma invocação simples do script já preparado abriu depois uma janela PowerShell normal. A instrução ao utilizador é confirmar com `yes`, definir a palavra-passe e repeti-la (a entrada não ecoa caracteres), e actualizar o estado no navegador.
## Limites
Este incremento não decide o isolamento de produção entre clientes nem implementa RLS, MFA, auto-registo, email, anexos, exportações, filas de negócio, fiscalidade, contabilidade ou planos comerciais. O escopo Eloquent é apenas uma camada: endpoints/serviços verificam autorização, constraints/trigger protegem relações e identificadores e os testes demonstram os caminhos incluídos. SQL administrativo e o operador do sistema continuam sujeitos a responsabilidades próprias; isto não é certificação de isolamento para produção.
O catálogo de acções suportadas está no código e as concessões estão nos dados. Não foi construído um motor geral de parâmetros ou toda a experiência Camaleão. Reutilizam-se cadastros e contexto sem copiar empresas/unidades por utilizador. A recuperação local preserva concessões ao alterar uma credencial; a recuperação excepcional de gestão requer outro plano explícito e confirmação, sem administrador universal.
## Correcção da recolha inicial de credencial
O procedimento aprovado usa agora Read-Host -AsSecureString do PowerShell. A confirmação da operação já dada na sessão é preservada; a credencial e a repetição seguem apenas pela entrada padrão do processo, com identidade/consentimento explícitos, sem argumentos ou ficheiros de segredos. O transporte foi verificado com entrada sintética aleatória contendo Unicode e espaços; a sintaxe PowerShell também foi validada. A suite passou com 30 testes e 117 asserções. A criação real continua dependente da conclusão dos dois pedidos protegidos pelo utilizador local.
## Conta inicial concluída
A criação foi concluída pelo utilizador no terminal local. Verificação posterior: Osmane Nala, nala@tempo.co.mz; tenant e empresa Tempo Lda; uma pertença e exactamente quatro concessões de empresa (company.view, company.edit, unit.create, access.manage); nenhuma unidade inicial. O guard local e a verificação de privilégios da aplicação passaram. A palavra-passe não foi consultada pelo assistente. Não repetir o procedimento de criação; usar o login e, se necessário, recuperação local assistida.
## Verificação final para aceitação
Em 25 de Outubro de 2026, a sessão real de Samira foi verificada no navegador: consulta de Tempo Lda e SEGI com campos só de leitura, sem botões de gravação/criação/gestão. A abertura directa de /api/accesses foi recusada. Foi removida a selecção de unidade, confirmada a limpeza do contexto e novamente seleccionada SEGI. A saída regressou ao ecrã de entrada e uma recarga permaneceu sem autenticação. Capturas: proofs/09-samira-read-only.png e proofs/10-real-session-logout.png.
Os direitos de Osmane foram conferidos por leitura e confrontados com as provas anteriores no navegador: gestão de acessos e consulta/edição da empresa, criação de unidades e consulta/edição explícitas da SEGI. Não foi iniciada outra sessão de Osmane nesta verificação nem consultada qualquer palavra-passe.
Estado na verificação: entrega tecnicamente pronta para aceitação do utilizador, no âmbito local e com os limites documentados. A verificação técnica pelo assistente não substitui essa aceitação nem autoriza implementação financeira ou publicação. Prestação de serviços permanece apenas em especificação.
## Aceitação e encerramento pelo utilizador
Em 25 de Outubro de 2026, o utilizador declarou:
> Aceito e dou por concluído o incremento local de entrada, contexto e permissões, no âmbito demonstrado e com os limites documentados em IDENTITY_DELIVERY.md. Preserva esta entrega como base. Serviços mantém-se apenas em especificação; não autorizo ainda implementação financeira nem publicação.
Estado actual: **aceite e concluído**, exclusivamente no âmbito local demonstrado. Preservar esta entrega e as respectivas provas como base dos incrementos seguintes. Esta aceitação não amplia as funcionalidades, não elimina os limites documentados e não autoriza implementação financeira ou publicação.
+70
View File
@@ -0,0 +1,70 @@
# Utilização e recuperação local
## Contexto e permissões
Tenant é o cliente contratante, empresa é a entidade empresarial e unidade é uma subdivisão dessa empresa. Pertença ao tenant não autoriza as suas empresas. Pertença à empresa, por si só, também não concede operações: as permissões são linhas em `permission_grants`.
| Concessão | Âmbito | Efeito |
| --- | --- | --- |
| company.view | Empresa | Consultar e seleccionar empresa |
| company.edit | Empresa | Editar apenas o nome da empresa |
| unit.create | Empresa | Criar unidade; receber consulta/edição explícitas apenas da unidade criada |
| access.manage | Empresa | Gerir concessões nesta empresa, limitado pelos próprios direitos |
| unit.view | Unidade concreta | Consultar e seleccionar essa unidade |
| unit.edit | Unidade concreta | Editar o nome dessa unidade, exigindo consulta e selecção dela |
Não existem papéis universais, privilégios por email ou acesso implícito a todas as unidades. Para conceder a edição de unidade é necessário conceder primeiro a consulta dessa unidade; para outros direitos é necessária consulta da empresa. Um gestor só pode conceder ou revogar direitos que possui no mesmo âmbito. A revogação de consulta de empresa remove os seus direitos dependentes apenas se o gestor tiver autoridade sobre todos eles; caso contrário é recusada. A revogação de consulta de uma unidade remove também a sua edição nas mesmas condições. Estas dependências são registadas na auditoria.
A mudança de empresa limpa a unidade anterior. Cada selecção gera um token de contexto; um pedido de gravação com o token anterior é recusado com 409. Os formulários visíveis são recarregados quando o contexto muda. Identificadores recebidos são sempre revalidados no servidor. Mudanças de direitos aplicam-se no pedido seguinte; cada alteração revalida direitos dentro da transacção e partilha um bloqueio da empresa com as alterações de acessos.
## Preparar outro utilizador, sem acessos automáticos
Substituir os campos pelos dados expressamente autorizados. A execução sem `--apply` é apenas pré-visualização:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 php artisan identity:provision --email="EMAIL" --name="NOME" --operator="RESPONSAVEL" --reason="MOTIVO"
```
Depois da confirmação da operação concreta, repetir com `--apply` num terminal interactivo. A palavra-passe é introduzida de forma oculta, duas vezes. Requer pelo menos 12 caracteres e até 72 bytes, respeitando o algoritmo bcrypt existente. A conta fica sem empresa e sem permissões até um gestor autorizado conceder os direitos pela interface, começando por `company.view`.
Para os primeiros acessos de uma empresa sem gestor, o mesmo comando aceita `--company=ID`, `--company-permission=PERMISSAO` repetido e `--unit-permission=ID:PERMISSAO` repetido. Não existe concessão por omissão. Para novo âmbito inicial, `--tenant-name` e `--company-name` são explícitos, exclusivos de `--company`, e não há reutilização por inferência. Se já houver gestor na empresa, a concessão deve ser feita pela interface dentro dos direitos desse gestor.
Todos os procedimentos locais recusam ambientes ou bases diferentes de `local` / `sgf_tempo_dev`, e exigem o papel de aplicação `sgf_dev_app`, não o administrador PostgreSQL.
## Recuperar palavra-passe
Verificar primeiro a identidade da pessoa e a autorização do responsável local. Não solicitar a credencial no chat. Pré-visualizar:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 php artisan identity:recover --email="EMAIL" --operator="RESPONSAVEL" --reason="MOTIVO"
```
Só depois da confirmação da operação, repetir com `--apply` e definir a nova credencial nos dois pedidos ocultos do terminal. A alteração é transaccional, aumenta a versão de autenticação e invalida as sessões anteriores no pedido seguinte. As concessões são preservadas. O operador e o motivo ficam na auditoria; palavras-passe, hashes, cookies e tokens não são incluídos nela. Não colocar segredos no motivo.
## Recuperar gestão quando não resta gestor
Não há excepção automática nem administrador universal. O operador local deve pré-visualizar e obter confirmação de concessões exactas para uma empresa existente e utilizador existente:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 php artisan identity:provision --existing-user --email="EMAIL" --name="NOME_EXISTENTE" --company=ID --company-permission=company.view --company-permission=access.manage --operator="RESPONSAVEL" --reason="MOTIVO"
```
A recuperação só é permitida se não existir concessão `access.manage` nessa empresa. Não altera a credencial, não cria outro tenant/empresa e não acrescenta direitos além dos explicitamente apresentados. `--apply` exige confirmação no terminal. Não usar SQL directo como procedimento habitual de recuperação.
## Prova de navegador com dados sintéticos
Nunca preparar contas de demonstração em desenvolvimento. Depois de parar outras provas e com as tabelas de teste vazias, iniciar:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\browser-proof.ps1 start
```
A prova usa `sgf_tempo_test`, endereço 127.0.0.1:8001, cookie, sessões e cache próprios. O ambiente `browser-testing` mantém CSRF activo e aplica o mesmo guard do destino de testes. O script recusa uma base não vazia e não apaga dados. A credencial aleatória sintética fica apenas num ficheiro local com ACL restrita, nunca no relatório. Não executar a suite enquanto esta prova está activa.
```powershell
C:\laragon\tools\sgf-tempo\node-v24.22.1-win-arm64\node.exe .\scripts\http-proof.mjs
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\browser-proof.ps1 stop
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\sgf.ps1 test
```
Parar elimina o ficheiro temporário da credencial; o último teste recria apenas a base de testes e elimina os dados sintéticos da prova. Não apresentar esse ficheiro, palavras-passe, cookies ou tokens. As capturas guardadas em `docs/proofs` mostram apenas entidades sintéticas.
+56
View File
@@ -0,0 +1,56 @@
> Registo histórico da preparação inicial. O estado actual e as instruções de utilização estão no README e em IDENTITY_WORKFLOW.md.
# Registo da preparação local
Data: 25 de Outubro de 2026. Destino confirmado: `C:\laragon\www\sgf-tempo`.
## Versões efectivamente instaladas ou utilizadas
| Componente | Versão | Localização / origem |
| --- | --- | --- |
| Windows | 11 Pro ARM64, 10.0.26300 | Sistema existente |
| PHP | 8.4.27 NTS VS17 x64 | C:\laragon\tools\sgf-tempo\php-8.4.27 |
| Composer | 2.10.3 | C:\laragon\tools\sgf-tempo\composer-2.10.3\composer.phar |
| Node.js | 24.22.1 ARM64 | C:\laragon\tools\sgf-tempo\node-v24.22.1-win-arm64 |
| npm | 11.19.1 | Incluído nesse Node.js |
| Esqueleto Laravel | 13.11.0 | laravel/laravel, sem scripts de criação automáticos |
| Laravel Framework | 13.38.0 | composer.lock |
| React / React DOM | 19.2.9 / 19.2.9 | package-lock.json |
| Vite | 8.3.5 | package-lock.json |
| Plugin React | 6.1.2 | package-lock.json |
| Laravel Vite Plugin | 3.2.0 | package-lock.json |
| brick/math | 0.14.8 | composer.lock |
| PHPUnit | 12.5.40 | composer.lock |
| Mockery | 1.6.12 | Dependência auxiliar dos testes, composer.lock |
| PostgreSQL | 17.11 x64 | Binários existentes; cluster e configuração exclusivos |
| Git | 2.55.0.windows.5 | Instalação existente; repositório local inicializado |
Sem substituições das versões propostas. PostGIS não foi activado e Python não foi instalado nem incorporado. A detecção anterior encontrou PostGIS 3.6.2 em ficheiros de extensão e Python 3.10.6 fora do PATH; nenhum é dependência desta entrega.
## Integridade e compatibilidade
Downloads de PHP, Node.js e Composer conferidos por SHA-256 contra metadados oficiais; arquivos e comprovativos estão em `C:\laragon\tools\sgf-tempo\downloads`. PHP x64 executou em Windows ARM64, carregou as extensões necessárias e executou Laravel/PHPUnit. Node ARM64 executou npm, a compilação e Vite. Resolução conjunta concluída sem ignorar requisitos de plataforma. Dependências transitivas exactas preservadas nos lockfiles.
Fontes: https://www.php.net/downloads.php, https://windows.php.net/downloads/releases/releases.json, https://nodejs.org/dist/v24.22.1/SHASUMS256.txt, https://getcomposer.org/download/2.10.3/composer.phar.sha256sum, https://repo.packagist.org e https://registry.npmjs.org.
## Verificações concluídas
- Composer check-platform-reqs: aprovado.
- Composer validate --strict --no-check-all: aprovado. --no-check-all desactiva os avisos contra versões exactas, que são deliberados neste âmbito; as verificações de esquema e lock permanecem.
- Composer audit e npm audit: sem vulnerabilidades conhecidas reportadas no momento da preparação.
- Vite build: concluído; manifest e recursos gerados.
- PHPUnit: 11 testes, 27 asserções aprovadas. Inclui acesso por pertença explícita, recusa entre empresas do mesmo tenant e de tenants diferentes, contexto ausente/limpo, escopo de leitura/escrita, modelo de contexto anterior, relação inválida na base e protecção do destino dos testes.
- migrate:fresh --env=local: recusado pelo guard antes da execução destrutiva.
- Aplicação de desenvolvimento: sgf_dev_app sem administração, sem CREATE no esquema e sem CONNECT à base de testes. Executor de testes sem CONNECT à base de desenvolvimento.
- Base de desenvolvimento sem fixtures dos testes.
- HTTP: /, /up e manifest com 200; /api/context sem autenticação com 401.
- Vite serviu JSX transformado em ARM64, HTTP 200.
- Colisão na porta 8000: arranque recusado, PIDs existentes preservados.
- Paragem e novo arranque do cluster e servidor próprios: concluídos.
- .env, .env.testing, vendor e node_modules ignorados pelo Git. Sem commit, remoto ou publicação.
## Limites e decisões pendentes
A demonstração é local e limitada à identidade/contexto. Não constitui isolamento de produção ou aceitação dos módulos. Permanecem pendentes estratégia de isolamento entre clientes, RLS, autenticação de produto, permissões detalhadas, propagação de contexto a filas/anexos/exportações, regras financeiras, fiscalidade, planos, preços, elegibilidade, quotas, fornecedores e operação dos futuros ambientes. As tabelas cache/jobs do esqueleto são apenas infraestrutura Laravel; esta entrega usa cache/sessões locais e fila síncrona, sem trabalhadores financeiros.
Não foram alterados o InvesteMoz, o serviço PostgreSQL existente, o PATH global ou a política global de execução. Servidor HTTP e cluster SGF ligam apenas a 127.0.0.1. Instruções de arranque e paragem no README.
+207
View File
@@ -0,0 +1,207 @@
# Primeiro percurso operacional de serviços
Data: 25 de Outubro de 2026.
Referência: SGF_TEMPO_Roteiro_v0_9.docx, particularmente secções 5, 14 a 19 e 20 a 24. Este documento especifica um percurso para revisão; não autoriza emissão fiscal, operações reais, movimentação de fundos ou implementação integral.
## Decisões já tomadas
- Começar pelo sector de prestação de serviços, com um serviço pontual como cenário de desenvolvimento.
- Reutilizar a fundação existente: tenant, empresa, unidade, utilizador, sessão, contexto, concessões e auditoria.
- Preservar independência do InvesteMoz, versões instaladas e separação entre desenvolvimento e testes.
- Usar dados sintéticos nas provas, sem os inserir como operações da Tempo Lda/SEGI.
As entidades, estados, limites e entregas abaixo são propostas técnicas. Valores de teste não são preços comerciais, taxas fiscais ou regras contabilísticas aprovadas.
## Condição antes da implementação seguinte
O incremento de entrada, contexto e permissões tem implementação e provas técnicas documentadas em IDENTITY_DELIVERY.md. A verificação por leitura confirmou uma única SEGI, os direitos de gestão de Osmane na Tempo Lda e apenas company.view/unit.view para Samira na Tempo Lda/SEGI. A última suite executada passou com 30 testes e 117 asserções; não foi repetida nesta revisão documental.
O utilizador aceitou e deu por concluído o incremento local de entrada, contexto e permissões em 25 de Outubro de 2026, no âmbito demonstrado e com os limites de IDENTITY_DELIVERY.md. Preservar essa entrega como base. Limites conhecidos: âmbito local, sem MFA/RLS de produção, sem criação geral de contas pela interface e com procedimentos locais assistidos de recuperação. A aceitação está registada separadamente do cenário de serviços.
Nesta etapa está autorizada apenas a especificação de serviços. As entregas A/B/C abaixo são um plano para decisões posteriores, não uma ordem para as implementar agora.
## Resultado pretendido
Um utilizador autorizado escolhe um cliente e um serviço, regista a prestação, prepara a facturação, acompanha recebimentos parciais e uma correcção e consegue explicar o saldo pelas origens. Os acontecimentos alimentam captura contabilística permanente, incluindo pendências. Reconciliação e fecho só são apresentados como concluídos quando cumprirem os controlos aprovados.
Percurso de referência:
Cliente → serviço → prestação → documento de facturação → recebimento parcial → correcção → segundo recebimento → reconciliação → fecho.
Os acontecimentos têm identidades e estados próprios. A prestação não é uma factura e uma factura não comprova recebimento. Registar um recebimento não inicia pagamento, transferência ou débito bancário.
| Acontecimento | Significado | O que não demonstra por si só |
| --- | --- | --- |
| Serviço prestado | Registo do trabalho realizado, cliente, conteúdo, unidade e data da prestação | Factura emitida, pagamento recebido ou reconhecimento contabilístico aprovado |
| Factura | Documento de cobrança com identidade, data, linhas e regras próprias | Serviço concluído ou dinheiro recebido |
| Recebimento | Observação/registo de um valor recebido, com origem/evidência e afectação | Emissão de factura, liquidação confirmada pela fonte ou reconciliação concluída |
Conservar separadamente datas, estados, identificadores e relações desses três acontecimentos. Não criar os três como efeito implícito de um único botão.
## Cenário sintético de aceitação
Entidades exclusivamente de teste: Empresa Exemplo de Serviços, Unidade de Teste, Cliente Exemplo e Serviço Pontual de Teste. Não utilizar os nomes, contas ou operações reais da Tempo Lda como fixtures.
Para demonstrar as relações e a aritmética do sub-razão, usar o seguinte exemplo em MZN. O valor 1 000,00 é um montante de referência artificial do teste; não significa preço aprovado, tributação nula ou montante fiscal já apurado.
### Caso principal liquidação por dois recebimentos
| Acontecimento | Valor do teste | Saldo aritmético por receber |
| --- | ---: | ---: |
| Documento de referência do serviço | 1 000,00 | 1 000,00 |
| Recebimento parcial afectado ao documento | 400,00 | 600,00 |
| Segundo recebimento afectado ao documento | 600,00 | 0,00 |
Resultado: 1 000,00 − 400,00 − 600,00 = 0,00. Cada recebimento tem identidade e afectação próprias. Repetir qualquer comando/importação ou processá-lo em concorrência não pode acrescentar outra afectação nem reduzir novamente o saldo. Mudar o conteúdo mantendo a mesma chave de repetição deve produzir conflito.
### Variante de correcção redutora
Reiniciar o cenário apenas com fixtures de teste próprias, sem alterar o caso principal nem a factura original. Ensaiar uma redução de 100,00 numa linha do documento, identificando documento e linha de origem, identidade da correcção, montante, data, autor e motivo sintético.
| Acontecimento | Valor do teste | Saldo aritmético por receber |
| --- | ---: | ---: |
| Documento original preservado | 1 000,00 | 1 000,00 |
| Primeiro recebimento | 400,00 | 600,00 |
| Correcção redutora independente | 100,00 | 500,00 |
| Segundo recebimento | 500,00 | 0,00 |
Resultado: 1 000,00 − 100,00 − 400,00 − 500,00 = 0,00. A correcção não apaga nem altera silenciosamente o original. A forma documental legal da correcção permanece por validar.
Este cenário distingue três provas: saldo operacional explicado, contabilização válida e fecho válido. A primeira não demonstra automaticamente as outras duas. Um perfil artificial de cálculo/contabilização, se aprovado para ensaio, será exclusivo de testes, identificado como sintético e impossibilitado de activar regras reais em desenvolvimento.
### Reconciliação exclusivamente simulada
Comparar os dois recebimentos/afectações com duas observações de fonte sintética, identificadas como simulação e contendo identificador, documento de referência, moeda, montante, data e estado de teste. Uma observação repetida não cria outro recebimento. Demonstrar correspondência individual e total, além de uma variante com divergência que permanece pendente.
Não consultar bancos, iniciar pagamentos, transferir fundos, executar débitos ou apresentar observações artificiais como extractos reais. A prova demonstra o mecanismo e os controlos incluídos, não integração ou liquidação bancária real.
## Significado de fecho neste cenário
Fecho significa o encerramento controlado de um período contabilístico de teste, com evidência preservada. Não significa apenas terminar o formulário, marcar a prestação como concluída ou obter saldo por receber igual a zero. Não é submissão fiscal ou bancária.
O fecho proposto deve:
1. Identificar empresa, período, corte e versões de regras/dados consideradas.
2. Verificar que os acontecimentos elegíveis foram capturados, processados e ligados aos documentos/afectações, sem efeitos perdidos ou repetidos.
3. Conferir o sub-razão, a reconciliação simulada e os lançamentos necessários, com débitos/créditos e saldos explicáveis sob um perfil de teste explicitamente aprovado.
4. Recusar encerramento integral se houver pendência material, divergência, classificação em falta ou configuração insuficiente. Mostrar o motivo sem inventar uma conta/regra.
5. Preservar evidências e actor responsável, passar de aberto para em fecho/encerrado conforme validações e impedir gravações concorrentes no período encerrado.
Eventos tardios ou correcções posteriores não reabrem nem reescrevem o período automaticamente. O tratamento deve ser uma operação autorizada segundo política ainda por validar. Sem perfil contabilístico suficientemente definido, a prova esperada é a recusa fundamentada do fecho; não uma conclusão artificial de conformidade.
## Dados mínimos propostos
| Entidade | Dados e vínculos iniciais |
| --- | --- |
| Contraparte com papel de cliente | Identificador estável, empresa, nome e papel de cliente; não duplicar o futuro cadastro de fornecedor |
| Serviço | Identificador, empresa, nome e descrição; sem preço comercial ou regra fiscal por omissão |
| Prestação | Empresa, unidade, cliente, linhas de serviço, descrição, quantidade, data da prestação e estado |
| Rascunho de facturação | Origem da prestação, cliente, empresa/unidade, moeda, linhas, preços propostos, versões e pendências |
| Documento confirmado futuro | Identidade própria e fotografia dos dados/regras efectivamente aplicados; numeração e validade dependem de decisão |
| Recebimento futuro | Identidade, empresa, cliente, data, moeda, montante, origem/evidência, estado e afectações aos documentos |
| Correcção futura | Referência ao original, linhas/valores abrangidos, motivo, autor e efeitos aprovados |
| Evento durável | Identidade, origem/versionamento, empresa/unidade, instante, tipo, correlação e processamento |
| Pendência contabilística | Evento, informação/regra em falta, estado, motivo e historial de resolução |
Não acrescentar já contratos recorrentes, horas, equipas de projecto, custos, campos fiscais presumidos ou stock. A moeda funcional, precisão e regras de cálculo da empresa não são inferidas do exemplo de teste.
Identificadores de tenant, empresa e unidade vêm do contexto autorizado. Cliente e serviço devem pertencer à empresa da prestação. Uma escolha de cliente reutiliza o cadastro; documentos confirmados futuros preservam a fotografia histórica necessária em vez de dependerem do cadastro mutável.
## Estados propostos
- Prestação: rascunho, confirmada ou cancelada antes de confirmação. Uma correcção posterior referencia a prestação confirmada; não elimina a origem.
- Facturação: rascunho, com pendências, pronta para validação e confirmada apenas com regras/configuração suficientes. Guardar um rascunho não equivale a emitir factura.
- Cobrança: sem afectação, parcialmente recebida ou saldo liquidado, derivados dos montantes e estados admissíveis, não de um botão livre.
- Recebimento: registado, com evidência por verificar, conciliado ou corrigido por operação rastreável. Os critérios concretos de evidência e liquidação serão aprovados.
- Contabilidade: capturado, pendente, pronto, contabilizado ou sem efeito justificado. Falha técnica fica distinguida de informação em falta.
- Período: aberto, em fecho ou encerrado, sujeito à política a validar.
O primeiro incremento não deve apresentar os estados futuros como capacidades já entregues.
## Regras técnicas propostas
1. Guardar preços, quantidades e montantes com precisão controlada; usar brick/math existente no servidor e cadeias decimais nas APIs. Não usar o JavaScript como autoridade financeira.
2. Validar escala e arredondamento explicitamente. A política financeira/fiscal precisa de aprovação; não depender de coerção silenciosa da base.
3. Negar por omissão cada acção e verificar empresa, unidade, registo e permissão no servidor. As concessões actuais de identidade não concedem acesso automático às novas áreas.
4. Recusar um formulário com contexto ou versão desactualizados. Mudança de empresa/unidade não relocaliza registos nem reaproveita silenciosamente autorizações anteriores.
5. Usar chaves de idempotência por empresa/operação/conteúdo para comandos repetíveis. Repetição idêntica recupera o resultado; conteúdo diferente com a mesma chave origina conflito.
6. Confirmar alteração, evento durável e trabalho pendente na mesma transacção. A outbox é uma proposta técnica a implementar no núcleo, sem exigir Redis ou novo fornecedor.
7. Não duplicar efeitos contabilísticos quando um evento é repetido ou uma interface comercial é activada posteriormente.
8. Sem plano de contas, classificação, imposto, moeda ou reconhecimento aprovados, criar pendências explícitas. Não escolher contas transitórias para ocultar lacunas.
9. Manter auditoria das alterações relevantes, sem palavras-passe, tokens ou raciocínio interno da IA.
10. Fecho verifica eventos, pendências, reconciliação e integridade, incluindo concorrência. Um saldo operacional nulo não basta para fechar.
## Ecrãs propostos
Reutilizar a navegação e o cabeçalho de contexto existentes:
- Clientes: pesquisar, consultar e preparar o cadastro mínimo de contraparte com papel de cliente.
- Serviços: pesquisar e manter o catálogo mínimo.
- Prestações: lista, detalhe e formulário com cliente, serviço e origem reutilizados.
- Facturação: rascunho ligado à prestação, valores e pendências visíveis; sem aparência de documento fiscal emitido.
- Detalhe do percurso: linha do tempo de prestação, documentos, recebimentos, correcções e pendências, conforme cada entrega disponibilizada.
- Posteriormente, recebimentos/reconciliação e fecho, depois de aprovadas as regras respectivas.
As interfaces mostram o que está guardado, o que falta e a próxima acção permitida. Não disponibilizar operações de servidor ainda inexistentes através de botões simulados.
## Permissões propostas
Separar consulta e alteração de cadastros, criação/edição/confirmação de prestações, preparação/confirmação de documentos, registo/afectação/correcção de recebimentos, reconciliação e fecho.
As concessões serão dados parametrizados, mas o catálogo cobre apenas acções suportadas. Gestão de acessos não implica capacidade financeira. Não ampliar automaticamente os direitos de Osmane ou Samira. A matriz concreta e a autoridade inicial de concessão das novas acções precisam de aprovação antes da disponibilização.
## Entregas verificáveis
### A Cadastros e preparação operacional
Proposta para a primeira implementação:
- Contraparte/cliente e serviço mínimos.
- Prestação em rascunho, reutilizando esses cadastros e o contexto existente.
- Rascunho de facturação ligado à prestação, sem emissão fiscal ou numeração definitiva.
- Captura durável dos acontecimentos abrangidos e pendências explícitas de classificação, sem inventar lançamentos.
- Permissões, auditoria, controlo de versão/contexto e repetição segura desde estes primeiros comandos.
- Dados sintéticos e provas apenas na base de testes; nenhuma factura ou recebimento real em desenvolvimento.
Critério: percurso cliente → serviço → prestação → rascunho reproduzível, com origem preservada, acessos indevidos recusados e informação insuficiente assinalada. Isto não é ainda o circuito financeiro completo.
### B Documentos e contabilidade permanente
Após validar campos/regras aplicáveis: confirmação documental, fotografia histórica, eventos/outbox, consumidor repetível, regras de contabilização e pendências. Demonstrar contabilidade permanente mesmo sem interface comercial de Contabilidade contratada.
### C Recebimentos correcção reconciliação e fecho
Completar o cenário sintético, incluindo os dois recebimentos, correcção ligada ao original, reconciliação explicável e tentativas de fecho com e sem pendências. Demonstrar concorrência, interrupções e recuperação. Utilização real fica dependente de regras e responsáveis aprovados.
Demonstrar separadamente o caso principal (400,00 + 600,00 liquidam 1 000,00) e a variante de correcção (400,00 + 500,00 liquidam o saldo líquido de 900,00). A reconciliação é sempre identificada como simulada.
Estas entregas partilham o mesmo núcleo. Não criam três produtos ou versões divergentes.
## Critérios de aceitação
- Cliente/serviço de outra empresa ou unidade não autorizada recusados por URL/API, identificador manipulado ou tarefa.
- Utilizador de consulta não consegue alterar, confirmar, receber, corrigir ou fechar.
- Direitos revogados e contexto alterado produzem recusa no pedido seguinte.
- Um cliente e uma prestação são reutilizados na facturação sem recaptura ou duplicação de cadastros.
- Guardar rascunho, repetir comando e retomar depois de falha não duplica documentos/eventos.
- Documento confirmado futuro conserva dados e regras mesmo que o cadastro seja alterado.
- Os valores do cenário e todas as afectações são explicáveis pelas origens e calculados com decimais controlados.
- Falta de regra fiscal/contabilística gera pendência, não taxa zero, conta inventada ou estado contabilizado.
- Correcção conserva o original, motivo e vínculos, sem edição directa de lançamentos confirmados.
- Falhas antes/depois do commit e dois consumidores concorrentes não deixam efeitos parciais ou duplicados.
- Fecho com lacuna material é recusado; lançamento concorrente não ultrapassa período encerrado.
- Activação posterior de interface de Contabilidade não repete efeitos já produzidos.
- Testes destrutivos continuam restritos a sgf_tempo_test e fixtures não entram como operações da Tempo Lda.
## Decisões pendentes antes das entregas correspondentes
Para A: aprovar esta delimitação e a matriz de permissões/autoridade inicial; confirmar quais dados mínimos do cliente são indispensáveis para um rascunho (a proposta inicial exige apenas nome).
Para B/C: responsáveis de validação contabilística/fiscal, documentos e numeração, regime/impostos aplicáveis, moeda funcional, plano de contas, reconhecimento, arredondamento, evidências de recebimento/reconciliação, corte e regras de fecho/correcção. O exemplo de teste não decide estas matérias.
Não seleccionar fornecedor, API bancária ou integração de pagamento. Não construir agora plano comercial, motor geral de parâmetros, IA, sincronização offline ou toda a orquestração Camaleão. As lacunas não justificam codificar valores de negócio fixos como substituto de validação.
## Estado desta etapa
Especificação proposta para revisão. Nenhuma migração, cadastro, documento, evento financeiro ou lançamento foi criado nesta etapa. O incremento de identidade está aceite e concluído; qualquer implementação posterior exige autorização sobre uma entrega concreta. O utilizador manteve expressamente serviços apenas em especificação e não autorizou implementação financeira ou publicação. Não implementar a entrega A nesta revisão documental.
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

+11
View File
@@ -0,0 +1,11 @@
{
"environment": "sgf_tempo_test, browser-testing, localhost:8001",
"results": [
"CSRF: pedido de alteração sem token/origem válida recusado (419).",
"URLs/API: empresa do mesmo tenant e unidade sem acesso recusadas; consulta não permite edição (403).",
"Concessão e revogação: efeito no pedido seguinte em sessão independente (200 → 403).",
"Formulário anterior à mudança de contexto recusado (409).",
"Gestor limitado não consegue conceder edição que não possui (403).",
"Replay do cookie da sessão terminada recusado (401)."
]
}
+940
View File
@@ -0,0 +1,940 @@
{
"name": "sgf-tempo",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sgf-tempo",
"dependencies": {
"react": "19.2.9",
"react-dom": "19.2.9"
},
"devDependencies": {
"@vitejs/plugin-react": "6.1.2",
"laravel-vite-plugin": "3.2.0",
"vite": "8.3.5"
}
},
"node_modules/@oxc-project/types": {
"version": "0.155.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
"integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/oxc-project"
}
},
"node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz",
"integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz",
"integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz",
"integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz",
"integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz",
"integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz",
"integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz",
"integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz",
"integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz",
"integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz",
"integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==",
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz",
"integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz",
"integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz",
"integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz",
"integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz",
"integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz",
"integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rolldown/pluginutils": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
"@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
"babel-plugin-react-compiler": "^1.0.0",
"oxc-transform-react": "^0.152.0",
"vite": "^8.0.0"
},
"peerDependenciesMeta": {
"@rolldown/plugin-babel": {
"optional": true
},
"babel-plugin-react-compiler": {
"optional": true
},
"oxc-transform-react": {
"optional": true
}
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/laravel-vite-plugin": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.2.0.tgz",
"integrity": "sha512-xSxY9Gzeb/eancd8WeK09piAFP+a6i5QIBqNCKNv9L0Eq6wziwzSem7F1GvMSrtjMh5F/QVKFxn8t9naGOA66A==",
"dev": true,
"license": "MIT",
"dependencies": {
"picocolors": "^1.0.0",
"tinyglobby": "^0.2.12",
"vite-plugin-full-reload": "^1.1.0"
},
"bin": {
"clean-orphaned-assets": "bin/clean.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
"fontaine": "^0.8.0",
"vite": "^8.0.0"
},
"peerDependenciesMeta": {
"fontaine": {
"optional": true
}
}
},
"node_modules/lightningcss": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.33.0",
"lightningcss-darwin-arm64": "1.33.0",
"lightningcss-darwin-x64": "1.33.0",
"lightningcss-freebsd-x64": "1.33.0",
"lightningcss-linux-arm-gnueabihf": "1.33.0",
"lightningcss-linux-arm64-gnu": "1.33.0",
"lightningcss-linux-arm64-musl": "1.33.0",
"lightningcss-linux-x64-gnu": "1.33.0",
"lightningcss-linux-x64-musl": "1.33.0",
"lightningcss-win32-arm64-msvc": "1.33.0",
"lightningcss-win32-x64-msvc": "1.33.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/nanoid": {
"version": "3.3.20",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": {
"version": "8.5.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz",
"integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.20",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/react": {
"version": "19.2.9",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.9.tgz",
"integrity": "sha512-RuLdAokXv3sRmZa2/N8QH0KmTBIgDKC+buRUikGqow31+mGSGidLCX2o4XFq8OV+xBFWfhM9DVgTq1PPHADH4w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.9",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.9.tgz",
"integrity": "sha512-YceU74W21fXEHszKBLj16MY0CmvpFKOs60iVrIyW/cCgOPsZJNaB6GVJEtIUqt6AG6EjzKLui5lPRrxAeS328Q==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.9"
}
},
"node_modules/rolldown": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz",
"integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.155.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm-eabi": "1.2.15",
"@rolldown/binding-android-arm64": "1.2.15",
"@rolldown/binding-darwin-arm64": "1.2.15",
"@rolldown/binding-darwin-x64": "1.2.15",
"@rolldown/binding-freebsd-x64": "1.2.15",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.15",
"@rolldown/binding-linux-arm64-gnu": "1.2.15",
"@rolldown/binding-linux-arm64-musl": "1.2.15",
"@rolldown/binding-linux-ppc64-gnu": "1.2.15",
"@rolldown/binding-linux-s390x-gnu": "1.2.15",
"@rolldown/binding-linux-x64-gnu": "1.2.15",
"@rolldown/binding-linux-x64-musl": "1.2.15",
"@rolldown/binding-openharmony-arm64": "1.2.15",
"@rolldown/binding-win32-arm64-msvc": "1.2.15",
"@rolldown/binding-win32-x64-msvc": "1.2.15"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/vite": {
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
"integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.7",
"postcss": "^8.5.30",
"rolldown": "~1.2.14",
"tinyglobby": "^0.2.17"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.7.1 || ^0.8.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"@vitejs/devtools": {
"optional": true
},
"esbuild": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
},
"node_modules/vite-plugin-full-reload": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/vite-plugin-full-reload/-/vite-plugin-full-reload-1.2.0.tgz",
"integrity": "sha512-kz18NW79x0IHbxRSHm0jttP4zoO9P9gXh+n6UTwlNKnviTTEpOlum6oS9SmecrTtSr+muHEn5TUuC75UovQzcA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picocolors": "^1.0.0",
"picomatch": "^2.3.1"
}
},
"node_modules/vite-plugin-full-reload/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "sgf-tempo",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1 --port 5173 --strictPort",
"build": "vite build"
},
"dependencies": {
"react": "19.2.9",
"react-dom": "19.2.9"
},
"devDependencies": {
"laravel-vite-plugin": "3.2.0",
"vite": "8.3.5",
"@vitejs/plugin-react": "6.1.2"
}
}
+34
View File
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing" force="true"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_URL" value="" force="true"/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/>
</php>
</phpunit>
+25
View File
@@ -0,0 +1,25 @@
<IfModule mod_rewrite.c>
<IfModule mod_negotiation.c>
Options -MultiViews -Indexes
</IfModule>
RewriteEngine On
# Handle Authorization Header
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
# Handle X-XSRF-Token Header
RewriteCond %{HTTP:x-xsrf-token} .
RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}]
# Redirect Trailing Slashes If Not A Folder...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [L,R=301]
# Send Requests To Front Controller...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
</IfModule>
View File
+20
View File
@@ -0,0 +1,20 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true));
// Determine if the application is in maintenance mode...
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
require $maintenance;
}
// Register the Composer autoloader...
require __DIR__.'/../vendor/autoload.php';
// Bootstrap Laravel and handle the request...
/** @var Application $app */
$app = require_once __DIR__.'/../bootstrap/app.php';
$app->handleRequest(Request::capture());
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow:
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
//
+75
View File
@@ -0,0 +1,75 @@
import React, { useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import '../css/app.css';
const labels = { 'company.view': 'Consultar empresa', 'company.edit': 'Editar nome da empresa', 'unit.create': 'Criar unidades', 'access.manage': 'Gerir acessos', 'unit.view': 'Consultar unidade', 'unit.edit': 'Editar nome da unidade' };
function App() {
const [state, setState] = useState(null);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [busy, setBusy] = useState(false);
const [page, setPage] = useState('home');
const context = state?.context;
const can = (permission, unit = null) => !!context?.grants.some(g => g.permission === permission && g.unit_id === unit);
async function api(path, method = 'GET', body, token = context?.token) {
const response = await fetch(path, { method, credentials: 'same-origin', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-CSRF-TOKEN': state?.csrf || '', 'X-SGF-Context': token || '' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
const result = await response.json().catch(() => ({}));
if (!response.ok) {
if (response.status === 401) { await refresh(); }
const message = response.status === 419 ? 'A sessão expirou. Actualize a página e entre novamente.' : Object.values(result.errors || {}).flat().join(' ') || result.message || 'Não foi possível concluir a operação.';
throw new Error(message);
}
return result;
}
async function refresh() {
const response = await fetch('/api/session', { credentials: 'same-origin', headers: { Accept: 'application/json' } });
if (!response.ok) throw new Error('Não foi possível carregar a sessão.');
const next = await response.json(); setState(next);
if (next.notice) setNotice(next.notice);
}
useEffect(() => { refresh().catch(e => setError(e.message)); }, []);
async function perform(operation, message) {
setBusy(true); setError(''); setNotice('');
try { await operation(); await refresh(); if (message) setNotice(message); }
catch (e) { setError(e.message); }
finally { setBusy(false); }
}
async function select(companyId, unitId = null) {
const company = state.companies.find(c => c.id === Number(companyId));
if (!company) return;
await perform(() => api('/api/context', 'POST', { tenant_id: company.tenant_id, company_id: company.id, unit_id: unitId }), 'Contexto alterado. Os formulários foram recarregados.');
}
if (!state) return <main className="loading"><h1>SGF TEMPO</h1><p role="status">{error || 'A carregar…'}</p></main>;
if (state.setup_pending) return <main className="setup-pending"><p className="eyebrow">SGF TEMPO · PREPARAÇÃO LOCAL</p><h1>Falta concluir a primeira conta</h1><p>A aplicação está pronta, mas ainda não existe um utilizador. O login ficará disponível depois de definires a palavra-passe no procedimento local já autorizado.</p><section className="panel"><h2>Concluir no terminal do VS Code</h2><p>Executa este comando, confere o âmbito apresentado e define a palavra-passe nos dois pedidos de entrada oculta:</p><pre>powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\laragon\data\sgf-tempo\run\complete-first-admin.ps1</pre><p className="help">Não envies a palavra-passe pelo chat. Nenhuma conta é criada por este ecrã.</p><button className="primary" disabled={busy} onClick={() => perform(async () => {}, '')}>Já concluí — actualizar estado</button>{error && <p role="alert">{error}</p>}</section></main>;
if (!state.user) return <div className="login-layout"><aside><span className="brand">TEMPO<span>SGF</span></span><h1>O seu trabalho,<br />no contexto certo.</h1><p>Uma entrada para as empresas e unidades a que tem acesso.</p><small>Ambiente local · Primeiro percurso funcional</small></aside><main className="login-card"><p className="eyebrow">BEM-VINDO AO SGF TEMPO</p><h2>Entrar na aplicação</h2><p className="muted">Use a conta preparada pelo procedimento local autorizado.</p>{error && <div className="alert error" role="alert">{error}</div>}<Login busy={busy} onSubmit={(email, password) => perform(() => api('/api/login', 'POST', { email, password }), 'Sessão iniciada. Seleccione o contexto de trabalho.')} /><p className="help">Precisa de recuperar o acesso? Solicite recuperação local assistida. Não existe recuperação por email nesta etapa.</p></main></div>;
return <div className="shell"><aside className="sidebar"><a className="brand" href="/">TEMPO<span>SGF</span></a><p className="sidebar-label">ESPAÇO DE TRABALHO</p><nav>{[['home', 'Área inicial'], ['company', 'Empresa'], ['units', 'Unidades'], ...(can('access.manage') ? [['access', 'Acessos']] : [])].map(([id, text]) => <button key={id} className={page === id ? 'active' : ''} onClick={() => { setPage(id); setError(''); }}>{text}</button>)}</nav><div className="sidebar-bottom"><strong>{state.user.name}</strong><small>{state.user.email}</small><button disabled={busy} onClick={() => perform(() => api('/api/logout', 'POST'), 'Sessão terminada.')}>Sair da aplicação</button></div></aside><div className="workspace"><header><div><span className="eyebrow">CONTEXTO DE TRABALHO</span><strong>{context?.company.name || 'Seleccione uma empresa'}</strong><span className="muted">{context?.unit?.name || 'Sem unidade seleccionada'}</span></div><span className="local-badge">Ambiente local</span></header><main>
{error && <div className="alert error" role="alert">{error}</div>}{notice && <div className="alert success" role="status">{notice}</div>}
<section className="context-card"><label>Empresa<select aria-label="Empresa activa" disabled={busy} value={context?.company.id || ''} onChange={e => select(e.target.value)}><option value="">Escolher empresa autorizada</option>{state.companies.map(c => <option value={c.id} key={c.id}>{c.name}</option>)}</select></label><label>Unidade<select aria-label="Unidade activa" disabled={busy || !context} value={context?.unit?.id || ''} onChange={e => select(context.company.id, e.target.value ? Number(e.target.value) : null)}><option value="">Sem unidade seleccionada</option>{context?.units.map(u => <option value={u.id} key={u.id}>{u.name}</option>)}</select></label><p>Apenas os contextos com acesso explícito são apresentados.</p></section>
{!state.companies.length ? <section className="panel"><h1>Sem empresas autorizadas</h1><p>A conta existe, mas ainda não tem acesso a uma empresa. Peça ao responsável uma concessão explícita.</p></section> : !context ? <section className="panel"><h1>Escolha onde vai trabalhar</h1><p>Seleccione uma empresa. A pertença a um cliente contratante não dá acesso automático às suas empresas ou unidades.</p></section> : <div key={context.token}>
{page === 'home' && <><div className="page-title"><p className="eyebrow">ÁREA INICIAL</p><h1>Bem-vindo, {state.user.name.split(' ')[0]}.</h1><p className="muted">O contexto permanece visível e cada operação é validada pelo servidor.</p></div><div className="summary-grid"><section className="panel"><span className="eyebrow">EMPRESA ACTIVA</span><h2>{context.company.name}</h2><p>Cliente contratante #{context.company.tenant_id} · Empresa #{context.company.id}</p><button onClick={() => setPage('company')}>Consultar empresa</button></section><section className="panel"><span className="eyebrow">UNIDADE ACTIVA</span><h2>{context.unit?.name || 'Ainda não seleccionada'}</h2><p>{context.units.length} unidade(s) com consulta autorizada</p><button onClick={() => setPage('units')}>Ver unidades</button></section></div></>}
{page === 'company' && <section className="panel"><p className="eyebrow">DADOS BÁSICOS</p><h1>Empresa</h1><NameForm key={context.company.id} label="Nome da empresa" initial={context.company.name} editable={can('company.edit')} busy={busy} onSave={name => perform(() => api(`/api/companies/${context.company.id}`, 'PATCH', { name }, context.token), 'Nome da empresa actualizado.')} />{!can('company.edit') && <p className="help">O seu acesso permite consulta. A edição exige uma concessão própria.</p>}</section>}
{page === 'units' && <><section className="panel"><p className="eyebrow">DADOS BÁSICOS</p><h1>Unidades autorizadas</h1>{!context.units.length && <p>Não tem consulta concedida a nenhuma unidade desta empresa.</p>}<ul className="unit-list">{context.units.map(u => <li key={u.id}><strong>{u.name}</strong><button disabled={busy || context.unit?.id === u.id} onClick={() => select(context.company.id, u.id)}>{context.unit?.id === u.id ? 'Seleccionada' : 'Seleccionar'}</button></li>)}</ul>{context.unit && <NameForm label="Nome da unidade" initial={context.unit.name} editable={can('unit.edit', context.unit.id)} busy={busy} onSave={name => perform(() => api(`/api/units/${context.unit.id}`, 'PATCH', { name }, context.token), 'Nome da unidade actualizado.')} />}</section>{can('unit.create') && <section className="panel"><h2>Criar unidade</h2><p className="help">A unidade será criada nesta empresa. Receberá consulta e edição explícitas apenas para a nova unidade.</p><NameForm label="Nome da nova unidade" initial="" editable busy={busy} button="Criar unidade" onSave={name => perform(() => api('/api/units', 'POST', { name }, context.token), 'Unidade criada com os seus acessos explícitos.')} /></section>}</>}
{page === 'access' && (can('access.manage') ? <AccessPanel context={context} can={can} api={api} perform={perform} busy={busy} /> : <section className="panel"><h1>Acesso não autorizado</h1><p>A gestão de acessos exige uma concessão própria.</p></section>)}
</div>}
</main><footer>SGF TEMPO · Primeiro percurso funcional · Sem módulos financeiros nesta etapa</footer></div></div>;
}
function Login({ busy, onSubmit }) {
const [email, setEmail] = useState(''); const [password, setPassword] = useState('');
return <form onSubmit={async e => { e.preventDefault(); await onSubmit(email, password); setPassword(''); }}><label>Email<input type="email" autoComplete="username" value={email} onChange={e => setEmail(e.target.value)} required /></label><label>Palavra-passe<input type="password" autoComplete="current-password" value={password} onChange={e => setPassword(e.target.value)} required /></label><button className="primary" disabled={busy} type="submit">{busy ? 'A entrar…' : 'Entrar'}</button></form>;
}
function NameForm({ label, initial, editable, busy, onSave, button = 'Guardar nome' }) {
const [name, setName] = useState(initial);
return <form className="name-form" onSubmit={e => { e.preventDefault(); onSave(name); }}><label>{label}<input value={name} onChange={e => setName(e.target.value)} readOnly={!editable} maxLength={255} required /></label>{editable && <button className="primary" disabled={busy || !name.trim()}>{button}</button>}</form>;
}
function AccessPanel({ context, can, api, perform, busy }) {
const [list, setList] = useState(null); const [loadError, setLoadError] = useState('');
const [email, setEmail] = useState(''); const [scope, setScope] = useState('company'); const [permission, setPermission] = useState('company.view'); const [reason, setReason] = useState('');
const unit = scope === 'company' ? null : Number(scope);
const options = (unit === null ? ['company.view', 'company.edit', 'unit.create', 'access.manage'] : ['unit.view', 'unit.edit']).filter(p => can(p, unit));
useEffect(() => { api('/api/accesses').then(setList).catch(e => setLoadError(e.message)); }, [context.token]);
async function change(grant) { await perform(async () => { await api(grant ? '/api/accesses' : '/api/accesses/revoke', 'POST', { email, permission, unit_id: unit, reason }, context.token); setList(await api('/api/accesses')); }, grant ? 'Concessão registada.' : 'Revogação registada.'); }
return <><section className="panel"><p className="eyebrow">AUTORIZAÇÕES EXPLÍCITAS</p><h1>Gestão de acessos</h1><p>Actua apenas nesta empresa e pode delegar apenas direitos que possui no mesmo âmbito. Os utilizadores são preparados pelo procedimento local.</p>{loadError && <p role="alert">{loadError}</p>}<form onSubmit={e => { e.preventDefault(); change(true); }}><label>Email exacto do utilizador<input type="email" value={email} onChange={e => setEmail(e.target.value)} required /></label><div className="form-grid"><label>Âmbito<select value={scope} onChange={e => { setScope(e.target.value); setPermission(e.target.value === 'company' ? 'company.view' : 'unit.view'); }}><option value="company">Empresa activa</option>{context.units.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}</select></label><label>Permissão<select value={permission} onChange={e => setPermission(e.target.value)}>{options.map(p => <option key={p} value={p}>{labels[p]}</option>)}</select></label></div><label>Motivo da alteração<textarea value={reason} onChange={e => setReason(e.target.value)} maxLength={500} required /></label><p className="help">Não escreva palavras-passe ou outros segredos no motivo. A revogação de consulta da empresa remove também os direitos dependentes, apenas se tiver autoridade sobre todos eles. Revogar a sua própria gestão pode exigir recuperação assistida de acessos.</p><div className="actions"><button className="primary" disabled={busy || !options.includes(permission)}>Conceder acesso</button><button type="button" disabled={busy || !email || !reason || !options.includes(permission)} onClick={() => change(false)}>Revogar acesso</button></div></form></section><section className="panel"><h2>Acessos nesta empresa</h2>{list ? <div className="table-scroll"><table><thead><tr><th>Utilizador</th><th>Permissão</th><th>Âmbito</th></tr></thead><tbody>{list.grants.map(g => { const member = list.members.find(m => m.id === g.user_id); return <tr key={g.id}><td>{member?.name}<small>{member?.email}</small></td><td>{labels[g.permission]}</td><td>{g.unit_id ? context.units.find(u => u.id === g.unit_id)?.name || 'Unidade autorizada' : 'Empresa'}</td></tr>; })}</tbody></table></div> : <p>A carregar acessos…</p>}</section></>;
}
createRoot(document.getElementById('app')).render(<App />);
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="pt-MZ">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SGF Tempo</title>
@viteReactRefresh
@vite('resources/js/app.jsx')
</head>
<body><div id="app"></div></body>
</html>
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
<?php
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
+28
View File
@@ -0,0 +1,28 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Modules\Identity\Http\RequireCompanyContext;
use App\Modules\Identity\Http\IdentityController;
use App\Modules\Identity\Http\RequireValidSession;
Route::get('/', function () {
return view('app');
});
Route::get('/api/session', [IdentityController::class, 'session']);
Route::post('/api/login', [IdentityController::class, 'login']);
Route::middleware(['auth', RequireValidSession::class])->group(function (): void {
Route::post('/api/logout', [IdentityController::class, 'logout']);
Route::post('/api/context', [IdentityController::class, 'select']);
Route::middleware(RequireCompanyContext::class)->group(function (): void {
Route::get('/api/context', fn (\App\Shared\Context\CompanyContext $context) => response()->json($context->identifiers() + ['unit_id' => $context->unitId()]));
Route::patch('/api/companies/{company}', [IdentityController::class, 'renameCompany']);
Route::get('/api/companies/{company}', [IdentityController::class, 'company']);
Route::get('/api/units/{unit}', [IdentityController::class, 'unit']);
Route::patch('/api/units/{unit}', [IdentityController::class, 'renameUnit']);
Route::post('/api/units', [IdentityController::class, 'createUnit']);
Route::get('/api/accesses', [IdentityController::class, 'accesses']);
Route::post('/api/accesses', [IdentityController::class, 'grant']);
Route::post('/api/accesses/revoke', [IdentityController::class, 'revoke']);
});
});
+43
View File
@@ -0,0 +1,43 @@
param([ValidateSet('start','stop')][string]$Action = 'start')
$ErrorActionPreference = 'Stop'
$sgfProject = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
if ($sgfProject -ne 'C:\laragon\www\sgf-tempo') { throw 'Unexpected project path.' }
$sgfPhp = 'C:\laragon\tools\sgf-tempo\php-8.4.27\php.exe'
$sgfRun = 'C:\laragon\data\sgf-tempo\run'
$recordFile = Join-Path $sgfRun 'browser-proof.json'
Set-Location -LiteralPath $sgfProject
if ($Action -eq 'stop') {
if (Test-Path -LiteralPath $recordFile) {
$record = Get-Content -LiteralPath $recordFile -Raw | ConvertFrom-Json
$process = Get-Process -Id $record.pid -ErrorAction SilentlyContinue
if ($process) {
if ($process.Path -ne $sgfPhp -or $process.StartTime.ToUniversalTime().ToString('o') -ne $record.started) { throw 'Process identity mismatch; no process stopped.' }
Stop-Process -Id $process.Id
}
Remove-Item -LiteralPath $recordFile
}
$fixtureFile = 'C:\laragon\data\sgf-tempo\secrets\browser-fixture.json'
if (Test-Path -LiteralPath $fixtureFile) { Remove-Item -LiteralPath $fixtureFile }
Write-Output 'Browser proof server stopped. Synthetic data remains only in sgf_tempo_test until the next test run.'
exit 0
}
if (Get-NetTCPConnection -LocalPort 8001 -State Listen -ErrorAction SilentlyContinue) { throw 'Port 8001 occupied; no process was stopped.' }
if (Test-Path -LiteralPath $recordFile) { throw 'Browser proof record exists; stop that proof first.' }
& $sgfPhp scripts/prepare-browser-fixture.php --env=testing
if ($LASTEXITCODE) { throw 'Could not prepare synthetic fixtures.' }
$utf8 = New-Object Text.UTF8Encoding $false
$lines = Get-Content -LiteralPath '.env.testing' | Where-Object { $_ -notmatch '^(APP_ENV|APP_URL|SESSION_DRIVER|SESSION_COOKIE|SESSION_FILES|CACHE_STORE|CACHE_FILE_PATH|CACHE_LOCK_PATH)=' }
$lines += @('APP_ENV=browser-testing','APP_URL=http://127.0.0.1:8001','SESSION_DRIVER=file','SESSION_COOKIE=sgf_browser_proof',
'SESSION_FILES=C:/laragon/www/sgf-tempo/storage/framework/browser-proof/sessions', 'CACHE_STORE=file',
'CACHE_FILE_PATH=C:/laragon/www/sgf-tempo/storage/framework/browser-proof/cache', 'CACHE_LOCK_PATH=C:/laragon/www/sgf-tempo/storage/framework/browser-proof/locks')
[IO.File]::WriteAllLines((Join-Path $sgfProject '.env.browser-testing'), $lines, $utf8)
$account = [Security.Principal.WindowsIdentity]::GetCurrent().Name
& icacls.exe '.env.browser-testing' /inheritance:r /grant:r "${account}:F" 'SYSTEM:F' | Out-Null
New-Item -ItemType Directory -Path 'storage\framework\browser-proof\sessions','storage\framework\browser-proof\cache','storage\framework\browser-proof\locks' -Force | Out-Null
$env:APP_ENV = 'browser-testing'
try {
if (Get-NetTCPConnection -LocalPort 8001 -State Listen -ErrorAction SilentlyContinue) { throw 'Port 8001 became occupied; no process stopped.' }
$process = Start-Process -FilePath $sgfPhp -ArgumentList @('-S','127.0.0.1:8001','-t','public','scripts/router.php') -WorkingDirectory $sgfProject -WindowStyle Hidden -PassThru -RedirectStandardOutput "$sgfRun\browser-proof.stdout.log" -RedirectStandardError "$sgfRun\browser-proof.stderr.log"
@{pid=$process.Id;started=$process.StartTime.ToUniversalTime().ToString('o')} | ConvertTo-Json | Set-Content -LiteralPath $recordFile -Encoding UTF8
} finally { Remove-Item Env:APP_ENV -ErrorAction SilentlyContinue }
Write-Output 'Browser proof: http://127.0.0.1:8001 — synthetic data only, isolated session/cache, CSRF enabled.'
+9
View File
@@ -0,0 +1,9 @@
<?php
require __DIR__.'/../vendor/autoload.php';
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
if (! \App\Modules\Identity\Services\LocalDatabaseGuard::allows()) { fwrite(STDERR, "Invalid local destination.\n"); exit(1); }
$tables = \Illuminate\Support\Facades\DB::select("SELECT relname, has_table_privilege(current_user, c.oid, 'SELECT') AS can_read, has_table_privilege(current_user, c.oid, 'INSERT') AS can_insert, has_table_privilege(current_user, c.oid, 'UPDATE') AS can_update FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = 'public' AND relname IN ('users','tenants','companies','company_memberships','permission_grants','identity_audit') ORDER BY relname");
$sequences = \Illuminate\Support\Facades\DB::select("SELECT relname, has_sequence_privilege(current_user, c.oid, 'USAGE') AS can_use FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = 'public' AND relkind = 'S' ORDER BY relname");
echo json_encode(['guard' => 'passed', 'tables' => $tables, 'sequences' => $sequences], JSON_PRETTY_PRINT).PHP_EOL;
+59
View File
@@ -0,0 +1,59 @@
import fs from 'node:fs/promises';
import assert from 'node:assert/strict';
const base = 'http://127.0.0.1:8001';
const fixture = JSON.parse(await fs.readFile('C:/laragon/data/sgf-tempo/secrets/browser-fixture.json', 'utf8'));
const results = [];
function client() {
const cookies = new Map();
let csrf = ''; let context = '';
return {
cookies,
async request(path, method = 'GET', body, {token = context, csrfHeader = true} = {}) {
const headers = {Accept:'application/json', 'Content-Type':'application/json', Cookie:[...cookies].map(([k,v]) => `${k}=${v}`).join('; '), 'X-SGF-Context':token};
if (csrfHeader) headers['X-CSRF-TOKEN'] = csrf;
const response = await fetch(base+path, {method, headers, ...(body === undefined ? {} : {body:JSON.stringify(body)})});
for (const cookie of response.headers.getSetCookie()) { const pair=cookie.split(';',1)[0]; const split=pair.indexOf('='); cookies.set(pair.slice(0,split),pair.slice(split+1)); }
const data = await response.json().catch(() => ({}));
if (data.csrf) csrf=data.csrf;
if (data.context?.token) context=data.context.token;
return {status:response.status,data};
},
async login(email) {
assert.equal((await this.request('/api/session')).status,200);
assert.equal((await this.request('/api/login','POST',{email,password:fixture.password})).status,200);
},
async select(company=fixture.company,unit=fixture.unit) {
const result=await this.request('/api/context','POST',{tenant_id:fixture.tenant,company_id:company,unit_id:unit});
assert.equal(result.status,200); return result.data.context.token;
}
};
}
const manager=client(); await manager.login('gestor@example.test'); const token=await manager.select();
assert.equal((await manager.request(`/api/companies/${fixture.company}`,'PATCH',{name:'CSRF must refuse'},{csrfHeader:false})).status,419);
results.push('CSRF: pedido de alteração sem token/origem válida recusado (419).');
const reader=client(); await reader.login('consulta@example.test'); const readerToken=await reader.select();
assert.equal((await reader.request(`/api/companies/${fixture.sibling}`)).status,403);
assert.equal((await reader.request(`/api/units/${fixture.hidden}`)).status,403);
assert.equal((await reader.request(`/api/companies/${fixture.company}`,'PATCH',{name:'Not allowed'})).status,403);
results.push('URLs/API: empresa do mesmo tenant e unidade sem acesso recusadas; consulta não permite edição (403).');
const grant={email:'consulta@example.test',permission:'company.edit',reason:'Prova HTTP sintética'};
assert.equal((await manager.request('/api/accesses','POST',grant)).status,200);
assert.equal((await reader.request(`/api/companies/${fixture.company}`,'PATCH',{name:'Empresa Horizonte — Prova HTTP'})).status,200);
assert.equal((await manager.request('/api/accesses/revoke','POST',grant)).status,200);
assert.equal((await reader.request(`/api/companies/${fixture.company}`,'PATCH',{name:'Revoked'})).status,403);
results.push('Concessão e revogação: efeito no pedido seguinte em sessão independente (200 → 403).');
await manager.select(fixture.sibling,null);
assert.equal((await manager.request(`/api/companies/${fixture.company}`,'PATCH',{name:'Stale'},{token})).status,409);
results.push('Formulário anterior à mudança de contexto recusado (409).');
const limited=client(); await limited.login('limitado@example.test'); await limited.select();
assert.equal((await limited.request('/api/accesses','POST',grant)).status,403);
results.push('Gestor limitado não consegue conceder edição que não possui (403).');
const oldCookies=new Map(reader.cookies);
assert.equal((await reader.request('/api/logout','POST')).status,200);
reader.cookies.clear(); for(const [key,value] of oldCookies) reader.cookies.set(key,value);
assert.equal((await reader.request('/api/context')).status,401);
results.push('Replay do cookie da sessão terminada recusado (401).');
await manager.request('/api/logout','POST'); await limited.request('/api/logout','POST');
await fs.writeFile(new URL('../docs/proofs/http-results.json',import.meta.url),JSON.stringify({environment:'sgf_tempo_test, browser-testing, localhost:8001',results},null,2));
console.log(results.join('\n'));
+11
View File
@@ -0,0 +1,11 @@
<?php
require __DIR__.'/../vendor/autoload.php';
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
$db = \Illuminate\Support\Facades\DB::selectOne('SELECT current_database() AS db, current_user AS role');
$counts = [];
foreach (['users', 'tenants', 'companies', 'units', 'company_memberships', 'permission_grants', 'identity_audit'] as $table) {
$counts[$table] = \Illuminate\Support\Facades\Schema::hasTable($table) ? \Illuminate\Support\Facades\DB::table($table)->count() : 'pending migration';
}
echo json_encode(['database' => $db->db, 'role' => $db->role, 'counts' => $counts], JSON_PRETTY_PRINT).PHP_EOL;
+15
View File
@@ -0,0 +1,15 @@
<?php
require __DIR__.'/../vendor/autoload.php';
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
\App\Shared\Testing\TestingDatabaseGuard::assertSafe();
foreach (['users', 'tenants', 'companies', 'units'] as $table) {
if (\Illuminate\Support\Facades\DB::table($table)->exists()) {
fwrite(STDERR, "Test database is not empty. No data was deleted. Stop other test workflows and prepare the test database explicitly.\n"); exit(1);
}
}
$fixture = \Illuminate\Support\Facades\DB::transaction(fn () => \Tests\Support\IdentityFixture::create());
$safe = array_intersect_key($fixture, array_flip(['tenant', 'otherTenant', 'company', 'sibling', 'foreign', 'unit', 'hidden', 'siblingUnit', 'password']));
file_put_contents('C:/laragon/data/sgf-tempo/secrets/browser-fixture.json', json_encode($safe, JSON_THROW_ON_ERROR));
echo "Synthetic browser fixtures prepared only in sgf_tempo_test. Credentials stored locally with restricted ACL; not displayed.\n";
+44
View File
@@ -0,0 +1,44 @@
param(
[Parameter(Mandatory=$true)][string]$Email,
[Parameter(Mandatory=$true)][string]$Name,
[Parameter(Mandatory=$true)][string]$Operator,
[Parameter(Mandatory=$true)][string]$Reason
)
$ErrorActionPreference = 'Stop'
$project = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
if ($project -ne 'C:\laragon\www\sgf-tempo') { throw 'Unexpected project path.' }
Set-Location -LiteralPath $project
$OutputEncoding = New-Object Text.UTF8Encoding $false
[Console]::OutputEncoding = $OutputEncoding
$sgfPhp = 'C:\laragon\tools\sgf-tempo\php-8.4.27\php.exe'
$arguments = @('artisan','identity:provision',"--email=$Email","--name=$Name","--operator=$Operator","--reason=$Reason")
& $sgfPhp @arguments
if ($LASTEXITCODE -ne 0) { throw 'Preview refused. No account created.' }
$answer = Read-Host 'Confirma criar apenas esta conta, sem permissoes automaticas? [sim/nao]'
if ($answer -notin @('sim','s','yes','y')) { Write-Host 'Operacao cancelada. Nada criado.'; exit 1 }
Write-Host 'Defina a palavra-passe apenas neste terminal. Minimo 12 caracteres; maximo 72 bytes UTF-8.'
while ($true) {
$first = Read-Host 'Nova palavra-passe' -AsSecureString
$repeat = Read-Host 'Repita a palavra-passe' -AsSecureString
$firstPtr = [IntPtr]::Zero; $repeatPtr = [IntPtr]::Zero
$plainFirst = $null; $plainRepeat = $null; $payload = $null
try {
$firstPtr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($first)
$repeatPtr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($repeat)
$plainFirst = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($firstPtr)
$plainRepeat = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($repeatPtr)
if ($plainFirst.Length -lt 12) { Write-Host 'Use pelo menos 12 caracteres. Tente novamente.' -ForegroundColor Yellow; continue }
if ([Text.Encoding]::UTF8.GetByteCount($plainFirst) -gt 72) { Write-Host 'Excede 72 bytes. Tente novamente.' -ForegroundColor Yellow; continue }
if ($plainFirst -cne $plainRepeat) { Write-Host 'As entradas nao coincidem. Tente novamente.' -ForegroundColor Yellow; continue }
$payload = @{confirmed=$true;email=$Email.Trim().ToLowerInvariant();password=$plainFirst;confirmation=$plainRepeat} | ConvertTo-Json -Compress
$payload | & $sgfPhp @arguments --apply --protected-stdin
if ($LASTEXITCODE -ne 0) { throw 'Account creation refused. Copy only the error message, never the password.' }
Write-Host 'Conta criada sem acessos automaticos. Um gestor autorizado pode agora conceder os direitos na aplicacao.' -ForegroundColor Green
break
} finally {
if ($firstPtr -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($firstPtr) }
if ($repeatPtr -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($repeatPtr) }
$plainFirst=$null; $plainRepeat=$null; $payload=$null
$first.Dispose(); $repeat.Dispose()
}
}
+9
View File
@@ -0,0 +1,9 @@
<?php
$public = realpath(__DIR__.'/../public');
$uri = rawurldecode(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) ?: '/');
$file = realpath($public.$uri);
if ($uri !== '/' && $file && str_starts_with($file, $public.DIRECTORY_SEPARATOR) && is_file($file)) {
return false;
}
require $public.'/index.php';
+130
View File
@@ -0,0 +1,130 @@
param(
[ValidateSet('start', 'stop', 'status', 'frontend', 'php', 'composer', 'npm', 'test', 'migrate')]
[string]$Action = 'status',
[Parameter(ValueFromRemainingArguments = $true)] [string[]]$ToolArguments
)
$ErrorActionPreference = 'Stop'
$sgfProject = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
if ($sgfProject -ne 'C:\laragon\www\sgf-tempo') { throw 'Unexpected project path.' }
$sgfTools = 'C:\laragon\tools\sgf-tempo'
$sgfData = 'C:\laragon\data\sgf-tempo'
$sgfCluster = Join-Path $sgfData 'postgres-17'
$sgfPg = 'C:\Program Files\PostgreSQL\17\bin'
$sgfPhp = "$sgfTools\php-8.4.27\php.exe"
$sgfNode = "$sgfTools\node-v24.22.1-win-arm64\node.exe"
$sgfComposer = "$sgfTools\composer-2.10.3\composer.phar"
$sgfNpmCli = "$sgfTools\node-v24.22.1-win-arm64\node_modules\npm\bin\npm-cli.js"
$sgfRun = Join-Path $sgfData 'run'
$env:PATH = "$sgfTools\php-8.4.27;$sgfTools\node-v24.22.1-win-arm64;" + $env:PATH
$env:COMPOSER_HOME = "$sgfTools\composer-home"
$env:COMPOSER_CACHE_DIR = "$sgfTools\composer-cache"
$env:npm_config_cache = "$sgfTools\npm-cache"
$env:npm_config_userconfig = "$sgfTools\npmrc"
Remove-Item Env:COMPOSER_AUTH -ErrorAction SilentlyContinue
Set-Location -LiteralPath $sgfProject
New-Item -ItemType Directory -Path $sgfRun -Force | Out-Null
function Assert-FreePort([int]$Port) {
if (Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue) {
throw "Port $Port is occupied. No process was stopped."
}
}
function Start-SgfDatabase {
$listeners = @(Get-NetTCPConnection -LocalPort 55432 -State Listen -ErrorAction SilentlyContinue)
if ($listeners.Count) {
$pidFile = Join-Path $sgfCluster 'postmaster.pid'
if (!(Test-Path -LiteralPath $pidFile)) { throw 'Port 55432 belongs to another process.' }
$clusterPid = [int](Get-Content -LiteralPath $pidFile -TotalCount 1)
$process = Get-CimInstance Win32_Process -Filter "ProcessId = $clusterPid"
$normalizedCommand = if ($process) { $process.CommandLine.Replace('/', '\') } else { '' }
if (($listeners.OwningProcess -notcontains $clusterPid) -or !$process -or $process.ExecutablePath -ne "$sgfPg\postgres.exe" -or $normalizedCommand -notlike "*$sgfCluster*") {
throw 'Port 55432 is not owned by the SGF cluster. No process was stopped.'
}
return
}
Assert-FreePort 55432
& "$sgfPg\pg_ctl.exe" -D $sgfCluster -l (Join-Path $sgfData 'postgres.log') -w start
if ($LASTEXITCODE) { throw 'SGF database start failed.' }
}
function Start-SgfProcess([string]$Name, [string]$Executable, [string[]]$Arguments, [int]$Port) {
Assert-FreePort $Port
$process = Start-Process -FilePath $Executable -ArgumentList $Arguments -WorkingDirectory $sgfProject -WindowStyle Hidden -PassThru -RedirectStandardOutput "$sgfRun\$Name.stdout.log" -RedirectStandardError "$sgfRun\$Name.stderr.log"
$record = @{pid=$process.Id; executable=$Executable; project=$sgfProject; port=$Port; started=$process.StartTime.ToUniversalTime().ToString('o')}
$record | ConvertTo-Json | Set-Content -LiteralPath "$sgfRun\$Name.json" -Encoding UTF8
$ready = $false
for ($attempt = 0; $attempt -lt 40; $attempt++) {
$process.Refresh()
if ($process.HasExited) { throw "$Name exited. See SGF run logs." }
$listeners = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue)
if ($listeners.OwningProcess -contains $process.Id) { $ready = $true; break }
Start-Sleep -Milliseconds 250
}
if (!$ready) { throw "$Name did not become ready. No other process was stopped." }
Write-Output "$Name ready at http://127.0.0.1:$Port"
}
switch ($Action) {
'start' {
Assert-FreePort 8000
Start-SgfDatabase
Start-SgfProcess 'web' $sgfPhp @('-S', '127.0.0.1:8000', '-t', 'public', 'scripts/router.php') 8000
}
'frontend' {
Start-SgfProcess 'vite' $sgfNode @('node_modules/vite/bin/vite.js', '--host', '127.0.0.1', '--port', '5173', '--strictPort') 5173
}
'status' {
& "$sgfPg\pg_ctl.exe" -D $sgfCluster status
Get-NetTCPConnection -LocalPort 55432,8000,5173 -State Listen -ErrorAction SilentlyContinue | Select-Object LocalAddress,LocalPort,OwningProcess
}
'stop' {
foreach ($name in @('vite', 'web')) {
$recordFile = "$sgfRun\$name.json"
if (Test-Path -LiteralPath $recordFile) {
$record = Get-Content -LiteralPath $recordFile -Raw | ConvertFrom-Json
$process = Get-Process -Id $record.pid -ErrorAction SilentlyContinue
if ($process) {
if ($process.Path -ne $record.executable -or $record.project -ne $sgfProject -or $process.StartTime.ToUniversalTime().ToString('o') -ne $record.started) {
throw "Process identity mismatch for $name; refusing to stop it."
}
Stop-Process -Id $process.Id
}
Remove-Item -LiteralPath $recordFile
if ($name -eq 'vite') {
$hotFile = Join-Path $sgfProject 'public\hot'
if ((Test-Path -LiteralPath $hotFile) -and (Get-Content -LiteralPath $hotFile -Raw).Trim() -eq 'http://127.0.0.1:5173') {
Remove-Item -LiteralPath $hotFile
}
}
}
}
& "$sgfPg\pg_ctl.exe" -D $sgfCluster -m fast -w stop
if ($LASTEXITCODE -ne 0 -and (Test-Path -LiteralPath "$sgfCluster\postmaster.pid")) { throw 'SGF cluster stop failed.' }
}
'php' { & $sgfPhp @ToolArguments; if ($LASTEXITCODE) { throw 'PHP command failed.' } }
'composer' { & $sgfPhp $sgfComposer @ToolArguments; if ($LASTEXITCODE) { throw 'Composer command failed.' } }
'npm' { & $sgfNode $sgfNpmCli @ToolArguments; if ($LASTEXITCODE) { throw 'npm command failed.' } }
'test' {
if (Test-Path -LiteralPath "$sgfRun\browser-proof.json") { throw 'Stop the browser proof before tests reset sgf_tempo_test.' }
Start-SgfDatabase
& $sgfPhp 'vendor/phpunit/phpunit/phpunit' @ToolArguments
if ($LASTEXITCODE) { throw 'Tests failed.' }
}
'migrate' {
Start-SgfDatabase
$credentials = Get-Content -LiteralPath "$sgfData\secrets\database.json" -Raw | ConvertFrom-Json
$env:APP_ENV = 'local'
$env:DB_CONNECTION = 'pgsql'
$env:DB_HOST = '127.0.0.1'
$env:DB_PORT = '55432'
$env:DB_DATABASE = 'sgf_tempo_dev'
$env:DB_USERNAME = 'sgf_dev_owner'
$env:DB_PASSWORD = $credentials.dev_owner
$env:DB_URL = ''
try {
& $sgfPhp artisan migrate --no-interaction
if ($LASTEXITCODE) { throw 'Development migration failed.' }
} finally {
foreach ($name in @('APP_ENV','DB_CONNECTION','DB_HOST','DB_PORT','DB_DATABASE','DB_USERNAME','DB_PASSWORD','DB_URL')) { Remove-Item "Env:$name" -ErrorAction SilentlyContinue }
}
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\DB;
require __DIR__.'/../vendor/autoload.php';
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
$identity = DB::selectOne('SELECT current_database() AS db, current_user AS role');
$privileges = DB::selectOne("SELECT rolsuper, rolcreatedb, rolcreaterole, rolbypassrls FROM pg_roles WHERE rolname = current_user");
$ddl = DB::selectOne("SELECT has_schema_privilege(current_user, 'public', 'CREATE') AS allowed");
$crossDatabase = DB::selectOne("SELECT has_database_privilege(current_user, 'sgf_tempo_test', 'CONNECT') AS allowed");
if ($identity->db !== 'sgf_tempo_dev' || $identity->role !== 'sgf_dev_app'
|| $privileges->rolsuper || $privileges->rolcreatedb || $privileges->rolcreaterole
|| $privileges->rolbypassrls || $ddl->allowed || $crossDatabase->allowed) {
fwrite(STDERR, "Development database identity/privilege check failed.\n");
exit(1);
}
if (DB::table('users')->where('email', 'like', '%@example.test')->exists()) {
fwrite(STDERR, "Synthetic test accounts must not be present in development.\n");
exit(1);
}
echo "Development connection: sgf_dev_app; no administration, schema creation or test database access. No test fixtures in development.\n";
+9
View File
@@ -0,0 +1,9 @@
<?php
require __DIR__.'/../vendor/autoload.php';
try {
[$password, $confirmation] = \App\Modules\Identity\Services\ProtectedProvisioningInput::read(STDIN, 'synthetic@example.test');
$expected = getenv('SGF_SYNTHETIC_HASH');
if (! is_string($expected) || $expected === '' || ! hash_equals($expected, hash('sha256', $password)) || $password !== $confirmation) { exit(1); }
echo "Protected PowerShell-to-PHP transport: OK (synthetic input, no database writes).\n";
} catch (\Throwable) { fwrite(STDERR, "Protected transport verification failed; payload not displayed.\n"); exit(1); }
+4
View File
@@ -0,0 +1,4 @@
*
!private/
!public/
!.gitignore
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+10
View File
@@ -0,0 +1,10 @@
compiled.php
config.php
down
events.scanned.php
lsp-*.php
maintenance.php
routes.php
routes.scanned.php
schedule-*
services.json
+4
View File
@@ -0,0 +1,4 @@
*
!data/
!locks/
!.gitignore
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore

Some files were not shown because too many files have changed in this diff Show More