# Provider Session and Deferred Required Markers Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Keep remembered provider-browser sessions active for exactly 30 days without affecting app sessions, revoke only the corresponding API session, and reveal required asterisks only after a missing-value submission.

**Architecture:** The API owns token lifetime through a validated `provider_web` session context embedded in JWT and temporary 2FA claims. The web BFF injects that context, retains the JWT only in Laravel session storage, and explicitly revokes it on logout. Provider JavaScript keeps native accessibility semantics from page load but creates and removes visible markers in response to missing-value validation.

**Tech Stack:** Laravel/PHPUnit in `api_mycode` and `web_mycode`; Tymon JWTAuth; vanilla JavaScript and Node assertion tests; Laravel Mix/Webpack.

---

## File Structure

### `api_mycode`

- Create `app/Services/Auth/AuthSessionLifetimePolicy.php`: allow-list session contexts and select the configured TTL.
- Modify `config/jwt.php`: expose the 30-day provider-web remembered TTL.
- Modify `.env.example`: document `JWT_PROVIDER_WEB_REMEMBER_TTL=43200`.
- Modify `app/Services/Auth/AuthTokenService.php`: issue tokens, claims, responses, and session rows using the policy.
- Modify `app/Http/Controllers/AuthController.php`: validate login context and preserve it during refresh/session rotation.
- Modify `app/Services/Auth/TwoFactorService.php`: retain context inside the encrypted temporary token.
- Modify `app/Http/Controllers/Auth/TwoFactorController.php`: issue the final token with the retained context.
- Modify `tests/Feature/ApiSecurityTest.php`: cover context TTL, legacy app TTL, refresh, and session isolation.
- Modify `tests/Feature/TwoFactorAuthTest.php`: cover 2FA context preservation.
- Create `tests/Unit/Services/Auth/AuthSessionLifetimePolicyTest.php`: cover the policy matrix.

### `web_mycode`

- Modify `.env.example`: retain Laravel sessions for 43,200 minutes.
- Modify `app/Application/Provider/ProviderAuthGateway.php`: add logout to the BFF boundary.
- Modify `app/Infrastructure/Http/ProviderAuthApi.php`: inject `provider_web` during login and call authenticated API logout.
- Modify `app/Presentation/Http/Controllers/Web/ProviderSessionController.php`: revoke upstream before clearing local state, with safe expired-token handling.
- Modify `tests/Feature/ProviderSessionTest.php`: cover injected context, API logout, and local cleanup.
- Modify `resources/js/provider/02-requirements.js`: separate accessible required initialization from visible missing-value markers.
- Modify `resources/js/provider/08-categories-viewmodel.js`: reveal and clear the custom category-group marker.
- Modify `resources/views/provider/partials/categories.blade.php`: hide the custom group marker initially.
- Modify `tests/js/provider-requirements.test.js`: cover initial, invalid, corrected, and format-error states.
- Modify `tests/js/provider-categories.test.js`: cover group marker lifecycle.
- Modify `tests/Feature/ProviderPortalRequirementsGuidanceTest.php`: update the HTML contract.
- Modify `VERSION`: patch-version increment after the pre-commit version review.

## Task 1: API Lifetime Policy

- [ ] **Step 1: Write the failing unit test**

Create `tests/Unit/Services/Auth/AuthSessionLifetimePolicyTest.php` with a data provider asserting:

```php
#[DataProvider('lifetimes')]
public function test_it_selects_the_lifetime(bool $persistent, ?string $context, string $configKey): void
{
    $policy = app(AuthSessionLifetimePolicy::class);

    $this->assertSame((int) config($configKey), $policy->ttl($persistent, $context));
}

public static function lifetimes(): array
{
    return [
        'ordinary' => [false, null, 'jwt.ttl'],
        'app remembered' => [true, null, 'jwt.remember_ttl'],
        'web ordinary' => [false, AuthSessionLifetimePolicy::PROVIDER_WEB, 'jwt.ttl'],
        'web remembered' => [true, AuthSessionLifetimePolicy::PROVIDER_WEB, 'jwt.provider_web_remember_ttl'],
    ];
}
```

- [ ] **Step 2: Verify the test is red**

Run:

```bash
php artisan test tests/Unit/Services/Auth/AuthSessionLifetimePolicyTest.php
```

Expected: failure because `AuthSessionLifetimePolicy` does not exist.

- [ ] **Step 3: Implement the policy and configuration**

Create the service with this public contract:

```php
final class AuthSessionLifetimePolicy
{
    public const PROVIDER_WEB = 'provider_web';

    public function ttl(bool $persistent, ?string $context): int
    {
        if (! $persistent) {
            return (int) config('jwt.ttl');
        }

        return (int) config($context === self::PROVIDER_WEB
            ? 'jwt.provider_web_remember_ttl'
            : 'jwt.remember_ttl');
    }
}
```

