@adonis-agora/authkit-server 0.82.2 → 0.82.3

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.
@@ -608,6 +608,18 @@ export interface OrganizationsCapability {
608
608
  listOrgsForAccount(accountId: string): Promise<Array<OrgSummary & {
609
609
  role: string;
610
610
  }>>;
611
+ /**
612
+ * OPTIONAL: every org that has at least one member whose account exists, with
613
+ * its member count, in a bounded number of queries. Must return exactly what
614
+ * the admin fallback builds by walking `listAccounts` (admin order) and calling
615
+ * `listOrgsForAccount` per account: orgs in first-seen order, `role` being the
616
+ * role of the first member seen in that order, `memberCount` being
617
+ * `listOrgMembers(orgId).length`. Absent → the admin service uses that walk.
618
+ */
619
+ listAllOrgsWithMemberCount?(): Promise<Array<OrgSummary & {
620
+ role: string;
621
+ memberCount: number;
622
+ }>>;
611
623
  updateOrg(orgId: string, patch: {
612
624
  name?: string;
613
625
  logoUrl?: string | null;
@@ -121,6 +121,35 @@ function resolveAccountTable(Model) {
121
121
  }
122
122
  return undefined;
123
123
  }
124
+ /** Up to this many ids, filter with `WHERE IN`; above it, read every id instead. */
125
+ const ADMIN_ORDER_IN_LIMIT = 500;
126
+ /**
127
+ * Of `accountIds`, the ones that exist, in the order `listAccounts` pages them
128
+ * (`ORDER BY email ASC`, sorted by the database so collation matches). One query,
129
+ * plain rows (no model hydration), primary key only.
130
+ */
131
+ async function listAccountIdsInAdminOrder(Model, accountIds) {
132
+ if (accountIds.length === 0)
133
+ return [];
134
+ Model.boot?.();
135
+ const pk = Model.$columnsDefinitions?.get?.('id')?.columnName ?? 'id';
136
+ const readAll = async () => (await Model.query().select(pk).orderBy('email', 'asc').pojo());
137
+ let rows;
138
+ if (accountIds.length <= ADMIN_ORDER_IN_LIMIT) {
139
+ try {
140
+ rows = await Model.query().select(pk).whereIn(pk, accountIds).orderBy('email', 'asc').pojo();
141
+ }
142
+ catch {
143
+ // e.g. a member row whose account id doesn't fit the key type: the full read
144
+ // never binds member ids, exactly like the `listAccounts` walk.
145
+ rows = await readAll();
146
+ }
147
+ }
148
+ else {
149
+ rows = await readAll();
150
+ }
151
+ return rows.map((row) => String(row[pk]));
152
+ }
124
153
  export function lucidAccountStore(Model, options = {}) {
125
154
  const mfaIssuer = options.mfaIssuer ?? 'AuthKit';
126
155
  const recoveryCodeCount = options.recoveryCodeCount ?? 8;
@@ -242,6 +271,7 @@ export function lucidAccountStore(Model, options = {}) {
242
271
  const row = await Model.find(accountId);
243
272
  return row?.email ?? null;
244
273
  },
274
+ listAccountIdsInAdminOrder: (accountIds) => listAccountIdsInAdminOrder(Model, accountIds),
245
275
  }),
246
276
  }
247
277
  : {}),
@@ -9,5 +9,11 @@ export interface OrgStoreContext {
9
9
  InvitationModel: any;
10
10
  /** Mapa accountId → email, para validar aceitação de convite. */
11
11
  findAccountEmail: (accountId: string) => Promise<string | null>;
12
+ /**
13
+ * Of the given account ids, the ones whose account exists, in the admin
14
+ * listing order (`listAccounts`: email asc). When present, the store exposes
15
+ * `listAllOrgsWithMemberCount` (bounded queries for the admin org list).
16
+ */
17
+ listAccountIdsInAdminOrder?: (accountIds: string[]) => Promise<string[]>;
12
18
  }
13
19
  export declare function buildOrganizations(ctx: OrgStoreContext): OrganizationsCapability;
@@ -1,5 +1,15 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
2
  import { DateTime } from 'luxon';
