From 1e8376f3627671d16127192a31776cfc9bffad5e Mon Sep 17 00:00:00 2001
From: JasonWildMe
Date: Thu, 11 Jun 2026 15:29:19 -0700
Subject: [PATCH 01/10] Add token-UI + agent-skill design spec & implementation
plan (Codex-reviewed)
Co-Authored-By: Claude Opus 4.8
---
...026-06-11-token-ui-and-agent-skill-plan.md | 1058 +++++++++++++++++
...6-06-11-token-ui-and-agent-skill-design.md | 235 ++++
2 files changed, 1293 insertions(+)
create mode 100644 docs/superpowers/plans/2026-06-11-token-ui-and-agent-skill-plan.md
create mode 100644 docs/superpowers/specs/2026-06-11-token-ui-and-agent-skill-design.md
diff --git a/docs/superpowers/plans/2026-06-11-token-ui-and-agent-skill-plan.md b/docs/superpowers/plans/2026-06-11-token-ui-and-agent-skill-plan.md
new file mode 100644
index 0000000000..0cb79d1125
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-11-token-ui-and-agent-skill-plan.md
@@ -0,0 +1,1058 @@
+# Token-Issuance UI + Served Agent Skill — 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:** Let a logged-in user mint a short-lived bearer token from the Wildbook UI (with a server-verified step-up password), and serve an anonymous agent-skill document at `GET /api/v3/agent-skill` that teaches a user's AI agent the token-scoped API, schema, how to get a token, and to never accept the user's credentials.
+
+**Architecture:** Component A — harden the existing `AuthToken` mint to verify a *fresh* Basic credential server-side (a session cookie must not suffice), then a React `/api-access` page that collects the password and calls it via a cookie-less `fetch`. Component B — an `AgentSkill` servlet streaming a curated, version-controlled markdown resource, anon-gated.
+
+**Tech Stack:** Java 17 servlets (`javax.servlet`, `Shepherd`/JDO), org.json, JUnit 5 + Mockito; React (react-bootstrap, react-query), Jest/RTL; Shiro (`web.xml [urls]`).
+
+**Spec:** `docs/superpowers/specs/2026-06-11-token-ui-and-agent-skill-design.md` (Codex-reviewed).
+**Branch:** `token-auth-scoped-search` (PR #1613). Do NOT push/merge — the user does that.
+
+**Repo conventions (read before starting):**
+- JUnit 5 assertions put the message LAST: `assertEquals(expected, actual, "msg")`.
+- Normalize line endings before every commit: `grep -c $'\r' ` must be 0; else `sed -i 's/\r$//' `.
+- Java test run: `mvn test -Dtest= -DargLine="--add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.time=ALL-UNNAMED --add-opens java.base/java.io=ALL-UNNAMED -Xmx2g"`
+- Frontend test run: `cd frontend && npx jest ` (CI is continue-on-error for jest, but tests must pass locally).
+- Commit messages end with: `Co-Authored-By: Claude Opus 4.8 `
+
+**Verified facts this plan relies on:**
+- `ServletUtilities.hashAndSaltPassword(String clearText, String salt)` (ServletUtilities.java:752) produces the stored hash; `User.getSalt()`, `User.getPassword()`, `User.getUsername()`, `User.getId()` exist.
+- `AuthToken extends ApiBase` and currently mints for `myShepherd.getUser(request)` (session/Basic via Shiro) — this is what we harden.
+- `/api/v3/user` (`UserInfo`) exposes `username`. `/api/*` is mapped to `WildbookApi`, so a new **exact** servlet mapping `/api/v3/agent-skill` wins.
+- `SearchApi` token allowlist = `encounter`, `annotation`, `individual` (others 403) — the drift-guard test pins the skill to this.
+
+---
+
+## File Structure
+
+| File | Responsibility |
+|---|---|
+| `src/main/java/org/ecocean/User.java` (**modify**) | Add `checkPassword(String clearText)` — verify against stored salted hash. |
+| `src/main/java/org/ecocean/api/AuthToken.java` (**modify**) | Require + verify a fresh Basic credential; reject session-only; `no-store`; audit log. |
+| `src/main/java/org/ecocean/api/AgentSkill.java` (**create**) | Stream the curated skill markdown as `text/markdown`. |
+| `src/main/resources/agent-skill.md` (**create**) | The curated agent skill content. |
+| `src/main/webapp/WEB-INF/web.xml` (**modify**) | Register `AgentSkill` servlet + `/api/v3/agent-skill` mapping + `anon` Shiro rule. |
+| `src/test/java/org/ecocean/api/AuthTokenTest.java` (**modify/create**) | Step-up enforcement cases. |
+| `src/test/java/org/ecocean/api/AgentSkillTest.java` (**create**) | Serving + content-anchor + drift-guard tests. |
+| `src/test/java/org/ecocean/api/EndpointAuthWiringTest.java` (**modify**) | Assert servlet + url-pattern + `anon` rule. |
+| `frontend/src/models/auth/useMintToken.js` (**create**) | Cookie-less mint hook (raw fetch, Basic header). |
+| `frontend/src/pages/ApiAccess/ApiAccessPage.jsx` (**create**) | Page + password modal + token display. |
+| `frontend/src/AuthenticatedSwitch.jsx` (**modify**) | `/api-access` route. |
+| `frontend/src/components/header/AvatarAndUserProfile.jsx` (**modify**) | "API Access" menu item. |
+| `frontend/src/__tests__/...` (**create**) | Hook + page + menu tests. |
+
+---
+
+## Task 1: `User.checkPassword` helper
+
+**Files:**
+- Modify: `src/main/java/org/ecocean/User.java`
+- Test: `src/test/java/org/ecocean/UserCheckPasswordTest.java`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `src/test/java/org/ecocean/UserCheckPasswordTest.java`:
+
+```java
+package org.ecocean;
+
+import static org.junit.jupiter.api.Assertions.*;
+import org.junit.jupiter.api.Test;
+
+class UserCheckPasswordTest {
+
+ private static final String SALT = "0123456789abcdef";
+
+ private User userWith(String clear) {
+ // IMPORTANT: User.setPassword(String) only ASSIGNS the field — it does NOT hash. Production
+ // stores an already-hashed value alongside a matching salt, so the test must do the same.
+ User u = new User();
+ u.setUsername("alice");
+ u.setSalt(SALT);
+ u.setPassword(org.ecocean.servlet.ServletUtilities.hashAndSaltPassword(clear, SALT));
+ return u;
+ }
+
+ @Test void checkPassword_trueForCorrect_falseForWrong() {
+ User u = userWith("s3cr3t!");
+ assertTrue(u.checkPassword("s3cr3t!"), "correct password verifies");
+ assertFalse(u.checkPassword("nope"), "wrong password rejected");
+ }
+
+ @Test void checkPassword_falseOnNullOrNoStoredPassword() {
+ User u = new User();
+ u.setUsername("bob"); // no password/salt set
+ assertFalse(u.checkPassword("anything"), "no stored password -> false");
+ User u2 = userWith("pw");
+ assertFalse(u2.checkPassword(null), "null candidate -> false");
+ assertFalse(u2.checkPassword(""), "empty candidate -> false");
+ }
+}
+```
+
+Note: `User` has a no-arg constructor, `setSalt(String)`, and `setPassword(String)` that **only
+assigns** (does not hash). `checkPassword` (Step 3) hashes the candidate with the stored salt and
+compares to the stored hash — exactly mirroring `WildbookBasicHttpAuthenticationFilter`. If any setter
+name differs, adapt the setup but keep the "store a real hash + matching salt" shape and the assertions.
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `mvn test -Dtest=UserCheckPasswordTest -DargLine="--add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.time=ALL-UNNAMED --add-opens java.base/java.io=ALL-UNNAMED -Xmx2g"`
+Expected: FAIL — `checkPassword` undefined (compile error).
+
+- [ ] **Step 3: Implement**
+
+Add to `src/main/java/org/ecocean/User.java` (near `getPassword()`):
+
+```java
+ /**
+ * Verify a clear-text password against this user's stored salted hash, using the same hashing
+ * as login (ServletUtilities.hashAndSaltPassword). Constant-time comparison. Returns false if
+ * this user has no stored password or the candidate is blank.
+ */
+ public boolean checkPassword(String clearText) {
+ if ((clearText == null) || clearText.isEmpty()) return false;
+ String stored = this.getPassword();
+ String salt = this.getSalt();
+ if ((stored == null) || stored.isEmpty() || (salt == null)) return false;
+ String hashed = org.ecocean.servlet.ServletUtilities.hashAndSaltPassword(clearText, salt);
+ if (hashed == null) return false;
+ return java.security.MessageDigest.isEqual(
+ hashed.getBytes(java.nio.charset.StandardCharsets.UTF_8),
+ stored.getBytes(java.nio.charset.StandardCharsets.UTF_8));
+ }
+```
+
+- [ ] **Step 4: Run test to verify it passes** (same command) — Expected: PASS (2 tests).
+
+- [ ] **Step 5: Normalize + commit**
+
+```bash
+grep -c $'\r' src/main/java/org/ecocean/User.java src/test/java/org/ecocean/UserCheckPasswordTest.java
+git add src/main/java/org/ecocean/User.java src/test/java/org/ecocean/UserCheckPasswordTest.java
+git commit -m "User: add checkPassword(clearText) for fresh credential verification
+
+Co-Authored-By: Claude Opus 4.8 "
+```
+
+---
+
+## Task 2: `AuthToken` step-up enforcement
+
+Harden the mint so a session cookie alone can NEVER mint — a fresh, valid `Authorization: Basic` credential is required and verified server-side. (Codex High.)
+
+**Files:**
+- Modify: `src/main/java/org/ecocean/api/AuthToken.java`
+- Test: `src/test/java/org/ecocean/api/AuthTokenStepUpTest.java`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `src/test/java/org/ecocean/api/AuthTokenStepUpTest.java`:
+
+```java
+package org.ecocean.api;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+import static org.mockito.ArgumentMatchers.*;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.util.Base64;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.ecocean.User;
+import org.ecocean.shepherd.core.Shepherd;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedConstruction;
+
+class AuthTokenStepUpTest {
+
+ private HttpServletResponse resp(StringWriter out) throws Exception {
+ HttpServletResponse r = mock(HttpServletResponse.class);
+ when(r.getWriter()).thenReturn(new PrintWriter(out));
+ return r;
+ }
+
+ private String basic(String u, String p) {
+ return "Basic " + Base64.getEncoder().encodeToString((u + ":" + p).getBytes());
+ }
+
+ @Test void noBasicHeader_sessionOnly_is401() throws Exception {
+ HttpServletRequest req = mock(HttpServletRequest.class);
+ when(req.getHeader("Authorization")).thenReturn(null); // session-only
+ StringWriter out = new StringWriter();
+ HttpServletResponse r = resp(out);
+ try (MockedConstruction sh = mockConstruction(Shepherd.class, (m, c) -> {
+ doNothing().when(m).beginDBTransaction();
+ doNothing().when(m).setAction(anyString());
+ doNothing().when(m).rollbackAndClose();
+ })) {
+ new AuthToken().doPostForTest(req, r);
+ }
+ verify(r).setStatus(401);
+ }
+
+ // Servlet-level check: a present-but-wrong Basic credential is rejected by THIS servlet
+ // regardless of any session. (The full filter+session end-to-end — "authenticated session +
+ // wrong Basic still 401" — depends on Shiro and is covered by the live smoke in the spec.)
+ @Test void wrongPassword_servletRejects_401() throws Exception {
+ HttpServletRequest req = mock(HttpServletRequest.class);
+ when(req.getHeader("Authorization")).thenReturn(basic("alice", "WRONG"));
+ StringWriter out = new StringWriter();
+ HttpServletResponse r = resp(out);
+ User alice = mock(User.class);
+ when(alice.checkPassword("WRONG")).thenReturn(false);
+ try (MockedConstruction sh = mockConstruction(Shepherd.class, (m, c) -> {
+ doNothing().when(m).beginDBTransaction();
+ doNothing().when(m).setAction(anyString());
+ doNothing().when(m).rollbackAndClose();
+ when(m.getUser("alice")).thenReturn(alice);
+ })) {
+ new AuthToken().doPostForTest(req, r);
+ }
+ verify(r).setStatus(401);
+ }
+
+ @Test void correctPassword_mints200() throws Exception {
+ HttpServletRequest req = mock(HttpServletRequest.class);
+ when(req.getHeader("Authorization")).thenReturn(basic("alice", "right"));
+ StringWriter out = new StringWriter();
+ HttpServletResponse r = resp(out);
+ User alice = mock(User.class);
+ when(alice.checkPassword("right")).thenReturn(true);
+ when(alice.getId()).thenReturn("uuid-alice");
+ when(alice.getUsername()).thenReturn("alice");
+ try (MockedConstruction sh = mockConstruction(Shepherd.class, (m, c) -> {
+ doNothing().when(m).beginDBTransaction();
+ doNothing().when(m).setAction(anyString());
+ doNothing().when(m).rollbackAndClose();
+ when(m.getUser("alice")).thenReturn(alice);
+ })) {
+ new AuthToken().doPostForTest(req, r);
+ }
+ // 200 only if JWT issuance is configured in the test env; otherwise 503.
+ // Assert we did NOT 401 (the credential was accepted) and set no-store.
+ verify(r, never()).setStatus(401);
+ verify(r).setHeader("Cache-Control", "no-store");
+ }
+}
+```
+
+Note: the `correctPassword_mints200` test asserts "not 401" + the `no-store` header rather than a hard 200, because JWT signing may be unconfigured in the unit env (yielding 503). The point is the credential path, not the signer.
+
+- [ ] **Step 2: Run test to verify it fails** (`mvn test -Dtest=AuthTokenStepUpTest ...`) — FAIL (`doPostForTest` undefined / logic absent).
+
+- [ ] **Step 3: Implement**
+
+Rewrite `AuthToken.doPost` to parse + verify a fresh Basic credential. Replace the current `doPost` body and add a `doPostForTest` shim + a parse helper. Full new `AuthToken.java`:
+
+```java
+package org.ecocean.api;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.ecocean.User;
+import org.ecocean.Util;
+import org.ecocean.api.auth.JwtService;
+import org.ecocean.shepherd.core.Shepherd;
+import org.json.JSONObject;
+
+/**
+ * POST /api/v3/auth/token
+ * Mints a short-lived RS256 token. STEP-UP: requires a fresh, valid HTTP Basic credential and
+ * verifies it server-side (a Shiro session alone is NOT sufficient) — so a stolen/unlocked session
+ * or same-origin script cannot mint without the password.
+ */
+public class AuthToken extends ApiBase {
+ private static final long DEFAULT_TTL_MILLIS = 30L * 60L * 1000L; // 30 min
+
+ @Override protected void doPost(HttpServletRequest request, HttpServletResponse response)
+ throws IOException {
+ handle(request, response);
+ }
+
+ // package-visible test entry point
+ void doPostForTest(HttpServletRequest request, HttpServletResponse response) throws IOException {
+ handle(request, response);
+ }
+
+ private void handle(HttpServletRequest request, HttpServletResponse response) throws IOException {
+ response.setHeader("Cache-Control", "no-store");
+ final String context = "context0"; // Basic auth is pinned to context0 (see filter)
+ String clientIp = request.getRemoteAddr();
+
+ String[] cred = parseBasic(request.getHeader("Authorization"));
+ if (cred == null) {
+ // No fresh Basic credential -> session alone is not sufficient.
+ System.out.println("AuthToken mint DENIED (no Basic credential) ip=" + clientIp);
+ writeError(response, 401, "basic credentials required");
+ return;
+ }
+ String username = cred[0];
+ String password = cred[1];
+
+ Shepherd myShepherd = new Shepherd(context);
+ myShepherd.setAction("api.AuthToken.doPost");
+ myShepherd.beginDBTransaction();
+ try {
+ User user = (Util.stringExists(username)) ? myShepherd.getUser(username) : null;
+ if ((user == null) || !user.checkPassword(password)) {
+ System.out.println("AuthToken mint DENIED (bad credentials) user=" + username
+ + " ip=" + clientIp);
+ writeError(response, 401, "invalid credentials");
+ return;
+ }
+ String tokenContext = org.ecocean.CommonConfiguration.getProperty("jwtContext", context);
+ if (!Util.stringExists(tokenContext)) tokenContext = "context0";
+ JwtService jwt = JwtService.fromConfig(tokenContext);
+ if (!jwt.isEnabled()) {
+ writeError(response, 503, "token issuance not configured");
+ return;
+ }
+ long ttl = ttlFromConfig(tokenContext);
+ String token = jwt.sign(user.getId(), tokenContext, ttl);
+ System.out.println("AuthToken mint OK user=" + username + " ip=" + clientIp);
+ JSONObject out = new JSONObject();
+ out.put("token", token);
+ out.put("tokenType", "Bearer");
+ out.put("expiresInSeconds", ttl / 1000L);
+ response.setStatus(200);
+ response.setContentType("application/json");
+ response.getWriter().write(out.toString());
+ } catch (Exception ex) {
+ ex.printStackTrace();
+ writeError(response, 500, "token issuance failed");
+ } finally {
+ myShepherd.rollbackAndClose();
+ }
+ }
+
+ /** Parse an HTTP Basic Authorization header into [username, password], or null if absent/invalid. */
+ private static String[] parseBasic(String header) {
+ if (header == null) return null;
+ if (!header.regionMatches(true, 0, "Basic ", 0, 6)) return null;
+ try {
+ String decoded = new String(Base64.getDecoder().decode(header.substring(6).trim()),
+ StandardCharsets.UTF_8);
+ int i = decoded.indexOf(':');
+ if (i < 0) return null;
+ return new String[] { decoded.substring(0, i), decoded.substring(i + 1) };
+ } catch (IllegalArgumentException ex) {
+ return null;
+ }
+ }
+
+ private long ttlFromConfig(String context) {
+ String v = org.ecocean.CommonConfiguration.getProperty("jwtTtlSeconds", context);
+ if (Util.stringExists(v)) {
+ try {
+ long secs = Long.parseLong(v.trim());
+ secs = Math.max(60L, Math.min(secs, 24L * 3600L));
+ return secs * 1000L;
+ } catch (NumberFormatException ignore) {}
+ }
+ return DEFAULT_TTL_MILLIS;
+ }
+
+ private void writeError(HttpServletResponse response, int code, String message)
+ throws IOException {
+ response.setStatus(code);
+ response.setContentType("application/json");
+ response.getWriter().write(new JSONObject().put("success", false)
+ .put("error", message).toString());
+ }
+}
+```
+
+Key changes vs the old version: token is minted for the **Basic-verified** user (`getUser(username)` + `checkPassword`), never `getUser(request)` (which would honor the session); `no-store` header; audit `println`s without password/token. The Shiro `authcBasicWildbook` filter stays on the route (unchanged) — this servlet-level check is the authoritative step-up.
+
+- [ ] **Step 4: Run the new test** (`mvn test -Dtest=AuthTokenStepUpTest ...`) — Expected: PASS (3 tests).
+
+- [ ] **Step 4b: Update the EXISTING `AuthTokenTest` for the new contract (Codex High)**
+
+The rewrite changes behavior, so `src/test/java/org/ecocean/api/AuthTokenTest.java` will regress — a
+request with **no** Basic header now returns `401` *before* any Shepherd/JWT setup. Read that file and
+update each case to the new contract:
+- Any "no credentials / session-only" case → expect `401` ("basic credentials required").
+- The JWT-disabled (`503`) and context-pin cases → must now supply a **valid** Basic header
+ (`Authorization: Basic base64("alice:right")`) and mock `Shepherd.getUser("alice")` → a `User` mock
+ whose `checkPassword("right")` returns `true` (mirror `AuthTokenStepUpTest`), so execution reaches
+ the JWT/issuance logic those cases exercise.
+Run `mvn test -Dtest=AuthTokenTest ...` → all green. Do NOT delete coverage — adapt it.
+
+- [ ] **Step 5: Normalize + commit**
+
+```bash
+grep -c $'\r' src/main/java/org/ecocean/api/AuthToken.java src/test/java/org/ecocean/api/AuthTokenStepUpTest.java src/test/java/org/ecocean/api/AuthTokenTest.java
+git add src/main/java/org/ecocean/api/AuthToken.java src/test/java/org/ecocean/api/AuthTokenStepUpTest.java src/test/java/org/ecocean/api/AuthTokenTest.java
+git commit -m "AuthToken: server-side step-up — require+verify fresh Basic, reject session-only
+
+Co-Authored-By: Claude Opus 4.8 "
+```
+
+---
+
+## Task 3: The curated agent-skill markdown
+
+**Files:**
+- Create: `src/main/resources/agent-skill.md`
+
+- [ ] **Step 1: Create the content**
+
+Create `src/main/resources/agent-skill.md` with the following (keep it self-contained, agent-agnostic, and free of internal ACL field names / deployment details):
+
+````markdown
+# Wildbook Token-Scoped API — Agent Skill
+
+You are an AI agent operating Wildbook's **read-only** API on behalf of a human user. You see exactly
+what that user is permitted to see (everything is access-controlled to their account).
+
+## Security — read first
+- **Never ask for, accept, or store the user's Wildbook username or password.** You do not need them.
+- The user generates a short-lived **bearer token** in Wildbook's UI (Account menu → **API Access**)
+ and pastes **only the token** to you.
+- Treat the token as a secret: never log or persist it, never send it anywhere except Wildbook over
+ HTTPS. It expires (typically ~30 minutes). When it expires, ask the user to paste a fresh one.
+
+## Authentication
+Send the token as a bearer header on every request:
+```
+Authorization: Bearer
+```
+The token response includes `expiresInSeconds`. An admin user's token sees all data; a normal user's
+token is filtered to their own accessible records. The server also enforces internal access-control
+fields, which are never returned to you.
+
+## Endpoints
+
+### Search — `POST /api/v3/search/{index}`
+`{index}` is one of: `encounter`, `individual`, `annotation`. (`occurrence` and `media_asset` return
+`403` for token callers.) Body is an OpenSearch query, e.g.:
+```json
+{ "query": { "term": { "taxonomy": "Salamandra salamandra" } } }
+```
+Pagination via `?from=&size=` query params; total hits in the `X-Wildbook-Total-Hits` response header.
+Non-admin `individual` search may only query/sort identity fields. Aggregations, scripted queries, and
+cross-index term lookups are rejected.
+
+### Media resolve — `POST /api/v3/media/resolve`
+Resolve up to 100 annotation IDs you are allowed to see into displayable image references:
+```json
+{ "annotationIds": ["", ""] }
+```
+Returns an array of `{ id, imageUrl, imageWidth, imageHeight, bbox: [x,y,w,h], theta, viewpoint,
+encounterId, individualId, methodVersion }`. The `bbox` is in the `imageWidth`×`imageHeight`
+coordinate space. **Fetch `imageUrl`, read its real pixel dimensions, and scale `bbox` by
+`realW/imageWidth`, `realH/imageHeight` before cropping** (usually a no-op). IDs you can't see (or that
+don't exist) are simply absent — the response never reveals which.
+
+## OpenSearch schema (token-exposed fields)
+See the field reference for full descriptions. Key indices/fields:
+- **encounter** — `id`, `taxonomy`, `locationId`/`locationName`, `date`/`dateMillis`, `individualId`,
+ `sex`, `lifeStage`, `livingStatus`, `country`, `behavior`, ...
+- **individual** — `id`, `displayName`, `names`/`nameMap`, `sex`, `taxonomy`, `timeOfBirth`/`timeOfDeath`.
+- **annotation** — `id`, `encounterId`, `viewpoint`, `iaClass`, `matchAgainst`, `mediaAssetId`, and
+ `embeddings` (nested: `method`, `methodVersion`, and the MiewID `vector`).
+
+Access-control fields exist server-side but are **never** returned.
+
+## Worked examples
+
+**Find an individual's salamander encounters, then view two annotations:**
+1. `POST /api/v3/search/encounter` with `{"query":{"term":{"taxonomy":"Salamandra salamandra"}}}`.
+2. Collect annotation IDs (search `annotation`, or via the encounters), then
+ `POST /api/v3/media/resolve` with those IDs.
+3. For each result, fetch `imageUrl`, scale `bbox` to the fetched pixels, crop, and present
+ side-by-side.
+
+**Comparing embeddings for missed matches:** only compare embeddings within the **same `viewpoint` and
+same `methodVersion`** — different viewpoints/versions live in different latent spaces and are not
+directly comparable. Calibrate similarity against known same-individual pairs before trusting a score.
+````
+
+- [ ] **Step 2: Commit** (no test yet; Task 4 wires + tests it)
+
+```bash
+grep -c $'\r' src/main/resources/agent-skill.md
+git add src/main/resources/agent-skill.md
+git commit -m "media/agent-skill: add curated agent-skill markdown resource
+
+Co-Authored-By: Claude Opus 4.8 "
+```
+
+---
+
+## Task 4: `AgentSkill` servlet + wiring + tests
+
+**Files:**
+- Create: `src/main/java/org/ecocean/api/AgentSkill.java`
+- Modify: `src/main/webapp/WEB-INF/web.xml`
+- Test: `src/test/java/org/ecocean/api/AgentSkillTest.java`
+- Modify: `src/test/java/org/ecocean/api/EndpointAuthWiringTest.java`
+- Modify: `src/main/java/org/ecocean/api/SearchApi.java` (extract the allowlist constant)
+
+- [ ] **Step 1: Extract the token allowlist constant in `SearchApi` (so the drift-guard is real)**
+
+In `src/main/java/org/ecocean/api/SearchApi.java`, add a package-visible constant near the top of the
+class:
+```java
+ /** Indices a token caller may search; everything else is 403. The agent-skill drift-guard test pins to this. */
+ static final java.util.Set TOKEN_ALLOWED_INDICES =
+ java.util.Set.of("encounter", "annotation", "individual");
+```
+Then replace the inline allowlist check (currently `tokenAuth && !"encounter".equals(effectiveIndex)
+&& !"annotation".equals(effectiveIndex) && !"individual".equals(effectiveIndex)`) with:
+```java
+ } else if (tokenAuth && !TOKEN_ALLOWED_INDICES.contains(effectiveIndex)) {
+```
+This is a behavior-preserving refactor; if `SearchApiTokenAuthTest`/`SearchApiChildIndexTest` exist,
+re-run them to confirm no regression. Commit this small refactor with the Task 4 work.
+
+- [ ] **Step 2: Write the failing test**
+
+Create `src/test/java/org/ecocean/api/AgentSkillTest.java`:
+
+```java
+package org.ecocean.api;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.junit.jupiter.api.Test;
+
+class AgentSkillTest {
+
+ private String body(boolean[] statusOk) throws Exception {
+ HttpServletRequest req = mock(HttpServletRequest.class);
+ HttpServletResponse resp = mock(HttpServletResponse.class);
+ StringWriter out = new StringWriter();
+ when(resp.getWriter()).thenReturn(new PrintWriter(out));
+ new AgentSkill().doGetForTest(req, resp);
+ verify(resp).setStatus(200);
+ verify(resp).setContentType("text/markdown; charset=UTF-8");
+ return out.toString();
+ }
+
+ @Test void serves_markdown_with_key_anchors() throws Exception {
+ String md = body(null);
+ assertFalse(md.isEmpty(), "skill body must be non-empty");
+ assertTrue(md.contains("Authorization: Bearer"), "documents bearer auth");
+ assertTrue(md.contains("/api/v3/media/resolve"), "documents media resolve");
+ assertTrue(md.contains("/api/v3/search/"), "documents search");
+ for (String idx : new String[] {"encounter", "individual", "annotation"})
+ assertTrue(md.contains(idx), "mentions index " + idx);
+ assertTrue(md.toLowerCase().contains("never ask for")
+ || md.toLowerCase().contains("never give"),
+ "contains the never-share-credentials guidance");
+ }
+
+ // Drift-guard: the skill's claimed allowed indices must match SearchApi's REAL token allowlist
+ // constant (not a hand-copied list) so the doc fails the build if the allowlist changes.
+ @Test void skill_index_claims_match_search_allowlist() throws Exception {
+ String md = body(null);
+ for (String idx : SearchApi.TOKEN_ALLOWED_INDICES)
+ assertTrue(md.contains(idx), "skill must list allowed index " + idx);
+ // denied indices must be named + described as 403 so agents don't try them
+ assertTrue(md.contains("occurrence") && md.contains("media_asset"),
+ "skill must name the denied indices");
+ assertTrue(md.contains("403"), "skill must state denied indices return 403");
+ // and any index the skill says is denied must NOT be in the allowlist (no contradiction)
+ for (String denied : new String[] {"occurrence", "media_asset"})
+ assertFalse(SearchApi.TOKEN_ALLOWED_INDICES.contains(denied),
+ "denied index " + denied + " must not be in the allowlist");
+ }
+
+ // Internal ACL field names must NOT leak into the public skill (Codex Low).
+ @Test void skill_does_not_leak_internal_acl_field_names() throws Exception {
+ String md = body(null);
+ for (String acl : new String[] {
+ "publiclyReadable", "submitterUserId", "submitterUserIds", "viewUsers", "editUsers"})
+ assertFalse(md.contains(acl), "skill must not expose internal ACL field name " + acl);
+ }
+}
+```
+
+- [ ] **Step 3: Run test to verify it fails** (`mvn test -Dtest=AgentSkillTest ...`) — FAIL (`AgentSkill` undefined).
+
+- [ ] **Step 4: Implement the servlet**
+
+Create `src/main/java/org/ecocean/api/AgentSkill.java`:
+
+```java
+package org.ecocean.api;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * GET /api/v3/agent-skill
+ * Serves the curated, version-controlled agent-skill markdown (classpath resource). Anonymous: a
+ * how-to-authenticate doc cannot itself require auth, and it contains no secrets — only API docs.
+ */
+public class AgentSkill extends ApiBase {
+ private static final String RESOURCE = "/agent-skill.md";
+
+ @Override protected void doGet(HttpServletRequest request, HttpServletResponse response)
+ throws IOException {
+ handle(request, response);
+ }
+
+ void doGetForTest(HttpServletRequest request, HttpServletResponse response) throws IOException {
+ handle(request, response);
+ }
+
+ private void handle(HttpServletRequest request, HttpServletResponse response) throws IOException {
+ String md = readResource();
+ if (md == null) {
+ response.setStatus(500);
+ response.setContentType("text/plain; charset=UTF-8");
+ response.getWriter().write("agent skill unavailable");
+ return;
+ }
+ response.setStatus(200);
+ response.setContentType("text/markdown; charset=UTF-8");
+ response.getWriter().write(md);
+ }
+
+ private String readResource() throws IOException {
+ try (InputStream in = AgentSkill.class.getResourceAsStream(RESOURCE)) {
+ if (in == null) return null;
+ return new String(in.readAllBytes(), StandardCharsets.UTF_8);
+ }
+ }
+}
+```
+
+- [ ] **Step 5: Wire web.xml**
+
+In `src/main/webapp/WEB-INF/web.xml`:
+
+(a) Shiro `[urls]` block — add near the other `/api/v3/*` rules (anonymous):
+```
+ /api/v3/agent-skill = anon
+```
+
+(b) Servlet registration (near other `org.ecocean.api.*` servlets):
+```xml
+
+ AgentSkill
+ org.ecocean.api.AgentSkill
+
+
+ AgentSkill
+ /api/v3/agent-skill
+
+```
+(An exact `` wins over the `/api/*` → `WildbookApi` mapping.)
+
+- [ ] **Step 6: Add wiring assertions to `EndpointAuthWiringTest.java`**
+
+```java
+ @Test
+ void agentSkill_servletClassIsRegistered() {
+ assertTrue(fullText().contains("org.ecocean.api.AgentSkill"),
+ "web.xml must register the AgentSkill servlet");
+ }
+
+ @Test
+ void agentSkill_urlPatternIsRegistered() {
+ assertTrue(fullText().contains("/api/v3/agent-skill"),
+ "web.xml must map /api/v3/agent-skill");
+ }
+
+ @Test
+ void agentSkill_shiroRuleIsAnon() {
+ String ruleLine = lines.stream()
+ .filter(l -> {
+ String t = l.stripLeading();
+ return !t.startsWith("#") && t.contains("/api/v3/agent-skill");
+ })
+ .findFirst().orElse(null);
+ assertNotNull(ruleLine, "Shiro [urls] must contain a rule for /api/v3/agent-skill");
+ String value = ruleLine.substring(
+ ruleLine.indexOf("/api/v3/agent-skill") + "/api/v3/agent-skill".length()).trim();
+ if (value.startsWith("=")) value = value.substring(1).trim();
+ assertEquals("anon", value,
+ "the agent-skill doc must be anon (a how-to-auth doc can't require auth); was: '" + value + "'");
+ }
+```
+(If `EndpointAuthWiringTest` exposes helpers under different names than `fullText()`/`lines`, mirror that file's existing assertions instead.)
+
+- [ ] **Step 7: Run tests to verify they pass**
+
+`mvn test -Dtest=AgentSkillTest,EndpointAuthWiringTest -DargLine="--add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.time=ALL-UNNAMED --add-opens java.base/java.io=ALL-UNNAMED -Xmx2g"`
+Expected: PASS (AgentSkillTest 3 + EndpointAuthWiringTest existing + 3 new).
+
+- [ ] **Step 8: Normalize + commit**
+
+```bash
+grep -c $'\r' src/main/java/org/ecocean/api/SearchApi.java src/main/java/org/ecocean/api/AgentSkill.java src/main/webapp/WEB-INF/web.xml src/test/java/org/ecocean/api/AgentSkillTest.java src/test/java/org/ecocean/api/EndpointAuthWiringTest.java
+git add src/main/java/org/ecocean/api/SearchApi.java src/main/java/org/ecocean/api/AgentSkill.java src/main/webapp/WEB-INF/web.xml src/test/java/org/ecocean/api/AgentSkillTest.java src/test/java/org/ecocean/api/EndpointAuthWiringTest.java
+git commit -m "agent-skill: serve curated skill at GET /api/v3/agent-skill (anon) + drift-guard tests
+
+Co-Authored-By: Claude Opus 4.8 "
+```
+
+---
+
+## Task 5: `useMintToken` frontend hook (cookie-less Basic)
+
+**Files:**
+- Create: `frontend/src/models/auth/useMintToken.js`
+- Test: `frontend/src/__tests__/models/useMintToken.test.js`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `frontend/src/__tests__/models/useMintToken.test.js`:
+
+```javascript
+import { mintToken } from "../../models/auth/useMintToken";
+
+describe("mintToken", () => {
+ let fetchMock;
+ beforeEach(() => { fetchMock = jest.fn(); global.fetch = fetchMock; }); // jsdom has no fetch by default
+ afterEach(() => { jest.resetAllMocks(); });
+
+ it("POSTs with a cookie-less Basic header and returns the token on 200", async () => {
+ fetchMock.mockResolvedValue({
+ status: 200,
+ json: async () => ({ token: "tok123", tokenType: "Bearer", expiresInSeconds: 1800 }),
+ });
+ const res = await mintToken("alice", "s3cr3t");
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toContain("/api/v3/auth/token");
+ expect(opts.method).toBe("POST");
+ expect(opts.credentials).toBe("omit"); // no session cookie
+ expect(opts.headers.Authorization).toBe("Basic " + btoa("alice:s3cr3t"));
+ expect(res.token).toBe("tok123");
+ expect(res.expiresInSeconds).toBe(1800);
+ });
+
+ it("throws a typed error with the status on non-200", async () => {
+ fetchMock.mockResolvedValue({ status: 401, json: async () => ({ error: "invalid credentials" }) });
+ await expect(mintToken("alice", "wrong")).rejects.toMatchObject({ status: 401 });
+ });
+});
+```
+
+- [ ] **Step 2: Run to verify it fails**: `cd frontend && npx jest src/__tests__/models/useMintToken.test.js` — FAIL (module missing).
+
+- [ ] **Step 3: Implement**
+
+Create `frontend/src/models/auth/useMintToken.js`:
+
+```javascript
+// Mint a short-lived API token via step-up Basic auth.
+// IMPORTANT: uses a raw fetch with credentials:"omit" so NO session cookie is sent — the server
+// must verify the supplied password fresh (a session alone cannot mint).
+export async function mintToken(username, password) {
+ const resp = await fetch("/api/v3/auth/token", {
+ method: "POST",
+ credentials: "omit",
+ headers: {
+ Authorization: "Basic " + btoa(`${username}:${password}`),
+ },
+ });
+ let data = null;
+ try { data = await resp.json(); } catch (_e) { /* non-JSON body */ }
+ if (resp.status !== 200 || !data || !data.token) {
+ const err = new Error((data && data.error) || `mint failed (${resp.status})`);
+ err.status = resp.status;
+ throw err;
+ }
+ return data; // { token, tokenType, expiresInSeconds }
+}
+
+// Thin hook wrapper for components (keeps call sites declarative).
+import { useState, useCallback } from "react";
+export default function useMintToken() {
+ const [loading, setLoading] = useState(false);
+ const mint = useCallback(async (username, password) => {
+ setLoading(true);
+ try { return await mintToken(username, password); }
+ finally { setLoading(false); }
+ }, []);
+ return { mint, loading };
+}
+```
+
+- [ ] **Step 4: Run to verify it passes** (same jest command) — PASS (2 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+grep -c $'\r' frontend/src/models/auth/useMintToken.js frontend/src/__tests__/models/useMintToken.test.js
+git add frontend/src/models/auth/useMintToken.js frontend/src/__tests__/models/useMintToken.test.js
+git commit -m "frontend: useMintToken hook (cookie-less step-up Basic mint)
+
+Co-Authored-By: Claude Opus 4.8 "
+```
+
+---
+
+## Task 6: `ApiAccessPage` (page + password modal + token display)
+
+**Files:**
+- Create: `frontend/src/pages/ApiAccess/ApiAccessPage.jsx`
+- Test: `frontend/src/__tests__/pages/ApiAccessPage.test.jsx`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `frontend/src/__tests__/pages/ApiAccessPage.test.jsx`:
+
+```javascript
+import React from "react";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import ApiAccessPage from "../../pages/ApiAccess/ApiAccessPage";
+
+jest.mock("../../models/auth/users/useGetMe", () => () => ({
+ data: { username: "alice" },
+}));
+const mockMint = jest.fn();
+jest.mock("../../models/auth/useMintToken", () => () => ({ mint: mockMint, loading: false }));
+
+describe("ApiAccessPage", () => {
+ beforeEach(() => { mockMint.mockReset(); });
+
+ it("mints and shows the token on success", async () => {
+ mockMint.mockResolvedValue({ token: "tok-xyz", expiresInSeconds: 1800 });
+ render();
+ fireEvent.click(screen.getByRole("button", { name: /generate/i }));
+ fireEvent.change(screen.getByLabelText(/password/i), { target: { value: "s3cr3t" } });
+ fireEvent.click(screen.getByRole("button", { name: /confirm/i }));
+ await waitFor(() => expect(screen.getByText(/tok-xyz/)).toBeInTheDocument());
+ expect(mockMint).toHaveBeenCalledWith("alice", "s3cr3t");
+ });
+
+ it("shows an inline error on 401", async () => {
+ mockMint.mockRejectedValue(Object.assign(new Error("invalid credentials"), { status: 401 }));
+ render();
+ fireEvent.click(screen.getByRole("button", { name: /generate/i }));
+ fireEvent.change(screen.getByLabelText(/password/i), { target: { value: "wrong" } });
+ fireEvent.click(screen.getByRole("button", { name: /confirm/i }));
+ await waitFor(() => expect(screen.getByText(/incorrect password/i)).toBeInTheDocument());
+ expect(screen.queryByText(/tok-/)).not.toBeInTheDocument();
+ });
+});
+```
+
+- [ ] **Step 2: Run to verify it fails**: `cd frontend && npx jest src/__tests__/pages/ApiAccessPage.test.jsx` — FAIL (module missing).
+
+- [ ] **Step 3: Implement**
+
+Create `frontend/src/pages/ApiAccess/ApiAccessPage.jsx`:
+
+```jsx
+import React, { useState } from "react";
+import { Button, Modal, Form, Alert } from "react-bootstrap";
+import useGetMe from "../../models/auth/users/useGetMe";
+import useMintToken from "../../models/auth/useMintToken";
+
+const SKILL_URL = "/api/v3/agent-skill";
+
+export default function ApiAccessPage() {
+ const me = useGetMe();
+ const username = me?.data?.username || "";
+ const { mint, loading } = useMintToken();
+
+ const [showModal, setShowModal] = useState(false);
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState(null);
+ const [token, setToken] = useState(null);
+ const [expiresIn, setExpiresIn] = useState(null);
+
+ const openModal = () => { setError(null); setPassword(""); setShowModal(true); };
+
+ const submit = async (e) => {
+ e.preventDefault();
+ setError(null);
+ try {
+ const res = await mint(username, password);
+ setToken(res.token);
+ setExpiresIn(res.expiresInSeconds);
+ setPassword("");
+ setShowModal(false);
+ } catch (err) {
+ if (err.status === 401) setError("Incorrect password. If your account uses single sign-on, API tokens aren't available yet.");
+ else if (err.status === 503) setError("Token issuance isn't enabled on this server.");
+ else setError("Couldn't generate a token. Please try again.");
+ }
+ };
+
+ return (
+
+
API Access
+
+ Generate a short-lived token so an AI agent or script can act with your{" "}
+ Wildbook access. Treat it like a password.
+
+
+ Do not give your agent your username/password — paste it only a token.
+
+
+ Your agent can learn this API here:{" "}
+ {SKILL_URL}{" "}
+
+
+
+
+
+ {token && (
+
+
+ Copy this token now — it won't be shown again
+ {expiresIn ? ` and expires in ~${Math.round(expiresIn / 60)} min` : ""}.
+
+
+ {token}
+
+
+
+ )}
+
+ setShowModal(false)}>
+
+
+
+ );
+}
+```
+
+Notes:
+- **Verify `useGetMe`'s return shape** before relying on `me?.data?.username`. `useGetMe` wraps
+ `useFetch` (react-query) whose `dataAccessor` is `result?.data?.data`; confirm where `username`
+ lands (it may be `me.data.username` or nested differently) and adjust the accessor to match the
+ real hook. The test mocks `useGetMe` as `{ data: { username: "alice" } }`, so the page's accessor
+ and that mock must agree.
+- The 401 path keeps the modal open — `submit` only calls `setShowModal(false)` on success (the catch
+ block does not close it). The success test asserts the token appears; the 401 test asserts the
+ inline error appears with the modal still open.
+- The token display uses a static "expires in ~N min" message (computed from `expiresInSeconds`), not
+ a live ticking countdown — a live countdown is unnecessary for copy-now UX (YAGNI).
+
+- [ ] **Step 4: Run to verify it passes** (same jest command) — PASS (2 tests). Fix selector/label mismatches if RTL can't find elements (keep `htmlFor`/`id` paired).
+
+- [ ] **Step 5: Commit**
+
+```bash
+grep -c $'\r' frontend/src/pages/ApiAccess/ApiAccessPage.jsx frontend/src/__tests__/pages/ApiAccessPage.test.jsx
+git add frontend/src/pages/ApiAccess/ApiAccessPage.jsx frontend/src/__tests__/pages/ApiAccessPage.test.jsx
+git commit -m "frontend: ApiAccessPage — step-up modal + one-time token display
+
+Co-Authored-By: Claude Opus 4.8 "
+```
+
+---
+
+## Task 7: Route + avatar menu item
+
+**Files:**
+- Modify: `frontend/src/AuthenticatedSwitch.jsx`
+- Modify: `frontend/src/components/header/AvatarAndUserProfile.jsx`
+- Test: `frontend/src/__tests__/components/AvatarApiAccessLink.test.jsx`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `frontend/src/__tests__/components/AvatarApiAccessLink.test.jsx`:
+
+```javascript
+import React from "react";
+import { screen } from "@testing-library/react";
+import { renderWithProviders } from "../../utils/utils";
+import AvatarAndUserProfile from "../../components/header/AvatarAndUserProfile";
+import AuthContext from "../../AuthProvider";
+import LocaleContext from "../../IntlProvider";
+
+// AvatarAndUserProfile uses useNavigate + AuthContext + i18n, so render via the repo's standard
+// renderWithProviders (router + intl), wrapped in the same contexts the header tests use
+// (mirror frontend/src/__tests__/components/header/AuthenticatedHeader.test.js).
+describe("AvatarAndUserProfile", () => {
+ it("includes an API Access link to /api-access", () => {
+ renderWithProviders(
+
+
+
+
+ ,
+ );
+ const link = screen.getByText(/api access/i).closest("a");
+ expect(link).toHaveAttribute("href", expect.stringContaining("/api-access"));
+ });
+});
+```
+
+- [ ] **Step 2: Run to verify it fails**: `cd frontend && npx jest src/__tests__/components/AvatarApiAccessLink.test.jsx` — FAIL (no such link). If the component needs providers/router to render, wrap with the same test utilities other component tests in `frontend/src/__tests__/components` use (mirror an existing one).
+
+- [ ] **Step 3: Implement**
+
+(a) `frontend/src/AuthenticatedSwitch.jsx` — add the import + route alongside the others (near line 115):
+```jsx
+import ApiAccessPage from "./pages/ApiAccess/ApiAccessPage";
+```
+```jsx
+ } />
+```
+
+(b) `frontend/src/components/header/AvatarAndUserProfile.jsx` — add a dropdown item before Logout:
+```jsx
+
+ API Access
+
+```
+(Match the existing `NavDropdown.Item` style used by the sibling items.)
+
+- [ ] **Step 4: Run to verify it passes** (same jest command) — PASS. Run the page + hook tests too to confirm no breakage.
+
+- [ ] **Step 5: Commit**
+
+```bash
+grep -c $'\r' frontend/src/AuthenticatedSwitch.jsx frontend/src/components/header/AvatarAndUserProfile.jsx frontend/src/__tests__/components/AvatarApiAccessLink.test.jsx
+git add frontend/src/AuthenticatedSwitch.jsx frontend/src/components/header/AvatarAndUserProfile.jsx frontend/src/__tests__/components/AvatarApiAccessLink.test.jsx
+git commit -m "frontend: route /api-access + API Access avatar menu item
+
+Co-Authored-By: Claude Opus 4.8 "
+```
+
+---
+
+## Final verification (after all tasks)
+
+- [ ] Backend suite (includes the **existing** `AuthTokenTest` updated in Task 2 Step 4b, plus the
+ SearchApi token tests to confirm the allowlist-constant refactor didn't regress):
+ `mvn test -Dtest=UserCheckPasswordTest,AuthTokenTest,AuthTokenStepUpTest,AgentSkillTest,EndpointAuthWiringTest,SearchApiTokenAuthTest,SearchApiChildIndexTest -DargLine="--add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.time=ALL-UNNAMED --add-opens java.base/java.io=ALL-UNNAMED -Xmx2g"` → all green. (Drop any class name that doesn't exist in this tree.)
+- [ ] Frontend suite: `cd frontend && npx jest src/__tests__/models/useMintToken.test.js src/__tests__/pages/ApiAccessPage.test.jsx src/__tests__/components/AvatarApiAccessLink.test.jsx` → all green.
+- [ ] `git log --oneline` shows the focused commits.
+- [ ] Hand to Codex for a final code review (per the user's standing rule) before any PR/merge.
+- [ ] Do NOT push — the user does that. (Live smoke per the spec runs after deploy: anon `GET /api/v3/agent-skill`; UI mint flow; session+wrong-password→401.)
diff --git a/docs/superpowers/specs/2026-06-11-token-ui-and-agent-skill-design.md b/docs/superpowers/specs/2026-06-11-token-ui-and-agent-skill-design.md
new file mode 100644
index 0000000000..1518f8d97b
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-11-token-ui-and-agent-skill-design.md
@@ -0,0 +1,235 @@
+# Token-Issuance UI + Served Agent Skill — Design
+
+**Date:** 2026-06-11
+**Status:** Draft — incorporated Codex design review (2026-06-11, verified against source; all
+High/Medium/Low findings folded). Pending user review.
+**Builds on:** the deployed token-scoped read API — Artifact B (`JwtService`/`AuthToken`,
+`POST /api/v3/auth/token`), Artifact D + Spec A (token-scoped `encounter`/`individual`/`annotation`
+search), and the media-resolve endpoint (`POST /api/v3/media/resolve`). All on branch
+`token-auth-scoped-search` / PR #1613.
+
+---
+
+## Problem
+
+A logged-in Wildbook user has no way to obtain a bearer token from the UI — tokens are only mintable
+by calling `POST /api/v3/auth/token` with HTTP Basic auth, which a browser session can't do on its
+own. And even with a token, a user's AI agent has no machine-readable description of *how* to use
+Wildbook's token-scoped API (endpoints, the OpenSearch schema/fields, how to authenticate, what not to
+do). This design adds the two missing pieces so a user can (1) generate a token from the UI and
+(2) point their agent at a Wildbook-served skill that teaches it the API safely.
+
+## Goals
+
+1. **Token UI:** a logged-in user can mint a short-lived bearer token from a UI page, with a
+ step-up password confirmation, and copy it once.
+2. **Agent skill:** Wildbook serves an agent-loadable markdown document describing the token-scoped
+ API, the OpenSearch schema (indices + fields + descriptions), how to get a token, and security
+ guidance that explicitly discourages giving an agent the user's username/password.
+
+## Non-Goals
+
+- Managed/persisted personal API keys (naming, listing, revocation) — the token stays the existing
+ **stateless** 30-min RS256 JWT. A managed-key store is a documented future phase.
+- Any change to token TTL, scoping, or the JWT shape (reuses the deployed signing path). **Exception
+ (required by Codex review):** the mint endpoint gains server-side enforcement that a *fresh* Basic
+ credential was supplied — a session cookie alone must NOT be sufficient to mint (see Mint auth).
+- Dynamic/live-introspected skill generation — the skill is a curated static doc (future: hybrid).
+- Support for password-less/SSO accounts in the step-up flow (noted limitation).
+
+---
+
+## Token model (decided)
+
+Ephemeral **generate-and-copy**: the existing stateless RS256 JWT (default 30-min TTL, server-clamped
+1 min–24 h). No storage, no revocation, no list. Matches exactly what is already deployed.
+
+## Mint auth (decided) — step-up password, **enforced server-side**
+
+**Step-up password re-prompt.** The UI collects the user's password; the server verifies it **fresh**
+on every mint and never accepts a session cookie as sufficient.
+
+**Why this needs a backend change (Codex High).** `/api/v3/auth/token` is Shiro-gated by
+`authcBasicWildbook`, but Shiro's basic-auth filter lets an *already-authenticated session* pass
+**without parsing/verifying the Basic header** (`AuthToken` mints for `myShepherd.getUser(request)`).
+So as originally drafted, a logged-in browser could mint via its cookie — even with a *wrong*
+password — and the "no CSRF / step-up" guarantee would be false. The fix:
+
+- **Backend (`AuthToken`):** require an `Authorization: Basic` header and **verify the supplied
+ username/password against the user's stored credential using Wildbook's existing password check**
+ (the same credential path login uses), independent of any session. Mint for *that* verified user.
+ A request with no Basic header (session-only) → `401`; a Basic header with a wrong password → `401`,
+ regardless of an active session. This also closes the same-origin-XSS vector (a malicious in-page
+ script can't mint without the password). Add `Cache-Control: no-store` to the token response.
+- **Frontend (defense in depth):** send the mint with `fetch(..., { credentials: "omit" })` (a raw
+ fetch, not the shared cookie-bearing axios client) so no session cookie accompanies the Basic
+ request.
+
+CSRF: Basic-auth credentials are not auto-sent cross-site and the token is returned only in the
+response body (unreadable cross-origin); with the server-side Basic requirement, a session alone can
+never mint.
+
+---
+
+## Component A — Token-issuance UI (+ a small backend enforcement)
+
+`POST /api/v3/auth/token` returns `{token, tokenType, expiresInSeconds}`. The UI authenticates the
+mint with `Authorization: Basic base64(:)` (raw `fetch`, `credentials:
+"omit"`) and displays the result; the backend verifies the Basic credential fresh per the Mint-auth
+section above.
+
+### Backend unit
+- `AuthToken` (modify) — require + verify a fresh `Authorization: Basic` credential server-side
+ (reuse Wildbook's existing password check), reject session-only (`401`), add `Cache-Control:
+ no-store`. **Rate-limit / audit (Codex Medium, adjusted to reality):** verified against source —
+ Wildbook has **no** app-level login lockout/throttle today (`/api/v3/login`, `/rest/**` Basic auth,
+ etc. are equally unthrottled), so there is no existing mechanism to "reuse," and adding a bespoke
+ per-username lockout to *only* the mint would be an inconsistent one-off that also enables targeted
+ account-DoS. Decision: the mint is no more of a password oracle than the existing auth surfaces;
+ v1 adds **audit logging** of every mint attempt (username + client IP + success/failure, **never**
+ the password or token) so abuse is detectable, plus `Cache-Control: no-store`. A platform-wide auth
+ throttle (covering login + Basic + mint uniformly) is the correct home and is recorded as a
+ separate follow-up — out of scope here.
+
+### Units (all under `frontend/src`)
+- **Route:** new authenticated route `/api-access` in `AuthenticatedSwitch.jsx` → `ApiAccessPage`.
+- **Menu:** a new "API Access" item in `components/header/AvatarAndUserProfile.jsx` linking to it.
+- **Hook:** `useMintToken` (a model hook) — builds the Basic header from the `useGetMe`
+ (`/api/v3/user`) username + the entered password and POSTs to `/api/v3/auth/token`. The password is
+ passed in at call time, used for the single request, and never stored.
+- **Components:** a password-confirm modal + a token-display box (copy button, expiry countdown,
+ "shown once" warning).
+
+### UX flow
+1. Avatar dropdown → **API Access** → `/api-access`.
+2. Page explains the feature, warns *"Do NOT give your agent your username/password — use a token,"*
+ and links the agent skill URL (`/api/v3/agent-skill`) with a copy button.
+3. **Generate API token** → password-confirm modal (username shown read-only from `useGetMe`;
+ password field; "Confirm your password to mint a token").
+4. Submit → `POST /api/v3/auth/token` with the Basic header.
+5. **Success** → modal closes; the token is shown once in a copy box with an expiry countdown
+ ("expires in ~30 min") and "copy it now — it won't be shown again." Token lives only in component
+ state; navigating away clears it.
+
+### Errors
+- `401` → inline "Incorrect password" in the modal (do not close it).
+- `503` → "Token issuance isn't enabled on this server."
+- network / `500` → generic "Couldn't generate a token, try again."
+- **Password-less / SSO accounts:** `/api/v3/user` exposes `username` but **not** whether the account
+ has a local password (confirmed: `UserInfo`/`User.infoJSONObject`), so there is no cheap, reliable
+ pre-check. v1 does **not** add a `hasLocalPassword` signal — such users simply get the normal `401`
+ ("Incorrect password") path. The `401` copy may add a hint ("if your account uses single sign-on,
+ API tokens aren't available yet"), but no tailored detection is built. (A `hasLocalPassword` field
+ is a documented future option.)
+
+### Security
+- Password sent only in the single mint request over HTTPS, then discarded; never logged/stored.
+- Token held only in component state, cleared on navigation; never persisted to localStorage.
+- No CSRF surface (Basic-auth path). The `/api-access` route is authenticated (logged-in only);
+ minting still requires the step-up password.
+
+---
+
+## Component B — Served agent skill (small backend + curated doc)
+
+### Serving
+- New servlet `AgentSkill` (`org.ecocean.api`) at **`GET /api/v3/agent-skill`**, returning
+ `text/markdown; charset=UTF-8`.
+- **Anonymous** Shiro rule (`/api/v3/agent-skill = anon`) — a how-to-authenticate doc cannot itself
+ sit behind auth, and it contains no secrets (only API docs + schema field metadata + guidance).
+- Content lives in a versioned resource `src/main/resources/agent-skill.md`, loaded from the
+ classpath and streamed; the servlet adds no dynamic data. Deploy-versioned, easy to edit.
+
+### Skill content (agent-agnostic, self-contained markdown)
+1. **Preamble** — "You are an agent operating Wildbook's read API on behalf of a user." Scope:
+ read-only; the user's own ACL-scoped view.
+2. **Security first (the requirement that motivated this):** *Never ask for or accept the user's
+ Wildbook username/password.* The human mints a short-lived token in the API Access UI and pastes
+ **only the token**; treat it as a secret; it expires (~30 min) and is re-minted as needed; never
+ log or persist it.
+3. **Auth mechanics** — link to the API Access UI; `Authorization: Bearer `;
+ `expiresInSeconds`; admin-vs-non-admin scoping (everything is ACL-filtered to what the user sees).
+4. **Endpoints** — `POST /api/v3/search/{encounter|individual|annotation}` (allowed query-DSL subset,
+ pagination headers, what is returned/scrubbed); `POST /api/v3/media/resolve` (annotation IDs →
+ `imageUrl` + source-frame `bbox` + `imageWidth/imageHeight`, the **consumer-scales** contract,
+ ≤100 IDs); what is **not** allowed (`occurrence`/`media_asset` → 403; restricted aggregate/script
+ queries for non-admin individual search).
+5. **OpenSearch schema** — the `encounter`/`individual`/`annotation` indices with field descriptions
+ (sourced from `docs/opensearch-indices-and-fields.md`), limited to **token-exposed, returned**
+ fields; `embeddings` (nested vector, `method`/`methodVersion`). **Do not** document internal ACL
+ field names (`publiclyReadable`/`submitterUserId(s)`/`viewUsers`/`editUsers`) or deployment
+ details (Codex Low) — state only that ACL fields exist server-side and are never returned.
+6. **Worked examples** — search by taxonomy; resolve annotation IDs and crop with the bbox; the
+ missed-match calibration caveat (compare within one `viewpoint` + `methodVersion`).
+
+### Security
+- Anon GET; no secrets, no user data — only documentation. Its content actively discourages
+ credential sharing and promotes short-lived tokens.
+
+---
+
+## Testing
+
+### Frontend (Jest / React Testing Library)
+- `useMintToken` builds the correct `Authorization: Basic` header from the `useGetMe` username + the
+ entered password and POSTs to `/api/v3/auth/token`.
+- `ApiAccessPage`: **Generate** opens the modal; a successful mint renders the token + expiry + copy
+ control; `401` renders the inline "Incorrect password" error (modal stays open); `503` renders the
+ "not enabled" message.
+- `AvatarAndUserProfile` includes the new "API Access" item linking to `/api-access`.
+- (Repo note: frontend jest is continue-on-error in CI — tests are still written and run locally.)
+
+### Backend (JUnit)
+- **`AuthToken` step-up enforcement (Codex High):** a session-only request (no Basic header) → `401`;
+ a Basic header with a *wrong* password → `401` *even with an active session*; a correct Basic
+ credential → `200` + token + `Cache-Control: no-store`. (Mock the session subject + the credential
+ check.)
+- `AgentSkill` returns `200` with `Content-Type: text/markdown; charset=UTF-8`, a non-empty body
+ containing the key anchors: `Authorization: Bearer`, `/api/v3/media/resolve`, the three index names,
+ and the "never share credentials" guidance.
+- **Skill drift-guard (Codex Medium):** a test asserts the skill markdown's API claims stay in sync —
+ it mentions exactly the token-allowed indices (`encounter`/`individual`/`annotation`, and that
+ `occurrence`/`media_asset` are 403) consistent with `SearchApi`'s allowlist and `MediaResolveApi`,
+ and references no field the skill claims is returned that isn't. (Pragmatic form: assert the
+ allowed/denied index sets in the markdown match the `SearchApi` token allowlist constants; flag on
+ mismatch so the doc can't silently drift.)
+- `EndpointAuthWiringTest`: the `AgentSkill` servlet + `/api/v3/agent-skill` `` are
+ registered (an exact mapping that wins over the `/api/*` → `WildbookApi` mapping), and the Shiro
+ `[urls]` rule for `/api/v3/agent-skill` is exactly `anon`.
+
+### Live smoke
+- `GET /api/v3/agent-skill` on flakebook returns the markdown anonymously.
+- Run the UI mint → copy flow as a logged-in user; confirm `401` on wrong password.
+- **Step-up enforcement:** while logged in (session cookie present), `POST /api/v3/auth/token` with a
+ *wrong* Basic password returns `401` (the session does not let it through); with no Basic header at
+ all returns `401`.
+- Paste the minted token into a `POST /api/v3/search/...` call and confirm it works.
+
+---
+
+## Components / file boundary
+
+**Component A — backend (step-up enforcement):**
+- `src/main/java/org/ecocean/api/AuthToken.java` (modify) — require + verify a fresh Basic credential
+ server-side (reuse Wildbook's password check), reject session-only, reuse login lockout/throttle,
+ `Cache-Control: no-store`, audit-log without secrets.
+- `src/test/java/org/ecocean/api/AuthTokenTest.java` (modify/extend) — the step-up enforcement cases.
+
+**Component A — frontend:**
+- `frontend/src/pages/ApiAccess/ApiAccessPage.jsx` (new) — page + modal + token display.
+- `frontend/src/models/auth/useMintToken.js` (new) — the mint hook (raw `fetch`, `credentials:
+ "omit"`, Basic header).
+- `frontend/src/AuthenticatedSwitch.jsx` (modify) — add the `/api-access` route.
+- `frontend/src/components/header/AvatarAndUserProfile.jsx` (modify) — add the menu item.
+- Frontend tests under `frontend/src/__tests__/`.
+
+**Backend (Component B):**
+- `src/main/java/org/ecocean/api/AgentSkill.java` (new) — the servlet.
+- `src/main/resources/agent-skill.md` (new) — the curated skill content.
+- `src/main/webapp/WEB-INF/web.xml` (modify) — servlet + mapping + anon Shiro rule.
+- `src/test/java/org/ecocean/api/AgentSkillTest.java` (new) + `EndpointAuthWiringTest.java` (modify).
+
+This is a self-contained increment on the `token-auth-scoped-search` branch. Component A is mostly
+frontend plus a focused security hardening of the existing `AuthToken` mint (fresh Basic
+verification); Component B adds one servlet + one resource + a web.xml mapping. Neither changes the
+token JWT shape, TTL, scoping, or the search/index code paths.
From d1ef6354beeac9cd4e4bfa78a9602e18a3b6992d Mon Sep 17 00:00:00 2001
From: JasonWildMe
Date: Thu, 11 Jun 2026 15:34:26 -0700
Subject: [PATCH 02/10] User: add checkPassword(clearText) for fresh credential
verification
Co-Authored-By: Claude Opus 4.8
---
src/main/java/org/ecocean/User.java | 17 ++++++++++
.../org/ecocean/UserCheckPasswordTest.java | 34 +++++++++++++++++++
2 files changed, 51 insertions(+)
create mode 100644 src/test/java/org/ecocean/UserCheckPasswordTest.java
diff --git a/src/main/java/org/ecocean/User.java b/src/main/java/org/ecocean/User.java
index 54dd0205b8..e22003012a 100644
--- a/src/main/java/org/ecocean/User.java
+++ b/src/main/java/org/ecocean/User.java
@@ -317,6 +317,23 @@ public void setPassword(String password) {
public void setSalt(String salt) { this.salt = salt; }
public String getSalt() { return salt; }
+ /**
+ * Verify a clear-text password against this user's stored salted hash, using the same hashing
+ * as login (ServletUtilities.hashAndSaltPassword). Constant-time comparison. Returns false if
+ * this user has no stored password or the candidate is blank.
+ */
+ public boolean checkPassword(String clearText) {
+ if ((clearText == null) || clearText.isEmpty()) return false;
+ String stored = this.getPassword();
+ String salt = this.getSalt();
+ if ((stored == null) || stored.isEmpty() || (salt == null)) return false;
+ String hashed = org.ecocean.servlet.ServletUtilities.hashAndSaltPassword(clearText, salt);
+ if (hashed == null) return false;
+ return java.security.MessageDigest.isEqual(
+ hashed.getBytes(java.nio.charset.StandardCharsets.UTF_8),
+ stored.getBytes(java.nio.charset.StandardCharsets.UTF_8));
+ }
+
public void setUserProject(String newProj) {
if (newProj != null) { userProject = newProj; } else { userProject = null; }
}
diff --git a/src/test/java/org/ecocean/UserCheckPasswordTest.java b/src/test/java/org/ecocean/UserCheckPasswordTest.java
new file mode 100644
index 0000000000..cebcb73d8c
--- /dev/null
+++ b/src/test/java/org/ecocean/UserCheckPasswordTest.java
@@ -0,0 +1,34 @@
+package org.ecocean;
+
+import static org.junit.jupiter.api.Assertions.*;
+import org.junit.jupiter.api.Test;
+
+class UserCheckPasswordTest {
+
+ private static final String SALT = "0123456789abcdef";
+
+ private User userWith(String clear) {
+ // IMPORTANT: User.setPassword(String) only ASSIGNS the field — it does NOT hash. Production
+ // stores an already-hashed value alongside a matching salt, so the test must do the same.
+ User u = new User();
+ u.setUsername("alice");
+ u.setSalt(SALT);
+ u.setPassword(org.ecocean.servlet.ServletUtilities.hashAndSaltPassword(clear, SALT));
+ return u;
+ }
+
+ @Test void checkPassword_trueForCorrect_falseForWrong() {
+ User u = userWith("s3cr3t!");
+ assertTrue(u.checkPassword("s3cr3t!"), "correct password verifies");
+ assertFalse(u.checkPassword("nope"), "wrong password rejected");
+ }
+
+ @Test void checkPassword_falseOnNullOrNoStoredPassword() {
+ User u = new User();
+ u.setUsername("bob"); // no password/salt set
+ assertFalse(u.checkPassword("anything"), "no stored password -> false");
+ User u2 = userWith("pw");
+ assertFalse(u2.checkPassword(null), "null candidate -> false");
+ assertFalse(u2.checkPassword(""), "empty candidate -> false");
+ }
+}
From 5e2ed4a3a3b7dd2d7a076a509203f834c986dcb6 Mon Sep 17 00:00:00 2001
From: JasonWildMe
Date: Thu, 11 Jun 2026 15:44:10 -0700
Subject: [PATCH 03/10] =?UTF-8?q?AuthToken:=20server-side=20step-up=20?=
=?UTF-8?q?=E2=80=94=20require+verify=20fresh=20Basic,=20reject=20session-?=
=?UTF-8?q?only?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 4.8
---
src/main/java/org/ecocean/api/AuthToken.java | 61 ++++++++++---
.../org/ecocean/api/AuthTokenStepUpTest.java | 85 +++++++++++++++++++
.../java/org/ecocean/api/AuthTokenTest.java | 44 ++++++----
3 files changed, 163 insertions(+), 27 deletions(-)
create mode 100644 src/test/java/org/ecocean/api/AuthTokenStepUpTest.java
diff --git a/src/main/java/org/ecocean/api/AuthToken.java b/src/main/java/org/ecocean/api/AuthToken.java
index a57248102a..b0e7514c3e 100644
--- a/src/main/java/org/ecocean/api/AuthToken.java
+++ b/src/main/java/org/ecocean/api/AuthToken.java
@@ -1,6 +1,8 @@
package org.ecocean.api;
import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@@ -12,27 +14,46 @@
/**
* POST /api/v3/auth/token
- * Gated by existing Shiro auth (authcBasicWildbook). Mints a short-lived
- * RS256 token carrying only the caller's identity (uuid + context). The
- * external scoped-access kernel validates it with the public key.
+ * Mints a short-lived RS256 token. STEP-UP: requires a fresh, valid HTTP Basic credential and
+ * verifies it server-side (a Shiro session alone is NOT sufficient) — so a stolen/unlocked session
+ * or same-origin script cannot mint without the password.
*/
public class AuthToken extends ApiBase {
private static final long DEFAULT_TTL_MILLIS = 30L * 60L * 1000L; // 30 min
@Override protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws IOException {
- // SECURITY (Codex High): pin to the fixed auth context. Basic auth authenticates
- // against context0 (WildbookBasicHttpAuthenticationFilter), so the user lookup,
- // signing key/config, and the token's context claim must ALL come from context0 —
- // never from ServletUtilities.getContext(request), which honors ?context=.
- final String context = "context0";
+ handle(request, response);
+ }
+
+ // package-visible test entry point
+ void doPostForTest(HttpServletRequest request, HttpServletResponse response) throws IOException {
+ handle(request, response);
+ }
+
+ private void handle(HttpServletRequest request, HttpServletResponse response) throws IOException {
+ response.setHeader("Cache-Control", "no-store");
+ final String context = "context0"; // Basic auth is pinned to context0 (see filter)
+ String clientIp = request.getRemoteAddr();
+
+ String[] cred = parseBasic(request.getHeader("Authorization"));
+ if (cred == null) {
+ System.out.println("AuthToken mint DENIED (no Basic credential) ip=" + clientIp);
+ writeError(response, 401, "basic credentials required");
+ return;
+ }
+ String username = cred[0];
+ String password = cred[1];
+
Shepherd myShepherd = new Shepherd(context);
myShepherd.setAction("api.AuthToken.doPost");
myShepherd.beginDBTransaction();
try {
- User user = myShepherd.getUser(request);
- if (user == null) {
- writeError(response, 401, "unauthenticated");
+ User user = (Util.stringExists(username)) ? myShepherd.getUser(username) : null;
+ if ((user == null) || !user.checkPassword(password)) {
+ System.out.println("AuthToken mint DENIED (bad credentials) user=" + username
+ + " ip=" + clientIp);
+ writeError(response, 401, "invalid credentials");
return;
}
String tokenContext = org.ecocean.CommonConfiguration.getProperty("jwtContext", context);
@@ -44,6 +65,7 @@ public class AuthToken extends ApiBase {
}
long ttl = ttlFromConfig(tokenContext);
String token = jwt.sign(user.getId(), tokenContext, ttl);
+ System.out.println("AuthToken mint OK user=" + username + " ip=" + clientIp);
JSONObject out = new JSONObject();
out.put("token", token);
out.put("tokenType", "Bearer");
@@ -59,12 +81,27 @@ public class AuthToken extends ApiBase {
}
}
+ /** Parse an HTTP Basic Authorization header into [username, password], or null if absent/invalid. */
+ private static String[] parseBasic(String header) {
+ if (header == null) return null;
+ if (!header.regionMatches(true, 0, "Basic ", 0, 6)) return null;
+ try {
+ String decoded = new String(Base64.getDecoder().decode(header.substring(6).trim()),
+ StandardCharsets.UTF_8);
+ int i = decoded.indexOf(':');
+ if (i < 0) return null;
+ return new String[] { decoded.substring(0, i), decoded.substring(i + 1) };
+ } catch (IllegalArgumentException ex) {
+ return null;
+ }
+ }
+
private long ttlFromConfig(String context) {
String v = org.ecocean.CommonConfiguration.getProperty("jwtTtlSeconds", context);
if (Util.stringExists(v)) {
try {
long secs = Long.parseLong(v.trim());
- // Codex Low: clamp to a sane range (1 min .. 24 h)
+ // clamp to a sane range (1 min .. 24 h)
secs = Math.max(60L, Math.min(secs, 24L * 3600L));
return secs * 1000L;
} catch (NumberFormatException ignore) {}
diff --git a/src/test/java/org/ecocean/api/AuthTokenStepUpTest.java b/src/test/java/org/ecocean/api/AuthTokenStepUpTest.java
new file mode 100644
index 0000000000..78079cfcfc
--- /dev/null
+++ b/src/test/java/org/ecocean/api/AuthTokenStepUpTest.java
@@ -0,0 +1,85 @@
+package org.ecocean.api;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+import static org.mockito.ArgumentMatchers.*;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.util.Base64;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.ecocean.User;
+import org.ecocean.shepherd.core.Shepherd;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedConstruction;
+
+class AuthTokenStepUpTest {
+
+ private HttpServletResponse resp(StringWriter out) throws Exception {
+ HttpServletResponse r = mock(HttpServletResponse.class);
+ when(r.getWriter()).thenReturn(new PrintWriter(out));
+ return r;
+ }
+ private String basic(String u, String p) {
+ return "Basic " + Base64.getEncoder().encodeToString((u + ":" + p).getBytes());
+ }
+
+ @Test void noBasicHeader_sessionOnly_is401() throws Exception {
+ HttpServletRequest req = mock(HttpServletRequest.class);
+ when(req.getHeader("Authorization")).thenReturn(null); // session-only
+ StringWriter out = new StringWriter();
+ HttpServletResponse r = resp(out);
+ try (MockedConstruction sh = mockConstruction(Shepherd.class, (m, c) -> {
+ doNothing().when(m).beginDBTransaction();
+ doNothing().when(m).setAction(anyString());
+ doNothing().when(m).rollbackAndClose();
+ })) {
+ new AuthToken().doPostForTest(req, r);
+ }
+ verify(r).setStatus(401);
+ }
+
+ @Test void wrongPassword_servletRejects_401() throws Exception {
+ // Servlet-level check: a present-but-wrong Basic credential is rejected regardless of session.
+ // (The full filter+session end-to-end is covered by the live smoke in the spec.)
+ HttpServletRequest req = mock(HttpServletRequest.class);
+ when(req.getHeader("Authorization")).thenReturn(basic("alice", "WRONG"));
+ StringWriter out = new StringWriter();
+ HttpServletResponse r = resp(out);
+ User alice = mock(User.class);
+ when(alice.checkPassword("WRONG")).thenReturn(false);
+ try (MockedConstruction sh = mockConstruction(Shepherd.class, (m, c) -> {
+ doNothing().when(m).beginDBTransaction();
+ doNothing().when(m).setAction(anyString());
+ doNothing().when(m).rollbackAndClose();
+ when(m.getUser("alice")).thenReturn(alice);
+ })) {
+ new AuthToken().doPostForTest(req, r);
+ }
+ verify(r).setStatus(401);
+ }
+
+ @Test void correctPassword_mints200() throws Exception {
+ HttpServletRequest req = mock(HttpServletRequest.class);
+ when(req.getHeader("Authorization")).thenReturn(basic("alice", "right"));
+ StringWriter out = new StringWriter();
+ HttpServletResponse r = resp(out);
+ User alice = mock(User.class);
+ when(alice.checkPassword("right")).thenReturn(true);
+ when(alice.getId()).thenReturn("uuid-alice");
+ when(alice.getUsername()).thenReturn("alice");
+ try (MockedConstruction sh = mockConstruction(Shepherd.class, (m, c) -> {
+ doNothing().when(m).beginDBTransaction();
+ doNothing().when(m).setAction(anyString());
+ doNothing().when(m).rollbackAndClose();
+ when(m.getUser("alice")).thenReturn(alice);
+ })) {
+ new AuthToken().doPostForTest(req, r);
+ }
+ // 200 only if JWT issuance is configured in the unit env; otherwise 503. The point is the
+ // credential was ACCEPTED (not 401) and no-store is set.
+ verify(r, never()).setStatus(401);
+ verify(r).setHeader("Cache-Control", "no-store");
+ }
+}
diff --git a/src/test/java/org/ecocean/api/AuthTokenTest.java b/src/test/java/org/ecocean/api/AuthTokenTest.java
index b944e5629f..3626ef0d5c 100644
--- a/src/test/java/org/ecocean/api/AuthTokenTest.java
+++ b/src/test/java/org/ecocean/api/AuthTokenTest.java
@@ -2,11 +2,13 @@
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
+import java.util.Base64;
import java.util.concurrent.atomic.AtomicReference;
import javax.servlet.http.HttpServletRequest;
@@ -40,19 +42,23 @@ void setUp() throws IOException {
when(mockRequest.getContextPath()).thenReturn("");
}
+ private String basic(String u, String p) {
+ return "Basic " + Base64.getEncoder().encodeToString((u + ":" + p).getBytes());
+ }
+
/**
- * Primary protection gate: unauthenticated caller (getUser returns null) → 401.
- * This is the most important unit-testable gate: the endpoint must reject requests
- * where Shiro passed but no User principal was resolved.
+ * Primary protection gate: no Basic header (session-only) → 401.
+ * The new step-up enforcement rejects any request that does not carry a fresh
+ * Basic credential, regardless of Shiro session state.
*/
@Test
void unauthenticated_returns401() throws Exception {
+ when(mockRequest.getHeader("Authorization")).thenReturn(null);
try (MockedConstruction mockShepherd = mockConstruction(Shepherd.class,
(mock, ctx) -> {
doNothing().when(mock).beginDBTransaction();
doNothing().when(mock).setAction(anyString());
doNothing().when(mock).rollbackAndClose();
- when(mock.getUser(any(HttpServletRequest.class))).thenReturn(null);
})) {
AuthToken servlet = new AuthToken();
servlet.doPost(mockRequest, mockResponse);
@@ -63,7 +69,6 @@ void unauthenticated_returns401() throws Exception {
assertFalse(body.isEmpty(), "response body must not be empty");
JSONObject json = new JSONObject(body);
assertFalse(json.optBoolean("success", true), "success must be false");
- assertEquals("unauthenticated", json.optString("error"), "error message");
}
}
@@ -73,15 +78,21 @@ void unauthenticated_returns401() throws Exception {
* A caller supplying context=evilcontext must not shift user lookup or key config
* to another context. We capture the first constructor arg via MockedConstruction
* and assert it equals "context0".
- * The request is also unauthenticated (getUser→null) so we get a 401, confirming
- * the servlet completed the pinned construction path before returning.
+ * The request carries a valid Basic credential whose password check returns true,
+ * so execution reaches the Shepherd construction path before JWT check.
*/
@Test
void requestContextParamIgnored() throws Exception {
// Make the request look like it carries a context override
when(mockRequest.getParameter("context")).thenReturn("evilcontext");
+ // Supply a valid Basic credential so execution reaches Shepherd construction
+ when(mockRequest.getHeader("Authorization")).thenReturn(basic("alice", "right"));
AtomicReference capturedCtorArg = new AtomicReference<>();
+ User alice = mock(User.class);
+ when(alice.checkPassword("right")).thenReturn(true);
+ when(alice.getId()).thenReturn("uuid-alice");
+ when(alice.getUsername()).thenReturn("alice");
try (MockedConstruction mockShepherd = mockConstruction(Shepherd.class,
(mock, ctx) -> {
@@ -92,8 +103,7 @@ void requestContextParamIgnored() throws Exception {
doNothing().when(mock).beginDBTransaction();
doNothing().when(mock).setAction(anyString());
doNothing().when(mock).rollbackAndClose();
- // unauthenticated → servlet returns 401 without attempting to sign
- when(mock.getUser(any(HttpServletRequest.class))).thenReturn(null);
+ when(mock.getUser("alice")).thenReturn(alice);
})) {
AuthToken servlet = new AuthToken();
servlet.doPost(mockRequest, mockResponse);
@@ -102,19 +112,23 @@ void requestContextParamIgnored() throws Exception {
// Shepherd must have been constructed with the pinned context, not "evilcontext"
assertEquals("context0", capturedCtorArg.get(),
"Shepherd must be constructed with pinned context0, not a request-derived value");
- // Unauthenticated → 401 (confirms the code path ran fully through construction)
- verify(mockResponse).setStatus(401);
+ // Credential accepted → not 401 (confirms the code path ran through Shepherd construction)
+ verify(mockResponse, never()).setStatus(401);
}
}
/**
* JwtService disabled (no private key configured) → 503.
- * Exercises the second gate after successful auth.
+ * Exercises the second gate after successful credential verification.
+ * Must supply a valid Basic credential so execution reaches the JWT logic.
*/
@Test
void jwtDisabled_returns503() throws Exception {
- User fakeUser = new User();
- fakeUser.setUsername("test-user");
+ when(mockRequest.getHeader("Authorization")).thenReturn(basic("alice", "right"));
+ User alice = mock(User.class);
+ when(alice.checkPassword("right")).thenReturn(true);
+ when(alice.getId()).thenReturn("uuid-alice");
+ when(alice.getUsername()).thenReturn("alice");
try (MockedStatic mockConfig =
mockStatic(org.ecocean.CommonConfiguration.class);
@@ -123,7 +137,7 @@ void jwtDisabled_returns503() throws Exception {
doNothing().when(mock).beginDBTransaction();
doNothing().when(mock).setAction(anyString());
doNothing().when(mock).rollbackAndClose();
- when(mock.getUser(any(HttpServletRequest.class))).thenReturn(fakeUser);
+ when(mock.getUser("alice")).thenReturn(alice);
})) {
// All CommonConfiguration.getProperty calls return null → JwtService disabled
mockConfig.when(() -> org.ecocean.CommonConfiguration.getProperty(
From e72694eb24fd6e56cb12c2e00243f0e024ac7253 Mon Sep 17 00:00:00 2001
From: JasonWildMe
Date: Thu, 11 Jun 2026 15:49:34 -0700
Subject: [PATCH 04/10] agent-skill: add curated agent-skill markdown resource
Co-Authored-By: Claude Opus 4.8
---
src/main/resources/agent-skill.md | 66 +++++++++++++++++++++++++++++++
1 file changed, 66 insertions(+)
create mode 100644 src/main/resources/agent-skill.md
diff --git a/src/main/resources/agent-skill.md b/src/main/resources/agent-skill.md
new file mode 100644
index 0000000000..0ec2d2058b
--- /dev/null
+++ b/src/main/resources/agent-skill.md
@@ -0,0 +1,66 @@
+# Wildbook Token-Scoped API — Agent Skill
+
+You are an AI agent operating Wildbook's **read-only** API on behalf of a human user. You see exactly
+what that user is permitted to see (everything is access-controlled to their account).
+
+## Security — read first
+- **Never ask for, accept, or store the user's Wildbook username or password.** You do not need them.
+- The user generates a short-lived **bearer token** in Wildbook's UI (Account menu → **API Access**)
+ and pastes **only the token** to you.
+- Treat the token as a secret: never log or persist it, never send it anywhere except Wildbook over
+ HTTPS. It expires (typically ~30 minutes). When it expires, ask the user to paste a fresh one.
+
+## Authentication
+Send the token as a bearer header on every request:
+```
+Authorization: Bearer
+```
+The token response includes `expiresInSeconds`. An admin user's token sees all data; a normal user's
+token is filtered to their own accessible records. The server also enforces internal access-control
+fields, which are never returned to you.
+
+## Endpoints
+
+### Search — `POST /api/v3/search/{index}`
+`{index}` is one of: `encounter`, `individual`, `annotation`. (`occurrence` and `media_asset` return
+`403` for token callers.) Body is an OpenSearch query, e.g.:
+```json
+{ "query": { "term": { "taxonomy": "Salamandra salamandra" } } }
+```
+Pagination via `?from=&size=` query params; total hits in the `X-Wildbook-Total-Hits` response header.
+Non-admin `individual` search may only query/sort identity fields. Aggregations, scripted queries, and
+cross-index term lookups are rejected.
+
+### Media resolve — `POST /api/v3/media/resolve`
+Resolve up to 100 annotation IDs you are allowed to see into displayable image references:
+```json
+{ "annotationIds": ["", ""] }
+```
+Returns an array of `{ id, imageUrl, imageWidth, imageHeight, bbox: [x,y,w,h], theta, viewpoint,
+encounterId, individualId, methodVersion }`. The `bbox` is in the `imageWidth`×`imageHeight`
+coordinate space. **Fetch `imageUrl`, read its real pixel dimensions, and scale `bbox` by
+`realW/imageWidth`, `realH/imageHeight` before cropping** (usually a no-op). IDs you can't see (or that
+don't exist) are simply absent — the response never reveals which.
+
+## OpenSearch schema (token-exposed fields)
+See the field reference for full descriptions. Key indices/fields:
+- **encounter** — `id`, `taxonomy`, `locationId`/`locationName`, `date`/`dateMillis`, `individualId`,
+ `sex`, `lifeStage`, `livingStatus`, `country`, `behavior`, ...
+- **individual** — `id`, `displayName`, `names`/`nameMap`, `sex`, `taxonomy`, `timeOfBirth`/`timeOfDeath`.
+- **annotation** — `id`, `encounterId`, `viewpoint`, `iaClass`, `matchAgainst`, `mediaAssetId`, and
+ `embeddings` (nested: `method`, `methodVersion`, and the MiewID `vector`).
+
+Access-control fields exist server-side but are **never** returned.
+
+## Worked examples
+
+**Find an individual's salamander encounters, then view two annotations:**
+1. `POST /api/v3/search/encounter` with `{"query":{"term":{"taxonomy":"Salamandra salamandra"}}}`.
+2. Collect annotation IDs (search `annotation`, or via the encounters), then
+ `POST /api/v3/media/resolve` with those IDs.
+3. For each result, fetch `imageUrl`, scale `bbox` to the fetched pixels, crop, and present
+ side-by-side.
+
+**Comparing embeddings for missed matches:** only compare embeddings within the **same `viewpoint` and
+same `methodVersion`** — different viewpoints/versions live in different latent spaces and are not
+directly comparable. Calibrate similarity against known same-individual pairs before trusting a score.
From f25470e13e610b1eabf93af1ae5af6594d0ff71b Mon Sep 17 00:00:00 2001
From: JasonWildMe
Date: Thu, 11 Jun 2026 15:56:25 -0700
Subject: [PATCH 05/10] agent-skill: serve curated skill at GET
/api/v3/agent-skill (anon) + SearchApi allowlist constant + drift-guard
Co-Authored-By: Claude Opus 4.8
---
src/main/java/org/ecocean/api/AgentSkill.java | 45 +++++++++++++++
src/main/java/org/ecocean/api/SearchApi.java | 8 ++-
src/main/webapp/WEB-INF/web.xml | 10 ++++
.../java/org/ecocean/api/AgentSkillTest.java | 55 +++++++++++++++++++
.../ecocean/api/EndpointAuthWiringTest.java | 30 ++++++++++
5 files changed, 145 insertions(+), 3 deletions(-)
create mode 100644 src/main/java/org/ecocean/api/AgentSkill.java
create mode 100644 src/test/java/org/ecocean/api/AgentSkillTest.java
diff --git a/src/main/java/org/ecocean/api/AgentSkill.java b/src/main/java/org/ecocean/api/AgentSkill.java
new file mode 100644
index 0000000000..3a04f07487
--- /dev/null
+++ b/src/main/java/org/ecocean/api/AgentSkill.java
@@ -0,0 +1,45 @@
+package org.ecocean.api;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * GET /api/v3/agent-skill
+ * Serves the curated, version-controlled agent-skill markdown (classpath resource). Anonymous: a
+ * how-to-authenticate doc cannot itself require auth, and it contains no secrets — only API docs.
+ */
+public class AgentSkill extends ApiBase {
+ private static final String RESOURCE = "/agent-skill.md";
+
+ @Override protected void doGet(HttpServletRequest request, HttpServletResponse response)
+ throws IOException {
+ handle(request, response);
+ }
+
+ void doGetForTest(HttpServletRequest request, HttpServletResponse response) throws IOException {
+ handle(request, response);
+ }
+
+ private void handle(HttpServletRequest request, HttpServletResponse response) throws IOException {
+ String md = readResource();
+ if (md == null) {
+ response.setStatus(500);
+ response.setContentType("text/plain; charset=UTF-8");
+ response.getWriter().write("agent skill unavailable");
+ return;
+ }
+ response.setStatus(200);
+ response.setContentType("text/markdown; charset=UTF-8");
+ response.getWriter().write(md);
+ }
+
+ private String readResource() throws IOException {
+ try (InputStream in = AgentSkill.class.getResourceAsStream(RESOURCE)) {
+ if (in == null) return null;
+ return new String(in.readAllBytes(), StandardCharsets.UTF_8);
+ }
+ }
+}
diff --git a/src/main/java/org/ecocean/api/SearchApi.java b/src/main/java/org/ecocean/api/SearchApi.java
index 1e1a8fdd5d..58f60e4fba 100644
--- a/src/main/java/org/ecocean/api/SearchApi.java
+++ b/src/main/java/org/ecocean/api/SearchApi.java
@@ -15,6 +15,10 @@
import org.json.JSONObject;
public class SearchApi extends ApiBase {
+ /** Indices a token caller may search; everything else is 403. The agent-skill drift-guard test pins to this. */
+ static final java.util.Set TOKEN_ALLOWED_INDICES =
+ java.util.Set.of("encounter", "annotation", "individual");
+
public void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
doPost(request, response);
@@ -100,9 +104,7 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response)
response.setStatus(403);
res.put("error", 403);
// --- token index allowlist: encounter, annotation, individual (others 403) ---
- } else if (tokenAuth && !"encounter".equals(effectiveIndex)
- && !"annotation".equals(effectiveIndex)
- && !"individual".equals(effectiveIndex)) {
+ } else if (tokenAuth && !TOKEN_ALLOWED_INDICES.contains(effectiveIndex)) {
response.setStatus(403);
res.put("error", "token search is limited to encounter, annotation, individual");
} else if ((query == null) && !"POST".equals(request.getMethod())) {
diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml
index 123826beac..eba4633cdc 100644
--- a/src/main/webapp/WEB-INF/web.xml
+++ b/src/main/webapp/WEB-INF/web.xml
@@ -112,6 +112,7 @@
# No Bearer => falls through to SearchApi (which self-401s if no session). Filter handles auth.
/api/v3/search/** = tokenAuthSearch
/api/v3/media/resolve = tokenAuthSearch
+ /api/v3/agent-skill = anon
# ===== Appadmin =====
@@ -573,6 +574,15 @@
/api/v3/media/resolve
+
+ AgentSkill
+ org.ecocean.api.AgentSkill
+
+
+ AgentSkill
+ /api/v3/agent-skill
+
+
BulkExportorg.ecocean.api.EncounterExport
diff --git a/src/test/java/org/ecocean/api/AgentSkillTest.java b/src/test/java/org/ecocean/api/AgentSkillTest.java
new file mode 100644
index 0000000000..6b9045cd96
--- /dev/null
+++ b/src/test/java/org/ecocean/api/AgentSkillTest.java
@@ -0,0 +1,55 @@
+package org.ecocean.api;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.junit.jupiter.api.Test;
+
+class AgentSkillTest {
+
+ private String body() throws Exception {
+ HttpServletRequest req = mock(HttpServletRequest.class);
+ HttpServletResponse resp = mock(HttpServletResponse.class);
+ StringWriter out = new StringWriter();
+ when(resp.getWriter()).thenReturn(new PrintWriter(out));
+ new AgentSkill().doGetForTest(req, resp);
+ verify(resp).setStatus(200);
+ verify(resp).setContentType("text/markdown; charset=UTF-8");
+ return out.toString();
+ }
+
+ @Test void serves_markdown_with_key_anchors() throws Exception {
+ String md = body();
+ assertFalse(md.isEmpty(), "skill body must be non-empty");
+ assertTrue(md.contains("Authorization: Bearer"), "documents bearer auth");
+ assertTrue(md.contains("/api/v3/media/resolve"), "documents media resolve");
+ assertTrue(md.contains("/api/v3/search/"), "documents search");
+ for (String idx : new String[] {"encounter", "individual", "annotation"})
+ assertTrue(md.contains(idx), "mentions index " + idx);
+ assertTrue(md.toLowerCase().contains("never ask for") || md.toLowerCase().contains("never give"),
+ "contains the never-share-credentials guidance");
+ }
+
+ @Test void skill_index_claims_match_search_allowlist() throws Exception {
+ String md = body();
+ for (String idx : SearchApi.TOKEN_ALLOWED_INDICES)
+ assertTrue(md.contains(idx), "skill must list allowed index " + idx);
+ assertTrue(md.contains("occurrence") && md.contains("media_asset"),
+ "skill must name the denied indices");
+ assertTrue(md.contains("403"), "skill must state denied indices return 403");
+ for (String denied : new String[] {"occurrence", "media_asset"})
+ assertFalse(SearchApi.TOKEN_ALLOWED_INDICES.contains(denied),
+ "denied index " + denied + " must not be in the allowlist");
+ }
+
+ @Test void skill_does_not_leak_internal_acl_field_names() throws Exception {
+ String md = body();
+ for (String acl : new String[] {
+ "publiclyReadable", "submitterUserId", "submitterUserIds", "viewUsers", "editUsers"})
+ assertFalse(md.contains(acl), "skill must not expose internal ACL field name " + acl);
+ }
+}
diff --git a/src/test/java/org/ecocean/api/EndpointAuthWiringTest.java b/src/test/java/org/ecocean/api/EndpointAuthWiringTest.java
index 3bd77f268d..7c9455ad68 100644
--- a/src/test/java/org/ecocean/api/EndpointAuthWiringTest.java
+++ b/src/test/java/org/ecocean/api/EndpointAuthWiringTest.java
@@ -235,4 +235,34 @@ void mediaResolve_shiroRuleIsTokenFilterOnly() {
assertEquals("tokenAuthSearch", value,
"media path must map to tokenAuthSearch ONLY (no authc/roles chained); was: '" + value + "'");
}
+
+ // -----------------------------------------------------------------------
+ // Assertions — /api/v3/agent-skill wiring
+ // -----------------------------------------------------------------------
+
+ @Test
+ void agentSkill_servletClassIsRegistered() {
+ assertTrue(fullText().contains("org.ecocean.api.AgentSkill"),
+ "web.xml must register the AgentSkill servlet");
+ }
+
+ @Test
+ void agentSkill_urlPatternIsRegistered() {
+ assertTrue(fullText().contains("/api/v3/agent-skill"),
+ "web.xml must map /api/v3/agent-skill");
+ }
+
+ @Test
+ void agentSkill_shiroRuleIsAnon() {
+ String ruleLine = lines.stream()
+ .filter(l -> { String t = l.stripLeading();
+ return !t.startsWith("#") && t.contains("/api/v3/agent-skill"); })
+ .findFirst().orElse(null);
+ assertNotNull(ruleLine, "Shiro [urls] must contain a rule for /api/v3/agent-skill");
+ String value = ruleLine.substring(
+ ruleLine.indexOf("/api/v3/agent-skill") + "/api/v3/agent-skill".length()).trim();
+ if (value.startsWith("=")) value = value.substring(1).trim();
+ assertEquals("anon", value,
+ "the agent-skill doc must be anon (a how-to-auth doc can't require auth); was: '" + value + "'");
+ }
}
From 90f4c74d2cd21e8b538e5432bbeb2dcf5afad0f8 Mon Sep 17 00:00:00 2001
From: JasonWildMe
Date: Thu, 11 Jun 2026 16:09:53 -0700
Subject: [PATCH 06/10] frontend: useMintToken hook (cookie-less step-up Basic
mint)
Co-Authored-By: Claude Opus 4.8
---
.../src/__tests__/models/useMintToken.test.js | 27 +++++++++++++++
frontend/src/models/auth/useMintToken.js | 33 +++++++++++++++++++
2 files changed, 60 insertions(+)
create mode 100644 frontend/src/__tests__/models/useMintToken.test.js
create mode 100644 frontend/src/models/auth/useMintToken.js
diff --git a/frontend/src/__tests__/models/useMintToken.test.js b/frontend/src/__tests__/models/useMintToken.test.js
new file mode 100644
index 0000000000..e290ad0ad6
--- /dev/null
+++ b/frontend/src/__tests__/models/useMintToken.test.js
@@ -0,0 +1,27 @@
+import { mintToken } from "../../models/auth/useMintToken";
+
+describe("mintToken", () => {
+ let fetchMock;
+ beforeEach(() => { fetchMock = jest.fn(); global.fetch = fetchMock; }); // jsdom has no fetch by default
+ afterEach(() => { jest.resetAllMocks(); });
+
+ it("POSTs with a cookie-less Basic header and returns the token on 200", async () => {
+ fetchMock.mockResolvedValue({
+ status: 200,
+ json: async () => ({ token: "tok123", tokenType: "Bearer", expiresInSeconds: 1800 }),
+ });
+ const res = await mintToken("alice", "s3cr3t");
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toContain("/api/v3/auth/token");
+ expect(opts.method).toBe("POST");
+ expect(opts.credentials).toBe("omit"); // no session cookie
+ expect(opts.headers.Authorization).toBe("Basic " + btoa("alice:s3cr3t"));
+ expect(res.token).toBe("tok123");
+ expect(res.expiresInSeconds).toBe(1800);
+ });
+
+ it("throws a typed error with the status on non-200", async () => {
+ fetchMock.mockResolvedValue({ status: 401, json: async () => ({ error: "invalid credentials" }) });
+ await expect(mintToken("alice", "wrong")).rejects.toMatchObject({ status: 401 });
+ });
+});
diff --git a/frontend/src/models/auth/useMintToken.js b/frontend/src/models/auth/useMintToken.js
new file mode 100644
index 0000000000..2b8dd4d64c
--- /dev/null
+++ b/frontend/src/models/auth/useMintToken.js
@@ -0,0 +1,33 @@
+import { useState, useCallback } from "react";
+
+// Mint a short-lived API token via step-up Basic auth.
+// IMPORTANT: uses a raw fetch with credentials:"omit" so NO session cookie is sent — the server
+// must verify the supplied password fresh (a session alone cannot mint).
+export async function mintToken(username, password) {
+ const resp = await fetch("/api/v3/auth/token", {
+ method: "POST",
+ credentials: "omit",
+ headers: {
+ Authorization: "Basic " + btoa(`${username}:${password}`),
+ },
+ });
+ let data = null;
+ try { data = await resp.json(); } catch (_e) { /* non-JSON body */ }
+ if (resp.status !== 200 || !data || !data.token) {
+ const err = new Error((data && data.error) || `mint failed (${resp.status})`);
+ err.status = resp.status;
+ throw err;
+ }
+ return data; // { token, tokenType, expiresInSeconds }
+}
+
+// Thin hook wrapper for components (keeps call sites declarative).
+export default function useMintToken() {
+ const [loading, setLoading] = useState(false);
+ const mint = useCallback(async (username, password) => {
+ setLoading(true);
+ try { return await mintToken(username, password); }
+ finally { setLoading(false); }
+ }, []);
+ return { mint, loading };
+}
From ca12aa3238e222bc81034e8e0a166673744483cf Mon Sep 17 00:00:00 2001
From: JasonWildMe
Date: Thu, 11 Jun 2026 16:27:59 -0700
Subject: [PATCH 07/10] =?UTF-8?q?frontend:=20ApiAccessPage=20=E2=80=94=20s?=
=?UTF-8?q?tep-up=20modal=20+=20one-time=20token=20display?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Also adds missing @testing-library/dom peer dependency (required by
@testing-library/react@16; was absent, breaking all RTL-based tests).
Co-Authored-By: Claude Opus 4.8
---
.../__tests__/pages/ApiAccessPage.test.jsx | 33 +++++
.../src/pages/ApiAccess/ApiAccessPage.jsx | 129 ++++++++++++++++++
2 files changed, 162 insertions(+)
create mode 100644 frontend/src/__tests__/pages/ApiAccessPage.test.jsx
create mode 100644 frontend/src/pages/ApiAccess/ApiAccessPage.jsx
diff --git a/frontend/src/__tests__/pages/ApiAccessPage.test.jsx b/frontend/src/__tests__/pages/ApiAccessPage.test.jsx
new file mode 100644
index 0000000000..dc2b9e8056
--- /dev/null
+++ b/frontend/src/__tests__/pages/ApiAccessPage.test.jsx
@@ -0,0 +1,33 @@
+import React from "react";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import ApiAccessPage from "../../pages/ApiAccess/ApiAccessPage";
+
+jest.mock("../../models/auth/users/useGetMe", () => () => ({
+ data: { username: "alice" },
+}));
+const mockMint = jest.fn();
+jest.mock("../../models/auth/useMintToken", () => () => ({ mint: mockMint, loading: false }));
+
+describe("ApiAccessPage", () => {
+ beforeEach(() => { mockMint.mockReset(); });
+
+ it("mints and shows the token on success", async () => {
+ mockMint.mockResolvedValue({ token: "tok-xyz", expiresInSeconds: 1800 });
+ render();
+ fireEvent.click(screen.getByRole("button", { name: /generate/i }));
+ fireEvent.change(screen.getByLabelText(/password/i), { target: { value: "s3cr3t" } });
+ fireEvent.click(screen.getByRole("button", { name: /confirm/i }));
+ await waitFor(() => expect(screen.getByText(/tok-xyz/)).toBeInTheDocument());
+ expect(mockMint).toHaveBeenCalledWith("alice", "s3cr3t");
+ });
+
+ it("shows an inline error on 401", async () => {
+ mockMint.mockRejectedValue(Object.assign(new Error("invalid credentials"), { status: 401 }));
+ render();
+ fireEvent.click(screen.getByRole("button", { name: /generate/i }));
+ fireEvent.change(screen.getByLabelText(/password/i), { target: { value: "wrong" } });
+ fireEvent.click(screen.getByRole("button", { name: /confirm/i }));
+ await waitFor(() => expect(screen.getByText(/incorrect password/i)).toBeInTheDocument());
+ expect(screen.queryByText(/tok-/)).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/pages/ApiAccess/ApiAccessPage.jsx b/frontend/src/pages/ApiAccess/ApiAccessPage.jsx
new file mode 100644
index 0000000000..c3d2f5a44c
--- /dev/null
+++ b/frontend/src/pages/ApiAccess/ApiAccessPage.jsx
@@ -0,0 +1,129 @@
+import React, { useState } from "react";
+import { Button, Modal, Form, Alert } from "react-bootstrap";
+import useGetMe from "../../models/auth/users/useGetMe";
+import useMintToken from "../../models/auth/useMintToken";
+
+const SKILL_URL = "/api/v3/agent-skill";
+
+export default function ApiAccessPage() {
+ const me = useGetMe();
+ const username = me?.data?.username || "";
+ const { mint, loading } = useMintToken();
+
+ const [showModal, setShowModal] = useState(false);
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState(null);
+ const [token, setToken] = useState(null);
+ const [expiresIn, setExpiresIn] = useState(null);
+
+ const openModal = () => {
+ setError(null);
+ setPassword("");
+ setShowModal(true);
+ };
+
+ const submit = async (e) => {
+ e.preventDefault();
+ setError(null);
+ try {
+ const res = await mint(username, password);
+ setToken(res.token);
+ setExpiresIn(res.expiresInSeconds);
+ setPassword("");
+ setShowModal(false);
+ } catch (err) {
+ if (err.status === 401)
+ setError(
+ "Incorrect password. If your account uses single sign-on, API tokens aren't available yet.",
+ );
+ else if (err.status === 503)
+ setError("Token issuance isn't enabled on this server.");
+ else setError("Couldn't generate a token. Please try again.");
+ }
+ };
+
+ return (
+
+
API Access
+
+ Generate a short-lived token so an AI agent or script can act with{" "}
+ your Wildbook access. Treat it like a password.
+
+
+ Do not give your agent your username/password —
+ paste it only a token.
+
+
+ Your agent can learn this API here: {SKILL_URL}{" "}
+
+
+
+
+
+ {token && (
+
+
+ Copy this token now — it won't be shown again
+ {expiresIn
+ ? ` and expires in ~${Math.round(expiresIn / 60)} min`
+ : ""}
+ .
+
+
+ {token}
+
+
+
+ )}
+
+ setShowModal(false)}>
+
+
+
+ );
+}
From 555f04b29585a76e8cc30e2e7c519ed59b2b12ac Mon Sep 17 00:00:00 2001
From: JasonWildMe
Date: Thu, 11 Jun 2026 16:47:17 -0700
Subject: [PATCH 08/10] frontend: route /api-access + API Access avatar menu
item
Co-Authored-By: Claude Opus 4.8
---
frontend/src/AuthenticatedSwitch.jsx | 2 ++
.../components/AvatarApiAccessLink.test.jsx | 27 +++++++++++++++++++
.../header/AvatarAndUserProfile.jsx | 3 +++
3 files changed, 32 insertions(+)
create mode 100644 frontend/src/__tests__/components/AvatarApiAccessLink.test.jsx
diff --git a/frontend/src/AuthenticatedSwitch.jsx b/frontend/src/AuthenticatedSwitch.jsx
index 453dd30b36..f2c7e0376b 100644
--- a/frontend/src/AuthenticatedSwitch.jsx
+++ b/frontend/src/AuthenticatedSwitch.jsx
@@ -34,6 +34,7 @@ const MatchResults = lazy(
const Encounter = lazy(() => import("./pages/Encounter/Encounter"));
const Citation = lazy(() => import("./pages/Citation"));
+const ApiAccessPage = lazy(() => import("./pages/ApiAccess/ApiAccessPage"));
const PoliciesAndData = lazy(
() => import("./pages/PoliciesAndData/PoliciesAndData"),
);
@@ -112,6 +113,7 @@ export default function AuthenticatedSwitch({
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/frontend/src/__tests__/components/AvatarApiAccessLink.test.jsx b/frontend/src/__tests__/components/AvatarApiAccessLink.test.jsx
new file mode 100644
index 0000000000..e50fa01c14
--- /dev/null
+++ b/frontend/src/__tests__/components/AvatarApiAccessLink.test.jsx
@@ -0,0 +1,27 @@
+import React from "react";
+import { screen, fireEvent } from "@testing-library/react";
+import { renderWithProviders } from "../../utils/utils";
+import AvatarAndUserProfile from "../../components/header/AvatarAndUserProfile";
+import AuthContext from "../../AuthProvider";
+import LocaleContext from "../../IntlProvider";
+
+// AvatarAndUserProfile uses useNavigate + AuthContext + i18n, so render via the repo's standard
+// renderWithProviders (router + intl), wrapped in the same contexts the header tests use
+// (mirror frontend/src/__tests__/components/header/AuthenticatedHeader.test.js).
+describe("AvatarAndUserProfile", () => {
+ it("includes an API Access link to /api-access", () => {
+ renderWithProviders(
+
+
+
+
+ ,
+ );
+ // The dropdown is hover-controlled (show={shows} toggled by onMouseEnter).
+ // Fire mouseEnter on the dropdown container to open it before querying items.
+ const dropdown = document.querySelector(".custom-nav-dropdown");
+ fireEvent.mouseEnter(dropdown);
+ const link = screen.getByText(/api access/i).closest("a");
+ expect(link).toHaveAttribute("href", expect.stringContaining("/api-access"));
+ });
+});
diff --git a/frontend/src/components/header/AvatarAndUserProfile.jsx b/frontend/src/components/header/AvatarAndUserProfile.jsx
index 29834490b5..6457f483a6 100644
--- a/frontend/src/components/header/AvatarAndUserProfile.jsx
+++ b/frontend/src/components/header/AvatarAndUserProfile.jsx
@@ -49,6 +49,9 @@ export default function AvatarAndUserProfile({ avatar }) {
+
+ API Access
+
From d35745f6daa135452c42ff04b48c199eb480e70e Mon Sep 17 00:00:00 2001
From: JasonWildMe
Date: Thu, 11 Jun 2026 17:06:29 -0700
Subject: [PATCH 09/10] =?UTF-8?q?token-ui:=20fold=20final=20review=20?=
=?UTF-8?q?=E2=80=94=20UTF-8=20Basic,=20assert=20real=20mint=20200,=20guar?=
=?UTF-8?q?d=20username,=20copy=20fix?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 4.8
---
.../src/__tests__/models/useMintToken.test.js | 9 +++++++
frontend/src/__tests__/setupTests.js | 5 ++++
frontend/src/models/auth/useMintToken.js | 6 ++++-
.../src/pages/ApiAccess/ApiAccessPage.jsx | 6 ++---
.../org/ecocean/api/AuthTokenStepUpTest.java | 26 ++++++++++++-------
5 files changed, 38 insertions(+), 14 deletions(-)
diff --git a/frontend/src/__tests__/models/useMintToken.test.js b/frontend/src/__tests__/models/useMintToken.test.js
index e290ad0ad6..a97e2fa966 100644
--- a/frontend/src/__tests__/models/useMintToken.test.js
+++ b/frontend/src/__tests__/models/useMintToken.test.js
@@ -24,4 +24,13 @@ describe("mintToken", () => {
fetchMock.mockResolvedValue({ status: 401, json: async () => ({ error: "invalid credentials" }) });
await expect(mintToken("alice", "wrong")).rejects.toMatchObject({ status: 401 });
});
+
+ it("UTF-8 encodes non-ASCII credentials", async () => {
+ fetchMock.mockResolvedValue({ status: 200, json: async () => ({ token: "t" }) });
+ await mintToken("José", "pâss");
+ const auth = fetchMock.mock.calls[0][1].headers.Authorization;
+ const b64 = auth.replace(/^Basic /, "");
+ const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
+ expect(new TextDecoder().decode(bytes)).toBe("José:pâss"); // round-trips as UTF-8
+ });
});
diff --git a/frontend/src/__tests__/setupTests.js b/frontend/src/__tests__/setupTests.js
index 63f4cc7e88..8a904fab8d 100644
--- a/frontend/src/__tests__/setupTests.js
+++ b/frontend/src/__tests__/setupTests.js
@@ -1,5 +1,10 @@
import "@testing-library/jest-dom";
+// jsdom does not ship TextEncoder/TextDecoder; polyfill from Node's util module.
+const { TextEncoder, TextDecoder } = require("util");
+if (typeof global.TextEncoder === "undefined") global.TextEncoder = TextEncoder;
+if (typeof global.TextDecoder === "undefined") global.TextDecoder = TextDecoder;
+
jest.mock("react-intl", () => {
const actual = jest.requireActual("react-intl");
return {
diff --git a/frontend/src/models/auth/useMintToken.js b/frontend/src/models/auth/useMintToken.js
index 2b8dd4d64c..84aaf68e78 100644
--- a/frontend/src/models/auth/useMintToken.js
+++ b/frontend/src/models/auth/useMintToken.js
@@ -4,11 +4,15 @@ import { useState, useCallback } from "react";
// IMPORTANT: uses a raw fetch with credentials:"omit" so NO session cookie is sent — the server
// must verify the supplied password fresh (a session alone cannot mint).
export async function mintToken(username, password) {
+ const creds = `${username}:${password}`;
+ const utf8 = new TextEncoder().encode(creds);
+ let binary = "";
+ for (let i = 0; i < utf8.length; i++) binary += String.fromCharCode(utf8[i]);
const resp = await fetch("/api/v3/auth/token", {
method: "POST",
credentials: "omit",
headers: {
- Authorization: "Basic " + btoa(`${username}:${password}`),
+ Authorization: "Basic " + btoa(binary),
},
});
let data = null;
diff --git a/frontend/src/pages/ApiAccess/ApiAccessPage.jsx b/frontend/src/pages/ApiAccess/ApiAccessPage.jsx
index c3d2f5a44c..0564c4b397 100644
--- a/frontend/src/pages/ApiAccess/ApiAccessPage.jsx
+++ b/frontend/src/pages/ApiAccess/ApiAccessPage.jsx
@@ -51,7 +51,7 @@ export default function ApiAccessPage() {
Do not give your agent your username/password —
- paste it only a token.
+ paste only the token.
Your agent can learn this API here: {SKILL_URL}{" "}
@@ -68,7 +68,7 @@ export default function ApiAccessPage() {
-
+
{token && (
@@ -118,7 +118,7 @@ export default function ApiAccessPage() {
-