Plugins

Public API

Create the api_keys table, key management UI, and public API routes for external integrations
Prerequisite: Complete the Get Started guide first.

To install this plugin, copy the first prompt, paste it into Claude Code (or Codex, or any AI coding tool), let it finish, then run the next prompt.

API Keys

Build the API key management system so external services can authenticate
with the app.

Permissions:

Add these keys to `shared/permissions.ts` if they don't already exist:

```
"api_keys.view": ["owner", "admin"],
"api_keys.create": ["owner", "admin"],
"api_keys.revoke": ["owner", "admin"],
```

Database (via Supabase MCP):

Create an `api_keys` table — id (uuid, default gen_random_uuid(), primary
key), team_id (uuid, references teams(id) on delete cascade, not null),
created_by (uuid, references profiles(id), not null), name (text, not null),
key_prefix (text, not null), key_hash (text, not null), last_used_at
(timestamptz, nullable), expires_at (timestamptz, nullable), revoked_at
(timestamptz, nullable), created_at (timestamptz, default now()).

Enable RLS. Add a SELECT-only policy using `is_team_member()` scoped to
`team_id` — follow the `announcements_team_member_read` pattern in
`supabase/migrations/00001_initial_schema.sql`. Writes go through the
service-role server routes below, so no insert/update/delete RLS policies
are needed.

Enable the activity log for the table, excluding the secret hash:

```sql
select enable_activity_log('api_keys', exclude_cols => array['key_hash']);
```

Server routes (all use `authUser`):

- `GET /api/teams/[teamId]/api-keys` — uses
  `authUser(event, "api_keys.view")`. Returns all API keys for the team
  (NEVER return key_hash in the response), ordered by created_at desc.
  Include the creator's name by joining profiles.

- `POST /api/teams/[teamId]/api-keys` — uses
  `authUser(event, "api_keys.create")`. Reads { name } from the body.
  Generates a new API key:
  1. Generate 32 random bytes using Node's `crypto.randomBytes`
  2. Base64url encode the bytes to create the raw key string
  3. Create a prefix from the first 8 characters of the encoded key
  4. Hash the full key with SHA-256 using Node's `crypto.createHash`
  5. Convert the hash to a hex string
  6. Store team_id, name, key_prefix, key_hash, and created_by
  7. Return the full key in the response — this is the ONLY time it is
     ever visible. The server never stores the raw key.

- `PATCH /api/teams/[teamId]/api-keys/[keyId]` — uses
  `authUser(event, "api_keys.revoke")`. Sets revoked_at to now. Validates
  the key belongs to the team and is not already revoked.

UI:

Add an "API Keys" tab to the settings page. Create
`app/pages/app/settings/api-keys.vue` (settings uses a `settings.vue` parent
with file-based child routes) and register the tab in the `links` computed
in `app/pages/app/settings.vue`. Gate the tab with `can("api_keys.view")` so
only owners/admins see it. Use `definePageMeta({ middleware:
[requirePermission("api_keys.view", "/app/settings")] })` on the page itself.

The API Keys settings tab should:

- List all keys in a table showing: name, prefix (displayed as
  `sk_...{prefix}`), created by (name), created date, last used date
  (or "Never"), and status. Status is a badge: "Active" (green) if not
  revoked and not expired, "Revoked" (red) if revoked_at is set,
  "Expired" (neutral) if expires_at is in the past.

- A "Create Key" button at the top right, wrapped in
  `CanAccess permission="api_keys.create"`. Opens a `UModal` with a
  single field: key name (required). Show loading on submit.

- After creation, show a second modal displaying the full API key with
  a copy-to-clipboard button and a warning message: "Copy this key now.
  You will not be able to see it again." The modal should only be
  dismissible after the user has seen the key.

- Each active key row has a "Revoke" button wrapped in
  `CanAccess permission="api_keys.revoke"`. Clicking it shows a
  confirmation modal before revoking. Show per-row loading state.

- Show an empty state when no keys exist with a message like
  "No API keys yet. Create one to allow external services to access
  your data."

- Show `USkeleton` placeholders on initial load.

Use the icon `i-solar-key-bold-duotone` for the settings tab entry.

Regenerate TypeScript types via Supabase MCP and save to
`shared/types/database.types.ts`.

External API

Build the external API so tools like n8n, Zapier, or custom integrations
can interact with the app's data programmatically.

1. Create `server/utils/authApiKey.ts` with an `authApiKey` function that
   takes an H3 event. It should:

   - Read the `Authorization` header and extract the Bearer token — throw
     a 401 error if the header is missing or not in `Bearer <token>` format
   - Hash the token with SHA-256 using Node's `crypto.createHash` (same
     approach as the key generation step) and convert to hex
   - Look up the hash in the `api_keys` table using an untagged service
     role client (`serverSupabaseServiceRole<Database>(event)`)
   - Throw 401 if not found
   - Throw 401 if `revoked_at` is set (key has been revoked)
   - Throw 401 if `expires_at` is set and is in the past (key has expired)
   - Update `last_used_at` to now (fire and forget — do not await)
   - Build an audited client with `createAuditedClient({ actorId:
     apiKey.created_by, teamId: apiKey.team_id, source: "api" })` so every
     write from an API-key request is attributed in the activity log the
     same way browser writes are
   - Return `{ apiKey, client }` where `client` is the audited client and
     `apiKey` is the full row including team_id and created_by

2. Create public API routes under `/api/v1/`. These routes do NOT use
   `authUser` — they use `authApiKey` instead. All queries are scoped to
   the API key's `team_id`.

   Analyze the app's existing database tables (exclude system tables like
   teams, profiles, members, invitations, api_keys, rate_limits, and
   schema_migrations). For each domain table found, create:

   - `GET /api/v1/{table}` — returns all records for the key's team,
     ordered by created_at desc. Support `?limit=` and `?offset=` for
     pagination (default limit 50, max 100).

   - `POST /api/v1/{table}` — creates a record. Accept all non-system
     columns from the body. Set team_id from the API key and created_by
     from the API key's created_by. Validate that required columns (not
     null without defaults) are present.

   - `PATCH /api/v1/{table}/[id]` — updates a record. Validate the record
     belongs to the API key's team before updating.

   Include relevant joins where foreign keys exist (e.g., if a table
   references another by ID, include the referenced name in GET responses).

Both `authUser` (browser sessions) and `authApiKey` (API keys) return an
audited Supabase client built with `createAuditedClient`. The only
difference is how the caller authenticates. This is what makes API keys
useful — external services can manage data without needing a browser
session, and every write still lands in `activity_log` with the key's
creator as the actor.

Error responses for the v1 routes should use a consistent JSON format:
`{ "error": "message" }` with appropriate HTTP status codes (400 for
validation errors, 401 for auth errors, 404 for not found).
Testing with curlOnce built, you can test the external API with curl:
# List records
curl -H "Authorization: Bearer YOUR_API_KEY" http://localhost:3000/api/v1/your-table

# Create a record
curl -X POST -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Example", "email": "test@example.com"}' \
  http://localhost:3000/api/v1/your-table