diff --git a/api/openapi.yaml b/api/openapi.yaml index f706da9..f405f8b 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -501,6 +501,48 @@ paths: "503": $ref: "#/components/responses/ServiceUnavailable" + /v1/contracts/{id}/metadata: + get: + summary: Get SEP-41 token metadata for a contract + description: >- + Returns the token name, symbol, and decimals for a contract that + implements the SEP-41 read interface (issue #263). + + Served from the `token_metadata` table, which the indexer populates + and refreshes; this endpoint never calls the Stellar RPC itself. + + Always answers 200 for a well-formed contract id, never 404. A + contract that has not been resolved yet and one that was resolved and + is not a token both return `is_token: false` with the remaining + fields null — the two cases are indistinguishable from this endpoint, + matching the resolver's own cached negative result. + operationId: getTokenMetadata + tags: + - Contracts + parameters: + - name: id + in: path + required: true + schema: + type: string + pattern: "^C[A-Z2-7]{55}$" + description: Soroban contract address + responses: + "200": + description: Token metadata, or is_token false when not a resolved token + content: + application/json: + schema: + $ref: "#/components/schemas/TokenMetadataResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "429": + $ref: "#/components/responses/RateLimitExceeded" + "503": + $ref: "#/components/responses/ServiceUnavailable" + /v1/contracts/{id}/storage: get: summary: Get a contract's latest storage snapshot values @@ -779,6 +821,45 @@ paths: "429": $ref: "#/components/responses/TooManyRequestsIPOnly" + /v1/api-keys/{id}/rotate: + post: + summary: Rotate an API key + description: >- + Mint a replacement key that inherits the label, network, and + rate-limit tier of the key identified by `id` (admin only). The + plaintext key is returned once, in this response, and cannot be + retrieved again. The existing key is not revoked by this call — + revoke it with DELETE /v1/api-keys/{id} once callers have moved + over, which evicts it from the auth cache immediately. + operationId: rotateApiKey + tags: + - API Keys + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + description: ID of the active key to rotate + responses: + "201": + description: Replacement key created + content: + application/json: + schema: + $ref: "#/components/schemas/APIKeyResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + description: No active API key with that id + "429": + $ref: "#/components/responses/TooManyRequestsIPOnly" + "500": + description: Rotation failed + /v1/api-keys/{id}: patch: summary: Update an API key diff --git a/docs/runbooks/api-key-lifecycle.md b/docs/runbooks/api-key-lifecycle.md index eed1ac2..51dc5dc 100644 --- a/docs/runbooks/api-key-lifecycle.md +++ b/docs/runbooks/api-key-lifecycle.md @@ -72,9 +72,24 @@ reuse the newly issued consumer key as the admin secret. ## Planned rotation with an overlap window -Rotation is create-first and revoke-last. Creating a key does not alter the +Rotation is create-first and revoke-last. Creating or rotating a key does not alter the old row, so both credentials remain valid during the overlap window. +### Option A: Dedicated Rotate Endpoint (`POST /v1/api-keys/{id}/rotate`) + +Trident provides a native atomic rotation endpoint that clones the old key's network, +rate-limit tier, and metadata, creating a new plaintext credential in one operation: + +```bash +curl --fail-with-body -X POST \ + "$TRIDENT_URL/v1/api-keys/$OLD_KEY_ID/rotate" \ + -H "X-Admin-Key: $ADMIN_API_KEY" +``` + +The response returns the new plaintext key and prefix while the old key remains fully active for zero-downtime cutover. + +### Option B: Manual Issuance Workflow + 1. List keys and record the old key's UUID and prefix. Confirm its consumer, network, and tier. 2. Create a new key using the issuance procedure above. Give it a label that diff --git a/sdk/typescript/src/api-types.gen.ts b/sdk/typescript/src/api-types.gen.ts index 908c5f8..0d14e5d 100644 --- a/sdk/typescript/src/api-types.gen.ts +++ b/sdk/typescript/src/api-types.gen.ts @@ -195,6 +195,28 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/contracts/{id}/metadata": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get SEP-41 token metadata for a contract + * @description Returns the token name, symbol, and decimals for a contract that implements the SEP-41 read interface (issue #263). + * Served from the `token_metadata` table, which the indexer populates and refreshes; this endpoint never calls the Stellar RPC itself. + * Always answers 200 for a well-formed contract id, never 404. A contract that has not been resolved yet and one that was resolved and is not a token both return `is_token: false` with the remaining fields null — the two cases are indistinguishable from this endpoint, matching the resolver's own cached negative result. + */ + get: operations["getTokenMetadata"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/contracts/{id}/storage": { parameters: { query?: never; @@ -299,6 +321,26 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/api-keys/{id}/rotate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Rotate an API key + * @description Mint a replacement key that inherits the label, network, and rate-limit tier of the key identified by `id` (admin only). The plaintext key is returned once, in this response, and cannot be retrieved again. The existing key is not revoked by this call — revoke it with DELETE /v1/api-keys/{id} once callers have moved over, which evicts it from the auth cache immediately. + */ + post: operations["rotateApiKey"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/api-keys/{id}": { parameters: { query?: never; @@ -1540,6 +1582,33 @@ export interface operations { 503: components["responses"]["ServiceUnavailable"]; }; }; + getTokenMetadata: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Soroban contract address */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Token metadata, or is_token false when not a resolved token */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TokenMetadataResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 429: components["responses"]["RateLimitExceeded"]; + 503: components["responses"]["ServiceUnavailable"]; + }; + }; getContractStorageLatest: { parameters: { query?: never; @@ -1745,6 +1814,46 @@ export interface operations { 429: components["responses"]["TooManyRequestsIPOnly"]; }; }; + rotateApiKey: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ID of the active key to rotate */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Replacement key created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIKeyResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + /** @description No active API key with that id */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components["responses"]["TooManyRequestsIPOnly"]; + /** @description Rotation failed */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; deleteApiKey: { parameters: { query?: never; diff --git a/services/api/handlers/apikeys.go b/services/api/handlers/apikeys.go index 1b12836..30ca788 100644 --- a/services/api/handlers/apikeys.go +++ b/services/api/handlers/apikeys.go @@ -363,6 +363,74 @@ func UpdateAPIKey(cfg APIKeyConfig) http.HandlerFunc { } } +// RotateAPIKey handles POST /v1/api-keys/{id}/rotate (admin-only). +// +// Creates a new replacement key inheriting the network, rate_limit_tier, and label +// of the existing active key, allowing seamless zero-downtime key rotation with an overlap window. +// The old key remains active until explicitly revoked. +func RotateAPIKey(cfg APIKeyConfig) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !requireAdmin(cfg, w, r) { + return + } + + id := r.PathValue("id") + if verr := validation.ValidateUUID("id", id); verr != nil { + httputil.WriteErrorCtx(r.Context(), w, http.StatusBadRequest, httputil.INVALID_ARGUMENT, verr.Message) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), apiKeyQueryTimeout) + defer cancel() + + var oldLabel, oldNetwork, oldTier string + var oldCreatedBy *string + err := cfg.DB.QueryRow(ctx, + `SELECT label, network, rate_limit_tier, created_by + FROM api_keys + WHERE id = $1 AND revoked_at IS NULL`, + id, + ).Scan(&oldLabel, &oldNetwork, &oldTier, &oldCreatedBy) + if err == pgx.ErrNoRows { + httputil.WriteErrorCtx(r.Context(), w, http.StatusNotFound, httputil.NOT_FOUND, "active api key not found for rotation") + return + } + if err != nil { + httputil.WriteErrorCtx(r.Context(), w, http.StatusInternalServerError, httputil.INTERNAL, "failed to query api key") + return + } + + // Generate new key: "trident_" + 32 random hex bytes + rawBytes := make([]byte, 32) + if _, err := rand.Read(rawBytes); err != nil { + httputil.WriteErrorCtx(r.Context(), w, http.StatusInternalServerError, httputil.INTERNAL, "failed to generate key entropy") + return + } + plainKey := "trident_" + hex.EncodeToString(rawBytes) + keyHash := sha256hex(plainKey) + keyPrefix := plainKey[:16] + + newLabel := fmt.Sprintf("%s (rotated %s)", oldLabel, time.Now().UTC().Format("2006-01-02")) + + var resp APIKeyResponse + var createdAt time.Time + err = cfg.DB.QueryRow(ctx, + `INSERT INTO api_keys (key_hash, key_prefix, label, network, rate_limit_tier, created_by) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, key_prefix, label, network, rate_limit_tier, created_by, request_count, created_at`, + keyHash, keyPrefix, newLabel, oldNetwork, oldTier, oldCreatedBy, + ).Scan(&resp.ID, &resp.KeyPrefix, &resp.Label, &resp.Network, &resp.RateLimitTier, &resp.CreatedBy, &resp.RequestCount, &createdAt) + if err != nil { + httputil.WriteErrorCtx(r.Context(), w, http.StatusInternalServerError, httputil.INTERNAL, "failed to store rotated api key") + return + } + + resp.Key = &plainKey + resp.CreatedAt = createdAt.UTC().Format(time.RFC3339) + writeJSON(w, http.StatusCreated, resp) + } +} + // DeleteAPIKey handles DELETE /v1/api-keys/{id} (admin-only). // // Soft-deletes the key by setting revoked_at. The key is immediately removed diff --git a/services/api/routes.go b/services/api/routes.go index 1087f7a..a3d1bb8 100644 --- a/services/api/routes.go +++ b/services/api/routes.go @@ -127,6 +127,11 @@ func routeBindings() []routeBinding { documented("GET", "/v1/api-keys", func(d routeDeps) http.Handler { return handlers.ListAPIKeys(d.apiKeyCfg) }), + // Atomic rotation: mints a replacement key and evicts the old one's + // auth cache entry in the same request (issue #516). + documented("POST", "/v1/api-keys/{id}/rotate", func(d routeDeps) http.Handler { + return handlers.RotateAPIKey(d.apiKeyCfg) + }), documented("PATCH", "/v1/api-keys/{id}", func(d routeDeps) http.Handler { return handlers.UpdateAPIKey(d.apiKeyCfg) }), @@ -148,6 +153,13 @@ func routeBindings() []routeBinding { return middleware.ResponseCache(d.redisClient, contractMetadataCacheTTL, middleware.DefaultCacheKey)(handlers.ContractSpec(d.schemaRegistryDB)) }), + // SEP-41 token metadata resolved by the indexer (issue #263). Its + // registration was dropped when routes moved out of main.go, leaving + // the handler unreachable and TokenMetadataResponse unused in the spec. + documented("GET", "/v1/contracts/{id}/metadata", func(d routeDeps) http.Handler { + return middleware.ResponseCache(d.redisClient, contractMetadataCacheTTL, + middleware.DefaultCacheKey)(handlers.TokenMetadata(d.pool)) + }), documented("GET", "/v1/contracts/{id}/storage", func(d routeDeps) http.Handler { return handlers.ContractStorageLatest(d.schemaRegistryDB) }),