@adonis-agora/authkit-server 0.44.0 → 0.46.0

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 (52) hide show
  1. package/build/commands/ui_preset.js +15 -1
  2. package/build/host/views/account/confirm.edge +118 -52
  3. package/build/host/views/account/mfa.edge +1 -1
  4. package/build/host/views/login.edge +2 -4
  5. package/build/host/views/mfa-challenge.edge +1 -1
  6. package/build/host/views/partials/styles.edge +1 -1
  7. package/build/index.d.ts +5 -1
  8. package/build/index.js +15 -1
  9. package/build/src/define_config.d.ts +71 -3
  10. package/build/src/define_config.js +11 -0
  11. package/build/src/host/account_api/account_api_controller.js +2 -2
  12. package/build/src/host/account_deletion_ops.d.ts +2 -2
  13. package/build/src/host/account_deletion_ops.js +3 -3
  14. package/build/src/host/account_deletion_service.js +1 -1
  15. package/build/src/host/assets/webauthn.js +2 -0
  16. package/build/src/host/avatar_storage.d.ts +40 -14
  17. package/build/src/host/avatar_storage.js +221 -72
  18. package/build/src/host/controllers/account_confirm_controller.d.ts +11 -14
  19. package/build/src/host/controllers/account_confirm_controller.js +42 -130
  20. package/build/src/host/controllers/account_security_controller.js +3 -3
  21. package/build/src/host/controllers/account_session_controller.js +20 -4
  22. package/build/src/host/controllers/webauthn_asset_controller.d.ts +22 -0
  23. package/build/src/host/controllers/webauthn_asset_controller.js +66 -0
  24. package/build/src/host/durable/account_deletion_workflow.js +1 -1
  25. package/build/src/host/i18n.d.ts +14 -0
  26. package/build/src/host/i18n.js +16 -0
  27. package/build/src/host/impersonation_session.js +15 -0
  28. package/build/src/host/rate_limit.d.ts +10 -0
  29. package/build/src/host/rate_limit.js +6 -0
  30. package/build/src/host/register_auth_host.d.ts +19 -0
  31. package/build/src/host/register_auth_host.js +72 -4
  32. package/build/src/host/sudo/index.d.ts +47 -0
  33. package/build/src/host/sudo/index.js +41 -0
  34. package/build/src/host/sudo/methods/magic_link.d.ts +43 -0
  35. package/build/src/host/sudo/methods/magic_link.js +174 -0
  36. package/build/src/host/sudo/methods/oidc_step_up.d.ts +68 -0
  37. package/build/src/host/sudo/methods/oidc_step_up.js +78 -0
  38. package/build/src/host/sudo/methods/passkey.d.ts +28 -0
  39. package/build/src/host/sudo/methods/passkey.js +139 -0
  40. package/build/src/host/sudo/methods/password.d.ts +19 -0
  41. package/build/src/host/sudo/methods/password.js +93 -0
  42. package/build/src/host/sudo/runtime.d.ts +141 -0
  43. package/build/src/host/sudo/runtime.js +327 -0
  44. package/build/src/host/sudo/types.d.ts +93 -0
  45. package/build/src/host/sudo/types.js +1 -0
  46. package/build/src/host/sudo_mode.d.ts +116 -6
  47. package/build/src/host/sudo_mode.js +133 -7
  48. package/package.json +10 -2
  49. package/build/stubs/ui/edge/views/consent.edge +0 -13
  50. package/build/stubs/ui/edge/views/login.edge +0 -19
  51. package/stubs/ui/edge/views/consent.edge +0 -13
  52. package/stubs/ui/edge/views/login.edge +0 -19