Add to `config/jwt.php`:

```php
'provider_web_remember_ttl' => env('JWT_PROVIDER_WEB_REMEMBER_TTL', 43200),
```

Add to `.env.example`:

```dotenv
JWT_PROVIDER_WEB_REMEMBER_TTL=43200
```

- [ ] **Step 4: Verify the policy test is green**

Run the focused unit test and expect all four datasets to pass.

## Task 2: API Login, 2FA, Refresh, and Session Isolation

- [ ] **Step 1: Add failing feature coverage**

Extend authentication tests to assert:

```php
$response = $this->postJson('/auth/login', [
    'email' => $user->email,
    'password' => 'password',
    'remember_me' => true,
    'session_context' => AuthSessionLifetimePolicy::PROVIDER_WEB,
]);

$response->assertOk()->assertJson([
    'persistent' => true,
    'expires_in' => config('jwt.provider_web_remember_ttl') * 60,
]);
```

Also assert that an unknown context returns 422, a context-free remembered login still uses `jwt.remember_ttl`, refresh preserves `provider_web`, 2FA completion uses the web TTL, and logging out the web token leaves a second app session active.

- [ ] **Step 2: Verify the focused tests are red**

Run:

```bash
php artisan test tests/Feature/ApiSecurityTest.php tests/Feature/TwoFactorAuthTest.php
```

Expected: web-context assertions fail because the API ignores or rejects the new context.

- [ ] **Step 3: Make token issuance context-aware**

Inject `AuthSessionLifetimePolicy` into `AuthTokenService` and update its public methods to accept `?string $context = null`. Build claims as:

```php
$claims = [
    'session_id' => $sessionId,
    'token_version' => (int) $user->token_version,
];
if ($context !== null) {
    $claims['session_context'] = $context;
}
```

Use `$this->lifetimes->ttl($persistent, $context)` consistently for `setTTL()` and `expires_in`.

- [ ] **Step 4: Preserve context at every authentication transition**

In `AuthController::loginForRoles()`, validate:

```php
'session_context' => ['nullable', Rule::in([AuthSessionLifetimePolicy::PROVIDER_WEB])],
```

Pass the normalized context to `generateTemporaryToken()` and `issueTokenForUser()`. Store it in `TwoFactorService::generateTemporaryToken()` and pass it from both 2FA verification methods. Read `session_context` from the current JWT during refresh and legacy session rotation, set the same claim, and select the same TTL.

- [ ] **Step 5: Verify focused API tests are green**

Run the two feature files and the policy unit test. Expected: all pass, including session-isolation assertions.

## Task 3: Web BFF Session Context and Logout

- [ ] **Step 1: Write failing web feature tests**

Update `ProviderSessionTest` so login expects the upstream request data to include:

```php
[
    'email' => 'provider@example.com',
    'password' => 'secret-password',
    'remember_me' => true,
    'session_context' => 'provider_web',
]
```

Add a logout test with two fakes: `/user/logout` must receive `Authorization: Bearer <server token>`, then the response must clear all provider session keys. Add an expired-token case that clears local state without sending a bearer token.

- [ ] **Step 2: Verify the tests are red**

Run:

```bash
php artisan test tests/Feature/ProviderSessionTest.php
```

Expected: login lacks `session_context` and logout never reaches the API.

- [ ] **Step 3: Implement the BFF contract**

In `ProviderAuthApi::login()`, overwrite the server-controlled context before posting:

```php
$body = $this->post('/auth/login', $payload + ['session_context' => 'provider_web']);
```

Add to `ProviderAuthGateway`:

```php
public function logout(string $token): void;
```

Implement it with `ApiMyCodeHttpClient::requestJson()` and a server-side bearer header. Treat 401 caused by an already expired token as an idempotent logout; normalize other upstream failures without exposing details.

In `ProviderSessionController::logout()`, retrieve the validated server token, attempt upstream logout, then always forget provider state, invalidate the Laravel session, and regenerate the CSRF token.

- [ ] **Step 4: Align Laravel retention**

Change `.env.example` to:

```dotenv
SESSION_LIFETIME=43200
```

The production `.env` will receive the same value during deployment.

- [ ] **Step 5: Verify web session tests are green**

Run `ProviderSessionTest` and provider session security/transition tests. Expected: all pass and no JWT appears in JSON or HTML.

## Task 4: Deferred Required Markers

- [ ] **Step 1: Rewrite the JavaScript test expectations first**

Update `provider-requirements.test.js` to assert:

```js
providerInitializeRequiredGuidance(form, documentStub);
assert.strictEqual(emailLabel.querySelectorAll('[data-provider-required-marker]').length, 0);

email.validity = { valueMissing: true };
providerHandleRequiredInvalid({ target: email }, form, documentStub);
assert.strictEqual(emailLabel.querySelectorAll('[data-provider-required-marker]').length, 1);

email.validity = { valueMissing: false };
providerHandleRequiredCorrection({ target: email }, form, documentStub);
assert.strictEqual(emailLabel.querySelectorAll('[data-provider-required-marker]').length, 0);
```

