# Implementation Plan: NetSuite OAuth 2.0 M2M Migration & Certificate Rotation

---

## 1. System Summary

- New `netsuite_m2m` auth type implements **OAuth 2.0 Client Credentials via JWT Bearer Assertion** (RFC 7523) — `grant_type=client_credentials` + `client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer`
- Per-tenant **RSA-4096 key pairs** generated by `NetsuiteM2MService`; private key encrypted in `encrypted_keys` via the existing `KeyManagementService`; public key exported as self-signed X.509 PEM for customer upload to NetSuite
- Access tokens (~60-min TTL) **cached in Redis** per connector (`netsuite_m2m_token:{connector_id}`); auto-refreshed on miss/expiry
- **Certificate rotation** uses overlap-window: secondary key is stored and advertised before old key is removed; an atomic Redis lock (`netsuite_m2m_rotation_lock:{connector_id}`) prevents concurrent switches
- Plugs into existing `AuthContext` / `IpaasHelper::getAuthContextStrategy()` strategy dispatch via a new `case 'netsuite_m2m':`

**Key components involved:**
- `NetsuiteM2MService` (new) — JWT generation, token exchange, Redis caching, key pair lifecycle
- `ConnectorNetsuiteM2M` (new) — `ConnectorStrategy` implementation wiring service into the dispatch system
- `NetsuiteM2MController` (new) — HTTP layer for key generation, cert download, rotation, verification
- `IpaasHelper` (modified) — strategy switch extended
- `ConnectorSeeder` (modified) — two new `config_types` rows

---

## 2. Execution Plan

---

### Step 1 — Extend `ConnectorSeeder` with `netsuite_m2m` config types

**Objective:** Register the auth type and application codes that all subsequent classes depend on.

**Actions:**
- Edit `database/seeders/tenants/ConnectorSeeder.php`
- Append to the `$records` array:
  ```php
  [
      "id" => 31,
      "category" => "auth_types",
      "code" => "netsuite_m2m",
      "name" => "NetSuite M2M (OAuth 2.0 Client Credentials)",
      "created_at" => null,
      "updated_at" => null
  ],
  [
      "id" => 32,
      "category" => "application",
      "code" => "netsuite_m2m",
      "name" => "NetSuite (M2M / Client Credentials)",
      "created_at" => null,
      "updated_at" => null
  ],
  ```

**Expected Outcome:** Two new rows exist in `config_types` after seeding.

**Validation:**
- `php artisan db:seed --class="Database\\Seeders\\tenants\\ConnectorSeeder"` exits 0
- `select id, category, code from config_types where id in (31,32);` returns both rows

**Dependencies:** None

---

### Step 2 — Create `netsuite_m2m.json` connector template

**Objective:** Define the static connector definition that seeds the M2M connector into the platform.

**Actions:**
- Create `src/Domain/Ipaas/Connectors/Data/netsuite_m2m.json`:

```json
{
    "name": "NetSuite Connector (M2M / Client Credentials)",
    "connector_type": "netsuite_m2m",
    "base_url": "https://{{account_id}}.suitetalk.api.netsuite.com",
    "auth_type": "31",
    "auth_url": "",
    "config": {
        "client_id": "",
        "client_secret": "",
        "redirect_url": "",
        "scope": "restlets rest_webservices",
        "token_secret": "",
        "send_credentials_via": "5",
        "send_token_via": "8",
        "auth_endpoint": "",
        "access_token_url": "https://{{account_id}}.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token",
        "revoke_token_url": "",
        "resource_url": "",
        "valid_domains": "",
        "headers": [
            { "key": "", "value": "" }
        ],
        "requestBody": [
            { "key": "", "value": "" }
        ],
        "consumer_key": "",
        "consumer_secret": "",
        "access_token": "",
        "signature_method": "",
        "realm": "",
        "include_body_hash": "",
        "add_empty_params": "",
        "encode_params": ""
    },
    "throttle_max_requests": 20,
    "throttle_per_seconds": 60,
    "media_type": "14",
    "grant_type": "3",
    "auth_scope": "restlets rest_webservices",
    "application": "32"
}
```

