@brika/auth 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +207 -0
- package/package.json +50 -0
- package/src/__tests__/AuthClient.test.ts +736 -0
- package/src/__tests__/AuthService.test.ts +140 -0
- package/src/__tests__/ScopeService.test.ts +156 -0
- package/src/__tests__/SessionService.test.ts +311 -0
- package/src/__tests__/UserService-avatar.test.ts +277 -0
- package/src/__tests__/UserService.test.ts +223 -0
- package/src/__tests__/canAccess.test.ts +166 -0
- package/src/__tests__/disabledScopes.test.ts +101 -0
- package/src/__tests__/middleware.test.ts +190 -0
- package/src/__tests__/plugin.test.ts +78 -0
- package/src/__tests__/requireSession.test.ts +78 -0
- package/src/__tests__/routes-auth.test.ts +248 -0
- package/src/__tests__/routes-profile.test.ts +403 -0
- package/src/__tests__/routes-scopes.test.ts +64 -0
- package/src/__tests__/routes-sessions.test.ts +235 -0
- package/src/__tests__/routes-users.test.ts +477 -0
- package/src/__tests__/serveImage.test.ts +277 -0
- package/src/__tests__/setup.test.ts +270 -0
- package/src/__tests__/verifyToken.test.ts +219 -0
- package/src/client/AuthClient.ts +312 -0
- package/src/client/http-client.ts +84 -0
- package/src/client/index.ts +19 -0
- package/src/config.ts +82 -0
- package/src/constants.ts +10 -0
- package/src/index.ts +16 -0
- package/src/lib/define-roles.ts +35 -0
- package/src/lib/define-scopes.ts +48 -0
- package/src/middleware/canAccess.ts +126 -0
- package/src/middleware/index.ts +13 -0
- package/src/middleware/requireAuth.ts +35 -0
- package/src/middleware/requireScope.ts +46 -0
- package/src/middleware/verifyToken.ts +52 -0
- package/src/plugin.ts +86 -0
- package/src/react/AuthProvider.tsx +105 -0
- package/src/react/hooks.ts +128 -0
- package/src/react/index.ts +51 -0
- package/src/react/withScopeGuard.tsx +73 -0
- package/src/roles.ts +40 -0
- package/src/schemas.ts +112 -0
- package/src/scopes.ts +60 -0
- package/src/server/index.ts +44 -0
- package/src/server/requireSession.ts +44 -0
- package/src/server/routes/auth.ts +102 -0
- package/src/server/routes/cookie.ts +7 -0
- package/src/server/routes/index.ts +32 -0
- package/src/server/routes/profile.ts +162 -0
- package/src/server/routes/scopes.ts +22 -0
- package/src/server/routes/sessions.ts +68 -0
- package/src/server/routes/setup.ts +50 -0
- package/src/server/routes/users.ts +175 -0
- package/src/server/serveImage.ts +91 -0
- package/src/services/AuthService.ts +80 -0
- package/src/services/ScopeService.ts +94 -0
- package/src/services/SessionService.ts +245 -0
- package/src/services/UserService.ts +245 -0
- package/src/setup.ts +99 -0
- package/src/tanstack/index.ts +15 -0
- package/src/tanstack/routeBuilder.ts +311 -0
- package/src/types.ts +118 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @brika/auth - TanStack Router Protected Routes
|
|
3
|
+
*
|
|
4
|
+
* Declarative route definitions with nesting, scope inheritance,
|
|
5
|
+
* and automatic route tree building.
|
|
6
|
+
*
|
|
7
|
+
* Auth gating is handled at the RootLayout level (redirect to /login).
|
|
8
|
+
* Scope protection is handled via withScopeGuard HOC.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { type AnyRoute, createRoute, type RouteComponent } from '@tanstack/react-router';
|
|
12
|
+
import React from 'react';
|
|
13
|
+
import { withScopeGuard } from '../react/withScopeGuard';
|
|
14
|
+
import { Scope } from '../types';
|
|
15
|
+
|
|
16
|
+
// ─── Path param type extraction ──────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/** Normalize bare splat `$` to `_splat` (matches TanStack Router convention) */
|
|
19
|
+
export type NormalizeParam<P extends string> = P extends '' ? '_splat' : P;
|
|
20
|
+
|
|
21
|
+
/** Extract `$param` names from a path string into a union */
|
|
22
|
+
export type ExtractParams<T extends string> = T extends `${string}/$${infer Param}/${infer Rest}`
|
|
23
|
+
? NormalizeParam<Param> | ExtractParams<`/${Rest}`>
|
|
24
|
+
: T extends `${string}/$${infer Param}`
|
|
25
|
+
? NormalizeParam<Param>
|
|
26
|
+
: never;
|
|
27
|
+
|
|
28
|
+
/** If the path has params, require them. Otherwise no args needed. */
|
|
29
|
+
export type ParamsArg<T extends string> = [ExtractParams<T>] extends [never]
|
|
30
|
+
? []
|
|
31
|
+
: [params: Record<ExtractParams<T>, string>];
|
|
32
|
+
|
|
33
|
+
// ─── Definition types (input) ────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A route definition. Pure data — no TanStack-specific callbacks.
|
|
37
|
+
*/
|
|
38
|
+
export interface ProtectedRouteDefinition<TPath extends string = string> {
|
|
39
|
+
path: TPath;
|
|
40
|
+
/** undefined = inherit parent scopes. null = no scope check. */
|
|
41
|
+
scopes?: Scope | Scope[] | null;
|
|
42
|
+
component: React.ComponentType;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A route definition that may have nested children.
|
|
47
|
+
* Children inherit parent scopes unless they explicitly set their own.
|
|
48
|
+
*/
|
|
49
|
+
export interface ProtectedRouteWithChildren<TPath extends string = string>
|
|
50
|
+
extends ProtectedRouteDefinition<TPath> {
|
|
51
|
+
children?: Record<string, ProtectedRouteDefinition<string>>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ─── Result types (output) ────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Result for a single route with navigation + permission metadata.
|
|
58
|
+
*/
|
|
59
|
+
export interface ProtectedRouteResult<TPath extends string = string> {
|
|
60
|
+
/** The TanStack route object */
|
|
61
|
+
route: AnyRoute;
|
|
62
|
+
/** The URL path pattern */
|
|
63
|
+
path: TPath;
|
|
64
|
+
/** Effective scopes (after inheritance). null = authenticated only. */
|
|
65
|
+
scopes: Scope | Scope[] | null;
|
|
66
|
+
/** Resolve path params to a concrete URL */
|
|
67
|
+
to(...args: ParamsArg<TPath>): string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ─── Result type mapping ─────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
type UnionToIntersection<U> = (U extends unknown ? (k: U) => void : never) extends (
|
|
73
|
+
k: infer I
|
|
74
|
+
) => void
|
|
75
|
+
? I
|
|
76
|
+
: never;
|
|
77
|
+
|
|
78
|
+
/** Compute the full absolute path for a child route */
|
|
79
|
+
type ChildAbsolutePath<Parent extends string, Child extends string> = Child extends '/'
|
|
80
|
+
? Parent
|
|
81
|
+
: Child extends `/${string}`
|
|
82
|
+
? `${Parent}${Child}`
|
|
83
|
+
: `${Parent}/${Child}`;
|
|
84
|
+
|
|
85
|
+
/** Extract and flatten all children maps into a single intersection */
|
|
86
|
+
type FlattenChildren<T> = UnionToIntersection<
|
|
87
|
+
{
|
|
88
|
+
[K in keyof T]: T[K] extends {
|
|
89
|
+
path: infer PP extends string;
|
|
90
|
+
children: infer C extends Record<string, ProtectedRouteDefinition>;
|
|
91
|
+
}
|
|
92
|
+
? { [CK in keyof C]: ProtectedRouteResult<ChildAbsolutePath<PP, C[CK]['path']>> }
|
|
93
|
+
: Record<string, never>;
|
|
94
|
+
}[keyof T]
|
|
95
|
+
>;
|
|
96
|
+
|
|
97
|
+
/** Map a group of definitions to results, flattening children within the group */
|
|
98
|
+
type GroupResults<T extends Record<string, ProtectedRouteDefinition | ProtectedRouteWithChildren>> =
|
|
99
|
+
{
|
|
100
|
+
[K in keyof T]: ProtectedRouteResult<T[K]['path']>;
|
|
101
|
+
} & FlattenChildren<T>;
|
|
102
|
+
|
|
103
|
+
/** Map grouped definitions to nested route results */
|
|
104
|
+
type GroupedRouteResults<
|
|
105
|
+
T extends Record<string, Record<string, ProtectedRouteDefinition | ProtectedRouteWithChildren>>,
|
|
106
|
+
> = {
|
|
107
|
+
[G in keyof T]: GroupResults<T[G]>;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// ─── Options ─────────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
export interface ProtectedRoutesOptions {
|
|
113
|
+
/** Component rendered when a user lacks required scopes (403). */
|
|
114
|
+
defaultForbiddenComponent?: React.ComponentType;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/** Replace $param segments with actual values (supports splat $) */
|
|
120
|
+
export function resolvePath(path: string, params?: Record<string, string>): string {
|
|
121
|
+
if (!params) {
|
|
122
|
+
return path;
|
|
123
|
+
}
|
|
124
|
+
let resolved = path.replaceAll(/\$(\w+)/g, (_, key) => params[key] ?? `$${key}`);
|
|
125
|
+
// Handle trailing splat param ($)
|
|
126
|
+
if (resolved.endsWith('/$') && params._splat !== null && params._splat !== undefined) {
|
|
127
|
+
resolved = resolved.slice(0, -1) + params._splat;
|
|
128
|
+
}
|
|
129
|
+
return resolved;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Resolve the absolute path for a child route */
|
|
133
|
+
function resolveChildPath(parentPath: string, childPath: string): string {
|
|
134
|
+
if (childPath === '/') {
|
|
135
|
+
return parentPath;
|
|
136
|
+
}
|
|
137
|
+
if (childPath.startsWith('/')) {
|
|
138
|
+
return `${parentPath}${childPath}`;
|
|
139
|
+
}
|
|
140
|
+
return `${parentPath}/${childPath}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Build child routes and register them in the group results */
|
|
144
|
+
function processChildren(
|
|
145
|
+
children: Record<string, ProtectedRouteDefinition>,
|
|
146
|
+
parentPath: string,
|
|
147
|
+
parentRoute: AnyRoute,
|
|
148
|
+
parentScopes: Scope | Scope[] | null,
|
|
149
|
+
groupResults: Record<string, ProtectedRouteResult>,
|
|
150
|
+
options?: ProtectedRoutesOptions
|
|
151
|
+
): void {
|
|
152
|
+
const childRoutes: AnyRoute[] = [];
|
|
153
|
+
|
|
154
|
+
for (const [childKey, childDef] of Object.entries(children)) {
|
|
155
|
+
const childScopes = childDef.scopes === undefined ? parentScopes : (childDef.scopes ?? null);
|
|
156
|
+
const fullChildPath = resolveChildPath(parentPath, childDef.path);
|
|
157
|
+
const childResult = buildRoute(
|
|
158
|
+
childDef,
|
|
159
|
+
() => parentRoute,
|
|
160
|
+
childScopes,
|
|
161
|
+
options,
|
|
162
|
+
fullChildPath
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
groupResults[childKey] = childResult;
|
|
166
|
+
childRoutes.push(childResult.route);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
parentRoute.addChildren(childRoutes);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Create a ProtectedRouteResult from a definition + parent reference */
|
|
173
|
+
function buildRoute<TPath extends string>(
|
|
174
|
+
definition: ProtectedRouteDefinition<TPath>,
|
|
175
|
+
getParentRoute: () => AnyRoute,
|
|
176
|
+
effectiveScopes: Scope | Scope[] | null,
|
|
177
|
+
options?: ProtectedRoutesOptions,
|
|
178
|
+
absolutePath?: string
|
|
179
|
+
): ProtectedRouteResult<TPath> {
|
|
180
|
+
const { path, component } = definition;
|
|
181
|
+
|
|
182
|
+
const guardOptions = options?.defaultForbiddenComponent
|
|
183
|
+
? {
|
|
184
|
+
fallback: React.createElement(options.defaultForbiddenComponent),
|
|
185
|
+
}
|
|
186
|
+
: undefined;
|
|
187
|
+
|
|
188
|
+
const guardedComponent =
|
|
189
|
+
effectiveScopes === null ? component : withScopeGuard(component, effectiveScopes, guardOptions);
|
|
190
|
+
|
|
191
|
+
const route = createRoute({
|
|
192
|
+
getParentRoute,
|
|
193
|
+
path: path as string,
|
|
194
|
+
component: guardedComponent as RouteComponent,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const displayPath = absolutePath ?? path;
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
route: route as AnyRoute,
|
|
201
|
+
path: displayPath as TPath,
|
|
202
|
+
scopes: effectiveScopes,
|
|
203
|
+
to: ((...args: unknown[]) =>
|
|
204
|
+
resolvePath(
|
|
205
|
+
displayPath,
|
|
206
|
+
args[0] as Record<string, string> | undefined
|
|
207
|
+
)) as ProtectedRouteResult<TPath>['to'],
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ─── Main API ─────────────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Create protected routes with declarative nesting and scope inheritance.
|
|
215
|
+
*
|
|
216
|
+
* Routes are organized into named groups. Each group becomes a namespace
|
|
217
|
+
* on the returned `routes` object.
|
|
218
|
+
*
|
|
219
|
+
* - Pass rootRoute as first argument
|
|
220
|
+
* - Nest children inline via `children: { ... }`
|
|
221
|
+
* - Children inherit parent scopes (set explicitly to override, or null to clear)
|
|
222
|
+
* - Returns `{ routes, routeTree }` — grouped route map + assembled TanStack tree
|
|
223
|
+
*
|
|
224
|
+
* @example
|
|
225
|
+
* ```ts
|
|
226
|
+
* const { routes, routeTree } = createProtectedRoutes(rootRoute, {
|
|
227
|
+
* dashboard: { index: page('/') },
|
|
228
|
+
* plugins: {
|
|
229
|
+
* list: page('/plugins', ..., Scope.PLUGIN_READ),
|
|
230
|
+
* detail: {
|
|
231
|
+
* ...page('/plugins/$uid', ..., Scope.PLUGIN_READ),
|
|
232
|
+
* children: {
|
|
233
|
+
* overview: page('/'), // inherits PLUGIN_READ
|
|
234
|
+
* tab: page('$tab'), // inherits PLUGIN_READ
|
|
235
|
+
* },
|
|
236
|
+
* },
|
|
237
|
+
* },
|
|
238
|
+
* });
|
|
239
|
+
*
|
|
240
|
+
* routes.dashboard.index.path // '/'
|
|
241
|
+
* routes.plugins.list.scopes // Scope.PLUGIN_READ
|
|
242
|
+
* routes.plugins.detail.to({ uid: '...' })
|
|
243
|
+
* ```
|
|
244
|
+
*/
|
|
245
|
+
export function createProtectedRoutes<
|
|
246
|
+
const T extends Record<
|
|
247
|
+
string,
|
|
248
|
+
Record<string, ProtectedRouteDefinition<string> | ProtectedRouteWithChildren<string>>
|
|
249
|
+
>,
|
|
250
|
+
>(
|
|
251
|
+
rootRoute: AnyRoute,
|
|
252
|
+
groups: T,
|
|
253
|
+
options?: ProtectedRoutesOptions
|
|
254
|
+
): {
|
|
255
|
+
routes: GroupedRouteResults<T>;
|
|
256
|
+
routeTree: AnyRoute;
|
|
257
|
+
} {
|
|
258
|
+
const groupedRoutes: Record<string, Record<string, ProtectedRouteResult>> = {};
|
|
259
|
+
const topLevelRoutes: AnyRoute[] = [];
|
|
260
|
+
|
|
261
|
+
for (const [groupName, definitions] of Object.entries(groups)) {
|
|
262
|
+
groupedRoutes[groupName] = {};
|
|
263
|
+
|
|
264
|
+
for (const [key, definition] of Object.entries(definitions)) {
|
|
265
|
+
const effectiveScopes = definition.scopes ?? null;
|
|
266
|
+
const result = buildRoute(definition, () => rootRoute, effectiveScopes, options);
|
|
267
|
+
groupedRoutes[groupName][key] = result;
|
|
268
|
+
|
|
269
|
+
// Process children if present
|
|
270
|
+
const children = (definition as ProtectedRouteWithChildren).children;
|
|
271
|
+
if (children) {
|
|
272
|
+
processChildren(
|
|
273
|
+
children,
|
|
274
|
+
definition.path,
|
|
275
|
+
result.route,
|
|
276
|
+
effectiveScopes,
|
|
277
|
+
groupedRoutes[groupName],
|
|
278
|
+
options
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
topLevelRoutes.push(result.route);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const routeTree = rootRoute.addChildren(topLevelRoutes);
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
routes: groupedRoutes as GroupedRouteResults<T>,
|
|
290
|
+
routeTree,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ─── Single-route API (advanced use) ─────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
/** Definition with explicit getParentRoute for advanced / one-off use. */
|
|
297
|
+
export interface LegacyRouteDefinition<TPath extends string = string>
|
|
298
|
+
extends ProtectedRouteDefinition<TPath> {
|
|
299
|
+
getParentRoute: () => AnyRoute;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Create a single protected route with explicit parent.
|
|
304
|
+
* Prefer createProtectedRoutes() for most use cases.
|
|
305
|
+
*/
|
|
306
|
+
export function createProtectedRoute<TPath extends string>(
|
|
307
|
+
definition: LegacyRouteDefinition<TPath>
|
|
308
|
+
): ProtectedRouteResult<TPath> {
|
|
309
|
+
const { getParentRoute, ...rest } = definition;
|
|
310
|
+
return buildRoute(rest, getParentRoute, rest.scopes ?? null);
|
|
311
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @brika/auth - Core Type Definitions
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Role } from './roles';
|
|
6
|
+
import type { Scope } from './scopes';
|
|
7
|
+
|
|
8
|
+
export { Role } from './roles';
|
|
9
|
+
export { Scope } from './scopes';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* User account
|
|
13
|
+
*/
|
|
14
|
+
export interface User {
|
|
15
|
+
id: string;
|
|
16
|
+
email: string;
|
|
17
|
+
name: string;
|
|
18
|
+
role: Role;
|
|
19
|
+
avatarHash: string | null;
|
|
20
|
+
createdAt: Date;
|
|
21
|
+
updatedAt: Date;
|
|
22
|
+
isActive: boolean;
|
|
23
|
+
scopes: Scope[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Active session (attached to request context by middleware)
|
|
28
|
+
*/
|
|
29
|
+
export interface Session {
|
|
30
|
+
id: string;
|
|
31
|
+
userId: string;
|
|
32
|
+
userEmail: string;
|
|
33
|
+
userName: string;
|
|
34
|
+
userRole: Role;
|
|
35
|
+
scopes: Scope[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Session record stored in DB
|
|
40
|
+
*/
|
|
41
|
+
export interface SessionRecord {
|
|
42
|
+
id: string;
|
|
43
|
+
userId: string;
|
|
44
|
+
tokenHash: string;
|
|
45
|
+
ip: string | null;
|
|
46
|
+
userAgent: string | null;
|
|
47
|
+
createdAt: number;
|
|
48
|
+
lastSeenAt: number;
|
|
49
|
+
expiresAt: number;
|
|
50
|
+
revokedAt: number | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* API token for third-party integrations
|
|
55
|
+
*/
|
|
56
|
+
export interface ApiToken {
|
|
57
|
+
id: string;
|
|
58
|
+
userId: string;
|
|
59
|
+
name: string;
|
|
60
|
+
tokenHash: string;
|
|
61
|
+
scopes: Scope[];
|
|
62
|
+
createdAt: Date;
|
|
63
|
+
expiresAt?: Date;
|
|
64
|
+
lastUsedAt?: Date;
|
|
65
|
+
revokedAt?: Date;
|
|
66
|
+
usageCount?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Response when creating an API token
|
|
71
|
+
*/
|
|
72
|
+
export interface ApiTokenCreated {
|
|
73
|
+
id: string;
|
|
74
|
+
name: string;
|
|
75
|
+
plaintext: string;
|
|
76
|
+
scopes: Scope[];
|
|
77
|
+
createdAt: Date;
|
|
78
|
+
expiresAt?: Date;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Login request/response
|
|
83
|
+
*/
|
|
84
|
+
export interface LoginRequest {
|
|
85
|
+
email: string;
|
|
86
|
+
password: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface LoginResponse {
|
|
90
|
+
token: string;
|
|
91
|
+
user: User;
|
|
92
|
+
expiresIn: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Create user request
|
|
97
|
+
*/
|
|
98
|
+
export interface CreateUserRequest {
|
|
99
|
+
email: string;
|
|
100
|
+
name: string;
|
|
101
|
+
role: Role;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Session configuration
|
|
106
|
+
*/
|
|
107
|
+
export interface SessionConfig {
|
|
108
|
+
sessionTTL: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Error response
|
|
113
|
+
*/
|
|
114
|
+
export interface ErrorResponse {
|
|
115
|
+
error: string;
|
|
116
|
+
message: string;
|
|
117
|
+
details?: Record<string, unknown>;
|
|
118
|
+
}
|