# Node Portal — API Design

> **Parent:** [00-overview.md](00-overview.md)

---

## 1. Endpoint Summary

| Method | Path | Auth | Description |
|---|---|---|---|
| `GET` | `/` | None | Serve portal HTML (resolved template or default) |
| `GET` | `/v1/site` | None | Portal metadata JSON |
| `GET` | `/v1/site/template` | Operator | Download current template HTML |
| `POST` | `/v1/site/template` | Operator | Upload new template HTML |
| `DELETE` | `/v1/site/template` | Operator | Delete custom template (revert to default) |
| `POST` | `/v1/site/import` | Operator | Import portal bundle (template + memory + KV) |
| `GET` | `/v1/site/changelog` | Operator | View portal change log |
| `POST` | `/v1/site/cache-invalidate` | Operator | Force cache refresh |
| `GET` | `/v1/site/prompt` | None | AI navigation prompt for portal |

All JSON endpoints use standard AIMEAT envelope via `success()` / `error()`.

---

## 2. GET / — Portal Page

Serves the portal HTML to the browser. This is the node's public face.

**Behavior:**
1. Check if custom template exists in storage (`__site_template__`)
2. If yes → resolve `{{...}}` template tags → serve HTML
3. If no → serve existing default portal (`humanPortalHtml()`)

**Content Negotiation:**
- `Accept: text/html` (browsers) → HTML portal
- `Accept: application/json` → redirect to `GET /v1/site`

**Response:** Raw HTML (not wrapped in AIMEAT envelope).

**Caching:** Resolved HTML cached for 60s (configurable). Cache header: `Cache-Control: public, max-age=60`.

---

## 3. GET /v1/site — Portal Metadata

JSON summary of the portal for API clients and agents.

**Response 200:**
```json
{
  "ok": true,
  "node": "my-node-001",
  "data": {
    "node_id": "my-node-001",
    "node_type": "full",
    "base_url": "http://85.132.44.21:40050",
    "has_custom_template": true,
    "kv": {
      "region": "Oulu",
      "contact": "timo@example.fi",
      "tagline": "AI-agenttien koti pohjoisessa"
    },
    "template_updated_at": "2026-03-01T10:00:00.000Z",
    "cache_ttl_seconds": 60
  },
  "hints": {
    "next_actions": [
      { "description": "View the portal", "method": "GET", "url": "/" },
      { "description": "Get AI prompt", "method": "GET", "url": "/v1/site/prompt" },
      { "description": "View action catalogue", "method": "GET", "url": "/v1/actions/catalogue" }
    ]
  }
}
```

---

## 4. GET /v1/site/template — Download Template

**Auth:** `requireAuth()` + `requireRole('operator')`

Returns the raw HTML template with unresolved tags.

**Response 200:**
```json
{
  "ok": true,
  "data": {
    "template": "<!DOCTYPE html>\n<html>\n<head>...</head>\n<body>\n<h1>{{kv:tagline}}</h1>\n<p>{{memory:portal/welcome}}</p>\n<img src=\"{{storage:logo-uuid}}\">\n...</body>\n</html>",
    "size_bytes": 12480,
    "updated_at": "2026-03-01T10:00:00.000Z",
    "tags_found": ["config:nodeId", "memory:portal/welcome", "storage:logo-uuid", "kv:tagline"]
  }
}
```

**Response 404** (no custom template):
```json
{
  "ok": false,
  "error": { "code": "NO_TEMPLATE", "message": "No custom template. Default portal is active." }
}
```

---

## 5. POST /v1/site/template — Upload Template

**Auth:** `requireAuth()` + `requireRole('operator')`

**Request Body:**
```json
{
  "template": "<!DOCTYPE html>\n<html>...\n<h1>{{kv:tagline}}</h1>\n<p>{{memory:portal/welcome}}</p>\n...</html>"
}
```

**Validation:**
- Template must be valid HTML (starts with `<!DOCTYPE` or `<html`)
- Size ≤ `siteMaxTemplateSizeKb` (default 512KB)
- No `<script>` tags containing `{{memory:*}}` (prevent script injection via memory)
- Template tags syntax-checked (well-formed `{{type:key}}`)

**Response 200:**
```json
{
  "ok": true,
  "data": {
    "stored": true,
    "size_bytes": 12480,
    "tags_found": ["config:nodeId", "memory:portal/welcome", "storage:logo-uuid", "kv:tagline"],
    "unresolvable_tags": ["memory:portal/nonexistent"]
  }
}
```

Unresolvable tags are warnings, not errors — the operator might write those memory keys later.

Cache is invalidated automatically.

---

## 6. DELETE /v1/site/template — Revert to Default

**Auth:** `requireAuth()` + `requireRole('operator')`

Deletes the custom template. Portal reverts to the default (`portal-human.ts`).