**Note:** `realm` stores the NetSuite account ID (e.g. `1234567`). The `{{account_id}}` placeholder in `base_url` and `access_token_url` must be replaced at connector configuration time.

**Expected Outcome:** File is valid JSON and all config keys match `ConnectorConfig::$fillable`.

**Validation:**
- `php -r "var_dump(json_decode(file_get_contents('src/Domain/Ipaas/Connectors/Data/netsuite_m2m.json'), true) !== null);"` outputs `bool(true)`

**Dependencies:** Step 1

---

### Step 3 — Create `NetsuiteM2MService`

**Objective:** Core service for JWT generation, token exchange, Redis caching, key pair generation, and certificate rotation.

**Actions:**
- Create `src/App/Services/Ipaas/Connectors/NetsuiteM2MService.php`
- Class extends `App\Services\JWT\BaseJWTAuth`
- Constructor signature:
  ```php
  public function __construct(
      ConnectorConfig $config,
      KeyManagementService $keyManager
  )
  ```
- Store `$config` and `$keyManager` as private properties. Pass `$config->realm` (account ID) as `$accountIdentifier`, `$config->client_id` as `$username`, and `3540` as `$tokenExpiration` to `parent::__construct()`.

**Method: `getPrivateKey()`**
- Decode `$this->config->file_config` as JSON
- Extract `activeKeyId` (fall back to `primaryKeyId` if `activeKeyId` absent)
- Throw `\RuntimeException('No active key configured for this connector')` if key ID is empty
- Call `$this->keyManager->getPrivateKey($keyId)`, throw on null result
- Return `openssl_pkey_get_private($content)`, throw if `false`

**Method: `createPayload(): array`**
- Return:
  ```php
  [
      'iss' => $this->config->client_id,
      'sub' => $this->config->client_id,
      'aud' => $this->config->access_token_url,
      'iat' => time(),
      'exp' => $this->getExpirationTime(),
  ]
  ```

**Method: `authenticate(): array`**
- Call `createPayload()` and `getPrivateKey()`
- Call `generateJWT($payload, $privateKey)` from `BaseJWTAuth`
- POST to `$this->config->access_token_url` using `IpaasHelper::executeCurl()` with form body:
  ```
  grant_type=client_credentials
  client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
  client_assertion=<jwt>
  ```
- Parse JSON response; return `['success' => true, 'access_token' => ..., 'expires_in' => ..., 'token_type' => ...]`
- On HTTP 4xx/5xx or `access_token` absent in response, log error and return `['success' => false, 'error' => ...]`

**Method: `getOrRefreshAccessToken(): string`**
- Cache key: `"netsuite_m2m_token:{$this->config->connector_id}"`
- Call `Cache::get($cacheKey)`; if present and not empty, return it
- Otherwise call `authenticate()`; on `success = false`, throw `\RuntimeException`
- Store access token: `Cache::put($cacheKey, $token, max(1, ($expiresIn - 300)))`
- Return token string

**Method: `bustTokenCache(): void`**
- `Cache::forget("netsuite_m2m_token:{$this->config->connector_id}")`

**Method: `generateKeyPair(): array`**
- `$keyResource = openssl_pkey_new(['digest_alg' => 'sha512', 'private_key_bits' => 4096, 'private_key_type' => OPENSSL_KEYTYPE_RSA])`
- `openssl_pkey_export($keyResource, $privatePem)` — throw on failure
- `$details = openssl_pkey_get_details($keyResource)` — extract `$details['key']` as `$publicPem`
- Return `['privateKey' => $privatePem, 'publicKey' => $publicPem, 'keyResource' => $keyResource]`

**Method: `generateCertificate($privateKeyResource): string`**
- Define DN: `['commonName' => 'SuiteX iPaaS - Tenant ' . $this->config->realm, 'organizationName' => 'SuiteX', 'countryName' => 'US']`
- `$csr = openssl_csr_new($dn, $privateKeyResource, ['digest_alg' => 'sha256'])`
- `$cert = openssl_csr_sign($csr, null, $privateKeyResource, 1095, ['digest_alg' => 'sha256'])` (3-year validity)
- `openssl_x509_export($cert, $certPem)` — throw on failure
- Return `$certPem`

