Skip to content
Merged
81 changes: 81 additions & 0 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
17 changes: 16 additions & 1 deletion docs/runbooks/api-key-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
109 changes: 109 additions & 0 deletions sdk/typescript/src/api-types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
68 changes: 68 additions & 0 deletions services/api/handlers/apikeys.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions services/api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}),
Expand All @@ -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)
}),
Expand Down
Loading