← Controller Pinning Audit / API
Tokens

Drive Controller Pinning Audit from your own code

What this app does

Controller Pinning Audit reviews one unit of a Laravel application — a controller, the routes that reach it, the Eloquent models it writes, its form requests and policies — and does one of two jobs over it. The audit lane is a security review; the tests lane writes the feature-test file that pins each finding, one test that fails on the pasted code and passes once the fix lands. The app never runs your code; every answer is derived from the text you send.

The task field comes first

Every request carries task, and it decides the whole contract of the reply.

taskWhat it doeslane_detail carriesartifact
auditAuthorization and IDOR, mass assignment, SQL and column-name injection, validation, unescaped Blade, CSRF, uploads, rate limits, password hashing, secrets and configuration.surface, mass_assignment, owasp (ten rows, A01–A10)kind: "none"
testsA complete Pest or PHPUnit feature-test file. With a handoff from an audit, one test per fixable finding.test_plan, factories, pinning, run_commandkind: "test", the whole .php file in body

An unknown or missing task is not an error: the model picks the closer lane, does only that one, and names it in the first sentence of exec_summary. Check lane in the reply rather than assuming.

The envelope and the error codes

Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses the same two-shape envelope:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "details": { ... } } }

Authentication is one header on every call except the guest mint: Authorization: Bearer <token>. There is no slug header — the token is minted for controller-pin-audit and carries the app with it.

CodeHTTPWhat causes itWhat to do
VALIDATION_ERROR400A body that is not a JSON object.Send one flat object. Nothing is charged.
UNAUTHORIZED401Missing, malformed or expired token.Get a fresh one from the token page.
FORBIDDEN403A guest token calling /run, or a token minted for another app.Sign in and use a personal token.
PAYMENT_REQUIRED402Balance below the run's min_credits.Compare /estimate with /me before running; top up.
NOT_FOUND404A job_id that belongs to a different token.Poll with the token that created the job.
RATE_LIMITED429A poll loop with no sleep.Poll at most once a second and back off.
INTERNAL500A failure on our side.Retry with the same Idempotency-Key.

/estimate also returns a warnings array. It names fields the app does not declare (the common one is wrapping the body as {"input": {...}}) and required fields you left out. A warning is not a rejection — the run would still happen and still be charged — so treat a non-empty warnings as a bug in your request.

The input contract

One flat JSON object. Every field is a string.

FieldRequiredMeaning
taskyesaudit or tests.
codeyesThe Laravel source. Separate files with a marker line // file: app/Http/Controllers/PostController.php; findings quote those paths and line numbers counted from the line after each marker. The page sends at most 60,000 characters and cuts over-long files in the middle, leaving a // [... N characters cut from the middle of this file ...] marker the model is told to respect.
contextnoFree text: who can sign up, which records are private, what is about to change.
laravel_versionno"12", "11", "10", "9" or "". It changes facts such as where CSRF exceptions live and whether $this->authorize() exists on the base controller.
auth_stacknobreeze, jetstream, fortify, sanctum, passport, custom or "".
test_frameworknopest (default) or phpunit; used by the tests lane.
prescan_factsnoA JSON string of the browser prescan (routes, controllers, models, form requests, flags). When present, the model must reconcile every flags[].id exactly once in coverage_check. Omit it from your own client and coverage_check comes back empty.
handoffnoA JSON string {"from":"audit","findings":[{"id":"LD-001","severity":"critical","category":"injection","rule":"...","location":"...","problem":"...","fix":"..."}]}. Send it with task: "tests" to get one pinning test per finding.
retry_notenoOnly on a reformat retry: tells the model its previous reply did not parse.

The output contract

The reply, at job.output.output, is one JSON object with the same outer envelope in both lanes. Every array is always present, even when empty.

