Commit Graph

54 Commits

Author SHA1 Message Date
900fd5e975 feat(application): DeactivateRubroCommandHandler guard contra Products activos
Extiende IProductQueryRepository con CountActiveByRubroAsync, inyecta
el repositorio en el handler e intercala el chequeo después del guard
de hijos activos. Tests de unidad cubren: throw, success con 0 productos,
y estabilidad del orden de guardas (hijos primero).
2026-04-19 17:08:30 -03:00
b4f17d6961 refactor: eliminar NullProductQueryRepository dead code + EXISTS en ProductQueryRepository (PRD-002 S1 S2) 2026-04-19 13:37:10 -03:00
8c9a50504d feat(infrastructure): ProductRepository + ProductQueryRepository, DI swap activates guard (PRD-002) 2026-04-19 13:10:21 -03:00
bb455be745 feat(application): Product handlers + DI registration, fix permiso count to 27 (PRD-002) 2026-04-19 13:07:59 -03:00
8b555e1f8b feat(application): Product commands, DTOs, IProductRepository, validators (PRD-002) 2026-04-19 13:02:42 -03:00
5c8f19bf39 feat(application): CRUD handlers + validators + DI de ProductType (PRD-001)
Create/Update/Deactivate handlers con TransactionScope + audit; validators FluentValidation;
DI wiring NullProductQueryRepository + 5 handlers; SqlTestFixture V017 + permiso count 25→26.
2026-04-19 09:46:31 -03:00
3c9e852379 feat(application): IProductTypeRepository + IProductQueryRepository stub + queries (PRD-001) 2026-04-19 09:38:51 -03:00
f861dfa826 feat(application): RubroTreeBuilder + GetRubroTree con tieneAvisos (CAT-002) 2026-04-19 08:25:13 -03:00
c03aad8c5a feat(application): guard avisos en MoveRubroCommandHandler (CAT-002) 2026-04-19 08:24:07 -03:00
216983623a feat(application): guard avisos en CreateRubroCommandHandler (CAT-002) 2026-04-19 08:22:55 -03:00
9e50a929ae feat(application): RubroTreeBuilder + GetRubroTree con tieneAvisos (CAT-002) 2026-04-19 08:20:36 -03:00
673194e249 feat(application): IAvisoQueryRepository + NullAvisoQueryRepository (CAT-002) 2026-04-19 08:18:56 -03:00
5e2323e0bc feat(api): RubrosController + integration tests e2e + audit verification (CAT-001) 2026-04-19 07:42:54 -03:00
d9fc9a2867 feat(application): Rubros commands/queries + RubroTreeBuilder + audit (CAT-001) 2026-04-19 07:42:26 -03:00
0d50d4f3cc feat(bd): V016 create Rubro table con SYSTEM_VERSIONING (CAT-001)
- dbo.Rubro: adjacency list, self-FK, soft-delete, temporal retention 10y
- Filtered unique index UQ_Rubro_ParentId_Nombre_Activo + covering IX_Rubro_ParentId_Activo
- Permission catalogo:rubros:gestionar seeded + assigned to admin role
- V016_ROLLBACK.sql: full reversal script
- RubrosOptions class (MaxDepth=10) + appsettings.json Rubros section
- services.Configure<RubrosOptions> registered in Infrastructure DI
- database/README.md updated with V013-V016 entries
2026-04-19 07:42:25 -03:00
9957724c40 chore(tests): dotnet format sobre archivos pre-existentes (surfaced durante CAT-001)
Fix mecánico de whitespace detectado por dotnet format --verify-no-changes durante la verify phase de CAT-001 (PR #30). Sin cambios funcionales.
2026-04-18 20:56:23 -03:00
d69da5ff4c feat(udt-011): T400.10 — inject TimeProvider into all Application handlers
All command handlers that call domain mutators now inject TimeProvider
via constructor and use _timeProvider.GetUtcNow().UtcDateTime as the
explicit 'now' argument. Replaces previous direct DateTime.UtcNow usage.
2026-04-18 10:12:17 -03:00
54d2340bb9 feat(udt-011): register TimeProvider.System in AddApplication DI 2026-04-18 09:44:21 -03:00
4e70b0f847 feat(udt-011): TimeProviderArgentinaExtensions.GetArgentinaToday cross-platform 2026-04-18 09:43:35 -03:00
bd0c4deea7 feat(adm-009): TipoDeIva + IngresosBrutos handlers, DTOs, DI registration 2026-04-17 18:09:52 -03:00
eead0a35cd feat(adm-009): ITipoDeIvaRepository + IIngresosBrutosRepository abstractions 2026-04-17 18:09:36 -03:00
7d432a949a revert(backend): eliminar handlers/endpoints/excepciones de reserva de numero ADM-008
Eliminar SecuenciaComprobante entity, TipoComprobante enum, DeadlockTransientException,
PuntoDeVentaInactivoException, carpetas Reservar/ y ProximoNumero/ de Application,
métodos ReservarNumeroAsync/GetUltimoNumeroAsync del repositorio, endpoints
POST /secuencias/.../reservar y GET /secuencias/.../proximo del controller,
y mapping PuntoDeVentaInactivoException del ExceptionFilter.
2026-04-17 14:16:09 -03:00
65787db272 fix(adm-008): correcciones del verify loop
Seis ajustes post-verify detectados durante la corrida full de tests:

1. PuntoDeVentaRepository: UQ_PuntoDeVenta_Medio_AFIP (no _MedioId_NumeroAFIP)
   — el catch de unique violation no disparaba → 500 en race duplicado.

2. Application.DependencyInjection: registro de 8 handlers PuntosDeVenta
   — sin esto, dispatcher arrojaba "No service registered" → 500.

3. ReservarNumeroCommandHandler: backoff ampliado a 5 retries
   [25, 75, 200, 500, 1200]ms para soportar 50 threads concurrentes.

4. SecuenciaComprobante: SYSTEM_VERSIONING = OFF (AD8 revisitado).
   Under UPDATE concurrente sobre misma fila, el engine arroja
   "transaction time earlier than period start time" — limitación
   conocida de Temporal Tables con alta contención de UPDATEs.
   Decisión: secuencia es operacional, no configuración → sin history.
   V013 y SqlTestFixture actualizados para ser idempotentes.

5. SqlTestFixture: EnsureV013SchemaAsync idempotente + PuntoDeVenta_History
   en TablesToIgnore + permiso administracion:puntos_de_venta:gestionar
   en seed canónico + asignación a rol admin.

6. Tests: conteos 22→23 permisos (V013 agrega uno); repository fixtures
   ignoran PuntoDeVenta_History; test UpdatePdv_WhenPdvInactive eliminado
   (over-specified — spec no bloquea update en PdV inactivo, solo en Medio
   padre inactivo; alineado con frontend que permite editar PdV inactivo).

Resultado: 190/190 Api.Tests y tests específicos ADM-008 verdes
(Domain 13, Application 42, Api 21 = 76 tests nuevos). El único failure
residual (AuditEventRepositoryTests.QueryAsync_Limit_EmitsCursor) es
pre-existente y no relacionado a ADM-008.

Covers: verify report CRITICAL (UQ name mismatch) + WARNINGs descubiertos
durante la ejecución (DI registro, temporal tables concurrency, permiso
fixture, counts de tests pre-existentes).
2026-04-17 13:02:35 -03:00
50f6f2b67a feat(application): repository abstraction + DTOs + validators + handlers CRUD PuntosDeVenta con auditoría + retry deadlock 2026-04-17 12:28:11 -03:00
870cbe91b3 feat(secciones): validar medio activo en update/deactivate/reactivate — issue #16 2026-04-17 11:46:01 -03:00
f672de78ce feat(medios,secciones): application layer + handlers TDD — ADM-001 B3+B4
- IMedioRepository, ISeccionRepository interfaces
- MediosQuery, SeccionesQuery common records
- TipoSeccion static AllowedTipos helper
- Medios: 6 use cases (Create/Update/Deactivate/Reactivate/List/GetById) with validators, handlers and DTOs
- Secciones: 6 use cases mirroring Medios; Create validates MedioId active via IMedioRepository
- 52 unit tests (xUnit + NSubstitute) all green; audit LogAsync asserted per mutating handler
- DI registrations for all 12 handlers and validators auto-scanned via AddValidatorsFromAssemblyContaining
2026-04-16 18:53:57 -03:00
b619c05762 feat(audit): security events en Auth + authorization handlers (UDT-010 B9)
Instruments auth pipeline with ISecurityEventLogger per #REQ-AUTH-SEC:

LoginCommandHandler:
- login success → action=login result=success actorUserId=user.Id
- login failure disaggregated internally (client still sees 401 unified):
  user_not_found / user_inactive / invalid_password
  — attempts captured with attemptedUsername + FailureReason

LogoutCommandHandler:
- action=logout result=success actorUserId=cmd.UsuarioId

RefreshCommandHandler:
- refresh.issue success on successful rotation
- refresh.reuse_detected failure when revoked token is presented (chain
  revoke already happens; we add the security event with metadata.familyId)
- refresh.issue failure for: token_expired / sub_mismatch / user_not_found /
  user_inactive

PermissionAuthorizationHandler:
- permission.denied failure on require-permission rejection, with metadata
  { permissionRequired, endpoint, method }. ActorUserId from JWT sub.

DI: ISecurityEventLogger was already registered by B6 (AddInfrastructure).

Test updates: 4 test classes now inject ISecurityEventLogger mock:
- LoginCommandHandlerTests, LogoutCommandHandlerTests, RefreshCommandHandlerTests
- PermissionAuthorizationHandlerTests (Api.Tests)

Suite: 378/378 Application.Tests + 141/141 Api.Tests = 519/519 passing.

Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-SEC-2/3/4/5 #REQ-AUTH-SEC,
design, tasks#B9}
2026-04-16 13:59:27 -03:00
a3f01bc6c9 feat(audit): enchufar audit en handlers de Rol (UDT-010 B8)
4 command handlers del módulo Roles + Permisos ahora auditan:

| Handler                              | Action                 |
|--------------------------------------|------------------------|
| CreateRolCommandHandler              | rol.create             |
| UpdateRolCommandHandler              | rol.update             |
| DeactivateRolCommandHandler          | rol.deactivate         |
| AssignPermisosToRolCommandHandler    | rol.permisos_update    |

Mismo patrón que B7 (using block + post-commit reads outside scope).

Metadata:
- rol.create: after={Codigo, Nombre, Descripcion}
- rol.update: {before, after} diff
- rol.permisos_update: {before, after} con arrays de codigos ordenados

AssignPermisosToRolCommandHandler captura 'before' leyendo
GetByRolCodigoAsync antes del TransactionScope para poder emitir el diff.

4 test classes actualizados con mock de IAuditLogger.

Suite: 378/378 Application.Tests + 141/141 Api.Tests = 519/519 passing.

Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-RM-AUD, design, tasks#B8}
2026-04-16 13:54:47 -03:00
26efb74c22 feat(audit): enchufar audit en handlers de Usuario — Closes #6
7 command handlers del módulo Usuarios ahora auditan via IAuditLogger:

| Handler                                 | Action                  |
|-----------------------------------------|-------------------------|
| CreateUsuarioCommandHandler             | usuario.create          |
| UpdateUsuarioCommandHandler             | usuario.update          |
| DeactivateUsuarioCommandHandler         | usuario.deactivate      |
| ReactivateUsuarioCommandHandler         | usuario.reactivate      |
| ChangeMyPasswordCommandHandler          | usuario.password_change |
| ResetUsuarioPasswordCommandHandler      | usuario.password_reset  |
| UpdateUsuarioPermisosOverridesHandler   | usuario.permisos_update |

Patrón por handler (per design #D-1):
  using (var tx = new TransactionScope(Required, ReadCommitted, AsyncFlowEnabled))
  {
      await repo.UpdateAsync(...);
      await audit.LogAsync(...);
      tx.Complete();
  }
  // post-commit reads OUTSIDE the using block
  var updated = await repo.GetDetailAsync(...);

Metadata captured:
- usuario.create: after={username, nombre, apellido, email, rol} — NO password.
- usuario.update: {before, after} diff of editable fields.
- usuario.password_reset: {targetId} only — tempPassword is NEVER persisted to
  audit (returned to caller once, never stored).
- usuario.permisos_update: {before, after} of grant/deny override lists.

Key fix during implementation: initially used 'using var tx = ...' (bare
declaration). This kept the TransactionScope active for the rest of the method,
causing 'The current TransactionScope is already complete' when post-commit
reads (GetDetailAsync) tried to enlist. Solution: explicit 'using (var tx = ...)
{ ... }' block that disposes the scope before post-commit reads.

AuditContextMissingException surfaces from AuditLogger when IAuditContext
lacks ActorUserId — fail-closed per #REQ-AUD-4. In integration tests, the
middleware populates ActorUserId from the JWT sub of the authenticated admin.

Test updates: 6 existing unit test classes now inject IAuditLogger mock:
- CreateUsuarioCommandHandlerTests
- UpdateUsuarioCommandHandlerTests
- DeactivateUsuarioCommandHandlerTests
- ReactivateUsuarioCommandHandlerTests
- ChangeMyPasswordCommandHandlerTests
- ResetUsuarioPasswordCommandHandlerTests

Follow-up #6 ([Auditoría] Registrar admin creador en alta de usuarios) is
closed: CreateUsuarioCommandHandler now records ActorUserId = admin JWT sub
on every user creation. TODO comment removed.

Suite: 378/378 Application.Tests + 141/141 Api.Tests = 519/519 passing.

Closes #6
Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-UM-AUD, design, tasks#B7}
2026-04-16 13:49:44 -03:00
300badda73 feat(infra): audit + security event repositories (UDT-010 B5)
Introduces persistence layer for audit and security events per design #D-6:

SIGCM2.Application/Audit/:
- IAuditEventRepository: InsertAsync + QueryAsync with cursor pagination
- ISecurityEventRepository: InsertAsync only (no query — SecurityEvent is
  queried only from an admin dashboard deferred to ADM-004)
- AuditEventQueryResult: (Items, NextCursor) record

SIGCM2.Infrastructure/Audit/:
- AuditEventCursor (public): base64(OccurredAt:O|Id) opaque cursor for
  DESC pagination. TryDecode is fail-open — malformed cursor returns null
  and the query starts from the top.
- AuditEventRepository: Dapper INSERT via OUTPUT INSERTED.Id + dynamic
  WHERE composition with parameterized filters (zero SQL injection risk).
  LEFT JOIN to dbo.Usuario to populate ActorUsername in AuditEventDto.
  Pagination fetches Limit+1 rows to detect "more pages"; emits cursor
  from the Nth row when overflow observed.
- SecurityEventRepository: straight INSERT for login/logout/refresh/
  permission.denied events.

DI: AddScoped for both repos in AddInfrastructure.

Integration tests (Strict TDD): 13 total, all against SIGCM2_Test.
- AuditEventRepositoryTests (10): insert-roundtrip, filter-by-actor,
  filter-by-target, filter-by-date-range, cursor pagination across 3 pages
  (no overlap/no gap), malformed-cursor fail-open, LEFT JOIN Usuario
  populates username, cursor encode/decode roundtrip, cursor malformed
  variants.
- SecurityEventRepositoryTests (3): insert success, insert failure with
  null ActorUserId + AttemptedUsername, CK_SecurityEvent_Result rejection.

Suite: 368/368 Application.Tests + 141/141 Api.Tests = 509/509 passing.

Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-2,7 #REQ-SEC-1,
design#D-6, tasks#B5}
2026-04-16 13:38:05 -03:00
08d6622e43 feat(infra): JsonSanitizer + AuditOptions binding (UDT-010 B3)
Adds the metadata sanitization layer per #REQ-AUD-5:

SIGCM2.Infrastructure/Audit/JsonSanitizer.cs (static class):
- Sanitize(object?, IReadOnlyCollection<string>) -> string?
- Serializes via System.Text.Json + JsonNode recursive traversal.
- Strips blacklisted keys at every nesting level (objects + arrays).
- Case-insensitive match (ToLowerInvariant on both sides).
- Null input -> null output (never throws).
- Output is always valid JSON (ISJSON=1 compatible — satisfies AuditEvent CHECK).

SIGCM2.Application/Audit/AuditOptions.cs:
- Documented the IConfiguration array-binding quirk: config is ADDITIVE
  (append at higher indices), not REPLACE. Intentional for security — defaults
  like 'password'/'token'/'cvv' must not be silently dropped.

SIGCM2.Infrastructure/DependencyInjection.cs:
- services.Configure<AuditOptions>(configuration.GetSection(AuditOptions.SectionName))
  wired in AddInfrastructure().

Tests (Strict TDD, RED -> GREEN):
- JsonSanitizerTests (10): null/empty-blacklist/flat/nested/arrays/case-insensitive/
  primitives/round-trip-valid-json/string-as-value/default-keys-effective.
- AuditOptionsBindingTests (2): defaults when section absent + additive override.

One test needed adjustment during GREEN: 'AlreadySerializedJsonString' originally
asserted against an encoding-specific literal; rewrote to use JsonDocument
round-trip (validates behavior without coupling to encoder quirks).

Suite: 348/348 Application.Tests + 130/130 Api.Tests = 478/478 passing.

Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-5, design#D-5, tasks#B3}
2026-04-16 13:28:37 -03:00
68f96b90c7 feat(application): audit abstractions (UDT-010 B2)
Introduces the contract layer for audit logging per design #D-8:

SIGCM2.Application/Audit/:
- IAuditContext — request-scoped accessor for ActorUserId/ActorRoleId/
  Ip/UserAgent/CorrelationId. Populated by CorrelationIdMiddleware +
  AuditActorMiddleware (B4).
- IAuditLogger.LogAsync(action, targetType, targetId, metadata?, ct) —
  domain-level audit emitter. Enlists in ambient TransactionScope
  (fail-closed per #REQ-AUD-4).
- ISecurityEventLogger.LogAsync(action, result, actorUserId?, attemptedUsername?,
  sessionId?, failureReason?, metadata?, ct) — security-events emitter
  separate from IAuditLogger (different retention, no transaction scope,
  captures login/logout/refresh/permission.denied).
- AuditOptions — bindable POCO with SanitizedKeys[] defaults (used by
  JsonSanitizer in B3).
- AuditEventDto — read projection for GET /api/v1/audit/events (B10).
- AuditEventFilter — query filter record with default Limit=50.

SIGCM2.Domain/Exceptions/:
- AuditContextMissingException : DomainException — fail-closed sentinel
  thrown when IAuditLogger is called without ActorUserId in a user-scoped
  command (#REQ-AUD-4).

Tests (Strict TDD — shape contract, RED → GREEN):
- tests/SIGCM2.Application.Tests/Audit/AuditAbstractionsTests.cs: 11 tests
  covering nullability, signatures, default options, record equality.

Suite: 336/336 Application.Tests (prev 325 + 11 new). 130/130 Api.Tests.

Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-3/4/5, design#D-8, tasks#B2}
2026-04-16 13:23:11 -03:00
7d4dc4d2bb feat(api): PUT /api/v1/users/{id}/permisos/overrides + excepciones domain + ExceptionFilter [UDT-009] 2026-04-15 21:43:38 -03:00
47323302cc feat(api): GET /api/v1/users/{id}/permisos con CQRS handler [UDT-009] 2026-04-15 21:43:08 -03:00
5fd88b5a9d feat(infra): IUsuarioRepository.UpdatePermisosJsonAsync + impl Dapper [UDT-009] 2026-04-15 21:33:39 -03:00
fb07a1139a feat(application): LoginCommandHandler usa PermisoResolver para permisos efectivos [UDT-009] 2026-04-15 21:29:33 -03:00
da1eb83ac1 feat(application): PermisosOverride record + PermisoResolver static helper [UDT-009] 2026-04-15 21:25:09 -03:00
2925336783 feat(api): List + GetById usuarios — handlers, repo, endpoints [UDT-008] 2026-04-15 17:46:23 -03:00
9dcd63543e feat(auth): extend LoginResponse with username + mustChangePassword + ultimoLogin [UDT-008] 2026-04-15 17:39:48 -03:00
cdb8dcd03c feat(api): login response permisos desde RolPermiso [UDT-006] 2026-04-15 16:24:21 -03:00
1a864e9f8b fix(app): validar formato codigo rol en GetRolPermisos [UDT-005]
Agrega GetRolPermisosQueryValidator con regex ^[a-z][a-z0-9_]*$ para
rechazar codigos invalidos con 400 en GET /api/v1/roles/{codigo}/permisos.
2026-04-15 15:56:49 -03:00
704794a2e2 feat(app): BATCH 3 - handlers permisos con TDD [UDT-005] 2026-04-15 15:31:26 -03:00
6f999b8fcd feat(api): UDT-004 controller de roles + refactor validator UDT-003 a lookup dinamico
- RolesController /api/v1/roles CRUD admin-only: GET list, GET {codigo}, POST, PUT, DELETE (soft-delete con guard 409)
- ExceptionFilter: mapea RolNotFound (404), RolAlreadyExists (409), RolInUse (409)
- DI: registra 5 handlers de Roles (Application) y IRolRepository/RolRepository (Infrastructure)
- CreateUsuarioCommandValidator: reemplaza whitelist hardcoded por IRolRepository.ExistsActiveByCodigoAsync via MustAsync; constructor recibe (AuthOptions, IRolRepository)
- Tests: 202 verdes (173 application + 29 api). Nuevas: RolesEndpointTests (13 integration), CreateUsuarioCommandValidatorTests reescrito con NSubstitute mock, CreateUsuario_WithInactiveRol_Returns400 en Api.Tests
- Fix: ApiIntegration pasa de IClassFixture (N factories) a ICollectionFixture (1 factory shared) — evitaba ObjectDisposedException sobre RSABCrypt al compartir coleccion con multiples test classes
- tests/tests.runsettings: MaxCpuCount=1 para evitar race entre assemblies sobre SIGCM2_Test
2026-04-15 12:50:24 -03:00
34b714750a feat(api): UDT-004 dominio + repositorio + application roles (tdd)
- Migraciones V003 (tabla Rol + 8 seeds canonicos) y V004 (drop CK + FK Usuario.Rol)
- Dominio: Rol entity + 3 excepciones (RolNotFound/AlreadyExists/InUse)
- Infraestructura: RolRepository (Dapper) con List/Get/ExistsActive/Add/Update/HasActiveUsuarios
- Application: CRUD queries y commands (List, Get, Create, Update, Deactivate) + validators (codigo regex ^[a-z][a-z0-9_]*$)
- Validator UDT-003: whitelist alineada a codigos canonicos (full IRolRepository lookup diferido a Phase 5.1)
- Tests: 169 application + 15 api (todos verdes). Respawn configurado para re-seedear Rol canonical post-reset.
- Estricto TDD: RED/GREEN/TRIANGULATE en todos los handlers nuevos.
2026-04-15 12:31:29 -03:00
3d598faffc feat(api): UDT-003 registro de usuarios — backend completo (Phases 1-6)
- Domain: Usuario.ForCreation factory, UsernameAlreadyExistsException, IUsuarioRepository extendido
- Application: CreateUsuarioCommand/Validator/Handler, UsuarioCreatedDto, AuthOptions password policy
- Infrastructure: UsuarioRepository.ExistsByUsernameAsync + AddAsync (INSERT OUTPUT INSERTED.Id), RoleClaimType="rol" en TokenValidationParameters
- Api: UsuariosController POST api/v1/users [Authorize(Roles="admin")], ExceptionFilter mapea UsernameAlreadyExistsException + SqlException 2627 → 409
- Tests (unit): 43 tests — 33 validator + 10 handler (107 total, green)
- Tests (integration): 7 tests CreateUsuarioEndpoint — 401/403/400/201/409/race/e2e (green)
- Fix: TestWebAppFactory.ConfigureTestServices reemplaza SqlConnectionFactory singleton con CS de test correcto
2026-04-15 10:47:48 -03:00
aed26e3de9 feat(infra): register RefreshTokenRepository, RefreshTokenGenerator, ClientContext and handlers in DI 2026-04-14 13:28:36 -03:00
8bbd2b6f2a feat(app): update LoginCommandHandler to persist hashed refresh token on login 2026-04-14 13:28:16 -03:00
6c02197369 feat(app): implement LogoutCommand handler with idempotent revocation 2026-04-14 13:28:10 -03:00
f5e67b78a5 feat(app): implement RefreshCommand handler with token rotation and chain revocation 2026-04-14 13:28:06 -03:00
971f6f572f feat(app): add IClientContext abstraction for IP and UserAgent 2026-04-14 13:17:12 -03:00