@adonis-agora/authkit-server 0.82.1 → 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;
@@ -1,4 +1,3 @@
1
- import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server';
2
1
  import { PasswordManager } from '../password/password_manager.js';
3
2
  import { buildCore } from './lucid_store/core.js';
4
3
  import { buildLoginMethods, supportsLoginMethodsColumn } from './lucid_store/login_methods.js';
@@ -7,7 +6,7 @@ import { defaultOrganizationModels } from './lucid_store/organization_models.js'
7
6
  import { buildOrganizations } from './lucid_store/organizations.js';
8
7
  import { buildPasswordExpiration, buildPasswordHistory } from './lucid_store/password_hygiene.js';
9
8
  import { buildProviderIdentity } from './lucid_store/provider_identity.js';
10
- import { hasTable, } from './lucid_store/shared.js';
9
+ import { defaultWebauthnCeremonies, hasTable, } from './lucid_store/shared.js';
11
10
  import { buildDeletion, buildEmailVerificationStatus, buildProfile, buildStatus, hasColumn, } from './lucid_store/status_profile.js';
12
11
  import { buildWebauthn } from './lucid_store/webauthn.js';
13
12
  export function resolveEmailTokens(input) {
@@ -122,6 +121,35 @@ function resolveAccountTable(Model) {
122
121
  }
123
122
  return undefined;
124
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
+ }
125
153
  export function lucidAccountStore(Model, options = {}) {
126
154
  const mfaIssuer = options.mfaIssuer ?? 'AuthKit';
127
155
  const recoveryCodeCount = options.recoveryCodeCount ?? 8;
@@ -147,10 +175,7 @@ export function lucidAccountStore(Model, options = {}) {
147
175
  };
148
176
  // Cerimônias WebAuthn: reais por default, injetáveis para testes.
149
177
  const ceremonies = {
150
- generateRegistrationOptions,
151
- verifyRegistrationResponse,
152
- generateAuthenticationOptions,
153
- verifyAuthenticationResponse,
178
+ ...defaultWebauthnCeremonies,
154
179
  ...options.webauthnCeremonies,
155
180
  };
156
181
  const passwords = new PasswordManager(options.password, {
@@ -246,6 +271,7 @@ export function lucidAccountStore(Model, options = {}) {
246
271
  const row = await Model.find(accountId);
247
272
  return row?.email ?? null;
248
273
  },
274
+ listAccountIdsInAdminOrder: (accountIds) => listAccountIdsInAdminOrder(Model, accountIds),
249
275
  }),
250
276
  }
251
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({
@@ -1,4 +1,4 @@
1
- import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse } from '@simplewebauthn/server';
1
+ import type { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse } from '@simplewebauthn/server';
2
2
  import type { AuditSink } from '../../audit/audit_sink.js';
3
3
  import type { PasswordManager } from '../../password/password_manager.js';
4
4
  import type { AuthAccount } from '../account_store.js';
@@ -23,6 +23,12 @@ export interface WebauthnCeremonies {
23
23
  generateAuthenticationOptions: typeof generateAuthenticationOptions;
24
24
  verifyAuthenticationResponse: typeof verifyAuthenticationResponse;
25
25
  }
26
+ /**
27
+ * As cerimônias reais (default de {@link WebauthnCeremonies}). Todas já são
28
+ * assíncronas no `@simplewebauthn/server`; cada uma só espera o import lazy antes
29
+ * de delegar.
30
+ */
31
+ export declare const defaultWebauthnCeremonies: WebauthnCeremonies;
26
32
  /** RP (Relying Party) do WebAuthn usado nas cerimônias. */
27
33
  export interface ResolvedRp {
28
34
  rpName: string;
@@ -1,4 +1,28 @@
1
1
  import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ /**
3
+ * `@simplewebauthn/server` carregado na primeira cerimônia, não no boot: a maioria
4
+ * dos processos (workers, ace, apps sem passkeys) nunca faz uma. Um import por
5
+ * processo; uma falha não fica em cache (a próxima cerimônia tenta de novo).
6
+ */
7
+ let simpleWebauthn;
8
+ function loadSimpleWebauthn() {
9
+ simpleWebauthn ??= import('@simplewebauthn/server').catch((error) => {
10
+ simpleWebauthn = undefined;
11
+ throw error;
12
+ });
13
+ return simpleWebauthn;
14
+ }
15
+ /**
16
+ * As cerimônias reais (default de {@link WebauthnCeremonies}). Todas já são
17
+ * assíncronas no `@simplewebauthn/server`; cada uma só espera o import lazy antes
18
+ * de delegar.
19
+ */
20
+ export const defaultWebauthnCeremonies = {
21
+ generateRegistrationOptions: async (options) => (await loadSimpleWebauthn()).generateRegistrationOptions(options),
22
+ verifyRegistrationResponse: async (options) => (await loadSimpleWebauthn()).verifyRegistrationResponse(options),
23
+ generateAuthenticationOptions: async (options) => (await loadSimpleWebauthn()).generateAuthenticationOptions(options),
24
+ verifyAuthenticationResponse: async (options) => (await loadSimpleWebauthn()).verifyAuthenticationResponse(options),
25
+ };
2
26
  export const sha256 = (value) => createHash('sha256').update(value).digest('hex');
3
27
  /** Recovery code legível: 10 chars hex em duas metades (ex.: a1b2c-3d4e5). */
4
28
  export function generateRecoveryCode() {
@@ -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.1",
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",