@adonis-agora/authkit-server 1.0.0-rc.16 → 1.0.0-rc.17

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/build/index.d.ts +3 -3
  3. package/build/index.js +2 -1
  4. package/build/src/audit/audit_sink.d.ts +26 -19
  5. package/build/src/audit/lucid_audit_sink.js +99 -28
  6. package/build/src/commands/expire_scan_command.js +0 -2
  7. package/build/src/dev_tools/tools.js +10 -12
  8. package/build/src/host/account_export_service.js +0 -2
  9. package/build/src/host/admin_api/api_clients_controller.d.ts +6 -1
  10. package/build/src/host/admin_api/api_clients_controller.js +9 -5
  11. package/build/src/host/admin_api/api_misc_controller.d.ts +17 -7
  12. package/build/src/host/admin_api/api_misc_controller.js +10 -8
  13. package/build/src/host/admin_api/api_orgs_controller.d.ts +5 -1
  14. package/build/src/host/admin_api/api_orgs_controller.js +10 -8
  15. package/build/src/host/admin_api/api_users_controller.d.ts +15 -8
  16. package/build/src/host/admin_api/api_users_controller.js +11 -9
  17. package/build/src/host/admin_console/console_audit_controller.d.ts +14 -7
  18. package/build/src/host/admin_console/console_audit_controller.js +7 -8
  19. package/build/src/host/admin_console/console_clients_controller.d.ts +2 -1
  20. package/build/src/host/admin_console/console_clients_controller.js +5 -5
  21. package/build/src/host/admin_console/console_orgs_controller.d.ts +2 -1
  22. package/build/src/host/admin_console/console_orgs_controller.js +6 -2
  23. package/build/src/host/admin_console/console_overview_controller.d.ts +1 -1
  24. package/build/src/host/admin_console/console_overview_controller.js +4 -6
  25. package/build/src/host/admin_console/console_personal_agents_controller.d.ts +17 -20
  26. package/build/src/host/admin_console/console_personal_agents_controller.js +29 -8
  27. package/build/src/host/admin_console/console_sessions_controller.d.ts +11 -10
  28. package/build/src/host/admin_console/console_sessions_controller.js +41 -18
  29. package/build/src/host/admin_console/console_users_controller.d.ts +12 -8
  30. package/build/src/host/admin_console/console_users_controller.js +7 -12
  31. package/build/src/host/admin_stats_service.js +4 -4
  32. package/build/src/host/list_api.d.ts +118 -0
  33. package/build/src/host/list_api.js +236 -0
  34. package/build/src/host/login_attempt.js +0 -2
  35. package/build/src/host/login_notify.js +1 -2
  36. package/build/src/host/session_context.js +1 -2
  37. package/build/src/host/ui-dist/assets/index-SteA4yAA.js +67 -0
  38. package/build/src/host/ui-dist/index.html +1 -1
  39. package/build/src/pagination.d.ts +5 -31
  40. package/build/src/pagination.js +5 -12
  41. package/package.json +3 -2
  42. package/build/src/host/ui-dist/assets/index-Dq3dJVBv.js +0 -67