{
  "lane": "audit",
  "unit_title": "PostController + routes/web.php",
  "verdict": "blocks-deploy | needs-work | ready",
  "headline": "one sentence",
  "exec_summary": "two to four sentences",
  "facts": {
    "files": 3,
    "controllers": 1,
    "routes": 5,
    "models": 1,
    "form_requests": 0,
    "raw_queries": 1,
    "laravel_version": "11",
    "auth_stack": "breeze"
  },
  "findings": [
    {
      "id": "LD-001",
      "severity": "critical | high | medium | low",
      "category": "authz | mass-assignment | injection | xss | csrf | validation | upload | secrets | config | rate-limit | auth | other",
      "rule": "kebab-case",
      "location": "app/Http/Controllers/PostController.php:42",
      "problem": "...",
      "impact": "...",
      "fix": "...",
      "snippet": "corrected PHP or \"\""
    }
  ],
  "metrics": [
    {
      "label": "...",
      "value": "3",
      "target": "0",
      "status": "ok | warn | fail | unknown",
      "note": "..."
    }
  ],
  "lane_detail": {},
  "artifact": {
    "kind": "test | none",
    "filename": "tests/Feature/PostControllerTest.php",
    "language": "php",
    "body": "",
    "note": ""
  },
  "coverage_check": [
    {
      "flag_id": "PS-001",
      "status": "confirmed | cleared | out-of-scope",
      "note": "..."
    }
  ],
  "assumptions": [],
  "open_questions": [],
  "next_steps": [],
  "summary": "..."
}

verdict is blocks-deploy when any finding is critical, needs-work when the worst is high or medium, and ready otherwise. In the tests lane it describes the code under test.

lane_detail for audit

{
  "surface": [
    {
      "route": "PUT /posts/{post}",
      "action": "PostController@update",
      "middleware": "web, auth",
      "authz": "none | policy | gate | form-request | scoped-query | inline",
      "validation": "none | inline | form-request | partial",
      "risk": "critical | high | medium | low | none",
      "note": "..."
    }
  ],
  "mass_assignment": [
    {
      "model": "Post",
      "protection": "guarded-empty | fillable | guarded | default",
      "exposed_fields": [
        "user_id"
      ],
      "reached_from": "PostController@update via $request->all()"
    }
  ],
  "owasp": [
    {
      "category": "A01 Broken Access Control",
      "status": "fail | pass | not-applicable | unknown",
      "evidence": "..."
    }
  ]
}

lane_detail for tests

{
  "test_plan": [
    {
      "test_name": "it forbids updating another user's post",
      "kind": "happy | authz | validation | guest | security | edge",
      "pins": "LD-003",
      "route": "PUT /posts/{post}",
      "expect_today": "fails | passes | unknown",
      "setup": "...",
      "asserts": [
        "..."
      ]
    }
  ],
  "factories": [
    {
      "model": "Post",
      "states": [],
      "note": "needs user_id via ->for($owner)"
    }
  ],
  "pinning": {
    "pinned": [
      "LD-001"
    ],
    "unpinned": [
      {
        "finding_id": "LD-010",
        "why": "..."
      }
    ]
  },
  "run_command": "php artisan test --filter=PostControllerTest"
}

Every test_name appears in artifact.body (Pest: as the it() description; PHPUnit: as the method name), and every handed-off finding id appears in exactly one of pinning.pinned or pinning.unpinned. The page checks both; your client should too.

Step by step

1. Get a token

The token page shows the token this browser holds, signs you in for a personal one, and copies a ready-made shell export. A guest token is enough for /me and /estimate; running a lane is metered and needs a personal token.

# Easiest: open /tokens.html on this app, sign in, press "Copy shell export":
export SKILLSAFE_TOKEN="YOUR_TOKEN"

# A guest token (enough for /me and /estimate, not for /run):
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"controller-pin-audit"}' | jq -r '.data.token'

2. A tiny client helper

One function that sends the token and unwraps the envelope.

BASE="https://api.skillsafe.ai/v1/app-api"
api() {  # api METHOD PATH [BODY_FILE] -> prints .data, or the error and returns 1
  local out
  if [ -n "$3" ]; then
    out=$(curl -s -X "$1" "$BASE$2" -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
      -H "Content-Type: application/json" --data-binary @"$3")
  else
    out=$(curl -s -X "$1" "$BASE$2" -H "Authorization: Bearer $SKILLSAFE_TOKEN")
  fi
  if [ "$(printf '%s' "$out" | jq -r '.ok')" = "true" ]; then
    printf '%s' "$out" | jq '.data'
  else
    printf '%s' "$out" | jq '.error' >&2; return 1
  fi
}

3. Check the session and the balance

GET /me returns subject_type (user or guest) and, for a user, credits.

