added editing oauth clients

This commit is contained in:
Swifty
2025-12-09 17:53:15 +01:00
parent 2a4d474ca4
commit 0d0c426209
13 changed files with 2633 additions and 18 deletions

View File

@@ -238,13 +238,14 @@ async def execute_agent(
detail="Webhook URL not in allowed domains for this client",
)
# Store webhook registration
# Store webhook registration with client's webhook secret
await prisma.executionwebhook.create(
data={ # type: ignore[typeddict-item]
"executionId": graph_exec.id,
"webhookUrl": request.webhook_url,
"clientId": client.id,
"userId": token.user_id,
"secret": client.webhookSecret,
}
)
logger.info(

View File

@@ -16,6 +16,7 @@ import secrets
from autogpt_libs.auth import get_user_id
from fastapi import APIRouter, HTTPException, Security
from prisma.enums import OAuthClientStatus
from pydantic import BaseModel
from backend.data.db import prisma
from backend.server.oauth.models import (
@@ -39,6 +40,11 @@ def _generate_client_secret() -> str:
return secrets.token_urlsafe(32)
def _generate_webhook_secret() -> str:
"""Generate a secure webhook secret for HMAC signing."""
return secrets.token_urlsafe(32)
def _hash_secret(secret: str, salt: str) -> str:
"""Hash a client secret with salt."""
return hashlib.sha256(f"{salt}{secret}".encode()).hexdigest()
@@ -59,7 +65,7 @@ def _client_to_response(client) -> ClientResponse:
redirect_uris=client.redirectUris,
allowed_scopes=client.allowedScopes,
webhook_domains=client.webhookDomains,
status=client.status.value,
status=client.status,
created_at=client.createdAt,
updated_at=client.updatedAt,
)
@@ -87,6 +93,7 @@ async def register_client(
The client is immediately active (no admin approval required).
For confidential clients, the client_secret is returned only once.
The webhook_secret is always generated and returned only once.
"""
# Generate client credentials
client_id = _generate_client_id()
@@ -99,6 +106,9 @@ async def register_client(
client_secret_salt = secrets.token_urlsafe(16)
client_secret_hash = _hash_secret(client_secret, client_secret_salt)
# Generate webhook secret for HMAC signing
webhook_secret = _generate_webhook_secret()
# Create client
await prisma.oauthclient.create(
data={ # type: ignore[typeddict-item]
@@ -121,6 +131,7 @@ async def register_client(
"redirectUris": request.redirect_uris,
"allowedScopes": DEFAULT_ALLOWED_SCOPES,
"webhookDomains": request.webhook_domains,
"webhookSecret": webhook_secret,
"status": OAuthClientStatus.ACTIVE,
"ownerId": user_id,
}
@@ -129,6 +140,7 @@ async def register_client(
return ClientSecretResponse(
client_id=client_id,
client_secret=client_secret or "",
webhook_secret=webhook_secret,
)
@@ -236,6 +248,7 @@ async def rotate_client_secret(
Rotate the client secret for a confidential client.
The new secret is returned only once. All existing tokens remain valid.
Also rotates the webhook secret for security.
"""
client = await prisma.oauthclient.find_first(
where={"clientId": client_id, "ownerId": user_id}
@@ -250,22 +263,65 @@ async def rotate_client_secret(
detail="Cannot rotate secret for public clients",
)
# Generate new secret
# Generate new secrets
new_secret = _generate_client_secret()
new_salt = secrets.token_urlsafe(16)
new_hash = _hash_secret(new_secret, new_salt)
new_webhook_secret = _generate_webhook_secret()
await prisma.oauthclient.update(
where={"id": client.id},
data={
"clientSecretHash": new_hash,
"clientSecretSalt": new_salt,
"webhookSecret": new_webhook_secret,
},
)
return ClientSecretResponse(
client_id=client_id,
client_secret=new_secret,
webhook_secret=new_webhook_secret,
)
class WebhookSecretResponse(BaseModel):
"""Response containing newly generated webhook secret."""
client_id: str
webhook_secret: str
@client_router.post(
"/{client_id}/rotate-webhook-secret", response_model=WebhookSecretResponse
)
async def rotate_webhook_secret(
client_id: str,
user_id: str = Security(get_user_id),
) -> WebhookSecretResponse:
"""
Rotate only the webhook secret for a client.
The new webhook secret is returned only once.
"""
client = await prisma.oauthclient.find_first(
where={"clientId": client_id, "ownerId": user_id}
)
if not client:
raise HTTPException(status_code=404, detail="Client not found")
# Generate new webhook secret
new_webhook_secret = _generate_webhook_secret()
await prisma.oauthclient.update(
where={"id": client.id},
data={"webhookSecret": new_webhook_secret},
)
return WebhookSecretResponse(
client_id=client_id,
webhook_secret=new_webhook_secret,
)

View File

@@ -236,12 +236,16 @@ class ClientResponse(BaseModel):
class ClientSecretResponse(BaseModel):
"""Response containing newly generated client secret."""
"""Response containing newly generated client credentials."""
client_id: str
client_secret: str = Field(
..., description="Client secret (only shown once, store securely)"
)
webhook_secret: str = Field(
...,
description="Webhook secret for HMAC signing (only shown once, store securely)",
)
# ============================================================

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "platform"."OAuthClient" ADD COLUMN "webhookSecret" TEXT;

View File

@@ -1008,6 +1008,7 @@ model OAuthClient {
redirectUris String[]
allowedScopes String[]
webhookDomains String[] // For webhook URL validation
webhookSecret String? // Secret for HMAC signing webhooks
// Security
requirePkce Boolean @default(true)

View File

@@ -36,6 +36,7 @@ export function OAuthClientModals() {
handleCreateClient,
handleCopyClientId,
handleCopyClientSecret,
handleCopyWebhookSecret,
resetForm,
} = useOAuthClientModals();
@@ -211,12 +212,12 @@ export function OAuthClientModals() {
</Dialog>
<Dialog open={isSecretDialogOpen} onOpenChange={setIsSecretDialogOpen}>
<DialogContent>
<DialogContent className="sm:max-w-[525px]">
<DialogHeader>
<DialogTitle>OAuth Client Created</DialogTitle>
<DialogDescription>
Please copy your client credentials now. The client secret will
not be shown again!
Please copy your client credentials now. These secrets will not be
shown again!
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
@@ -226,7 +227,11 @@ export function OAuthClientModals() {
<code className="flex-1 rounded-md bg-secondary p-2 font-mono text-sm">
{newClientSecret?.client_id}
</code>
<Button size="icon" variant="outline" onClick={handleCopyClientId}>
<Button
size="icon"
variant="outline"
onClick={handleCopyClientId}
>
<LuCopy className="h-4 w-4" />
</Button>
</div>
@@ -246,11 +251,31 @@ export function OAuthClientModals() {
<LuCopy className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-destructive">
This secret will only be shown once. Store it securely!
</div>
)}
{newClientSecret?.webhook_secret && (
<div className="space-y-2">
<Label>Webhook Secret</Label>
<div className="flex items-center space-x-2">
<code className="flex-1 break-all rounded-md bg-secondary p-2 font-mono text-sm">
{newClientSecret.webhook_secret}
</code>
<Button
size="icon"
variant="outline"
onClick={handleCopyWebhookSecret}
>
<LuCopy className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
Use this secret to verify webhook signatures (HMAC-SHA256)
</p>
</div>
)}
<p className="text-xs text-destructive">
These secrets will only be shown once. Store them securely!
</p>
</div>
<DialogFooter>
<Button onClick={() => setIsSecretDialogOpen(false)}>Close</Button>

View File

@@ -12,6 +12,11 @@ import { useState } from "react";
type ClientType = "public" | "confidential";
// Extended type to include webhook_secret (will be in generated types after API regeneration)
interface ClientSecretResponseWithWebhook extends ClientSecretResponse {
webhook_secret?: string;
}
interface ClientFormState {
name: string;
description: string;
@@ -36,7 +41,8 @@ export function useOAuthClientModals() {
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [isSecretDialogOpen, setIsSecretDialogOpen] = useState(false);
const [formState, setFormState] = useState<ClientFormState>(initialFormState);
const [newClientSecret, setNewClientSecret] = useState<ClientSecretResponse | null>(null);
const [newClientSecret, setNewClientSecret] =
useState<ClientSecretResponseWithWebhook | null>(null);
const queryClient = getQueryClient();
const { toast } = useToast();
@@ -117,7 +123,7 @@ export function useOAuthClientModals() {
});
if (response.status === 200) {
const secretData = response.data as ClientSecretResponse;
const secretData = response.data as ClientSecretResponseWithWebhook;
setNewClientSecret(secretData);
setIsCreateOpen(false);
setIsSecretDialogOpen(true);
@@ -140,7 +146,7 @@ export function useOAuthClientModals() {
try {
const response = await rotateSecret({ clientId });
if (response.status === 200) {
const secretData = response.data as ClientSecretResponse;
const secretData = response.data as ClientSecretResponseWithWebhook;
setNewClientSecret(secretData);
setIsSecretDialogOpen(true);
toast({
@@ -177,6 +183,16 @@ export function useOAuthClientModals() {
}
}
function handleCopyWebhookSecret() {
if (newClientSecret?.webhook_secret) {
navigator.clipboard.writeText(newClientSecret.webhook_secret);
toast({
title: "Copied",
description: "Webhook secret copied to clipboard",
});
}
}
function handleSecretDialogChange(open: boolean) {
setIsSecretDialogOpen(open);
if (!open) {
@@ -198,6 +214,7 @@ export function useOAuthClientModals() {
handleRotateSecret,
handleCopyClientId,
handleCopyClientSecret,
handleCopyWebhookSecret,
resetForm,
};
}

View File

@@ -1,6 +1,7 @@
"use client";
import { Loader2, MoreVertical } from "lucide-react";
import { LuCopy } from "react-icons/lu";
import { Button } from "@/components/__legacy__/ui/button";
import {
Table,
@@ -18,6 +19,17 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/__legacy__/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/__legacy__/ui/dialog";
import { Label } from "@/components/__legacy__/ui/label";
import { Input } from "@/components/__legacy__/ui/input";
import { Textarea } from "@/components/__legacy__/ui/textarea";
import { useOAuthClientSection } from "./useOAuthClientSection";
export function OAuthClientSection() {
@@ -27,12 +39,31 @@ export function OAuthClientSection() {
isDeleting,
isSuspending,
isActivating,
isRotatingWebhookSecret,
isUpdating,
handleDeleteClient,
handleSuspendClient,
handleActivateClient,
handleRotateWebhookSecret,
handleCopyWebhookSecret,
handleEditClient,
handleSaveClient,
webhookSecretDialogOpen,
setWebhookSecretDialogOpen,
newWebhookSecret,
editDialogOpen,
setEditDialogOpen,
editingClient,
editFormState,
setEditFormState,
} = useOAuthClientSection();
const isActionPending = isDeleting || isSuspending || isActivating;
const isActionPending =
isDeleting ||
isSuspending ||
isActivating ||
isRotatingWebhookSecret ||
isUpdating;
return (
<>
@@ -106,6 +137,21 @@ export function OAuthClientSection() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => handleEditClient(client)}
disabled={isActionPending}
>
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
handleRotateWebhookSecret(client.client_id)
}
disabled={isActionPending}
>
Rotate Webhook Secret
</DropdownMenuItem>
<DropdownMenuSeparator />
{client.status === "ACTIVE" ? (
<DropdownMenuItem
onClick={() => handleSuspendClient(client.client_id)}
@@ -141,6 +187,196 @@ export function OAuthClientSection() {
No OAuth clients registered yet. Create one to get started.
</div>
)}
<Dialog
open={webhookSecretDialogOpen}
onOpenChange={setWebhookSecretDialogOpen}
>
<DialogContent className="sm:max-w-[525px]">
<DialogHeader>
<DialogTitle>Webhook Secret Rotated</DialogTitle>
<DialogDescription>
Your new webhook secret has been generated. Please copy it now as
it will not be shown again.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>New Webhook Secret</Label>
<div className="flex items-center space-x-2">
<code className="flex-1 break-all rounded-md bg-secondary p-2 font-mono text-sm">
{newWebhookSecret}
</code>
<Button
size="icon"
variant="outline"
onClick={handleCopyWebhookSecret}
>
<LuCopy className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
Use this secret to verify webhook signatures (HMAC-SHA256)
</p>
</div>
<p className="text-xs text-destructive">
This secret will only be shown once. Store it securely!
</p>
</div>
<DialogFooter>
<Button onClick={() => setWebhookSecretDialogOpen(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-[525px]">
<DialogHeader>
<DialogTitle>Edit OAuth Client</DialogTitle>
<DialogDescription>
Update your OAuth client settings. Changes will take effect
immediately.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="edit-name">Name</Label>
<Input
id="edit-name"
value={editFormState.name ?? ""}
onChange={(e) =>
setEditFormState((prev) => ({
...prev,
name: e.target.value,
}))
}
placeholder="My Application"
maxLength={100}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-description">Description</Label>
<Input
id="edit-description"
value={editFormState.description ?? ""}
onChange={(e) =>
setEditFormState((prev) => ({
...prev,
description: e.target.value,
}))
}
placeholder="A brief description of your application"
maxLength={500}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-redirectUris">Redirect URIs</Label>
<Textarea
id="edit-redirectUris"
value={editFormState.redirect_uris?.join("\n") ?? ""}
onChange={(e) =>
setEditFormState((prev) => ({
...prev,
redirect_uris: e.target.value
.split(/[\n,]/)
.map((uri) => uri.trim())
.filter(Boolean),
}))
}
placeholder="https://myapp.com/callback&#10;https://localhost:3000/callback"
rows={3}
/>
<p className="text-xs text-muted-foreground">
Enter one URI per line or separate with commas
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-webhookDomains">Webhook Domains</Label>
<Textarea
id="edit-webhookDomains"
value={editFormState.webhook_domains?.join("\n") ?? ""}
onChange={(e) =>
setEditFormState((prev) => ({
...prev,
webhook_domains: e.target.value
.split(/[\n,]/)
.map((domain) => domain.trim())
.filter(Boolean),
}))
}
placeholder="https://myapp.com&#10;https://api.myapp.com"
rows={3}
/>
<p className="text-xs text-muted-foreground">
Domains that can receive webhook notifications
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-homepageUrl">Homepage URL</Label>
<Input
id="edit-homepageUrl"
type="url"
value={editFormState.homepage_url ?? ""}
onChange={(e) =>
setEditFormState((prev) => ({
...prev,
homepage_url: e.target.value || undefined,
}))
}
placeholder="https://myapp.com"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-privacyPolicyUrl">Privacy Policy URL</Label>
<Input
id="edit-privacyPolicyUrl"
type="url"
value={editFormState.privacy_policy_url ?? ""}
onChange={(e) =>
setEditFormState((prev) => ({
...prev,
privacy_policy_url: e.target.value || undefined,
}))
}
placeholder="https://myapp.com/privacy"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-termsOfServiceUrl">
Terms of Service URL
</Label>
<Input
id="edit-termsOfServiceUrl"
type="url"
value={editFormState.terms_of_service_url ?? ""}
onChange={(e) =>
setEditFormState((prev) => ({
...prev,
terms_of_service_url: e.target.value || undefined,
}))
}
placeholder="https://myapp.com/terms"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSaveClient} disabled={isUpdating}>
{isUpdating ? "Saving..." : "Save Changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -1,11 +1,16 @@
"use client";
import { useState } from "react";
import {
getGetOauthClientsListClientsQueryKey,
useDeleteOauthClientsDeleteClient,
useGetOauthClientsListClients,
usePatchOauthClientsUpdateClient,
usePostOauthClientsActivateClient,
usePostOauthClientsRotateWebhookSecret,
usePostOauthClientsSuspendClient,
} from "@/app/api/__generated__/endpoints/oauth-clients/oauth-clients";
import type { ClientResponse } from "@/app/api/__generated__/models/clientResponse";
import type { UpdateClientRequest } from "@/app/api/__generated__/models/updateClientRequest";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { getQueryClient } from "@/lib/react-query/queryClient";
@@ -13,6 +18,14 @@ export function useOAuthClientSection() {
const queryClient = getQueryClient();
const { toast } = useToast();
const [webhookSecretDialogOpen, setWebhookSecretDialogOpen] = useState(false);
const [newWebhookSecret, setNewWebhookSecret] = useState<string | null>(null);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [editingClient, setEditingClient] = useState<ClientResponse | null>(
null,
);
const [editFormState, setEditFormState] = useState<UpdateClientRequest>({});
const { data: oauthClients, isLoading } = useGetOauthClientsListClients({
query: {
select: (res) => {
@@ -55,6 +68,22 @@ export function useOAuthClientSection() {
},
});
const {
mutateAsync: rotateWebhookSecret,
isPending: isRotatingWebhookSecret,
} = usePostOauthClientsRotateWebhookSecret();
const { mutateAsync: updateClient, isPending: isUpdating } =
usePatchOauthClientsUpdateClient({
mutation: {
onSettled: () => {
return queryClient.invalidateQueries({
queryKey: getGetOauthClientsListClientsQueryKey(),
});
},
},
});
async function handleDeleteClient(clientId: string) {
try {
await deleteClient({ clientId });
@@ -103,14 +132,95 @@ export function useOAuthClientSection() {
}
}
async function handleRotateWebhookSecret(clientId: string) {
try {
const response = await rotateWebhookSecret({ clientId });
if (response.status === 200) {
setNewWebhookSecret(response.data.webhook_secret);
setWebhookSecretDialogOpen(true);
toast({
title: "Success",
description: "Webhook secret rotated successfully",
});
}
} catch {
toast({
title: "Error",
description: "Failed to rotate webhook secret",
variant: "destructive",
});
}
}
function handleCopyWebhookSecret() {
if (newWebhookSecret) {
navigator.clipboard.writeText(newWebhookSecret);
toast({
title: "Copied",
description: "Webhook secret copied to clipboard",
});
}
}
function handleEditClient(client: ClientResponse) {
setEditingClient(client);
setEditFormState({
name: client.name,
description: client.description ?? undefined,
homepage_url: client.homepage_url ?? undefined,
privacy_policy_url: client.privacy_policy_url ?? undefined,
terms_of_service_url: client.terms_of_service_url ?? undefined,
redirect_uris: client.redirect_uris,
webhook_domains: client.webhook_domains,
});
setEditDialogOpen(true);
}
async function handleSaveClient() {
if (!editingClient) return;
try {
await updateClient({
clientId: editingClient.client_id,
data: editFormState,
});
toast({
title: "Success",
description: "OAuth client updated successfully",
});
setEditDialogOpen(false);
setEditingClient(null);
} catch {
toast({
title: "Error",
description: "Failed to update OAuth client",
variant: "destructive",
});
}
}
return {
oauthClients,
isLoading,
isDeleting,
isSuspending,
isActivating,
isRotatingWebhookSecret,
isUpdating,
handleDeleteClient,
handleSuspendClient,
handleActivateClient,
handleRotateWebhookSecret,
handleCopyWebhookSecret,
handleEditClient,
handleSaveClient,
webhookSecretDialogOpen,
setWebhookSecretDialogOpen,
newWebhookSecret,
editDialogOpen,
setEditDialogOpen,
editingClient,
editFormState,
setEditFormState,
};
}

View File

@@ -5663,7 +5663,7 @@
"post": {
"tags": ["oauth-clients", "oauth-clients"],
"summary": "Register Client",
"description": "Register a new OAuth client.\n\nThe client is immediately active (no admin approval required).\nFor confidential clients, the client_secret is returned only once.",
"description": "Register a new OAuth client.\n\nThe client is immediately active (no admin approval required).\nFor confidential clients, the client_secret is returned only once.\nThe webhook_secret is always generated and returned only once.",
"operationId": "postOauth-clientsRegisterClient",
"requestBody": {
"content": {
@@ -5825,7 +5825,7 @@
"post": {
"tags": ["oauth-clients", "oauth-clients"],
"summary": "Rotate Client Secret",
"description": "Rotate the client secret for a confidential client.\n\nThe new secret is returned only once. All existing tokens remain valid.",
"description": "Rotate the client secret for a confidential client.\n\nThe new secret is returned only once. All existing tokens remain valid.\nAlso rotates the webhook secret for security.",
"operationId": "postOauth-clientsRotateClientSecret",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
@@ -5861,6 +5861,46 @@
}
}
},
"/oauth/clients/{client_id}/rotate-webhook-secret": {
"post": {
"tags": ["oauth-clients", "oauth-clients"],
"summary": "Rotate Webhook Secret",
"description": "Rotate only the webhook secret for a client.\n\nThe new webhook secret is returned only once.",
"operationId": "postOauth-clientsRotateWebhookSecret",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "client_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Client Id" }
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WebhookSecretResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
}
}
}
},
"/oauth/clients/{client_id}/suspend": {
"post": {
"tags": ["oauth-clients", "oauth-clients"],
@@ -6901,12 +6941,17 @@
"type": "string",
"title": "Client Secret",
"description": "Client secret (only shown once, store securely)"
},
"webhook_secret": {
"type": "string",
"title": "Webhook Secret",
"description": "Webhook secret for HMAC signing (only shown once, store securely)"
}
},
"type": "object",
"required": ["client_id", "client_secret"],
"required": ["client_id", "client_secret", "webhook_secret"],
"title": "ClientSecretResponse",
"description": "Response containing newly generated client secret."
"description": "Response containing newly generated client credentials."
},
"CountResponse": {
"properties": {
@@ -12109,6 +12154,16 @@
"url"
],
"title": "Webhook"
},
"WebhookSecretResponse": {
"properties": {
"client_id": { "type": "string", "title": "Client Id" },
"webhook_secret": { "type": "string", "title": "Webhook Secret" }
},
"type": "object",
"required": ["client_id", "webhook_secret"],
"title": "WebhookSecretResponse",
"description": "Response containing newly generated webhook secret."
}
},
"securitySchemes": {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,9 @@ nav:
- Using D-ID: platform/d_id.md
- Blocks: platform/blocks/blocks.md
- External API Integration: platform/external-api-integration.md
- Framework Integrations:
- Next.js: platform/integrations/nextjs.md
- Ruby on Rails: platform/integrations/rails.md
- Contributing:
- Tests: platform/contributing/tests.md
- OAuth Flows: platform/contributing/oauth-integration-flow.md