**Response 200:**
```json
{
  "ok": true,
  "data": { "deleted": true, "reverted_to": "default" }
}
```

---

## 7. POST /v1/site/import — Import Portal Bundle

**Auth:** `requireAuth()` + `requireRole('operator')`

Import a bundle generated by the AI-assisted editor. Contains template + memory writes + KV pairs.

**Request Body:**
```json
{
  "template": "<!DOCTYPE html>...",
  "memory": {
    "portal/welcome": "Tervetuloa Oulun AIMEAT-nodeen!",
    "portal/services": "<ul><li>Puheentunnistus</li><li>Kuvien generointi</li></ul>",
    "portal/about": "Timo pyörittää tätä nodea Oulusta käsin."
  },
  "kv": {
    "region": "Oulu",
    "contact": "timo@example.fi"
  }
}
```

**What It Does:**
1. Validates template (same rules as `POST /v1/site/template`)
2. Stores template in storage
3. Writes each memory key (as operator's memory with `portal/*` namespace)
4. Updates KV config values
5. Invalidates cache
6. Logs all changes

**Response 200:**
```json
{
  "ok": true,
  "data": {
    "template_stored": true,
    "memory_keys_written": 3,
    "kv_pairs_updated": 2,
    "changelog_entry_id": "log-uuid"
  }
}
```

**Fields are optional** — you can import just memory keys without changing the template:
```json
{
  "memory": {
    "portal/welcome": "Updated welcome text"
  }
}
```

---

## 8. GET /v1/site/changelog — Change Log

**Auth:** `requireAuth()` + `requireRole('operator')`

**Query:** `limit` (default 20), `cursor`.

**Response 200:**
```json
{
  "ok": true,
  "data": [
    {
      "id": "log-1",
      "action": "import",
      "summary": "Imported portal bundle: template + 3 memory keys + 2 KV pairs",
      "changed_by": "gaii://my-node/owners/timo",
      "changed_at": "2026-03-03T12:00:00.000Z"
    },
    {
      "id": "log-2",
      "action": "template_upload",
      "summary": "Updated portal template (12.4 KB)",
      "changed_by": "gaii://my-node/owners/timo",
      "changed_at": "2026-03-02T08:00:00.000Z"
    }
  ]
}
```

---

## 9. POST /v1/site/cache-invalidate — Force Refresh

**Auth:** `requireAuth()` + `requireRole('operator')`

Clears the resolved HTML cache so next `GET /` fetches fresh data from memory/storage.

**Response 200:**
```json
{ "ok": true, "data": { "cache_cleared": true } }
```

---

## 10. GET /v1/site/prompt — AI Navigation Prompt

Returns an instruction prompt for AI agents to understand portal content.

**Response 200:**
```json
{
  "ok": true,
  "data": {
    "prompt": "You are connected to AIMEAT node \"{nodeId}\". This node has a portal at GET /. The operator has configured: {kv summary}. Portal content is served from memory keys under portal/* namespace. To view the portal data: GET /v1/site. To browse available actions: GET /v1/actions/catalogue."
  }
}
```

---

## 11. Board System: `system` Visibility

Same design as before — extending board visibility:

| Operation | `system` board |
|---|---|
| Create board | Operator only |
| Post to board | Operator only |
| Read posts | Everyone |
| Reactions | Authenticated users |
| Morsel cost | None |

Route changes in `boards.ts`:
```typescript
// POST /v1/boards — create with system visibility
if (body.visibility === 'system' && !req.auth?.roles?.includes('operator'))
  return res.status(403).json(error(config.nodeId, 'FORBIDDEN', 'Only operators can create system boards'));

// POST /v1/boards/:boardId/posts — post to system board
if (board.visibility === 'system' && !req.auth?.roles?.includes('operator'))
  return res.status(403).json(error(config.nodeId, 'FORBIDDEN', 'Only operators can post to system boards'));

// Skip morsel cost for system boards
if (board.visibility === 'system') { /* no morsel deduction */ }
```

---

## 12. MCP Tool

**Tool #19: `aimeat_site_browse`**

```typescript
{
  name: 'aimeat_site_browse',
  description: 'Browse the node portal — metadata, KV pairs, available content',
  inputSchema: {
    type: 'object',
    properties: {
      action: { type: 'string', enum: ['info', 'prompt'] },
    },
    required: ['action'],
  },
}
```

---

## 13. Error Codes

| Code | HTTP | When |
|---|---|---|
| `NO_TEMPLATE` | 404 | No custom template exists |
| `TEMPLATE_TOO_LARGE` | 422 | Template exceeds size limit |
| `TEMPLATE_INVALID` | 422 | Not valid HTML or contains forbidden patterns |
| `IMPORT_INVALID` | 422 | Malformed import bundle |