@@ -0,0 +1,2 @@
1
+ function l(e){let t=new Uint8Array(e),r="";for(let n of t)r+=String.fromCharCode(n);return btoa(r).replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")}function h(e){let t=e.replace(/-/g,"+").replace(/_/g,"/"),r=(4-t.length%4)%4,i=t.padEnd(t.length+r,"="),n=atob(i),c=new ArrayBuffer(n.length),f=new Uint8Array(c);for(let a=0;a<n.length;a++)f[a]=n.charCodeAt(a);return c}function p(){return C.stubThis(globalThis?.PublicKeyCredential!==void 0&&typeof globalThis.PublicKeyCredential=="function")}var C={stubThis:e=>e};function R(e){let{id:t}=e;return{...e,id:h(t),transports:e.transports}}function w(e){return e==="localhost"||/^((xn--[a-z0-9-]+|[a-z0-9]+(-[a-z0-9]+)*)\.)+([a-z]{2,}|xn--[a-z0-9-]+)$/i.test(e)}var o=class extends Error{constructor({message:t,code:r,cause:i,name:n}){super(t,{cause:i}),Object.defineProperty(this,"code",{enumerable:!0,configurable:!0,writable:!0,value:void 0}),this.name=n??i.name,this.code=r}};function I({error:e,options:t}){let{publicKey:r}=t;if(!r)throw Error("options was missing required publicKey property");if(e.name==="AbortError"){if(t.signal instanceof AbortSignal)return new o({message:"Registration ceremony was sent an abort signal",code:"ERROR_CEREMONY_ABORTED",cause:e})}else if(e.name==="ConstraintError"){if(r.authenticatorSelection?.requireResidentKey===!0)return new o({message:"Discoverable credentials were required but no available authenticator supported it",code:"ERROR_AUTHENTICATOR_MISSING_DISCOVERABLE_CREDENTIAL_SUPPORT",cause:e});if(t.mediation==="conditional"&&r.authenticatorSelection?.userVerification==="required")return new o({message:"User verification was required during automatic registration but it could not be performed",code:"ERROR_AUTO_REGISTER_USER_VERIFICATION_FAILURE",cause:e});if(r.authenticatorSelection?.userVerification==="required")return new o({message:"User verification was required but no available authenticator supported it",code:"ERROR_AUTHENTICATOR_MISSING_USER_VERIFICATION_SUPPORT",cause:e})}else{if(e.name==="InvalidStateError")return new o({message:"The authenticator was previously registered",code:"ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED",cause:e});if(e.name==="NotAllowedError")return new o({message:e.message,code:"ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY",cause:e});if(e.name==="NotSupportedError")return r.pubKeyCredParams.filter(n=>n.type==="public-key").length===0?new o({message:'No entry in pubKeyCredParams was of type "public-key"',code:"ERROR_MALFORMED_PUBKEYCREDPARAMS",cause:e}):new o({message:"No available authenticator supported any of the specified pubKeyCredParams algorithms",code:"ERROR_AUTHENTICATOR_NO_SUPPORTED_PUBKEYCREDPARAMS_ALG",cause:e});if(e.name==="SecurityError"){let i=globalThis.location.hostname;if(w(i)){if(r.rp.id!==i)return new o({message:`The RP ID "${r.rp.id}" is invalid for this domain`,code:"ERROR_INVALID_RP_ID",cause:e})}else return new o({message:`${globalThis.location.hostname} is an invalid domain`,code:"ERROR_INVALID_DOMAIN",cause:e})}else if(e.name==="TypeError"){if(r.user.id.byteLength<1||r.user.id.byteLength>64)return new o({message:"User ID was not between 1 and 64 characters",code:"ERROR_INVALID_USER_ID_LENGTH",cause:e})}else if(e.name==="UnknownError")return new o({message:"The authenticator was unable to process the specified options, or could not create a new credential",code:"ERROR_AUTHENTICATOR_GENERAL_ERROR",cause:e})}return e}var S=class{constructor(){Object.defineProperty(this,"controller",{enumerable:!0,configurable:!0,writable:!0,value:void 0})}createNewAbortSignal(){if(this.controller){let r=new Error("Cancelling existing WebAuthn API call for new one");r.name="AbortError",this.controller.abort(r)}let t=new AbortController;return this.controller=t,t.signal}cancelCeremony(){if(this.controller){let t=new Error("Manually cancelling existing WebAuthn API call");t.name="AbortError",this.controller.abort(t),this.controller=void 0}}},A=new S;var x=["cross-platform","platform"];function E(e){if(e&&!(x.indexOf(e)<0))return e}async function N(e){!e.optionsJSON&&e.challenge&&(console.warn("startRegistration() was not called correctly. It will try to continue with the provided options, but this call should be refactored to use the expected call structure instead. See https://simplewebauthn.dev/docs/packages/browser#typeerror-cannot-read-properties-of-undefined-reading-challenge for more information."),e={optionsJSON:e});let{optionsJSON:t,useAutoRegister:r=!1}=e;if(!p())throw new Error("WebAuthn is not supported in this browser");let i={...t,challenge:h(t.challenge),user:{...t.user,id:h(t.user.id)},excludeCredentials:t.excludeCredentials?.map(R)},n={};r&&(n.mediation="conditional"),n.publicKey=i,n.signal=A.createNewAbortSignal();let c;try{c=await navigator.credentials.create(n)}catch(u){throw I({error:u,options:n})}if(!c)throw new Error("Registration was not completed");let{id:f,rawId:a,response:s,type:y}=c,d;typeof s.getTransports=="function"&&(d=s.getTransports());let g;if(typeof s.getPublicKeyAlgorithm=="function")try{g=s.getPublicKeyAlgorithm()}catch(u){O("getPublicKeyAlgorithm()",u)}let b;if(typeof s.getPublicKey=="function")try{let u=s.getPublicKey();u!==null&&(b=l(u))}catch(u){O("getPublicKey()",u)}let m;if(typeof s.getAuthenticatorData=="function")try{m=l(s.getAuthenticatorData())}catch(u){O("getAuthenticatorData()",u)}return{id:f,rawId:l(a),response:{attestationObject:l(s.attestationObject),clientDataJSON:l(s.clientDataJSON),transports:d,publicKeyAlgorithm:g,publicKey:b,authenticatorData:m},type:y,clientExtensionResults:c.getClientExtensionResults(),authenticatorAttachment:E(c.authenticatorAttachment)}}function O(e,t){console.warn(`The browser extension that intercepted this WebAuthn API call incorrectly implemented ${e}. You should report this error to them.
2
+ `,t)}function _(){if(!p())return T.stubThis(new Promise(t=>t(!1)));let e=globalThis.PublicKeyCredential;return e?.isConditionalMediationAvailable===void 0?T.stubThis(new Promise(t=>t(!1))):T.stubThis(e.isConditionalMediationAvailable())}var T={stubThis:e=>e};function P({error:e,options:t}){let{publicKey:r}=t;if(!r)throw Error("options was missing required publicKey property");if(e.name==="AbortError"){if(t.signal instanceof AbortSignal)return new o({message:"Authentication ceremony was sent an abort signal",code:"ERROR_CEREMONY_ABORTED",cause:e})}else{if(e.name==="NotAllowedError")return new o({message:e.message,code:"ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY",cause:e});if(e.name==="SecurityError"){let i=globalThis.location.hostname;if(w(i)){if(r.rpId!==i)return new o({message:`The RP ID "${r.rpId}" is invalid for this domain`,code:"ERROR_INVALID_RP_ID",cause:e})}else return new o({message:`${globalThis.location.hostname} is an invalid domain`,code:"ERROR_INVALID_DOMAIN",cause:e})}else if(e.name==="UnknownError")return new o({message:"The authenticator was unable to process the specified options, or could not create a new assertion signature",code:"ERROR_AUTHENTICATOR_GENERAL_ERROR",cause:e})}return e}async function D(e){!e.optionsJSON&&e.challenge&&(console.warn("startAuthentication() was not called correctly. It will try to continue with the provided options, but this call should be refactored to use the expected call structure instead. See https://simplewebauthn.dev/docs/packages/browser#typeerror-cannot-read-properties-of-undefined-reading-challenge for more information."),e={optionsJSON:e});let{optionsJSON:t,useBrowserAutofill:r=!1,verifyBrowserAutofillInput:i=!0}=e;if(!p())throw new Error("WebAuthn is not supported in this browser");let n;t.allowCredentials?.length!==0&&(n=t.allowCredentials?.map(R));let c={...t,challenge:h(t.challenge),allowCredentials:n},f={};if(r){if(!await _())throw Error("Browser does not support WebAuthn autofill");if(document.querySelectorAll("input[autocomplete$='webauthn']").length<1&&i)throw Error('No <input> with "webauthn" as the only or last value in its `autocomplete` attribute was detected');f.mediation="conditional",c.allowCredentials=[]}f.publicKey=c,f.signal=A.createNewAbortSignal();let a;try{a=await navigator.credentials.get(f)}catch(m){throw P({error:m,options:f})}if(!a)throw new Error("Authentication was not completed");let{id:s,rawId:y,response:d,type:g}=a,b;return d.userHandle&&(b=l(d.userHandle)),{id:s,rawId:l(y),response:{authenticatorData:l(d.authenticatorData),clientDataJSON:l(d.clientDataJSON),signature:l(d.signature),userHandle:b},type:g,clientExtensionResults:a.getClientExtensionResults(),authenticatorAttachment:E(a.authenticatorAttachment)}}function v(){return p()?PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable():new Promise(e=>e(!1))}export{A as WebAuthnAbortService,o as WebAuthnError,p as browserSupportsWebAuthn,_ as browserSupportsWebAuthnAutofill,v as platformAuthenticatorIsAvailable,D as startAuthentication,N as startRegistration};
@@ -20,6 +20,18 @@ type DriveService = any;
20
20
  * @internal
21
21
  */
22
22
  export declare function __setDriveLoaderForTests(fn: (() => Promise<DriveService | null>) | undefined): void;
23
+ /**
24
+ * Módulo `@adonis-agora/media/single-file` resolvido de forma preguiçosa. Tipado
25
+ * como `any` de propósito: a lib NÃO depende do media em tempo de compilação
26
+ * (peer/opt-in). Expõe `storeSingleFile`/`removeSingleFile`/`isSingleFileStoreAvailable`.
27
+ */
28
+ type MediaModule = any;
29
+ /**
30
+ * Permite reapontar/limpar o loader do media (usado em testes). Espelha
31
+ * {@link __setDriveLoaderForTests}.
32
+ * @internal
33
+ */
34
+ export declare function __setMediaLoaderForTests(fn: (() => Promise<MediaModule | null>) | undefined): void;
23
35
  /** Erro de validação do upload (mensagem já localizada no controller). */
24
36
  export declare class AvatarUploadError extends Error {
25
37
  reason: 'extname' | 'size';
@@ -29,7 +41,9 @@ export declare class AvatarUploadError extends Error {
29
41
  export interface UploadedAvatar {
30
42
  extname?: string | null;
31
43
  size?: number;
32
- /** Caminho temporário (drive daqui para stream). */
44
+ /** MIME reportado pelo multipart (ex.: 'image/png'). Usado pelo backend media. */
45
+ type?: string | null;
46
+ /** Caminho temporário (drive lê daqui para stream; media lê os bytes daqui). */
33
47
  tmpPath?: string | null;
34
48
  /** Move o arquivo para um disk do drive (API v3+). */
35
49
  moveToDisk?: (key: string, options?: {
@@ -42,27 +56,39 @@ export interface UploadedAvatar {
42
56
  */
43
57
  export declare function isDriveAvailable(): Promise<boolean>;
44
58
  /**
45
- * Armazena o avatar no drive do host e retorna a URL pública.
59
+ * Indica se o upload de avatar está disponível para a config dada i.e. se ALGUM
60
+ * backend configurado consegue armazenar (mesma lógica de seleção do
61
+ * {@link resolveUploader}: `'builtin'` → drive; `'media'` → media; `'auto'` →
62
+ * media OU drive). Usado pelas views/controllers para decidir mostrar o input de
63
+ * arquivo. Best-effort: nunca lança.
64
+ *
65
+ * Substitui o antigo gate por {@link isDriveAvailable}, que escondia o input num
66
+ * host media-only (media presente, drive ausente) mesmo com o media capaz de armazenar.
67
+ */
68
+ export declare function isAvatarUploadSupported(cfg: ResolvedUploadsConfig): Promise<boolean>;
69
+ /**
70
+ * Armazena o avatar no backend ativo (drive OU media) e retorna a URL pública.
46
71
  *
47
- * - Valida extensão (jpg/jpeg/png/webp) e tamanho (≤ maxSizeMb) lança
72
+ * - Resolve o backend PRIMEIRO: se nenhum estiver disponível → retorna `null`
73
+ * (degrada para o input de URL, feature off) SEM validar/lançar. Isso preserva o
74
+ * comportamento histórico: um host sem backend nunca vê erro de validação.
75
+ * - Só quando há backend valida extensão (jpg/jpeg/png/webp) e tamanho
76
+ * (≤ maxSizeMb) — COMPARTILHADO, antes de entregar ao backend; lança
48
77
  * {@link AvatarUploadError} se inválido (o controller traduz/flasha).
49
- * - Usa o disk configurado em `uploads.avatars.disk` ou o disk DEFAULT do app.
50
- * - Se o drive estiver ausente/não-configurado → retorna `null` (degrada para URL).
78
+ * - Backend conforme `cfg.avatars.storage` (ver {@link resolveUploader}).
51
79
  *
52
- * Nunca lança por causa de drive ausente; só lança em validação.
80
+ * Nunca lança por causa de backend ausente; só lança em validação (com backend).
53
81
  */
54
- export declare function storeAvatar(_ctx: HttpContext, cfg: ResolvedUploadsConfig, file: UploadedAvatar, accountId: string, messages: {
82
+ export declare function storeAvatar(ctx: HttpContext, cfg: ResolvedUploadsConfig, file: UploadedAvatar, accountId: string, messages: {
55
83
  extname: string;
56
84
  size: string;
57
85
  }): Promise<string | null>;
58
86
  /**
59
- * Deleta (best-effort, fail-safe) o avatar de uma conta no drive do host, a partir
60
- * da URL/key armazenada. Usado pela deleção de conta (LGPD): apaga o arquivo se
61
- * conseguirmos derivar a key dentro do diretório configurado.
87
+ * Deleta (best-effort, fail-safe) o avatar de uma conta no backend ativo. Usado
88
+ * pela deleção de conta (LGPD). O `accountId` é o owner — necessário para o backend
89
+ * media apagar por owner; o backend builtin deriva a key da `storedUrlOrKey`.
62
90
  *
63
- * NUNCA lança: drive ausente, key não reconhecida ou erro de I/O → no-op. A key é
64
- * derivada da `storedUrlOrKey` pegando o trecho a partir de `directory/` (cobre
65
- * tanto uma key relativa quanto uma URL pública que contenha o caminho).
91
+ * NUNCA lança: backend ausente, key não reconhecida ou erro de I/O → no-op.
66
92
  */
67
- export declare function deleteAvatar(cfg: ResolvedUploadsConfig, storedUrlOrKey: string | null | undefined): Promise<boolean>;
93
+ export declare function deleteAvatar(cfg: ResolvedUploadsConfig, accountId: string | null | undefined, storedUrlOrKey: string | null | undefined): Promise<boolean>;
68
94
  export {};
@@ -34,8 +34,43 @@ export function __setDriveLoaderForTests(fn) {
34
34
  driveServicePromise = undefined;
35
35
  }
36
36
  }
37
+ let mediaModulePromise;
38
+ /**
39
+ * Importa o helper single-file do `@adonis-agora/media` de forma preguiçosa e
40
+ * fail-safe. Espelha {@link loadDrive}: se o pacote não estiver instalado, resolve
41
+ * `null` (o specifier é indireto para não ser resolvido em build-time — peer opcional).
42
+ */
43
+ async function loadMedia() {
44
+ if (!mediaModulePromise) {
45
+ const specifier = '@adonis-agora/media/single-file';
46
+ mediaModulePromise = import(__rewriteRelativeImportExtension(specifier))
47
+ .then((mod) => mod ?? null)
48
+ .catch(() => null);
49
+ }
50
+ return mediaModulePromise;
51
+ }
52
+ /**
53
+ * Permite reapontar/limpar o loader do media (usado em testes). Espelha
54
+ * {@link __setDriveLoaderForTests}.
55
+ * @internal
56
+ */
57
+ export function __setMediaLoaderForTests(fn) {
58
+ if (fn) {
59
+ mediaModulePromise = fn();
60
+ }
61
+ else {
62
+ mediaModulePromise = undefined;
63
+ }
64
+ }
37
65
  /** Extensões aceitas para o avatar (imagem raster comum). */
38
66
  const ALLOWED_EXTNAMES = ['jpg', 'jpeg', 'png', 'webp'];
67
+ /** MIME por extensão validada — fallback quando o file de multipart não traz `type`. */
68
+ const EXT_MIME = {
69
+ jpg: 'image/jpeg',
70
+ jpeg: 'image/jpeg',
71
+ png: 'image/png',
72
+ webp: 'image/webp',
73
+ };
39
74
  /** Erro de validação do upload (mensagem já localizada no controller). */
40
75
  export class AvatarUploadError extends Error {
41
76
  reason;
@@ -76,91 +111,205 @@ function buildKey(cfg, accountId, ext) {
76
111
  return `${cfg.avatars.directory}/${accountId}-${random}.${ext}`;
77
112
  }
78
113
  /**
79
- * Armazena o avatar no drive do host e retorna a URL pública.
114
+ * Backend builtin: o `@adonisjs/drive` configurado no app. Comportamento
115
+ * BYTE-IDÊNTICO ao histórico (moveToDisk/putStream + getUrl; delete por key
116
+ * derivada do diretório).
117
+ */
118
+ const builtinUploader = {
119
+ async store(_ctx, cfg, file, accountId, ext) {
120
+ const drive = await loadDrive();
121
+ if (!drive)
122
+ return null;
123
+ // Resolve o disk: o configurado, ou o DEFAULT do drive do app.
124
+ let disk;
125
+ try {
126
+ disk = cfg.avatars.disk ? drive.use(cfg.avatars.disk) : drive.use();
127
+ }
128
+ catch {
129
+ // disk inválido/não-configurado — degrada para URL.
130
+ return null;
131
+ }
132
+ if (!disk)
133
+ return null;
134
+ const key = buildKey(cfg, accountId || 'account', ext);
135
+ // API @adonisjs/drive v3+: o file de multipart move-se direto para o disk.
136
+ // `moveToDisk` lê do tmpPath e usa o disk informado (ou o default da config).
137
+ if (typeof file.moveToDisk === 'function') {
138
+ await file.moveToDisk(key, cfg.avatars.disk ? { disk: cfg.avatars.disk } : undefined);
139
+ }
140
+ else if (file.tmpPath) {
141
+ // Fallback: lê do tmpPath e escreve via putStream no disk resolvido.
142
+ const fs = await import('node:fs');
143
+ await disk.putStream(key, fs.createReadStream(file.tmpPath));
144
+ }
145
+ else {
146
+ return null;
147
+ }
148
+ try {
149
+ return await disk.getUrl(key);
150
+ }
151
+ catch {
152
+ // disk sem getUrl público — retorna a key como referência relativa.
153
+ return key;
154
+ }
155
+ },
156
+ async delete(cfg, _accountId, storedUrlOrKey) {
157
+ if (!storedUrlOrKey)
158
+ return false;
159
+ const drive = await loadDrive();
160
+ if (!drive)
161
+ return false;
162
+ // Deriva a key: trecho a partir de `<directory>/`. Se não bater, aborta (não
163
+ // arriscamos deletar algo fora do nosso diretório).
164
+ const dir = cfg.avatars.directory.replace(/\/+$/, '');
165
+ const marker = `${dir}/`;
166
+ const idx = storedUrlOrKey.indexOf(marker);
167
+ if (idx < 0)
168
+ return false;
169
+ // Remove querystring/fragment de uma URL pública.
170
+ const key = storedUrlOrKey.slice(idx).split(/[?#]/)[0];
171
+ let disk;
172
+ try {
173
+ disk = cfg.avatars.disk ? drive.use(cfg.avatars.disk) : drive.use();
174
+ }
175
+ catch {
176
+ return false;
177
+ }
178
+ if (!disk || typeof disk.delete !== 'function')
179
+ return false;
180
+ try {
181
+ await disk.delete(key);
182
+ return true;
183
+ }
184
+ catch {
185
+ return false;
186
+ }
187
+ },
188
+ };
189
+ /**
190
+ * Backend media: delega ao `@adonis-agora/media` (collection single-file). O
191
+ * lifecycle é keyed por owner (`ownerType`/`ownerId`) — o `single: true` da
192
+ * collection faz o replace de slot; a URL final é o que persistimos em `avatarUrl`.
193
+ */
194
+ function makeMediaUploader(media) {
195
+ return {
196
+ async store(_ctx, cfg, file, accountId, ext) {
197
+ if (!file.tmpPath)
198
+ return null;
199
+ const fs = await import('node:fs/promises');
200
+ const contents = await fs.readFile(file.tmpPath);
201
+ const fileName = `avatar.${ext}`;
202
+ const mimeType = file.type ?? EXT_MIME[ext] ?? 'application/octet-stream';
203
+ const result = await media.storeSingleFile({
204
+ ownerType: cfg.avatars.ownerType,
205
+ ownerId: accountId,
206
+ collection: cfg.avatars.collection,
207
+ fileName,
208
+ mimeType,
209
+ contents,
210
+ });
211
+ return result?.url ?? null;
212
+ },
213
+ async delete(cfg, accountId, _storedUrlOrKey) {
214
+ // media apaga por owner (a key/URL não é usada — o lifecycle é do owner).
215
+ if (!accountId)
216
+ return false;
217
+ try {
218
+ await media.removeSingleFile({
219
+ ownerType: cfg.avatars.ownerType,
220
+ ownerId: accountId,
221
+ collection: cfg.avatars.collection,
222
+ });
223
+ return true;
224
+ }
225
+ catch {
226
+ return false;
227
+ }
228
+ },
229
+ };
230
+ }
231
+ /**
232
+ * Resolve o backend de avatar conforme `cfg.avatars.storage`:
233
+ * - `'builtin'` → sempre o drive (ou `null` se ausente).
234
+ * - `'media'` → o media (ou `null` se ausente/indisponível — degrada gracioso).
235
+ * - `'auto'` → media se disponível, senão o drive.
80
236
  *
81
- * - Valida extensão (jpg/jpeg/png/webp) e tamanho (≤ maxSizeMb) lança
82
- * {@link AvatarUploadError} se inválido (o controller traduz/flasha).
83
- * - Usa o disk configurado em `uploads.avatars.disk` ou o disk DEFAULT do app.
84
- * - Se o drive estiver ausente/não-configurado → retorna `null` (degrada para URL).
237
+ * "media disponível" = pacote presente E `isSingleFileStoreAvailable()` true (o
238
+ * MediaManager está bindado no container do app). Nunca lança.
239
+ */
240
+ async function resolveUploader(cfg) {
241
+ const storage = cfg.avatars.storage;
242
+ if (storage === 'builtin') {
243
+ return (await loadDrive()) ? builtinUploader : null;
244
+ }
245
+ if (storage === 'media') {
246
+ const media = await loadMediaIfUsable();
247
+ return media ? makeMediaUploader(media) : null;
248
+ }
249
+ // 'auto' (default): media se disponível, senão builtin.
250
+ const media = await loadMediaIfUsable();
251
+ if (media)
252
+ return makeMediaUploader(media);
253
+ return (await loadDrive()) ? builtinUploader : null;
254
+ }
255
+ /**
256
+ * Indica se o upload de avatar está disponível para a config dada — i.e. se ALGUM
257
+ * backend configurado consegue armazenar (mesma lógica de seleção do
258
+ * {@link resolveUploader}: `'builtin'` → drive; `'media'` → media; `'auto'` →
259
+ * media OU drive). Usado pelas views/controllers para decidir mostrar o input de
260
+ * arquivo. Best-effort: nunca lança.
85
261
  *
86
- * Nunca lança por causa de drive ausente; lança em validação.
262
+ * Substitui o antigo gate por {@link isDriveAvailable}, que escondia o input num
263
+ * host media-only (media presente, drive ausente) mesmo com o media capaz de armazenar.
87
264
  */
88
- export async function storeAvatar(_ctx, cfg, file, accountId, messages) {
89
- const drive = await loadDrive();
90
- if (!drive)
265
+ export async function isAvatarUploadSupported(cfg) {
266
+ return (await resolveUploader(cfg)) !== null;
267
+ }
268
+ /**
269
+ * Carrega o módulo media só se ele estiver USÁVEL: pacote presente E
270
+ * `isSingleFileStoreAvailable()` resolve `true`. Best-effort — qualquer erro → null.
271
+ */
272
+ async function loadMediaIfUsable() {
273
+ const media = await loadMedia();
274
+ if (!media)
91
275
  return null;
92
- const ext = validate(file, cfg, messages);
93
- // Resolve o disk: o configurado, ou o DEFAULT do drive do app.
94
- let disk;
95
276
  try {
96
- disk = cfg.avatars.disk ? drive.use(cfg.avatars.disk) : drive.use();
277
+ return (await media.isSingleFileStoreAvailable()) ? media : null;
97
278
  }
98
279
  catch {
99
- // disk inválido/não-configurado — degrada para URL.
100
280
  return null;
101
281
  }
102
- if (!disk)
103
- return null;
104
- const key = buildKey(cfg, accountId || 'account', ext);
105
- // API @adonisjs/drive v3+: o file de multipart move-se direto para o disk.
106
- // `moveToDisk` do tmpPath e usa o disk informado (ou o default da config).
107
- if (typeof file.moveToDisk === 'function') {
108
- await file.moveToDisk(key, cfg.avatars.disk ? { disk: cfg.avatars.disk } : undefined);
109
- }
110
- else if (file.tmpPath) {
111
- // Fallback: do tmpPath e escreve via putStream no disk resolvido.
112
- const fs = await import('node:fs');
113
- await disk.putStream(key, fs.createReadStream(file.tmpPath));
114
- }
115
- else {
282
+ }
283
+ /**
284
+ * Armazena o avatar no backend ativo (drive OU media) e retorna a URL pública.
285
+ *
286
+ * - Resolve o backend PRIMEIRO: se nenhum estiver disponível retorna `null`
287
+ * (degrada para o input de URL, feature off) SEM validar/lançar. Isso preserva o
288
+ * comportamento histórico: um host sem backend nunca erro de validação.
289
+ * - Só quando há backend valida extensão (jpg/jpeg/png/webp) e tamanho
290
+ * (≤ maxSizeMb) — COMPARTILHADO, antes de entregar ao backend; lança
291
+ * {@link AvatarUploadError} se inválido (o controller traduz/flasha).
292
+ * - Backend conforme `cfg.avatars.storage` (ver {@link resolveUploader}).
293
+ *
294
+ * Nunca lança por causa de backend ausente; só lança em validação (com backend).
295
+ */
296
+ export async function storeAvatar(ctx, cfg, file, accountId, messages) {
297
+ const uploader = await resolveUploader(cfg);
298
+ if (!uploader)
116
299
  return null;
117
- }
118
- try {
119
- return await disk.getUrl(key);
120
- }
121
- catch {
122
- // disk sem getUrl público — retorna a key como referência relativa.
123
- return key;
124
- }
300
+ const ext = validate(file, cfg, messages);
301
+ return uploader.store(ctx, cfg, file, accountId, ext);
125
302
  }
126
303
  /**
127
- * Deleta (best-effort, fail-safe) o avatar de uma conta no drive do host, a partir
128
- * da URL/key armazenada. Usado pela deleção de conta (LGPD): apaga o arquivo se
129
- * conseguirmos derivar a key dentro do diretório configurado.
304
+ * Deleta (best-effort, fail-safe) o avatar de uma conta no backend ativo. Usado
305
+ * pela deleção de conta (LGPD). O `accountId` é o owner — necessário para o backend
306
+ * media apagar por owner; o backend builtin deriva a key da `storedUrlOrKey`.
130
307
  *
131
- * NUNCA lança: drive ausente, key não reconhecida ou erro de I/O → no-op. A key é
132
- * derivada da `storedUrlOrKey` pegando o trecho a partir de `directory/` (cobre
133
- * tanto uma key relativa quanto uma URL pública que contenha o caminho).
308
+ * NUNCA lança: backend ausente, key não reconhecida ou erro de I/O → no-op.
134
309
  */
135
- export async function deleteAvatar(cfg, storedUrlOrKey) {
136
- if (!storedUrlOrKey)
137
- return false;
138
- const drive = await loadDrive();
139
- if (!drive)
140
- return false;
141
- // Deriva a key: trecho a partir de `<directory>/`. Se não bater, aborta (não
142
- // arriscamos deletar algo fora do nosso diretório).
143
- const dir = cfg.avatars.directory.replace(/\/+$/, '');
144
- const marker = `${dir}/`;
145
- const idx = storedUrlOrKey.indexOf(marker);
146
- if (idx < 0)
147
- return false;
148
- // Remove querystring/fragment de uma URL pública.
149
- const key = storedUrlOrKey.slice(idx).split(/[?#]/)[0];
150
- let disk;
151
- try {
152
- disk = cfg.avatars.disk ? drive.use(cfg.avatars.disk) : drive.use();
153
- }
154
- catch {
310
+ export async function deleteAvatar(cfg, accountId, storedUrlOrKey) {
311
+ const uploader = await resolveUploader(cfg);
312
+ if (!uploader)
155
313
  return false;
156
- }
157
- if (!disk || typeof disk.delete !== 'function')
158
- return false;
159
- try {
160
- await disk.delete(key);
161
- return true;
162
- }
163
- catch {
164
- return false;
165
- }
314
+ return uploader.delete(cfg, accountId, storedUrlOrKey);
166
315
  }
@@ -1,24 +1,21 @@
1
1
  /**
2
2
  * Sudo mode — tela de confirmação de identidade (/account/confirm).
3
3
  *
4
- * GET /account/confirm → exibe o formulário de senha (e opção de passkey se disponível).
5
- * POST /account/confirm → verifica a senha e, se correta, marca o sudo na sessão.
6
- * POST /account/confirm/passkey/options gera as opções de autenticação.
7
- * POST /account/confirm/passkey → verifica a resposta da passkey e marca o sudo.
4
+ * O GET lista os métodos DISPONÍVEIS para a conta (SPI `SudoMethod`); a
5
+ * verificação de cada um vive no próprio método, nas rotas que ele registra.
6
+ * Este controller não verifica credencial nem chama `markSudo`.
8
7
  *
9
8
  * A tela está atrás do `accountGuard` (requer sessão de conta ativa).
10
- * Após confirmação, redireciona para `return_to` (validado) ou para `/account/tokens`.
11
9
  */
12
10
  import '../augmentations.js';
13
11
  import type { HttpContext } from '@adonisjs/core/http';
12
+ import { sudoContextFrom } from '../sudo/runtime.js';
13
+ /**
14
+ * Reexport de compatibilidade. O construtor canônico do `SudoContext` vive em
15
+ * `sudo/runtime.ts` (é runtime do SPI, não detalhe da tela); este caminho
16
+ * antigo segue valendo para quem já o importava.
17
+ */
18
+ export { sudoContextFrom };
14
19
  export default class AccountConfirmController {
15
- show(ctx: HttpContext): Promise<any>;
16
- confirm(ctx: HttpContext): Promise<void>;
17
- passkeyOptions(ctx: HttpContext): Promise<any>;
18
- passkeyConfirm(ctx: HttpContext): Promise<void>;
19
- /**
20
- * Verifica se a conta é "passwordless" (sem hash de senha definido).
21
- * Fail-safe: retorna `false` quando não é possível determinar.
22
- */
23
- private isPasswordless;
20
+ show(ctx: HttpContext): Promise<unknown>;
24
21
  }