**Method: `initiateRotation(): string`** (returns new public cert PEM)
- Decode current `$this->config->file_config` JSON
- Throw `\RuntimeException('Rotation already in progress')` if `secondaryKeyId` is non-empty
- Call `generateKeyPair()` and `generateCertificate()`
- Generate secondary key ID: `'netsuite_m2m_secondary_' . $this->config->connector_id . '_' . time()`
- Call `$this->keyManager->storePrivateKey($secondaryKeyId, $keyPair['privateKey'], $this->config->connector_id)`
- Update `file_config` JSON: merge `secondaryKeyId`, `rotationInitiatedAt = now()->toIso8601String()` into existing JSON
- Save `$this->config`
- Return `$certPem`

**Method: `confirmRotation(): void`**
- Acquire Redis lock: `Cache::lock("netsuite_m2m_rotation_lock:{$this->config->connector_id}", 30)`; throw `\RuntimeException('Could not acquire rotation lock')` on failure
- Inside lock:
  - Decode `file_config`; throw `\RuntimeException('No rotation in progress')` if `secondaryKeyId` is empty
  - Record `$oldPrimaryKeyId = $fileConfig['primaryKeyId'] ?? null`
  - Update `file_config`: set `primaryKeyId = secondaryKeyId`, `activeKeyId = secondaryKeyId`, unset `secondaryKeyId` and `rotationInitiatedAt`
  - Save `$this->config`
  - If `$oldPrimaryKeyId` is non-empty, call `$this->keyManager->deletePrivateKey($oldPrimaryKeyId)` — log warning but do not throw on failure
  - Call `bustTokenCache()`
- Release lock

**Expected Outcome:** Class instantiable; all methods behave per spec; PHPStan level 5 clean.

**Validation:**
- `./vendor/bin/phpstan analyse src/App/Services/Ipaas/Connectors/NetsuiteM2MService.php --level=5`

**Dependencies:** Steps 1, 2; `BaseJWTAuth`, `KeyManagementService`, `IpaasHelper` already exist

---

### Step 4 — Create `ConnectorNetsuiteM2M` strategy

**Objective:** Auth strategy that wires `NetsuiteM2MService` into the `AuthContext` dispatch system.

**Actions:**
- Create `src/Domain/Ipaas/Connectors/Strategy/ConnectorNetsuiteM2M.php`
- Class extends `Domain\Ipaas\Abstracts\ConnectorStrategy`
- Constructor: `public function __construct(private KeyManagementService $keyManager)`

**Method: `authenticate($connector)`**
- Instantiate `NetsuiteM2MService($connector->config, $this->keyManager)`
- Call `$service->getOrRefreshAccessToken()`
- On success: `$connector->config->access_token = $token; $connector->config->save();`
- Return `['redirect' => false]`
- Catch `\Throwable`, log with `connector_id`, re-throw

**Method: `refreshToken($connector, $relogin = false, $isPreview = false)`**
- Instantiate `NetsuiteM2MService($connector->config, $this->keyManager)`
- Call `$service->bustTokenCache()`
- Call `$service->getOrRefreshAccessToken()`
- Update `$connector->config->access_token` and save
- Return `['success' => true]`
- Catch `\Throwable`, log, throw `TokenRefreshException`

**Method: `makeRequest($input)`**
- Set `$this->setToken = true`
- Return `$this->request($input)` (parent `ConnectorStrategy::request()` handles Bearer header injection via `IpaasHelper::setHeaders`)

**Expected Outcome:** PHPStan level 5 clean; strategy dispatches correctly.

**Validation:**
- `./vendor/bin/phpstan analyse src/Domain/Ipaas/Connectors/Strategy/ConnectorNetsuiteM2M.php --level=5`

**Dependencies:** Steps 1, 3

---

### Step 5 — Register `netsuite_m2m` in `IpaasHelper::getAuthContextStrategy()`

**Objective:** Route connectors with `authType->code === 'netsuite_m2m'` to `ConnectorNetsuiteM2M`.