@@ -0,0 +1,118 @@
1
+ import { BaseFilter, type CursorPageResponse, type OffsetPage, type ResolvedSortItem } from '@adonis-agora/filter';
2
+ import type { HttpContext } from '@adonisjs/core/http';
3
+ import type { AccountStore, AuthAccount } from '../accounts/account_store.js';
4
+ import type { AuditSink, StoredAuditEvent } from '../audit/audit_sink.js';
5
+ /**
6
+ * The list wire format of the admin console, the Admin REST API and the account API:
7
+ * `@adonis-agora/filter`'s. `filter[field]=v` (or `filter[field][]=a&filter[field][]=b`),
8
+ * `search=term`, `sort=-field`, then either `page`/`size` (offset lists, answered with
9
+ * `{ data, meta: { total, perPage, currentPage, lastPage, firstPage } }`) or
10
+ * `after`/`before` + `first`/`last` (the audit log, answered with
11
+ * `{ data, meta: { nextCursor, prevCursor, hasNext, hasPrev } }`).
12
+ *
13
+ * An unknown filter field, an unsupported operator or a sort the list cannot do throws
14
+ * `InvalidColumnFilterError`; {@link listQueryError} turns it into a 400.
15
+ */
16
+ /** A decoded query string (`ctx.request.qs()`), or the same shape built by hand. */
17
+ export type ListQuery = Record<string, unknown>;
18
+ /** What a filter class fills in: field → the values it must match (one = equals, several = any). */
19
+ export declare class ListDraft {
20
+ readonly where: Map<string, string[]>;
21
+ /** The values of `field`, or `undefined` when the request does not filter on it. */
22
+ get(field: string): string[] | undefined;
23
+ /** The single value of `field` (the first one when several were sent). */
24
+ first(field: string): string | undefined;
25
+ }
26
+ /**
27
+ * Base of every authkit list filter: each field method accepts `equals` / `in` and narrows the
28
+ * draft to those values. A blank value is the same as no filter.
29
+ */
30
+ declare abstract class ValuesFilter extends BaseFilter<ListDraft> {
31
+ $query: ListDraft;
32
+ protected narrow(field: string, value: unknown, operator: string): void;
33
+ }
34
+ /** Lists with no filter fields (users, clients, organizations): any `filter[...]` is a 400. */
35
+ export declare class NoListFilter extends ValuesFilter {
36
+ }
37
+ /** `GET …/audit`: `filter[type]` (one or more event types) and `filter[subject]` (an account id). */
38
+ export declare class AuditListFilter extends ValuesFilter {
39
+ type(value: unknown, operator: string): void;
40
+ subject(value: unknown, operator: string): void;
41
+ }
42
+ /** `GET {admin}/api/sessions`: `filter[accountId]`. */
43
+ export declare class SessionListFilter extends ValuesFilter {
44
+ accountId(value: unknown, operator: string): void;
45
+ }
46
+ /** `GET {admin}/api/personal-agents/connections`: `filter[protocol|clientId|accountId]`. */
47
+ export declare class ConnectionListFilter extends ValuesFilter {
48
+ protocol(value: unknown, operator: string): void;
49
+ clientId(value: unknown, operator: string): void;
50
+ accountId(value: unknown, operator: string): void;
51
+ }
52
+ type ListFilterClass = new () => ValuesFilter;
53
+ /** Runs `cls` over the request's filters. Throws `InvalidColumnFilterError` on a bad one. */
54
+ export declare function readListFilter(qs: ListQuery, cls: ListFilterClass): Promise<ListDraft>;
55
+ /** The trimmed `search` term, or `''`. */
56
+ export declare function readSearch(qs: ListQuery): string;
57
+ /**
58
+ * The request's sort, checked against what the list can do. `fixed` is for a list whose store
59
+ * has a single order: only that order is accepted.
60
+ */
61
+ export declare function readSort<F extends string>(qs: ListQuery, options: {
62
+ sortable: readonly F[];
63
+ default?: string;
64
+ fixed?: boolean;
65
+ }): ResolvedSortItem<F>[];
66
+ /** `page` (1-based) and `size` (default 20, at most 200). */
67
+ export declare function readPage(qs: ListQuery): {
68
+ page: number;
69
+ size: number;
70
+ };
71
+ /** `after` / `before` and the page size (`first` / `last`, default 20, at most 200). */
72
+ export declare function readCursor(qs: ListQuery): {
73
+ after?: string;
74
+ before?: string;
75
+ size: number;
76
+ };
77
+ /**
78
+ * Maps a refused list query (`InvalidColumnFilterError`) to `400 { error: { code:
79
+ * 'invalid_list_query', message } }`; anything else is rethrown.
80
+ */
81
+ export declare function listQueryError(ctx: HttpContext, error: unknown): void;
82
+ /** Runs `handler`, answering a refused list query with a 400 (see {@link listQueryError}). */
83
+ export declare function withListQuery<T>(ctx: HttpContext, handler: () => Promise<T>): Promise<void | T>;
84
+ type Accessor<T> = (row: T) => string | number | null | undefined;
85
+ /** Sorts a copy of `rows` by `sort`, through `accessors` (nulls last). */
86
+ export declare function sortRows<T, F extends string>(rows: readonly T[], sort: readonly ResolvedSortItem<F>[], accessors: Record<F, Accessor<T>>): T[];
87
+ /** Keeps the rows where some of `fields` contains `search` (case-insensitive). */
88
+ export declare function searchRows<T>(rows: readonly T[], search: string, fields: Accessor<T>[]): T[];
89
+ /** One offset page of an array the store returns whole. */
90
+ export declare function pageRows<T>(rows: readonly T[], qs: ListQuery): OffsetPage<T>;
91
+ /** `GET /users`: `search` (email), `sort=email` only (the store's order), `page`/`size`. */
92
+ export declare function listAccountsPage(store: AccountStore, qs: ListQuery): Promise<OffsetPage<AuthAccount>>;
93
+ /**
94
+ * `GET /audit`, newest first: `filter[type]`, `filter[subject]`, `sort=-createdAt` only,
95
+ * `after`/`before` + `first`/`last`.
96
+ */
97
+ export declare function listAuditPage(sink: AuditSink, qs: ListQuery): Promise<CursorPageResponse<StoredAuditEvent>>;
98
+ /** The OIDC client fields the clients list searches and sorts on. */
99
+ interface ClientRow {
100
+ clientId: string;
101
+ clientName?: string | null;
102
+ }
103
+ /** `GET /clients`: `search` (client id or name), `sort=clientId|clientName`, `page`/`size`. */
104
+ export declare function listClientsPage<T extends ClientRow>(clients: readonly T[], qs: ListQuery): Promise<OffsetPage<T>>;
105
+ /** The organization fields the organizations list searches and sorts on. */
106
+ interface OrgRow {
107
+ id: string;
108
+ name: string;
109
+ slug: string;
110
+ createdAt?: unknown;
111
+ memberCount?: number;
112
+ }
113
+ /**
114
+ * `GET /organizations`: `search` (name or slug), `sort=name|slug|createdAt|memberCount`,
115
+ * `page`/`size`.
116
+ */
117
+ export declare function listOrgsPage<T extends OrgRow>(orgs: readonly T[], qs: ListQuery): Promise<OffsetPage<T>>;
118
+ export {};
@@ -0,0 +1,236 @@
1
+ import { applyCustomFilter, BaseFilter, cursorPage, InvalidColumnFilterError, offsetPage, parseCursorParams, parsePageParams, resolveSort, } from '@adonis-agora/filter';
2
+ import { ADMIN_LIST_HTTP_DEFAULT_SIZE, ADMIN_LIST_MAX_SIZE } from '../pagination.js';
3
+ import { apiError } from './admin_api/dto.js';
4
+ /** Page size policy of every authkit list: 20 by default, at most 200. */
5
+ const SIZE_POLICY = { defaultSize: ADMIN_LIST_HTTP_DEFAULT_SIZE, maxSize: ADMIN_LIST_MAX_SIZE };
6
+ /** What a filter class fills in: field → the values it must match (one = equals, several = any). */
7
+ export class ListDraft {
8
+ where = new Map();
9
+ /** The values of `field`, or `undefined` when the request does not filter on it. */
10
+ get(field) {
11
+ return this.where.get(field);
12
+ }
13
+ /** The single value of `field` (the first one when several were sent). */
14
+ first(field) {
15
+ return this.where.get(field)?.[0];
16
+ }
17
+ }
18
+ /** A repeated param arrives as an array, a single one as a scalar, `a,b` already split. */
19
+ function asValues(value) {
20
+ const list = Array.isArray(value)
21
+ ? value
22
+ : value !== null && typeof value === 'object'
23
+ ? Object.values(value)
24
+ : [value];
25
+ return list
26
+ .filter((v) => v !== undefined && v !== null && String(v).trim() !== '')
27
+ .map((v) => String(v).trim());
28
+ }
29
+ /**
30
+ * Base of every authkit list filter: each field method accepts `equals` / `in` and narrows the
31
+ * draft to those values. A blank value is the same as no filter.
32
+ */
33
+ class ValuesFilter extends BaseFilter {
34
+ narrow(field, value, operator) {
35
+ if (operator !== 'equals' && operator !== 'in' && operator !== 'isAnyOf') {
36
+ throw new InvalidColumnFilterError(`"${field}" does not support the "${operator}" operator.`);
37
+ }
38
+ const values = asValues(value);
39
+ if (values.length > 0)
40
+ this.$query.where.set(field, values);
41
+ }
42
+ }
43
+ /** Lists with no filter fields (users, clients, organizations): any `filter[...]` is a 400. */
44
+ export class NoListFilter extends ValuesFilter {
45
+ }
46
+ /** `GET …/audit`: `filter[type]` (one or more event types) and `filter[subject]` (an account id). */
47
+ export class AuditListFilter extends ValuesFilter {
48
+ type(value, operator) {
49
+ this.narrow('type', value, operator);
50
+ }
51
+ subject(value, operator) {
52
+ this.narrow('subject', value, operator);
53
+ }
54
+ }
55
+ /** `GET {admin}/api/sessions`: `filter[accountId]`. */
56
+ export class SessionListFilter extends ValuesFilter {
57
+ accountId(value, operator) {
58
+ this.narrow('accountId', value, operator);
59
+ }
60
+ }
61
+ /** `GET {admin}/api/personal-agents/connections`: `filter[protocol|clientId|accountId]`. */
62
+ export class ConnectionListFilter extends ValuesFilter {
63
+ protocol(value, operator) {
64
+ this.narrow('protocol', value, operator);
65
+ }
66
+ clientId(value, operator) {
67
+ this.narrow('clientId', value, operator);
68
+ }
69
+ accountId(value, operator) {
70
+ this.narrow('accountId', value, operator);
71
+ }
72
+ }
73
+ /** Runs `cls` over the request's filters. Throws `InvalidColumnFilterError` on a bad one. */
74
+ export async function readListFilter(qs, cls) {
75
+ const draft = new ListDraft();
76
+ await applyCustomFilter(draft, cls, { request: { qs: () => ({ ...qs }) } });
77
+ return draft;
78
+ }
79
+ /** The trimmed `search` term, or `''`. */
80
+ export function readSearch(qs) {
81
+ return typeof qs.search === 'string' ? qs.search.trim() : '';
82
+ }
83
+ /**
84
+ * The request's sort, checked against what the list can do. `fixed` is for a list whose store
85
+ * has a single order: only that order is accepted.
86
+ */
87
+ export function readSort(qs, options) {
88
+ const sort = resolveSort(qs.sort, {
89
+ sortable: options.sortable,
90
+ ...(options.default !== undefined && { default: options.default }),
91
+ });
92
+ if (options.fixed && options.default !== undefined) {
93
+ const wanted = resolveSort(options.default, { sortable: options.sortable });
94
+ const same = sort.length === wanted.length &&
95
+ sort.every((s, i) => s.field === wanted[i]?.field && s.direction === wanted[i]?.direction);
96
+ if (!same) {
97
+ throw new InvalidColumnFilterError(`This list is always sorted by "${options.default}"; it cannot be sorted otherwise.`);
98
+ }
99
+ }
100
+ return sort;
101
+ }
102
+ /** `page` (1-based) and `size` (default 20, at most 200). */
103
+ export function readPage(qs) {
104
+ return parsePageParams(qs, SIZE_POLICY);
105
+ }
106
+ /** `after` / `before` and the page size (`first` / `last`, default 20, at most 200). */
107
+ export function readCursor(qs) {
108
+ const { after, before, size } = parseCursorParams(qs, SIZE_POLICY);
109
+ return { ...(after !== undefined && { after }), ...(before !== undefined && { before }), size };
110
+ }
111
+ /**
112
+ * Maps a refused list query (`InvalidColumnFilterError`) to `400 { error: { code:
113
+ * 'invalid_list_query', message } }`; anything else is rethrown.
114
+ */
115
+ export function listQueryError(ctx, error) {
116
+ if (error instanceof InvalidColumnFilterError) {
117
+ return ctx.response.badRequest(apiError('invalid_list_query', error.message));
118
+ }
119
+ throw error;
120
+ }
121
+ /** Runs `handler`, answering a refused list query with a 400 (see {@link listQueryError}). */
122
+ export async function withListQuery(ctx, handler) {
123
+ try {
124
+ return await handler();
125
+ }
126
+ catch (error) {
127
+ return listQueryError(ctx, error);
128
+ }
129
+ }
130
+ /** Sorts a copy of `rows` by `sort`, through `accessors` (nulls last). */
131
+ export function sortRows(rows, sort, accessors) {
132
+ if (sort.length === 0)
133
+ return [...rows];
134
+ return [...rows].sort((a, b) => {
135
+ for (const { field, direction } of sort) {
136
+ const av = accessors[field](a);
137
+ const bv = accessors[field](b);
138
+ if (av === bv)
139
+ continue;
140
+ if (av === null || av === undefined)
141
+ return 1;
142
+ if (bv === null || bv === undefined)
143
+ return -1;
144
+ const cmp = typeof av === 'number' && typeof bv === 'number'
145
+ ? av - bv
146
+ : String(av).localeCompare(String(bv));
147
+ if (cmp !== 0)
148
+ return direction === 'desc' ? -cmp : cmp;
149
+ }
150
+ return 0;
151
+ });
152
+ }
153
+ /** Keeps the rows where some of `fields` contains `search` (case-insensitive). */
154
+ export function searchRows(rows, search, fields) {
155
+ const term = search.toLowerCase();
156
+ if (!term)
157
+ return [...rows];
158
+ return rows.filter((row) => fields.some((f) => {
159
+ const v = f(row);
160
+ return v !== null && v !== undefined && String(v).toLowerCase().includes(term);
161
+ }));
162
+ }
163
+ /** One offset page of an array the store returns whole. */
164
+ export function pageRows(rows, qs) {
165
+ const { page, size } = readPage(qs);
166
+ const start = (page - 1) * size;
167
+ return offsetPage(rows.slice(start, start + size), { total: rows.length, page, size });
168
+ }
169
+ // ─── Lists shared by the console, the Admin API and the embedded SDK ─────────
170
+ /** `GET /users`: `search` (email), `sort=email` only (the store's order), `page`/`size`. */
171
+ export async function listAccountsPage(store, qs) {
172
+ await readListFilter(qs, NoListFilter);
173
+ readSort(qs, { sortable: ['email'], default: 'email', fixed: true });
174
+ const { page, size } = readPage(qs);
175
+ const result = await store.listAccounts({ search: readSearch(qs), page, size });
176
+ return offsetPage(result.data, { total: result.total, page, size });
177
+ }
178
+ /**
179
+ * `GET /audit`, newest first: `filter[type]`, `filter[subject]`, `sort=-createdAt` only,
180
+ * `after`/`before` + `first`/`last`.
181
+ */
182
+ export async function listAuditPage(sink, qs) {
183
+ if (typeof sink.list !== 'function')
184
+ throw new Error('The audit sink cannot be queried.');
185
+ const filter = await readListFilter(qs, AuditListFilter);
186
+ readSort(qs, { sortable: ['createdAt'], default: '-createdAt', fixed: true });
187
+ const { after, before, size } = readCursor(qs);
188
+ const type = filter.get('type');
189
+ const subject = filter.first('subject');
190
+ const page = await sink.list({
191
+ ...(type && { type }),
192
+ ...(subject && { subject }),
193
+ ...(after !== undefined && { after }),
194
+ ...(before !== undefined && { before }),
195
+ size,
196
+ });
197
+ return cursorPage(page.data, {
198
+ nextCursor: page.nextCursor,
199
+ prevCursor: page.prevCursor,
200
+ hasNext: page.hasNext,
201
+ hasPrev: page.hasPrev,
202
+ });
203
+ }
204
+ /** `GET /clients`: `search` (client id or name), `sort=clientId|clientName`, `page`/`size`. */
205
+ export async function listClientsPage(clients, qs) {
206
+ await readListFilter(qs, NoListFilter);
207
+ const sort = readSort(qs, { sortable: ['clientId', 'clientName'], default: 'clientId' });
208
+ const found = searchRows(clients, readSearch(qs), [(c) => c.clientId, (c) => c.clientName]);
209
+ return pageRows(sortRows(found, sort, { clientId: (c) => c.clientId, clientName: (c) => c.clientName }), qs);
210
+ }
211
+ function isoOf(value) {
212
+ if (value === null || value === undefined)
213
+ return null;
214
+ if (value instanceof Date)
215
+ return value.toISOString();
216
+ const iso = value.toISO?.();
217
+ return iso ?? String(value);
218
+ }
219
+ /**
220
+ * `GET /organizations`: `search` (name or slug), `sort=name|slug|createdAt|memberCount`,
221
+ * `page`/`size`.
222
+ */
223
+ export async function listOrgsPage(orgs, qs) {
224
+ await readListFilter(qs, NoListFilter);
225
+ const sort = readSort(qs, {
226
+ sortable: ['name', 'slug', 'createdAt', 'memberCount'],
227
+ default: 'name',
228
+ });
229
+ const found = searchRows(orgs, readSearch(qs), [(o) => o.name, (o) => o.slug]);
230
+ return pageRows(sortRows(found, sort, {
231
+ name: (o) => o.name,
232
+ slug: (o) => o.slug,
233
+ createdAt: (o) => isoOf(o.createdAt),
234
+ memberCount: (o) => o.memberCount ?? 0,
235
+ }), qs);
236
+ }
@@ -1,5 +1,4 @@
1
1
  import { supportsAccountStatus, supportsEmailVerificationStatus, supportsPasswordExpiration, } from '../accounts/account_store.js';
