From 2b6ee4e3b21262a163452e65eb0f2eda57c22aad Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Wed, 11 Feb 2026 14:48:47 -0800 Subject: [PATCH 01/15] Implement create apps inside plans page --- api/server.go | 2 + api/v1_users_developer_apps.go | 309 +++++++++++++++++- config/config.go | 2 + static/plans/README.md | 10 + static/plans/src/App.tsx | 189 +++++++++-- .../plans/src/components/CreateKeyModal.tsx | 151 +++++++++ static/plans/src/main.tsx | 15 +- static/plans/src/vite-env.d.ts | 8 + static/plans/vite.config.ts | 1 + 9 files changed, 647 insertions(+), 40 deletions(-) create mode 100644 static/plans/src/components/CreateKeyModal.tsx diff --git a/api/server.go b/api/server.go index cc7004bc..daf93a3f 100644 --- a/api/server.go +++ b/api/server.go @@ -417,6 +417,8 @@ func NewApiServer(config config.Config) *ApiServer { g.Get("/users/:userId/authorized-apps", app.v1UsersAuthorizedApps) g.Get("/users/:userId/developer_apps", app.v1UsersDeveloperApps) g.Get("/users/:userId/developer-apps", app.v1UsersDeveloperApps) + g.Post("/users/:userId/developer_apps", app.requirePlansAppAuth, app.postV1UsersDeveloperAppCreate) + g.Post("/users/:userId/developer-apps", app.requirePlansAppAuth, app.postV1UsersDeveloperAppCreate) g.Get("/users/:userId/withdrawals/download", app.requireAuthForUserId, app.v1UsersWithdrawalsDownloadCsv) g.Get("/users/:userId/withdrawals/download/json", app.requireAuthForUserId, app.v1UsersWithdrawalsDownloadJson) g.Post("/users/:userId/follow", app.requireAuthMiddleware, app.postV1UserFollow) diff --git a/api/v1_users_developer_apps.go b/api/v1_users_developer_apps.go index 647ae526..9e618c79 100644 --- a/api/v1_users_developer_apps.go +++ b/api/v1_users_developer_apps.go @@ -1,9 +1,25 @@ package api import ( + "context" + "crypto/ecdsa" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "api.audius.co/indexer" "api.audius.co/trashid" + corev1 "github.com/OpenAudio/go-openaudio/pkg/api/core/v1" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" "github.com/gofiber/fiber/v2" "github.com/jackc/pgx/v5" + "go.uber.org/zap" ) type DeveloperApp struct { @@ -15,13 +31,15 @@ type DeveloperApp struct { } type DeveloperAppWithMetrics struct { - Address string `json:"address" db:"address"` - UserId trashid.HashId `json:"user_id" db:"user_id"` - Name string `json:"name" db:"name"` - Description *string `json:"description" db:"description"` - ImageUrl *string `json:"image_url" db:"image_url"` - RequestCount int64 `json:"request_count" db:"request_count"` - RequestCountAllTime int64 `json:"request_count_all_time" db:"request_count_all_time"` + Address string `json:"address" db:"address"` + UserId trashid.HashId `json:"user_id" db:"user_id"` + Name string `json:"name" db:"name"` + Description *string `json:"description" db:"description"` + ImageUrl *string `json:"image_url" db:"image_url"` + RequestCount int64 `json:"request_count" db:"request_count"` + RequestCountAllTime int64 `json:"request_count_all_time" db:"request_count_all_time"` + IsLegacy bool `json:"is_legacy" db:"is_legacy"` + APIAccessKeys json.RawMessage `json:"api_access_keys" db:"api_access_keys"` } func (app *ApiServer) v1UsersDeveloperApps(c *fiber.Ctx) error { @@ -59,14 +77,24 @@ func (app *ApiServer) v1UsersDeveloperApps(c *fiber.Ctx) error { func (app *ApiServer) v1UsersDeveloperAppsWithMetrics(c *fiber.Ctx, userId int32) error { sql := ` - SELECT + SELECT da.address, da.user_id, da.name, da.description, da.image_url, COALESCE(SUM(ama.request_count) FILTER (WHERE ama.date >= DATE_TRUNC('month', CURRENT_DATE)::date AND ama.date <= CURRENT_DATE), 0)::bigint AS request_count, - COALESCE(SUM(ama.request_count), 0)::bigint AS request_count_all_time + COALESCE(SUM(ama.request_count), 0)::bigint AS request_count_all_time, + NOT EXISTS ( + SELECT 1 FROM api_access_keys aak + WHERE aak.api_key = da.address AND aak.is_active = true + ) AS is_legacy, + COALESCE( + (SELECT json_agg(json_build_object('api_access_key', aak.api_access_key, 'is_active', aak.is_active)) + FROM api_access_keys aak + WHERE aak.api_key = da.address AND aak.is_active = true), + '[]'::json + ) AS api_access_keys FROM developer_apps da LEFT JOIN api_metrics_apps ama ON ama.api_key = da.address WHERE da.user_id = @userId @@ -91,3 +119,266 @@ func (app *ApiServer) v1UsersDeveloperAppsWithMetrics(c *fiber.Ctx, userId int32 "data": apps, }) } + +// requirePlansAppAuth validates Bearer token and checks that the plans app has a grant from the user. +// Must run after requireUserIdMiddleware. +func (app *ApiServer) requirePlansAppAuth(c *fiber.Ctx) error { + secret := app.config.PlansAppApiSecret + if secret == "" { + app.logger.Error("PLANS_APP_API_SECRET not configured") + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Plans app not configured", + }) + } + + authHeader := c.Get("Authorization") + if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "Missing or invalid Authorization header. Use Bearer ", + }) + } + token := strings.TrimPrefix(authHeader, "Bearer ") + + pathUserId := app.getUserId(c) + if pathUserId == 0 { + return fiber.NewError(fiber.StatusBadRequest, "invalid userId") + } + + jwtUserId, err := app.validateOAuthJWTTokenToUserId(c.Context(), token) + if err != nil { + return err + } + + if int32(jwtUserId) != pathUserId { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "Token userId does not match path userId", + }) + } + + // Derive plans app address from private key + privateKey, err := crypto.HexToECDSA(strings.TrimPrefix(secret, "0x")) + if err != nil { + app.logger.Error("Invalid PLANS_APP_API_SECRET", zap.Error(err)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Plans app misconfigured", + }) + } + plansAppAddress := strings.ToLower(crypto.PubkeyToAddress(privateKey.PublicKey).Hex()) + + if !app.isAuthorizedRequest(c.Context(), pathUserId, plansAppAddress) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "User has not granted the plans app write access. Log in with OAuth scope 'write'.", + }) + } + + return c.Next() +} + +// validateOAuthJWTTokenToUserId validates the OAuth JWT and returns the userId from the payload. +func (app *ApiServer) validateOAuthJWTTokenToUserId(ctx context.Context, token string) (trashid.HashId, error) { + tokenParts := strings.Split(token, ".") + if len(tokenParts) != 3 { + return 0, fiber.NewError(fiber.StatusBadRequest, "Invalid JWT token format") + } + + base64Header := tokenParts[0] + base64Payload := tokenParts[1] + base64Signature := tokenParts[2] + + paddedSignature := base64Signature + if len(paddedSignature)%4 != 0 { + paddedSignature += strings.Repeat("=", 4-len(paddedSignature)%4) + } + signatureDecoded, err := base64.URLEncoding.DecodeString(paddedSignature) + if err != nil { + return 0, fiber.NewError(fiber.StatusBadRequest, "The JWT signature could not be decoded") + } + signatureHex := string(signatureDecoded) + signatureBytes := common.FromHex(signatureHex) + + message := fmt.Sprintf("%s.%s", base64Header, base64Payload) + encodedToRecover := []byte(message) + prefixedMessage := []byte(fmt.Sprintf("\x19Ethereum Signed Message:\n%d%s", len(encodedToRecover), encodedToRecover)) + finalHash := crypto.Keccak256Hash(prefixedMessage) + + if len(signatureBytes) != 65 { + return 0, fiber.NewError(fiber.StatusBadRequest, "The JWT signature was incorrectly signed") + } + if signatureBytes[64] >= 27 { + signatureBytes[64] -= 27 + } + publicKey, err := crypto.SigToPub(finalHash.Bytes(), signatureBytes) + if err != nil { + return 0, fiber.NewError(fiber.StatusUnauthorized, "The JWT signature is invalid") + } + recoveredAddr := crypto.PubkeyToAddress(*publicKey) + walletLower := strings.ToLower(recoveredAddr.Hex()) + + paddedPayload := base64Payload + if len(paddedPayload)%4 != 0 { + paddedPayload += strings.Repeat("=", 4-len(paddedPayload)%4) + } + stringifiedPayload, err := base64.URLEncoding.DecodeString(paddedPayload) + if err != nil { + return 0, fiber.NewError(fiber.StatusBadRequest, "JWT payload could not be decoded") + } + var payload map[string]interface{} + if err := json.Unmarshal(stringifiedPayload, &payload); err != nil { + return 0, fiber.NewError(fiber.StatusBadRequest, "JWT payload could not be unmarshalled") + } + + userIdInterface, exists := payload["userId"] + if !exists { + return 0, fiber.NewError(fiber.StatusBadRequest, "JWT payload missing userId field") + } + userIdStr, ok := userIdInterface.(string) + if !ok { + return 0, fiber.NewError(fiber.StatusBadRequest, "JWT payload userId must be a string") + } + jwtUserId, err := trashid.DecodeHashId(userIdStr) + if err != nil { + return 0, fiber.NewError(fiber.StatusBadRequest, "Invalid JWT payload userId") + } + + walletUserId, err := app.queries.GetUserForWallet(ctx, walletLower) + if err != nil { + if err == pgx.ErrNoRows { + return 0, fiber.NewError(fiber.StatusUnauthorized, "The JWT signature is invalid - invalid wallet") + } + return 0, err + } + + if int32(walletUserId) != int32(jwtUserId) { + isManager, err := app.isActiveManager(ctx, int32(jwtUserId), int32(walletUserId)) + if err != nil { + return 0, err + } + if !isManager { + return 0, fiber.NewError(fiber.StatusForbidden, "The JWT signature is invalid - the wallet does not match the user") + } + } + + return trashid.HashId(jwtUserId), nil +} + +type createDeveloperAppBody struct { + Name string `json:"name"` +} + +func (app *ApiServer) postV1UsersDeveloperAppCreate(c *fiber.Ctx) error { + userID := app.getUserId(c) + + var body createDeveloperAppBody + if err := c.BodyParser(&body); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Invalid request body", + }) + } + name := strings.TrimSpace(body.Name) + if name == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "name is required", + }) + } + + if app.writePool == nil { + app.logger.Error("Write pool not configured") + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Database write not available", + }) + } + + // Generate ECDSA keypair for the new app + privateKey, err := ecdsa.GenerateKey(crypto.S256(), rand.Reader) + if err != nil { + app.logger.Error("Failed to generate keypair", zap.Error(err)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to create developer app", + }) + } + address := crypto.PubkeyToAddress(privateKey.PublicKey).Hex() + apiSecretHex := hex.EncodeToString(privateKey.D.Bytes()) + + // Insert into api_keys + _, err = app.writePool.Exec(c.Context(), ` + INSERT INTO api_keys (api_key, api_secret, rps, rpm) + VALUES ($1, $2, 10, 500000) + ON CONFLICT (api_key) DO UPDATE SET api_secret = EXCLUDED.api_secret + `, address, apiSecretHex) + if err != nil { + app.logger.Error("Failed to insert api_keys", zap.Error(err)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to create developer app", + }) + } + + // Build app_signature for ManageEntity + unixTs := strconv.FormatInt(time.Now().Unix(), 10) + message := "Creating Audius developer app at " + unixTs + hash := crypto.Keccak256Hash([]byte(message)) + signature, err := crypto.Sign(hash.Bytes(), privateKey) + if err != nil { + app.logger.Error("Failed to sign app message", zap.Error(err)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to create developer app", + }) + } + signatureHex := hex.EncodeToString(signature) + + metadataObj := map[string]interface{}{ + "name": name, + "description": "", + "image_url": "", + "app_signature": map[string]interface{}{ + "message": message, + "signature": signatureHex, + }, + } + metadataBytes, _ := json.Marshal(metadataObj) + + nonce := time.Now().UnixNano() + manageEntityTx := &corev1.ManageEntityLegacy{ + Signer: common.HexToAddress(address).String(), + UserId: int64(userID), + EntityId: 0, + Action: indexer.Action_Create, + EntityType: indexer.Entity_DeveloperApp, + Nonce: strconv.FormatInt(nonce, 10), + Metadata: string(metadataBytes), + } + + response, err := app.sendTransactionWithSigner(manageEntityTx, privateKey) + if err != nil { + app.logger.Error("Failed to send developer app create transaction", zap.Error(err)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to create developer app", + }) + } + + // Generate api_access_key (random base64 for Basic Auth) + apiAccessKeyBytes := make([]byte, 32) + if _, err := rand.Read(apiAccessKeyBytes); err != nil { + app.logger.Error("Failed to generate api_access_key", zap.Error(err)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to create developer app", + }) + } + apiAccessKey := base64.URLEncoding.EncodeToString(apiAccessKeyBytes) + + _, err = app.writePool.Exec(c.Context(), ` + INSERT INTO api_access_keys (api_key, api_access_key, is_active) + VALUES ($1, $2, true) + `, address, apiAccessKey) + if err != nil { + app.logger.Error("Failed to insert api_access_keys", zap.Error(err)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to create developer app", + }) + } + + return c.JSON(fiber.Map{ + "api_key": address, + "api_secret": apiAccessKey, + "transaction_hash": response.Msg.GetTransaction().GetHash(), + }) +} diff --git a/config/config.go b/config/config.go index 6c314fec..f3fa3995 100644 --- a/config/config.go +++ b/config/config.go @@ -49,6 +49,7 @@ type Config struct { RewardCodeAuthorizedKeys []string LaunchpadDeterministicSecret string UnsplashKeys []string + PlansAppApiSecret string } var Cfg = Config{ @@ -72,6 +73,7 @@ var Cfg = Config{ CommsMessagePush: true, LaunchpadDeterministicSecret: os.Getenv("launchpadDeterministicSecret"), UnsplashKeys: strings.Split(os.Getenv("unsplashKeys"), ","), + PlansAppApiSecret: os.Getenv("PLANS_APP_API_SECRET"), } func init() { diff --git a/static/plans/README.md b/static/plans/README.md index 5cbbbf17..a7a4c22c 100644 --- a/static/plans/README.md +++ b/static/plans/README.md @@ -35,6 +35,16 @@ npm run build This will create a `dist/` directory with the built files that will be served by the Go API server. +## Create Key (local dev) + +To create developer apps locally, the **API server** (not this app) must have `PLANS_APP_API_SECRET` set. Add to the API root `.env`: + +``` +PLANS_APP_API_SECRET= +``` + +This is the private key for the OAuth app. It must match the address of the app identified by `VITE_AUDIUS_API_KEY`. For local dev you can generate a key with `openssl rand -hex 32` and register a new OAuth app, or use the key from your staging/production plans app config. + ## Deployment After building, the Go server will serve the built files from `./static/plans/dist/` at the `/plans` route. diff --git a/static/plans/src/App.tsx b/static/plans/src/App.tsx index f213d4b6..9c4a907f 100644 --- a/static/plans/src/App.tsx +++ b/static/plans/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { ThemeProvider as HarmonyThemeProvider, Text, @@ -8,12 +8,21 @@ import { Button, TextLink, IconValidationCheck, + IconPlus, + Tag, + Tooltip, } from "@audius/harmony"; import { css } from "@emotion/react"; import { useSdk } from "./hooks/useSdk"; +import { CreateKeyModal } from "./components/CreateKeyModal"; type OAuthUser = { userId: string; handle: string }; +type ApiAccessKey = { + api_access_key: string; + is_active: boolean; +}; + type DeveloperApp = { address: string; user_id: string; @@ -22,6 +31,8 @@ type DeveloperApp = { image_url: string | null; request_count?: number; request_count_all_time?: number; + is_legacy?: boolean; + api_access_keys?: ApiAccessKey[]; }; // API returns 150x150, 480x480; SDK uses _150x150, _480x480 @@ -42,9 +53,10 @@ type FullUser = { profilePicture?: ProfilePictureData; }; -const API_BASE = "https://api.audius.co"; +const API_BASE = import.meta.env.VITE_API_BASE ?? "https://api.audius.co"; const OAUTH_USER_KEY = "audius-api-plans-oauth-user"; +const OAUTH_TOKEN_KEY = "@audius/sdk/token"; const messages = { navAudius: "audius.co", @@ -93,6 +105,10 @@ const messages = { freeMonthlyLimit: "500,000", unlimitedRateLimit: "Unlimited", unlimitedMonthlyLimit: "Unlimited", + legacyPill: "LEGACY app", + legacyTooltip: + "Sign API requests using your API Secret in the Authorization Header", + createNewKey: "Create New Key", }; const planGraphicSize = 64; @@ -251,6 +267,21 @@ function ProfileAvatar({ } }; + const sizePx = size === "small" ? 24 : size === "medium" ? 32 : 48; + if (currentSrc == null) { + return ( + + ); + } + return ; } @@ -455,13 +486,15 @@ export default function App() { }); const [fullUser, setFullUser] = useState(null); const [developerApps, setDeveloperApps] = useState([]); + const [createKeyModalOpen, setCreateKeyModalOpen] = useState(false); const handleLogin = () => { - sdk.oauth?.login({ scope: "read" }); + sdk.oauth?.login({ scope: "write" }); }; const handleLogout = () => { sessionStorage.removeItem(OAUTH_USER_KEY); + sessionStorage.removeItem(OAUTH_TOKEN_KEY); setOauthUser(null); setFullUser(null); setDeveloperApps([]); @@ -472,22 +505,76 @@ export default function App() { */ useEffect(() => { sdk.oauth?.init({ - successCallback: (profile: { - userId?: string | number; - sub?: string | number; - handle?: string; - }) => { + successCallback: ( + profile: { + userId?: string | number; + sub?: string | number; + handle?: string; + }, + token?: string, + ) => { const user: OAuthUser = { userId: String(profile.userId ?? profile.sub ?? ""), handle: profile.handle ?? "", }; sessionStorage.setItem(OAUTH_USER_KEY, JSON.stringify(user)); + if (token) { + sessionStorage.setItem(OAUTH_TOKEN_KEY, token); + } setOauthUser(user); }, errorCallback: (error: string) => console.log("Got error", error), }); }, [sdk.oauth]); + const loadDeveloperApps = useCallback(async () => { + if (!oauthUser?.userId) return; + try { + const appsRes = await fetch( + `${API_BASE}/v1/users/${encodeURIComponent(oauthUser.userId)}/developer-apps?include=metrics`, + ); + if (appsRes.ok) { + const { data } = (await appsRes.json()) as { data: DeveloperApp[] }; + setDeveloperApps(data ?? []); + } + } catch { + // ignore + } + }, [oauthUser?.userId]); + + const handleCreateKey = useCallback( + async (name: string) => { + if (!oauthUser?.userId) throw new Error("Not logged in"); + const token = sessionStorage.getItem(OAUTH_TOKEN_KEY); + const res = await fetch( + `${API_BASE}/v1/users/${encodeURIComponent(oauthUser.userId)}/developer-apps`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ name }), + }, + ); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error((err as { error?: string })?.error ?? "Failed to create key"); + } + const result = (await res.json()) as { + api_key?: string; + api_secret?: string; + }; + if (result.api_key && result.api_secret) { + navigator.clipboard.writeText( + `API Key: ${result.api_key}\nAPI Secret: ${result.api_secret}`, + ); + } + await loadDeveloperApps(); + }, + [oauthUser?.userId, loadDeveloperApps], + ); + /** * Fetch full user profile and developer apps when logged in. * Uses API (api.audius.co) for user - it returns proper CDN URLs for profile pictures. @@ -550,6 +637,11 @@ export default function App() { return ( + setCreateKeyModalOpen(false)} + onSubmit={handleCreateKey} + /> - - {messages.apiKeysSection} - + + + {messages.apiKeysSection} + + + - {app.name} + + {app.name} + {app.is_legacy ? ( + + + {messages.legacyPill} + + + ) : null} + {app.description ? ( {app.description} @@ -923,6 +1055,7 @@ export default function App() { - - + + + + {!app.is_legacy && + (app.api_access_keys?.length ?? 0) > 0 + ? app.api_access_keys?.map((aak, idx) => ( + + )) + : null} diff --git a/static/plans/src/components/CreateKeyModal.tsx b/static/plans/src/components/CreateKeyModal.tsx new file mode 100644 index 00000000..88f9555e --- /dev/null +++ b/static/plans/src/components/CreateKeyModal.tsx @@ -0,0 +1,151 @@ +import { useState, useCallback } from "react"; +import { + Modal, + ModalHeader, + ModalTitle, + ModalContent, + ModalFooter, + Button, + Flex, + TextInput, + IconEmbed, +} from "@audius/harmony"; +import { css } from "@emotion/react"; + +const messages = { + title: "Create New Key", + nameLabel: "Key name", + namePlaceholder: "Enter a name for your API key", + cancel: "Cancel", + create: "Create", + creating: "Creating...", +}; + +type CreateKeyModalProps = { + isOpen: boolean; + onClose: () => void; + onSubmit: (name: string) => Promise; +}; + +export const CreateKeyModal = ({ + isOpen, + onClose, + onSubmit, +}: CreateKeyModalProps) => { + const [name, setName] = useState(""); + const [isPending, setIsPending] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = useCallback(async () => { + const trimmed = name.trim(); + if (!trimmed) return; + setIsPending(true); + setError(null); + try { + await onSubmit(trimmed); + setName(""); + onClose(); + } catch (e) { + setError((e as Error)?.message ?? "Something went wrong"); + } finally { + setIsPending(false); + } + }, [name, onSubmit, onClose]); + + const handleClose = useCallback(() => { + if (!isPending) { + setName(""); + setError(null); + onClose(); + } + }, [isPending, onClose]); + + return ( + +
div { + flex-direction: row; + flex-wrap: wrap; + align-items: center; + gap: var(--harmony-unit-2); + } + & > div > button { + position: relative; + flex-shrink: 0; + } + & > div > div { + flex: 1; + min-width: 0; + margin-left: var(--harmony-unit-4); + margin-right: var(--harmony-unit-4); + } + `} + > + + + +
+ + + setName(e.target.value)} + disabled={isPending} + /> + {error ? ( + + {error} + + ) : null} + + + + + + + + +
+ ); +}; diff --git a/static/plans/src/main.tsx b/static/plans/src/main.tsx index 51247380..4c0e6572 100644 --- a/static/plans/src/main.tsx +++ b/static/plans/src/main.tsx @@ -1,10 +1,11 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.tsx' -import '@audius/harmony/dist/harmony.css' +import "timers"; +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App.tsx"; +import "@audius/harmony/dist/harmony.css"; -ReactDOM.createRoot(document.getElementById('root')!).render( +ReactDOM.createRoot(document.getElementById("root")!).render( - -) + , +); diff --git a/static/plans/src/vite-env.d.ts b/static/plans/src/vite-env.d.ts index 11f02fe2..7a7f1cd9 100644 --- a/static/plans/src/vite-env.d.ts +++ b/static/plans/src/vite-env.d.ts @@ -1 +1,9 @@ /// + +interface ImportMetaEnv { + readonly VITE_API_BASE?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/static/plans/vite.config.ts b/static/plans/vite.config.ts index b55e341e..1f2b6bc5 100644 --- a/static/plans/vite.config.ts +++ b/static/plans/vite.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ plugins: [ react(), nodePolyfills({ + include: ["timers"], globals: { process: true, }, From 090cdecd021e8f6857b3a53d183c608e9be747fa Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Wed, 11 Feb 2026 19:03:24 -0800 Subject: [PATCH 02/15] Implement rate limits and key creation --- api/auth_middleware_test.go | 71 ++++++++++ api/dbv1/models.go | 15 ++ api/metrics_middleware.go | 18 ++- api/rate_limit_middleware.go | 225 ++++++++++++++++++++++++++++++ api/rate_limit_middleware_test.go | 46 ++++++ api/request_helpers.go | 67 +++++++-- api/server.go | 28 +++- api/v1_users_developer_apps.go | 6 +- sql/01_schema.sql | 61 +++++++- 9 files changed, 515 insertions(+), 22 deletions(-) create mode 100644 api/rate_limit_middleware.go create mode 100644 api/rate_limit_middleware_test.go diff --git a/api/auth_middleware_test.go b/api/auth_middleware_test.go index 36900fc6..fac53974 100644 --- a/api/auth_middleware_test.go +++ b/api/auth_middleware_test.go @@ -1,6 +1,7 @@ package api import ( + "context" "encoding/base64" "fmt" "io" @@ -254,6 +255,76 @@ func TestGetApiSignerBasicAuth(t *testing.T) { }) } +func TestGetApiSignerWithApiAccessKey(t *testing.T) { + app := emptyTestApp(t) + if app.writePool == nil { + t.Skip("writePool required for api_access_key lookup") + } + + ctx := context.Background() + ensureApiKeysTables(t, app, ctx) + + // Same private key as TestGetApiSignerBasicAuth - derives to 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 + testPrivateKey := "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + parentApiKey := "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" + apiAccessKey := "test-access-key-123" + + _, err := app.writePool.Exec(ctx, ` + INSERT INTO api_keys (api_key, api_secret, rps, rpm) + VALUES ($1, $2, 10, 500000) + ON CONFLICT (api_key) DO UPDATE SET api_secret = EXCLUDED.api_secret + `, parentApiKey, testPrivateKey) + assert.NoError(t, err) + + _, err = app.writePool.Exec(ctx, ` + INSERT INTO api_access_keys (api_key, api_access_key, is_active) + VALUES ($1, $2, true) + ON CONFLICT (api_key, api_access_key) DO UPDATE SET is_active = true + `, parentApiKey, apiAccessKey) + assert.NoError(t, err) + + testApp := fiber.New() + testApp.Post("/test", func(c *fiber.Ctx) error { + signer, err := app.getApiSigner(c) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + return c.JSON(fiber.Map{ + "address": signer.Address, + }) + }) + + req := httptest.NewRequest("POST", "/test", nil) + req.Header.Set("Authorization", "Basic "+encodeBasicAuth("", apiAccessKey)) + res, err := testApp.Test(req, -1) + assert.NoError(t, err) + assert.Equal(t, fiber.StatusOK, res.StatusCode) + body, _ := io.ReadAll(res.Body) + assert.Contains(t, string(body), parentApiKey) +} + +// ensureApiKeysTables creates api_keys and api_access_keys if they do not exist. +func ensureApiKeysTables(t *testing.T, app *ApiServer, ctx context.Context) { + t.Helper() + _, err := app.writePool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS api_keys ( + api_key VARCHAR(255) NOT NULL PRIMARY KEY, + api_secret VARCHAR(255), + rps INTEGER NOT NULL DEFAULT 10, + rpm INTEGER NOT NULL DEFAULT 500000, + created_at TIMESTAMP NOT NULL DEFAULT NOW() + ); + CREATE TABLE IF NOT EXISTS api_access_keys ( + api_key VARCHAR(255) NOT NULL, + api_access_key VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + is_active BOOLEAN NOT NULL DEFAULT true, + PRIMARY KEY (api_key, api_access_key) + ); + `) + assert.NoError(t, err) +} + // Helper function to encode basic auth credentials func encodeBasicAuth(username, password string) string { auth := username + ":" + password diff --git a/api/dbv1/models.go b/api/dbv1/models.go index 340496ee..7499836d 100644 --- a/api/dbv1/models.go +++ b/api/dbv1/models.go @@ -783,6 +783,21 @@ type AlbumPriceHistory struct { CreatedAt time.Time `json:"created_at"` } +type ApiAccessKey struct { + ApiKey string `json:"api_key"` + ApiAccessKey string `json:"api_access_key"` + CreatedAt time.Time `json:"created_at"` + IsActive bool `json:"is_active"` +} + +type ApiKey struct { + ApiKey string `json:"api_key"` + ApiSecret pgtype.Text `json:"api_secret"` + Rps int32 `json:"rps"` + Rpm int32 `json:"rpm"` + CreatedAt time.Time `json:"created_at"` +} + type ApiMetricsApp struct { Date pgtype.Date `json:"date"` ApiKey string `json:"api_key"` diff --git a/api/metrics_middleware.go b/api/metrics_middleware.go index c76fb035..9590c261 100644 --- a/api/metrics_middleware.go +++ b/api/metrics_middleware.go @@ -3,6 +3,7 @@ package api import ( "context" "runtime" + "strings" "sync" "time" @@ -96,13 +97,22 @@ func NewMetricsCollector(logger *zap.Logger, writePool *pgxpool.Pool) *MetricsCo return collector } -// Fiber middleware that collects metrics -func (rmc *MetricsCollector) Middleware() fiber.Handler { +// Fiber middleware that collects metrics. Pass apiServer to resolve identifier from Basic Auth signer first; if nil or no signer, falls back to api_key/app_name query params. +func (rmc *MetricsCollector) Middleware(apiServer *ApiServer) fiber.Handler { return func(c *fiber.Ctx) error { err := c.Next() - apiKey := c.Query("api_key") - appName := c.Query("app_name") + var apiKey, appName string + if apiServer != nil { + signer, signerErr := apiServer.getApiSigner(c) + if signerErr == nil && signer != nil { + apiKey = fiberutils.CopyString(strings.ToLower(signer.Address)) + } + } + if apiKey == "" && appName == "" { + apiKey = c.Query("api_key") + appName = c.Query("app_name") + } ipAddress := utils.GetIP(c) // Only record if we have some identifier diff --git a/api/rate_limit_middleware.go b/api/rate_limit_middleware.go new file mode 100644 index 00000000..4d788fef --- /dev/null +++ b/api/rate_limit_middleware.go @@ -0,0 +1,225 @@ +package api + +import ( + "context" + "strconv" + "strings" + "sync" + "time" + + "api.audius.co/utils" + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/maypok86/otter" + "go.uber.org/zap" +) + +const defaultRPS = 5 + +// rpmMonthCacheTTL - on expiry, next request triggers DB refresh to pick up flushed metrics. +const rpmMonthCacheTTL = 5 * time.Minute + +type apiKeysLimits struct { + // Requests per second + RPS int + // Requests per month + RPM int +} + +// rpsState tracks request timestamps per identifier for sliding window RPS +type rpsState struct { + mu sync.Mutex + data map[string][]int64 +} + +func (r *rpsState) allow(identifier string, limit int, now int64) bool { + r.mu.Lock() + defer r.mu.Unlock() + if r.data == nil { + r.data = make(map[string][]int64) + } + windowStart := now - int64(time.Second) + // Prune old timestamps + timestamps := r.data[identifier] + i := 0 + for _, ts := range timestamps { + if ts > windowStart { + timestamps[i] = ts + i++ + } + } + timestamps = timestamps[:i] + if len(timestamps) >= limit { + return false + } + timestamps = append(timestamps, now) + r.data[identifier] = timestamps + return true +} + +// NewRateLimitMiddleware creates middleware that enforces RPS and RPM (requests per month) from api_keys. +func NewRateLimitMiddleware(logger *zap.Logger, writePool *pgxpool.Pool) *RateLimitMiddleware { + limitsCache, err := otter.MustBuilder[string, apiKeysLimits](50_000). + WithTTL(5 * time.Minute). + CollectStats(). + Build() + if err != nil { + panic(err) + } + rpmMonthCacheVal, err := otter.MustBuilder[string, int64](50_000). + WithTTL(rpmMonthCacheTTL). + Build() + if err != nil { + panic(err) + } + return &RateLimitMiddleware{ + logger: logger.With(zap.String("component", "RateLimitMiddleware")), + writePool: writePool, + limitsCache: &limitsCache, + rpmMonthCache: &rpmMonthCacheVal, + rpmMonthMu: &sync.Mutex{}, + rpmMonthPending: make(map[string]int64), + rpsState: &rpsState{data: make(map[string][]int64)}, + } +} + +// RateLimitMiddleware enforces RPS and RPM (requests per month) from api_keys table. +type RateLimitMiddleware struct { + logger *zap.Logger + writePool *pgxpool.Pool + limitsCache *otter.Cache[string, apiKeysLimits] + rpmMonthCache *otter.Cache[string, int64] // base count from api_metrics_apps at last refresh + rpmMonthMu *sync.Mutex + rpmMonthPending map[string]int64 // identifier -> count allowed since last DB refresh + rpsState *rpsState +} + +// Middleware returns the Fiber handler. Pass apiServer to resolve identifier from Basic Auth signer first; if nil or no signer, falls back to api_key/app_name query params. +func (rlm *RateLimitMiddleware) Middleware(apiServer *ApiServer) fiber.Handler { + return func(c *fiber.Ctx) error { + var identifier string + if apiServer != nil { + signer, err := apiServer.getApiSigner(c) + if err == nil && signer != nil { + identifier = strings.ToLower(signer.Address) + } + } + if identifier == "" { + apiKey := c.Query("api_key") + appName := c.Query("app_name") + identifier = apiKey + if identifier == "" { + identifier = appName + } + } + ipAddress := utils.GetIP(c) + + // Resolve rate limits + rps, rpm, useDefault := rlm.getLimits(c.Context(), identifier) + if useDefault { + // Unlisted: RPS only, no RPM; key by IP when no identifier + rps = defaultRPS + if identifier == "" { + identifier = "ip:" + ipAddress + } + } + + now := time.Now().UnixNano() + + // Check RPS + if !rlm.rpsState.allow(identifier, rps, now) { + rlm.logger.Debug("RPS rate limit exceeded", + zap.String("identifier", identifier), + zap.Int("rps", rps)) + return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{ + "error": "Rate limit exceeded. Try again later.", + }) + } + + // Check RPM only for apps with an api_key (useDefault=false) + var remainingMonth int64 = -1 // -1 = not applicable (no RPM check) + if !useDefault { + allowed, remaining := rlm.checkRpm(c.Context(), identifier, int64(rpm)) + remainingMonth = remaining + if !allowed { + rlm.logger.Debug("RPM rate limit exceeded (requests per month)", + zap.String("identifier", identifier), + zap.Int("rpm", rpm)) + c.Set("X-RateLimit-Remaining-Month", "0") + return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{ + "error": "Rate limit exceeded. Try again later.", + }) + } + } + + if remainingMonth >= 0 { + c.Set("X-RateLimit-Remaining-Month", strconv.FormatInt(remainingMonth, 10)) + } + return c.Next() + } +} + +// checkRpm returns (allowed, remaining). remaining = requests left in the month after this one. +// On cache miss: queries api_metrics_apps and loads into cache. On cache hit: uses +// baseCount + pending and increments pending on allow. +func (rlm *RateLimitMiddleware) checkRpm(ctx context.Context, identifier string, limit int64) (allowed bool, remaining int64) { + if rlm.writePool == nil { + return true, limit + } + baseCount, ok := rlm.rpmMonthCache.Get(identifier) + if !ok { + var dbCount int64 + err := rlm.writePool.QueryRow(ctx, ` + SELECT COALESCE(SUM(request_count), 0) + FROM api_metrics_apps + WHERE (api_key = $1 OR app_name = $1) + AND date >= CURRENT_DATE - INTERVAL '30 days' + `, identifier).Scan(&dbCount) + if err != nil { + rlm.logger.Debug("Failed to get monthly request count", zap.String("identifier", identifier), zap.Error(err)) + return true, limit + } + baseCount = dbCount + rlm.rpmMonthCache.Set(identifier, baseCount) + rlm.rpmMonthMu.Lock() + rlm.rpmMonthPending[identifier] = 0 + rlm.rpmMonthMu.Unlock() + } + + rlm.rpmMonthMu.Lock() + pending := rlm.rpmMonthPending[identifier] + total := baseCount + pending + if total >= limit { + rlm.rpmMonthMu.Unlock() + return false, 0 + } + rlm.rpmMonthPending[identifier] = pending + 1 + rlm.rpmMonthMu.Unlock() + // remaining = limit - count after this request + return true, limit - total - 1 +} + +// getLimits returns rps, rpm (requests per month), and useDefault (true if identifier not in api_keys). +func (rlm *RateLimitMiddleware) getLimits(ctx context.Context, identifier string) (rps, rpm int, useDefault bool) { + if identifier == "" { + return 0, 0, true + } + if hit, ok := rlm.limitsCache.Get(identifier); ok { + return hit.RPS, hit.RPM, false + } + if rlm.writePool == nil { + return 0, 0, true + } + var rpsVal, rpmVal int + err := rlm.writePool.QueryRow(ctx, ` + SELECT COALESCE(rps, 10), COALESCE(rpm, 500000) + FROM api_keys + WHERE api_key = $1 + `, identifier).Scan(&rpsVal, &rpmVal) + if err == pgx.ErrNoRows || err != nil { + return 0, 0, true + } + rlm.limitsCache.Set(identifier, apiKeysLimits{RPS: rpsVal, RPM: rpmVal}) + return rpsVal, rpmVal, false +} diff --git a/api/rate_limit_middleware_test.go b/api/rate_limit_middleware_test.go new file mode 100644 index 00000000..8d2b3718 --- /dev/null +++ b/api/rate_limit_middleware_test.go @@ -0,0 +1,46 @@ +package api + +import ( + "context" + "net/http/httptest" + "testing" + + "api.audius.co/config" + "api.audius.co/database" + "api.audius.co/logging" + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" +) + +func TestRateLimitMiddleware(t *testing.T) { + pool := database.CreateTestDatabase(t, "test_api") + ctx := context.Background() + + _, err := pool.Exec(ctx, ` + INSERT INTO api_keys (api_key, api_secret, rps, rpm) + VALUES ('rate-test-key', NULL, 1, 2) + ON CONFLICT (api_key) DO UPDATE SET rps = 1, rpm = 2 + `) + assert.NoError(t, err) + + logger := logging.NewZapLogger(config.Config{}).With() + rlm := NewRateLimitMiddleware(logger, pool) + + testApp := fiber.New() + testApp.Use(rlm.Middleware(nil)) + testApp.Get("/test", func(c *fiber.Ctx) error { + return c.SendString("ok") + }) + + // First request should succeed + req1 := httptest.NewRequest("GET", "/test?api_key=rate-test-key", nil) + res1, err := testApp.Test(req1, -1) + assert.NoError(t, err) + assert.Equal(t, fiber.StatusOK, res1.StatusCode, "first request should succeed") + + // Second request within same second should be rate limited (rps=1) + req2 := httptest.NewRequest("GET", "/test?api_key=rate-test-key", nil) + res2, err := testApp.Test(req2, -1) + assert.NoError(t, err) + assert.Equal(t, fiber.StatusTooManyRequests, res2.StatusCode, "second request should be rate limited") +} diff --git a/api/request_helpers.go b/api/request_helpers.go index 54e5ead3..774e073a 100644 --- a/api/request_helpers.go +++ b/api/request_helpers.go @@ -1,6 +1,7 @@ package api import ( + "context" "crypto/ecdsa" "encoding/base64" "fmt" @@ -9,9 +10,16 @@ import ( "github.com/ethereum/go-ethereum/crypto" "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) +// apiAccessKeySignerEntry caches the result of api_access_key -> (api_key, api_secret) lookup +type apiAccessKeySignerEntry struct { + ApiKey string + ApiSecret string +} + // Signer holds the address, public key, and private key for signing transactions type Signer struct { UserId int @@ -30,27 +38,25 @@ func getOptionalBool(c *fiber.Ctx, key string) (pgtype.Bool, error) { return pgtype.Bool{}, nil } -// getApiSigner extracts a private key from the Basic Auth header and returns a Signer -// The Basic Auth username is ignored, and the password should contain the private key hex string (without 0x prefix) +// getApiSigner extracts a signer from the Basic Auth header. +// If the password is an api_access_key, looks up api_keys for the api_secret (private key hex). +// Otherwise treats the password as a raw private key hex. func (app *ApiServer) getApiSigner(c *fiber.Ctx) (*Signer, error) { authHeader := c.Get("Authorization") if authHeader == "" { return nil, fmt.Errorf("missing Authorization header") } - // Check if it's a Basic Auth header if !strings.HasPrefix(authHeader, "Basic ") { return nil, fmt.Errorf("Authorization header is not Basic Auth") } - // Decode the base64 encoded credentials encodedCreds := strings.TrimPrefix(authHeader, "Basic ") decodedBytes, err := base64.StdEncoding.DecodeString(encodedCreds) if err != nil { return nil, fmt.Errorf("failed to decode Basic Auth credentials: %w", err) } - // Split username:password creds := string(decodedBytes) parts := strings.SplitN(creds, ":", 2) if len(parts) != 2 { @@ -65,18 +71,61 @@ func (app *ApiServer) getApiSigner(c *fiber.Ctx) (*Signer, error) { // The private key is in the password field (parts[1]) privateKeyHex := strings.TrimPrefix(parts[1], "0x") - // Parse the private key + // Branch A: Try api_access_key lookup (password) + if app.writePool != nil && privateKeyHex != "" { + if signer := app.getSignerFromApiAccessKey(c.Context(), privateKeyHex); signer != nil { + return signer, nil + } + } + + // Branch B: Treat password as direct private key hex privateKey, err := crypto.HexToECDSA(privateKeyHex) if err != nil { return nil, fmt.Errorf("failed to parse private key: %w", err) } - - // Derive the public key and address from the private key address := crypto.PubkeyToAddress(privateKey.PublicKey) - return &Signer{ UserId: userId, Address: address.Hex(), PrivateKey: privateKey, }, nil } + +// getSignerFromApiAccessKey looks up api_access_keys and api_keys to build a Signer. +func (app *ApiServer) getSignerFromApiAccessKey(ctx context.Context, apiAccessKey string) *Signer { + if hit, ok := app.apiAccessKeySignerCache.Get(apiAccessKey); ok { + privateKey, err := crypto.HexToECDSA(strings.TrimPrefix(hit.ApiSecret, "0x")) + if err != nil { + return nil + } + return &Signer{ + Address: hit.ApiKey, + PrivateKey: privateKey, + } + } + + var parentApiKey, apiSecret string + err := app.writePool.QueryRow(ctx, ` + SELECT aak.api_key, ak.api_secret + FROM api_access_keys aak + JOIN api_keys ak ON ak.api_key = aak.api_key + WHERE aak.api_access_key = $1 AND aak.is_active = true + `, apiAccessKey).Scan(&parentApiKey, &apiSecret) + if err == pgx.ErrNoRows || err != nil || apiSecret == "" { + return nil + } + + app.apiAccessKeySignerCache.Set(apiAccessKey, apiAccessKeySignerEntry{ + ApiKey: parentApiKey, + ApiSecret: apiSecret, + }) + + privateKey, err := crypto.HexToECDSA(strings.TrimPrefix(apiSecret, "0x")) + if err != nil { + return nil + } + return &Signer{ + Address: parentApiKey, + PrivateKey: privateKey, + } +} diff --git a/api/server.go b/api/server.go index daf93a3f..8b0dd458 100644 --- a/api/server.go +++ b/api/server.go @@ -124,6 +124,14 @@ func NewApiServer(config config.Config) *ApiServer { panic(err) } + apiAccessKeySignerCache, err := otter.MustBuilder[string, apiAccessKeySignerEntry](10_000). + WithTTL(5 * time.Minute). + CollectStats(). + Build() + if err != nil { + panic(err) + } + privateKey, err := crypto.HexToECDSA(config.DelegatePrivateKey) if err != nil { panic(err) @@ -194,8 +202,10 @@ func NewApiServer(config config.Config) *ApiServer { // Initialize metrics collector if writePool is available var metricsCollector *MetricsCollector + var rateLimitMiddleware *RateLimitMiddleware if writePool != nil && config.Env != "test" { metricsCollector = NewMetricsCollector(logger, writePool) + rateLimitMiddleware = NewRateLimitMiddleware(logger, writePool) } commsRpcProcessor, err := comms.NewProcessor(pool, writePool, &config, logger) @@ -225,6 +235,7 @@ func NewApiServer(config config.Config) *ApiServer { resolveHandleCache: &resolveHandleCache, resolveGrantCache: &resolveGrantCache, resolveWalletCache: &resolveWalletCache, + apiAccessKeySignerCache: &apiAccessKeySignerCache, requestValidator: requestValidator, rewardAttester: rewardAttester, transactionSender: transactionSender, @@ -236,6 +247,7 @@ func NewApiServer(config config.Config) *ApiServer { openAudioSDK: openAudioSDK, openAudioPool: openAudioPool, metricsCollector: metricsCollector, + rateLimitMiddleware: rateLimitMiddleware, birdeyeClient: birdeye.New(config.BirdeyeToken), solanaRpcClient: solanaRpc, meteoraDbcClient: meteoraDbcClient, @@ -276,7 +288,11 @@ func NewApiServer(config config.Config) *ApiServer { // Add request metrics middleware if available if app.metricsCollector != nil { - app.Use(app.metricsCollector.Middleware()) + app.Use(app.metricsCollector.Middleware(app)) + } + // Add rate limit middleware after metrics, before auth + if app.rateLimitMiddleware != nil { + app.Use(app.rateLimitMiddleware.Middleware(app)) } app.Use(fiberzap.New(fiberzap.Config{ Logger: logger, @@ -713,10 +729,11 @@ type ApiServer struct { esClient *elasticsearch.Client logger *zap.Logger started time.Time - resolveHandleCache *otter.Cache[string, int32] - resolveGrantCache *otter.Cache[string, bool] - resolveWalletCache *otter.Cache[string, int] - requestValidator *RequestValidator + resolveHandleCache *otter.Cache[string, int32] + resolveGrantCache *otter.Cache[string, bool] + resolveWalletCache *otter.Cache[string, int] + apiAccessKeySignerCache *otter.Cache[string, apiAccessKeySignerEntry] + requestValidator *RequestValidator rewardManagerClient *reward_manager.RewardManagerClient claimableTokensClient *claimable_tokens.ClaimableTokensClient rewardAttester *rewards.RewardAttester @@ -728,6 +745,7 @@ type ApiServer struct { audiusAppUrl string skipAuthCheck bool // set to true in a test if you don't care about auth middleware metricsCollector *MetricsCollector + rateLimitMiddleware *RateLimitMiddleware birdeyeClient BirdeyeClient solanaRpcClient *rpc.Client meteoraDbcClient *meteora_dbc.Client diff --git a/api/v1_users_developer_apps.go b/api/v1_users_developer_apps.go index 9e618c79..b5b3098f 100644 --- a/api/v1_users_developer_apps.go +++ b/api/v1_users_developer_apps.go @@ -312,10 +312,11 @@ func (app *ApiServer) postV1UsersDeveloperAppCreate(c *fiber.Ctx) error { }) } - // Build app_signature for ManageEntity + // Build app_signature for ManageEntity (Ethereum personal sign format so indexer can recover address) unixTs := strconv.FormatInt(time.Now().Unix(), 10) message := "Creating Audius developer app at " + unixTs - hash := crypto.Keccak256Hash([]byte(message)) + prefixedMessage := []byte(fmt.Sprintf("\x19Ethereum Signed Message:\n%d%s", len(message), message)) + hash := crypto.Keccak256Hash(prefixedMessage) signature, err := crypto.Sign(hash.Bytes(), privateKey) if err != nil { app.logger.Error("Failed to sign app message", zap.Error(err)) @@ -326,6 +327,7 @@ func (app *ApiServer) postV1UsersDeveloperAppCreate(c *fiber.Ctx) error { signatureHex := hex.EncodeToString(signature) metadataObj := map[string]interface{}{ + "address": strings.ToLower(address), "name": name, "description": "", "image_url": "", diff --git a/sql/01_schema.sql b/sql/01_schema.sql index 41f43800..8d259a98 100644 --- a/sql/01_schema.sql +++ b/sql/01_schema.sql @@ -2,8 +2,9 @@ -- PostgreSQL database dump -- --- Dumped from database version 17.5 (Debian 17.5-1.pgdg120+1) --- Dumped by pg_dump version 17.5 (Debian 17.5-1.pgdg120+1) + +-- Dumped from database version 17.7 (Debian 17.7-3.pgdg13+1) +-- Dumped by pg_dump version 17.7 (Debian 17.7-3.pgdg13+1) SET statement_timeout = 0; SET lock_timeout = 0; @@ -5847,6 +5848,31 @@ CREATE TABLE public.album_price_history ( ); +-- +-- Name: api_access_keys; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.api_access_keys ( + api_key character varying(255) NOT NULL, + api_access_key character varying(255) NOT NULL, + created_at timestamp without time zone DEFAULT now() NOT NULL, + is_active boolean DEFAULT true NOT NULL +); + + +-- +-- Name: api_keys; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.api_keys ( + api_key character varying(255) NOT NULL, + api_secret character varying(255), + rps integer DEFAULT 10 NOT NULL, + rpm integer DEFAULT 500000 NOT NULL, + created_at timestamp without time zone DEFAULT now() NOT NULL +); + + -- -- Name: api_metrics_apps; Type: TABLE; Schema: public; Owner: - -- @@ -9438,6 +9464,22 @@ ALTER TABLE ONLY public.album_price_history ADD CONSTRAINT album_price_history_pkey PRIMARY KEY (playlist_id, block_timestamp); +-- +-- Name: api_access_keys api_access_keys_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.api_access_keys + ADD CONSTRAINT api_access_keys_pkey PRIMARY KEY (api_key, api_access_key); + + +-- +-- Name: api_keys api_keys_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.api_keys + ADD CONSTRAINT api_keys_pkey PRIMARY KEY (api_key); + + -- -- Name: api_metrics_apps api_metrics_apps_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -10739,6 +10781,20 @@ CREATE INDEX follows_inbound_idx ON public.follows USING btree (followee_user_id CREATE INDEX idx_aggregate_user_follower_count ON public.aggregate_user USING btree (user_id, follower_count); +-- +-- Name: idx_api_access_keys_api_access_key; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_api_access_keys_api_access_key ON public.api_access_keys USING btree (api_access_key); + + +-- +-- Name: idx_api_access_keys_is_active; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_api_access_keys_is_active ON public.api_access_keys USING btree (api_key, is_active) WHERE (is_active = true); + + -- -- Name: idx_api_metrics_apps_api_key; Type: INDEX; Schema: public; Owner: - -- @@ -12577,3 +12633,4 @@ ALTER TABLE ONLY public.users -- PostgreSQL database dump complete -- + From 86509366392cd3572b5b33ac84d9167f36365511 Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Wed, 11 Feb 2026 19:04:30 -0800 Subject: [PATCH 03/15] Optimistic load --- static/plans/src/App.tsx | 72 ++++++++++++++++++++++++++++++---------- 1 file changed, 55 insertions(+), 17 deletions(-) diff --git a/static/plans/src/App.tsx b/static/plans/src/App.tsx index 9c4a907f..14d906e4 100644 --- a/static/plans/src/App.tsx +++ b/static/plans/src/App.tsx @@ -527,20 +527,34 @@ export default function App() { }); }, [sdk.oauth]); - const loadDeveloperApps = useCallback(async () => { - if (!oauthUser?.userId) return; - try { - const appsRes = await fetch( - `${API_BASE}/v1/users/${encodeURIComponent(oauthUser.userId)}/developer-apps?include=metrics`, - ); - if (appsRes.ok) { - const { data } = (await appsRes.json()) as { data: DeveloperApp[] }; - setDeveloperApps(data ?? []); + const loadDeveloperApps = useCallback( + async (mergeApp?: DeveloperApp) => { + if (!oauthUser?.userId) return; + try { + const appsRes = await fetch( + `${API_BASE}/v1/users/${encodeURIComponent(oauthUser.userId)}/developer-apps?include=metrics`, + ); + if (appsRes.ok) { + const { data } = (await appsRes.json()) as { data: DeveloperApp[] }; + let apps = data ?? []; + // If we have an optimistic app and the API doesn't include it yet (indexer lag), prepend it + if ( + mergeApp?.address && + !apps.some( + (a) => + a.address?.toLowerCase() === mergeApp.address?.toLowerCase(), + ) + ) { + apps = [mergeApp, ...apps]; + } + setDeveloperApps(apps); + } + } catch { + // ignore } - } catch { - // ignore - } - }, [oauthUser?.userId]); + }, + [oauthUser?.userId], + ); const handleCreateKey = useCallback( async (name: string) => { @@ -559,18 +573,37 @@ export default function App() { ); if (!res.ok) { const err = await res.json().catch(() => ({})); - throw new Error((err as { error?: string })?.error ?? "Failed to create key"); + throw new Error( + (err as { error?: string })?.error ?? "Failed to create key", + ); } const result = (await res.json()) as { api_key?: string; api_secret?: string; }; - if (result.api_key && result.api_secret) { + if (result.api_key != null && result.api_secret != null) { navigator.clipboard.writeText( `API Key: ${result.api_key}\nAPI Secret: ${result.api_secret}`, ); } - await loadDeveloperApps(); + // Optimistically add new app and reload; merge ensures it stays visible if indexer hasn't processed yet + const optimisticApp: DeveloperApp = { + address: result.api_key ?? "", + user_id: oauthUser.userId, + name, + description: null, + image_url: null, + request_count: 0, + request_count_all_time: 0, + is_legacy: false, + api_access_keys: + result.api_secret != null + ? [{ api_access_key: result.api_secret, is_active: true }] + : [], + }; + await loadDeveloperApps(optimisticApp); + // Retry after delay to pick up real data once indexer processes the new app + setTimeout(() => loadDeveloperApps(optimisticApp), 5000); }, [oauthUser?.userId, loadDeveloperApps], ); @@ -915,7 +948,12 @@ export default function App() { wrap="wrap" gap="s" > - + {messages.apiKeysSection} + + + + + ); +}; From f517170100a4fb0973fedd0db723c5a11cf07761 Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Wed, 11 Feb 2026 23:31:12 -0800 Subject: [PATCH 06/15] Update key add deletion --- api/v1_users_developer_apps.go | 20 +++++++++++++++++++ static/plans/src/App.tsx | 33 +++++++++++++++++++++++--------- static/plans/src/hooks/useSdk.ts | 2 +- 3 files changed, 45 insertions(+), 10 deletions(-) diff --git a/api/v1_users_developer_apps.go b/api/v1_users_developer_apps.go index cb6c3473..d08ee8fa 100644 --- a/api/v1_users_developer_apps.go +++ b/api/v1_users_developer_apps.go @@ -448,6 +448,26 @@ func (app *ApiServer) deleteV1UsersDeveloperApp(c *fiber.Ctx) error { } plansAddress := strings.ToLower(crypto.PubkeyToAddress(plansKey.PublicKey).Hex()) + // 1. Delete api_access_keys (revoke all access keys for this app) + // 2. Delete api_keys row + // 3. Send ManageEntity transaction to delete the developer app on-chain + if app.writePool != nil { + _, err = app.writePool.Exec(c.Context(), `DELETE FROM api_access_keys WHERE LOWER(api_key) = LOWER($1)`, address) + if err != nil { + app.logger.Error("Failed to delete api_access_keys", zap.Error(err)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to delete developer app", + }) + } + _, err = app.writePool.Exec(c.Context(), `DELETE FROM api_keys WHERE LOWER(api_key) = LOWER($1)`, address) + if err != nil { + app.logger.Error("Failed to delete api_keys", zap.Error(err)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to delete developer app", + }) + } + } + metadataObj := map[string]interface{}{ "address": strings.ToLower(address), } diff --git a/static/plans/src/App.tsx b/static/plans/src/App.tsx index f9a6fe5e..aeb430ba 100644 --- a/static/plans/src/App.tsx +++ b/static/plans/src/App.tsx @@ -1098,16 +1098,31 @@ export default function App() { ) : null} - - { - e.stopPropagation(); + { + e.stopPropagation(); + setDeleteAppModalApp(app); + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); setDeleteAppModalApp(app); - }} - /> - + } + }} + css={css` + display: inline-flex; + cursor: pointer; + `} + > + + + + {app.description ? ( diff --git a/static/plans/src/hooks/useSdk.ts b/static/plans/src/hooks/useSdk.ts index b1bce538..2ad49fa3 100644 --- a/static/plans/src/hooks/useSdk.ts +++ b/static/plans/src/hooks/useSdk.ts @@ -2,7 +2,7 @@ import { sdk } from "@audius/sdk"; const apiKey = (import.meta.env.VITE_AUDIUS_API_KEY as string | undefined) ?? - "8acf5eb7436ea403ee536a7334faa5e9ada4b50f"; + "2cc593fc814461263d282a84286fd4f72c79562e"; const instance = sdk({ appName: "Audius API Plans", From 6ef51ae497c58797e95625d98ce2eb3884891be2 Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Wed, 11 Feb 2026 23:42:36 -0800 Subject: [PATCH 07/15] Add fun animation --- static/plans/src/App.tsx | 124 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 123 insertions(+), 1 deletion(-) diff --git a/static/plans/src/App.tsx b/static/plans/src/App.tsx index aeb430ba..f097819e 100644 --- a/static/plans/src/App.tsx +++ b/static/plans/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { ThemeProvider as HarmonyThemeProvider, Text, @@ -117,6 +117,87 @@ const messages = { const planGraphicSize = 64; +/** Black circle overlay with inverted content - used when hovering "Open Audio Protocol" */ +const GrayscaleOverlay = ({ + x, + y, + isExiting, + onComplete, +}: { + x: number; + y: number; + isExiting: boolean; + onComplete: () => void; +}) => { + const [radius, setRadius] = useState(0); + const radiusRef = useRef(0); + + useEffect(() => { + const durationMs = 1800; + const start = performance.now(); + const animate = (now: number) => { + const elapsed = now - start; + const t = Math.min(elapsed / durationMs, 1); + const eased = 1 - (1 - t) * (1 - t); + const r = eased * 150; + radiusRef.current = r; + setRadius(r); + if (t < 1) requestAnimationFrame(animate); + }; + requestAnimationFrame(animate); + }, []); + + useEffect(() => { + if (!isExiting) return; + const durationMs = 1800; + const startRadius = radiusRef.current; + const start = performance.now(); + const animate = (now: number) => { + const elapsed = now - start; + const t = Math.min(elapsed / durationMs, 1); + const eased = 1 - t * t; + const r = startRadius * eased; + radiusRef.current = r; + setRadius(r); + if (t < 1) { + requestAnimationFrame(animate); + } else { + onComplete(); + } + }; + requestAnimationFrame(animate); + // eslint-disable-next-line react-hooks/exhaustive-deps -- only run when isExiting becomes true + }, [isExiting]); + return ( +
+ ); +}; + const FreePlanGraphic = () => ( (null); + // Grayscale "spotlight" effect when hovering Open Audio Protocol link + const [grayscaleHover, setGrayscaleHover] = useState(false); + const [grayscaleExiting, setGrayscaleExiting] = useState(false); + const grayscaleOrigin = useRef({ x: 0, y: 0 }); + const openAudioLinkRef = useRef(null); + + useEffect(() => { + if (!grayscaleHover || grayscaleExiting) return; + const checkLeave = (e: MouseEvent) => { + const wrapper = openAudioLinkRef.current; + if (!wrapper) return; + const el = document.elementFromPoint(e.clientX, e.clientY); + if (!el || !wrapper.contains(el)) { + setGrayscaleExiting(true); + } + }; + document.addEventListener("mousemove", checkLeave, { passive: true }); + return () => document.removeEventListener("mousemove", checkLeave); + }, [grayscaleHover, grayscaleExiting]); + const handleLogin = () => { sdk.oauth?.login({ scope: "write" }); }; @@ -714,6 +815,18 @@ export default function App() { app={deleteAppModalApp} onConfirm={handleDeleteApp} /> + {/* Grayscale spotlight overlay - B&W expands from cursor when hovering Open Audio Protocol */} + {grayscaleHover ? ( + { + setGrayscaleHover(false); + setGrayscaleExiting(false); + }} + /> + ) : null} Bring music to all your apps. Vibe-code ready and performant access to the world's largest open music catalog, the  + { + grayscaleOrigin.current = { x: e.clientX, y: e.clientY }; + setGrayscaleExiting(false); + setGrayscaleHover(true); + }} + onMouseLeave={() => setGrayscaleExiting(true)} css={css` color: inherit; text-decoration: underline; + cursor: pointer; &:hover { text-decoration: none; } @@ -853,6 +974,7 @@ export default function App() { > Open Audio Protocol + From 7d21eabaac439dc81e504099be21575fa4c1c7de Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Thu, 12 Feb 2026 00:13:23 -0800 Subject: [PATCH 08/15] Add/remove bearer token --- api/request_helpers.go | 9 +- api/server.go | 2 + api/swagger/swagger-v1.yaml | 106 ++++++++++++ api/v1_users_developer_apps.go | 129 +++++++++++++- static/plans/src/App.tsx | 308 +++++++++++++++++++++++++++++---- 5 files changed, 511 insertions(+), 43 deletions(-) diff --git a/api/request_helpers.go b/api/request_helpers.go index 1f22e2fc..aca6266d 100644 --- a/api/request_helpers.go +++ b/api/request_helpers.go @@ -117,7 +117,7 @@ func (app *ApiServer) getSignerFromApiAccessKey(ctx context.Context, apiAccessKe return nil } return &Signer{ - Address: hit.ApiKey, + Address: strings.ToLower(hit.ApiKey), PrivateKey: privateKey, } } @@ -126,15 +126,16 @@ func (app *ApiServer) getSignerFromApiAccessKey(ctx context.Context, apiAccessKe err := app.writePool.QueryRow(ctx, ` SELECT aak.api_key, ak.api_secret FROM api_access_keys aak - JOIN api_keys ak ON ak.api_key = aak.api_key + JOIN api_keys ak ON LOWER(ak.api_key) = LOWER(aak.api_key) WHERE aak.api_access_key = $1 AND aak.is_active = true `, apiAccessKey).Scan(&parentApiKey, &apiSecret) if err == pgx.ErrNoRows || err != nil || apiSecret == "" { return nil } + parentApiKeyLower := strings.ToLower(parentApiKey) app.apiAccessKeySignerCache.Set(apiAccessKey, apiAccessKeySignerEntry{ - ApiKey: parentApiKey, + ApiKey: parentApiKeyLower, ApiSecret: apiSecret, }) @@ -143,7 +144,7 @@ func (app *ApiServer) getSignerFromApiAccessKey(ctx context.Context, apiAccessKe return nil } return &Signer{ - Address: parentApiKey, + Address: parentApiKeyLower, PrivateKey: privateKey, } } diff --git a/api/server.go b/api/server.go index 688cebd7..e0f18e3a 100644 --- a/api/server.go +++ b/api/server.go @@ -436,6 +436,8 @@ func NewApiServer(config config.Config) *ApiServer { g.Post("/users/:userId/developer_apps", app.requirePlansAppAuth, app.postV1UsersDeveloperAppCreate) g.Post("/users/:userId/developer-apps", app.requirePlansAppAuth, app.postV1UsersDeveloperAppCreate) g.Delete("/users/:userId/developer-apps/:address", app.requirePlansAppAuth, app.deleteV1UsersDeveloperApp) + g.Post("/users/:userId/developer-apps/:address/access-keys/deactivate", app.requirePlansAppAuth, app.postV1UsersDeveloperAppAccessKeyDeactivate) + g.Post("/users/:userId/developer-apps/:address/access-keys", app.requirePlansAppAuth, app.postV1UsersDeveloperAppAccessKeyCreate) g.Get("/users/:userId/withdrawals/download", app.requireAuthForUserId, app.v1UsersWithdrawalsDownloadCsv) g.Get("/users/:userId/withdrawals/download/json", app.requireAuthForUserId, app.v1UsersWithdrawalsDownloadJson) g.Post("/users/:userId/follow", app.requireAuthMiddleware, app.postV1UserFollow) diff --git a/api/swagger/swagger-v1.yaml b/api/swagger/swagger-v1.yaml index cc38eb3f..17a54e9e 100644 --- a/api/swagger/swagger-v1.yaml +++ b/api/swagger/swagger-v1.yaml @@ -3912,6 +3912,96 @@ paths: "500": description: Server error content: {} + /users/{id}/developer-apps/{address}/access-keys/deactivate: + post: + tags: + - users + - developer_apps + description: Deactivate a bearer token (API access key) for a developer app (Plans API). Requires OAuth Bearer token with plans app grant. The deactivated token will no longer authenticate requests. + operationId: Deactivate User Developer App Access Key + security: + - BasicAuth: [] + - BearerAuth: [] + parameters: + - name: id + in: path + description: User ID + required: true + schema: + type: string + - name: address + in: path + description: Developer app address (API Key) + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/deactivate_access_key_request' + responses: + "200": + description: Access key deactivated successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + "400": + description: Bad request (api_access_key required) + content: {} + "401": + description: Unauthorized + content: {} + "404": + description: Developer app or access key not found + content: {} + "500": + description: Server error + content: {} + /users/{id}/developer-apps/{address}/access-keys: + post: + tags: + - users + - developer_apps + description: Create a new bearer token (API access key) for a developer app (Plans API). Requires OAuth Bearer token with plans app grant. + operationId: Create User Developer App Access Key + security: + - BasicAuth: [] + - BearerAuth: [] + parameters: + - name: id + in: path + description: User ID + required: true + schema: + type: string + - name: address + in: path + description: Developer app address (API Key) + required: true + schema: + type: string + responses: + "200": + description: Access key created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/create_access_key_response' + "401": + description: Unauthorized + content: {} + "404": + description: Developer app not found + content: {} + "500": + description: Server error + content: {} /users/{id}/balance/history: get: tags: @@ -9899,6 +9989,22 @@ components: type: string description: Developer app name (Plans API create) example: "My API Key" + deactivate_access_key_request: + type: object + required: + - api_access_key + properties: + api_access_key: + type: string + description: The bearer token (API access key) to deactivate + create_access_key_response: + type: object + required: + - api_access_key + properties: + api_access_key: + type: string + description: The newly created bearer token (API access key) responses: ParseError: description: When a mask can't be parsed diff --git a/api/v1_users_developer_apps.go b/api/v1_users_developer_apps.go index d08ee8fa..7c1825c4 100644 --- a/api/v1_users_developer_apps.go +++ b/api/v1_users_developer_apps.go @@ -296,7 +296,7 @@ func (app *ApiServer) postV1UsersDeveloperAppCreate(c *fiber.Ctx) error { "error": "Failed to create developer app", }) } - address := crypto.PubkeyToAddress(privateKey.PublicKey).Hex() + address := strings.ToLower(crypto.PubkeyToAddress(privateKey.PublicKey).Hex()) apiSecretHex := hex.EncodeToString(privateKey.D.Bytes()) // Insert into api_keys @@ -497,3 +497,130 @@ func (app *ApiServer) deleteV1UsersDeveloperApp(c *fiber.Ctx) error { "transaction_hash": response.Msg.GetTransaction().GetHash(), }) } + +type deactivateAccessKeyBody struct { + ApiAccessKey string `json:"api_access_key"` +} + +func (app *ApiServer) postV1UsersDeveloperAppAccessKeyDeactivate(c *fiber.Ctx) error { + userID := app.getUserId(c) + address := c.Params("address") + if address == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "address is required", + }) + } + if !strings.HasPrefix(address, "0x") { + address = "0x" + address + } + + var body deactivateAccessKeyBody + if err := c.BodyParser(&body); err != nil || strings.TrimSpace(body.ApiAccessKey) == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "api_access_key is required", + }) + } + apiAccessKey := strings.TrimSpace(body.ApiAccessKey) + + // Verify the app belongs to this user + var ownerUserID int32 + err := app.pool.QueryRow(c.Context(), ` + SELECT user_id FROM developer_apps + WHERE LOWER(address) = LOWER($1) + AND is_current = true + AND is_delete = false + ORDER BY created_at DESC + LIMIT 1 + `, address).Scan(&ownerUserID) + if err != nil || ownerUserID != userID { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": "Developer app not found", + }) + } + + if app.writePool == nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Database write not available", + }) + } + + result, err := app.writePool.Exec(c.Context(), ` + UPDATE api_access_keys + SET is_active = false + WHERE LOWER(api_key) = LOWER($1) AND api_access_key = $2 + `, address, apiAccessKey) + if err != nil { + app.logger.Error("Failed to deactivate api_access_key", zap.Error(err)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to deactivate access key", + }) + } + if result.RowsAffected() == 0 { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": "Access key not found", + }) + } + + // Invalidate signer cache so deactivated key is no longer accepted + app.apiAccessKeySignerCache.Delete(apiAccessKey) + + return c.JSON(fiber.Map{"success": true}) +} + +func (app *ApiServer) postV1UsersDeveloperAppAccessKeyCreate(c *fiber.Ctx) error { + userID := app.getUserId(c) + address := c.Params("address") + if address == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "address is required", + }) + } + if !strings.HasPrefix(address, "0x") { + address = "0x" + address + } + address = strings.ToLower(address) + + // Verify the app belongs to this user + var ownerUserID int32 + err := app.pool.QueryRow(c.Context(), ` + SELECT user_id FROM developer_apps + WHERE LOWER(address) = LOWER($1) + AND is_current = true + AND is_delete = false + ORDER BY created_at DESC + LIMIT 1 + `, address).Scan(&ownerUserID) + if err != nil || ownerUserID != userID { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": "Developer app not found", + }) + } + + if app.writePool == nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Database write not available", + }) + } + + apiAccessKeyBytes := make([]byte, 32) + if _, err := rand.Read(apiAccessKeyBytes); err != nil { + app.logger.Error("Failed to generate api_access_key", zap.Error(err)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to create access key", + }) + } + apiAccessKey := base64.URLEncoding.EncodeToString(apiAccessKeyBytes) + + _, err = app.writePool.Exec(c.Context(), ` + INSERT INTO api_access_keys (api_key, api_access_key, is_active) + VALUES ($1, $2, true) + `, address, apiAccessKey) + if err != nil { + app.logger.Error("Failed to insert api_access_keys", zap.Error(err)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to create access key", + }) + } + + return c.JSON(fiber.Map{"api_access_key": apiAccessKey}) +} diff --git a/static/plans/src/App.tsx b/static/plans/src/App.tsx index f097819e..cdf34f8a 100644 --- a/static/plans/src/App.tsx +++ b/static/plans/src/App.tsx @@ -13,6 +13,7 @@ import { IconButton, Tag, Tooltip, + IconClose, } from "@audius/harmony"; import { css } from "@emotion/react"; import { useSdk } from "./hooks/useSdk"; @@ -113,6 +114,8 @@ const messages = { "Sign API requests using your API Bearer Token in the Authorization Header", createNewKey: "Create New Key", deleteApp: "Delete API Key", + newBearerToken: "New Bearer Token", + revokeToken: "Revoke Bearer Token", }; const planGraphicSize = 64; @@ -166,7 +169,7 @@ const GrayscaleOverlay = ({ } }; requestAnimationFrame(animate); - // eslint-disable-next-line react-hooks/exhaustive-deps -- only run when isExiting becomes true + // eslint-disable-next-line react-hooks/exhaustive-deps -- only run when isExiting becomes true }, [isExiting]); return (
void | Promise; + onAdd: () => void | Promise; +}) { + const [copied, setCopied] = useState(false); + const [pending, setPending] = useState(false); + + const handleCopy = () => { + navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + + const handleRevoke = async () => { + if (pending) return; + setPending(true); + try { + await onRevoke(); + } catch (err) { + console.error(err); + } finally { + setPending(false); + } + }; + + const handleAdd = async () => { + if (pending) return; + setPending(true); + try { + await onAdd(); + } catch (err) { + console.error(err); + } finally { + setPending(false); + } + }; + + return ( + + + {messages.apiSecretLabel} + + + + + { + e.stopPropagation(); + void handleRevoke(); + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + void handleRevoke(); + } + }} + css={css` + display: inline-flex; + cursor: pointer; + `} + > + + + + + { + e.stopPropagation(); + void handleAdd(); + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + void handleAdd(); + } + }} + css={css` + display: inline-flex; + cursor: pointer; + `} + > + + + + + + + ); +} + function AppAvatar({ app }: { app: DeveloperApp }) { const size = 24; if (app.image_url) { @@ -572,7 +718,8 @@ export default function App() { const [fullUser, setFullUser] = useState(null); const [developerApps, setDeveloperApps] = useState([]); const [createKeyModalOpen, setCreateKeyModalOpen] = useState(false); - const [deleteAppModalApp, setDeleteAppModalApp] = useState(null); + const [deleteAppModalApp, setDeleteAppModalApp] = + useState(null); // Grayscale "spotlight" effect when hovering Open Audio Protocol link const [grayscaleHover, setGrayscaleHover] = useState(false); @@ -714,6 +861,81 @@ export default function App() { [oauthUser?.userId, loadDeveloperApps], ); + const handleDeactivateAccessKey = useCallback( + async (app: DeveloperApp, apiAccessKey: string) => { + if (oauthUser?.userId == null) throw new Error("Not logged in"); + const token = sessionStorage.getItem(OAUTH_TOKEN_KEY); + const res = await fetch( + `${API_BASE}/v1/users/${encodeURIComponent(oauthUser.userId)}/developer-apps/${encodeURIComponent(app.address)}/access-keys/deactivate`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token != null ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ api_access_key: apiAccessKey }), + }, + ); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error( + (err as { error?: string })?.error ?? "Failed to revoke bearer token", + ); + } + setDeveloperApps((prev) => + prev.map((a) => { + if (a.address?.toLowerCase() !== app.address?.toLowerCase()) return a; + const keys = + a.api_access_keys?.filter( + (k) => k.api_access_key !== apiAccessKey, + ) ?? []; + return { ...a, api_access_keys: keys }; + }), + ); + }, + [oauthUser?.userId], + ); + + const handleAddAccessKey = useCallback( + async (app: DeveloperApp) => { + if (oauthUser?.userId == null) throw new Error("Not logged in"); + const token = sessionStorage.getItem(OAUTH_TOKEN_KEY); + const res = await fetch( + `${API_BASE}/v1/users/${encodeURIComponent(oauthUser.userId)}/developer-apps/${encodeURIComponent(app.address)}/access-keys`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token != null ? { Authorization: `Bearer ${token}` } : {}), + }, + }, + ); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error( + (err as { error?: string })?.error ?? "Failed to create bearer token", + ); + } + const result = (await res.json()) as { api_access_key: string }; + if (result.api_access_key != null) { + setDeveloperApps((prev) => + prev.map((a) => { + if (a.address?.toLowerCase() !== app.address?.toLowerCase()) + return a; + const keys = [...(a.api_access_keys ?? [])]; + keys.push({ + api_access_key: result.api_access_key, + is_active: true, + }); + return { ...a, api_access_keys: keys }; + }), + ); + navigator.clipboard.writeText(result.api_access_key); + } + }, + [oauthUser?.userId], + ); + const handleDeleteApp = useCallback( async (app: { address: string; name: string }) => { if (oauthUser?.userId == null) throw new Error("Not logged in"); @@ -734,9 +956,7 @@ export default function App() { ); } setDeleteAppModalApp(null); - setDeveloperApps((prev) => - prev.filter((a) => a.address !== app.address), - ); + setDeveloperApps((prev) => prev.filter((a) => a.address !== app.address)); loadDeveloperApps(); }, [oauthUser?.userId, loadDeveloperApps], @@ -952,28 +1172,28 @@ export default function App() { Bring music to all your apps. Vibe-code ready and performant access to the world's largest open music catalog, the  - { - grayscaleOrigin.current = { x: e.clientX, y: e.clientY }; - setGrayscaleExiting(false); - setGrayscaleHover(true); - }} - onMouseLeave={() => setGrayscaleExiting(true)} - css={css` - color: inherit; - text-decoration: underline; - cursor: pointer; - &:hover { - text-decoration: none; - } - `} - > - Open Audio Protocol - + { + grayscaleOrigin.current = { x: e.clientX, y: e.clientY }; + setGrayscaleExiting(false); + setGrayscaleHover(true); + }} + onMouseLeave={() => setGrayscaleExiting(true)} + css={css` + color: inherit; + text-decoration: underline; + cursor: pointer; + &:hover { + text-decoration: none; + } + `} + > + Open Audio Protocol + @@ -1238,9 +1458,14 @@ export default function App() { cursor: pointer; `} > - + @@ -1298,15 +1523,22 @@ export default function App() { {!app.is_legacy && (app.api_access_keys?.length ?? 0) > 0 - ? app.api_access_keys?.map((aak, idx) => ( - - )) + ? (app.api_access_keys + ?.filter((aak) => aak.is_active !== false) + ?.map((aak, idx) => ( + + handleDeactivateAccessKey( + app, + aak.api_access_key, + ) + } + onAdd={() => handleAddAccessKey(app)} + /> + )) ?? null) : null} From 6663f0d8c3892fa7fbc13ad19be8a225dde3c145 Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Thu, 12 Feb 2026 00:19:25 -0800 Subject: [PATCH 09/15] Mobile web --- static/plans/src/App.tsx | 174 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 172 insertions(+), 2 deletions(-) diff --git a/static/plans/src/App.tsx b/static/plans/src/App.tsx index cdf34f8a..2c46fd41 100644 --- a/static/plans/src/App.tsx +++ b/static/plans/src/App.tsx @@ -11,6 +11,7 @@ import { IconPlus, IconTrash, IconButton, + IconKebabHorizontal, Tag, Tooltip, IconClose, @@ -720,6 +721,20 @@ export default function App() { const [createKeyModalOpen, setCreateKeyModalOpen] = useState(false); const [deleteAppModalApp, setDeleteAppModalApp] = useState(null); + const [navMenuOpen, setNavMenuOpen] = useState(false); + const navMenuRef = useRef(null); + + useEffect(() => { + if (!navMenuOpen) return; + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as Node; + if (navMenuRef.current != null && !navMenuRef.current.contains(target)) { + setNavMenuOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [navMenuOpen]); // Grayscale "spotlight" effect when hovering Open Audio Protocol link const [grayscaleHover, setGrayscaleHover] = useState(false); @@ -1065,9 +1080,18 @@ export default function App() { css={css` padding-bottom: 1rem; border-bottom: 1px solid var(--harmony-neutral-neutral-3, #e8e8e8); + position: relative; `} > - + + {/* Mobile menu button + dropdown */} + + + setNavMenuOpen((o) => !o)} + /> + {navMenuOpen ? ( + + + setNavMenuOpen(false)} + css={css` + display: block; + padding: 0.5rem; + color: inherit; + text-decoration: none; + &:hover { + text-decoration: underline; + background: var(--harmony-neutral-neutral-2, #f0f0f0); + margin: 0 -0.5rem; + padding: 0.5rem; + } + `} + > + {messages.navAudius} + + setNavMenuOpen(false)} + css={css` + display: block; + padding: 0.5rem; + color: inherit; + text-decoration: none; + &:hover { + text-decoration: underline; + background: var(--harmony-neutral-neutral-2, #f0f0f0); + margin: 0 -0.5rem; + padding: 0.5rem; + } + `} + > + {messages.navApiDocs} + + setNavMenuOpen(false)} + css={css` + display: block; + padding: 0.5rem; + color: inherit; + text-decoration: none; + &:hover { + text-decoration: underline; + background: var(--harmony-neutral-neutral-2, #f0f0f0); + margin: 0 -0.5rem; + padding: 0.5rem; + } + `} + > + {messages.navGithub} + + setNavMenuOpen(false)} + css={css` + display: block; + padding: 0.5rem; + color: inherit; + text-decoration: none; + &:hover { + text-decoration: underline; + background: var(--harmony-neutral-neutral-2, #f0f0f0); + margin: 0 -0.5rem; + padding: 0.5rem; + } + `} + > + {messages.navDiscord} + + + + ) : null} + + {oauthUser ? (