**Actions:**
- Edit `src/App/Helpers/IpaasHelper.php`
- Add import: `use Domain\Ipaas\Connectors\Strategy\ConnectorNetsuiteM2M;`
- In `getAuthContextStrategy()` switch, before `default:`, add:
  ```php
  case 'netsuite_m2m':
      $keyManager = app(KeyManagementService::class);
      $authContext->setStrategy(new ConnectorNetsuiteM2M($keyManager));
      break;
  ```

**Expected Outcome:** `getAuthContextStrategy()` dispatches `netsuite_m2m` connectors without hitting `default` exception.

**Validation:**
- `./vendor/bin/phpstan analyse src/App/Helpers/IpaasHelper.php --level=5`

**Dependencies:** Steps 1, 4

---

### Step 6 — Create `NetsuiteM2MController`

**Objective:** HTTP interface for key pair generation, certificate download, rotation lifecycle, and token verification.

**Actions:**
- Create `src/App/Http/Controllers/Ipaas/NetsuiteM2MController.php`
- Inject `KeyManagementService $keyManager` and `ConnectorPrivateKeyManager $privateKeyManager` via constructor

**Private helper `resolveConnector(int $connectorId): Connector`**
- Load `Connector::findOrFail($connectorId)->load('config', 'app')`
- Abort 404 if not found; abort 403 if `$connector->app->code !== 'netsuite_m2m'`
- Return connector

**`generateKeyPair(Request $request, int $connectorId): Response`**
- Call `resolveConnector()`
- Decode `connector->config->file_config`; abort 409 with `'Key pair already exists. Use the rotation endpoint.'` if `primaryKeyId` is non-empty
- Instantiate `NetsuiteM2MService($connector->config, $this->keyManager)`
- Call `$service->generateKeyPair()`, then `$service->generateCertificate($keyPair['keyResource'])`
- Generate `$keyId = 'netsuite_m2m_' . $connectorId . '_' . time()`
- Call `$this->privateKeyManager->savePrivateKey($keyId, $keyPair['privateKey'], $connectorId, 'netsuite_m2m_private.pem')`
- Update `connector->config->file_config = json_encode(['primaryKeyId' => $keyId, 'activeKeyId' => $keyId, 'fileName' => 'netsuite_m2m_private.pem'])`
- Save config
- Return file download response: `response($certPem, 200, ['Content-Type' => 'application/x-pem-file', 'Content-Disposition' => 'attachment; filename="netsuite_public_cert.pem"'])`

**`downloadCertificate(Request $request, int $connectorId): Response`**
- Call `resolveConnector()`
- Decode `file_config`; abort 404 if `activeKeyId` absent
- `$privatePem = $this->keyManager->getPrivateKey($activeKeyId)`; abort 404 if null
- `$keyResource = openssl_pkey_get_private($privatePem)`
- Instantiate `NetsuiteM2MService($connector->config, $this->keyManager)`
- Call `$service->generateCertificate($keyResource)`
- Return PEM file download

**`initiateRotation(Request $request, int $connectorId): JsonResponse`**
- Call `resolveConnector()`
- Instantiate `NetsuiteM2MService($connector->config, $this->keyManager)` — note: `initiateRotation()` on the service reads/writes `$connector->config->file_config` directly, so pass the config by reference or reload after
- Call `$certPem = $service->initiateRotation()`
- Return JSON: `{ "message": "Upload this certificate to your NetSuite integration record, then call /rotation/confirm", "certificate": "<certPem>" }` with HTTP 200
- If `RuntimeException('Rotation already in progress')`, return 422

**`confirmRotation(Request $request, int $connectorId): JsonResponse`**
- Call `resolveConnector()`
- Instantiate `NetsuiteM2MService($connector->config, $this->keyManager)`
- Call `$service->confirmRotation()`
- Return `{ "success": true, "message": "Certificate rotation complete. Remove the old certificate from NetSuite." }` with HTTP 200
- If `RuntimeException('No rotation in progress')`, return 422
- If `RuntimeException('Could not acquire rotation lock')`, return 503

**`verifyToken(Request $request, int $connectorId): JsonResponse`**
- Call `resolveConnector()`
- Instantiate `NetsuiteM2MService($connector->config, $this->keyManager)`
- Call `$service->bustTokenCache()` then `$service->authenticate()`
- If `success = true`: return `{ "success": true, "token_preview": "Bearer <first 20 chars>...", "expires_in": <n> }` with HTTP 200
- If `success = false`: return `{ "success": false, "error": <message> }` with HTTP 422