2
- import { LIST_FIRST_PAGE } from '../pagination.js';
3
2
  import { createAccountLockout } from './account_lockout.js';
4
3
  import { resolveEffectiveAccountExpiration, resolveEffectiveLockout, resolveEffectivePasswordExpiration, resolveEffectiveRequireVerifiedEmail, resolveEffectiveRequireVerifiedEmailFull, } from './runtime_toggles.js';
5
4
  /**
@@ -101,7 +100,6 @@ export async function isAccountExpired(audit, accountId, settings, logger) {
101
100
  const result = await audit.list({
102
101
  type: 'login.success',
103
102
  subject: accountId,
104
- page: LIST_FIRST_PAGE,
105
103
  size: 1,
106
104
  });
107
105
  if (result.data.length === 0) {
@@ -1,4 +1,4 @@
1
- import { ADMIN_LIST_MAX_SIZE, LIST_FIRST_PAGE } from '../pagination.js';
1
+ import { ADMIN_LIST_MAX_SIZE } from '../pagination.js';
2
2
  import { sendNewDeviceLoginEmail, sendNewLoginEmail } from './default_mailer.js';
3
3
  import { isTrustedDeviceValid, TRUSTED_DEVICE_COOKIE } from './trusted_device.js';
4
4
  /**
@@ -130,7 +130,6 @@ async function maybeNotifyNewLogin(ctx, cfg, data) {
130
130
  const page = await cfg.audit.list({
131
131
  type: 'login.success',
132
132
  subject: accountId,
133
- page: LIST_FIRST_PAGE,
134
133
  size: ADMIN_LIST_MAX_SIZE,
135
134
  });
136
135
  const sameIpCount = page.data.filter((e) => e.ip === ip).length;
@@ -1,4 +1,4 @@
1
- import { ADMIN_LIST_MAX_SIZE, LIST_FIRST_PAGE } from '../pagination.js';
1
+ import { ADMIN_LIST_MAX_SIZE } from '../pagination.js';
2
2
  import { resolveGeoSafe } from './geo.js';
3
3
  import { parseUserAgent } from './user_agent.js';
4
4
  /**
@@ -31,7 +31,6 @@ export async function enrichSessionsWithContext(cfg, accountId, sessions) {
31
31
  const page = await cfg.audit.list({
32
32
  type: 'login.success',
33
33
  subject: accountId,
34
- page: LIST_FIRST_PAGE,
35
34
  size: ADMIN_LIST_MAX_SIZE,
36
35
  });
37
36
  const events = page.data.map((e) => ({