api GET /me | jq '{subject_type, username, credits}'
# {"subject_type":"user","username":"you","credits":48210}

4. Price the lane — free, no job

POST /estimate takes the same body as /run and returns hold_credits (the reservation, priced at the full output cap), min_credits, the resolved model and markup_bps. The two lanes hold different amounts, so estimate the lane you are about to run. You are charged for what the run actually uses, which is usually far less than the hold.

# Write the body once. The quoted 'JSON' delimiter stops the shell expanding the
# $request and $post inside the PHP source.
cat > body.json <<'JSON'
{"task":"audit",
 "code":"// file: routes/web.php\n<?php\nRoute::middleware('auth')->group(function () {\n    Route::put('/posts/{post}', [PostController::class, 'update']);\n});\n\n// file: app/Http/Controllers/PostController.php\n<?php\nclass PostController extends Controller\n{\n    public function update(Request $request, Post $post)\n    {\n        $post->update($request->all());\n        return back();\n    }\n}",
 "context":"Community blog; any signed-in member can post.",
 "laravel_version":"11","auth_stack":"breeze","test_framework":"pest"}
JSON

api POST /estimate body.json | jq '{hold_credits, min_credits, model, model_alias, markup_bps, warnings}'
# {"hold_credits":2140,"min_credits":180,"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,"warnings":[]}

5. Run it, then poll the job

POST /run returns {"job_id": "...", "status": "queued"}. Poll GET /jobs/{job_id} until status is succeeded, failed or cancelled. Always send an Idempotency-Key built from the lane plus a hash of the input: the audit and the tests over the same paste are two different runs, and a retry after a timeout must reuse the key or you pay twice.

Idempotency-Key: controller-pin-audit:<task>:<12 hex of sha256(task, code, context, versions, handoff)>:a<attempt>
LANE=$(jq -r '.task' body.json)
DIGEST=$(shasum -a 256 body.json | cut -c1-12)
KEY="controller-pin-audit:$LANE:$DIGEST:a1"      # reuse this exact key on any retry

JOB=$(curl -s -X POST "$BASE/run" -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" -H "Idempotency-Key: $KEY" \
  --data-binary @body.json | jq -r '.data.job_id')

while :; do
  OUT=$(api GET "/jobs/$JOB")
  case "$(printf '%s' "$OUT" | jq -r '.status')" in succeeded|failed|cancelled) break ;; esac
  sleep 2
done
printf '%s' "$OUT" | jq -r '.output.output' > review.json
jq '.verdict, .headline' review.json

6. Or stream it

POST /run-stream takes the same body and key and answers with server-sent events: event: job first (keep the job_id so you can fall back to polling), optional event: delta frames with a text fragment, then event: done carrying the settled job — status, output, charged_credits, truncated. Treat done as authoritative; deltas may be absent entirely.

curl -s -N -X POST "$BASE/run-stream" -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" -H "Idempotency-Key: $KEY" --data-binary @body.json

# event: job
# data: {"job_id":"job_7Kq2","status":"running"}
#
# event: delta                      (may be absent, or arrive as one frame)
# data: {"text":"{\"lane\":\"audit\",..."}
#
# event: done
# data: {"job_id":"job_7Kq2","status":"succeeded","charged_credits":1180,"truncated":false,"output":{"output":"{...}"}}

7. Parse the reply

Take the text from the first { to the last }, parse it, check lane is the one you asked for, and — if you sent prescan_facts — check every flag id came back in coverage_check. If truncated is true the balance capped the output; render what arrived and say it was cut short.

jq '{lane, verdict, headline,
     findings: [.findings[] | {id, severity, rule, location}],
     unreconciled: (.coverage_check | length)}' review.json

# tests lane: write the generated file where it belongs
jq -r '.artifact.body' review.json > tests/Feature/PostControllerTest.php

Two worked examples, one per lane

Lane 1 — audit

The blog controller bundled with the app, with its prescan attached (shortened here).