**Expected Outcome:** PHPStan level 5 clean; all endpoints return documented shapes.

**Validation:**
- `./vendor/bin/phpstan analyse src/App/Http/Controllers/Ipaas/NetsuiteM2MController.php --level=5`

**Dependencies:** Steps 3, 4, 5

---

### Step 7 — Register routes in `routes/tenant.php`

**Objective:** Expose all 5 M2M endpoints under existing tenant auth middleware.

**Actions:**
- Edit `routes/tenant.php`
- Add import: `use App\Http\Controllers\Ipaas\NetsuiteM2MController;`
- Inside the existing authenticated tenant middleware group, add:
  ```php
  Route::prefix('ipaas/connectors/{connectorId}/m2m')
      ->name('ipaas.m2m.')
      ->group(function () {
          Route::post('key-pair', [NetsuiteM2MController::class, 'generateKeyPair'])
              ->name('generateKeyPair');
          Route::get('certificate', [NetsuiteM2MController::class, 'downloadCertificate'])
              ->name('downloadCertificate');
          Route::post('rotation/initiate', [NetsuiteM2MController::class, 'initiateRotation'])
              ->name('initiateRotation');
          Route::post('rotation/confirm', [NetsuiteM2MController::class, 'confirmRotation'])
              ->name('confirmRotation');
          Route::post('verify', [NetsuiteM2MController::class, 'verifyToken'])
              ->name('verifyToken');
      });
  ```

**Expected Outcome:** 5 named routes registered under the tenant middleware stack.

**Validation:**
- `php artisan route:list --name=ipaas.m2m` shows all 5 routes with correct verbs and URIs

**Dependencies:** Step 6

---

### Step 8 — PHPStan pass on all production files

**Objective:** Enforce static analysis compliance before writing tests.

**Actions:**
- Run:
  ```bash
  ./vendor/bin/phpstan analyse --level=5 \
    src/App/Services/Ipaas/Connectors/NetsuiteM2MService.php \
    src/Domain/Ipaas/Connectors/Strategy/ConnectorNetsuiteM2M.php \
    src/App/Helpers/IpaasHelper.php \
    src/App/Http/Controllers/Ipaas/NetsuiteM2MController.php
  ```
- Fix all reported errors before proceeding

**Expected Outcome:** Zero errors at level 5.

**Validation:**
- Exit code 0 from the above command

**Dependencies:** Steps 3–7

---

### Step 9 — Write unit tests for `NetsuiteM2MService`

**Objective:** Verify JWT payload correctness, caching behaviour, key lifecycle, and rotation atomicity.

**Actions:**
- Create `tests/Unit/Services/Ipaas/Connectors/NetsuiteM2MServiceTest.php`
- Use Pest PHP; mock `KeyManagementService` and `Cache` facade as needed
- **Test cases (minimum):**
  - `it creates correct JWT payload`: asserts `iss === client_id`, `sub === client_id`, `aud === access_token_url`, `exp = iat + 3540 ± 1`
  - `it returns cached access token on cache hit`: mock `Cache::get()` returning a token string; assert `authenticate()` is never called
  - `it fetches and caches token on cache miss`: mock `Cache::get()` null, mock HTTP call returning valid token; assert `Cache::put()` called with `TTL = expires_in - 300`
  - `it throws RuntimeException when authenticate fails`: mock HTTP 401 response; assert exception thrown from `getOrRefreshAccessToken()`
  - `it generates valid RSA 4096 private key`: call `generateKeyPair()`; assert `openssl_pkey_get_private($result['privateKey'])` !== false; assert key details show 4096 bits
  - `it generates a valid X509 certificate`: call `generateCertificate()` with test key resource; assert output contains `-----BEGIN CERTIFICATE-----`; assert `openssl_x509_parse()` returns array with `subject.CN` containing tenant realm
  - `it stores secondary key and sets rotationInitiatedAt on initiateRotation`: mock `KeyManagementService::storePrivateKey()`; assert `file_config->secondaryKeyId` non-empty and `rotationInitiatedAt` non-null after call
  - `it throws on initiateRotation when rotation already in progress`: set `file_config.secondaryKeyId` to non-empty string; assert `RuntimeException` thrown
  - `it promotes secondary key, deletes old primary, and busts cache on confirmRotation`: mock `KeyManagementService::deletePrivateKey()`; assert `file_config->primaryKeyId === oldSecondaryKeyId` and `Cache::forget()` called
  - `it throws on confirmRotation when no rotation in progress`: `file_config.secondaryKeyId` empty; assert `RuntimeException` thrown

