REST API, keys and extension
Call the workspace API, manage credentials and deliberately grant access to a new domain.
What the API exposes
The included REST API exposes two workspace operations. GET /api/v1/workspace reads the connected workspace; PATCH /api/v1/workspace changes its display name. Keys are tied to one workspace and a current owner/admin. They do not provide arbitrary database access, member management, billing control or AI access.
Use this interface for another server or script. Dashboard pages continue using signed-in, user-scoped database access. Never embed a workspace key into browser JavaScript or a public environment variable.
Create, expire and revoke keys
In the dashboard, open Settings → API keys while the correct workspace is selected. An owner/admin can enter a descriptive name, choose Read only or Read and write, and choose 30, 90, 365 days or Never. The default is 90 days. Choose the shortest useful lifetime; Never means you must rotate it deliberately.
The complete key is shown once when created. Save it in the calling service's secret store. Later listings show a prefix and metadata, not the recoverable secret. A workspace supports up to 20 active keys. Revoke unused keys before creating more. Revocation takes effect on subsequent authorization checks; an already authorized request can finish.
Membership removal or demotion to member revokes the creator's keys. Restoring their role does not revive old credentials. Create a replacement only when access is intended again. Switching the dashboard workspace never changes an existing key's target.
Read and rename with curl
Set API_BASE_URL to your own application origin and WORKSPACE_API_KEY through your shell's secret facility. These are variables for your client example, not new template .env requirements. The following is POSIX shell syntax; in PowerShell use $env:API_BASE_URL and $env:WORKSPACE_API_KEY, and call curl.exe explicitly.
curl --fail-with-body "$API_BASE_URL/api/v1/workspace" \
--header "Authorization: Bearer $WORKSPACE_API_KEY"
curl --fail-with-body --request PATCH "$API_BASE_URL/api/v1/workspace" \
--header "Authorization: Bearer $WORKSPACE_API_KEY" \
--header "Content-Type: application/json" \
--data '{"name":"Field Notes"}'The following is a synthetic response showing the exact shape, not a captured production request:
{
"data": {
"id": "10000000-0000-4000-8000-000000000001",
"name": "Field Notes",
"slug": "field-notes",
"created_at": "2026-09-15T12:00:00Z"
}
}Rename returns data with id, name and slug; it omits created_at. It preserves the ID and slug. The name is trimmed and must contain 2–80 characters. The strict input schema accepts only name: sending org_id, a billing field or an unknown property is rejected. The request body limit is 2,048 bytes.
TypeScript client
This server-side example validates the response with Zod. Replace its origin and supply the key through the calling server's environment. Do not log request headers.
import { z } from 'zod';
const responseSchema = z.object({
data: z.object({
id: z.uuid(), name: z.string(), slug: z.string(), created_at: z.string(),
}),
});
export async function readWorkspace() {
const origin = process.env.API_BASE_URL;
const key = process.env.WORKSPACE_API_KEY;
if (!origin || !key) throw new Error('Configure API_BASE_URL and WORKSPACE_API_KEY.');
const response = await fetch(new URL('/api/v1/workspace', origin), {
headers: { Authorization: `Bearer ${key}` }, cache: 'no-store',
});
if (!response.ok) throw new Error(`Workspace request failed (${response.status}).`);
return responseSchema.parse(await response.json()).data;
}Permissions, errors and limits
| Status | Meaning | Safe response |
|---|---|---|
| 401 | Missing, invalid, expired or revoked credential; current membership no longer permits it | Check the intended key and workspace; replace only through an authorized account |
| 403 | Valid credential lacks write permission or an operation is unavailable | Use a deliberately granted capability; do not retry as an elevated dashboard user |
| 400 / 413 | Invalid JSON, invalid schema or oversized body | Correct the input before retrying |
| 429 | Credential request window exhausted | Observe Retry-After: 60; use bounded backoff |
| 503 | Required service or operation unavailable | Retry with backoff; record status and request ID without the token |
| 404 | API disabled through config/integrations.json | Confirm the feature is enabled in the deployed source |
| 409 | Rename cannot be completed in the current state | Refresh the workspace and retry deliberately; preserve the original error context |
The current limiter permits 60 requests per credential per minute. JSON responses use Cache-Control: no-store and an X-Request-Id. Error bodies contain error and code. A successful GET is safe to retry. Rename is idempotent for the same target/name, but concurrent renames can still overwrite the display name in arrival order.
Schemas and OpenAPI
lib/integrations/schema.ts defines get_workspace and rename_workspace once for the service and MCP tools. lib/integrations/workspace.ts executes them; lib/integrations/auth.ts resolves the current principal. Inspect your app's /api/openapi for the release's machine-readable REST contract. The seller manual has no live connection to that endpoint.
When you change an input or output, update the shared schema, route/OpenAPI contract, integration tests and public product docs together. Do not broaden an existing operation's output to unrelated customer data just because its caller already has a workspace key.
Extend with workspace notes
Start with workspace notes. This optional extension adds read-only access to the latest 50 notes through a new endpoint. Existing keys and OAuth connections receive no new access by default. It works with either base note creation or the optional paid-create variant; it never adds external write access.
- Add the following versioned migration after the base notes migration. It stores a separate grant for one concrete API key or OAuth connection.
- Add the complete service, route and dashboard files in the table below.
- Apply migrations on the authorized hosted test target using the current runner, then typecheck and test the acceptance matrix below.
- In
/dashboard/notes-access, deliberately grant the new read capability to one credential. The page lists identifiers without revealing secret values.
| Download | Destination in your local project |
|---|---|
| Grant migration | supabase/migrations/20260916090200_notes_grants.sql |
| Notes integration service | lib/integrations/notes.ts |
| REST route | app/api/v1/notes/route.ts |
| Updated OpenAPI route | app/api/openapi/route.ts |
| Grant action | app/actions/notes-access.ts |
| Grant form | components/notes/notes-grant-form.tsx |
| Credential selection page | app/dashboard/notes-access/page.tsx |
-- Optional notes read capability. Existing keys/connections receive no grants.
create table private.notes_read_grants (
id uuid primary key default gen_random_uuid(),
org_id uuid not null references public.organizations(id) on delete cascade,
api_key_id uuid unique references private.api_keys(id) on delete cascade,
connection_id uuid unique references private.mcp_connections(id) on delete cascade,
check (num_nonnulls(api_key_id, connection_id) = 1)
);
alter table private.notes_read_grants enable row level security;
revoke all on private.notes_read_grants from public, anon, authenticated, service_role;
create function public.set_notes_read_access(target uuid, credential uuid, credential_kind text, enabled boolean)
returns void language plpgsql security definer set search_path='' as $$
begin
if not private.can_manage_org(target) or auth.jwt()->>'client_id' is not null or enabled is null then raise exception 'Not permitted'; end if;
if credential_kind='api' then
if not exists(select 1 from private.api_keys where id=credential and org_id=target and revoked_at is null and (expires_at is null or expires_at>now())) then raise exception 'Credential unavailable'; end if;
if enabled then
insert into private.notes_read_grants(org_id,api_key_id) values(target,credential) on conflict(api_key_id) do nothing;
else delete from private.notes_read_grants where org_id=target and api_key_id=credential; end if;
elsif credential_kind='oauth' then
if not exists(select 1 from private.mcp_connections where id=credential and org_id=target and user_id=auth.uid() and revoked_at is null) then raise exception 'Credential unavailable'; end if;
if enabled then
insert into private.notes_read_grants(org_id,connection_id) values(target,credential) on conflict(connection_id) do nothing;
else delete from private.notes_read_grants where org_id=target and connection_id=credential; end if;
else raise exception 'Invalid credential kind'; end if;
end $$;
revoke all on function public.set_notes_read_access(uuid,uuid,text,boolean) from public,anon,service_role;
grant execute on function public.set_notes_read_access(uuid,uuid,text,boolean) to authenticated;
create function public.notes_has_read_access(target uuid, credential uuid)
returns boolean language sql stable security definer set search_path='' as $$
select exists(
select 1 from private.notes_read_grants g
left join private.api_keys k on k.id=g.api_key_id
left join private.mcp_connections c on c.id=g.connection_id
join public.organization_members m on m.org_id=g.org_id and m.user_id=coalesce(k.created_by,c.user_id)
join auth.users u on u.id=m.user_id
where g.org_id=target and m.role in ('owner','admin') and u.email_confirmed_at is not null
and ((k.id=credential and k.revoked_at is null and (k.expires_at is null or k.expires_at>now()))
or (c.id=credential and c.revoked_at is null))
);
$$;
create function public.read_workspace_notes(target uuid, credential uuid)
returns jsonb language plpgsql security definer set search_path='' as $$
begin
if not public.notes_has_read_access(target,credential) then raise exception 'Notes access not granted'; end if;
return coalesce((select jsonb_agg(row_to_json(n) order by n.created_at desc,n.id)
from (select id,body,created_at from public.workspace_notes where org_id=target order by created_at desc,id limit 50) n),'[]'::jsonb);
end $$;
revoke all on function public.notes_has_read_access(uuid,uuid),public.read_workspace_notes(uuid,uuid) from public,anon,authenticated;
grant execute on function public.notes_has_read_access(uuid,uuid),public.read_workspace_notes(uuid,uuid) to service_role;
The grant action uses a verified dashboard session and user-scoped RPC. Only current owners/admins may change grants. An OAuth connection must also belong to the approving user; an OAuth bearer cannot grant itself more access. The server-only read RPC rechecks current membership, verification, expiration and revocation, and always filters by the authorized workspace. The service rejects legacy principals without a concrete credential ID.
import 'server-only';
import { z } from 'zod';
import { createAdminClient } from '@/lib/supabase/admin';
import type { Principal } from '@/lib/integrations/auth';
import { IntegrationError } from '@/lib/integrations/http';
export const notesOutput = z.object({ notes: z.array(z.object({ id: z.uuid(), body: z.string(), created_at: z.string() })).max(50) });
export async function canReadNotes(auth: Principal) {
if (!auth.credential_id) return false;
const { data, error } = await createAdminClient().rpc('notes_has_read_access', { target: auth.org_id, credential: auth.credential_id });
if (error) throw new IntegrationError(503, 'notes_unavailable', 'Notes access could not be checked.');
return data === true;
}
export async function readNotes(auth: Principal) {
if (!await canReadNotes(auth)) throw new IntegrationError(403, 'notes_scope_required', 'Grant workspace notes read access in the dashboard.');
const { data, error } = await createAdminClient().rpc('read_workspace_notes', { target: auth.org_id, credential: auth.credential_id });
if (error) throw new IntegrationError(403, 'notes_unavailable', 'Notes access is no longer available.');
const parsed = notesOutput.safeParse({ notes: data });
if (!parsed.success) throw new IntegrationError(503, 'notes_unavailable', 'Notes response is unavailable.');
return parsed.data;
}
import config from '@/config/integrations.json';
import { authorize } from '@/lib/integrations/auth';
import { errorResponse, json } from '@/lib/integrations/http';
import { readNotes } from '@/lib/integrations/notes';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export async function GET(request: Request) {
if (!config.apiEnabled) return new Response(null, { status: 404 });
try {
const auth = await authorize(request, 'read');
return json({ data: await readNotes(auth) });
} catch (error) { return errorResponse(error); }
}
GET /api/v1/notes returns { "data": { "notes": [] } } when the granted workspace has no notes. Entries have only id, body and created_at. Call it with the same bearer header as the workspace endpoint. No client-provided workspace ID is accepted. Remove the grant in the dashboard to stop future note reads while preserving the original workspace API permission.
The complete OpenAPI replacement in the file map preserves the existing workspace operations and documents the new endpoint using the same notesOutput schema. Review/merge it with any OpenAPI changes you already made. It declares the bearer requirement, explicit-grant requirement, 50-row bound and error responses. The tutorial's new contract is not installed in an unchanged starter release.
Verify the extension
Check an old key gets 403 for notes; a granted key reads only its workspace; a foreign credential ID cannot receive the grant; a member cannot grant access; and revoked/expired/demoted credentials fail. Test removal of just the notes grant, empty data, 50-row truncation, invalid input and service failure. Repeat for an OAuth connection through MCP.
The sample has local source/type verification. Its privileged SQL functions and hosted authorization paths require execution on a dedicated authorized installation before release. No hosted grant or live request is represented as completed by this manual's build.
Template 0.0.1 · September 2026 source snapshot