# AI API authentication investigation and development setup

**Status:** verified against source on 2026-09-06.
**Scope:** investigation of the Numu Angels AI API authentication paths. No
access token, database record, or deployment setting was changed during the
investigation.

## Development deployment

Use this base URL for the development deployment:

```text
https://dev.numuangels.net/numu_development/public
```

Set these values on the development server (not in source code):

```dotenv
APP_URL=https://dev.numuangels.net/numu_development/public
NUMU_AI_ENABLED=true
```

`APP_URL` must have no trailing slash. The application is served from a
subdirectory, so every URL retains `/public`:

```text
GET https://dev.numuangels.net/numu_development/public/api/v1/ai/startups
GET https://dev.numuangels.net/numu_development/public/api/v1/ai/_ping/read
GET https://dev.numuangels.net/numu_development/public/api/v1/ai/mcp/read
```

### Reachability check

On 2026-09-06, unauthenticated requests to `/api/v1/ai/_ping/read` and the
target `/api/v1/ai/startups` path on the development URL returned:

```text
HTTP 401
Content-Type: application/json
{"message":"Unauthenticated."}
```

This verifies that the hostname, subdirectory, route registration, and Laravel
authentication boundary are reachable in development. It does not validate any
specific PAT; that requires a valid development Token.

After the environment is deployed, refresh the cached configuration and
re-seed the OAuth client. The seed updates only its configured policy envelope
and derives the OAuth resource audiences from the effective `APP_URL`.

```bash
php artisan config:cache
php artisan db:seed --class=OauthClaudeClientSeeder
```

Do not run that seed on a different environment while `APP_URL` points to the
development host.

## Proven route behavior

`bootstrap/app.php` registers `routes/api.php` with the `api` prefix;
`routes/api.php` adds the `v1` prefix. The feature flag `config('ai.enabled')`
must be true or all `/api/v1/ai/*` routes return 404.

The legacy endpoint below is a **Sanctum-only** endpoint:

```text
GET /api/v1/ai/startups
```

It is registered in `routes/api.php` with:

```php
['auth:sanctum', 'ability:numu:read', 'ai.cb', 'throttle:300,1']
```

The request reaches `App\Http\Controllers\Api\V1\Ai\AiStartupController::list`
only after authentication and ability checks succeed.

This differs from the MCP endpoints:

```text
/api/v1/ai/mcp/read
/api/v1/ai/mcp/full
```

They use `App\Http\Middleware\McpAuth`, which accepts OAuth and Sanctum.

There is no middleware alias or implementation named
`oauth_or_sanctum_bearer` in this repository. That description must not be
used to infer the auth path for `/api/v1/ai/startups`.

## Token abilities, expiry, and revocation

AI connector Tokens are issued by
`app/Http/Controllers/Admin/AiConnectorController.php`. The canonical
abilities are defined in `app/Services/Ai/AiAbilities.php`:

| Connector type | Stored abilities | Legacy startup read |
|---|---|---|
| `read` | `numu:read` | Allowed |
| `full` | `numu:read`, `numu:full` | Allowed |

Token names are labels only. For example, a Token named
`numu-dev-api-admin` does not automatically gain `numu:full`; the JSON
`abilities` column is authoritative.

Sanctum accepts a full personal access Token in this standard form:

```text
<numeric-token-id>|<plain-text-secret>
```

`Laravel\Sanctum\PersonalAccessToken::findToken()` uses the numeric ID and a
hash comparison. The database stores a hash, never the plaintext secret.

The `personal_access_tokens` table contains `expires_at`, but the AI connector
issuer does not set an expiry and Sanctum's package default has
`expiration => null`. The effective development value still needs verification.

Connector Token revocation is hard deletion via `AiConnectorController::destroy`.
There is no `revoked_at` or `deleted_at` column on this Sanctum table; absence
of a row means it is not currently usable.

## OAuth and Sanctum decision flow

`app/Http/Middleware/McpAuth.php` uses the token prefix as the branch:

```text
Authorization: Bearer TOKEN
  TOKEN starts with naat_  -> OAuth access-token validation
  any other TOKEN          -> Sanctum PersonalAccessToken::findToken()
```

OAuth does not fall back to Sanctum after an OAuth failure. OAuth Tokens need
an exact resource audience and the expected scope (`numu:read` for read,
`numu:write` for full). They are supported for MCP routes, but not for the
legacy `/api/v1/ai/startups` route.

Bearer calls do not require CSRF, a Sanctum stateful-domain cookie, or a
session. Use `Accept: application/json` for a predictable JSON error response.

## Exact 401 source

For the legacy startup endpoint, this response:

```json
{"message":"Unauthenticated."}
```

means this path occurred:

```text
auth:sanctum
  -> Laravel\Sanctum\Guard::__invoke()
  -> token missing, invalid, expired, or tokenable invalid
  -> Illuminate\Auth\Middleware\Authenticate
  -> Illuminate\Foundation\Exceptions\Handler::unauthenticated()
  -> HTTP 401
```

The controller is not run. A valid Token without `numu:read` fails later with
403. The AI circuit breaker produces 423, and throttling produces 429.

`McpAuth` has a different error shape:

```json
{"error":"invalid_token","error_description":"..."}
```

## Safe development diagnostics

Confirm the route exists:

```bash
php artisan route:list --path=api/v1/ai/startups -v
```

Inspect only non-secret Token metadata:

```bash
php artisan tinker --execute='
use Laravel\Sanctum\PersonalAccessToken;

dump(PersonalAccessToken::query()
  ->whereIn("name", ["numu-dev-api-read", "numu-dev-api-admin"])
  ->get(["id", "tokenable_type", "tokenable_id", "name", "abilities", "created_at", "last_used_at", "expires_at"])
  ->map(fn ($token) => [
    "id" => $token->id,
    "name" => $token->name,
    "owner_type" => $token->tokenable_type,
    "owner_id" => $token->tokenable_id,
    "owner_email" => $token->tokenable?->email,
    "abilities" => $token->abilities,
    "created_at" => $token->created_at,
    "last_used_at" => $token->last_used_at,
    "expires_at" => $token->expires_at,
  ])->all());
'
```

Inspect the effective non-secret configuration:

```bash
php artisan tinker --execute='dump([
  "app_url" => config("app.url"),
  "ai_enabled" => config("ai.enabled"),
  "sanctum_expiration" => config("sanctum.expiration"),
  "sanctum_guard" => config("sanctum.guard"),
]);'
```

Verify the lowest-risk authenticated endpoint first:

```bash
curl -i \
  -H "Authorization: Bearer $FULL_TOKEN" \
  -H "Accept: application/json" \
  https://dev.numuangels.net/numu_development/public/api/v1/ai/_ping/read
```

Then test the target endpoint:

```bash
curl -i \
  -H "Authorization: Bearer $FULL_TOKEN" \
  -H "Accept: application/json" \
  https://dev.numuangels.net/numu_development/public/api/v1/ai/startups
```

Compare with a deliberately invalid Token:

```bash
curl -i \
  -H "Authorization: Bearer 0|definitely-not-a-valid-token" \
  -H "Accept: application/json" \
  https://dev.numuangels.net/numu_development/public/api/v1/ai/startups
```

## Conclusion

The full `id|secret` Sanctum PAT form is valid for the legacy startup endpoint.
A 401 with `{"message":"Unauthenticated."}` is a failed Sanctum resolution,
not an OAuth failure. Verify the effective development `APP_URL`,
`NUMU_AI_ENABLED`, route registration, and non-secret metadata for the two
named Tokens before rotating or recreating anything.