**Expected Outcome:** All tests green.

**Validation:**
- `./vendor/bin/pest tests/Unit/Services/Ipaas/Connectors/NetsuiteM2MServiceTest.php`

**Dependencies:** Steps 3, 8

---

### Step 10 — Write unit tests for `ConnectorNetsuiteM2M` strategy

**Objective:** Verify the strategy correctly delegates to the service, handles token injection, and responds to 401s.

**Actions:**
- Create `tests/Unit/Domain/Ipaas/Connectors/Strategy/ConnectorNetsuiteM2MTest.php`
- **Test cases (minimum):**
  - `it authenticates and stores access token without redirect`: mock `NetsuiteM2MService::getOrRefreshAccessToken()`; assert `connector->config->access_token` is set; assert return value is `['redirect' => false]`
  - `it refreshes token by busting cache first`: assert `bustTokenCache()` called before `getOrRefreshAccessToken()`
  - `it sets Bearer token header on makeRequest`: inspect `$input` passed to `request()`; assert `setToken === true`
  - `it throws TokenRefreshException when service fails`: mock service throwing; assert `TokenRefreshException` propagated from `refreshToken()`

**Expected Outcome:** All tests green.

**Validation:**
- `./vendor/bin/pest tests/Unit/Domain/Ipaas/Connectors/Strategy/ConnectorNetsuiteM2MTest.php`

**Dependencies:** Steps 4, 9

---

### Step 11 — Write feature tests for `NetsuiteM2MController`

**Objective:** Verify all 5 HTTP endpoints return correct status codes, headers, and bodies.

**Actions:**
- Create `tests/Feature/Http/Controllers/Ipaas/NetsuiteM2MControllerTest.php`
- Use `SetupMultiTenancyForTests` trait; seed config_types via `ConnectorSeeder`
- Create a test `netsuite_m2m` connector and config in `beforeEach`
- **Test cases (minimum):**
  - `POST /m2m/key-pair → 200 + PEM file download`: assert `Content-Type: application/x-pem-file`; assert `file_config->primaryKeyId` non-null in DB after
  - `POST /m2m/key-pair → 409 when key already exists`: set `file_config.primaryKeyId` pre-test; assert 409 response
  - `GET /m2m/certificate → 200 + PEM file`: assert response body contains `-----BEGIN CERTIFICATE-----`
  - `GET /m2m/certificate → 404 when no key configured`: assert 404
  - `POST /m2m/rotation/initiate → 200 + JSON with certificate field`: assert `data.certificate` contains PEM; assert `file_config->secondaryKeyId` non-null in DB
  - `POST /m2m/rotation/initiate → 422 when rotation already in progress`: preset `secondaryKeyId`; assert 422
  - `POST /m2m/rotation/confirm → 200 on success`: preset rotation state; assert `primaryKeyId` promoted; `secondaryKeyId` cleared
  - `POST /m2m/rotation/confirm → 422 when no rotation in progress`: assert 422
  - `POST /m2m/verify → 200 with token preview on valid config`: mock `NetsuiteM2MService::authenticate()` returning success; assert `data.success === true`
  - `POST /m2m/verify → 422 with error message on invalid config`: mock authenticate returning failure; assert 422 + `data.error` non-empty

**Expected Outcome:** All tests green.

**Validation:**
- `./vendor/bin/pest tests/Feature/Http/Controllers/Ipaas/NetsuiteM2MControllerTest.php`

**Dependencies:** Steps 6, 7, 10

---

## 3. File / Component Mapping

### New Files

