Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/ranged-dashboard-metrics.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

95 changes: 94 additions & 1 deletion src/schemas/metrics/schema.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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,
});
});
});
126 changes: 93 additions & 33 deletions src/schemas/metrics/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,46 +7,61 @@ export const MetricsIntervalSchema = z.enum(['hour', 'day']);

export type MetricsInterval = z.infer<typeof MetricsIntervalSchema>;

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<T extends { from?: string | undefined; to?: string | undefined }>(
data: T,
ctx: z.RefinementCtx<T>,
) {
for (const issue of timeRangeIssues(data)) {
ctx.addIssue({ code: 'custom', ...issue });
}
}

export const MetricsQuerySchema = z
.object({
userId: z.string().optional(),
from: z.string().optional(),
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<typeof MetricsQuerySchema>;

Expand Down Expand Up @@ -93,9 +108,42 @@ export const PartialAuthEventSchema = AuthEventSchema.partial();

export type PartialAuthEvent = z.infer<typeof PartialAuthEventSchema>;

/** 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<typeof MetricsWindowSchema>;

/** `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<typeof DashboardMetricsQuerySchema>;

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<typeof SecurityAnomaliesQuerySchema>;

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<typeof SecurityAnomaliesResponseSchema>;
Expand All @@ -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<typeof DashboardMetricsResponseSchema>;
Loading