Add a non-empty format-error case with `valueMissing: false` and expect no marker. Add category tests that reveal the legend marker only after an empty submit and clear it after selection.

- [ ] **Step 2: Verify JavaScript tests are red**

Run:

```bash
node tests/js/provider-requirements.test.js
node tests/js/provider-categories.test.js
```

Expected: initial-marker and lifecycle assertions fail against the current eager behavior.

- [ ] **Step 3: Implement marker lifecycle helpers**

Refactor `02-requirements.js` into focused helpers with these contracts:

```js
function providerInitializeRequiredGuidance(root = document) {
    const controls = Array.from(root.querySelectorAll('input[required], select[required], textarea[required]'));
    controls.forEach((control) => control.setAttribute('aria-required', 'true'));
    return controls.length;
}

function providerShowRequiredMarker(control, root = document, doc = document) {
    const label = providerRequirementLabel(control, root, doc);
    if (!label) return false;
    if (!label.querySelectorAll('[data-provider-required-marker]').length) {
        const marker = doc.createElement('span');
        marker.className = 'provider-required-marker';
        marker.dataset.providerRequiredMarker = '';
        marker.setAttribute('aria-hidden', 'true');
        marker.textContent = '*';
        label.insertBefore(marker, control);
    }
    if (!label.querySelectorAll('[data-provider-required-text]').length) {
        const text = doc.createElement('span');
        text.className = 'provider-sr-only';
        text.dataset.providerRequiredText = '';
        text.textContent = providerRequirementText('Campo obligatorio');
        label.insertBefore(text, control);
    }
    return true;
}

function providerHideRequiredMarker(control, root = document, doc = document) {
    const label = providerRequirementLabel(control, root, doc);
    if (!label) return false;
    ['[data-provider-required-marker]', '[data-provider-required-text]'].forEach((selector) => {
        label.querySelectorAll(selector).forEach((node) => node.remove());
    });
    return true;
}

function providerHandleRequiredInvalid(event, root = document, doc = document) {
    const control = event?.target;
    if (control?.validity?.valueMissing === true) {
        providerShowRequiredMarker(control, root, doc);
    }
}

function providerHandleRequiredCorrection(event, root = document, doc = document) {
    const control = event?.target;
    if (control?.validity?.valueMissing !== true) {
        providerHideRequiredMarker(control, root, doc);
    }
}
```

Register `invalid` in capture mode and `input`/`change` normally on the document. Keep `providerSetControlRequired()` compatible with dynamic controls without displaying a marker merely because a control became required.

- [ ] **Step 4: Implement category-group lifecycle**

Render the existing legend marker hidden initially. On a failed categories save caused by no selected categories, reveal it and its accessible text. Clear it after `providerHandleCategoryChange()` produces at least one selected category and a valid primary category.

- [ ] **Step 5: Verify focused JS and feature tests are green**

Run the two Node tests and `ProviderPortalRequirementsGuidanceTest`. Expected: markers follow submit state while requirement guides remain present.

## Task 5: Full Verification, Versioning, and Integration

- [ ] **Step 1: Run the API verification matrix**

```bash
php artisan test --do-not-cache-result
```

Expected: zero failures.

- [ ] **Step 2: Run the web verification matrix**

```bash
npm run test:provider
php artisan test --do-not-cache-result
php artisan architecture:audit --strict
npm run production
git diff --check
```

Expected: Node tests, PHPUnit, architecture audit, production build, and whitespace check all pass.

- [ ] **Step 3: Review generated artifacts and versions**

Confirm production bundles contain the new marker lifecycle and no secrets. Review the Git version dialogue. Because the API contract and web behavior both change compatibly, increment the web patch version from `1.7.3` to `1.7.4`; follow the API repository's existing version convention if it has a version file, otherwise do not invent one.

- [ ] **Step 4: Commit only scoped files**

Create separate API and web commits without staging pre-existing `.DS_Store`, image, cache, or `.codex` changes.

- [ ] **Step 5: Merge to and push `master`**

Fetch each remote, verify `origin/master...master` has no remote-only commits, fast-forward local master, and push API first, then web. Confirm `HEAD` and `origin/master` match in both repositories.

- [ ] **Step 6: Prepare deployment commands**

Provide two safe scripts for:

```text
/var/www/services/apimycode
/var/www/html/webmycode
```

The API script must set `JWT_PROVIDER_WEB_REMEMBER_TTL=43200`, migrate, rebuild caches, and return the service from maintenance mode. The web script must set `SESSION_LIFETIME=43200`, install dependencies, build production assets, rebuild caches, and return the site from maintenance mode. API deployment must run first.