| File | Purpose |
|------|---------|
| `src/Domain/Ipaas/Connectors/Data/netsuite_m2m.json` | Connector template definition for platform seeding |
| `src/App/Services/Ipaas/Connectors/NetsuiteM2MService.php` | Core: JWT generation, token exchange via RFC 7523, Redis caching, key pair/cert generation, rotation lifecycle |
| `src/Domain/Ipaas/Connectors/Strategy/ConnectorNetsuiteM2M.php` | Auth strategy: integrates M2M service into `AuthContext` dispatch |
| `src/App/Http/Controllers/Ipaas/NetsuiteM2MController.php` | HTTP: key pair generation, cert download, rotation initiate/confirm, token verification |
| `tests/Unit/Services/Ipaas/Connectors/NetsuiteM2MServiceTest.php` | Unit coverage for `NetsuiteM2MService` |
| `tests/Unit/Domain/Ipaas/Connectors/Strategy/ConnectorNetsuiteM2MTest.php` | Unit coverage for `ConnectorNetsuiteM2M` |
| `tests/Feature/Http/Controllers/Ipaas/NetsuiteM2MControllerTest.php` | Feature coverage for all 5 controller endpoints |

### Modified Files

| File | Change |
|------|--------|
| `database/seeders/tenants/ConnectorSeeder.php` | Append IDs 31 (`auth_types/netsuite_m2m`) and 32 (`application/netsuite_m2m`) |
| `src/App/Helpers/IpaasHelper.php` | Add `use ConnectorNetsuiteM2M;` + `case 'netsuite_m2m':` in strategy switch |
| `routes/tenant.php` | Register 5 named routes under `ipaas.m2m.*` prefix |

---

## 4. Risks & Unknowns

### Confirmed Before Implementation

1. **NetSuite JWT Bearer grant parameters**: Verify that NetSuite's token endpoint accepts `client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer`. Some NetSuite accounts use a non-standard `client_assertion_type` value or require `client_id` as a separate body parameter in addition to the assertion. Test against a sandbox before finalising `authenticate()`.

2. **JWT `aud` claim format**: NetSuite documentation specifies `aud` must equal the full token URL (`https://<account>.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token`). Confirm this requirement against the target NetSuite version before step 3 is complete.

3. **X.509 certificate DN requirements**: NetSuite may enforce specific CN or Organisation fields in the uploaded public certificate. Verify required Distinguished Name fields before finalising `generateCertificate()`.

4. **`EncryptedKey` table tenancy**: The `EncryptedKey` model uses the default (core) database connection — not `tenant_connection`. Secondary private keys stored during rotation will be scoped by `connector_id`, not by tenant. If per-tenant isolation is required in the `encrypted_keys` table, a migration to add `tenant_id` or move the model to the tenant database must be added as Step 0.

### Architecture Decisions (Flag for Review)

5. **`file_config` JSON vs. explicit migration columns**: The plan extends the existing `file_config` JSON blob (established by the Snowflake pattern) to track `primaryKeyId`, `activeKeyId`, `secondaryKeyId`, and `rotationInitiatedAt`. An alternative is a migration adding explicit typed columns (`active_key_id VARCHAR`, `secondary_key_id VARCHAR`, `key_rotation_initiated_at TIMESTAMP`) to `connector_config`. The JSON approach avoids a migration but reduces queryability and PHPStan type safety. If explicit columns are preferred, insert a migration step between Steps 1 and 3.

6. **TBA → M2M data migration for existing tenants**: This plan creates a new connector type. It does not include a script to migrate existing `netsuite_oauth2` or `netsuite_oauth1` connectors to the new type. A separate admin migration Artisan command scoped per tenant is needed for production cut-over and should be treated as a follow-up task.

7. **Redis lock fallback**: `confirmRotation()` uses `Cache::lock()` (Redis-backed). If Redis is unavailable, rotation will fail with a 503. A DB-level advisory lock or optimistic concurrency check on `rotationInitiatedAt` should be considered as a fallback if Redis reliability is a concern in the production environment.

8. **Certificate validity period**: `generateCertificate()` defaults to 1095 days (3 years). Confirm this aligns with the organisation's certificate rotation policy and SOC2 compliance requirements before shipping.