3
+ /** Max ids per `WHERE IN` (stays well below every dialect's bind-parameter limit). */
4
+ const IN_CHUNK = 500;
5
+ /** `Model.findMany` in chunks of {@link IN_CHUNK} ids. */
6
+ async function findManyChunked(Model, ids) {
7
+ const rows = [];
8
+ for (let i = 0; i < ids.length; i += IN_CHUNK) {
9
+ rows.push(...(await Model.findMany(ids.slice(i, i + IN_CHUNK))));
10
+ }
11
+ return rows;
12
+ }
3
13
  function toOrgSummary(row) {
4
14
  return {
5
15
  id: row.id,
@@ -49,8 +59,62 @@ async function countOwners(MemberModel, orgId) {
49
59
  return Number(result[0]?.$extras?.total ?? 0);
50
60
  }
51
61
  export function buildOrganizations(ctx) {
52
- const { OrgModel, MemberModel, InvitationModel, findAccountEmail } = ctx;
62
+ const { OrgModel, MemberModel, InvitationModel, findAccountEmail, listAccountIdsInAdminOrder } = ctx;
53
63
  return {
64
+ ...(listAccountIdsInAdminOrder
65
+ ? {
66
+ /**
67
+ * Admin org list in 3 queries (all memberships, the member accounts in
68
+ * admin order, the orgs) instead of walking every account. Mirrors the
69
+ * fallback in `AdminOrgsService.listOrgs` exactly: accounts in
70
+ * `listAccounts` order (email asc), each account's memberships in the
71
+ * same (natural) order `listOrgsForAccount` reads them, first sighting of
72
+ * an org wins (its `role`), and `memberCount` counts every member row
73
+ * (like `listOrgMembers(orgId).length`).
74
+ */
75
+ async listAllOrgsWithMemberCount() {
76
+ const memberships = await MemberModel.query();
77
+ if (memberships.length === 0)
78
+ return [];
79
+ const byAccount = new Map();
80
+ const memberCount = new Map();
81
+ for (const m of memberships) {
82
+ const accountId = String(m.accountId);
83
+ const orgId = String(m.organizationId);
84
+ memberCount.set(orgId, (memberCount.get(orgId) ?? 0) + 1);
85
+ const list = byAccount.get(accountId);
86
+ if (list)
87
+ list.push(m);
88
+ else
89
+ byAccount.set(accountId, [m]);
90
+ }
91
+ const accountIds = await listAccountIdsInAdminOrder([...byAccount.keys()]);
92
+ const orgs = new Map();
93
+ for (const org of await findManyChunked(OrgModel, [...memberCount.keys()])) {
94
+ orgs.set(String(org.id), org);
95
+ }
96
+ const result = [];
97
+ const seen = new Set();
98
+ for (const accountId of accountIds) {
99
+ for (const m of byAccount.get(String(accountId)) ?? []) {
100
+ const orgId = String(m.organizationId);
101
+ if (seen.has(orgId))
102
+ continue;
103
+ const org = orgs.get(orgId);
104
+ if (!org)
105
+ continue;
106
+ seen.add(orgId);
107
+ result.push({
108
+ ...toOrgSummary(org),
109
+ role: m.role,
110
+ memberCount: memberCount.get(orgId) ?? 0,
111
+ });
112
+ }
113
+ }
114
+ return result;
115
+ },
116
+ }
117
+ : {}),
54
118
  async createOrg(input) {
55
119
  const { randomUUID } = await import('node:crypto');
56
120
  const org = await OrgModel.create({
@@ -55,6 +55,11 @@ export class AdminOrgsService {
55
55
  const store = this.cfg.accountStore;
56
56
  if (!supportsOrganizations(store))
57
57
  return { ok: false, reason: 'not_supported' };
58
+ // Fast path: stores that can answer in a bounded number of queries (the Lucid
59
+ // store does). Same list, same order as the walk below.
60
+ if (typeof store.listAllOrgsWithMemberCount === 'function') {
61
+ return store.listAllOrgsWithMemberCount();
62
+ }
58
63
  // O store não tem listAllOrgs — acumulamos via memberships de todas as contas.
59
64
  // Para admin, fazemos um listAccounts full e coletamos todas as orgs únicas.
60
65
  // Mais eficiente: usa a tabela de organizations diretamente pelo listOrgMembers
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adonis-agora/authkit-server",
3
- "version": "0.82.2",
3
+ "version": "0.82.3",
4
4
  "description": "AdonisJS OIDC/OAuth2 provider (Identity Provider) toolkit: ejectable auth server with sessions, rate-limiting, MFA/TOTP, audit log, federated logout and OpenTelemetry metrics.",
5
5
  "license": "MIT",
6
6
  "author": "dudousxd",