diff --git a/.changeset/ranged-dashboard-metrics.md b/.changeset/ranged-dashboard-metrics.md new file mode 100644 index 0000000..aa43e59 --- /dev/null +++ b/.changeset/ranged-dashboard-metrics.md @@ -0,0 +1,11 @@ +--- +'@seamless-auth/types': minor +--- + +Time ranges for the dashboard metrics and security anomalies (fells-code/seamless-auth-api#132). + +- `DashboardMetricsQuerySchema` (`from`, `to`) and `SecurityAnomaliesQuerySchema` (`from`, `to`, `limit` 1 to 200 with a default of 200, `offset` with a default of 0). The range rules are the same as `MetricsQuerySchema`'s, which now shares them. +- `DashboardMetricsResponseSchema` gains optional `window`, `newUsers`, `loginSuccess`, `loginFailed`, `successRate`, `otpUsage` and `passkeyUsage`, which cover the requested window. The `*24h` fields keep meaning the last 24 hours. +- `SecurityAnomaliesResponseSchema` gains optional `window`, `limit` and `offset`, and `total` is documented as every matching event in the window. + +All the new response fields are optional, so a response from a server without ranges still parses. diff --git a/package-lock.json b/package-lock.json index ba733ab..33d1adb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@seamless-auth/types", - "version": "0.26.0", + "version": "0.27.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@seamless-auth/types", - "version": "0.26.0", + "version": "0.27.0", "license": "Apache-2.0", "dependencies": { "zod": "^4.3.6" diff --git a/src/schemas/metrics/schema.test.ts b/src/schemas/metrics/schema.test.ts index 8e3681c..93546e9 100644 --- a/src/schemas/metrics/schema.test.ts +++ b/src/schemas/metrics/schema.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect } from 'vitest'; -import { MetricsQuerySchema, PartialAuthEventSchema } from './schema.js'; +import { + DashboardMetricsQuerySchema, + DashboardMetricsResponseSchema, + MetricsQuerySchema, + PartialAuthEventSchema, + SecurityAnomaliesQuerySchema, + SecurityAnomaliesResponseSchema, +} from './schema.js'; describe('MetricsQuerySchema', () => { it('defaults the interval to hour', () => { @@ -51,3 +58,89 @@ describe('PartialAuthEventSchema', () => { expect(() => PartialAuthEventSchema.parse({})).not.toThrow(); }); }); + +describe('DashboardMetricsQuerySchema', () => { + it('accepts no range, which servers treat as the last 24 hours', () => { + expect(DashboardMetricsQuerySchema.parse({})).toEqual({}); + }); + + it('validates the range the same way the other metrics queries do', () => { + expect(() => + DashboardMetricsQuerySchema.parse({ + from: '2026-02-01T00:00:00.000Z', + to: '2026-01-01T00:00:00.000Z', + }), + ).toThrow(); + expect(() => DashboardMetricsQuerySchema.parse({ to: 'tomorrow' })).toThrow(); + expect(() => + DashboardMetricsQuerySchema.parse({ + from: '2024-01-01T00:00:00.000Z', + to: '2026-01-01T00:00:00.000Z', + }), + ).toThrow(); + }); +}); + +describe('SecurityAnomaliesQuerySchema', () => { + it('pages 200 at a time from the start by default', () => { + expect(SecurityAnomaliesQuerySchema.parse({})).toEqual({ limit: 200, offset: 0 }); + }); + + it('coerces query-string paging and bounds it', () => { + expect(SecurityAnomaliesQuerySchema.parse({ limit: '50', offset: '100' })).toEqual({ + limit: 50, + offset: 100, + }); + expect(() => SecurityAnomaliesQuerySchema.parse({ limit: '500' })).toThrow(); + expect(() => SecurityAnomaliesQuerySchema.parse({ offset: '-1' })).toThrow(); + }); + + it('rejects a reversed range', () => { + expect(() => + SecurityAnomaliesQuerySchema.parse({ + from: '2026-02-01T00:00:00.000Z', + to: '2026-01-01T00:00:00.000Z', + }), + ).toThrow(); + }); +}); + +describe('ranged metrics responses', () => { + const fixed = { + totalUsers: 10, + activeSessions: 4, + newUsers24h: 1, + loginSuccess24h: 9, + loginFailed24h: 1, + successRate24h: 90, + otpUsage24h: 2, + passkeyUsage24h: 7, + databaseSize: 1024, + }; + + it('still parses a dashboard response from a server without ranges', () => { + expect(DashboardMetricsResponseSchema.parse(fixed)).toEqual(fixed); + }); + + it('carries the window and the ranged figures alongside the 24 hour ones', () => { + const ranged = { + ...fixed, + window: { from: '2026-01-01T00:00:00.000Z', to: '2026-01-08T00:00:00.000Z' }, + newUsers: 5, + loginSuccess: 60, + loginFailed: 4, + successRate: 93.75, + otpUsage: 10, + passkeyUsage: 50, + }; + + expect(DashboardMetricsResponseSchema.parse(ranged)).toEqual(ranged); + }); + + it('still parses an anomalies response from a server without ranges', () => { + expect(SecurityAnomaliesResponseSchema.parse({ suspiciousEvents: [], total: 0 })).toEqual({ + suspiciousEvents: [], + total: 0, + }); + }); +}); diff --git a/src/schemas/metrics/schema.ts b/src/schemas/metrics/schema.ts index 7e9193e..dee629b 100644 --- a/src/schemas/metrics/schema.ts +++ b/src/schemas/metrics/schema.ts @@ -7,6 +7,53 @@ export const MetricsIntervalSchema = z.enum(['hour', 'day']); export type MetricsInterval = z.infer; +type RangeIssue = { path: ['from'] | ['to']; message: string }; + +// Shared by every metrics query that takes a range, so they agree on what a valid window is. +function timeRangeIssues(data: { + from?: string | undefined; + to?: string | undefined; +}): RangeIssue[] { + const fromDate = data.from ? new Date(data.from) : undefined; + const toDate = data.to ? new Date(data.to) : undefined; + + const fromValid = fromDate !== undefined && !Number.isNaN(fromDate.getTime()); + const toValid = toDate !== undefined && !Number.isNaN(toDate.getTime()); + const issues: RangeIssue[] = []; + + if (data.from !== undefined && !fromValid) { + issues.push({ path: ['from'], message: 'Invalid from date' }); + } + + if (data.to !== undefined && !toValid) { + issues.push({ path: ['to'], message: 'Invalid to date' }); + } + + if (!fromValid || !toValid || !fromDate || !toDate) { + return issues; + } + + if (fromDate.getTime() > toDate.getTime()) { + return [{ path: ['to'], message: 'from must be on or before to' }]; + } + + // Unbounded windows let a single request scan the whole event table. + if (toDate.getTime() - fromDate.getTime() > MAX_METRICS_WINDOW_MS) { + return [{ path: ['to'], message: 'time range exceeds the maximum window' }]; + } + + return issues; +} + +function refineTimeRange( + data: T, + ctx: z.RefinementCtx, +) { + for (const issue of timeRangeIssues(data)) { + ctx.addIssue({ code: 'custom', ...issue }); + } +} + export const MetricsQuerySchema = z .object({ userId: z.string().optional(), @@ -14,39 +61,7 @@ export const MetricsQuerySchema = z to: z.string().optional(), interval: MetricsIntervalSchema.optional().default('hour'), }) - .superRefine((data, ctx) => { - const fromDate = data.from ? new Date(data.from) : undefined; - const toDate = data.to ? new Date(data.to) : undefined; - - const fromValid = fromDate !== undefined && !Number.isNaN(fromDate.getTime()); - const toValid = toDate !== undefined && !Number.isNaN(toDate.getTime()); - - if (data.from !== undefined && !fromValid) { - ctx.addIssue({ code: 'custom', path: ['from'], message: 'Invalid from date' }); - } - - if (data.to !== undefined && !toValid) { - ctx.addIssue({ code: 'custom', path: ['to'], message: 'Invalid to date' }); - } - - if (!fromValid || !toValid || !fromDate || !toDate) { - return; - } - - if (fromDate.getTime() > toDate.getTime()) { - ctx.addIssue({ code: 'custom', path: ['to'], message: 'from must be on or before to' }); - return; - } - - // Unbounded windows let a single request scan the whole event table. - if (toDate.getTime() - fromDate.getTime() > MAX_METRICS_WINDOW_MS) { - ctx.addIssue({ - code: 'custom', - path: ['to'], - message: 'time range exceeds the maximum window', - }); - } - }); + .superRefine((data, ctx) => refineTimeRange(data, ctx)); export type MetricsQuery = z.infer; @@ -93,9 +108,42 @@ export const PartialAuthEventSchema = AuthEventSchema.partial(); export type PartialAuthEvent = z.infer; +/** The window a ranged response covers, as resolved by the server. */ +export const MetricsWindowSchema = z.object({ from: z.string(), to: z.string() }); + +export type MetricsWindow = z.infer; + +/** `from`/`to` default to the last 24 hours, as the endpoints did before they took a range. */ +export const DashboardMetricsQuerySchema = z + .object({ + from: z.string().optional(), + to: z.string().optional(), + }) + .superRefine((data, ctx) => refineTimeRange(data, ctx)); + +export type DashboardMetricsQuery = z.infer; + +export const SecurityAnomaliesQuerySchema = z + .object({ + from: z.string().optional(), + to: z.string().optional(), + limit: z.coerce.number().int().min(1).max(200).optional().default(200), + offset: z.coerce.number().int().min(0).optional().default(0), + }) + .superRefine((data, ctx) => refineTimeRange(data, ctx)); + +export type SecurityAnomaliesQuery = z.infer; + export const SecurityAnomaliesResponseSchema = z.object({ suspiciousEvents: z.array(PartialAuthEventSchema), + /** + * Every matching event in the window. Servers before the range was added reported + * the number returned, at most 200, here instead. + */ total: z.number().int().nonnegative(), + window: MetricsWindowSchema.optional(), + limit: z.number().int().optional(), + offset: z.number().int().optional(), }); export type SecurityAnomaliesResponse = z.infer; @@ -110,6 +158,18 @@ export const DashboardMetricsResponseSchema = z.object({ otpUsage24h: z.number(), passkeyUsage24h: z.number(), databaseSize: z.number(), + /** + * The same figures as the `*24h` fields, over the requested window instead of the + * last 24 hours. The `*24h` fields keep their meaning whatever window is asked for. + * Optional because servers before the range was added do not send them. + */ + window: MetricsWindowSchema.optional(), + newUsers: z.number().optional(), + loginSuccess: z.number().optional(), + loginFailed: z.number().optional(), + successRate: z.number().optional(), + otpUsage: z.number().optional(), + passkeyUsage: z.number().optional(), }); export type DashboardMetricsResponse = z.infer;