{
  "task": "audit",
  "code": "// file: routes/web.php\n<?php\n\nuse App\\Http\\Controllers\\PostController;\nuse App\\Http\\Controllers\\Auth\\RegisterController;\nuse Illuminate\\Support\\Facades\\Route;\n\nRoute::get('/', [PostController::class, 'index']);\nRoute::post('/register', [RegisterController::class, 'store']);\nRoute::post('/login', [RegisterController::class, 'login']);\n\nRoute::middleware('auth')->group(function () {\n    Route::post('/posts', [PostCont\n// ... the rest of the paste ...",
  "context": "Small community blog on Laravel 11 with Breeze. Any signed-in member can post. We are about to open registration to the public and want to know what breaks first.",
  "laravel_version": "11",
  "auth_stack": "breeze",
  "test_framework": "pest",
  "prescan_facts": "{\"title\": \"PostController + RegisterController + routes/web.php\", \"flags\": [{\"id\": \"PS-001\", \"rule\": \"raw-sql-interpolated\", \"severity\": \"critical\", \"category\": \"injection\", \"title\": \"DB::select() builds SQL from interpolated or concatenated values\", \"where\": \"app/Http/Controllers/PostController.php:16\"}, {\"id\": \"PS-002\", \"rule\": \"missing-authz\", \"severity\": \"critical\", \"category\": \"authz\", \"title\": \"PostController@destroy changes a record without an authorization check\", \"where\": \"app/Http/Controllers/PostController.php:35\"}]}  (shortened)"
}

Lane 2 — tests

The same paste with the audit's findings handed over, so the reply pins each one.

{
  "task": "tests",
  "code": "// file: routes/web.php\n<?php\n\nuse App\\Http\\Controllers\\PostController;\nuse App\\Http\\Controllers\\Auth\\RegisterController;\nuse Illuminate\\Support\\Facades\\Route;\n\nRoute::get('/', [PostController::class, 'index']);\nRoute::post('/register', [RegisterController::class, 'store']);\nRoute::post('/login', [RegisterController::class, 'login']);\n\nRoute::middleware('auth')->group(function () {\n    Route::post('/posts', [PostCont\n// ... the rest of the paste ...",
  "context": "Small community blog on Laravel 11 with Breeze. Any signed-in member can post. We are about to open registration to the public and want to know what breaks first.",
  "laravel_version": "11",
  "auth_stack": "breeze",
  "test_framework": "pest",
  "prescan_facts": "{\"title\": \"PostController + RegisterController + routes/web.php\", \"flags\": [{\"id\": \"PS-001\", \"rule\": \"raw-sql-interpolated\", \"severity\": \"critical\", \"category\": \"injection\", \"title\": \"DB::select() builds SQL from interpolated or concatenated values\", \"where\": \"app/Http/Controllers/PostController.php:16\"}, {\"id\": \"PS-002\", \"rule\": \"missing-authz\", \"severity\": \"critical\", \"category\": \"authz\", \"title\": \"PostController@destroy changes a record without an authorization check\", \"where\": \"app/Http/Controllers/PostController.php:35\"}]}  (shortened)",
  "handoff": "{\"from\": \"audit\", \"findings\": [{\"id\": \"LD-001\", \"severity\": \"critical\", \"category\": \"injection\", \"rule\": \"raw-sql-interpolated\", \"location\": \"app/Http/Controllers/PostController.php:16\", \"problem\": \"PostController@index interpolates both $term (the q parameter) and $sort (the sort parameter) straight into the DB::select string: \\\"... LIKE '%$term%' ORDER BY $sort DESC\\\". Neither value is bound or validated, and GET / sits outside the auth group.\", \"fix\": \"Validate q as a short string and sort against an allow-list, and replace the raw DB::select with the query builder so the search term is a bound parameter. One code path then serves both branches and always returns a paginator.\"}, {\"id\": \"LD-002\", \"severity\": \"critical\", \"category\": \"authz\", \"rule\": \"state-changing-get-unauthenticated\", \"location\": \"routes/web.php:18\", \"problem\": \"Route::get('/posts/{post}/delete', [PostController::class, 'destroy']) is declared outside the Route::middleware('auth') group and on GET. It reaches PostController@destroy, which deletes the route-model-bound Post with no check of any kind.\", \"fix\": \"Delete the GET route. Deletion already has the DELETE /posts/{post} route inside the auth group; submit it from a form with @csrf and @method('DELETE'), and add the ownership check from LD-003.\"}]}  (shortened)"
}

Both examples replay for free on the app page — load "the blog controller that leaks" and run either lane.