Files
supabase/apps/studio/data/analytics/api-keys-last-used-query.ts
Jordi EnricandJoshen Lim 3063679f1b feat(auth): restore key last-used timestamps FE-2462 FE-4315 (#50732)
## Problem

Studio expected aliased fields from the last-used API-key endpoint, but
the live endpoint returns OTEL attribute names. This kept legacy API-key
activity unavailable and prevented Studio from showing activity for new
JWT signing keys. Tracks FE-2462 and FE-4315.

## Fix

Normalize the endpoint response at the data boundary, keep the
`showApiKeysLastUsed` feature flag, and show activity from the past 24
hours for new JWT signing keys. Legacy HS256 signing keys remain blank
because the analytics response does not provide a stable signing-key
record ID for them. The request remains hosted-only, permission-gated,
and non-blocking, and the existing last-rotated column remains intact.

## How to test

- Make a request with a legacy anon or service-role API key, then open
Project Settings > API Keys and verify its last request appears.
- Make an Auth request signed by a new JWT signing key, then open JWT
Keys and verify the matching key shows a Last used timestamp.
- Verify a new key without activity shows No requests in the past 24
hours.
- Verify the legacy HS256 signing-key row leaves Last used blank.
- Expected result: legacy API keys and new JWT signing keys display
activity from the shared endpoint without changing self-hosted Studio.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added a **Last used** column for JWT signing keys on supported
platforms.
* Displays usage timestamps, loading and error states, or when a key has
had no requests in the past 24 hours.
  * Usage tracking now includes both API keys and JWT signing keys.
* **Bug Fixes**
  * Improved handling of usage records for legacy and current keys.
* Usage details appear only on supported platforms and for users with
the required permissions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
2026-09-23 13:46:00 +02:00

107 lines
3.5 KiB
TypeScript

import { useQuery } from '@tanstack/react-query'
import { z } from 'zod'
import { analyticsKeys } from './keys'
import { get, handleError } from '@/data/fetchers'
import { IS_PLATFORM } from '@/lib/constants'
import type { UseCustomQueryOptions } from '@/types'
export type ApiKeysLastUsedVariables = {
projectRef?: string
isoTimestampStart?: string
isoTimestampEnd?: string
}
const apiKeyLastUsedSchema = z.object({
timestamp: z.number(),
role: z.string().optional(),
signaturePrefix: z.string().optional(),
keyId: z.string().optional(),
})
export type ApiKeyLastUsed = z.infer<typeof apiKeyLastUsedSchema>
const apiKeyLastUsedEndpointRowSchema = z
.object({
timestamp: z.number().finite(),
role: z.string().nullish(),
signature_prefix: z.string().nullish(),
key_id: z.string().nullish(),
request_sb_jwt_authorization_payload_role: z.string().nullish(),
request_sb_jwt_authorization_payload_signature_prefix: z.string().nullish(),
request_sb_jwt_authorization_payload_key_id: z.string().nullish(),
})
.transform((row): ApiKeyLastUsed => {
const role = row.role || row.request_sb_jwt_authorization_payload_role
const signaturePrefix =
row.signature_prefix || row.request_sb_jwt_authorization_payload_signature_prefix
const keyId = row.key_id || row.request_sb_jwt_authorization_payload_key_id
const apiKeyLastUsed: ApiKeyLastUsed = { timestamp: row.timestamp }
if (role) apiKeyLastUsed.role = role
if (signaturePrefix) apiKeyLastUsed.signaturePrefix = signaturePrefix
if (keyId) apiKeyLastUsed.keyId = keyId
return apiKeyLastUsed
})
.pipe(apiKeyLastUsedSchema)
export const apiKeysLastUsedSchema = z.array(apiKeyLastUsedEndpointRowSchema)
export const getJWTSigningKeyLastUsedAt = (rows: ApiKeyLastUsed[], keyId: string) =>
rows.reduce<number | undefined>((latestTimestamp, row) => {
if (row.keyId !== keyId) return latestTimestamp
if (latestTimestamp === undefined) return row.timestamp
return Math.max(latestTimestamp, row.timestamp)
}, undefined)
export async function getApiKeysLastUsed(
{ projectRef, isoTimestampStart, isoTimestampEnd }: ApiKeysLastUsedVariables,
signal?: AbortSignal
) {
if (!projectRef) {
throw new Error('projectRef is required')
}
const { data, error } = await get(
'/platform/projects/{ref}/analytics/endpoints/api_keys.last_used.otel',
{
params: {
path: { ref: projectRef },
query: {
iso_timestamp_start: isoTimestampStart,
iso_timestamp_end: isoTimestampEnd,
},
},
signal,
}
)
if (error) handleError(error)
if (data?.error) {
throw new Error(
typeof data.error === 'string' ? data.error : 'Failed to fetch last-used API keys'
)
}
return apiKeysLastUsedSchema.parse(data?.result ?? [])
}
export type ApiKeysLastUsedData = Awaited<ReturnType<typeof getApiKeysLastUsed>>
export type ApiKeysLastUsedError = Error
export const useApiKeysLastUsedQuery = <TData = ApiKeysLastUsedData>(
{ projectRef, isoTimestampStart, isoTimestampEnd }: ApiKeysLastUsedVariables,
{
enabled = true,
...options
}: UseCustomQueryOptions<ApiKeysLastUsedData, ApiKeysLastUsedError, TData> = {}
) =>
useQuery<ApiKeysLastUsedData, ApiKeysLastUsedError, TData>({
queryKey: analyticsKeys.apiKeysLastUsed(projectRef, { isoTimestampStart, isoTimestampEnd }),
queryFn: ({ signal }) =>
getApiKeysLastUsed({ projectRef, isoTimestampStart, isoTimestampEnd }, signal),
enabled: IS_PLATFORM && enabled && typeof projectRef !== 'undefined',
...options,
})