From dec0aefed5049abfcd9b3c0b06b1b09ba5ccc95d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Dec 2025 13:54:32 +0000 Subject: [PATCH 01/44] build(deps): bump actions/github-script from 6 to 8 Bumps [actions/github-script](https://github.com/actions/github-script) from 6 to 8. - [Release notes](https://github.com/actions/github-script/releases) - [Commits](https://github.com/actions/github-script/compare/v6...v8) --- updated-dependencies: - dependency-name: actions/github-script dependency-version: '8' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/inactive-issues.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/inactive-issues.yml b/.github/workflows/inactive-issues.yml index 2d8cba7a3f..620b484db6 100644 --- a/.github/workflows/inactive-issues.yml +++ b/.github/workflows/inactive-issues.yml @@ -17,7 +17,7 @@ jobs: uses: actions/checkout@v3 - name: Manage inactive issues - uses: actions/github-script@v6 + uses: actions/github-script@v8 with: script: | const script = require('./.github/scripts/inactive-issues.js') From 7f08b5edf86bccf40e7e95488ce81957b952a3e3 Mon Sep 17 00:00:00 2001 From: Shamshad Ansari <149064103+shamshad-ansari@users.noreply.github.com> Date: Sun, 8 Feb 2026 17:03:09 -0600 Subject: [PATCH 02/44] Fix(webapp): Remove Vary: User-Agent header from hashed assets (#13852) --- packages/webapp/package.js | 2 +- packages/webapp/webapp_server.js | 13 ++-- packages/webapp/webapp_tests.js | 109 +++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 6 deletions(-) diff --git a/packages/webapp/package.js b/packages/webapp/package.js index 6a2bb3d3ff..ed92e1b55b 100644 --- a/packages/webapp/package.js +++ b/packages/webapp/package.js @@ -1,6 +1,6 @@ Package.describe({ summary: "Serves a Meteor app over HTTP", - version: "2.1.0", + version: "2.1.1", }); Npm.depends({ diff --git a/packages/webapp/webapp_server.js b/packages/webapp/webapp_server.js index ad76808974..0bd800088a 100644 --- a/packages/webapp/webapp_server.js +++ b/packages/webapp/webapp_server.js @@ -681,11 +681,14 @@ WebAppInternals.staticFilesMiddleware = async function( // We cache them ~forever (1yr). const maxAge = info.cacheable ? 1000 * 60 * 60 * 24 * 365 : 0; - if (info.cacheable) { - // Since we use req.headers["user-agent"] to determine whether the - // client should receive modern or legacy resources, tell the client - // to invalidate cached resources when/if its user agent string - // changes in the future. + // Resources identified by a hash (info.hash) are unique per + // architecture (modern vs legacy), so Vary: User-Agent is redundant. + // + // However, in Development/Test mode, hashes might be identical or missing, + // so we force Vary to be safe. + const varyAgentMode = Meteor.settings.packages?.webapp?.varyAgent ?? true; + + if (info.cacheable && (!info.hash || Meteor.isDevelopment) && varyAgentMode) { res.setHeader('Vary', 'User-Agent'); } diff --git a/packages/webapp/webapp_tests.js b/packages/webapp/webapp_tests.js index 60e6ba8c34..dcc99fdb26 100644 --- a/packages/webapp/webapp_tests.js +++ b/packages/webapp/webapp_tests.js @@ -432,3 +432,112 @@ Tinytest.addAsync("webapp - parse url queries", async function (test) { i++; } }); + +Tinytest.addAsync( + 'webapp - vary header optimization (hashed assets)', + async function (test) { + const arch = 'web.browser'; + const hashedJs = '/optim-hashed.js'; + + // Inject mock production files (with hashes) into the manifest. + WebAppInternals.staticFilesByArch[arch][hashedJs] = { + content: 'console.log("prod")', + absolutePath: '/tmp/mock-prod.js', + cacheable: true, + hash: 'js-hash-123', + type: 'js' + }; + + try { + const resJs = await asyncGet(Meteor.absoluteUrl(hashedJs)); + + const varyJs = (resJs.headers['vary'] || '').toLowerCase(); + test.isFalse( + varyJs.includes('user-agent'), + 'Vary: User-Agent should be removed from Hashed JS files' + ); + + } finally { + delete WebAppInternals.staticFilesByArch[arch][hashedJs]; + } + } +); + +Tinytest.addAsync( + 'webapp - vary header safety (unhashed assets)', + async function (test) { + const arch = 'web.browser'; + const unhashedJs = '/safety-unhashed.js'; + + // Inject mock development file (no hash). + WebAppInternals.staticFilesByArch[arch][unhashedJs] = { + content: 'console.log("dev")', + absolutePath: '/tmp/mock-dev.js', + cacheable: true, + hash: null, + type: 'js' + }; + + try { + const res = await asyncGet(Meteor.absoluteUrl(unhashedJs)); + const varyHeader = (res.headers['vary'] || '').toLowerCase(); + + test.isTrue( + varyHeader.includes('user-agent'), + 'Vary: User-Agent MUST be present on files without a hash to prevent cache poisoning' + ); + } finally { + delete WebAppInternals.staticFilesByArch[arch][unhashedJs]; + } + } +); + +Tinytest.addAsync( + 'webapp - hashed files identical across user-agents', + async function (test) { + const arch = 'web.browser'; + const hashedPath = '/cdn-consistency-test.js'; + const url = Meteor.absoluteUrl(hashedPath); + + // Inject a single hashed file to prove it serves identically to all browsers. + WebAppInternals.staticFilesByArch[arch][hashedPath] = { + content: 'console.log("consistent-cdn")', + absolutePath: '/tmp/mock-consistent.js', + cacheable: true, + hash: 'unique-hash-999', + type: 'js' + }; + + try { + // Request with Modern User-Agent (variable from webapp_tests.js scope) + const resModern = await asyncGet(url, { + headers: { 'User-Agent': modernUserAgent } + }); + + // Request with Legacy User-Agent (variable from webapp_tests.js scope) + const resLegacy = await asyncGet(url, { + headers: { 'User-Agent': legacyUserAgent } + }); + + test.equal( + resModern.content, + resLegacy.content, + 'Hashed URLs must serve identical content to all browsers' + ); + + const varyModern = (resModern.headers['vary'] || '').toLowerCase(); + const varyLegacy = (resLegacy.headers['vary'] || '').toLowerCase(); + + test.isFalse( + varyModern.includes('user-agent'), + 'Modern browser request should not see Vary: User-Agent' + ); + test.isFalse( + varyLegacy.includes('user-agent'), + 'Legacy browser request should not see Vary: User-Agent' + ); + } finally { + delete WebAppInternals.staticFilesByArch[arch][hashedPath]; + } + } +); \ No newline at end of file From b60d552401f8b219af18999c7e9c91b76a1ec966 Mon Sep 17 00:00:00 2001 From: Shamshad Ansari <149064103+shamshad-ansari@users.noreply.github.com> Date: Sun, 8 Feb 2026 17:13:26 -0600 Subject: [PATCH 03/44] Removed accidental check --- packages/webapp/webapp_server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webapp/webapp_server.js b/packages/webapp/webapp_server.js index 0bd800088a..0399bcfcd0 100644 --- a/packages/webapp/webapp_server.js +++ b/packages/webapp/webapp_server.js @@ -688,7 +688,7 @@ WebAppInternals.staticFilesMiddleware = async function( // so we force Vary to be safe. const varyAgentMode = Meteor.settings.packages?.webapp?.varyAgent ?? true; - if (info.cacheable && (!info.hash || Meteor.isDevelopment) && varyAgentMode) { + if (info.cacheable && !info.hash && varyAgentMode) { res.setHeader('Vary', 'User-Agent'); } From 67b9d06273410813d5099bc7811c6b3e3d9228ac Mon Sep 17 00:00:00 2001 From: Shamshad Ansari <149064103+shamshad-ansari@users.noreply.github.com> Date: Tue, 10 Feb 2026 19:34:36 -0600 Subject: [PATCH 04/44] Fix(webapp): Remove Vary: User-Agent header from hashed assets Optimizes CDN caching by removing the Vary header when the URL contains a unique file hash. Changes: - webapp_server.js: Implemented logic to strip header only when hash is present in the URL path. - webapp_tests.js: Added comprehensive tests for optimization (prod), safety (dev), and configuration. - webapp.md: Added documentation for Static Assets & Caching behavior. --- packages/webapp/webapp_server.js | 15 +++--- packages/webapp/webapp_tests.js | 84 ++++++++++++++++++++++++-------- v3-docs/docs/packages/webapp.md | 22 +++++++++ 3 files changed, 96 insertions(+), 25 deletions(-) diff --git a/packages/webapp/webapp_server.js b/packages/webapp/webapp_server.js index 0399bcfcd0..740f231447 100644 --- a/packages/webapp/webapp_server.js +++ b/packages/webapp/webapp_server.js @@ -681,14 +681,17 @@ WebAppInternals.staticFilesMiddleware = async function( // We cache them ~forever (1yr). const maxAge = info.cacheable ? 1000 * 60 * 60 * 24 * 365 : 0; - // Resources identified by a hash (info.hash) are unique per - // architecture (modern vs legacy), so Vary: User-Agent is redundant. + // Resources whose URL already contains the content hash are immutable + // and unique per architecture (modern vs legacy), so Vary: User-Agent + // is unnecessary and harms CDN cache efficiency. // - // However, in Development/Test mode, hashes might be identical or missing, - // so we force Vary to be safe. - const varyAgentMode = Meteor.settings.packages?.webapp?.varyAgent ?? true; + // If the requested URL does not contain the hash (e.g. development + // or unhashed assets), we keep Vary: User-Agent to prevent cache + // poisoning across different browsers. + const includeVaryUserAgent = + Meteor.settings.packages?.webapp?.includeVaryUserAgent ?? true; - if (info.cacheable && !info.hash && varyAgentMode) { + if (info.cacheable && !pathname.includes(info.hash) && includeVaryUserAgent) { res.setHeader('Vary', 'User-Agent'); } diff --git a/packages/webapp/webapp_tests.js b/packages/webapp/webapp_tests.js index dcc99fdb26..73de53b1f1 100644 --- a/packages/webapp/webapp_tests.js +++ b/packages/webapp/webapp_tests.js @@ -437,24 +437,24 @@ Tinytest.addAsync( 'webapp - vary header optimization (hashed assets)', async function (test) { const arch = 'web.browser'; - const hashedJs = '/optim-hashed.js'; + const hash = 'js-hash-123'; + const hashedJs = `/optim-hashed.${hash}.js`; - // Inject mock production files (with hashes) into the manifest. WebAppInternals.staticFilesByArch[arch][hashedJs] = { content: 'console.log("prod")', absolutePath: '/tmp/mock-prod.js', cacheable: true, - hash: 'js-hash-123', + hash: hash, type: 'js' }; try { const resJs = await asyncGet(Meteor.absoluteUrl(hashedJs)); - const varyJs = (resJs.headers['vary'] || '').toLowerCase(); + test.isFalse( varyJs.includes('user-agent'), - 'Vary: User-Agent should be removed from Hashed JS files' + 'Vary: User-Agent should be removed when the URL contains the file hash' ); } finally { @@ -468,13 +468,12 @@ Tinytest.addAsync( async function (test) { const arch = 'web.browser'; const unhashedJs = '/safety-unhashed.js'; - - // Inject mock development file (no hash). + WebAppInternals.staticFilesByArch[arch][unhashedJs] = { content: 'console.log("dev")', absolutePath: '/tmp/mock-dev.js', cacheable: true, - hash: null, + hash: 'dev-internal-hash', type: 'js' }; @@ -484,7 +483,7 @@ Tinytest.addAsync( test.isTrue( varyHeader.includes('user-agent'), - 'Vary: User-Agent MUST be present on files without a hash to prevent cache poisoning' + 'Vary: User-Agent MUST be present when the URL does NOT contain the hash' ); } finally { delete WebAppInternals.staticFilesByArch[arch][unhashedJs]; @@ -493,28 +492,75 @@ Tinytest.addAsync( ); Tinytest.addAsync( - 'webapp - hashed files identical across user-agents', + 'webapp - vary header respects includeVaryUserAgent setting', async function (test) { const arch = 'web.browser'; - const hashedPath = '/cdn-consistency-test.js'; - const url = Meteor.absoluteUrl(hashedPath); + const unhashedJs = '/config-test.js'; + + const originalSettings = Meteor.settings.packages?.webapp?.includeVaryUserAgent; + + if (!Meteor.settings.packages) Meteor.settings.packages = {}; + if (!Meteor.settings.packages.webapp) Meteor.settings.packages.webapp = {}; - // Inject a single hashed file to prove it serves identically to all browsers. - WebAppInternals.staticFilesByArch[arch][hashedPath] = { - content: 'console.log("consistent-cdn")', - absolutePath: '/tmp/mock-consistent.js', + WebAppInternals.staticFilesByArch[arch][unhashedJs] = { + content: 'console.log("config-test")', + absolutePath: '/tmp/mock-config.js', cacheable: true, - hash: 'unique-hash-999', + hash: 'internal-hash', + type: 'js' + }; + + try { + Meteor.settings.packages.webapp.includeVaryUserAgent = false; + const resDisabled = await asyncGet(Meteor.absoluteUrl(unhashedJs)); + const varyDisabled = (resDisabled.headers['vary'] || '').toLowerCase(); + + test.isFalse( + varyDisabled.includes('user-agent'), + 'Should NOT have Vary header when setting is false' + ); + + Meteor.settings.packages.webapp.includeVaryUserAgent = true; + const resEnabled = await asyncGet(Meteor.absoluteUrl(unhashedJs)); + const varyEnabled = (resEnabled.headers['vary'] || '').toLowerCase(); + + test.isTrue( + varyEnabled.includes('user-agent'), + 'Should HAVE Vary header when setting is true' + ); + + } finally { + delete WebAppInternals.staticFilesByArch[arch][unhashedJs]; + Meteor.settings.packages.webapp.includeVaryUserAgent = originalSettings; + } + } +); + +// Verification: Ensure that a URL containing a specific hash serves the exact same +// content and headers to all browsers (Modern vs Legacy). +// This proves that removing 'Vary: User-Agent' is safe because the file content +// is determined solely by the unique hash in the URL, not by the requesting browser. +Tinytest.addAsync( + 'webapp - hashed files identical across user-agents', + async function (test) { + const arch = 'web.browser'; + const hash = 'unique-hash-999'; + const hashedPath = `/cdn-consistency-test.${hash}.js`; + const url = Meteor.absoluteUrl(hashedPath); + + WebAppInternals.staticFilesByArch[arch][hashedPath] = { + content: 'console.log("consistent-cdn")', + absolutePath: '/tmp/mock-consistent.js', + cacheable: true, + hash: hash, type: 'js' }; try { - // Request with Modern User-Agent (variable from webapp_tests.js scope) const resModern = await asyncGet(url, { headers: { 'User-Agent': modernUserAgent } }); - // Request with Legacy User-Agent (variable from webapp_tests.js scope) const resLegacy = await asyncGet(url, { headers: { 'User-Agent': legacyUserAgent } }); diff --git a/v3-docs/docs/packages/webapp.md b/v3-docs/docs/packages/webapp.md index 09f4454e15..276200436b 100644 --- a/v3-docs/docs/packages/webapp.md +++ b/v3-docs/docs/packages/webapp.md @@ -103,6 +103,28 @@ We're using the [connect-route](https://www.npmjs.com/package/connect-route) NPM And finally, if you decide to use this technique you'll want to make sure you understand how conflicting client side routing will affect user experience. +### Static Assets & Caching + +By default, Meteor serves different bundles (Modern vs. Legacy) based on the user's browser. Historically, this required the `Vary: User-Agent` header on all responses, which forced CDNs to fragment their cache (storing separate copies for Chrome, Safari, etc.). + +`webapp` now includes an automatic optimization to solve this: + +* **Production (Hashed Filenames):** Files with the hash in the pathname (e.g., `/app.abc12345.js`) are served **without** the `Vary: User-Agent` header. Since the pathname uniquely identifies the content, CDNs can safely cache a single copy for all users. +* **Development (Unhashed Filenames):** Files without the hash in the pathname (e.g., `/packages/promise.js?hash=abc123`) **keep** the `Vary: User-Agent` header to ensure safety during development. + +This behavior is enabled by default. You can control it via `Meteor.settings`: +```json +{ + "packages": { + "webapp": { + "includeVaryUserAgent": true + } + } +} +``` + +Setting `includeVaryUserAgent` to `false` will disable the header for **all** static files. + ### Dynamic Runtime Configuration In some cases it is valuable to be able to control the **meteor_runtime_config** variable that initializes Meteor at runtime. From 644952bff57b044566fb1304ec07f022e36be36c Mon Sep 17 00:00:00 2001 From: Harry Adel Date: Thu, 19 Feb 2026 02:40:26 +0200 Subject: [PATCH 05/44] Add absolute URLs to llms.txt --- v3-docs/docs/.vitepress/config.mts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/v3-docs/docs/.vitepress/config.mts b/v3-docs/docs/.vitepress/config.mts index bef4db33ed..08b471f0d1 100644 --- a/v3-docs/docs/.vitepress/config.mts +++ b/v3-docs/docs/.vitepress/config.mts @@ -667,7 +667,27 @@ export default defineConfig({ vite: { plugins: [ llmstxt({ - title: "Meteor.js 3 Docs", + title: "Meteor.js 3 Documentation", + domain: "https://docs.meteor.com", + description: "Full-stack JavaScript platform for modern web and mobile applications.", + details: ` +Meteor is a full-stack JavaScript platform for developing web and mobile applications. + +Key capabilities: +- Real-time data synchronization with publications and subscriptions +- Built-in accounts and authentication system +- Frontend agnostic (React, Vue, Solid, Blaze, Svelte) +- Zero-config build system with modern tooling (SWC, Rspack) +- One-command deployment to Galaxy Cloud +- TypeScript support with full type inference + +Current version: Meteor 3.4 with Node.js 22.x support. + +## Structured API Data + +For complete API documentation in machine-readable format, see: +- [api-reference.json](/api-reference.json) - Full API reference with all functions, parameters, and types + `.trim(), }), ], }, From ded541a7bee5f25c1348d74d19ca951ce43c397c Mon Sep 17 00:00:00 2001 From: Harry Adel Date: Thu, 19 Feb 2026 02:40:44 +0200 Subject: [PATCH 06/44] Export API reference JSON --- v3-docs/docs/.gitignore | 5 +- .../generators/api-export/generateApiJson.js | 65 +++++++++++++++++++ v3-docs/docs/generators/codegen.js | 3 + 3 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 v3-docs/docs/generators/api-export/generateApiJson.js diff --git a/v3-docs/docs/.gitignore b/v3-docs/docs/.gitignore index bcd2cabb66..60c63498eb 100644 --- a/v3-docs/docs/.gitignore +++ b/v3-docs/docs/.gitignore @@ -3,4 +3,7 @@ /.vitepress/dist /data/data.js -/data/names.json \ No newline at end of file +/data/names.json + +# Generated API reference for LLMs +/public/api-reference.json \ No newline at end of file diff --git a/v3-docs/docs/generators/api-export/generateApiJson.js b/v3-docs/docs/generators/api-export/generateApiJson.js new file mode 100644 index 0000000000..1ab371ed9d --- /dev/null +++ b/v3-docs/docs/generators/api-export/generateApiJson.js @@ -0,0 +1,65 @@ +/** + * Generates a public JSON file from the JSDoc API data. + * This file is accessible to LLMs at /api-reference.json + */ + +const fs = require('fs'); +const path = require('path'); + +async function generateApiJson() { + console.log("πŸ“¦ Generating API reference JSON for LLMs..."); + + const dataPath = path.join(__dirname, '../../data/data.js'); + const publicDir = path.join(__dirname, '../../public'); + const outputPath = path.join(publicDir, 'api-reference.json'); + + // Check if data.js exists + if (!fs.existsSync(dataPath)) { + console.log("⚠️ data/data.js not found. Run 'npm run generate-jsdoc' first."); + return; + } + + // Read the data.js file + const dataContent = fs.readFileSync(dataPath, 'utf-8'); + + // Extract the JSON from the ES module export + // The file format is: // comment\nexport default{...}; + const jsonMatch = dataContent.match(/export default\s*(\{[\s\S]*\});?\s*$/); + + if (!jsonMatch) { + console.error("❌ Could not parse data/data.js"); + return; + } + + try { + // Parse the JSON + const apiData = JSON.parse(jsonMatch[1]); + + // Create public directory if it doesn't exist + if (!fs.existsSync(publicDir)) { + fs.mkdirSync(publicDir, { recursive: true }); + } + + // Add metadata + const output = { + _meta: { + generator: "Meteor Docs API Export", + generated: new Date().toISOString(), + description: "API reference for Meteor.js - for LLM consumption", + url: "https://docs.meteor.com/api-reference.json" + }, + apis: apiData + }; + + // Write the JSON file + fs.writeFileSync(outputPath, JSON.stringify(output, null, 2)); + + const apiCount = Object.keys(apiData).length; + console.log(`βœ… Generated api-reference.json with ${apiCount} APIs`); + + } catch (err) { + console.error("❌ Error generating API JSON:", err.message); + } +} + +module.exports = { generateApiJson }; diff --git a/v3-docs/docs/generators/codegen.js b/v3-docs/docs/generators/codegen.js index a6ed7ab00a..fae6b7a8af 100644 --- a/v3-docs/docs/generators/codegen.js +++ b/v3-docs/docs/generators/codegen.js @@ -1,11 +1,14 @@ const { generateChangelog } = require("./changelog/script.js"); const { listPackages } = require("./packages-listing/script.js"); const { generateMeteorVersions } = require("./meteor-versions/script.js"); +const { generateApiJson } = require("./api-export/generateApiJson.js"); + async function main() { console.log("πŸš‚ Started codegen πŸš‚"); await generateChangelog(); await listPackages(); await generateMeteorVersions(); + await generateApiJson(); console.log("πŸš€ Done codegen πŸš€"); } From d6242fe0da51bea7b50bff30ee8393916d241f89 Mon Sep 17 00:00:00 2001 From: Harry Adel Date: Sun, 1 Mar 2026 23:41:24 +0200 Subject: [PATCH 07/44] Get version dynamically --- v3-docs/docs/.vitepress/config.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/v3-docs/docs/.vitepress/config.mts b/v3-docs/docs/.vitepress/config.mts index 08b471f0d1..d7644fd222 100644 --- a/v3-docs/docs/.vitepress/config.mts +++ b/v3-docs/docs/.vitepress/config.mts @@ -681,7 +681,7 @@ Key capabilities: - One-command deployment to Galaxy Cloud - TypeScript support with full type inference -Current version: Meteor 3.4 with Node.js 22.x support. +Current version: Meteor ${metadata.currentVersion}. ## Structured API Data From 78e1d397e6f5d23554338746297db489369d70a9 Mon Sep 17 00:00:00 2001 From: Harry Adel Date: Sun, 1 Mar 2026 23:41:50 +0200 Subject: [PATCH 08/44] Replace regex with dynamic import --- .../generators/api-export/generateApiJson.js | 33 +++++++------------ 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/v3-docs/docs/generators/api-export/generateApiJson.js b/v3-docs/docs/generators/api-export/generateApiJson.js index 1ab371ed9d..16e11fb3a3 100644 --- a/v3-docs/docs/generators/api-export/generateApiJson.js +++ b/v3-docs/docs/generators/api-export/generateApiJson.js @@ -5,41 +5,30 @@ const fs = require('fs'); const path = require('path'); +const { pathToFileURL } = require('url'); async function generateApiJson() { console.log("πŸ“¦ Generating API reference JSON for LLMs..."); - + const dataPath = path.join(__dirname, '../../data/data.js'); const publicDir = path.join(__dirname, '../../public'); const outputPath = path.join(publicDir, 'api-reference.json'); - + // Check if data.js exists if (!fs.existsSync(dataPath)) { console.log("⚠️ data/data.js not found. Run 'npm run generate-jsdoc' first."); return; } - - // Read the data.js file - const dataContent = fs.readFileSync(dataPath, 'utf-8'); - - // Extract the JSON from the ES module export - // The file format is: // comment\nexport default{...}; - const jsonMatch = dataContent.match(/export default\s*(\{[\s\S]*\});?\s*$/); - - if (!jsonMatch) { - console.error("❌ Could not parse data/data.js"); - return; - } - + try { - // Parse the JSON - const apiData = JSON.parse(jsonMatch[1]); - + // Dynamically import the ESM data module + const { default: apiData } = await import(pathToFileURL(dataPath).href); + // Create public directory if it doesn't exist if (!fs.existsSync(publicDir)) { fs.mkdirSync(publicDir, { recursive: true }); } - + // Add metadata const output = { _meta: { @@ -50,13 +39,13 @@ async function generateApiJson() { }, apis: apiData }; - + // Write the JSON file fs.writeFileSync(outputPath, JSON.stringify(output, null, 2)); - + const apiCount = Object.keys(apiData).length; console.log(`βœ… Generated api-reference.json with ${apiCount} APIs`); - + } catch (err) { console.error("❌ Error generating API JSON:", err.message); } From ea217a8eeb6aa78b2826342edab358c743248175 Mon Sep 17 00:00:00 2001 From: Harry Adel Date: Sun, 8 Mar 2026 12:56:30 +0200 Subject: [PATCH 09/44] Apply @italojs changes --- .../generators/api-export/generateApiJson.js | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/v3-docs/docs/generators/api-export/generateApiJson.js b/v3-docs/docs/generators/api-export/generateApiJson.js index 16e11fb3a3..d58c53e3a7 100644 --- a/v3-docs/docs/generators/api-export/generateApiJson.js +++ b/v3-docs/docs/generators/api-export/generateApiJson.js @@ -5,9 +5,15 @@ const fs = require('fs'); const path = require('path'); -const { pathToFileURL } = require('url'); -async function generateApiJson() { +function parseApiData(dataSource) { + const json = dataSource + .replace(/^(?:\/\/.*\n\s*)*export default\s*/, '') + .replace(/;\s*$/, ''); + return JSON.parse(json); +} + +exports.generateApiJson = async function generateApiJson() { console.log("πŸ“¦ Generating API reference JSON for LLMs..."); const dataPath = path.join(__dirname, '../../data/data.js'); @@ -21,8 +27,7 @@ async function generateApiJson() { } try { - // Dynamically import the ESM data module - const { default: apiData } = await import(pathToFileURL(dataPath).href); + const apiData = parseApiData(fs.readFileSync(dataPath, 'utf8')); // Create public directory if it doesn't exist if (!fs.existsSync(publicDir)) { @@ -49,6 +54,4 @@ async function generateApiJson() { } catch (err) { console.error("❌ Error generating API JSON:", err.message); } -} - -module.exports = { generateApiJson }; +}; From 22ec1c6df9b0e8f7a0918a065f0060c270f0a589 Mon Sep 17 00:00:00 2001 From: Jan Dvorak Date: Thu, 12 Mar 2026 10:36:10 +0100 Subject: [PATCH 10/44] Add Wormhole to recommended community packages --- v3-docs/docs/community-packages/index.md | 4 + v3-docs/docs/community-packages/wormhole.md | 138 ++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 v3-docs/docs/community-packages/wormhole.md diff --git a/v3-docs/docs/community-packages/index.md b/v3-docs/docs/community-packages/index.md index ee31736a45..7de8ec0565 100644 --- a/v3-docs/docs/community-packages/index.md +++ b/v3-docs/docs/community-packages/index.md @@ -24,6 +24,10 @@ Please bear in mind if you are adding a package to this list, try providing as m ## List of Community Packages +#### AI/LLM helpers + +- [Wormhole](./wormhole.md) Meteor Wormhole, MCP and REST API endpoint creator + #### Method/Subscription helpers - [`meteor-rpc`](./meteor-rpc.md), Meteor Methods Evolved with type checking and runtime validation diff --git a/v3-docs/docs/community-packages/wormhole.md b/v3-docs/docs/community-packages/wormhole.md new file mode 100644 index 0000000000..5ddd5a72f4 --- /dev/null +++ b/v3-docs/docs/community-packages/wormhole.md @@ -0,0 +1,138 @@ +# Jam Method + +- `Who maintains the package` – [William Reiske](https://github.com/wreiske/meteor-wormhole/commits?author=wreiske) + +[[toc]] + +## What Is It? + +Meteor Wormhole is a **server-only, Meteor 3.4+ package** that bridges your Meteor methods to the outside world through: + +- **[MCP (Model Context Protocol)](https://modelcontextprotocol.io/)** β€” The open standard for connecting AI assistants to tools and data. Your methods become MCP tools that Claude, GPT, Cursor, VS Code Copilot, and any MCP-compatible client can discover and invoke. +- **REST API** β€” Every exposed method also gets a `POST /api/` endpoint. +- **OpenAPI 3.1 spec** β€” Auto-generated from your method schemas. +- **Swagger UI** β€” Built-in interactive API docs at `/api/docs`. + +## How It Works + +Two lines to get started: + +```js +import { Wormhole } from 'meteor/wreiske:meteor-wormhole'; + +Wormhole.init(); // That's it β€” all your methods are now MCP tools +``` + +By default it runs in **"all-in" mode**, which automatically exposes every `Meteor.methods()` call (minus DDP internals, private `_`-prefixed methods, and Accounts methods). You can also run in **"opt-in" mode** for explicit control: + +```js +Wormhole.init({ mode: 'opt-in' }); + +Wormhole.expose('todos.add', { + description: 'Add a new todo item', + inputSchema: { + type: 'object', + properties: { + title: { type: 'string', description: 'The todo title' }, + priority: { type: 'string', enum: ['low', 'medium', 'high'] } + }, + required: ['title'] + } +}); +``` + +Add richer schemas and descriptions, and AI agents get better context about what your tools do and how to call them. + +## Features at a Glance + +- **Zero-config MCP server** β€” Streamable HTTP transport at `/mcp`, session management, JSON-RPC 2.0 +- **Optional REST bridge** β€” Enable with `rest: { enabled: true }` for traditional HTTP clients +- **Auto-generated OpenAPI 3.1 spec** with Swagger UI +- **Optional API key auth** β€” Covers both MCP and REST endpoints +- **Smart exclusions** β€” Automatically skips DDP internals, `_private` methods, and Accounts methods; add your own patterns +- **Input validation** β€” JSON Schema β†’ Zod conversion for parameter validation +- **Error propagation** β€” `Meteor.Error` details are properly passed through to clients +- **Enrich existing methods** β€” Add descriptions and schemas to auto-registered methods with `Wormhole.expose()` + +## Configuration Options + +```js +Wormhole.init({ + mode: 'all', // 'all' or 'opt-in' + path: '/mcp', // MCP endpoint path + name: 'my-app', // MCP server name + apiKey: 'secret', // Optional bearer token auth + exclude: [/^admin\./], // Additional exclusion patterns + rest: { + enabled: true, // Enable REST API + path: '/api', // REST base path + docs: true // Swagger UI at /api/docs + } +}); +``` + +## Point Your MCP Client at It + +If you use Claude Desktop, Cursor, VS Code Copilot, or any other MCP-compatible client, you can connect to a Wormhole-enabled app and your AI assistant will immediately see all the exposed methods as callable tools. Just point it at your app's `/mcp` endpoint. + +## API Reference + +### `Wormhole.init(options)` + +Initialize the MCP bridge. + +| Option | Type | Default | Description | +| --------- | ---------------------- | ------------------- | ---------------------------------- | +| `mode` | `'all' \| 'opt-in'` | `'all'` | Exposure mode | +| `path` | `string` | `'/mcp'` | HTTP endpoint path | +| `name` | `string` | `'meteor-wormhole'` | MCP server name | +| `version` | `string` | `'1.0.0'` | MCP server version | +| `apiKey` | `string \| null` | `null` | Bearer token for auth | +| `exclude` | `(string \| RegExp)[]` | `[]` | Methods to exclude (all-in mode) | +| `rest` | `object \| boolean` | `false` | REST API configuration (see below) | + +#### `rest` options + +| Option | Type | Default | Description | +| --------- | ---------------- | ----------- | -------------------------------------------- | +| `enabled` | `boolean` | `false` | Enable REST endpoints | +| `path` | `string` | `'/api'` | Base path for REST endpoints | +| `docs` | `boolean` | `true` | Serve Swagger UI at `/docs` | +| `apiKey` | `string \| null` | _inherited_ | API key for REST (defaults to main `apiKey`) | + +Shorthand: `rest: true` enables REST with all defaults. + +### `Wormhole.expose(methodName, options)` + +Explicitly expose a method as an MCP tool. + +| Option | Type | Description | +| -------------- | -------- | --------------------------------------------------------------------------------------- | +| `description` | `string` | Human-readable tool description | +| `inputSchema` | `object` | JSON Schema for method parameters | +| `outputSchema` | `object` | JSON Schema for the return value (wrapped inside `{ result }` envelope in OpenAPI/REST) | + +### `Wormhole.unexpose(methodName)` + +Remove a method from MCP exposure. + +## How It Works + +1. **Registration**: In all-in mode, the package monkey-patches `Meteor.methods` to intercept every method registration. In opt-in mode, you call `Wormhole.expose()` manually. + +2. **MCP Server**: A Streamable HTTP MCP server is mounted at the configured path (default `/mcp`) on Meteor's `WebApp`. + +3. **Tool Mapping**: Each exposed Meteor method becomes an MCP tool. Method names are sanitized (e.g., `todos.add` β†’ `todos_add`). + +4. **Invocation**: When an AI agent calls a tool, the bridge invokes the corresponding Meteor method via `Meteor.callAsync()` and returns the result. + +5. **REST API** (optional): When enabled, a parallel REST bridge mounts at the configured path. Each method gets a `POST` endpoint. An OpenAPI 3.1 spec is auto-generated from the registry's metadata and input schemas, and Swagger UI provides interactive documentation. + + +## Links + +- **GitHub:** https://github.com/wreiske/meteor-wormhole +- **Live Demo:** https://wormhole.meteorapp.com/ +- **Swagger UI:** https://wormhole.meteorapp.com/api/docs +- **Atmosphere:** [https://atmospherejs.com/wreiske/meteor-wormhole](https://atmospherejs.com/wreiske/meteor-wormhole) +- **Packosphere:** [https://packosphere.com/wreiske/meteor-wormhole](https://packosphere.com/wreiske/meteor-wormhole) From 59796fedb0a8cb8921c2a7f0dfae6fa8c3c336f0 Mon Sep 17 00:00:00 2001 From: Harry Adel Date: Thu, 12 Mar 2026 20:53:17 +0200 Subject: [PATCH 11/44] Fix puppeteer_runner.js using broken msg._text API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When puppeteer was bumped from 20.4.0 to 23.6.0 in d8c8c3db77, the ConsoleMessage class switched from underscore-prefixed _text property to ES2022 private #text field, accessible only via msg.text(). The runner code was never updated, so msg._text is always undefined and test output silently falls through to the else branch, printing only "Test number: N" with no actual test results. The text variable from msg.text() is already computed on line 15 for the Permissions policy filter β€” reuse it instead of the broken msg._text. --- packages/test-in-console/puppeteer_runner.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/test-in-console/puppeteer_runner.js b/packages/test-in-console/puppeteer_runner.js index 991bbd4631..7065341278 100644 --- a/packages/test-in-console/puppeteer_runner.js +++ b/packages/test-in-console/puppeteer_runner.js @@ -16,7 +16,7 @@ async function runNextUrl(browser) { if (text.includes('Permissions policy violation')) { return; } - if (msg._text !== undefined) console.log(msg._text); + if (text) console.log(text); else { testNumber++; const currentClientTest = From cec38c47a242b9c09efa6680a4acff21908fffca Mon Sep 17 00:00:00 2001 From: Harry Adel Date: Thu, 12 Mar 2026 21:07:22 +0200 Subject: [PATCH 12/44] Docs: guide LLMs to use test-in-console/run.sh for package tests AGENTS.md listed `./meteor test-packages` but not the headless alternative. That command starts a web server and waits for a browser, producing no terminal output, which causes LLM agents to hang. Add `./packages/test-in-console/run.sh` alongside the existing test-packages command in AGENTS.md with a note explaining the difference. Also add package name examples and the PUPPETEER_DOWNLOAD_PATH hint to the testing skill. --- .github/skills/testing/SKILL.md | 5 ++++- AGENTS.md | 8 +++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/skills/testing/SKILL.md b/.github/skills/testing/SKILL.md index 160ba85c97..b4275b99a3 100644 --- a/.github/skills/testing/SKILL.md +++ b/.github/skills/testing/SKILL.md @@ -22,8 +22,11 @@ Test patterns, commands, and utilities for the Meteor codebase. ./meteor test-packages mongo # Test specific package TINYTEST_FILTER="collection" ./meteor test-packages # Filter specific tests -# Package tests in console (headless via Puppeteer) +# Package tests in console (headless via Puppeteer β€” prints results to terminal) +# Use this for automation or when you need terminal output without a browser. PUPPETEER_DOWNLOAD_PATH=~/.npm/chromium ./packages/test-in-console/run.sh +./packages/test-in-console/run.sh # Test all core packages +./packages/test-in-console/run.sh "mongo" # Test specific package # Modern E2E tests (Jest + Playwright) npm run install:modern # Install dependencies diff --git a/AGENTS.md b/AGENTS.md index 22f14547ae..2b774d8e66 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,11 +8,17 @@ Full-stack JavaScript platform for modern web and mobile applications. ./meteor run # Run from source ./meteor create my-app # Create app ./meteor self-test # CLI tests -./meteor test-packages ./packages/ # Package tests +./meteor test-packages ./packages/ # Package tests (browser UI at localhost:3000) +./packages/test-in-console/run.sh "" # Package tests (terminal output via Puppeteer) npm run test:unit # Unit tests (Jest) npm run test:e2e # E2E tests (Jest + Playwright) ``` +> **Note:** `./meteor test-packages` starts a web server and waits for a browser β€” +> it produces no terminal output. For automated/headless runs, use +> `./packages/test-in-console/run.sh ""` instead, which runs the same tests +> via Puppeteer and prints pass/fail results to stdout. + ## Structure ``` From f95cab0e54ef7535c5837720581a0a404eea1f3c Mon Sep 17 00:00:00 2001 From: dupontbertrand Date: Fri, 20 Mar 2026 00:57:42 +0100 Subject: [PATCH 13/44] docs: add CSS Modules section to rspack bundler integration guide The CSS Modules setup was missing from the rspack documentation, causing confusion for users trying to use .module.css files with TypeScript projects. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../rspack-bundler-integration.md | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/v3-docs/docs/about/modern-build-stack/rspack-bundler-integration.md b/v3-docs/docs/about/modern-build-stack/rspack-bundler-integration.md index 2ddbe1dc53..2c7bf79134 100644 --- a/v3-docs/docs/about/modern-build-stack/rspack-bundler-integration.md +++ b/v3-docs/docs/about/modern-build-stack/rspack-bundler-integration.md @@ -397,6 +397,46 @@ With the Meteor–Rspack integration, `zodern:melte` no longer works. Use the of Meteor-Rspack comes with built-in CSS support. You can import any CSS file into your code, and it will be processed and included in your HTML skeleton automatically. In addition, any CSS file placed in the same folder as your Meteor entry point will be processed and added as global styles without the need for explicit imports. +### CSS Modules + +[CSS Modules](https://rspack.rs/guide/tech/css#css-modules) are supported out of the box β€” any file named `*.module.css` is automatically scoped locally. + +By default, rspack uses **named exports**, so imports look like: + +``` js +import { app } from './App.module.css'; +``` + +If you prefer **default imports** (`import styles from './App.module.css'`), disable `namedExports` on both the `css/auto` and `css/module` parsers: + +``` js +module.exports = defineConfig(Meteor => ({ + module: { + parser: { + 'css/auto': { + namedExports: false, + }, + 'css/module': { + namedExports: false, + }, + }, + }, +})); +``` + +#### TypeScript + +When using CSS Modules with TypeScript, add a declaration file (e.g. `imports/css-modules.d.ts`) so the compiler recognizes `.module.css` imports: + +``` typescript +declare module '*.module.css' { + const classes: { readonly [key: string]: string }; + export default classes; +} +``` + +For more details, check [the official Rspack CSS Modules guide](https://rspack.rs/guide/tech/css#css-modules). + ### Less Less support is available in Meteor-Rspack. You need to replace the existing [Meteor `less` package](https://github.com/meteor/meteor/tree/master/packages/non-core/less) or similar with the Rspack configuration. From 9269deeba06c3c509523092b3b33b2d02cc533e9 Mon Sep 17 00:00:00 2001 From: felippeximenes Date: Tue, 27 Jan 2026 13:05:44 -0300 Subject: [PATCH 14/44] docs: add Windows PowerShell note about .\meteor and 7-Zip --- DEVELOPMENT.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 58eda23621..db40651623 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -33,6 +33,14 @@ can run Meteor directly from a Git checkout using these steps: $ ./meteor --help ``` + > **Note for Windows (PowerShell):** + > + > * In PowerShell, use `.\meteor` (not `./meteor`). + > * Meteor may need `7z.exe` available in your `PATH` to download/extract binaries (dev_bundle). + > * Verify: `where.exe 7z` + > * If missing, install 7-Zip and ensure it is on your PATH (for example via `choco install 7zip -y` or `scoop install 7zip`). + + 3. **Ready to Go!** Your local Meteor checkout is now ready to use! You can use this `./meteor` From ef6b2fe79536320d0c3e271bcff184920ddc8cb1 Mon Sep 17 00:00:00 2001 From: dupontbertrand Date: Mon, 23 Mar 2026 23:13:19 +0100 Subject: [PATCH 15/44] docs: add dupontbertrand:mail-preview to community packages Add documentation page for the mail-preview package, a zero-config dev-mode email preview UI that captures outgoing emails and displays them at /__meteor_mail__/. Ref: https://forums.meteor.com/t/built-in-mail-preview-ui-for-dev-mode/64489 --- v3-docs/docs/.vitepress/config.mts | 4 + v3-docs/docs/community-packages/index.md | 4 + .../docs/community-packages/mail-preview.md | 123 ++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 v3-docs/docs/community-packages/mail-preview.md diff --git a/v3-docs/docs/.vitepress/config.mts b/v3-docs/docs/.vitepress/config.mts index e25264bda4..fe19ac20d4 100644 --- a/v3-docs/docs/.vitepress/config.mts +++ b/v3-docs/docs/.vitepress/config.mts @@ -461,6 +461,10 @@ export default defineConfig({ text: "jam:offline", link: "/community-packages/offline", }, + { + text: "dupontbertrand:mail-preview", + link: "/community-packages/mail-preview", + }, ], collapsed: true, }, diff --git a/v3-docs/docs/community-packages/index.md b/v3-docs/docs/community-packages/index.md index 7de8ec0565..8e5411c860 100644 --- a/v3-docs/docs/community-packages/index.md +++ b/v3-docs/docs/community-packages/index.md @@ -40,6 +40,10 @@ Please bear in mind if you are adding a package to this list, try providing as m - [`jam:soft-delete`](./soft-delete.md), An easy way to add soft deletes to your Meteor app - [`jam:archive`](./archive.md), +#### Developer tools + +- [`dupontbertrand:mail-preview`](./mail-preview.md), Zero-config dev-mode mail preview UI β€” view captured emails at `/__meteor_mail__/` + #### Utilities - [`jam:offline`](./offline.md), An easy way to give your Meteor app offline capabilities and make it feel instant diff --git a/v3-docs/docs/community-packages/mail-preview.md b/v3-docs/docs/community-packages/mail-preview.md new file mode 100644 index 0000000000..9f654ccc93 --- /dev/null +++ b/v3-docs/docs/community-packages/mail-preview.md @@ -0,0 +1,123 @@ +# Mail Preview + +- `Who maintains the package` – [Bertrand Dupont](https://github.com/dupontbertrand) + +[[toc]] + +## What Is It? + +A zero-config, dev-mode mail preview UI for Meteor. Every email sent via `Email.sendAsync()` is captured and displayed in a browser UI at `/__meteor_mail__/`. + +Inspired by similar features in Rails (Action Mailer Preview), Phoenix (Swoosh), Laravel (Mailtrap), and Django (console backend). + +This is a `devOnly` package β€” it is **automatically excluded from production builds**. Zero overhead in production, no need to remove it before deploying. + +## How to Download It? + +```bash +meteor add dupontbertrand:mail-preview +``` + +That's it. No configuration needed. + +## How to Use It? + +1. Start your Meteor app in development mode (`meteor run`) +2. Trigger any email β€” password reset, verification, enrollment, or your own `Email.sendAsync()` calls +3. Open `http://localhost:3000/__meteor_mail__/` in your browser + +### What You Get + +- **Mail list** β€” live-updating list of all captured emails (polls every 2s, no page reload) +- **Mail detail** β€” view each email with tabs for **HTML render**, **Plain Text**, and **HTML Source** +- **Clickable links** β€” verification, password reset, and enrollment links work directly from the preview +- **JSON API** β€” programmatic access for testing and tooling +- **Clear all** β€” one-click button to clear captured mails + +### Example: Capturing an Accounts Email + +```js +// Server β€” nothing special needed, just use Meteor's built-in accounts +import { Accounts } from 'meteor/accounts-base'; + +// When a user registers, Meteor sends a verification email. +// mail-preview captures it automatically. +Accounts.sendVerificationEmail(userId); +``` + +Then open `/__meteor_mail__/` to see the captured email, click the verification link, and it works. + +### Example: Custom Emails with MJML + +```js +// Server +import { Email } from 'meteor/email'; +import mjml2html from 'mjml'; + +const { html } = mjml2html(` + + + + + Hello from Meteor! + + Visit Meteor + + + + + +`); + +await Email.sendAsync({ + from: 'noreply@example.com', + to: 'user@example.com', + subject: 'Welcome!', + html, +}); +``` + +The MJML-rendered email is captured and displayed with full HTML rendering in the preview UI. + +## JSON API + +For programmatic access (useful in tests or tooling): + +| Method | Endpoint | Description | +| -------- | ------------------------------ | ------------------------ | +| `GET` | `/__meteor_mail__/api/mails` | List all captured mails | +| `GET` | `/__meteor_mail__/api/mails/:id` | Get a single mail | +| `DELETE` | `/__meteor_mail__/api/mails` | Clear all captured mails | + +### Example: Using the API in Tests + +```js +// In a test, after triggering an email: +const res = await fetch('http://localhost:3000/__meteor_mail__/api/mails'); +const { mails } = await res.json(); + +assert.equal(mails[0].subject, 'Verify your email'); +assert.ok(mails[0].text.includes('verify-email')); +``` + +## How It Works + +The package uses `Email.hookSend()` to intercept outgoing emails and store them in memory (up to 50 β€” oldest are evicted). A middleware mounted via `WebApp.rawConnectHandlers` serves the preview UI. + +- **Dev mode only** β€” guarded by `Meteor.isDevelopment` and `devOnly: true` in `package.js` +- **No SMTP needed** β€” emails are captured before they reach any transport +- **No external dependencies** β€” uses only Meteor core packages (`email`, `webapp`, `ecmascript`) +- **Works alongside `MAIL_URL`** β€” if set, emails are still sent normally; the hook captures a copy + +## Compatibility + +- Meteor 3.4+ +- Works with `accounts-password` emails (verification, reset password, enrollment) +- Works with custom `Email.sendAsync()` / `Email.send()` calls +- Compatible with Rspack bundler + +## Sources + +- **Atmosphere:** [dupontbertrand:mail-preview](https://atmospherejs.com/dupontbertrand/mail-preview) +- **GitHub:** [dupontbertrand/meteor-mail-preview](https://github.com/dupontbertrand/meteor-mail-preview) +- **Forum Discussion:** [Built-in Mail Preview UI for Dev Mode](https://forums.meteor.com/t/built-in-mail-preview-ui-for-dev-mode/64489) From 494290612bd348489cbc69e3d9dca315f04aeb45 Mon Sep 17 00:00:00 2001 From: dupontbertrand Date: Mon, 23 Mar 2026 23:24:50 +0100 Subject: [PATCH 16/44] docs: add dupontbertrand:cluster to community packages - Add documentation page for dupontbertrand:cluster, a Meteor 3 compatible fork of meteorhacks:cluster - Add entry to community packages index under new Scaling / Clustering category - Add sidebar navigation entry --- v3-docs/docs/.vitepress/config.mts | 4 + v3-docs/docs/community-packages/cluster.md | 152 +++++++++++++++++++++ v3-docs/docs/community-packages/index.md | 4 + 3 files changed, 160 insertions(+) create mode 100644 v3-docs/docs/community-packages/cluster.md diff --git a/v3-docs/docs/.vitepress/config.mts b/v3-docs/docs/.vitepress/config.mts index 97af62202c..53eeaffe19 100644 --- a/v3-docs/docs/.vitepress/config.mts +++ b/v3-docs/docs/.vitepress/config.mts @@ -461,6 +461,10 @@ export default defineConfig({ text: "jam:offline", link: "/community-packages/offline", }, + { + text: "dupontbertrand:cluster", + link: "/community-packages/cluster", + }, ], collapsed: true, }, diff --git a/v3-docs/docs/community-packages/cluster.md b/v3-docs/docs/community-packages/cluster.md new file mode 100644 index 0000000000..04b8f6fadb --- /dev/null +++ b/v3-docs/docs/community-packages/cluster.md @@ -0,0 +1,152 @@ +# Cluster + +- `Who maintains the package` – [Bertrand Dupont](https://github.com/dupontbertrand) + +[[toc]] + +## What Is It? + +A Meteor 3 compatible fork of [`meteorhacks:cluster`](https://github.com/meteorhacks/cluster), providing multi-core support, load balancing, and service discovery for Meteor apps. + +The original package was abandoned around 2016. This fork ports it to Meteor 3 with async/await, modern dependencies, and updated MongoDB driver β€” while preserving the same public API. + +::: warning +This is a **compatibility / migration bridge**, not a new recommended scaling architecture. If you only need multi-core CPU utilization, an external process manager like [PM2](https://pm2.keymetrics.io/) is simpler. This package is for apps that relied on `meteorhacks:cluster` features like service discovery, DDP-aware proxying, or `Cluster.discoverConnection()`. +::: + +## How to Download It? + +```bash +meteor add dupontbertrand:cluster +``` + +## How to Use It? + +### Multi-Core (Simplest Use Case) + +Just set the `CLUSTER_WORKERS_COUNT` environment variable: + +```bash +# Use all available CPU cores +CLUSTER_WORKERS_COUNT=auto meteor run + +# Or specify a number +CLUSTER_WORKERS_COUNT=4 meteor run +``` + +The package forks child processes automatically. No code changes needed. + +### Service Discovery + Load Balancing + +For multi-instance deployments with automatic service registration and DDP-aware load balancing: + +```bash +export CLUSTER_DISCOVERY_URL="mongodb://your-mongo-host/cluster" +export CLUSTER_SERVICE="web" +export CLUSTER_WORKERS_COUNT=2 +``` + +Instances register themselves in MongoDB and discover each other automatically. No need to reconfigure a load balancer when adding or removing instances. + +### Microservices + +Connect to other services in the cluster by name: + +```js +// Server +const serviceConnection = Cluster.discoverConnection('payments'); + +// Call methods on the remote service +const result = await serviceConnection.callAsync('processPayment', data); +``` + +## API + +### `Cluster.connect(discoveryUrl, [options])` + +Connect to a discovery backend (currently MongoDB). + +```js +Cluster.connect('mongodb://host/db'); +``` + +Usually configured via the `CLUSTER_DISCOVERY_URL` environment variable instead of calling directly. + +### `Cluster.register(serviceName, [options])` + +Register this instance as a named service. + +```js +Cluster.register('web'); +``` + +Options: +- `endpoint` β€” the URL other instances use to reach this one (defaults to `ROOT_URL`) +- `balancer` β€” URL of the balancer (defaults to `CLUSTER_BALANCER_URL`) +- `uiService` β€” the service that serves the UI (defaults to the registered service name) + +Usually configured via the `CLUSTER_SERVICE` environment variable. + +### `Cluster.discoverConnection(serviceName, [ddpOptions])` + +Get a DDP connection to a named service. The connection automatically tracks healthy instances via the discovery backend. + +```js +const conn = Cluster.discoverConnection('analytics'); +const data = await conn.callAsync('getReport', params); +``` + +### `Cluster.allowPublicAccess(serviceList)` + +Allow public (non-authenticated) access to specific services. + +```js +Cluster.allowPublicAccess(['web', 'api']); +``` + +Usually configured via the `CLUSTER_PUBLIC_SERVICES` environment variable (comma-separated). + +## Environment Variables + +| Variable | Description | Default | +| -------- | ----------- | ------- | +| `CLUSTER_WORKERS_COUNT` | Number of worker processes (`auto` = all cores) | 1 (no clustering) | +| `CLUSTER_DISCOVERY_URL` | MongoDB URL for service discovery | β€” | +| `CLUSTER_SERVICE` | Name to register this instance as | β€” | +| `CLUSTER_ENDPOINT_URL` | URL for other instances to reach this one | `ROOT_URL` | +| `CLUSTER_BALANCER_URL` | URL of the load balancer | β€” | +| `CLUSTER_PUBLIC_SERVICES` | Comma-separated list of public services | β€” | +| `CLUSTER_UI_SERVICE` | Service that serves the UI | same as `CLUSTER_SERVICE` | + +## What Changed From meteorhacks:cluster + +This fork preserves the original API. Under the hood: + +- MongoDB discovery backend rewritten in **async/await** (was Fibers/wrapAsync) +- MongoDB driver updated from 1.4.x to **6.12.0** +- `underscore` replaced with native ES2015+ +- npm dependencies updated (`cookies`, `http-proxy`, `portscanner`) +- Fixed `Buffer()` deprecation and a pre-existing IPC listener bug +- Balancer made transport-aware for compatibility with pluggable transports ([#14231](https://github.com/meteor/meteor/pull/14231)) +- Test suite adapted for Meteor 3 + +Full changelog: [METEOR3-PORT.md](https://github.com/dupontbertrand/cluster/blob/master/METEOR3-PORT.md) + +## Known Limitations + +- The balancer uses `OverShadowServerEvent` to intercept HTTP/WS traffic at the `httpServer` level. This is invasive and could break if Meteor changes its listener initialization order. +- No Windows testing yet. +- The worker pool uses `child_process.fork` with per-worker ports (not `node:cluster`). + +## Compatibility + +- Meteor 3.4+ +- Node 22 +- Tested on Linux + +## Sources + +- **Atmosphere:** [dupontbertrand:cluster](https://atmospherejs.com/dupontbertrand/cluster) +- **GitHub:** [dupontbertrand/cluster](https://github.com/dupontbertrand/cluster) +- **Original package:** [meteorhacks/cluster](https://github.com/meteorhacks/cluster) +- **Forum Discussion:** [Memory usage and sessions quadruple after upgrading to Meteor 3](https://forums.meteor.com/t/memory-usage-and-sessions-quadruple-after-upgrading-to-meteor-3/64496) diff --git a/v3-docs/docs/community-packages/index.md b/v3-docs/docs/community-packages/index.md index ee31736a45..e49248e39f 100644 --- a/v3-docs/docs/community-packages/index.md +++ b/v3-docs/docs/community-packages/index.md @@ -36,6 +36,10 @@ Please bear in mind if you are adding a package to this list, try providing as m - [`jam:soft-delete`](./soft-delete.md), An easy way to add soft deletes to your Meteor app - [`jam:archive`](./archive.md), +#### Scaling / Clustering + +- [`dupontbertrand:cluster`](./cluster.md), Meteor 3 fork of meteorhacks:cluster β€” multi-core, load balancing, service discovery + #### Utilities - [`jam:offline`](./offline.md), An easy way to give your Meteor app offline capabilities and make it feel instant From d7638494e717667b1e5fa97cbf29b792b5cda8f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Codo=C3=B1er?= Date: Thu, 26 Mar 2026 16:56:15 +0100 Subject: [PATCH 17/44] add .coderabbit.yml configuration for review automation --- .coderabbit.yalm | 95 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 .coderabbit.yalm diff --git a/.coderabbit.yalm b/.coderabbit.yalm new file mode 100644 index 0000000000..b4edd06cab --- /dev/null +++ b/.coderabbit.yalm @@ -0,0 +1,95 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json + +language: "en-US" + +reviews: + profile: "chill" # community repo β€” keep it welcoming + request_changes_workflow: false + high_level_summary: true + poem: false # serious OSS platform + in_progress_fortune: false # noise + review_status: true + review_details: false + commit_status: true + collapse_walkthrough: true + changed_files_summary: true + sequence_diagrams: false # overkill for package-level PRs + estimate_code_review_effort: true + assess_linked_issues: true + related_issues: true + related_prs: true + suggested_labels: true + auto_apply_labels: false + suggested_reviewers: true + auto_assign_reviewers: false + + # Exclude generated, build, and Meteor-internal files + path_filters: + - "!**/node_modules/**" + - "!**/.meteor/**" + - "!**/bundle/**" + - "!**/programs/**" + - "!**/*.min.js" + - "!**/cordova-build/**" + - "!**/package-lock.json" + + path_instructions: + - path: "packages/**" + instructions: > + This is a core Meteor Atmosphere package. Focus on API backwards + compatibility, DDP/reactivity correctness, and client/server split. + Avoid nitpicking style β€” the codebase has legacy patterns. + - path: "tools/**" + instructions: > + This is the Meteor build tool (Isobuild). Be thorough about + correctness, edge cases, and performance in the CLI/build pipeline. + - path: "npm-packages/**" + instructions: > + These are npm packages published from the Meteor monorepo. + Check for correct exports, peer dependency handling, and Node.js compatibility. + - path: "v3-docs/**" + instructions: > + Documentation for Meteor v3. Check for accuracy, clarity, and + correct code examples. Grammar and spelling matter here. + - path: "scripts/**" + instructions: > + Build and CI scripts. Focus on correctness, portability, and + error handling. + + auto_review: + enabled: true + drafts: false + auto_incremental_review: true + auto_pause_after_reviewed_commits: 3 + ignore_title_keywords: + - "WIP" + - "DO NOT MERGE" + base_branches: [] + + finishing_touches: + docstrings: + enabled: false # legacy JS β€” too much noise across 100s of packages + unit_tests: + enabled: true + simplify: + enabled: false + + tools: + shellcheck: + enabled: true # βœ… they have .sh scripts in /scripts + markdownlint: + enabled: true # βœ… heavy docs contribution + languagetool: + enabled: true # βœ… useful for international doc contributors + level: "default" + disabled_categories: + - "TYPOGRAPHY" # too nitpicky for code comments + ruff: + enabled: false # ❌ not a Python project + biome: + enabled: false # ❌ they use ESLint already (.eslintignore exists) + ast-grep: + essential_rules: true + +chat: + auto_reply: true From 69f36ab27a64fc45704ab2c28ed62e3330abadfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Codo=C3=B1er?= Date: Thu, 26 Mar 2026 17:03:22 +0100 Subject: [PATCH 18/44] update .coderabbit.yaml configuration --- .coderabbit.yalm => .coderabbit.yaml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .coderabbit.yalm => .coderabbit.yaml (100%) diff --git a/.coderabbit.yalm b/.coderabbit.yaml similarity index 100% rename from .coderabbit.yalm rename to .coderabbit.yaml From a99f6e193789370a5738782e8e3ea08eb02cffd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Codo=C3=B1er?= Date: Thu, 26 Mar 2026 17:07:22 +0100 Subject: [PATCH 19/44] re-run checks From d5b8f6c904bc102197b63c44eca77175dc2bfed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Codo=C3=B1er?= Date: Thu, 26 Mar 2026 17:41:57 +0100 Subject: [PATCH 20/44] update .coderabbit.yaml to disable review_status --- .coderabbit.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index b4edd06cab..ba6de87c22 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -8,7 +8,7 @@ reviews: high_level_summary: true poem: false # serious OSS platform in_progress_fortune: false # noise - review_status: true + review_status: false review_details: false commit_status: true collapse_walkthrough: true From 9c9d400260ff54c500db3735cfe31e4a0444e8c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Codo=C3=B1er?= Date: Mon, 30 Mar 2026 09:45:30 +0200 Subject: [PATCH 21/44] add `checkout-pr` script and update documentation for testing fork branches locally --- CONTRIBUTING.md | 8 ++ DEVELOPMENT.md | 26 ++++ scripts/checkout-pr.js | 261 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 295 insertions(+) create mode 100755 scripts/checkout-pr.js diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c646fad457..40ac2548f1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,6 +53,14 @@ Current Reviewers: - [@zodern](https://github.com/zodern) - [@radekmie](https://github.com/radekmie) +##### Testing a contributor's branch locally + +To quickly check out a PR branch from a fork for local testing, see the [Testing a fork branch](DEVELOPMENT.md#testing-a-fork-branch) section in `DEVELOPMENT.md`, or run: + +```sh +node scripts/checkout-pr.js https://github.com/meteor/meteor/pull/ +``` + #### Core Committer The contributors with commit access to meteor/meteor are employees of Meteor Software LP or community members who have distinguished themselves in other contribution areas or members of partner companies. If you want to become a core committer, please start writing PRs. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 58eda23621..e8c49ab999 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -56,6 +56,32 @@ can run Meteor directly from a Git checkout using these steps: > > Then you can use the chrome debugger inside `chrome://inspect`. +### Testing a fork branch + +When reviewing a pull request or testing changes from a contributor's fork, use the `checkout-pr.js` script to set up a local branch automatically: + +```sh +# From a PR URL (requires gh CLI or falls back to GitHub API via curl) +$ node scripts/checkout-pr.js https://github.com/meteor/meteor/pull/ + +# From a user:branch shorthand +$ node scripts/checkout-pr.js : + +# From a full fork repo URL and branch name +$ node scripts/checkout-pr.js +``` + +The script will: + +1. Add the fork as a git remote (named after the fork owner) if not already present +2. Fetch the target branch +3. Create (or update) a local branch named `fork//` +4. Print instructions for switching back to your previous branch + +For upstream PRs (branches on `meteor/meteor` itself), the script detects the existing `origin` remote and checks out the branch directly without the `fork/` prefix. + +If you run the script again for the same fork branch, it will fetch the latest changes and update the local branch. + ### Notes when running from a checkout The following are some distinct differences you must pay attention to when running Meteor from a checkout: diff --git a/scripts/checkout-pr.js b/scripts/checkout-pr.js new file mode 100755 index 0000000000..f904f2a5ac --- /dev/null +++ b/scripts/checkout-pr.js @@ -0,0 +1,261 @@ +#!/usr/bin/env node +// +// checkout-pr.js β€” prepare a local branch from a fork contribution +// +// Usage: +// node scripts/checkout-pr.js +// node scripts/checkout-pr.js : +// node scripts/checkout-pr.js +// +// Examples: +// node scripts/checkout-pr.js https://github.com/meteor/meteor/pull/ +// node scripts/checkout-pr.js : +// node scripts/checkout-pr.js + +'use strict'; + +const { execSync } = require('child_process'); +const https = require('https'); + +// Colors (disabled if stdout is not a TTY) +const isTTY = process.stdout.isTTY; +const c = { + red: isTTY ? '\x1b[0;31m' : '', + green: isTTY ? '\x1b[0;32m' : '', + yellow: isTTY ? '\x1b[0;33m' : '', + cyan: isTTY ? '\x1b[0;36m' : '', + bold: isTTY ? '\x1b[1m' : '', + reset: isTTY ? '\x1b[0m' : '', +}; + +function info(msg) { console.log(`${c.cyan}\u2192${c.reset} ${msg}`); } +function ok(msg) { console.log(`${c.green}\u2713${c.reset} ${msg}`); } +function warn(msg) { console.log(`${c.yellow}\u26A0${c.reset} ${msg}`); } +function err(msg) { console.error(`${c.red}\u2717${c.reset} ${msg}`); } + +function die(msg) { + err(msg); + process.exit(1); +} + +function usage() { + console.log(`Usage: + checkout-pr.js + checkout-pr.js : + checkout-pr.js + +Prepares a local branch from a fork contribution for testing and review. + +Examples: + node scripts/checkout-pr.js https://github.com/meteor/meteor/pull/ + node scripts/checkout-pr.js : + node scripts/checkout-pr.js `); + process.exit(1); +} + +function git(cmd, { silent = false } = {}) { + try { + return execSync(`git ${cmd}`, { + encoding: 'utf8', + stdio: silent ? ['pipe', 'pipe', 'pipe'] : ['pipe', 'pipe', 'inherit'], + }).trim(); + } catch (e) { + if (silent) return null; + throw e; + } +} + +function ghCli(args) { + try { + return execSync(`gh ${args}`, { encoding: 'utf8', stdio: 'pipe' }).trim(); + } catch { + return null; + } +} + +function hasCommand(name) { + try { + execSync(process.platform === 'win32' ? `where ${name}` : `command -v ${name}`, { stdio: 'pipe' }); + return true; + } catch { + return false; + } +} + +function httpsGet(url) { + return new Promise((resolve, reject) => { + https.get(url, { headers: { 'User-Agent': 'meteor-checkout-pr' } }, (res) => { + if (res.statusCode < 200 || res.statusCode >= 300) { + return reject(new Error(`HTTP ${res.statusCode}`)); + } + let data = ''; + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => resolve(data)); + }).on('error', reject); + }); +} + +async function extractFromPrUrl(prUrl) { + const match = prUrl.match(/^https?:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/); + if (!match) die(`could not parse PR URL: ${prUrl}`); + const [, repoPath, prNumber] = match; + + // Try gh CLI first + if (hasCommand('gh')) { + const result = ghCli(`pr view "${prUrl}" --json headRepositoryOwner,headRefName`); + if (result) { + try { + const data = JSON.parse(result); + const owner = data.headRepositoryOwner?.login; + const branch = data.headRefName; + if (owner && branch) return { owner, branch }; + } catch { /* fall through */ } + } + } + + // Fall back to GitHub REST API + const apiUrl = `https://api.github.com/repos/${repoPath}/pulls/${prNumber}`; + let body; + try { + body = await httpsGet(apiUrl); + } catch (e) { + die(`could not fetch PR data from ${apiUrl} (${e.message})`); + } + + try { + const data = JSON.parse(body); + const owner = data.head?.user?.login; + const branch = data.head?.ref; + if (owner && branch) return { owner, branch }; + } catch { /* fall through */ } + + die(`could not extract fork owner/branch from PR #${prNumber}`); +} + +function extractOwnerFromUrl(url) { + const match = url.match(/github\.com[:/]([^/]+)\//); + return match ? match[1] : null; +} + +function normalizeUrl(url) { + return url + .replace(/\.git$/, '') + .replace(/\/$/, '') + .replace(/^https?:\/\//, '') + .replace(/^git@github\.com:/, 'github.com/'); +} + +async function main() { + const args = process.argv.slice(2); + + // Ensure we're inside a git repo + if (!git('rev-parse --is-inside-work-tree', { silent: true })) { + die('not inside a git repository'); + } + + let forkOwner, forkBranch, forkRepoUrl; + + if (args.length === 0) { + usage(); + } else if (args.length === 1) { + const arg = args[0]; + const prMatch = arg.match(/^https?:\/\/github\.com\/.*\/pull\/\d+/); + const shortMatch = arg.match(/^([^:]+):(.+)$/); + + if (prMatch) { + const result = await extractFromPrUrl(arg); + forkOwner = result.owner; + forkBranch = result.branch; + forkRepoUrl = `https://github.com/${forkOwner}/meteor.git`; + } else if (shortMatch) { + forkOwner = shortMatch[1]; + forkBranch = shortMatch[2]; + forkRepoUrl = `https://github.com/${forkOwner}/meteor.git`; + } else { + err(`unrecognized format: ${arg}`); + console.error(''); + usage(); + } + } else if (args.length === 2) { + forkRepoUrl = args[0]; + forkBranch = args[1]; + forkOwner = extractOwnerFromUrl(forkRepoUrl); + if (!forkOwner) die(`could not extract owner from URL: ${forkRepoUrl}`); + } else { + usage(); + } + + const previousBranch = git('symbolic-ref --short HEAD', { silent: true }) + || git('rev-parse --short HEAD', { silent: true }) + || 'HEAD'; + + // Detect if the PR is from the upstream repo (not a fork) + let remoteName = ''; + let isUpstream = false; + const originUrl = git('remote get-url origin', { silent: true }); + if (originUrl) { + const normFork = normalizeUrl(forkRepoUrl); + const normOrigin = normalizeUrl(originUrl); + if (normFork === normOrigin) { + remoteName = 'origin'; + isUpstream = true; + } + } + + let localBranch; + if (isUpstream) { + localBranch = forkBranch; + } else { + remoteName = forkOwner; + localBranch = `fork/${forkOwner}/${forkBranch}`; + } + + console.log(`${c.bold}--- checkout-pr ---${c.reset}`); + info(`owner: ${c.bold}${forkOwner}${c.reset}`); + info(`branch: ${c.bold}${forkBranch}${c.reset}`); + info(`repo: ${c.bold}${forkRepoUrl}${c.reset}`); + if (isUpstream) { + info(`upstream: yes (using remote '${c.bold}${remoteName}${c.reset}')`); + } + info(`local branch: ${c.bold}${localBranch}${c.reset}`); + console.log(''); + + // Add remote if needed (skip for upstream PRs) + if (!isUpstream) { + const existingUrl = git(`remote get-url "${remoteName}"`, { silent: true }); + if (existingUrl) { + warn(`remote '${remoteName}' already exists, reusing it`); + } else { + info(`adding remote '${remoteName}' \u2192 ${forkRepoUrl}`); + git(`remote add "${remoteName}" "${forkRepoUrl}"`); + ok(`remote '${remoteName}' added`); + } + } + + // Fetch the branch + info(`fetching '${forkBranch}' from '${remoteName}'...`); + const fetchResult = git(`fetch "${remoteName}" "${forkBranch}"`, { silent: true }); + if (fetchResult === null) { + die(`failed to fetch branch '${forkBranch}' from '${remoteName}' \u2014 check that the fork and branch exist`); + } + ok(`fetched latest from '${remoteName}'`); + + // Create or switch to local branch + const branchExists = git(`show-ref --verify "refs/heads/${localBranch}"`, { silent: true }); + if (branchExists) { + warn(`branch '${localBranch}' already exists, switching and updating...`); + git(`checkout "${localBranch}"`); + git(`reset --hard "refs/remotes/${remoteName}/${forkBranch}"`); + } else { + info(`creating branch '${localBranch}'...`); + git(`checkout -b "${localBranch}" "refs/remotes/${remoteName}/${forkBranch}"`); + } + + console.log(''); + ok(`ready on branch: ${c.bold}${localBranch}${c.reset}`); + info(`to switch back: ${c.bold}git checkout ${previousBranch}${c.reset}`); +} + +main().catch((e) => { + die(e.message); +}); From 8f8ed8ccb4c59fd23f6919c2b69ad803a0452106 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Codo=C3=B1er?= Date: Mon, 30 Mar 2026 14:15:28 +0200 Subject: [PATCH 22/44] support SSH-based fork URLs and add clear error handling for unrecognized repo formats in checkout-pr script --- scripts/checkout-pr.js | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/scripts/checkout-pr.js b/scripts/checkout-pr.js index f904f2a5ac..1b174899b0 100755 --- a/scripts/checkout-pr.js +++ b/scripts/checkout-pr.js @@ -145,6 +145,15 @@ function normalizeUrl(url) { .replace(/^git@github\.com:/, 'github.com/'); } +function buildForkUrl(owner) { + // Match origin's protocol (SSH vs HTTPS) + const originUrl = git('remote get-url origin', { silent: true }) || ''; + if (originUrl.startsWith('git@')) { + return `git@github.com:${owner}/meteor.git`; + } + return `https://github.com/${owner}/meteor.git`; +} + async function main() { const args = process.argv.slice(2); @@ -160,17 +169,22 @@ async function main() { } else if (args.length === 1) { const arg = args[0]; const prMatch = arg.match(/^https?:\/\/github\.com\/.*\/pull\/\d+/); - const shortMatch = arg.match(/^([^:]+):(.+)$/); + const sshMatch = arg.match(/^git@[^:]+:/); + const httpsRepoMatch = arg.match(/^https?:\/\/.*\.git$/); + // user:branch β€” must not start with git@ (SSH) or contain / before : (URLs) + const shortMatch = !sshMatch && arg.match(/^([^/:]+):(.+)$/); if (prMatch) { const result = await extractFromPrUrl(arg); forkOwner = result.owner; forkBranch = result.branch; - forkRepoUrl = `https://github.com/${forkOwner}/meteor.git`; + forkRepoUrl = buildForkUrl(forkOwner); + } else if (sshMatch || httpsRepoMatch) { + die(`repo URL requires a branch argument: node scripts/checkout-pr.js ${arg} `); } else if (shortMatch) { forkOwner = shortMatch[1]; forkBranch = shortMatch[2]; - forkRepoUrl = `https://github.com/${forkOwner}/meteor.git`; + forkRepoUrl = buildForkUrl(forkOwner); } else { err(`unrecognized format: ${arg}`); console.error(''); From 7ec1dd5948dddcfbb767a5f7ca2026e946fc084a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Codo=C3=B1er?= Date: Mon, 30 Mar 2026 14:17:57 +0200 Subject: [PATCH 23/44] add `checkout:pr` npm script and update documentation usage --- CONTRIBUTING.md | 2 +- DEVELOPMENT.md | 8 ++++---- package.json | 3 ++- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 40ac2548f1..04902faabf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,7 +58,7 @@ Current Reviewers: To quickly check out a PR branch from a fork for local testing, see the [Testing a fork branch](DEVELOPMENT.md#testing-a-fork-branch) section in `DEVELOPMENT.md`, or run: ```sh -node scripts/checkout-pr.js https://github.com/meteor/meteor/pull/ +npm run checkout:pr -- https://github.com/meteor/meteor/pull/ ``` #### Core Committer diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 9c5c216561..80f6cbf585 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -70,13 +70,13 @@ When reviewing a pull request or testing changes from a contributor's fork, use ```sh # From a PR URL (requires gh CLI or falls back to GitHub API via curl) -$ node scripts/checkout-pr.js https://github.com/meteor/meteor/pull/ +$ npm run checkout:pr -- https://github.com/meteor/meteor/pull/ # From a user:branch shorthand -$ node scripts/checkout-pr.js : +$ npm run checkout:pr -- : -# From a full fork repo URL and branch name -$ node scripts/checkout-pr.js +# From a full fork repo URL and branch name (HTTPS or SSH) +$ npm run checkout:pr -- ``` The script will: diff --git a/package.json b/package.json index 9c096fcd0a..6c4f6cc640 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,8 @@ "test:unit": "cd tools/unit-tests && npm test", "test:idle-bot": "node --test .github/scripts/__tests__/inactive-issues.test.js", "install:e2e": "cd tools/modern-tests && npm install && npx playwright install --with-deps chromium chromium-headless-shell", - "test:e2e": "cd tools/modern-tests && npm test -- " + "test:e2e": "cd tools/modern-tests && npm test -- ", + "checkout:pr": "node scripts/checkout-pr.js" }, "jshintConfig": { "esversion": 11 From f1a0d2677805aae9ddab034adcb14f22febb58d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Codo=C3=B1er?= Date: Mon, 30 Mar 2026 14:21:04 +0200 Subject: [PATCH 24/44] sdd SSH fork URL support to `checkout:pr` script and usage documentation --- DEVELOPMENT.md | 5 ++++- scripts/checkout-pr.js | 16 ++++++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 80f6cbf585..bf5c84193f 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -75,8 +75,11 @@ $ npm run checkout:pr -- https://github.com/meteor/meteor/pull/ # From a user:branch shorthand $ npm run checkout:pr -- : -# From a full fork repo URL and branch name (HTTPS or SSH) +# From a full fork repo URL and branch name (HTTPS) $ npm run checkout:pr -- + +# From a full fork repo URL and branch name (SSH) +$ npm run checkout:pr -- git@github.com:/.git ``` The script will: diff --git a/scripts/checkout-pr.js b/scripts/checkout-pr.js index 1b174899b0..d0c1e32ebd 100755 --- a/scripts/checkout-pr.js +++ b/scripts/checkout-pr.js @@ -6,11 +6,13 @@ // node scripts/checkout-pr.js // node scripts/checkout-pr.js : // node scripts/checkout-pr.js +// node scripts/checkout-pr.js git@github.com:/.git // // Examples: // node scripts/checkout-pr.js https://github.com/meteor/meteor/pull/ // node scripts/checkout-pr.js : // node scripts/checkout-pr.js +// node scripts/checkout-pr.js git@github.com:/.git 'use strict'; @@ -40,16 +42,18 @@ function die(msg) { function usage() { console.log(`Usage: - checkout-pr.js - checkout-pr.js : - checkout-pr.js + npm run checkout:pr -- + npm run checkout:pr -- : + npm run checkout:pr -- + npm run checkout:pr -- git@github.com:/.git Prepares a local branch from a fork contribution for testing and review. Examples: - node scripts/checkout-pr.js https://github.com/meteor/meteor/pull/ - node scripts/checkout-pr.js : - node scripts/checkout-pr.js `); + npm run checkout:pr -- https://github.com/meteor/meteor/pull/ + npm run checkout:pr -- : + npm run checkout:pr -- + npm run checkout:pr -- git@github.com:/.git `); process.exit(1); } From f2c0f8136aacb09a6ad24067e6a1a7d5947194aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Codo=C3=B1er?= Date: Mon, 30 Mar 2026 17:36:17 +0200 Subject: [PATCH 25/44] add PR number support to `checkout:pr` script and enhance usage documentation --- scripts/checkout-pr.js | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/scripts/checkout-pr.js b/scripts/checkout-pr.js index d0c1e32ebd..7914243b07 100755 --- a/scripts/checkout-pr.js +++ b/scripts/checkout-pr.js @@ -3,12 +3,14 @@ // checkout-pr.js β€” prepare a local branch from a fork contribution // // Usage: +// node scripts/checkout-pr.js // node scripts/checkout-pr.js // node scripts/checkout-pr.js : // node scripts/checkout-pr.js // node scripts/checkout-pr.js git@github.com:/.git // // Examples: +// node scripts/checkout-pr.js 123 // node scripts/checkout-pr.js https://github.com/meteor/meteor/pull/ // node scripts/checkout-pr.js : // node scripts/checkout-pr.js @@ -42,6 +44,7 @@ function die(msg) { function usage() { console.log(`Usage: + npm run checkout:pr -- npm run checkout:pr -- npm run checkout:pr -- : npm run checkout:pr -- @@ -50,6 +53,7 @@ function usage() { Prepares a local branch from a fork contribution for testing and review. Examples: + npm run checkout:pr -- 123 npm run checkout:pr -- https://github.com/meteor/meteor/pull/ npm run checkout:pr -- : npm run checkout:pr -- @@ -136,6 +140,18 @@ async function extractFromPrUrl(prUrl) { die(`could not extract fork owner/branch from PR #${prNumber}`); } +function getRepoPathFromOrigin() { + const originUrl = git('remote get-url origin', { silent: true }); + if (!originUrl) return null; + // Match HTTPS: https://github.com/owner/repo(.git) + const httpsMatch = originUrl.match(/github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/); + if (httpsMatch) return httpsMatch[1]; + // Match SSH: git@github.com:owner/repo(.git) + const sshMatch = originUrl.match(/github\.com:([^/]+\/[^/]+?)(?:\.git)?$/); + if (sshMatch) return sshMatch[1]; + return null; +} + function extractOwnerFromUrl(url) { const match = url.match(/github\.com[:/]([^/]+)\//); return match ? match[1] : null; @@ -172,13 +188,23 @@ async function main() { usage(); } else if (args.length === 1) { const arg = args[0]; + const prNumberMatch = arg.match(/^\d+$/); const prMatch = arg.match(/^https?:\/\/github\.com\/.*\/pull\/\d+/); const sshMatch = arg.match(/^git@[^:]+:/); const httpsRepoMatch = arg.match(/^https?:\/\/.*\.git$/); // user:branch β€” must not start with git@ (SSH) or contain / before : (URLs) const shortMatch = !sshMatch && arg.match(/^([^/:]+):(.+)$/); - if (prMatch) { + if (prNumberMatch) { + const repoPath = getRepoPathFromOrigin(); + if (!repoPath) die('could not determine repository from origin remote'); + const prUrl = `https://github.com/${repoPath}/pull/${arg}`; + info(`resolved PR #${arg} β†’ ${c.bold}${prUrl}${c.reset}`); + const result = await extractFromPrUrl(prUrl); + forkOwner = result.owner; + forkBranch = result.branch; + forkRepoUrl = buildForkUrl(forkOwner); + } else if (prMatch) { const result = await extractFromPrUrl(arg); forkOwner = result.owner; forkBranch = result.branch; From 7c72c706f77e5b847d44b1b12fa959ce3071c47a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nacho=20Codo=C3=B1er?= Date: Mon, 30 Mar 2026 17:38:33 +0200 Subject: [PATCH 26/44] refactor `checkout:pr` script to simplify argument validation and improve error handling --- scripts/checkout-pr.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/scripts/checkout-pr.js b/scripts/checkout-pr.js index 7914243b07..1bfdc7cdf2 100755 --- a/scripts/checkout-pr.js +++ b/scripts/checkout-pr.js @@ -184,9 +184,7 @@ async function main() { let forkOwner, forkBranch, forkRepoUrl; - if (args.length === 0) { - usage(); - } else if (args.length === 1) { + if (args.length === 1) { const arg = args[0]; const prNumberMatch = arg.match(/^\d+$/); const prMatch = arg.match(/^https?:\/\/github\.com\/.*\/pull\/\d+/); @@ -218,7 +216,7 @@ async function main() { } else { err(`unrecognized format: ${arg}`); console.error(''); - usage(); + return usage(); } } else if (args.length === 2) { forkRepoUrl = args[0]; @@ -226,7 +224,7 @@ async function main() { forkOwner = extractOwnerFromUrl(forkRepoUrl); if (!forkOwner) die(`could not extract owner from URL: ${forkRepoUrl}`); } else { - usage(); + return usage(); } const previousBranch = git('symbolic-ref --short HEAD', { silent: true }) From 5f8a9535fc3ba723f102d2fd97549c9dc23a9ba9 Mon Sep 17 00:00:00 2001 From: Gabriel Grubba Date: Mon, 30 Mar 2026 20:36:14 -0300 Subject: [PATCH 27/44] DOCS: add Galaxy Cloud as a valid option for MongoDB and update node version in example file --- v3-docs/docs/tutorials/deployment/deployment.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/v3-docs/docs/tutorials/deployment/deployment.md b/v3-docs/docs/tutorials/deployment/deployment.md index d0c026661d..98ee5301a3 100644 --- a/v3-docs/docs/tutorials/deployment/deployment.md +++ b/v3-docs/docs/tutorials/deployment/deployment.md @@ -209,6 +209,7 @@ When you deploy your Meteor server, you need a `MONGO_URL` that points to your M There are a variety of services out there: +- [MongoDB hosted by Galaxy Cloud](https://galaxycloud.app/) - MongoDB hosting provided by Galaxy Cloud - [MongoDB Atlas](https://www.mongodb.com/cloud/atlas) - The official MongoDB cloud service - [DigitalOcean Managed Databases](https://www.digitalocean.com/products/managed-databases-mongodb) - [AWS DocumentDB](https://aws.amazon.com/documentdb/) (MongoDB compatible) @@ -265,7 +266,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: 22.x - name: Install Meteor run: curl https://install.meteor.com/ | sh From 1feda279afc8bebd9afd4d2ab573cb84640395fb Mon Sep 17 00:00:00 2001 From: Gabriel Grubba Date: Mon, 30 Mar 2026 20:52:30 -0300 Subject: [PATCH 28/44] DOCS: remove routing from core concepts docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This doc only covers FlowRouter and Blaze, I'm not sure how useful this doc can be for core in general(maybe we could have multiple sections about routers?) – I'll remove this one for now until we know how to proceed about these core concepts(what should be a core concept?) --- v3-docs/docs/.vitepress/config.mts | 4 - v3-docs/docs/tutorials/routing/routing.md | 505 ---------------------- 2 files changed, 509 deletions(-) delete mode 100644 v3-docs/docs/tutorials/routing/routing.md diff --git a/v3-docs/docs/.vitepress/config.mts b/v3-docs/docs/.vitepress/config.mts index 25eb149a16..5cd9edb97f 100644 --- a/v3-docs/docs/.vitepress/config.mts +++ b/v3-docs/docs/.vitepress/config.mts @@ -553,10 +553,6 @@ export default defineConfig({ text: "Accounts", link: "/tutorials/accounts/accounts", }, - { - text: "Routing", - link: "/tutorials/routing/routing", - }, ] }, { diff --git a/v3-docs/docs/tutorials/routing/routing.md b/v3-docs/docs/tutorials/routing/routing.md deleted file mode 100644 index 2f66c8e00f..0000000000 --- a/v3-docs/docs/tutorials/routing/routing.md +++ /dev/null @@ -1,505 +0,0 @@ -# URLs and Routing - -After reading this guide, you'll know: - -1. The role URLs play in a client-rendered app, and how it's different from a traditional server-rendered app. -2. How to define client and server routes for your app using Flow Router. -3. How to have your app display different content depending on the URL. -4. How to dynamically load application modules depending on the URL. -5. How to construct links to routes and go to routes programmatically. - -## Client-side Routing - -In a web application, _routing_ is the process of using URLs to drive the user interface (UI). URLs are a prominent feature in every single web browser, and have several main functions from the user's point of view: - -1. **Bookmarking** - Users can bookmark URLs in their web browser to save content they want to come back to later. -2. **Sharing** - Users can share content with others by sending a link to a certain page. -3. **Navigation** - URLs are used to drive the web browser's back/forward functions. - -In a traditional web application stack, where the server renders HTML one page at a time, the URL is the fundamental entry point for the user to access the application. Users navigate an application by clicking through URLs, which are sent to the server via HTTP, and the server responds appropriately via a server-side router. - -In contrast, Meteor operates on the principle of _data on the wire_, where the server doesn't think in terms of URLs or HTML pages. The client application communicates with the server over DDP. Typically as an application loads, it initializes a series of _subscriptions_ which fetch the data required to render the application. As the user interacts with the application, different subscriptions may load, but there's no technical need for URLs to be involved in this process - you could have a Meteor app where the URL never changes. - -However, most of the user-facing features of URLs listed above are still relevant for typical Meteor applications. Since the server is not URL-driven, the URL becomes a useful representation of the client-side state the user is currently looking at. However, unlike in a server-rendered application, it does not need to describe the entirety of the user's current state; it needs to contain the parts that you want to be linkable. For example, the URL should contain any search filters applied on a page, but not necessarily the state of a dropdown menu or popup. - -## Using Flow Router - -To add routing to your app, install the [`ostrio:flow-router-extra`](https://atmospherejs.com/ostrio/flow-router-extra) package: - -```bash -meteor add ostrio:flow-router-extra -``` - -Flow Router Extra is the community routing package for Meteor. It is a carefully extended `flow-router` package with additional features like `waitOn`, template context, and built-in `.render()`. The packages `arillo:flow-router-helpers` and `zimme:active-route` are already built into Flow Router Extra and updated to support the latest Meteor release. - -## Defining a simple route - -The basic purpose of a router is to match certain URLs and perform actions as a result. This all happens on the client side, in the app user's browser or mobile app container. Let's take an example from the Todos example app: - -```js -import { FlowRouter } from 'meteor/ostrio:flow-router-extra'; - -FlowRouter.route('/lists/:_id', { - name: 'Lists.show', - action(params, queryParams) { - console.log("Looking at a list?"); - } -}); -``` - -This route handler will run in two situations: if the page loads initially at a URL that matches the URL pattern, or if the URL changes to one that matches the pattern while the page is open. Note that, unlike in a server-side-rendered app, the URL can change without any additional requests to the server. - -When the route is matched, the `action` method executes, and you can perform any actions you need to. The `name` property of the route is optional, but will let us refer to this route more conveniently later on. - -### URL pattern matching - -Consider the following URL pattern, used in the code snippet above: - -```js -'/lists/:_id' -``` - -The above pattern will match certain URLs. You may notice that one segment of the URL is prefixed by `:` - this means that it is a *url parameter*, and will match any string that is present in that segment of the path. Flow Router will make that part of the URL available on the `params` property of the current route. - -Additionally, the URL could contain an HTTP [*query string*](https://en.wikipedia.org/wiki/Query_string) (the part after an optional `?`). If so, Flow Router will also split it up into named parameters, which it calls `queryParams`. - -Here are some example URLs and the resulting `params` and `queryParams`: - -| URL | matches pattern? | params | queryParams | -| ---- | ---- | ---- | ---- | -| / | no | | | -| /about | no | | | -| /lists/ | no | | | -| /lists/eMtGij5AFESbTKfkT | yes | { _id: "eMtGij5AFESbTKfkT"} | { } | -| /lists/1 | yes | { _id: "1"} | { } | -| /lists/1?todoSort=top | yes | { _id: "1"} | { todoSort: "top" } | - -Note that all of the values in `params` and `queryParams` are always strings since URLs don't have any way of encoding data types. For example, if you wanted a parameter to represent a number, you might need to use `parseInt(value, 10)` to convert it when you access it. - -## Accessing Route information - -In addition to passing in the parameters as arguments to the `action` function on the route, Flow Router makes a variety of information available via (reactive and otherwise) functions on the global singleton `FlowRouter`. As the user navigates around your app, the values of these functions will change (reactively in some cases) correspondingly. - -Like any other global singleton in your application, it's best to limit your access to `FlowRouter`. That way the parts of your app will remain modular and more independent. In the case of `FlowRouter`, it's best to access it solely from the top of your component hierarchy, either in the "page" component, or the layouts that wrap it. - -### The current route - -It's useful to access information about the current route in your code. Here are some reactive functions you can call: - -* `FlowRouter.getRouteName()` gets the name of the route -* `FlowRouter.getParam(paramName)` returns the value of a single URL parameter -* `FlowRouter.getQueryParam(paramName)` returns the value of a single URL query parameter - -In our example of the list page from the Todos app, we access the current list's id with `FlowRouter.getParam('_id')`. - -### Highlighting the active route - -One situation where it is sensible to access the global `FlowRouter` singleton to access the current route's information deeper in the component hierarchy is when rendering links via a navigation component. It's often required to highlight the "active" route in some way (this is the route or section of the site that the user is currently looking at). - -In the Todos example app, we link to each list the user knows about in the `App_body` template: - -```html -{{#each list in lists}} - - ... - {{list.name}} - -{{/each}} -``` - -We can determine if the user is currently viewing the list with the `activeListClass` helper: - -```js -Template.App_body.helpers({ - activeListClass(list) { - const active = ActiveRoute.name('Lists.show') - && FlowRouter.getParam('_id') === list._id; - - return active && 'active'; - } -}); -``` - -## Rendering based on the route - -Now we understand how to define routes and access information about the current route, we are in a position to do what you usually want to do when a user accesses a route---render a user interface to the screen that represents it. - -### Rendering with Blaze - -When using Flow Router with Blaze, the simplest way to display different views on the page for different URLs is to use the complementary Blaze Layout package. First, make sure you have the Blaze Layout package installed: - -```bash -meteor add kadira:blaze-layout -``` - -To use this package, we need to define a "layout" component. In the Todos example app, that component is called `App_body`: - -```html - -``` - -Here, we are using a Blaze feature called `Template.dynamic` to render a template which is attached to the `main` property of the data context. Using Blaze Layout, we can change that `main` property when a route is accessed. - -We do that in the `action` function of our `Lists.show` route definition: - -```js -import { BlazeLayout } from 'meteor/kadira:blaze-layout'; -import { FlowRouter } from 'meteor/ostrio:flow-router-extra'; - -FlowRouter.route('/lists/:_id', { - name: 'Lists.show', - action() { - BlazeLayout.render('App_body', { main: 'Lists_show_page' }); - } -}); -``` - -What this means is that whenever a user visits a URL of the form `/lists/X`, the `Lists.show` route will kick in, triggering the `BlazeLayout` call to set the `main` property of the `App_body` component. - -### Rendering with React - -When using React, you can render components directly in the route action. Here's how to set up React with Flow Router: - -```js -import React from 'react'; -import { createRoot } from 'react-dom/client'; -import { FlowRouter } from 'meteor/ostrio:flow-router-extra'; - -// Create a root element for React -const rootElement = document.getElementById('react-root'); -const root = createRoot(rootElement); - -FlowRouter.route('/lists/:_id', { - name: 'Lists.show', - action(params) { - root.render(); - } -}); -``` - -Or you can use `react-mount` package for a more integrated approach: - -```bash -meteor npm install react-mounter -``` - -```js -import { mount } from 'react-mounter'; -import { FlowRouter } from 'meteor/ostrio:flow-router-extra'; -import { MainLayout } from './layouts/MainLayout'; -import { ListsShowPage } from './pages/ListsShowPage'; - -FlowRouter.route('/lists/:_id', { - name: 'Lists.show', - action(params) { - mount(MainLayout, { - content: - }); - } -}); -``` - -## Components as pages - -Notice that we called the component to be rendered `Lists_show_page` (rather than `Lists_show`). This indicates that this template is rendered directly by a Flow Router action and forms the 'top' of the rendering hierarchy for this URL. - -The `Lists_show_page` template renders *without* arguments---it is this template's responsibility to collect information from the current route, and then pass this information down into its child templates. Correspondingly the `Lists_show_page` template is very tied to the route that rendered it, and so it needs to be a smart component. - -It makes sense for a "page" smart component like `Lists_show_page` to: - -1. Collect route information, -2. Subscribe to relevant subscriptions, -3. Fetch the data from those subscriptions, and -4. Pass that data into a sub-component. - -In this case, the HTML template for `Lists_show_page` will look very simple, with most of the logic in the JavaScript code: - -```html - -``` - -```js -Template.Lists_show_page.helpers({ - listIdArray() { - const instance = Template.instance(); - const listId = instance.getListId(); - return Lists.findOne(listId) ? [listId] : []; - }, - async listArgs(listId) { - const instance = Template.instance(); - return { - todosReady: instance.subscriptionsReady(), - list() { - return Lists.findOne(listId); - }, - todos: await Lists.findOneAsync(listId, { fields: { _id: true } }).todos() - }; - } -}); -``` - -### Changing page when logged out - -There are types of rendering logic that appear related to the route but which also seem related to user interface rendering. A classic example is authorization; for instance, you may want to render a login form for some subset of your pages if the user is not yet logged in. - -It's best to keep all logic around what to render in the component hierarchy. So this authorization should happen inside a component: - -```html - -``` - -You can create wrapper components using Blaze's "template as block helper" ability: - -```html - -``` - -Once that template exists, we can wrap our `Lists_show_page`: - -```html - -``` - -## Changing Routes - -Rendering an updated UI when a user reaches a new route is not that useful without giving the user some way to reach a new route! The simplest way is with the trusty `` tag and a URL. You can generate the URLs yourself using helpers such as `FlowRouter.pathFor` to display a link to a certain route: - -```html - -``` - -### Routing programmatically - -In some cases you want to change routes based on user action outside of them clicking on a link. For instance, in the example app, when a user creates a new list, we want to route them to the list they just created. We do this by calling `FlowRouter.go()` once we know the id of the new list: - -```js -import { FlowRouter } from 'meteor/ostrio:flow-router-extra'; - -Template.App_body.events({ - async 'click .js-new-list'() { - const listId = await insert.callAsync(); - FlowRouter.go('Lists.show', { _id: listId }); - } -}); -``` - -You can also change only part of the URL if you want to, using the `FlowRouter.setParams()` and `FlowRouter.setQueryParams()`. For instance, if we were viewing one list and wanted to go to another: - -```js -FlowRouter.setParams({ _id: newList._id }); -``` - -Of course, calling `FlowRouter.go()` will always work, so unless you are trying to optimize for a specific situation it's better to use that. - -### Storing data in the URL - -As we discussed in the introduction, the URL is really a serialization of some part of the client-side state the user is looking at. Although parameters can only be strings, it's possible to convert any type of data to a string by serializing it. - -In general if you want to store arbitrary serializable data in a URL param, you can use `EJSON.stringify()` to turn it into a string. You'll need to URL-encode the string using `encodeURIComponent` to remove any characters that have meaning in a URL: - -```js -import { EJSON } from 'meteor/ejson'; -import { FlowRouter } from 'meteor/ostrio:flow-router-extra'; - -FlowRouter.setQueryParams({ data: encodeURIComponent(EJSON.stringify(data)) }); -``` - -You can then get the data back out of Flow Router using `EJSON.parse()`. Note that Flow Router does the URL decoding for you automatically: - -```js -const data = EJSON.parse(FlowRouter.getQueryParam('data')); -``` - -## Redirecting - -Sometimes, your users will end up on a page that isn't a good place for them to be. Maybe the data they were looking for has moved, maybe they were on an admin panel page and logged out, or maybe they just created a new object and you want them to end up on the page for the thing they just created. - -Usually, we can redirect in response to a user's action by calling `FlowRouter.go()` and friends, like in our list creation example above, but if a user browses directly to a URL that doesn't exist, it's useful to know how to redirect immediately. - -If a URL is out-of-date (sometimes you might change the URL scheme of an application), you can redirect inside the `action` function of the route: - -```js -FlowRouter.route('/old-list-route/:_id', { - action(params) { - FlowRouter.go('Lists.show', params); - } -}); -``` - -### Redirecting dynamically - -The above approach will only work for static redirects. However, sometimes you need to load some data to figure out where to redirect to. In this case you'll need to render part of the component hierarchy to subscribe to the data you need. For example, in the Todos example app, we want to make the root (`/`) route redirect to the first known list: - -```js -FlowRouter.route('/', { - name: 'App.home', - action() { - BlazeLayout.render('App_body', { main: 'App_rootRedirector' }); - } -}); -``` - -The `App_rootRedirector` component is rendered inside the `App_body` layout: - -```js -Template.App_rootRedirector.onCreated(function rootRedirectorOnCreated() { - this.autorun(async () => { - if (this.subscriptionsReady()) { - const list = await Lists.findOneAsync(); - if (list) { - FlowRouter.go('Lists.show', { _id: list._id }); - } - } - }); -}); -``` - -### Redirecting after a user's action - -Often, you just want to go to a new route programmatically when a user has completed a certain action. If you want to wait for the method to return from the server, you can use async/await: - -```js -Template.App_body.events({ - async 'click .js-new-list'() { - try { - const listId = await lists.insert.callAsync(); - FlowRouter.go('Lists.show', { _id: listId }); - } catch (err) { - // Handle error - show message to user - console.error('Failed to create list:', err); - } - } -}); -``` - -You will also want to show some kind of indication that the method is working in between their click of the button and the redirect completing. Don't forget to provide feedback if the method is returning an error. - -## Advanced Routing - -### Dynamically load modules - -[Dynamic imports](https://docs.meteor.com/packages/dynamic-import) allow you to dramatically reduce the client's bundle size, and load modules and dependencies dynamically upon request, based on the current URI. - -Assume we have `index.html` and `index.js` with code for `index` template and this is the only place in the application where it depends on the large `moment` package. This means the `moment` package is not needed in the other parts of our app, and it will only waste bandwidth and slow load time. - -```html - - -``` - -```js -// /imports/client/index.js -import moment from 'moment'; -import { Template } from 'meteor/templating'; -import './index.html'; - -Template.index.helpers({ - time() { - return moment().format('LTS'); - } -}); -``` - -```js -// /imports/lib/routes.js -import { FlowRouter } from 'meteor/ostrio:flow-router-extra'; -import { BlazeLayout } from 'meteor/kadira:blaze-layout'; - -FlowRouter.route('/', { - name: 'index', - waitOn() { - // Wait for index.js to load over the wire - return import('/imports/client/index.js'); - }, - action() { - BlazeLayout.render('App_body', { main: 'index' }); - } -}); -``` - -### Missing pages (404) - -If a user types an incorrect URL, chances are you want to show them some kind of amusing not-found page. There are actually two categories of not-found pages. The first is when the URL typed in doesn't match any of your route definitions. You can use `FlowRouter.notFound` to handle this: - -```js -FlowRouter.notFound = { - action() { - BlazeLayout.render('App_body', { main: 'App_notFound' }); - } -}; -``` - -The second is when the URL is valid, but doesn't actually match any data. In this case, the URL matches a route, but once the route has successfully subscribed, it discovers there is no data. It usually makes sense in this case for the page component to render a not-found template instead of the usual template for the page: - -```html - -``` - -### Analytics - -It's common to want to know which pages of your app are most commonly visited, and where users are coming from. You can read about how to set up Flow Router based analytics in the [Deployment Guide](/tutorials/deployment/deployment#analytics). - -### Server Side Routing - -As we've discussed, Meteor is a framework for client rendered applications, but this doesn't always remove the requirement for server rendered routes. There are three main use cases for server-side routing. - -#### Server Routing for API access - -Although Meteor allows you to write low-level connect handlers to create any kind of API you like on the server-side, if all you want to do is create a RESTful version of your Methods and Publications, you can often use the [`simple:rest`](http://atmospherejs.com/simple/rest) package. - -If you need more control, you can use the comprehensive [`nimble:restivus`](https://atmospherejs.com/nimble/restivus) package to create more or less whatever you need in whatever ontology you require. - -#### Server Rendering - -While Blaze does not have support for server-side rendering, React does. This means it is possible to render HTML on the server if you use React as your rendering framework. - -For server-side rendering with React, consider using packages like [`server-render`](/packages/server-render) which provides utilities for SSR in Meteor. - -#### Server Routing for additional resources - -There might be additional resources that you want to make available on your server or receive web hooks. If you need anything more complicated with dynamic parts of the URL you might want to implement [Picker](https://atmospherejs.com/communitypackages/picker) which is a simple server-side router that handles dynamic routes. - -If you need to authenticate the user when providing additional server-side resources such as PDF documents or XLSX spreadsheets, you can use [`mhagmajer:server-router`](https://atmospherejs.com/mhagmajer/server-router) package to do this easily. From 9e79c9fa9d7d9ee44194fe6eeb82fd86a2a4bdd7 Mon Sep 17 00:00:00 2001 From: Frederico Maia Date: Mon, 30 Mar 2026 21:01:26 -0300 Subject: [PATCH 29/44] Update README.md to reflect Meteor version 3.4.0 and enhance tutorial links for Blaze, Svelte, and Solid. --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e37bdc7cae..6cdbf5a9c4 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ [![Travis CI Status](https://api.travis-ci.com/meteor/meteor.svg?branch=devel)](https://app.travis-ci.com/github/meteor/meteor) [![CircleCI Status](https://circleci.com/gh/meteor/meteor.svg?style=svg)](https://app.circleci.com/pipelines/github/meteor/meteor?branch=devel) -[![built with Meteor](https://img.shields.io/badge/Meteor-3.2.2-green?logo=meteor&logoColor=white)](https://meteor.com) +[![built with Meteor](https://img.shields.io/badge/Meteor-3.4.0-green?logo=meteor&logoColor=white)](https://meteor.com) ![node-current](https://img.shields.io/node/v/meteor) ![Discord](https://img.shields.io/discord/1247973371040239676) ![Twitter Follow](https://img.shields.io/twitter/follow/meteorjs?style=social) @@ -55,8 +55,10 @@ How about trying a tutorial to get started with your favorite technology? | [ React](https://docs.meteor.com/tutorials/react/) | | - | -| [ Blaze](https://blaze-tutorial.meteor.com/) | +| [ Blaze](https://docs.meteor.com/tutorials/blaze/) | | [ Vue](https://docs.meteor.com/tutorials/vue/meteorjs3-vue3.html) | +| [ Svelte](https://docs.meteor.com/tutorials/svelte/) | +| [ Solid](https://docs.meteor.com/tutorials/solid/) | # πŸš€ Quick Start @@ -84,6 +86,7 @@ meteor **Building an application with Meteor?** * Deploy on [Galaxy](https://galaxycloud.app) +* Find packages on [Atmosphere](https://atmospherejs.com/) * Discuss on [Forums](https://forums.meteor.com/) * Join the [Meteor Discord](https://discord.gg/hZkTCaVjmT) From e02d65a09a6505170fdaf79afac62117fdc4fb53 Mon Sep 17 00:00:00 2001 From: Gabriel Grubba Date: Mon, 30 Mar 2026 21:09:35 -0300 Subject: [PATCH 30/44] DOCS: revamp and update accounts tutorial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In this PR: Removed - `accounts-ui`section - `alanning:roles` references β€” replaced with the Meteor core roles package Updated methods to be using the correct name(*Async for example) And added sections about Passwordless login, 2FA and Security configuration --- v3-docs/docs/tutorials/accounts/accounts.md | 565 +++++++++++++++----- 1 file changed, 434 insertions(+), 131 deletions(-) diff --git a/v3-docs/docs/tutorials/accounts/accounts.md b/v3-docs/docs/tutorials/accounts/accounts.md index 8d5d3154c3..05d5329b07 100644 --- a/v3-docs/docs/tutorials/accounts/accounts.md +++ b/v3-docs/docs/tutorials/accounts/accounts.md @@ -3,11 +3,12 @@ After reading this article, you'll know: 1. What features in core Meteor enable user accounts -2. How to use accounts-ui for a quick prototype -3. How to build a fully-featured password login experience -4. How to enable login through OAuth providers like Facebook -5. How to add custom data to Meteor's users collection -6. How to manage user roles and permissions +2. How to build a fully-featured password login experience +3. How to set up passwordless login +4. How to add two-factor authentication (2FA) +5. How to enable login through OAuth providers like Facebook +6. How to add custom data to Meteor's users collection +7. How to protect your data with per-document permissions ## Features in core Meteor @@ -23,41 +24,13 @@ This built-in feature means that you always get `this.userId` inside Methods and This package is the core of Meteor's developer-facing user accounts functionality. This includes: -1. A users collection with a standard schema, accessed through [`Meteor.users`](/api/accounts#Meteor-users), and the client-side singletons [`Meteor.userId()`](/api/accounts#Meteor-userId) and [`Meteor.user()`](/api/accounts#Meteor-user), which represent the login state on the client. -2. A variety of helpful other generic methods to keep track of login state, log out, validate users, etc. Visit the [Accounts section of the docs](/api/accounts) to find a complete list. -3. An API for registering new login handlers, which is used by all of the other accounts packages to integrate with the accounts system. +1. A users collection with a standard schema, accessed through [`Meteor.users`](/api/accounts#Meteor-users), and the client-side singletons [`Meteor.userId()`](/api/accounts#Meteor-userId), [`Meteor.user()`](/api/accounts#Meteor-user), and the async [`Meteor.userAsync()`](/api/accounts#Meteor-userAsync), which represent the login state on the client. +2. Reactive helpers [`Accounts.loggingIn()`](/api/accounts#Accounts-loggingIn) and [`Accounts.loggingOut()`](/api/accounts#Accounts-loggingOut) to track in-progress login/logout state. +3. A variety of helpful other generic methods to keep track of login state, log out, validate users, etc. Visit the [Accounts section of the docs](/api/accounts) to find a complete list. +4. An API for registering new login handlers, which is used by all of the other accounts packages to integrate with the accounts system. Usually, you don't need to include `accounts-base` yourself since it's added for you if you use `accounts-password` or similar, but it's good to be aware of what is what. -## Fast prototyping with accounts-ui - -Often, a complicated accounts system is not the first thing you want to build when you're starting out with a new app, so it's useful to have something you can drop in quickly. This is where `accounts-ui` comes in - it's one line that you drop into your app to get an accounts system. To add it: - -```bash -meteor add accounts-ui -``` - -Then include it anywhere in a Blaze template: - -```html -{{> loginButtons}} -``` - -Then, make sure to pick a login provider; they will automatically integrate with `accounts-ui`: - -```bash -# pick one or more of the below -meteor add accounts-password -meteor add accounts-facebook -meteor add accounts-google -meteor add accounts-github -meteor add accounts-twitter -meteor add accounts-meetup -meteor add accounts-meteor-developer -``` - -Now open your app, follow the configuration steps, and you're good to go - if you've done one of our [Meteor tutorials](/tutorials/react/1.creating-the-app), you've already seen this in action. Of course, in a production application, you probably want a more custom user interface and some logic to have a more tailored UX, but that's why we have the rest of these tutorials. - ## Password login Meteor comes with a secure and fully-featured password login system out of the box. To use it, add the package: @@ -66,11 +39,9 @@ Meteor comes with a secure and fully-featured password login system out of the b meteor add accounts-password ``` -To see what options are available to you, read the complete description of the [`accounts-password` API in the Meteor docs](/api/accounts). - ### Requiring username or email -By default, the `Accounts.createUser` function provided by `accounts-password` allows you to create an account with a username, email, or both. Most apps expect a specific combination of the two, so you will certainly want to validate the new user creation: +By default, the `Accounts.createUserAsync` function provided by `accounts-password` allows you to create an account with a username, email, or both. Most apps expect a specific combination of the two, so you will certainly want to validate the new user creation: ```js // Ensuring every user has an email address, should be in server-side code @@ -78,11 +49,11 @@ Accounts.validateNewUser((user) => { new SimpleSchema({ _id: { type: String }, emails: { type: Array }, - 'emails.$': { type: Object }, - 'emails.$.address': { type: String }, - 'emails.$.verified': { type: Boolean }, + "emails.$": { type: Object }, + "emails.$.address": { type: String }, + "emails.$.verified": { type: Boolean }, createdAt: { type: Date }, - services: { type: Object, blackbox: true } + services: { type: Object, blackbox: true }, }).validate(user); // Return true to allow user creation to proceed @@ -90,11 +61,49 @@ Accounts.validateNewUser((user) => { }); ``` -### Multiple emails +> When creating users programmatically, prefer the async variant: -Often, users might want to associate multiple email addresses with the same account. `accounts-password` addresses this case by storing the email addresses as an array in the user collection. There are some handy API methods to deal with [adding](/api/accounts#Accounts-addEmail), [removing](/api/accounts#Accounts-removeEmail), and [verifying](/api/accounts#Accounts-verifyEmail) emails. +```js +// Client or server +const userId = await Accounts.createUserAsync({ + username: "ada", + email: "ada@lovelace.com", + password: "secret", + profile: { name: "Ada Lovelace" }, +}); +``` -One useful thing to add for your app can be the concept of a "primary" email address. This way, if the user has added multiple emails, you know where to send confirmation emails and similar. +If you want to automatically send an email verification after account creation, use `Accounts.createUserVerifyingEmail` instead: + +```js +await Accounts.createUserVerifyingEmail({ + email: "ada@lovelace.com", + password: "secret", +}); +``` + +### Managing multiple email addresses + +Users can associate more than one email address with their account. Meteor stores them as an array in the user document, so you can add, remove, and verify each one independently. + +```js +// Add a new address for the user (server) +await Accounts.addEmailAsync(userId, "work@example.com"); + +// Remove an address (server) +Accounts.removeEmail(userId, "old@example.com"); + +// Send a verification email to a specific address (server) +Accounts.sendVerificationEmail(userId, "work@example.com"); +``` + +A common pattern is to record a "primary" email address β€” the one used for notifications and password resets β€” as a top-level field on the user document: + +```js +await Meteor.users.updateAsync(userId, { + $set: { primaryEmail: "work@example.com" }, +}); +``` ### Case sensitivity @@ -104,6 +113,80 @@ Meteor handles case sensitivity for email addresses and usernames. Since MongoDB Follow one rule: don't query the database by `username` or `email` directly. Instead, use the [`Accounts.findUserByUsername`](/api/accounts#Accounts-findUserByUsername) and [`Accounts.findUserByEmail`](/api/accounts#Accounts-findUserByEmail) methods provided by Meteor. This will run a query for you that is case-insensitive, so you will always find the user you are looking for. +### Security configuration + +`Accounts.config()` exposes several options that harden your login system. Call it once from server-side startup code. + +**Prevent user enumeration.** When enabled (the default in Meteor 3), "user not found" and "incorrect password" return the same error message to the caller, making it impossible for an attacker to discover which email addresses are registered: + +```js +Accounts.config({ ambiguousErrorMessages: true }); // default: true +``` + +**Block client-side account creation.** Ensure new accounts can only be created server-side (e.g. through a trusted Meteor Method), preventing unvetted signups from the browser console: + +```js +Accounts.config({ forbidClientAccountCreation: true }); +``` + +**Restrict signups by email domain.** Accept a string, an array of strings, or a function: + +```js +// single domain +Accounts.config({ restrictCreationByEmailDomain: "mycompany.com" }); + +// multiple domains +Accounts.config({ + restrictCreationByEmailDomain: ["mycompany.com", "contractor.io"], +}); + +// custom logic +Accounts.config({ + restrictCreationByEmailDomain: (email) => email.endsWith(".edu"), +}); +``` + +**Credential storage.** By default, login tokens are stored in `localStorage` and survive across browser sessions. Set `clientStorage` to `'session'` to clear credentials when the browser tab is closed: + +```js +Accounts.config({ clientStorage: "session" }); // 'local' (default) or 'session' +``` + +### Password hashing + +Meteor uses **bcrypt** to hash passwords by default. You can tune the work factor: + +```js +Accounts.config({ bcryptRounds: 12 }); // default: 10 +``` + +Meteor 3.x also supports **Argon2**, which is recommended by [OWASP](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) for new applications: + +```js +// server-side startup code +Accounts.config({ + argon2Enabled: true, + argon2Type: "argon2id", // 'argon2i' | 'argon2d' | 'argon2id' (default) + argon2TimeCost: 2, // iterations (default: 2) + argon2MemoryCost: 19456, // memory in KiB β€” 19 MB (default) + argon2Parallelism: 1, // threads (default: 1) +}); +``` + +Enabling Argon2 does not break existing users. Existing bcrypt hashes continue to work and are transparently re-hashed to Argon2 the next time each user logs in. + +### Token lifetime configuration + +You can control how long session and email tokens remain valid: + +```js +Accounts.config({ + loginExpirationInDays: 90, // session token lifetime (default: 90; set to null to never expire) + passwordResetTokenExpirationInDays: 3, // password reset link lifetime (default: 3 days) + passwordEnrollTokenExpirationInDays: 30, // account enrollment link lifetime (default: 30 days) +}); +``` + ### Email flows When you have a login system for your app based on user emails, that opens up the possibility for email-based account flows. The common thing between all of these workflows is that they involve sending a unique link to the user's email address, which does something special when it is clicked. Let's look at some common examples that Meteor's `accounts-password` package supports out of the box: @@ -116,38 +199,44 @@ When you have a login system for your app based on user emails, that opens up th `accounts-password` comes with handy functions that you can call from the server to send an email: -1. [`Accounts.sendResetPasswordEmail`](/api/accounts#Accounts-sendResetPasswordEmail) -2. [`Accounts.sendEnrollmentEmail`](/api/accounts#Accounts-sendEnrollmentEmail) -3. [`Accounts.sendVerificationEmail`](/api/accounts#Accounts-sendVerificationEmail) +1. [`Accounts.sendResetPasswordEmail(userId, email?, extraTokenData?, extraParams?)`](/api/accounts#Accounts-sendResetPasswordEmail) +2. [`Accounts.sendEnrollmentEmail(userId, email?, extraTokenData?, extraParams?)`](/api/accounts#Accounts-sendEnrollmentEmail) +3. [`Accounts.sendVerificationEmail(userId, email?, extraTokenData?, extraParams?)`](/api/accounts#Accounts-sendVerificationEmail) -The email is generated using the email templates from [`Accounts.emailTemplates`](/api/accounts#Accounts-emailTemplates), and include links generated with `Accounts.urls`. +The optional `extraTokenData` object is merged into the token stored in the database and is available inside email templates. The optional `extraParams` object is appended to the generated URL as query parameters. -#### Identifying when the link is clicked +If you need to generate a token without sending an email (for example, to build a custom mailer), use the lower-level helpers: -When the user receives the email and clicks the link inside, their web browser will take them to your app. Now, you need to be able to identify these special links and act appropriately. If you haven't customized the link URL, then you can use some built-in callbacks to identify when the app is in the middle of an email flow: +```js +// Generate a password reset token (server) +const { token } = Accounts.generateResetToken(userId, email, "resetPassword"); -1. [`Accounts.onResetPasswordLink`](/api/accounts#Accounts-onResetPasswordLink) -2. [`Accounts.onEnrollmentLink`](/api/accounts#Accounts-onEnrollmentLink) -3. [`Accounts.onEmailVerificationLink`](/api/accounts#Accounts-onEmailVerificationLink) +// Generate an email verification token (server) +const { token } = Accounts.generateVerificationToken(userId, email); +``` -Here's how you would use one of these functions: +The email is generated using the email templates from [`Accounts.emailTemplates`](/api/accounts#Accounts-emailTemplates), and includes links generated with `Accounts.urls`. + +#### Handling the link in your app + +When the user clicks the link in their email, their browser navigates to your app with the token embedded in the URL. Register a client-side callback to detect each flow and render the appropriate UI β€” there is one for each link type: `Accounts.onResetPasswordLink`, `Accounts.onEnrollmentLink`, and `Accounts.onEmailVerificationLink`. Here's how you would implement the password reset flow: ```js Accounts.onResetPasswordLink(async (token, done) => { // Display the password reset UI, get the new password... try { - await Accounts.resetPasswordAsync(token, newPassword); + await Accounts.resetPassword(token, newPassword); // Resume normal operation done(); } catch (err) { // Display error - console.error('Password reset failed:', err); + console.error("Password reset failed:", err); } }); ``` -If you want a different URL for your reset password page, you need to customize it using the `Accounts.urls` option: +If you want a different URL for your reset password page, you need to customize it using the `Accounts.urls` option. URL generators can also be `async` or return a `Promise`: ```js Accounts.urls.resetPassword = (token) => { @@ -159,10 +248,10 @@ If you have customized the URL, you will need to add a new route to your router #### Completing the process -When the user submits the form, you need to call the appropriate function to commit their change to the database: +When the user submits the form, you need to call the appropriate function to commit their change to the database. Both functions return a `Promise`: -1. [`Accounts.resetPasswordAsync`](/api/accounts#Accounts-resetPassword) - this one should be used both for resetting the password, and enrolling a new user; it accepts both kinds of tokens. -2. [`Accounts.verifyEmailAsync`](/api/accounts#Accounts-verifyEmail) +1. [`Accounts.resetPassword(token, newPassword)`](/api/accounts#Accounts-resetPassword) β€” use this both for resetting the password and enrolling a new user; it accepts both kinds of tokens. Logs in the user after a successful reset (unless 2FA is enabled β€” see [Two-Factor Authentication](#two-factor-authentication-accounts-2fa)). +2. [`Accounts.verifyEmail(token)`](/api/accounts#Accounts-verifyEmail) β€” logs in the user after a successful verification (unless 2FA is enabled). After you have called one of the two functions above or the user has cancelled the process, call the `done` function you got in the link callback. @@ -175,7 +264,7 @@ Accounts.emailTemplates.siteName = "Meteor Guide Todos Example"; Accounts.emailTemplates.from = "Meteor Todos Accounts "; Accounts.emailTemplates.resetPassword = { - subject(user) { + subject(user, url) { return "Reset your password on Meteor Todos"; }, text(user, url) { @@ -190,7 +279,7 @@ The Meteor Todos team html(user, url) { // This is where HTML email content would go. // See the section about html emails below. - } + }, }; ``` @@ -198,34 +287,192 @@ The Meteor Todos team If you've ever needed to deal with sending pretty HTML emails from an app, you know that it can quickly become a nightmare. Compatibility of popular email clients with basic HTML features like CSS is notoriously spotty. Start with a [responsive email template](https://github.com/leemunroe/responsive-html-email-template) or [framework](https://get.foundation/emails), and then use a tool to convert your email content into something that is compatible with all email clients. +## Passwordless login + +The `accounts-passwordless` package provides a one-time token (magic link) login experience β€” no password required. + +```bash +meteor add accounts-passwordless +``` + +### Requesting a login token + +On the client, call `Accounts.requestLoginTokenForUser` to send a one-time token to the user's email address: + +```js +// Client +await Accounts.requestLoginTokenForUser({ + selector: { email: "ada@lovelace.com" }, + // options.userCreationDisabled: true prevents creating a new account + // if no existing user matches the selector + options: {}, +}); +``` + +If no account exists for the given selector and `userCreationDisabled` is not set, you can pass `userData` to create the account on the fly: + +```js +await Accounts.requestLoginTokenForUser({ + selector: { email: "ada@lovelace.com" }, + userData: { email: "ada@lovelace.com", profile: { name: "Ada Lovelace" } }, +}); +``` + +### Logging in with the token + +When the user clicks the link in their email (or copies the token), call: + +```js +// Client +await Meteor.passwordlessLoginWithToken({ email: "ada@lovelace.com" }, token); +``` + +If the user has [Two-Factor Authentication](#two-factor-authentication-accounts-2fa) enabled, use the 2FA variant instead: + +```js +await Meteor.passwordlessLoginWithTokenAnd2faCode( + { email: "ada@lovelace.com" }, + token, + totpCode +); +``` + +### Automatic URL-based login + +Add `Accounts.autoLoginWithToken()` to your client startup code to detect when the URL contains a login token (e.g. from an email link) and log the user in automatically: + +```js +// client-side startup +Accounts.autoLoginWithToken(); +``` + +### Customizing the email + +Customize the token email through `Accounts.emailTemplates.sendLoginToken`: + +```js +Accounts.emailTemplates.sendLoginToken = { + subject(user) { + return "Your login link"; + }, + text(user, url) { + return `Click the link below to log in:\n\n${url}\n\nThis link expires in 15 minutes.`; + }, +}; +``` + +## Two-Factor Authentication + +The `accounts-2fa` package adds Time-based One-Time Password (TOTP) two-factor authentication, compatible with any standard authenticator app (Google Authenticator, Authy, etc.). + +```bash +meteor add accounts-2fa +``` + +### Enabling 2FA for a user + +The setup flow happens on the client: + +```js +// Step 1: generate a QR code and display it to the user +const { svg, secret, uri } = await new Promise((resolve, reject) => + Accounts.generate2faActivationQrCode("My App", (err, result) => { + if (err) reject(err); + else resolve(result); + }) +); +// Render `svg` in your UI so the user can scan it with their authenticator app + +// Step 2: once the user has scanned the QR code and sees the first code, confirm it +await new Promise((resolve, reject) => + Accounts.enableUser2fa(totpCode, (err) => { + if (err) reject(err); + else resolve(); + }) +); +``` + +### Disabling 2FA and checking status + +```js +// Check if the current user has 2FA enabled +const enabled = await new Promise((resolve, reject) => + Accounts.has2faEnabled((err, result) => { + if (err) reject(err); + else resolve(result); + }) +); + +// Disable 2FA for the current user +await new Promise((resolve, reject) => + Accounts.disableUser2fa((err) => { + if (err) reject(err); + else resolve(); + }) +); +``` + +### Logging in with 2FA + +When a user has 2FA enabled, the standard `Meteor.loginWithPassword` call will fail with an error prompting for a code. Use the dedicated method instead: + +```js +try { + await Meteor.loginWithPasswordAnd2faCode( + "ada@lovelace.com", + "mypassword", + totpCode + ); +} catch (err) { + console.error("Login failed:", err); +} +``` + +### Effect on password reset and email verification + +When 2FA is enabled, completing a password reset (`Accounts.resetPassword`) or email verification (`Accounts.verifyEmail`) will **not** automatically log the user in. The user must perform a full login (including the 2FA step) manually afterward. + +### Configuration + +```js +Accounts.config({ + loginTokenExpirationHours: 1, // how long a TOTP window stays valid (default: 1 hour) + tokenSequenceLength: 6, // TOTP code length (default: 6) +}); +``` + ## OAuth login Meteor supports popular login providers through OAuth out of the box. -### Facebook, Google, and more +### Adding an OAuth provider -Here's a complete list of login providers for which Meteor actively maintains core packages: +Meteor maintains packages for popular login providers. Add one or more to your app: -1. Facebook with `accounts-facebook` -2. Google with `accounts-google` -3. GitHub with `accounts-github` -4. Twitter with `accounts-twitter` -5. Meetup with `accounts-meetup` -6. Meteor Developer Accounts with `accounts-meteor-developer` +```bash +meteor add accounts-facebook # Facebook +meteor add accounts-google # Google +meteor add accounts-github # GitHub +meteor add accounts-twitter # Twitter +meteor add accounts-meetup # Meetup +meteor add accounts-meteor-developer # Meteor Developer Accounts +``` -### Logging in +Each package adds a `Meteor.loginWith` function and registers the service in the OAuth configuration UI. -If you are using an off-the-shelf login UI like `accounts-ui`, you don't need to write any code after adding the relevant package. If you are building a login experience from scratch, you can log in programmatically using the [`Meteor.loginWith`](/api/accounts#Meteor-loginWithExternalService) function: +### Logging in programmatically + +You can log in with any configured OAuth provider using the `Meteor.loginWith` function: ```js try { - await Meteor.loginWithFacebookAsync({ - requestPermissions: ['user_friends', 'public_profile', 'email'] + await Meteor.loginWithFacebook({ + requestPermissions: ["user_friends", "public_profile", "email"], }); // successful login! } catch (err) { // handle error - console.error('Login failed:', err); + console.error("Login failed:", err); } ``` @@ -234,9 +481,43 @@ try { There are a few points to know about configuring OAuth login: 1. **Client ID and secret.** It's best to keep your OAuth secret keys outside of your source code, and pass them in through Meteor.settings. Read how in the [Security article](/tutorials/security/security#api-keys). -2. **Redirect URL.** On the OAuth provider's side, you'll need to specify a *redirect URL*. The URL will look like: `https://www.example.com/_oauth/facebook`. Replace `facebook` with the name of the service you are using. Note that you will need to configure two URLs - one for your production app, and one for your development environment, where the URL might be something like `http://localhost:3000/_oauth/facebook`. +2. **Redirect URL.** On the OAuth provider's side, you'll need to specify a _redirect URL_. The URL will look like: `https://www.example.com/_oauth/facebook`. Replace `facebook` with the name of the service you are using. Note that you will need to configure two URLs - one for your production app, and one for your development environment, where the URL might be something like `http://localhost:3000/_oauth/facebook`. 3. **Permissions.** Each login service provider should have documentation about which permissions are available. If you want additional permissions to the user's data when they log in, pass some of these strings in the `requestPermissions` option. +### Server-side hooks for OAuth + +You can customize how OAuth accounts are created and updated on the server using these hooks. Each can only be registered once: + +```js +// Called before processing an external login. Return false to block the login. +Accounts.beforeExternalLogin((serviceName, serviceData, user) => { + // e.g. only allow logins from a specific GitHub org + if (serviceName === "github" && !serviceData.orgs?.includes("my-org")) { + return false; + } + return true; +}); + +// Provide additional lookup logic to find an existing user for an external login. +// Useful for linking accounts when the external service email matches an existing user. +Accounts.setAdditionalFindUserOnExternalLogin( + ({ serviceName, serviceData }) => { + if (serviceData.email) { + return Accounts.findUserByEmail(serviceData.email); + } + } +); + +// Called on every external login to update the user document. +// Return a modified user object to apply changes. +Accounts.onExternalLogin((options, user) => { + // Merge the latest profile data from the OAuth provider + user.profile = user.profile || {}; + user.profile.name = options.serviceData.name; + return user; +}); +``` + ### Calling service API for more data If your app supports or even requires login with an external service such as Facebook, it's natural to also want to use that service's API to request additional data about that user. @@ -273,39 +554,44 @@ On the server, each connection has a different logged in user, so there is no gl ```js // Accessing this.userId inside a publication -Meteor.publish('lists.private', function() { +Meteor.publish("lists.private", function () { if (!this.userId) { return this.ready(); } - return Lists.find({ - userId: this.userId - }, { - fields: Lists.publicFields - }); + return Lists.find( + { + userId: this.userId, + }, + { + fields: Lists.publicFields, + } + ); }); ``` ```js // Accessing this.userId inside a Method Meteor.methods({ - async 'todos.updateText'({ todoId, newText }) { + async "todos.updateText"({ todoId, newText }) { new SimpleSchema({ todoId: { type: String }, - newText: { type: String } + newText: { type: String }, }).validate({ todoId, newText }); const todo = await Todos.findOneAsync(todoId); if (!todo.editableBy(this.userId)) { - throw new Meteor.Error('todos.updateText.unauthorized', - 'Cannot edit todos in a private list that is not yours'); + throw new Meteor.Error( + "todos.updateText.unauthorized", + "Cannot edit todos in a private list that is not yours" + ); } await Todos.updateAsync(todoId, { - $set: { text: newText } + $set: { text: newText }, }); - } + }, }); ``` @@ -381,17 +667,17 @@ The best way to store your custom data onto the `Meteor.users` collection is to ```js // Using address schema from schema.org const newMailingAddress = { - addressCountry: 'US', - addressLocality: 'Seattle', - addressRegion: 'WA', - postalCode: '98052', - streetAddress: "20341 Whitworth Institute 405 N. Whitworth" + addressCountry: "US", + addressLocality: "Seattle", + addressRegion: "WA", + postalCode: "98052", + streetAddress: "20341 Whitworth Institute 405 N. Whitworth", }; await Meteor.users.updateAsync(userId, { $set: { - mailingAddress: newMailingAddress - } + mailingAddress: newMailingAddress, + }, }); ``` @@ -405,7 +691,7 @@ Sometimes, you want to set a field when the user first creates their account. Yo // Generate user initials after Facebook login Accounts.onCreateUser((options, user) => { if (!user.services.facebook) { - throw new Error('Expected login with Facebook only.'); + throw new Error("Expected login with Facebook only."); } const { first_name, last_name } = user.services.facebook; @@ -424,7 +710,7 @@ Accounts.onCreateUser((options, user) => { Note that the `user` object provided doesn't have an `_id` field yet. If you need to do something with the new user's ID inside this function, you can generate the ID yourself: ```js -import { Random } from 'meteor/random'; +import { Random } from "meteor/random"; // Generate a todo list for each new user Accounts.onCreateUser(async (options, user) => { @@ -450,7 +736,9 @@ Rather than dealing with the specifics of this field, it can be helpful to ignor ```js // Deny all client-side updates to user documents Meteor.users.deny({ - update() { return true; } + update() { + return true; + }, }); ``` @@ -459,21 +747,21 @@ Meteor.users.deny({ If you want to access the custom data you've added to the `Meteor.users` collection in your UI, you'll need to publish it to the client. The most important thing to keep in mind is that user documents contain private data about your usersβ€”hashed passwords and access keys for external APIs. This means it's critically important to filter the fields of the user document that you send to any client. ```js -Meteor.publish('Meteor.users.initials', function ({ userIds }) { +Meteor.publish("Meteor.users.initials", function ({ userIds }) { // Validate the arguments to be what we expect new SimpleSchema({ userIds: { type: Array }, - 'userIds.$': { type: String } + "userIds.$": { type: String }, }).validate({ userIds }); // Select only the users that match the array of IDs passed in const selector = { - _id: { $in: userIds } + _id: { $in: userIds }, }; // Only return one field, `initials` const options = { - fields: { initials: 1 } + fields: { initials: 1 }, }; return Meteor.users.find(selector, options); @@ -492,10 +780,14 @@ const user = await Meteor.userAsync({ fields: { "profile.name": 1 } }); const name = user?.profile?.name; // check if an email exists without fetching their entire document: -const userExists = !!await Accounts.findUserByEmail(email, { fields: { _id: 1 } }); +const userExists = !!(await Accounts.findUserByEmail(email, { + fields: { _id: 1 }, +})); // get the user id from a userName: -const user = await Accounts.findUserByUsername(userName, { fields: { _id: 1 } }); +const user = await Accounts.findUserByUsername(userName, { + fields: { _id: 1 }, +}); const userId = user?._id; ``` @@ -509,7 +801,7 @@ Accounts.config({ createdAt: 1, profile: 1, services: 1, - } + }, }); ``` @@ -521,21 +813,27 @@ Accounts.config({ defaultFieldSelector: { myBigArray: 0 } }); ## Roles and permissions -One of the main reasons you might want to add a login system to your app is to have permissions for your data. For example, if you were running a forum, you would want administrators or moderators to be able to delete any post, but normal users can only delete their own. This uncovers two different types of permissions: +Once users are logged in, you'll often want to control what each user can do. This uncovers two different types of permissions: 1. Role-based permissions 2. Per-document permissions -### alanning:roles +### roles -The most popular package for role-based permissions in Meteor is [`alanning:roles`](https://atmospherejs.com/alanning/roles). For example, here is how you would make a user into an administrator, or a moderator: +Meteor ships a core [`roles`](/packages/roles) package for role-based permissions. Add it to your app: + +```bash +meteor add roles +``` + +Here is how you would make a user into an administrator, or a moderator: ```js // Give Alice the 'admin' role -await Roles.addUsersToRolesAsync(aliceUserId, 'admin', Roles.GLOBAL_GROUP); +await Roles.addUsersToRolesAsync(aliceUserId, "admin", Roles.GLOBAL_GROUP); // Give Bob the 'moderator' role for a particular category -await Roles.addUsersToRolesAsync(bobsUserId, 'moderator', categoryId); +await Roles.addUsersToRolesAsync(bobsUserId, "moderator", categoryId); ``` Now, let's say you wanted to check if someone was allowed to delete a particular forum post: @@ -545,21 +843,21 @@ const forumPost = await Posts.findOneAsync(postId); const canDelete = await Roles.userIsInRoleAsync( userId, - ['admin', 'moderator'], + ["admin", "moderator"], forumPost.categoryId ); if (!canDelete) { - throw new Meteor.Error('unauthorized', - 'Only admins and moderators can delete posts.'); + throw new Meteor.Error( + "unauthorized", + "Only admins and moderators can delete posts." + ); } await Posts.removeAsync(postId); ``` -Note that we can check for multiple roles at once, and if someone has a role in the `GLOBAL_GROUP`, they are considered as having that role in every group. - -Read more in the [`alanning:roles` package documentation](https://atmospherejs.com/alanning/roles). +Note that you can check for multiple roles at once, and if someone has a role in `GLOBAL_GROUP`, they are considered as having that role in every group. ### Per-document permissions @@ -572,7 +870,7 @@ Lists.helpers({ return false; } return this.userId === userId; - } + }, }); ``` @@ -582,8 +880,10 @@ Now, we can call this simple function to determine if a particular user is allow const list = await Lists.findOneAsync(listId); if (!list.editableBy(userId)) { - throw new Meteor.Error('unauthorized', - 'Only list owners can edit private lists.'); + throw new Meteor.Error( + "unauthorized", + "Only list owners can edit private lists." + ); } ``` @@ -592,12 +892,15 @@ Learn more about how to use collection helpers in the [Collections article](/tut ## Best practices summary 1. **Use accounts-password** for email/password login and add OAuth packages as needed. -2. **Validate new users** with `Accounts.validateNewUser` to ensure required fields. -3. **Use case-insensitive queries** with `Accounts.findUserByEmail` and `Accounts.findUserByUsername`. -4. **Customize email templates** using `Accounts.emailTemplates` for professional communications. -5. **Never use the profile field** for sensitive dataβ€”deny client-side writes to user documents. -6. **Add custom data to top-level fields** on user documents, not nested in profile. -7. **Always filter fields** when publishing user data to clients. -8. **Use alanning:roles** for role-based access control. -9. **Use collection helpers** for per-document permissions. -10. **Configure defaultFieldSelector** to optimize user document fetching. +2. **Validate new users** with `Accounts.validateNewUser` to ensure required fields are present. +3. **Use `Accounts.createUserAsync`** (or `Accounts.createUserVerifyingEmail`) instead of the callback-based `createUser`. +4. **Use case-insensitive queries** with `Accounts.findUserByEmail` and `Accounts.findUserByUsername` β€” never query `Meteor.users` directly by email or username. +5. **Enable `ambiguousErrorMessages: true`** (the default) to prevent user enumeration attacks. +6. **Customize email templates** using `Accounts.emailTemplates` for professional-looking communications. +7. **Never use the profile field** for sensitive data β€” deny client-side writes with `Meteor.users.deny({ update() { return true; } })`. +8. **Add custom data to top-level fields** on user documents, not nested inside `profile`. +9. **Always filter fields** when publishing user data to clients β€” never expose password hashes or access tokens. +10. **Consider Argon2** for new applications by enabling `argon2Enabled: true` in `Accounts.config()`. +11. **Add 2FA** with `accounts-2fa` for applications with elevated security requirements. +12. **Use `accounts-passwordless`** to offer a frictionless, password-free login experience. +13. **Configure `defaultFieldSelector`** to avoid loading large user documents on every login. From 7981d7f26486eb8c233578a6c861cfea2f9b119c Mon Sep 17 00:00:00 2001 From: Gabriel Grubba Date: Mon, 30 Mar 2026 21:10:10 -0300 Subject: [PATCH 31/44] Revert "DOCS: revamp and update accounts tutorial" This reverts commit e02d65a09a6505170fdaf79afac62117fdc4fb53. --- v3-docs/docs/tutorials/accounts/accounts.md | 565 +++++--------------- 1 file changed, 131 insertions(+), 434 deletions(-) diff --git a/v3-docs/docs/tutorials/accounts/accounts.md b/v3-docs/docs/tutorials/accounts/accounts.md index 05d5329b07..8d5d3154c3 100644 --- a/v3-docs/docs/tutorials/accounts/accounts.md +++ b/v3-docs/docs/tutorials/accounts/accounts.md @@ -3,12 +3,11 @@ After reading this article, you'll know: 1. What features in core Meteor enable user accounts -2. How to build a fully-featured password login experience -3. How to set up passwordless login -4. How to add two-factor authentication (2FA) -5. How to enable login through OAuth providers like Facebook -6. How to add custom data to Meteor's users collection -7. How to protect your data with per-document permissions +2. How to use accounts-ui for a quick prototype +3. How to build a fully-featured password login experience +4. How to enable login through OAuth providers like Facebook +5. How to add custom data to Meteor's users collection +6. How to manage user roles and permissions ## Features in core Meteor @@ -24,13 +23,41 @@ This built-in feature means that you always get `this.userId` inside Methods and This package is the core of Meteor's developer-facing user accounts functionality. This includes: -1. A users collection with a standard schema, accessed through [`Meteor.users`](/api/accounts#Meteor-users), and the client-side singletons [`Meteor.userId()`](/api/accounts#Meteor-userId), [`Meteor.user()`](/api/accounts#Meteor-user), and the async [`Meteor.userAsync()`](/api/accounts#Meteor-userAsync), which represent the login state on the client. -2. Reactive helpers [`Accounts.loggingIn()`](/api/accounts#Accounts-loggingIn) and [`Accounts.loggingOut()`](/api/accounts#Accounts-loggingOut) to track in-progress login/logout state. -3. A variety of helpful other generic methods to keep track of login state, log out, validate users, etc. Visit the [Accounts section of the docs](/api/accounts) to find a complete list. -4. An API for registering new login handlers, which is used by all of the other accounts packages to integrate with the accounts system. +1. A users collection with a standard schema, accessed through [`Meteor.users`](/api/accounts#Meteor-users), and the client-side singletons [`Meteor.userId()`](/api/accounts#Meteor-userId) and [`Meteor.user()`](/api/accounts#Meteor-user), which represent the login state on the client. +2. A variety of helpful other generic methods to keep track of login state, log out, validate users, etc. Visit the [Accounts section of the docs](/api/accounts) to find a complete list. +3. An API for registering new login handlers, which is used by all of the other accounts packages to integrate with the accounts system. Usually, you don't need to include `accounts-base` yourself since it's added for you if you use `accounts-password` or similar, but it's good to be aware of what is what. +## Fast prototyping with accounts-ui + +Often, a complicated accounts system is not the first thing you want to build when you're starting out with a new app, so it's useful to have something you can drop in quickly. This is where `accounts-ui` comes in - it's one line that you drop into your app to get an accounts system. To add it: + +```bash +meteor add accounts-ui +``` + +Then include it anywhere in a Blaze template: + +```html +{{> loginButtons}} +``` + +Then, make sure to pick a login provider; they will automatically integrate with `accounts-ui`: + +```bash +# pick one or more of the below +meteor add accounts-password +meteor add accounts-facebook +meteor add accounts-google +meteor add accounts-github +meteor add accounts-twitter +meteor add accounts-meetup +meteor add accounts-meteor-developer +``` + +Now open your app, follow the configuration steps, and you're good to go - if you've done one of our [Meteor tutorials](/tutorials/react/1.creating-the-app), you've already seen this in action. Of course, in a production application, you probably want a more custom user interface and some logic to have a more tailored UX, but that's why we have the rest of these tutorials. + ## Password login Meteor comes with a secure and fully-featured password login system out of the box. To use it, add the package: @@ -39,9 +66,11 @@ Meteor comes with a secure and fully-featured password login system out of the b meteor add accounts-password ``` +To see what options are available to you, read the complete description of the [`accounts-password` API in the Meteor docs](/api/accounts). + ### Requiring username or email -By default, the `Accounts.createUserAsync` function provided by `accounts-password` allows you to create an account with a username, email, or both. Most apps expect a specific combination of the two, so you will certainly want to validate the new user creation: +By default, the `Accounts.createUser` function provided by `accounts-password` allows you to create an account with a username, email, or both. Most apps expect a specific combination of the two, so you will certainly want to validate the new user creation: ```js // Ensuring every user has an email address, should be in server-side code @@ -49,11 +78,11 @@ Accounts.validateNewUser((user) => { new SimpleSchema({ _id: { type: String }, emails: { type: Array }, - "emails.$": { type: Object }, - "emails.$.address": { type: String }, - "emails.$.verified": { type: Boolean }, + 'emails.$': { type: Object }, + 'emails.$.address': { type: String }, + 'emails.$.verified': { type: Boolean }, createdAt: { type: Date }, - services: { type: Object, blackbox: true }, + services: { type: Object, blackbox: true } }).validate(user); // Return true to allow user creation to proceed @@ -61,49 +90,11 @@ Accounts.validateNewUser((user) => { }); ``` -> When creating users programmatically, prefer the async variant: +### Multiple emails -```js -// Client or server -const userId = await Accounts.createUserAsync({ - username: "ada", - email: "ada@lovelace.com", - password: "secret", - profile: { name: "Ada Lovelace" }, -}); -``` +Often, users might want to associate multiple email addresses with the same account. `accounts-password` addresses this case by storing the email addresses as an array in the user collection. There are some handy API methods to deal with [adding](/api/accounts#Accounts-addEmail), [removing](/api/accounts#Accounts-removeEmail), and [verifying](/api/accounts#Accounts-verifyEmail) emails. -If you want to automatically send an email verification after account creation, use `Accounts.createUserVerifyingEmail` instead: - -```js -await Accounts.createUserVerifyingEmail({ - email: "ada@lovelace.com", - password: "secret", -}); -``` - -### Managing multiple email addresses - -Users can associate more than one email address with their account. Meteor stores them as an array in the user document, so you can add, remove, and verify each one independently. - -```js -// Add a new address for the user (server) -await Accounts.addEmailAsync(userId, "work@example.com"); - -// Remove an address (server) -Accounts.removeEmail(userId, "old@example.com"); - -// Send a verification email to a specific address (server) -Accounts.sendVerificationEmail(userId, "work@example.com"); -``` - -A common pattern is to record a "primary" email address β€” the one used for notifications and password resets β€” as a top-level field on the user document: - -```js -await Meteor.users.updateAsync(userId, { - $set: { primaryEmail: "work@example.com" }, -}); -``` +One useful thing to add for your app can be the concept of a "primary" email address. This way, if the user has added multiple emails, you know where to send confirmation emails and similar. ### Case sensitivity @@ -113,80 +104,6 @@ Meteor handles case sensitivity for email addresses and usernames. Since MongoDB Follow one rule: don't query the database by `username` or `email` directly. Instead, use the [`Accounts.findUserByUsername`](/api/accounts#Accounts-findUserByUsername) and [`Accounts.findUserByEmail`](/api/accounts#Accounts-findUserByEmail) methods provided by Meteor. This will run a query for you that is case-insensitive, so you will always find the user you are looking for. -### Security configuration - -`Accounts.config()` exposes several options that harden your login system. Call it once from server-side startup code. - -**Prevent user enumeration.** When enabled (the default in Meteor 3), "user not found" and "incorrect password" return the same error message to the caller, making it impossible for an attacker to discover which email addresses are registered: - -```js -Accounts.config({ ambiguousErrorMessages: true }); // default: true -``` - -**Block client-side account creation.** Ensure new accounts can only be created server-side (e.g. through a trusted Meteor Method), preventing unvetted signups from the browser console: - -```js -Accounts.config({ forbidClientAccountCreation: true }); -``` - -**Restrict signups by email domain.** Accept a string, an array of strings, or a function: - -```js -// single domain -Accounts.config({ restrictCreationByEmailDomain: "mycompany.com" }); - -// multiple domains -Accounts.config({ - restrictCreationByEmailDomain: ["mycompany.com", "contractor.io"], -}); - -// custom logic -Accounts.config({ - restrictCreationByEmailDomain: (email) => email.endsWith(".edu"), -}); -``` - -**Credential storage.** By default, login tokens are stored in `localStorage` and survive across browser sessions. Set `clientStorage` to `'session'` to clear credentials when the browser tab is closed: - -```js -Accounts.config({ clientStorage: "session" }); // 'local' (default) or 'session' -``` - -### Password hashing - -Meteor uses **bcrypt** to hash passwords by default. You can tune the work factor: - -```js -Accounts.config({ bcryptRounds: 12 }); // default: 10 -``` - -Meteor 3.x also supports **Argon2**, which is recommended by [OWASP](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) for new applications: - -```js -// server-side startup code -Accounts.config({ - argon2Enabled: true, - argon2Type: "argon2id", // 'argon2i' | 'argon2d' | 'argon2id' (default) - argon2TimeCost: 2, // iterations (default: 2) - argon2MemoryCost: 19456, // memory in KiB β€” 19 MB (default) - argon2Parallelism: 1, // threads (default: 1) -}); -``` - -Enabling Argon2 does not break existing users. Existing bcrypt hashes continue to work and are transparently re-hashed to Argon2 the next time each user logs in. - -### Token lifetime configuration - -You can control how long session and email tokens remain valid: - -```js -Accounts.config({ - loginExpirationInDays: 90, // session token lifetime (default: 90; set to null to never expire) - passwordResetTokenExpirationInDays: 3, // password reset link lifetime (default: 3 days) - passwordEnrollTokenExpirationInDays: 30, // account enrollment link lifetime (default: 30 days) -}); -``` - ### Email flows When you have a login system for your app based on user emails, that opens up the possibility for email-based account flows. The common thing between all of these workflows is that they involve sending a unique link to the user's email address, which does something special when it is clicked. Let's look at some common examples that Meteor's `accounts-password` package supports out of the box: @@ -199,44 +116,38 @@ When you have a login system for your app based on user emails, that opens up th `accounts-password` comes with handy functions that you can call from the server to send an email: -1. [`Accounts.sendResetPasswordEmail(userId, email?, extraTokenData?, extraParams?)`](/api/accounts#Accounts-sendResetPasswordEmail) -2. [`Accounts.sendEnrollmentEmail(userId, email?, extraTokenData?, extraParams?)`](/api/accounts#Accounts-sendEnrollmentEmail) -3. [`Accounts.sendVerificationEmail(userId, email?, extraTokenData?, extraParams?)`](/api/accounts#Accounts-sendVerificationEmail) +1. [`Accounts.sendResetPasswordEmail`](/api/accounts#Accounts-sendResetPasswordEmail) +2. [`Accounts.sendEnrollmentEmail`](/api/accounts#Accounts-sendEnrollmentEmail) +3. [`Accounts.sendVerificationEmail`](/api/accounts#Accounts-sendVerificationEmail) -The optional `extraTokenData` object is merged into the token stored in the database and is available inside email templates. The optional `extraParams` object is appended to the generated URL as query parameters. +The email is generated using the email templates from [`Accounts.emailTemplates`](/api/accounts#Accounts-emailTemplates), and include links generated with `Accounts.urls`. -If you need to generate a token without sending an email (for example, to build a custom mailer), use the lower-level helpers: +#### Identifying when the link is clicked -```js -// Generate a password reset token (server) -const { token } = Accounts.generateResetToken(userId, email, "resetPassword"); +When the user receives the email and clicks the link inside, their web browser will take them to your app. Now, you need to be able to identify these special links and act appropriately. If you haven't customized the link URL, then you can use some built-in callbacks to identify when the app is in the middle of an email flow: -// Generate an email verification token (server) -const { token } = Accounts.generateVerificationToken(userId, email); -``` +1. [`Accounts.onResetPasswordLink`](/api/accounts#Accounts-onResetPasswordLink) +2. [`Accounts.onEnrollmentLink`](/api/accounts#Accounts-onEnrollmentLink) +3. [`Accounts.onEmailVerificationLink`](/api/accounts#Accounts-onEmailVerificationLink) -The email is generated using the email templates from [`Accounts.emailTemplates`](/api/accounts#Accounts-emailTemplates), and includes links generated with `Accounts.urls`. - -#### Handling the link in your app - -When the user clicks the link in their email, their browser navigates to your app with the token embedded in the URL. Register a client-side callback to detect each flow and render the appropriate UI β€” there is one for each link type: `Accounts.onResetPasswordLink`, `Accounts.onEnrollmentLink`, and `Accounts.onEmailVerificationLink`. Here's how you would implement the password reset flow: +Here's how you would use one of these functions: ```js Accounts.onResetPasswordLink(async (token, done) => { // Display the password reset UI, get the new password... try { - await Accounts.resetPassword(token, newPassword); + await Accounts.resetPasswordAsync(token, newPassword); // Resume normal operation done(); } catch (err) { // Display error - console.error("Password reset failed:", err); + console.error('Password reset failed:', err); } }); ``` -If you want a different URL for your reset password page, you need to customize it using the `Accounts.urls` option. URL generators can also be `async` or return a `Promise`: +If you want a different URL for your reset password page, you need to customize it using the `Accounts.urls` option: ```js Accounts.urls.resetPassword = (token) => { @@ -248,10 +159,10 @@ If you have customized the URL, you will need to add a new route to your router #### Completing the process -When the user submits the form, you need to call the appropriate function to commit their change to the database. Both functions return a `Promise`: +When the user submits the form, you need to call the appropriate function to commit their change to the database: -1. [`Accounts.resetPassword(token, newPassword)`](/api/accounts#Accounts-resetPassword) β€” use this both for resetting the password and enrolling a new user; it accepts both kinds of tokens. Logs in the user after a successful reset (unless 2FA is enabled β€” see [Two-Factor Authentication](#two-factor-authentication-accounts-2fa)). -2. [`Accounts.verifyEmail(token)`](/api/accounts#Accounts-verifyEmail) β€” logs in the user after a successful verification (unless 2FA is enabled). +1. [`Accounts.resetPasswordAsync`](/api/accounts#Accounts-resetPassword) - this one should be used both for resetting the password, and enrolling a new user; it accepts both kinds of tokens. +2. [`Accounts.verifyEmailAsync`](/api/accounts#Accounts-verifyEmail) After you have called one of the two functions above or the user has cancelled the process, call the `done` function you got in the link callback. @@ -264,7 +175,7 @@ Accounts.emailTemplates.siteName = "Meteor Guide Todos Example"; Accounts.emailTemplates.from = "Meteor Todos Accounts "; Accounts.emailTemplates.resetPassword = { - subject(user, url) { + subject(user) { return "Reset your password on Meteor Todos"; }, text(user, url) { @@ -279,7 +190,7 @@ The Meteor Todos team html(user, url) { // This is where HTML email content would go. // See the section about html emails below. - }, + } }; ``` @@ -287,192 +198,34 @@ The Meteor Todos team If you've ever needed to deal with sending pretty HTML emails from an app, you know that it can quickly become a nightmare. Compatibility of popular email clients with basic HTML features like CSS is notoriously spotty. Start with a [responsive email template](https://github.com/leemunroe/responsive-html-email-template) or [framework](https://get.foundation/emails), and then use a tool to convert your email content into something that is compatible with all email clients. -## Passwordless login - -The `accounts-passwordless` package provides a one-time token (magic link) login experience β€” no password required. - -```bash -meteor add accounts-passwordless -``` - -### Requesting a login token - -On the client, call `Accounts.requestLoginTokenForUser` to send a one-time token to the user's email address: - -```js -// Client -await Accounts.requestLoginTokenForUser({ - selector: { email: "ada@lovelace.com" }, - // options.userCreationDisabled: true prevents creating a new account - // if no existing user matches the selector - options: {}, -}); -``` - -If no account exists for the given selector and `userCreationDisabled` is not set, you can pass `userData` to create the account on the fly: - -```js -await Accounts.requestLoginTokenForUser({ - selector: { email: "ada@lovelace.com" }, - userData: { email: "ada@lovelace.com", profile: { name: "Ada Lovelace" } }, -}); -``` - -### Logging in with the token - -When the user clicks the link in their email (or copies the token), call: - -```js -// Client -await Meteor.passwordlessLoginWithToken({ email: "ada@lovelace.com" }, token); -``` - -If the user has [Two-Factor Authentication](#two-factor-authentication-accounts-2fa) enabled, use the 2FA variant instead: - -```js -await Meteor.passwordlessLoginWithTokenAnd2faCode( - { email: "ada@lovelace.com" }, - token, - totpCode -); -``` - -### Automatic URL-based login - -Add `Accounts.autoLoginWithToken()` to your client startup code to detect when the URL contains a login token (e.g. from an email link) and log the user in automatically: - -```js -// client-side startup -Accounts.autoLoginWithToken(); -``` - -### Customizing the email - -Customize the token email through `Accounts.emailTemplates.sendLoginToken`: - -```js -Accounts.emailTemplates.sendLoginToken = { - subject(user) { - return "Your login link"; - }, - text(user, url) { - return `Click the link below to log in:\n\n${url}\n\nThis link expires in 15 minutes.`; - }, -}; -``` - -## Two-Factor Authentication - -The `accounts-2fa` package adds Time-based One-Time Password (TOTP) two-factor authentication, compatible with any standard authenticator app (Google Authenticator, Authy, etc.). - -```bash -meteor add accounts-2fa -``` - -### Enabling 2FA for a user - -The setup flow happens on the client: - -```js -// Step 1: generate a QR code and display it to the user -const { svg, secret, uri } = await new Promise((resolve, reject) => - Accounts.generate2faActivationQrCode("My App", (err, result) => { - if (err) reject(err); - else resolve(result); - }) -); -// Render `svg` in your UI so the user can scan it with their authenticator app - -// Step 2: once the user has scanned the QR code and sees the first code, confirm it -await new Promise((resolve, reject) => - Accounts.enableUser2fa(totpCode, (err) => { - if (err) reject(err); - else resolve(); - }) -); -``` - -### Disabling 2FA and checking status - -```js -// Check if the current user has 2FA enabled -const enabled = await new Promise((resolve, reject) => - Accounts.has2faEnabled((err, result) => { - if (err) reject(err); - else resolve(result); - }) -); - -// Disable 2FA for the current user -await new Promise((resolve, reject) => - Accounts.disableUser2fa((err) => { - if (err) reject(err); - else resolve(); - }) -); -``` - -### Logging in with 2FA - -When a user has 2FA enabled, the standard `Meteor.loginWithPassword` call will fail with an error prompting for a code. Use the dedicated method instead: - -```js -try { - await Meteor.loginWithPasswordAnd2faCode( - "ada@lovelace.com", - "mypassword", - totpCode - ); -} catch (err) { - console.error("Login failed:", err); -} -``` - -### Effect on password reset and email verification - -When 2FA is enabled, completing a password reset (`Accounts.resetPassword`) or email verification (`Accounts.verifyEmail`) will **not** automatically log the user in. The user must perform a full login (including the 2FA step) manually afterward. - -### Configuration - -```js -Accounts.config({ - loginTokenExpirationHours: 1, // how long a TOTP window stays valid (default: 1 hour) - tokenSequenceLength: 6, // TOTP code length (default: 6) -}); -``` - ## OAuth login Meteor supports popular login providers through OAuth out of the box. -### Adding an OAuth provider +### Facebook, Google, and more -Meteor maintains packages for popular login providers. Add one or more to your app: +Here's a complete list of login providers for which Meteor actively maintains core packages: -```bash -meteor add accounts-facebook # Facebook -meteor add accounts-google # Google -meteor add accounts-github # GitHub -meteor add accounts-twitter # Twitter -meteor add accounts-meetup # Meetup -meteor add accounts-meteor-developer # Meteor Developer Accounts -``` +1. Facebook with `accounts-facebook` +2. Google with `accounts-google` +3. GitHub with `accounts-github` +4. Twitter with `accounts-twitter` +5. Meetup with `accounts-meetup` +6. Meteor Developer Accounts with `accounts-meteor-developer` -Each package adds a `Meteor.loginWith` function and registers the service in the OAuth configuration UI. +### Logging in -### Logging in programmatically - -You can log in with any configured OAuth provider using the `Meteor.loginWith` function: +If you are using an off-the-shelf login UI like `accounts-ui`, you don't need to write any code after adding the relevant package. If you are building a login experience from scratch, you can log in programmatically using the [`Meteor.loginWith`](/api/accounts#Meteor-loginWithExternalService) function: ```js try { - await Meteor.loginWithFacebook({ - requestPermissions: ["user_friends", "public_profile", "email"], + await Meteor.loginWithFacebookAsync({ + requestPermissions: ['user_friends', 'public_profile', 'email'] }); // successful login! } catch (err) { // handle error - console.error("Login failed:", err); + console.error('Login failed:', err); } ``` @@ -481,43 +234,9 @@ try { There are a few points to know about configuring OAuth login: 1. **Client ID and secret.** It's best to keep your OAuth secret keys outside of your source code, and pass them in through Meteor.settings. Read how in the [Security article](/tutorials/security/security#api-keys). -2. **Redirect URL.** On the OAuth provider's side, you'll need to specify a _redirect URL_. The URL will look like: `https://www.example.com/_oauth/facebook`. Replace `facebook` with the name of the service you are using. Note that you will need to configure two URLs - one for your production app, and one for your development environment, where the URL might be something like `http://localhost:3000/_oauth/facebook`. +2. **Redirect URL.** On the OAuth provider's side, you'll need to specify a *redirect URL*. The URL will look like: `https://www.example.com/_oauth/facebook`. Replace `facebook` with the name of the service you are using. Note that you will need to configure two URLs - one for your production app, and one for your development environment, where the URL might be something like `http://localhost:3000/_oauth/facebook`. 3. **Permissions.** Each login service provider should have documentation about which permissions are available. If you want additional permissions to the user's data when they log in, pass some of these strings in the `requestPermissions` option. -### Server-side hooks for OAuth - -You can customize how OAuth accounts are created and updated on the server using these hooks. Each can only be registered once: - -```js -// Called before processing an external login. Return false to block the login. -Accounts.beforeExternalLogin((serviceName, serviceData, user) => { - // e.g. only allow logins from a specific GitHub org - if (serviceName === "github" && !serviceData.orgs?.includes("my-org")) { - return false; - } - return true; -}); - -// Provide additional lookup logic to find an existing user for an external login. -// Useful for linking accounts when the external service email matches an existing user. -Accounts.setAdditionalFindUserOnExternalLogin( - ({ serviceName, serviceData }) => { - if (serviceData.email) { - return Accounts.findUserByEmail(serviceData.email); - } - } -); - -// Called on every external login to update the user document. -// Return a modified user object to apply changes. -Accounts.onExternalLogin((options, user) => { - // Merge the latest profile data from the OAuth provider - user.profile = user.profile || {}; - user.profile.name = options.serviceData.name; - return user; -}); -``` - ### Calling service API for more data If your app supports or even requires login with an external service such as Facebook, it's natural to also want to use that service's API to request additional data about that user. @@ -554,44 +273,39 @@ On the server, each connection has a different logged in user, so there is no gl ```js // Accessing this.userId inside a publication -Meteor.publish("lists.private", function () { +Meteor.publish('lists.private', function() { if (!this.userId) { return this.ready(); } - return Lists.find( - { - userId: this.userId, - }, - { - fields: Lists.publicFields, - } - ); + return Lists.find({ + userId: this.userId + }, { + fields: Lists.publicFields + }); }); ``` ```js // Accessing this.userId inside a Method Meteor.methods({ - async "todos.updateText"({ todoId, newText }) { + async 'todos.updateText'({ todoId, newText }) { new SimpleSchema({ todoId: { type: String }, - newText: { type: String }, + newText: { type: String } }).validate({ todoId, newText }); const todo = await Todos.findOneAsync(todoId); if (!todo.editableBy(this.userId)) { - throw new Meteor.Error( - "todos.updateText.unauthorized", - "Cannot edit todos in a private list that is not yours" - ); + throw new Meteor.Error('todos.updateText.unauthorized', + 'Cannot edit todos in a private list that is not yours'); } await Todos.updateAsync(todoId, { - $set: { text: newText }, + $set: { text: newText } }); - }, + } }); ``` @@ -667,17 +381,17 @@ The best way to store your custom data onto the `Meteor.users` collection is to ```js // Using address schema from schema.org const newMailingAddress = { - addressCountry: "US", - addressLocality: "Seattle", - addressRegion: "WA", - postalCode: "98052", - streetAddress: "20341 Whitworth Institute 405 N. Whitworth", + addressCountry: 'US', + addressLocality: 'Seattle', + addressRegion: 'WA', + postalCode: '98052', + streetAddress: "20341 Whitworth Institute 405 N. Whitworth" }; await Meteor.users.updateAsync(userId, { $set: { - mailingAddress: newMailingAddress, - }, + mailingAddress: newMailingAddress + } }); ``` @@ -691,7 +405,7 @@ Sometimes, you want to set a field when the user first creates their account. Yo // Generate user initials after Facebook login Accounts.onCreateUser((options, user) => { if (!user.services.facebook) { - throw new Error("Expected login with Facebook only."); + throw new Error('Expected login with Facebook only.'); } const { first_name, last_name } = user.services.facebook; @@ -710,7 +424,7 @@ Accounts.onCreateUser((options, user) => { Note that the `user` object provided doesn't have an `_id` field yet. If you need to do something with the new user's ID inside this function, you can generate the ID yourself: ```js -import { Random } from "meteor/random"; +import { Random } from 'meteor/random'; // Generate a todo list for each new user Accounts.onCreateUser(async (options, user) => { @@ -736,9 +450,7 @@ Rather than dealing with the specifics of this field, it can be helpful to ignor ```js // Deny all client-side updates to user documents Meteor.users.deny({ - update() { - return true; - }, + update() { return true; } }); ``` @@ -747,21 +459,21 @@ Meteor.users.deny({ If you want to access the custom data you've added to the `Meteor.users` collection in your UI, you'll need to publish it to the client. The most important thing to keep in mind is that user documents contain private data about your usersβ€”hashed passwords and access keys for external APIs. This means it's critically important to filter the fields of the user document that you send to any client. ```js -Meteor.publish("Meteor.users.initials", function ({ userIds }) { +Meteor.publish('Meteor.users.initials', function ({ userIds }) { // Validate the arguments to be what we expect new SimpleSchema({ userIds: { type: Array }, - "userIds.$": { type: String }, + 'userIds.$': { type: String } }).validate({ userIds }); // Select only the users that match the array of IDs passed in const selector = { - _id: { $in: userIds }, + _id: { $in: userIds } }; // Only return one field, `initials` const options = { - fields: { initials: 1 }, + fields: { initials: 1 } }; return Meteor.users.find(selector, options); @@ -780,14 +492,10 @@ const user = await Meteor.userAsync({ fields: { "profile.name": 1 } }); const name = user?.profile?.name; // check if an email exists without fetching their entire document: -const userExists = !!(await Accounts.findUserByEmail(email, { - fields: { _id: 1 }, -})); +const userExists = !!await Accounts.findUserByEmail(email, { fields: { _id: 1 } }); // get the user id from a userName: -const user = await Accounts.findUserByUsername(userName, { - fields: { _id: 1 }, -}); +const user = await Accounts.findUserByUsername(userName, { fields: { _id: 1 } }); const userId = user?._id; ``` @@ -801,7 +509,7 @@ Accounts.config({ createdAt: 1, profile: 1, services: 1, - }, + } }); ``` @@ -813,27 +521,21 @@ Accounts.config({ defaultFieldSelector: { myBigArray: 0 } }); ## Roles and permissions -Once users are logged in, you'll often want to control what each user can do. This uncovers two different types of permissions: +One of the main reasons you might want to add a login system to your app is to have permissions for your data. For example, if you were running a forum, you would want administrators or moderators to be able to delete any post, but normal users can only delete their own. This uncovers two different types of permissions: 1. Role-based permissions 2. Per-document permissions -### roles +### alanning:roles -Meteor ships a core [`roles`](/packages/roles) package for role-based permissions. Add it to your app: - -```bash -meteor add roles -``` - -Here is how you would make a user into an administrator, or a moderator: +The most popular package for role-based permissions in Meteor is [`alanning:roles`](https://atmospherejs.com/alanning/roles). For example, here is how you would make a user into an administrator, or a moderator: ```js // Give Alice the 'admin' role -await Roles.addUsersToRolesAsync(aliceUserId, "admin", Roles.GLOBAL_GROUP); +await Roles.addUsersToRolesAsync(aliceUserId, 'admin', Roles.GLOBAL_GROUP); // Give Bob the 'moderator' role for a particular category -await Roles.addUsersToRolesAsync(bobsUserId, "moderator", categoryId); +await Roles.addUsersToRolesAsync(bobsUserId, 'moderator', categoryId); ``` Now, let's say you wanted to check if someone was allowed to delete a particular forum post: @@ -843,21 +545,21 @@ const forumPost = await Posts.findOneAsync(postId); const canDelete = await Roles.userIsInRoleAsync( userId, - ["admin", "moderator"], + ['admin', 'moderator'], forumPost.categoryId ); if (!canDelete) { - throw new Meteor.Error( - "unauthorized", - "Only admins and moderators can delete posts." - ); + throw new Meteor.Error('unauthorized', + 'Only admins and moderators can delete posts.'); } await Posts.removeAsync(postId); ``` -Note that you can check for multiple roles at once, and if someone has a role in `GLOBAL_GROUP`, they are considered as having that role in every group. +Note that we can check for multiple roles at once, and if someone has a role in the `GLOBAL_GROUP`, they are considered as having that role in every group. + +Read more in the [`alanning:roles` package documentation](https://atmospherejs.com/alanning/roles). ### Per-document permissions @@ -870,7 +572,7 @@ Lists.helpers({ return false; } return this.userId === userId; - }, + } }); ``` @@ -880,10 +582,8 @@ Now, we can call this simple function to determine if a particular user is allow const list = await Lists.findOneAsync(listId); if (!list.editableBy(userId)) { - throw new Meteor.Error( - "unauthorized", - "Only list owners can edit private lists." - ); + throw new Meteor.Error('unauthorized', + 'Only list owners can edit private lists.'); } ``` @@ -892,15 +592,12 @@ Learn more about how to use collection helpers in the [Collections article](/tut ## Best practices summary 1. **Use accounts-password** for email/password login and add OAuth packages as needed. -2. **Validate new users** with `Accounts.validateNewUser` to ensure required fields are present. -3. **Use `Accounts.createUserAsync`** (or `Accounts.createUserVerifyingEmail`) instead of the callback-based `createUser`. -4. **Use case-insensitive queries** with `Accounts.findUserByEmail` and `Accounts.findUserByUsername` β€” never query `Meteor.users` directly by email or username. -5. **Enable `ambiguousErrorMessages: true`** (the default) to prevent user enumeration attacks. -6. **Customize email templates** using `Accounts.emailTemplates` for professional-looking communications. -7. **Never use the profile field** for sensitive data β€” deny client-side writes with `Meteor.users.deny({ update() { return true; } })`. -8. **Add custom data to top-level fields** on user documents, not nested inside `profile`. -9. **Always filter fields** when publishing user data to clients β€” never expose password hashes or access tokens. -10. **Consider Argon2** for new applications by enabling `argon2Enabled: true` in `Accounts.config()`. -11. **Add 2FA** with `accounts-2fa` for applications with elevated security requirements. -12. **Use `accounts-passwordless`** to offer a frictionless, password-free login experience. -13. **Configure `defaultFieldSelector`** to avoid loading large user documents on every login. +2. **Validate new users** with `Accounts.validateNewUser` to ensure required fields. +3. **Use case-insensitive queries** with `Accounts.findUserByEmail` and `Accounts.findUserByUsername`. +4. **Customize email templates** using `Accounts.emailTemplates` for professional communications. +5. **Never use the profile field** for sensitive dataβ€”deny client-side writes to user documents. +6. **Add custom data to top-level fields** on user documents, not nested in profile. +7. **Always filter fields** when publishing user data to clients. +8. **Use alanning:roles** for role-based access control. +9. **Use collection helpers** for per-document permissions. +10. **Configure defaultFieldSelector** to optimize user document fetching. From 10612875886c8fa6b0d5eaba121a140979385eda Mon Sep 17 00:00:00 2001 From: Gabriel Grubba Date: Mon, 30 Mar 2026 21:09:35 -0300 Subject: [PATCH 32/44] DOCS: revamp and update accounts tutorial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In this PR: Removed - `accounts-ui`section - `alanning:roles` references β€” replaced with the Meteor core roles package Updated methods to be using the correct name(*Async for example) And added sections about Passwordless login, 2FA and Security configuration From c6a9c42cfe61424a7b6a23dad8462be57720f3f2 Mon Sep 17 00:00:00 2001 From: Gabriel Grubba Date: Mon, 30 Mar 2026 21:10:10 -0300 Subject: [PATCH 33/44] Revert "DOCS: revamp and update accounts tutorial" This reverts commit e02d65a09a6505170fdaf79afac62117fdc4fb53. --- v3-docs/docs/tutorials/accounts/accounts.md | 565 +++++--------------- 1 file changed, 131 insertions(+), 434 deletions(-) diff --git a/v3-docs/docs/tutorials/accounts/accounts.md b/v3-docs/docs/tutorials/accounts/accounts.md index 05d5329b07..8d5d3154c3 100644 --- a/v3-docs/docs/tutorials/accounts/accounts.md +++ b/v3-docs/docs/tutorials/accounts/accounts.md @@ -3,12 +3,11 @@ After reading this article, you'll know: 1. What features in core Meteor enable user accounts -2. How to build a fully-featured password login experience -3. How to set up passwordless login -4. How to add two-factor authentication (2FA) -5. How to enable login through OAuth providers like Facebook -6. How to add custom data to Meteor's users collection -7. How to protect your data with per-document permissions +2. How to use accounts-ui for a quick prototype +3. How to build a fully-featured password login experience +4. How to enable login through OAuth providers like Facebook +5. How to add custom data to Meteor's users collection +6. How to manage user roles and permissions ## Features in core Meteor @@ -24,13 +23,41 @@ This built-in feature means that you always get `this.userId` inside Methods and This package is the core of Meteor's developer-facing user accounts functionality. This includes: -1. A users collection with a standard schema, accessed through [`Meteor.users`](/api/accounts#Meteor-users), and the client-side singletons [`Meteor.userId()`](/api/accounts#Meteor-userId), [`Meteor.user()`](/api/accounts#Meteor-user), and the async [`Meteor.userAsync()`](/api/accounts#Meteor-userAsync), which represent the login state on the client. -2. Reactive helpers [`Accounts.loggingIn()`](/api/accounts#Accounts-loggingIn) and [`Accounts.loggingOut()`](/api/accounts#Accounts-loggingOut) to track in-progress login/logout state. -3. A variety of helpful other generic methods to keep track of login state, log out, validate users, etc. Visit the [Accounts section of the docs](/api/accounts) to find a complete list. -4. An API for registering new login handlers, which is used by all of the other accounts packages to integrate with the accounts system. +1. A users collection with a standard schema, accessed through [`Meteor.users`](/api/accounts#Meteor-users), and the client-side singletons [`Meteor.userId()`](/api/accounts#Meteor-userId) and [`Meteor.user()`](/api/accounts#Meteor-user), which represent the login state on the client. +2. A variety of helpful other generic methods to keep track of login state, log out, validate users, etc. Visit the [Accounts section of the docs](/api/accounts) to find a complete list. +3. An API for registering new login handlers, which is used by all of the other accounts packages to integrate with the accounts system. Usually, you don't need to include `accounts-base` yourself since it's added for you if you use `accounts-password` or similar, but it's good to be aware of what is what. +## Fast prototyping with accounts-ui + +Often, a complicated accounts system is not the first thing you want to build when you're starting out with a new app, so it's useful to have something you can drop in quickly. This is where `accounts-ui` comes in - it's one line that you drop into your app to get an accounts system. To add it: + +```bash +meteor add accounts-ui +``` + +Then include it anywhere in a Blaze template: + +```html +{{> loginButtons}} +``` + +Then, make sure to pick a login provider; they will automatically integrate with `accounts-ui`: + +```bash +# pick one or more of the below +meteor add accounts-password +meteor add accounts-facebook +meteor add accounts-google +meteor add accounts-github +meteor add accounts-twitter +meteor add accounts-meetup +meteor add accounts-meteor-developer +``` + +Now open your app, follow the configuration steps, and you're good to go - if you've done one of our [Meteor tutorials](/tutorials/react/1.creating-the-app), you've already seen this in action. Of course, in a production application, you probably want a more custom user interface and some logic to have a more tailored UX, but that's why we have the rest of these tutorials. + ## Password login Meteor comes with a secure and fully-featured password login system out of the box. To use it, add the package: @@ -39,9 +66,11 @@ Meteor comes with a secure and fully-featured password login system out of the b meteor add accounts-password ``` +To see what options are available to you, read the complete description of the [`accounts-password` API in the Meteor docs](/api/accounts). + ### Requiring username or email -By default, the `Accounts.createUserAsync` function provided by `accounts-password` allows you to create an account with a username, email, or both. Most apps expect a specific combination of the two, so you will certainly want to validate the new user creation: +By default, the `Accounts.createUser` function provided by `accounts-password` allows you to create an account with a username, email, or both. Most apps expect a specific combination of the two, so you will certainly want to validate the new user creation: ```js // Ensuring every user has an email address, should be in server-side code @@ -49,11 +78,11 @@ Accounts.validateNewUser((user) => { new SimpleSchema({ _id: { type: String }, emails: { type: Array }, - "emails.$": { type: Object }, - "emails.$.address": { type: String }, - "emails.$.verified": { type: Boolean }, + 'emails.$': { type: Object }, + 'emails.$.address': { type: String }, + 'emails.$.verified': { type: Boolean }, createdAt: { type: Date }, - services: { type: Object, blackbox: true }, + services: { type: Object, blackbox: true } }).validate(user); // Return true to allow user creation to proceed @@ -61,49 +90,11 @@ Accounts.validateNewUser((user) => { }); ``` -> When creating users programmatically, prefer the async variant: +### Multiple emails -```js -// Client or server -const userId = await Accounts.createUserAsync({ - username: "ada", - email: "ada@lovelace.com", - password: "secret", - profile: { name: "Ada Lovelace" }, -}); -``` +Often, users might want to associate multiple email addresses with the same account. `accounts-password` addresses this case by storing the email addresses as an array in the user collection. There are some handy API methods to deal with [adding](/api/accounts#Accounts-addEmail), [removing](/api/accounts#Accounts-removeEmail), and [verifying](/api/accounts#Accounts-verifyEmail) emails. -If you want to automatically send an email verification after account creation, use `Accounts.createUserVerifyingEmail` instead: - -```js -await Accounts.createUserVerifyingEmail({ - email: "ada@lovelace.com", - password: "secret", -}); -``` - -### Managing multiple email addresses - -Users can associate more than one email address with their account. Meteor stores them as an array in the user document, so you can add, remove, and verify each one independently. - -```js -// Add a new address for the user (server) -await Accounts.addEmailAsync(userId, "work@example.com"); - -// Remove an address (server) -Accounts.removeEmail(userId, "old@example.com"); - -// Send a verification email to a specific address (server) -Accounts.sendVerificationEmail(userId, "work@example.com"); -``` - -A common pattern is to record a "primary" email address β€” the one used for notifications and password resets β€” as a top-level field on the user document: - -```js -await Meteor.users.updateAsync(userId, { - $set: { primaryEmail: "work@example.com" }, -}); -``` +One useful thing to add for your app can be the concept of a "primary" email address. This way, if the user has added multiple emails, you know where to send confirmation emails and similar. ### Case sensitivity @@ -113,80 +104,6 @@ Meteor handles case sensitivity for email addresses and usernames. Since MongoDB Follow one rule: don't query the database by `username` or `email` directly. Instead, use the [`Accounts.findUserByUsername`](/api/accounts#Accounts-findUserByUsername) and [`Accounts.findUserByEmail`](/api/accounts#Accounts-findUserByEmail) methods provided by Meteor. This will run a query for you that is case-insensitive, so you will always find the user you are looking for. -### Security configuration - -`Accounts.config()` exposes several options that harden your login system. Call it once from server-side startup code. - -**Prevent user enumeration.** When enabled (the default in Meteor 3), "user not found" and "incorrect password" return the same error message to the caller, making it impossible for an attacker to discover which email addresses are registered: - -```js -Accounts.config({ ambiguousErrorMessages: true }); // default: true -``` - -**Block client-side account creation.** Ensure new accounts can only be created server-side (e.g. through a trusted Meteor Method), preventing unvetted signups from the browser console: - -```js -Accounts.config({ forbidClientAccountCreation: true }); -``` - -**Restrict signups by email domain.** Accept a string, an array of strings, or a function: - -```js -// single domain -Accounts.config({ restrictCreationByEmailDomain: "mycompany.com" }); - -// multiple domains -Accounts.config({ - restrictCreationByEmailDomain: ["mycompany.com", "contractor.io"], -}); - -// custom logic -Accounts.config({ - restrictCreationByEmailDomain: (email) => email.endsWith(".edu"), -}); -``` - -**Credential storage.** By default, login tokens are stored in `localStorage` and survive across browser sessions. Set `clientStorage` to `'session'` to clear credentials when the browser tab is closed: - -```js -Accounts.config({ clientStorage: "session" }); // 'local' (default) or 'session' -``` - -### Password hashing - -Meteor uses **bcrypt** to hash passwords by default. You can tune the work factor: - -```js -Accounts.config({ bcryptRounds: 12 }); // default: 10 -``` - -Meteor 3.x also supports **Argon2**, which is recommended by [OWASP](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) for new applications: - -```js -// server-side startup code -Accounts.config({ - argon2Enabled: true, - argon2Type: "argon2id", // 'argon2i' | 'argon2d' | 'argon2id' (default) - argon2TimeCost: 2, // iterations (default: 2) - argon2MemoryCost: 19456, // memory in KiB β€” 19 MB (default) - argon2Parallelism: 1, // threads (default: 1) -}); -``` - -Enabling Argon2 does not break existing users. Existing bcrypt hashes continue to work and are transparently re-hashed to Argon2 the next time each user logs in. - -### Token lifetime configuration - -You can control how long session and email tokens remain valid: - -```js -Accounts.config({ - loginExpirationInDays: 90, // session token lifetime (default: 90; set to null to never expire) - passwordResetTokenExpirationInDays: 3, // password reset link lifetime (default: 3 days) - passwordEnrollTokenExpirationInDays: 30, // account enrollment link lifetime (default: 30 days) -}); -``` - ### Email flows When you have a login system for your app based on user emails, that opens up the possibility for email-based account flows. The common thing between all of these workflows is that they involve sending a unique link to the user's email address, which does something special when it is clicked. Let's look at some common examples that Meteor's `accounts-password` package supports out of the box: @@ -199,44 +116,38 @@ When you have a login system for your app based on user emails, that opens up th `accounts-password` comes with handy functions that you can call from the server to send an email: -1. [`Accounts.sendResetPasswordEmail(userId, email?, extraTokenData?, extraParams?)`](/api/accounts#Accounts-sendResetPasswordEmail) -2. [`Accounts.sendEnrollmentEmail(userId, email?, extraTokenData?, extraParams?)`](/api/accounts#Accounts-sendEnrollmentEmail) -3. [`Accounts.sendVerificationEmail(userId, email?, extraTokenData?, extraParams?)`](/api/accounts#Accounts-sendVerificationEmail) +1. [`Accounts.sendResetPasswordEmail`](/api/accounts#Accounts-sendResetPasswordEmail) +2. [`Accounts.sendEnrollmentEmail`](/api/accounts#Accounts-sendEnrollmentEmail) +3. [`Accounts.sendVerificationEmail`](/api/accounts#Accounts-sendVerificationEmail) -The optional `extraTokenData` object is merged into the token stored in the database and is available inside email templates. The optional `extraParams` object is appended to the generated URL as query parameters. +The email is generated using the email templates from [`Accounts.emailTemplates`](/api/accounts#Accounts-emailTemplates), and include links generated with `Accounts.urls`. -If you need to generate a token without sending an email (for example, to build a custom mailer), use the lower-level helpers: +#### Identifying when the link is clicked -```js -// Generate a password reset token (server) -const { token } = Accounts.generateResetToken(userId, email, "resetPassword"); +When the user receives the email and clicks the link inside, their web browser will take them to your app. Now, you need to be able to identify these special links and act appropriately. If you haven't customized the link URL, then you can use some built-in callbacks to identify when the app is in the middle of an email flow: -// Generate an email verification token (server) -const { token } = Accounts.generateVerificationToken(userId, email); -``` +1. [`Accounts.onResetPasswordLink`](/api/accounts#Accounts-onResetPasswordLink) +2. [`Accounts.onEnrollmentLink`](/api/accounts#Accounts-onEnrollmentLink) +3. [`Accounts.onEmailVerificationLink`](/api/accounts#Accounts-onEmailVerificationLink) -The email is generated using the email templates from [`Accounts.emailTemplates`](/api/accounts#Accounts-emailTemplates), and includes links generated with `Accounts.urls`. - -#### Handling the link in your app - -When the user clicks the link in their email, their browser navigates to your app with the token embedded in the URL. Register a client-side callback to detect each flow and render the appropriate UI β€” there is one for each link type: `Accounts.onResetPasswordLink`, `Accounts.onEnrollmentLink`, and `Accounts.onEmailVerificationLink`. Here's how you would implement the password reset flow: +Here's how you would use one of these functions: ```js Accounts.onResetPasswordLink(async (token, done) => { // Display the password reset UI, get the new password... try { - await Accounts.resetPassword(token, newPassword); + await Accounts.resetPasswordAsync(token, newPassword); // Resume normal operation done(); } catch (err) { // Display error - console.error("Password reset failed:", err); + console.error('Password reset failed:', err); } }); ``` -If you want a different URL for your reset password page, you need to customize it using the `Accounts.urls` option. URL generators can also be `async` or return a `Promise`: +If you want a different URL for your reset password page, you need to customize it using the `Accounts.urls` option: ```js Accounts.urls.resetPassword = (token) => { @@ -248,10 +159,10 @@ If you have customized the URL, you will need to add a new route to your router #### Completing the process -When the user submits the form, you need to call the appropriate function to commit their change to the database. Both functions return a `Promise`: +When the user submits the form, you need to call the appropriate function to commit their change to the database: -1. [`Accounts.resetPassword(token, newPassword)`](/api/accounts#Accounts-resetPassword) β€” use this both for resetting the password and enrolling a new user; it accepts both kinds of tokens. Logs in the user after a successful reset (unless 2FA is enabled β€” see [Two-Factor Authentication](#two-factor-authentication-accounts-2fa)). -2. [`Accounts.verifyEmail(token)`](/api/accounts#Accounts-verifyEmail) β€” logs in the user after a successful verification (unless 2FA is enabled). +1. [`Accounts.resetPasswordAsync`](/api/accounts#Accounts-resetPassword) - this one should be used both for resetting the password, and enrolling a new user; it accepts both kinds of tokens. +2. [`Accounts.verifyEmailAsync`](/api/accounts#Accounts-verifyEmail) After you have called one of the two functions above or the user has cancelled the process, call the `done` function you got in the link callback. @@ -264,7 +175,7 @@ Accounts.emailTemplates.siteName = "Meteor Guide Todos Example"; Accounts.emailTemplates.from = "Meteor Todos Accounts "; Accounts.emailTemplates.resetPassword = { - subject(user, url) { + subject(user) { return "Reset your password on Meteor Todos"; }, text(user, url) { @@ -279,7 +190,7 @@ The Meteor Todos team html(user, url) { // This is where HTML email content would go. // See the section about html emails below. - }, + } }; ``` @@ -287,192 +198,34 @@ The Meteor Todos team If you've ever needed to deal with sending pretty HTML emails from an app, you know that it can quickly become a nightmare. Compatibility of popular email clients with basic HTML features like CSS is notoriously spotty. Start with a [responsive email template](https://github.com/leemunroe/responsive-html-email-template) or [framework](https://get.foundation/emails), and then use a tool to convert your email content into something that is compatible with all email clients. -## Passwordless login - -The `accounts-passwordless` package provides a one-time token (magic link) login experience β€” no password required. - -```bash -meteor add accounts-passwordless -``` - -### Requesting a login token - -On the client, call `Accounts.requestLoginTokenForUser` to send a one-time token to the user's email address: - -```js -// Client -await Accounts.requestLoginTokenForUser({ - selector: { email: "ada@lovelace.com" }, - // options.userCreationDisabled: true prevents creating a new account - // if no existing user matches the selector - options: {}, -}); -``` - -If no account exists for the given selector and `userCreationDisabled` is not set, you can pass `userData` to create the account on the fly: - -```js -await Accounts.requestLoginTokenForUser({ - selector: { email: "ada@lovelace.com" }, - userData: { email: "ada@lovelace.com", profile: { name: "Ada Lovelace" } }, -}); -``` - -### Logging in with the token - -When the user clicks the link in their email (or copies the token), call: - -```js -// Client -await Meteor.passwordlessLoginWithToken({ email: "ada@lovelace.com" }, token); -``` - -If the user has [Two-Factor Authentication](#two-factor-authentication-accounts-2fa) enabled, use the 2FA variant instead: - -```js -await Meteor.passwordlessLoginWithTokenAnd2faCode( - { email: "ada@lovelace.com" }, - token, - totpCode -); -``` - -### Automatic URL-based login - -Add `Accounts.autoLoginWithToken()` to your client startup code to detect when the URL contains a login token (e.g. from an email link) and log the user in automatically: - -```js -// client-side startup -Accounts.autoLoginWithToken(); -``` - -### Customizing the email - -Customize the token email through `Accounts.emailTemplates.sendLoginToken`: - -```js -Accounts.emailTemplates.sendLoginToken = { - subject(user) { - return "Your login link"; - }, - text(user, url) { - return `Click the link below to log in:\n\n${url}\n\nThis link expires in 15 minutes.`; - }, -}; -``` - -## Two-Factor Authentication - -The `accounts-2fa` package adds Time-based One-Time Password (TOTP) two-factor authentication, compatible with any standard authenticator app (Google Authenticator, Authy, etc.). - -```bash -meteor add accounts-2fa -``` - -### Enabling 2FA for a user - -The setup flow happens on the client: - -```js -// Step 1: generate a QR code and display it to the user -const { svg, secret, uri } = await new Promise((resolve, reject) => - Accounts.generate2faActivationQrCode("My App", (err, result) => { - if (err) reject(err); - else resolve(result); - }) -); -// Render `svg` in your UI so the user can scan it with their authenticator app - -// Step 2: once the user has scanned the QR code and sees the first code, confirm it -await new Promise((resolve, reject) => - Accounts.enableUser2fa(totpCode, (err) => { - if (err) reject(err); - else resolve(); - }) -); -``` - -### Disabling 2FA and checking status - -```js -// Check if the current user has 2FA enabled -const enabled = await new Promise((resolve, reject) => - Accounts.has2faEnabled((err, result) => { - if (err) reject(err); - else resolve(result); - }) -); - -// Disable 2FA for the current user -await new Promise((resolve, reject) => - Accounts.disableUser2fa((err) => { - if (err) reject(err); - else resolve(); - }) -); -``` - -### Logging in with 2FA - -When a user has 2FA enabled, the standard `Meteor.loginWithPassword` call will fail with an error prompting for a code. Use the dedicated method instead: - -```js -try { - await Meteor.loginWithPasswordAnd2faCode( - "ada@lovelace.com", - "mypassword", - totpCode - ); -} catch (err) { - console.error("Login failed:", err); -} -``` - -### Effect on password reset and email verification - -When 2FA is enabled, completing a password reset (`Accounts.resetPassword`) or email verification (`Accounts.verifyEmail`) will **not** automatically log the user in. The user must perform a full login (including the 2FA step) manually afterward. - -### Configuration - -```js -Accounts.config({ - loginTokenExpirationHours: 1, // how long a TOTP window stays valid (default: 1 hour) - tokenSequenceLength: 6, // TOTP code length (default: 6) -}); -``` - ## OAuth login Meteor supports popular login providers through OAuth out of the box. -### Adding an OAuth provider +### Facebook, Google, and more -Meteor maintains packages for popular login providers. Add one or more to your app: +Here's a complete list of login providers for which Meteor actively maintains core packages: -```bash -meteor add accounts-facebook # Facebook -meteor add accounts-google # Google -meteor add accounts-github # GitHub -meteor add accounts-twitter # Twitter -meteor add accounts-meetup # Meetup -meteor add accounts-meteor-developer # Meteor Developer Accounts -``` +1. Facebook with `accounts-facebook` +2. Google with `accounts-google` +3. GitHub with `accounts-github` +4. Twitter with `accounts-twitter` +5. Meetup with `accounts-meetup` +6. Meteor Developer Accounts with `accounts-meteor-developer` -Each package adds a `Meteor.loginWith` function and registers the service in the OAuth configuration UI. +### Logging in -### Logging in programmatically - -You can log in with any configured OAuth provider using the `Meteor.loginWith` function: +If you are using an off-the-shelf login UI like `accounts-ui`, you don't need to write any code after adding the relevant package. If you are building a login experience from scratch, you can log in programmatically using the [`Meteor.loginWith`](/api/accounts#Meteor-loginWithExternalService) function: ```js try { - await Meteor.loginWithFacebook({ - requestPermissions: ["user_friends", "public_profile", "email"], + await Meteor.loginWithFacebookAsync({ + requestPermissions: ['user_friends', 'public_profile', 'email'] }); // successful login! } catch (err) { // handle error - console.error("Login failed:", err); + console.error('Login failed:', err); } ``` @@ -481,43 +234,9 @@ try { There are a few points to know about configuring OAuth login: 1. **Client ID and secret.** It's best to keep your OAuth secret keys outside of your source code, and pass them in through Meteor.settings. Read how in the [Security article](/tutorials/security/security#api-keys). -2. **Redirect URL.** On the OAuth provider's side, you'll need to specify a _redirect URL_. The URL will look like: `https://www.example.com/_oauth/facebook`. Replace `facebook` with the name of the service you are using. Note that you will need to configure two URLs - one for your production app, and one for your development environment, where the URL might be something like `http://localhost:3000/_oauth/facebook`. +2. **Redirect URL.** On the OAuth provider's side, you'll need to specify a *redirect URL*. The URL will look like: `https://www.example.com/_oauth/facebook`. Replace `facebook` with the name of the service you are using. Note that you will need to configure two URLs - one for your production app, and one for your development environment, where the URL might be something like `http://localhost:3000/_oauth/facebook`. 3. **Permissions.** Each login service provider should have documentation about which permissions are available. If you want additional permissions to the user's data when they log in, pass some of these strings in the `requestPermissions` option. -### Server-side hooks for OAuth - -You can customize how OAuth accounts are created and updated on the server using these hooks. Each can only be registered once: - -```js -// Called before processing an external login. Return false to block the login. -Accounts.beforeExternalLogin((serviceName, serviceData, user) => { - // e.g. only allow logins from a specific GitHub org - if (serviceName === "github" && !serviceData.orgs?.includes("my-org")) { - return false; - } - return true; -}); - -// Provide additional lookup logic to find an existing user for an external login. -// Useful for linking accounts when the external service email matches an existing user. -Accounts.setAdditionalFindUserOnExternalLogin( - ({ serviceName, serviceData }) => { - if (serviceData.email) { - return Accounts.findUserByEmail(serviceData.email); - } - } -); - -// Called on every external login to update the user document. -// Return a modified user object to apply changes. -Accounts.onExternalLogin((options, user) => { - // Merge the latest profile data from the OAuth provider - user.profile = user.profile || {}; - user.profile.name = options.serviceData.name; - return user; -}); -``` - ### Calling service API for more data If your app supports or even requires login with an external service such as Facebook, it's natural to also want to use that service's API to request additional data about that user. @@ -554,44 +273,39 @@ On the server, each connection has a different logged in user, so there is no gl ```js // Accessing this.userId inside a publication -Meteor.publish("lists.private", function () { +Meteor.publish('lists.private', function() { if (!this.userId) { return this.ready(); } - return Lists.find( - { - userId: this.userId, - }, - { - fields: Lists.publicFields, - } - ); + return Lists.find({ + userId: this.userId + }, { + fields: Lists.publicFields + }); }); ``` ```js // Accessing this.userId inside a Method Meteor.methods({ - async "todos.updateText"({ todoId, newText }) { + async 'todos.updateText'({ todoId, newText }) { new SimpleSchema({ todoId: { type: String }, - newText: { type: String }, + newText: { type: String } }).validate({ todoId, newText }); const todo = await Todos.findOneAsync(todoId); if (!todo.editableBy(this.userId)) { - throw new Meteor.Error( - "todos.updateText.unauthorized", - "Cannot edit todos in a private list that is not yours" - ); + throw new Meteor.Error('todos.updateText.unauthorized', + 'Cannot edit todos in a private list that is not yours'); } await Todos.updateAsync(todoId, { - $set: { text: newText }, + $set: { text: newText } }); - }, + } }); ``` @@ -667,17 +381,17 @@ The best way to store your custom data onto the `Meteor.users` collection is to ```js // Using address schema from schema.org const newMailingAddress = { - addressCountry: "US", - addressLocality: "Seattle", - addressRegion: "WA", - postalCode: "98052", - streetAddress: "20341 Whitworth Institute 405 N. Whitworth", + addressCountry: 'US', + addressLocality: 'Seattle', + addressRegion: 'WA', + postalCode: '98052', + streetAddress: "20341 Whitworth Institute 405 N. Whitworth" }; await Meteor.users.updateAsync(userId, { $set: { - mailingAddress: newMailingAddress, - }, + mailingAddress: newMailingAddress + } }); ``` @@ -691,7 +405,7 @@ Sometimes, you want to set a field when the user first creates their account. Yo // Generate user initials after Facebook login Accounts.onCreateUser((options, user) => { if (!user.services.facebook) { - throw new Error("Expected login with Facebook only."); + throw new Error('Expected login with Facebook only.'); } const { first_name, last_name } = user.services.facebook; @@ -710,7 +424,7 @@ Accounts.onCreateUser((options, user) => { Note that the `user` object provided doesn't have an `_id` field yet. If you need to do something with the new user's ID inside this function, you can generate the ID yourself: ```js -import { Random } from "meteor/random"; +import { Random } from 'meteor/random'; // Generate a todo list for each new user Accounts.onCreateUser(async (options, user) => { @@ -736,9 +450,7 @@ Rather than dealing with the specifics of this field, it can be helpful to ignor ```js // Deny all client-side updates to user documents Meteor.users.deny({ - update() { - return true; - }, + update() { return true; } }); ``` @@ -747,21 +459,21 @@ Meteor.users.deny({ If you want to access the custom data you've added to the `Meteor.users` collection in your UI, you'll need to publish it to the client. The most important thing to keep in mind is that user documents contain private data about your usersβ€”hashed passwords and access keys for external APIs. This means it's critically important to filter the fields of the user document that you send to any client. ```js -Meteor.publish("Meteor.users.initials", function ({ userIds }) { +Meteor.publish('Meteor.users.initials', function ({ userIds }) { // Validate the arguments to be what we expect new SimpleSchema({ userIds: { type: Array }, - "userIds.$": { type: String }, + 'userIds.$': { type: String } }).validate({ userIds }); // Select only the users that match the array of IDs passed in const selector = { - _id: { $in: userIds }, + _id: { $in: userIds } }; // Only return one field, `initials` const options = { - fields: { initials: 1 }, + fields: { initials: 1 } }; return Meteor.users.find(selector, options); @@ -780,14 +492,10 @@ const user = await Meteor.userAsync({ fields: { "profile.name": 1 } }); const name = user?.profile?.name; // check if an email exists without fetching their entire document: -const userExists = !!(await Accounts.findUserByEmail(email, { - fields: { _id: 1 }, -})); +const userExists = !!await Accounts.findUserByEmail(email, { fields: { _id: 1 } }); // get the user id from a userName: -const user = await Accounts.findUserByUsername(userName, { - fields: { _id: 1 }, -}); +const user = await Accounts.findUserByUsername(userName, { fields: { _id: 1 } }); const userId = user?._id; ``` @@ -801,7 +509,7 @@ Accounts.config({ createdAt: 1, profile: 1, services: 1, - }, + } }); ``` @@ -813,27 +521,21 @@ Accounts.config({ defaultFieldSelector: { myBigArray: 0 } }); ## Roles and permissions -Once users are logged in, you'll often want to control what each user can do. This uncovers two different types of permissions: +One of the main reasons you might want to add a login system to your app is to have permissions for your data. For example, if you were running a forum, you would want administrators or moderators to be able to delete any post, but normal users can only delete their own. This uncovers two different types of permissions: 1. Role-based permissions 2. Per-document permissions -### roles +### alanning:roles -Meteor ships a core [`roles`](/packages/roles) package for role-based permissions. Add it to your app: - -```bash -meteor add roles -``` - -Here is how you would make a user into an administrator, or a moderator: +The most popular package for role-based permissions in Meteor is [`alanning:roles`](https://atmospherejs.com/alanning/roles). For example, here is how you would make a user into an administrator, or a moderator: ```js // Give Alice the 'admin' role -await Roles.addUsersToRolesAsync(aliceUserId, "admin", Roles.GLOBAL_GROUP); +await Roles.addUsersToRolesAsync(aliceUserId, 'admin', Roles.GLOBAL_GROUP); // Give Bob the 'moderator' role for a particular category -await Roles.addUsersToRolesAsync(bobsUserId, "moderator", categoryId); +await Roles.addUsersToRolesAsync(bobsUserId, 'moderator', categoryId); ``` Now, let's say you wanted to check if someone was allowed to delete a particular forum post: @@ -843,21 +545,21 @@ const forumPost = await Posts.findOneAsync(postId); const canDelete = await Roles.userIsInRoleAsync( userId, - ["admin", "moderator"], + ['admin', 'moderator'], forumPost.categoryId ); if (!canDelete) { - throw new Meteor.Error( - "unauthorized", - "Only admins and moderators can delete posts." - ); + throw new Meteor.Error('unauthorized', + 'Only admins and moderators can delete posts.'); } await Posts.removeAsync(postId); ``` -Note that you can check for multiple roles at once, and if someone has a role in `GLOBAL_GROUP`, they are considered as having that role in every group. +Note that we can check for multiple roles at once, and if someone has a role in the `GLOBAL_GROUP`, they are considered as having that role in every group. + +Read more in the [`alanning:roles` package documentation](https://atmospherejs.com/alanning/roles). ### Per-document permissions @@ -870,7 +572,7 @@ Lists.helpers({ return false; } return this.userId === userId; - }, + } }); ``` @@ -880,10 +582,8 @@ Now, we can call this simple function to determine if a particular user is allow const list = await Lists.findOneAsync(listId); if (!list.editableBy(userId)) { - throw new Meteor.Error( - "unauthorized", - "Only list owners can edit private lists." - ); + throw new Meteor.Error('unauthorized', + 'Only list owners can edit private lists.'); } ``` @@ -892,15 +592,12 @@ Learn more about how to use collection helpers in the [Collections article](/tut ## Best practices summary 1. **Use accounts-password** for email/password login and add OAuth packages as needed. -2. **Validate new users** with `Accounts.validateNewUser` to ensure required fields are present. -3. **Use `Accounts.createUserAsync`** (or `Accounts.createUserVerifyingEmail`) instead of the callback-based `createUser`. -4. **Use case-insensitive queries** with `Accounts.findUserByEmail` and `Accounts.findUserByUsername` β€” never query `Meteor.users` directly by email or username. -5. **Enable `ambiguousErrorMessages: true`** (the default) to prevent user enumeration attacks. -6. **Customize email templates** using `Accounts.emailTemplates` for professional-looking communications. -7. **Never use the profile field** for sensitive data β€” deny client-side writes with `Meteor.users.deny({ update() { return true; } })`. -8. **Add custom data to top-level fields** on user documents, not nested inside `profile`. -9. **Always filter fields** when publishing user data to clients β€” never expose password hashes or access tokens. -10. **Consider Argon2** for new applications by enabling `argon2Enabled: true` in `Accounts.config()`. -11. **Add 2FA** with `accounts-2fa` for applications with elevated security requirements. -12. **Use `accounts-passwordless`** to offer a frictionless, password-free login experience. -13. **Configure `defaultFieldSelector`** to avoid loading large user documents on every login. +2. **Validate new users** with `Accounts.validateNewUser` to ensure required fields. +3. **Use case-insensitive queries** with `Accounts.findUserByEmail` and `Accounts.findUserByUsername`. +4. **Customize email templates** using `Accounts.emailTemplates` for professional communications. +5. **Never use the profile field** for sensitive dataβ€”deny client-side writes to user documents. +6. **Add custom data to top-level fields** on user documents, not nested in profile. +7. **Always filter fields** when publishing user data to clients. +8. **Use alanning:roles** for role-based access control. +9. **Use collection helpers** for per-document permissions. +10. **Configure defaultFieldSelector** to optimize user document fetching. From cf82fd880cf36e18d5b533bd167e8a188f367f10 Mon Sep 17 00:00:00 2001 From: Gabriel Grubba Date: Mon, 30 Mar 2026 21:11:41 -0300 Subject: [PATCH 34/44] Reapply "DOCS: revamp and update accounts tutorial" This reverts commit c6a9c42cfe61424a7b6a23dad8462be57720f3f2. --- v3-docs/docs/tutorials/accounts/accounts.md | 565 +++++++++++++++----- 1 file changed, 434 insertions(+), 131 deletions(-) diff --git a/v3-docs/docs/tutorials/accounts/accounts.md b/v3-docs/docs/tutorials/accounts/accounts.md index 8d5d3154c3..05d5329b07 100644 --- a/v3-docs/docs/tutorials/accounts/accounts.md +++ b/v3-docs/docs/tutorials/accounts/accounts.md @@ -3,11 +3,12 @@ After reading this article, you'll know: 1. What features in core Meteor enable user accounts -2. How to use accounts-ui for a quick prototype -3. How to build a fully-featured password login experience -4. How to enable login through OAuth providers like Facebook -5. How to add custom data to Meteor's users collection -6. How to manage user roles and permissions +2. How to build a fully-featured password login experience +3. How to set up passwordless login +4. How to add two-factor authentication (2FA) +5. How to enable login through OAuth providers like Facebook +6. How to add custom data to Meteor's users collection +7. How to protect your data with per-document permissions ## Features in core Meteor @@ -23,41 +24,13 @@ This built-in feature means that you always get `this.userId` inside Methods and This package is the core of Meteor's developer-facing user accounts functionality. This includes: -1. A users collection with a standard schema, accessed through [`Meteor.users`](/api/accounts#Meteor-users), and the client-side singletons [`Meteor.userId()`](/api/accounts#Meteor-userId) and [`Meteor.user()`](/api/accounts#Meteor-user), which represent the login state on the client. -2. A variety of helpful other generic methods to keep track of login state, log out, validate users, etc. Visit the [Accounts section of the docs](/api/accounts) to find a complete list. -3. An API for registering new login handlers, which is used by all of the other accounts packages to integrate with the accounts system. +1. A users collection with a standard schema, accessed through [`Meteor.users`](/api/accounts#Meteor-users), and the client-side singletons [`Meteor.userId()`](/api/accounts#Meteor-userId), [`Meteor.user()`](/api/accounts#Meteor-user), and the async [`Meteor.userAsync()`](/api/accounts#Meteor-userAsync), which represent the login state on the client. +2. Reactive helpers [`Accounts.loggingIn()`](/api/accounts#Accounts-loggingIn) and [`Accounts.loggingOut()`](/api/accounts#Accounts-loggingOut) to track in-progress login/logout state. +3. A variety of helpful other generic methods to keep track of login state, log out, validate users, etc. Visit the [Accounts section of the docs](/api/accounts) to find a complete list. +4. An API for registering new login handlers, which is used by all of the other accounts packages to integrate with the accounts system. Usually, you don't need to include `accounts-base` yourself since it's added for you if you use `accounts-password` or similar, but it's good to be aware of what is what. -## Fast prototyping with accounts-ui - -Often, a complicated accounts system is not the first thing you want to build when you're starting out with a new app, so it's useful to have something you can drop in quickly. This is where `accounts-ui` comes in - it's one line that you drop into your app to get an accounts system. To add it: - -```bash -meteor add accounts-ui -``` - -Then include it anywhere in a Blaze template: - -```html -{{> loginButtons}} -``` - -Then, make sure to pick a login provider; they will automatically integrate with `accounts-ui`: - -```bash -# pick one or more of the below -meteor add accounts-password -meteor add accounts-facebook -meteor add accounts-google -meteor add accounts-github -meteor add accounts-twitter -meteor add accounts-meetup -meteor add accounts-meteor-developer -``` - -Now open your app, follow the configuration steps, and you're good to go - if you've done one of our [Meteor tutorials](/tutorials/react/1.creating-the-app), you've already seen this in action. Of course, in a production application, you probably want a more custom user interface and some logic to have a more tailored UX, but that's why we have the rest of these tutorials. - ## Password login Meteor comes with a secure and fully-featured password login system out of the box. To use it, add the package: @@ -66,11 +39,9 @@ Meteor comes with a secure and fully-featured password login system out of the b meteor add accounts-password ``` -To see what options are available to you, read the complete description of the [`accounts-password` API in the Meteor docs](/api/accounts). - ### Requiring username or email -By default, the `Accounts.createUser` function provided by `accounts-password` allows you to create an account with a username, email, or both. Most apps expect a specific combination of the two, so you will certainly want to validate the new user creation: +By default, the `Accounts.createUserAsync` function provided by `accounts-password` allows you to create an account with a username, email, or both. Most apps expect a specific combination of the two, so you will certainly want to validate the new user creation: ```js // Ensuring every user has an email address, should be in server-side code @@ -78,11 +49,11 @@ Accounts.validateNewUser((user) => { new SimpleSchema({ _id: { type: String }, emails: { type: Array }, - 'emails.$': { type: Object }, - 'emails.$.address': { type: String }, - 'emails.$.verified': { type: Boolean }, + "emails.$": { type: Object }, + "emails.$.address": { type: String }, + "emails.$.verified": { type: Boolean }, createdAt: { type: Date }, - services: { type: Object, blackbox: true } + services: { type: Object, blackbox: true }, }).validate(user); // Return true to allow user creation to proceed @@ -90,11 +61,49 @@ Accounts.validateNewUser((user) => { }); ``` -### Multiple emails +> When creating users programmatically, prefer the async variant: -Often, users might want to associate multiple email addresses with the same account. `accounts-password` addresses this case by storing the email addresses as an array in the user collection. There are some handy API methods to deal with [adding](/api/accounts#Accounts-addEmail), [removing](/api/accounts#Accounts-removeEmail), and [verifying](/api/accounts#Accounts-verifyEmail) emails. +```js +// Client or server +const userId = await Accounts.createUserAsync({ + username: "ada", + email: "ada@lovelace.com", + password: "secret", + profile: { name: "Ada Lovelace" }, +}); +``` -One useful thing to add for your app can be the concept of a "primary" email address. This way, if the user has added multiple emails, you know where to send confirmation emails and similar. +If you want to automatically send an email verification after account creation, use `Accounts.createUserVerifyingEmail` instead: + +```js +await Accounts.createUserVerifyingEmail({ + email: "ada@lovelace.com", + password: "secret", +}); +``` + +### Managing multiple email addresses + +Users can associate more than one email address with their account. Meteor stores them as an array in the user document, so you can add, remove, and verify each one independently. + +```js +// Add a new address for the user (server) +await Accounts.addEmailAsync(userId, "work@example.com"); + +// Remove an address (server) +Accounts.removeEmail(userId, "old@example.com"); + +// Send a verification email to a specific address (server) +Accounts.sendVerificationEmail(userId, "work@example.com"); +``` + +A common pattern is to record a "primary" email address β€” the one used for notifications and password resets β€” as a top-level field on the user document: + +```js +await Meteor.users.updateAsync(userId, { + $set: { primaryEmail: "work@example.com" }, +}); +``` ### Case sensitivity @@ -104,6 +113,80 @@ Meteor handles case sensitivity for email addresses and usernames. Since MongoDB Follow one rule: don't query the database by `username` or `email` directly. Instead, use the [`Accounts.findUserByUsername`](/api/accounts#Accounts-findUserByUsername) and [`Accounts.findUserByEmail`](/api/accounts#Accounts-findUserByEmail) methods provided by Meteor. This will run a query for you that is case-insensitive, so you will always find the user you are looking for. +### Security configuration + +`Accounts.config()` exposes several options that harden your login system. Call it once from server-side startup code. + +**Prevent user enumeration.** When enabled (the default in Meteor 3), "user not found" and "incorrect password" return the same error message to the caller, making it impossible for an attacker to discover which email addresses are registered: + +```js +Accounts.config({ ambiguousErrorMessages: true }); // default: true +``` + +**Block client-side account creation.** Ensure new accounts can only be created server-side (e.g. through a trusted Meteor Method), preventing unvetted signups from the browser console: + +```js +Accounts.config({ forbidClientAccountCreation: true }); +``` + +**Restrict signups by email domain.** Accept a string, an array of strings, or a function: + +```js +// single domain +Accounts.config({ restrictCreationByEmailDomain: "mycompany.com" }); + +// multiple domains +Accounts.config({ + restrictCreationByEmailDomain: ["mycompany.com", "contractor.io"], +}); + +// custom logic +Accounts.config({ + restrictCreationByEmailDomain: (email) => email.endsWith(".edu"), +}); +``` + +**Credential storage.** By default, login tokens are stored in `localStorage` and survive across browser sessions. Set `clientStorage` to `'session'` to clear credentials when the browser tab is closed: + +```js +Accounts.config({ clientStorage: "session" }); // 'local' (default) or 'session' +``` + +### Password hashing + +Meteor uses **bcrypt** to hash passwords by default. You can tune the work factor: + +```js +Accounts.config({ bcryptRounds: 12 }); // default: 10 +``` + +Meteor 3.x also supports **Argon2**, which is recommended by [OWASP](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) for new applications: + +```js +// server-side startup code +Accounts.config({ + argon2Enabled: true, + argon2Type: "argon2id", // 'argon2i' | 'argon2d' | 'argon2id' (default) + argon2TimeCost: 2, // iterations (default: 2) + argon2MemoryCost: 19456, // memory in KiB β€” 19 MB (default) + argon2Parallelism: 1, // threads (default: 1) +}); +``` + +Enabling Argon2 does not break existing users. Existing bcrypt hashes continue to work and are transparently re-hashed to Argon2 the next time each user logs in. + +### Token lifetime configuration + +You can control how long session and email tokens remain valid: + +```js +Accounts.config({ + loginExpirationInDays: 90, // session token lifetime (default: 90; set to null to never expire) + passwordResetTokenExpirationInDays: 3, // password reset link lifetime (default: 3 days) + passwordEnrollTokenExpirationInDays: 30, // account enrollment link lifetime (default: 30 days) +}); +``` + ### Email flows When you have a login system for your app based on user emails, that opens up the possibility for email-based account flows. The common thing between all of these workflows is that they involve sending a unique link to the user's email address, which does something special when it is clicked. Let's look at some common examples that Meteor's `accounts-password` package supports out of the box: @@ -116,38 +199,44 @@ When you have a login system for your app based on user emails, that opens up th `accounts-password` comes with handy functions that you can call from the server to send an email: -1. [`Accounts.sendResetPasswordEmail`](/api/accounts#Accounts-sendResetPasswordEmail) -2. [`Accounts.sendEnrollmentEmail`](/api/accounts#Accounts-sendEnrollmentEmail) -3. [`Accounts.sendVerificationEmail`](/api/accounts#Accounts-sendVerificationEmail) +1. [`Accounts.sendResetPasswordEmail(userId, email?, extraTokenData?, extraParams?)`](/api/accounts#Accounts-sendResetPasswordEmail) +2. [`Accounts.sendEnrollmentEmail(userId, email?, extraTokenData?, extraParams?)`](/api/accounts#Accounts-sendEnrollmentEmail) +3. [`Accounts.sendVerificationEmail(userId, email?, extraTokenData?, extraParams?)`](/api/accounts#Accounts-sendVerificationEmail) -The email is generated using the email templates from [`Accounts.emailTemplates`](/api/accounts#Accounts-emailTemplates), and include links generated with `Accounts.urls`. +The optional `extraTokenData` object is merged into the token stored in the database and is available inside email templates. The optional `extraParams` object is appended to the generated URL as query parameters. -#### Identifying when the link is clicked +If you need to generate a token without sending an email (for example, to build a custom mailer), use the lower-level helpers: -When the user receives the email and clicks the link inside, their web browser will take them to your app. Now, you need to be able to identify these special links and act appropriately. If you haven't customized the link URL, then you can use some built-in callbacks to identify when the app is in the middle of an email flow: +```js +// Generate a password reset token (server) +const { token } = Accounts.generateResetToken(userId, email, "resetPassword"); -1. [`Accounts.onResetPasswordLink`](/api/accounts#Accounts-onResetPasswordLink) -2. [`Accounts.onEnrollmentLink`](/api/accounts#Accounts-onEnrollmentLink) -3. [`Accounts.onEmailVerificationLink`](/api/accounts#Accounts-onEmailVerificationLink) +// Generate an email verification token (server) +const { token } = Accounts.generateVerificationToken(userId, email); +``` -Here's how you would use one of these functions: +The email is generated using the email templates from [`Accounts.emailTemplates`](/api/accounts#Accounts-emailTemplates), and includes links generated with `Accounts.urls`. + +#### Handling the link in your app + +When the user clicks the link in their email, their browser navigates to your app with the token embedded in the URL. Register a client-side callback to detect each flow and render the appropriate UI β€” there is one for each link type: `Accounts.onResetPasswordLink`, `Accounts.onEnrollmentLink`, and `Accounts.onEmailVerificationLink`. Here's how you would implement the password reset flow: ```js Accounts.onResetPasswordLink(async (token, done) => { // Display the password reset UI, get the new password... try { - await Accounts.resetPasswordAsync(token, newPassword); + await Accounts.resetPassword(token, newPassword); // Resume normal operation done(); } catch (err) { // Display error - console.error('Password reset failed:', err); + console.error("Password reset failed:", err); } }); ``` -If you want a different URL for your reset password page, you need to customize it using the `Accounts.urls` option: +If you want a different URL for your reset password page, you need to customize it using the `Accounts.urls` option. URL generators can also be `async` or return a `Promise`: ```js Accounts.urls.resetPassword = (token) => { @@ -159,10 +248,10 @@ If you have customized the URL, you will need to add a new route to your router #### Completing the process -When the user submits the form, you need to call the appropriate function to commit their change to the database: +When the user submits the form, you need to call the appropriate function to commit their change to the database. Both functions return a `Promise`: -1. [`Accounts.resetPasswordAsync`](/api/accounts#Accounts-resetPassword) - this one should be used both for resetting the password, and enrolling a new user; it accepts both kinds of tokens. -2. [`Accounts.verifyEmailAsync`](/api/accounts#Accounts-verifyEmail) +1. [`Accounts.resetPassword(token, newPassword)`](/api/accounts#Accounts-resetPassword) β€” use this both for resetting the password and enrolling a new user; it accepts both kinds of tokens. Logs in the user after a successful reset (unless 2FA is enabled β€” see [Two-Factor Authentication](#two-factor-authentication-accounts-2fa)). +2. [`Accounts.verifyEmail(token)`](/api/accounts#Accounts-verifyEmail) β€” logs in the user after a successful verification (unless 2FA is enabled). After you have called one of the two functions above or the user has cancelled the process, call the `done` function you got in the link callback. @@ -175,7 +264,7 @@ Accounts.emailTemplates.siteName = "Meteor Guide Todos Example"; Accounts.emailTemplates.from = "Meteor Todos Accounts "; Accounts.emailTemplates.resetPassword = { - subject(user) { + subject(user, url) { return "Reset your password on Meteor Todos"; }, text(user, url) { @@ -190,7 +279,7 @@ The Meteor Todos team html(user, url) { // This is where HTML email content would go. // See the section about html emails below. - } + }, }; ``` @@ -198,34 +287,192 @@ The Meteor Todos team If you've ever needed to deal with sending pretty HTML emails from an app, you know that it can quickly become a nightmare. Compatibility of popular email clients with basic HTML features like CSS is notoriously spotty. Start with a [responsive email template](https://github.com/leemunroe/responsive-html-email-template) or [framework](https://get.foundation/emails), and then use a tool to convert your email content into something that is compatible with all email clients. +## Passwordless login + +The `accounts-passwordless` package provides a one-time token (magic link) login experience β€” no password required. + +```bash +meteor add accounts-passwordless +``` + +### Requesting a login token + +On the client, call `Accounts.requestLoginTokenForUser` to send a one-time token to the user's email address: + +```js +// Client +await Accounts.requestLoginTokenForUser({ + selector: { email: "ada@lovelace.com" }, + // options.userCreationDisabled: true prevents creating a new account + // if no existing user matches the selector + options: {}, +}); +``` + +If no account exists for the given selector and `userCreationDisabled` is not set, you can pass `userData` to create the account on the fly: + +```js +await Accounts.requestLoginTokenForUser({ + selector: { email: "ada@lovelace.com" }, + userData: { email: "ada@lovelace.com", profile: { name: "Ada Lovelace" } }, +}); +``` + +### Logging in with the token + +When the user clicks the link in their email (or copies the token), call: + +```js +// Client +await Meteor.passwordlessLoginWithToken({ email: "ada@lovelace.com" }, token); +``` + +If the user has [Two-Factor Authentication](#two-factor-authentication-accounts-2fa) enabled, use the 2FA variant instead: + +```js +await Meteor.passwordlessLoginWithTokenAnd2faCode( + { email: "ada@lovelace.com" }, + token, + totpCode +); +``` + +### Automatic URL-based login + +Add `Accounts.autoLoginWithToken()` to your client startup code to detect when the URL contains a login token (e.g. from an email link) and log the user in automatically: + +```js +// client-side startup +Accounts.autoLoginWithToken(); +``` + +### Customizing the email + +Customize the token email through `Accounts.emailTemplates.sendLoginToken`: + +```js +Accounts.emailTemplates.sendLoginToken = { + subject(user) { + return "Your login link"; + }, + text(user, url) { + return `Click the link below to log in:\n\n${url}\n\nThis link expires in 15 minutes.`; + }, +}; +``` + +## Two-Factor Authentication + +The `accounts-2fa` package adds Time-based One-Time Password (TOTP) two-factor authentication, compatible with any standard authenticator app (Google Authenticator, Authy, etc.). + +```bash +meteor add accounts-2fa +``` + +### Enabling 2FA for a user + +The setup flow happens on the client: + +```js +// Step 1: generate a QR code and display it to the user +const { svg, secret, uri } = await new Promise((resolve, reject) => + Accounts.generate2faActivationQrCode("My App", (err, result) => { + if (err) reject(err); + else resolve(result); + }) +); +// Render `svg` in your UI so the user can scan it with their authenticator app + +// Step 2: once the user has scanned the QR code and sees the first code, confirm it +await new Promise((resolve, reject) => + Accounts.enableUser2fa(totpCode, (err) => { + if (err) reject(err); + else resolve(); + }) +); +``` + +### Disabling 2FA and checking status + +```js +// Check if the current user has 2FA enabled +const enabled = await new Promise((resolve, reject) => + Accounts.has2faEnabled((err, result) => { + if (err) reject(err); + else resolve(result); + }) +); + +// Disable 2FA for the current user +await new Promise((resolve, reject) => + Accounts.disableUser2fa((err) => { + if (err) reject(err); + else resolve(); + }) +); +``` + +### Logging in with 2FA + +When a user has 2FA enabled, the standard `Meteor.loginWithPassword` call will fail with an error prompting for a code. Use the dedicated method instead: + +```js +try { + await Meteor.loginWithPasswordAnd2faCode( + "ada@lovelace.com", + "mypassword", + totpCode + ); +} catch (err) { + console.error("Login failed:", err); +} +``` + +### Effect on password reset and email verification + +When 2FA is enabled, completing a password reset (`Accounts.resetPassword`) or email verification (`Accounts.verifyEmail`) will **not** automatically log the user in. The user must perform a full login (including the 2FA step) manually afterward. + +### Configuration + +```js +Accounts.config({ + loginTokenExpirationHours: 1, // how long a TOTP window stays valid (default: 1 hour) + tokenSequenceLength: 6, // TOTP code length (default: 6) +}); +``` + ## OAuth login Meteor supports popular login providers through OAuth out of the box. -### Facebook, Google, and more +### Adding an OAuth provider -Here's a complete list of login providers for which Meteor actively maintains core packages: +Meteor maintains packages for popular login providers. Add one or more to your app: -1. Facebook with `accounts-facebook` -2. Google with `accounts-google` -3. GitHub with `accounts-github` -4. Twitter with `accounts-twitter` -5. Meetup with `accounts-meetup` -6. Meteor Developer Accounts with `accounts-meteor-developer` +```bash +meteor add accounts-facebook # Facebook +meteor add accounts-google # Google +meteor add accounts-github # GitHub +meteor add accounts-twitter # Twitter +meteor add accounts-meetup # Meetup +meteor add accounts-meteor-developer # Meteor Developer Accounts +``` -### Logging in +Each package adds a `Meteor.loginWith` function and registers the service in the OAuth configuration UI. -If you are using an off-the-shelf login UI like `accounts-ui`, you don't need to write any code after adding the relevant package. If you are building a login experience from scratch, you can log in programmatically using the [`Meteor.loginWith`](/api/accounts#Meteor-loginWithExternalService) function: +### Logging in programmatically + +You can log in with any configured OAuth provider using the `Meteor.loginWith` function: ```js try { - await Meteor.loginWithFacebookAsync({ - requestPermissions: ['user_friends', 'public_profile', 'email'] + await Meteor.loginWithFacebook({ + requestPermissions: ["user_friends", "public_profile", "email"], }); // successful login! } catch (err) { // handle error - console.error('Login failed:', err); + console.error("Login failed:", err); } ``` @@ -234,9 +481,43 @@ try { There are a few points to know about configuring OAuth login: 1. **Client ID and secret.** It's best to keep your OAuth secret keys outside of your source code, and pass them in through Meteor.settings. Read how in the [Security article](/tutorials/security/security#api-keys). -2. **Redirect URL.** On the OAuth provider's side, you'll need to specify a *redirect URL*. The URL will look like: `https://www.example.com/_oauth/facebook`. Replace `facebook` with the name of the service you are using. Note that you will need to configure two URLs - one for your production app, and one for your development environment, where the URL might be something like `http://localhost:3000/_oauth/facebook`. +2. **Redirect URL.** On the OAuth provider's side, you'll need to specify a _redirect URL_. The URL will look like: `https://www.example.com/_oauth/facebook`. Replace `facebook` with the name of the service you are using. Note that you will need to configure two URLs - one for your production app, and one for your development environment, where the URL might be something like `http://localhost:3000/_oauth/facebook`. 3. **Permissions.** Each login service provider should have documentation about which permissions are available. If you want additional permissions to the user's data when they log in, pass some of these strings in the `requestPermissions` option. +### Server-side hooks for OAuth + +You can customize how OAuth accounts are created and updated on the server using these hooks. Each can only be registered once: + +```js +// Called before processing an external login. Return false to block the login. +Accounts.beforeExternalLogin((serviceName, serviceData, user) => { + // e.g. only allow logins from a specific GitHub org + if (serviceName === "github" && !serviceData.orgs?.includes("my-org")) { + return false; + } + return true; +}); + +// Provide additional lookup logic to find an existing user for an external login. +// Useful for linking accounts when the external service email matches an existing user. +Accounts.setAdditionalFindUserOnExternalLogin( + ({ serviceName, serviceData }) => { + if (serviceData.email) { + return Accounts.findUserByEmail(serviceData.email); + } + } +); + +// Called on every external login to update the user document. +// Return a modified user object to apply changes. +Accounts.onExternalLogin((options, user) => { + // Merge the latest profile data from the OAuth provider + user.profile = user.profile || {}; + user.profile.name = options.serviceData.name; + return user; +}); +``` + ### Calling service API for more data If your app supports or even requires login with an external service such as Facebook, it's natural to also want to use that service's API to request additional data about that user. @@ -273,39 +554,44 @@ On the server, each connection has a different logged in user, so there is no gl ```js // Accessing this.userId inside a publication -Meteor.publish('lists.private', function() { +Meteor.publish("lists.private", function () { if (!this.userId) { return this.ready(); } - return Lists.find({ - userId: this.userId - }, { - fields: Lists.publicFields - }); + return Lists.find( + { + userId: this.userId, + }, + { + fields: Lists.publicFields, + } + ); }); ``` ```js // Accessing this.userId inside a Method Meteor.methods({ - async 'todos.updateText'({ todoId, newText }) { + async "todos.updateText"({ todoId, newText }) { new SimpleSchema({ todoId: { type: String }, - newText: { type: String } + newText: { type: String }, }).validate({ todoId, newText }); const todo = await Todos.findOneAsync(todoId); if (!todo.editableBy(this.userId)) { - throw new Meteor.Error('todos.updateText.unauthorized', - 'Cannot edit todos in a private list that is not yours'); + throw new Meteor.Error( + "todos.updateText.unauthorized", + "Cannot edit todos in a private list that is not yours" + ); } await Todos.updateAsync(todoId, { - $set: { text: newText } + $set: { text: newText }, }); - } + }, }); ``` @@ -381,17 +667,17 @@ The best way to store your custom data onto the `Meteor.users` collection is to ```js // Using address schema from schema.org const newMailingAddress = { - addressCountry: 'US', - addressLocality: 'Seattle', - addressRegion: 'WA', - postalCode: '98052', - streetAddress: "20341 Whitworth Institute 405 N. Whitworth" + addressCountry: "US", + addressLocality: "Seattle", + addressRegion: "WA", + postalCode: "98052", + streetAddress: "20341 Whitworth Institute 405 N. Whitworth", }; await Meteor.users.updateAsync(userId, { $set: { - mailingAddress: newMailingAddress - } + mailingAddress: newMailingAddress, + }, }); ``` @@ -405,7 +691,7 @@ Sometimes, you want to set a field when the user first creates their account. Yo // Generate user initials after Facebook login Accounts.onCreateUser((options, user) => { if (!user.services.facebook) { - throw new Error('Expected login with Facebook only.'); + throw new Error("Expected login with Facebook only."); } const { first_name, last_name } = user.services.facebook; @@ -424,7 +710,7 @@ Accounts.onCreateUser((options, user) => { Note that the `user` object provided doesn't have an `_id` field yet. If you need to do something with the new user's ID inside this function, you can generate the ID yourself: ```js -import { Random } from 'meteor/random'; +import { Random } from "meteor/random"; // Generate a todo list for each new user Accounts.onCreateUser(async (options, user) => { @@ -450,7 +736,9 @@ Rather than dealing with the specifics of this field, it can be helpful to ignor ```js // Deny all client-side updates to user documents Meteor.users.deny({ - update() { return true; } + update() { + return true; + }, }); ``` @@ -459,21 +747,21 @@ Meteor.users.deny({ If you want to access the custom data you've added to the `Meteor.users` collection in your UI, you'll need to publish it to the client. The most important thing to keep in mind is that user documents contain private data about your usersβ€”hashed passwords and access keys for external APIs. This means it's critically important to filter the fields of the user document that you send to any client. ```js -Meteor.publish('Meteor.users.initials', function ({ userIds }) { +Meteor.publish("Meteor.users.initials", function ({ userIds }) { // Validate the arguments to be what we expect new SimpleSchema({ userIds: { type: Array }, - 'userIds.$': { type: String } + "userIds.$": { type: String }, }).validate({ userIds }); // Select only the users that match the array of IDs passed in const selector = { - _id: { $in: userIds } + _id: { $in: userIds }, }; // Only return one field, `initials` const options = { - fields: { initials: 1 } + fields: { initials: 1 }, }; return Meteor.users.find(selector, options); @@ -492,10 +780,14 @@ const user = await Meteor.userAsync({ fields: { "profile.name": 1 } }); const name = user?.profile?.name; // check if an email exists without fetching their entire document: -const userExists = !!await Accounts.findUserByEmail(email, { fields: { _id: 1 } }); +const userExists = !!(await Accounts.findUserByEmail(email, { + fields: { _id: 1 }, +})); // get the user id from a userName: -const user = await Accounts.findUserByUsername(userName, { fields: { _id: 1 } }); +const user = await Accounts.findUserByUsername(userName, { + fields: { _id: 1 }, +}); const userId = user?._id; ``` @@ -509,7 +801,7 @@ Accounts.config({ createdAt: 1, profile: 1, services: 1, - } + }, }); ``` @@ -521,21 +813,27 @@ Accounts.config({ defaultFieldSelector: { myBigArray: 0 } }); ## Roles and permissions -One of the main reasons you might want to add a login system to your app is to have permissions for your data. For example, if you were running a forum, you would want administrators or moderators to be able to delete any post, but normal users can only delete their own. This uncovers two different types of permissions: +Once users are logged in, you'll often want to control what each user can do. This uncovers two different types of permissions: 1. Role-based permissions 2. Per-document permissions -### alanning:roles +### roles -The most popular package for role-based permissions in Meteor is [`alanning:roles`](https://atmospherejs.com/alanning/roles). For example, here is how you would make a user into an administrator, or a moderator: +Meteor ships a core [`roles`](/packages/roles) package for role-based permissions. Add it to your app: + +```bash +meteor add roles +``` + +Here is how you would make a user into an administrator, or a moderator: ```js // Give Alice the 'admin' role -await Roles.addUsersToRolesAsync(aliceUserId, 'admin', Roles.GLOBAL_GROUP); +await Roles.addUsersToRolesAsync(aliceUserId, "admin", Roles.GLOBAL_GROUP); // Give Bob the 'moderator' role for a particular category -await Roles.addUsersToRolesAsync(bobsUserId, 'moderator', categoryId); +await Roles.addUsersToRolesAsync(bobsUserId, "moderator", categoryId); ``` Now, let's say you wanted to check if someone was allowed to delete a particular forum post: @@ -545,21 +843,21 @@ const forumPost = await Posts.findOneAsync(postId); const canDelete = await Roles.userIsInRoleAsync( userId, - ['admin', 'moderator'], + ["admin", "moderator"], forumPost.categoryId ); if (!canDelete) { - throw new Meteor.Error('unauthorized', - 'Only admins and moderators can delete posts.'); + throw new Meteor.Error( + "unauthorized", + "Only admins and moderators can delete posts." + ); } await Posts.removeAsync(postId); ``` -Note that we can check for multiple roles at once, and if someone has a role in the `GLOBAL_GROUP`, they are considered as having that role in every group. - -Read more in the [`alanning:roles` package documentation](https://atmospherejs.com/alanning/roles). +Note that you can check for multiple roles at once, and if someone has a role in `GLOBAL_GROUP`, they are considered as having that role in every group. ### Per-document permissions @@ -572,7 +870,7 @@ Lists.helpers({ return false; } return this.userId === userId; - } + }, }); ``` @@ -582,8 +880,10 @@ Now, we can call this simple function to determine if a particular user is allow const list = await Lists.findOneAsync(listId); if (!list.editableBy(userId)) { - throw new Meteor.Error('unauthorized', - 'Only list owners can edit private lists.'); + throw new Meteor.Error( + "unauthorized", + "Only list owners can edit private lists." + ); } ``` @@ -592,12 +892,15 @@ Learn more about how to use collection helpers in the [Collections article](/tut ## Best practices summary 1. **Use accounts-password** for email/password login and add OAuth packages as needed. -2. **Validate new users** with `Accounts.validateNewUser` to ensure required fields. -3. **Use case-insensitive queries** with `Accounts.findUserByEmail` and `Accounts.findUserByUsername`. -4. **Customize email templates** using `Accounts.emailTemplates` for professional communications. -5. **Never use the profile field** for sensitive dataβ€”deny client-side writes to user documents. -6. **Add custom data to top-level fields** on user documents, not nested in profile. -7. **Always filter fields** when publishing user data to clients. -8. **Use alanning:roles** for role-based access control. -9. **Use collection helpers** for per-document permissions. -10. **Configure defaultFieldSelector** to optimize user document fetching. +2. **Validate new users** with `Accounts.validateNewUser` to ensure required fields are present. +3. **Use `Accounts.createUserAsync`** (or `Accounts.createUserVerifyingEmail`) instead of the callback-based `createUser`. +4. **Use case-insensitive queries** with `Accounts.findUserByEmail` and `Accounts.findUserByUsername` β€” never query `Meteor.users` directly by email or username. +5. **Enable `ambiguousErrorMessages: true`** (the default) to prevent user enumeration attacks. +6. **Customize email templates** using `Accounts.emailTemplates` for professional-looking communications. +7. **Never use the profile field** for sensitive data β€” deny client-side writes with `Meteor.users.deny({ update() { return true; } })`. +8. **Add custom data to top-level fields** on user documents, not nested inside `profile`. +9. **Always filter fields** when publishing user data to clients β€” never expose password hashes or access tokens. +10. **Consider Argon2** for new applications by enabling `argon2Enabled: true` in `Accounts.config()`. +11. **Add 2FA** with `accounts-2fa` for applications with elevated security requirements. +12. **Use `accounts-passwordless`** to offer a frictionless, password-free login experience. +13. **Configure `defaultFieldSelector`** to avoid loading large user documents on every login. From 0044d684f4049c2fb383c86a92f7c976f69ade33 Mon Sep 17 00:00:00 2001 From: Frederico Maia Date: Mon, 30 Mar 2026 21:13:25 -0300 Subject: [PATCH 35/44] Include alt text for tutorial logos, improving accessibility. --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 6cdbf5a9c4..d46cffc3ab 100644 --- a/README.md +++ b/README.md @@ -53,12 +53,12 @@ Use the same code whether you’re developing for web, iOS, Android, or desktop How about trying a tutorial to get started with your favorite technology? -| [ React](https://docs.meteor.com/tutorials/react/) | +| [React logo React](https://docs.meteor.com/tutorials/react/) | | - | -| [ Blaze](https://docs.meteor.com/tutorials/blaze/) | -| [ Vue](https://docs.meteor.com/tutorials/vue/meteorjs3-vue3.html) | -| [ Svelte](https://docs.meteor.com/tutorials/svelte/) | -| [ Solid](https://docs.meteor.com/tutorials/solid/) | +| [Blaze logo Blaze](https://docs.meteor.com/tutorials/blaze/) | +| [Vue logo Vue](https://docs.meteor.com/tutorials/vue/meteorjs3-vue3.html) | +| [Svelte logo Svelte](https://docs.meteor.com/tutorials/svelte/) | +| [Solid logo Solid](https://docs.meteor.com/tutorials/solid/) | # πŸš€ Quick Start From b587a33e1bf503f12f14b6e7877bf6440786886e Mon Sep 17 00:00:00 2001 From: Gabriel Grubba Date: Mon, 30 Mar 2026 21:09:35 -0300 Subject: [PATCH 36/44] DOCS: revamp and update accounts tutorial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In this PR: Removed - `accounts-ui`section - `alanning:roles` references β€” replaced with the Meteor core roles package Updated methods to be using the correct name(*Async for example) And added sections about Passwordless login, 2FA and Security configuration From e5423cbca392db92152534e4b68aee98f624d4a7 Mon Sep 17 00:00:00 2001 From: Gabriel Grubba Date: Mon, 30 Mar 2026 21:11:41 -0300 Subject: [PATCH 37/44] Reapply "DOCS: revamp and update accounts tutorial" This reverts commit c6a9c42cfe61424a7b6a23dad8462be57720f3f2. --- v3-docs/docs/tutorials/accounts/accounts.md | 565 +++++++++++++++----- 1 file changed, 434 insertions(+), 131 deletions(-) diff --git a/v3-docs/docs/tutorials/accounts/accounts.md b/v3-docs/docs/tutorials/accounts/accounts.md index 8d5d3154c3..05d5329b07 100644 --- a/v3-docs/docs/tutorials/accounts/accounts.md +++ b/v3-docs/docs/tutorials/accounts/accounts.md @@ -3,11 +3,12 @@ After reading this article, you'll know: 1. What features in core Meteor enable user accounts -2. How to use accounts-ui for a quick prototype -3. How to build a fully-featured password login experience -4. How to enable login through OAuth providers like Facebook -5. How to add custom data to Meteor's users collection -6. How to manage user roles and permissions +2. How to build a fully-featured password login experience +3. How to set up passwordless login +4. How to add two-factor authentication (2FA) +5. How to enable login through OAuth providers like Facebook +6. How to add custom data to Meteor's users collection +7. How to protect your data with per-document permissions ## Features in core Meteor @@ -23,41 +24,13 @@ This built-in feature means that you always get `this.userId` inside Methods and This package is the core of Meteor's developer-facing user accounts functionality. This includes: -1. A users collection with a standard schema, accessed through [`Meteor.users`](/api/accounts#Meteor-users), and the client-side singletons [`Meteor.userId()`](/api/accounts#Meteor-userId) and [`Meteor.user()`](/api/accounts#Meteor-user), which represent the login state on the client. -2. A variety of helpful other generic methods to keep track of login state, log out, validate users, etc. Visit the [Accounts section of the docs](/api/accounts) to find a complete list. -3. An API for registering new login handlers, which is used by all of the other accounts packages to integrate with the accounts system. +1. A users collection with a standard schema, accessed through [`Meteor.users`](/api/accounts#Meteor-users), and the client-side singletons [`Meteor.userId()`](/api/accounts#Meteor-userId), [`Meteor.user()`](/api/accounts#Meteor-user), and the async [`Meteor.userAsync()`](/api/accounts#Meteor-userAsync), which represent the login state on the client. +2. Reactive helpers [`Accounts.loggingIn()`](/api/accounts#Accounts-loggingIn) and [`Accounts.loggingOut()`](/api/accounts#Accounts-loggingOut) to track in-progress login/logout state. +3. A variety of helpful other generic methods to keep track of login state, log out, validate users, etc. Visit the [Accounts section of the docs](/api/accounts) to find a complete list. +4. An API for registering new login handlers, which is used by all of the other accounts packages to integrate with the accounts system. Usually, you don't need to include `accounts-base` yourself since it's added for you if you use `accounts-password` or similar, but it's good to be aware of what is what. -## Fast prototyping with accounts-ui - -Often, a complicated accounts system is not the first thing you want to build when you're starting out with a new app, so it's useful to have something you can drop in quickly. This is where `accounts-ui` comes in - it's one line that you drop into your app to get an accounts system. To add it: - -```bash -meteor add accounts-ui -``` - -Then include it anywhere in a Blaze template: - -```html -{{> loginButtons}} -``` - -Then, make sure to pick a login provider; they will automatically integrate with `accounts-ui`: - -```bash -# pick one or more of the below -meteor add accounts-password -meteor add accounts-facebook -meteor add accounts-google -meteor add accounts-github -meteor add accounts-twitter -meteor add accounts-meetup -meteor add accounts-meteor-developer -``` - -Now open your app, follow the configuration steps, and you're good to go - if you've done one of our [Meteor tutorials](/tutorials/react/1.creating-the-app), you've already seen this in action. Of course, in a production application, you probably want a more custom user interface and some logic to have a more tailored UX, but that's why we have the rest of these tutorials. - ## Password login Meteor comes with a secure and fully-featured password login system out of the box. To use it, add the package: @@ -66,11 +39,9 @@ Meteor comes with a secure and fully-featured password login system out of the b meteor add accounts-password ``` -To see what options are available to you, read the complete description of the [`accounts-password` API in the Meteor docs](/api/accounts). - ### Requiring username or email -By default, the `Accounts.createUser` function provided by `accounts-password` allows you to create an account with a username, email, or both. Most apps expect a specific combination of the two, so you will certainly want to validate the new user creation: +By default, the `Accounts.createUserAsync` function provided by `accounts-password` allows you to create an account with a username, email, or both. Most apps expect a specific combination of the two, so you will certainly want to validate the new user creation: ```js // Ensuring every user has an email address, should be in server-side code @@ -78,11 +49,11 @@ Accounts.validateNewUser((user) => { new SimpleSchema({ _id: { type: String }, emails: { type: Array }, - 'emails.$': { type: Object }, - 'emails.$.address': { type: String }, - 'emails.$.verified': { type: Boolean }, + "emails.$": { type: Object }, + "emails.$.address": { type: String }, + "emails.$.verified": { type: Boolean }, createdAt: { type: Date }, - services: { type: Object, blackbox: true } + services: { type: Object, blackbox: true }, }).validate(user); // Return true to allow user creation to proceed @@ -90,11 +61,49 @@ Accounts.validateNewUser((user) => { }); ``` -### Multiple emails +> When creating users programmatically, prefer the async variant: -Often, users might want to associate multiple email addresses with the same account. `accounts-password` addresses this case by storing the email addresses as an array in the user collection. There are some handy API methods to deal with [adding](/api/accounts#Accounts-addEmail), [removing](/api/accounts#Accounts-removeEmail), and [verifying](/api/accounts#Accounts-verifyEmail) emails. +```js +// Client or server +const userId = await Accounts.createUserAsync({ + username: "ada", + email: "ada@lovelace.com", + password: "secret", + profile: { name: "Ada Lovelace" }, +}); +``` -One useful thing to add for your app can be the concept of a "primary" email address. This way, if the user has added multiple emails, you know where to send confirmation emails and similar. +If you want to automatically send an email verification after account creation, use `Accounts.createUserVerifyingEmail` instead: + +```js +await Accounts.createUserVerifyingEmail({ + email: "ada@lovelace.com", + password: "secret", +}); +``` + +### Managing multiple email addresses + +Users can associate more than one email address with their account. Meteor stores them as an array in the user document, so you can add, remove, and verify each one independently. + +```js +// Add a new address for the user (server) +await Accounts.addEmailAsync(userId, "work@example.com"); + +// Remove an address (server) +Accounts.removeEmail(userId, "old@example.com"); + +// Send a verification email to a specific address (server) +Accounts.sendVerificationEmail(userId, "work@example.com"); +``` + +A common pattern is to record a "primary" email address β€” the one used for notifications and password resets β€” as a top-level field on the user document: + +```js +await Meteor.users.updateAsync(userId, { + $set: { primaryEmail: "work@example.com" }, +}); +``` ### Case sensitivity @@ -104,6 +113,80 @@ Meteor handles case sensitivity for email addresses and usernames. Since MongoDB Follow one rule: don't query the database by `username` or `email` directly. Instead, use the [`Accounts.findUserByUsername`](/api/accounts#Accounts-findUserByUsername) and [`Accounts.findUserByEmail`](/api/accounts#Accounts-findUserByEmail) methods provided by Meteor. This will run a query for you that is case-insensitive, so you will always find the user you are looking for. +### Security configuration + +`Accounts.config()` exposes several options that harden your login system. Call it once from server-side startup code. + +**Prevent user enumeration.** When enabled (the default in Meteor 3), "user not found" and "incorrect password" return the same error message to the caller, making it impossible for an attacker to discover which email addresses are registered: + +```js +Accounts.config({ ambiguousErrorMessages: true }); // default: true +``` + +**Block client-side account creation.** Ensure new accounts can only be created server-side (e.g. through a trusted Meteor Method), preventing unvetted signups from the browser console: + +```js +Accounts.config({ forbidClientAccountCreation: true }); +``` + +**Restrict signups by email domain.** Accept a string, an array of strings, or a function: + +```js +// single domain +Accounts.config({ restrictCreationByEmailDomain: "mycompany.com" }); + +// multiple domains +Accounts.config({ + restrictCreationByEmailDomain: ["mycompany.com", "contractor.io"], +}); + +// custom logic +Accounts.config({ + restrictCreationByEmailDomain: (email) => email.endsWith(".edu"), +}); +``` + +**Credential storage.** By default, login tokens are stored in `localStorage` and survive across browser sessions. Set `clientStorage` to `'session'` to clear credentials when the browser tab is closed: + +```js +Accounts.config({ clientStorage: "session" }); // 'local' (default) or 'session' +``` + +### Password hashing + +Meteor uses **bcrypt** to hash passwords by default. You can tune the work factor: + +```js +Accounts.config({ bcryptRounds: 12 }); // default: 10 +``` + +Meteor 3.x also supports **Argon2**, which is recommended by [OWASP](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) for new applications: + +```js +// server-side startup code +Accounts.config({ + argon2Enabled: true, + argon2Type: "argon2id", // 'argon2i' | 'argon2d' | 'argon2id' (default) + argon2TimeCost: 2, // iterations (default: 2) + argon2MemoryCost: 19456, // memory in KiB β€” 19 MB (default) + argon2Parallelism: 1, // threads (default: 1) +}); +``` + +Enabling Argon2 does not break existing users. Existing bcrypt hashes continue to work and are transparently re-hashed to Argon2 the next time each user logs in. + +### Token lifetime configuration + +You can control how long session and email tokens remain valid: + +```js +Accounts.config({ + loginExpirationInDays: 90, // session token lifetime (default: 90; set to null to never expire) + passwordResetTokenExpirationInDays: 3, // password reset link lifetime (default: 3 days) + passwordEnrollTokenExpirationInDays: 30, // account enrollment link lifetime (default: 30 days) +}); +``` + ### Email flows When you have a login system for your app based on user emails, that opens up the possibility for email-based account flows. The common thing between all of these workflows is that they involve sending a unique link to the user's email address, which does something special when it is clicked. Let's look at some common examples that Meteor's `accounts-password` package supports out of the box: @@ -116,38 +199,44 @@ When you have a login system for your app based on user emails, that opens up th `accounts-password` comes with handy functions that you can call from the server to send an email: -1. [`Accounts.sendResetPasswordEmail`](/api/accounts#Accounts-sendResetPasswordEmail) -2. [`Accounts.sendEnrollmentEmail`](/api/accounts#Accounts-sendEnrollmentEmail) -3. [`Accounts.sendVerificationEmail`](/api/accounts#Accounts-sendVerificationEmail) +1. [`Accounts.sendResetPasswordEmail(userId, email?, extraTokenData?, extraParams?)`](/api/accounts#Accounts-sendResetPasswordEmail) +2. [`Accounts.sendEnrollmentEmail(userId, email?, extraTokenData?, extraParams?)`](/api/accounts#Accounts-sendEnrollmentEmail) +3. [`Accounts.sendVerificationEmail(userId, email?, extraTokenData?, extraParams?)`](/api/accounts#Accounts-sendVerificationEmail) -The email is generated using the email templates from [`Accounts.emailTemplates`](/api/accounts#Accounts-emailTemplates), and include links generated with `Accounts.urls`. +The optional `extraTokenData` object is merged into the token stored in the database and is available inside email templates. The optional `extraParams` object is appended to the generated URL as query parameters. -#### Identifying when the link is clicked +If you need to generate a token without sending an email (for example, to build a custom mailer), use the lower-level helpers: -When the user receives the email and clicks the link inside, their web browser will take them to your app. Now, you need to be able to identify these special links and act appropriately. If you haven't customized the link URL, then you can use some built-in callbacks to identify when the app is in the middle of an email flow: +```js +// Generate a password reset token (server) +const { token } = Accounts.generateResetToken(userId, email, "resetPassword"); -1. [`Accounts.onResetPasswordLink`](/api/accounts#Accounts-onResetPasswordLink) -2. [`Accounts.onEnrollmentLink`](/api/accounts#Accounts-onEnrollmentLink) -3. [`Accounts.onEmailVerificationLink`](/api/accounts#Accounts-onEmailVerificationLink) +// Generate an email verification token (server) +const { token } = Accounts.generateVerificationToken(userId, email); +``` -Here's how you would use one of these functions: +The email is generated using the email templates from [`Accounts.emailTemplates`](/api/accounts#Accounts-emailTemplates), and includes links generated with `Accounts.urls`. + +#### Handling the link in your app + +When the user clicks the link in their email, their browser navigates to your app with the token embedded in the URL. Register a client-side callback to detect each flow and render the appropriate UI β€” there is one for each link type: `Accounts.onResetPasswordLink`, `Accounts.onEnrollmentLink`, and `Accounts.onEmailVerificationLink`. Here's how you would implement the password reset flow: ```js Accounts.onResetPasswordLink(async (token, done) => { // Display the password reset UI, get the new password... try { - await Accounts.resetPasswordAsync(token, newPassword); + await Accounts.resetPassword(token, newPassword); // Resume normal operation done(); } catch (err) { // Display error - console.error('Password reset failed:', err); + console.error("Password reset failed:", err); } }); ``` -If you want a different URL for your reset password page, you need to customize it using the `Accounts.urls` option: +If you want a different URL for your reset password page, you need to customize it using the `Accounts.urls` option. URL generators can also be `async` or return a `Promise`: ```js Accounts.urls.resetPassword = (token) => { @@ -159,10 +248,10 @@ If you have customized the URL, you will need to add a new route to your router #### Completing the process -When the user submits the form, you need to call the appropriate function to commit their change to the database: +When the user submits the form, you need to call the appropriate function to commit their change to the database. Both functions return a `Promise`: -1. [`Accounts.resetPasswordAsync`](/api/accounts#Accounts-resetPassword) - this one should be used both for resetting the password, and enrolling a new user; it accepts both kinds of tokens. -2. [`Accounts.verifyEmailAsync`](/api/accounts#Accounts-verifyEmail) +1. [`Accounts.resetPassword(token, newPassword)`](/api/accounts#Accounts-resetPassword) β€” use this both for resetting the password and enrolling a new user; it accepts both kinds of tokens. Logs in the user after a successful reset (unless 2FA is enabled β€” see [Two-Factor Authentication](#two-factor-authentication-accounts-2fa)). +2. [`Accounts.verifyEmail(token)`](/api/accounts#Accounts-verifyEmail) β€” logs in the user after a successful verification (unless 2FA is enabled). After you have called one of the two functions above or the user has cancelled the process, call the `done` function you got in the link callback. @@ -175,7 +264,7 @@ Accounts.emailTemplates.siteName = "Meteor Guide Todos Example"; Accounts.emailTemplates.from = "Meteor Todos Accounts "; Accounts.emailTemplates.resetPassword = { - subject(user) { + subject(user, url) { return "Reset your password on Meteor Todos"; }, text(user, url) { @@ -190,7 +279,7 @@ The Meteor Todos team html(user, url) { // This is where HTML email content would go. // See the section about html emails below. - } + }, }; ``` @@ -198,34 +287,192 @@ The Meteor Todos team If you've ever needed to deal with sending pretty HTML emails from an app, you know that it can quickly become a nightmare. Compatibility of popular email clients with basic HTML features like CSS is notoriously spotty. Start with a [responsive email template](https://github.com/leemunroe/responsive-html-email-template) or [framework](https://get.foundation/emails), and then use a tool to convert your email content into something that is compatible with all email clients. +## Passwordless login + +The `accounts-passwordless` package provides a one-time token (magic link) login experience β€” no password required. + +```bash +meteor add accounts-passwordless +``` + +### Requesting a login token + +On the client, call `Accounts.requestLoginTokenForUser` to send a one-time token to the user's email address: + +```js +// Client +await Accounts.requestLoginTokenForUser({ + selector: { email: "ada@lovelace.com" }, + // options.userCreationDisabled: true prevents creating a new account + // if no existing user matches the selector + options: {}, +}); +``` + +If no account exists for the given selector and `userCreationDisabled` is not set, you can pass `userData` to create the account on the fly: + +```js +await Accounts.requestLoginTokenForUser({ + selector: { email: "ada@lovelace.com" }, + userData: { email: "ada@lovelace.com", profile: { name: "Ada Lovelace" } }, +}); +``` + +### Logging in with the token + +When the user clicks the link in their email (or copies the token), call: + +```js +// Client +await Meteor.passwordlessLoginWithToken({ email: "ada@lovelace.com" }, token); +``` + +If the user has [Two-Factor Authentication](#two-factor-authentication-accounts-2fa) enabled, use the 2FA variant instead: + +```js +await Meteor.passwordlessLoginWithTokenAnd2faCode( + { email: "ada@lovelace.com" }, + token, + totpCode +); +``` + +### Automatic URL-based login + +Add `Accounts.autoLoginWithToken()` to your client startup code to detect when the URL contains a login token (e.g. from an email link) and log the user in automatically: + +```js +// client-side startup +Accounts.autoLoginWithToken(); +``` + +### Customizing the email + +Customize the token email through `Accounts.emailTemplates.sendLoginToken`: + +```js +Accounts.emailTemplates.sendLoginToken = { + subject(user) { + return "Your login link"; + }, + text(user, url) { + return `Click the link below to log in:\n\n${url}\n\nThis link expires in 15 minutes.`; + }, +}; +``` + +## Two-Factor Authentication + +The `accounts-2fa` package adds Time-based One-Time Password (TOTP) two-factor authentication, compatible with any standard authenticator app (Google Authenticator, Authy, etc.). + +```bash +meteor add accounts-2fa +``` + +### Enabling 2FA for a user + +The setup flow happens on the client: + +```js +// Step 1: generate a QR code and display it to the user +const { svg, secret, uri } = await new Promise((resolve, reject) => + Accounts.generate2faActivationQrCode("My App", (err, result) => { + if (err) reject(err); + else resolve(result); + }) +); +// Render `svg` in your UI so the user can scan it with their authenticator app + +// Step 2: once the user has scanned the QR code and sees the first code, confirm it +await new Promise((resolve, reject) => + Accounts.enableUser2fa(totpCode, (err) => { + if (err) reject(err); + else resolve(); + }) +); +``` + +### Disabling 2FA and checking status + +```js +// Check if the current user has 2FA enabled +const enabled = await new Promise((resolve, reject) => + Accounts.has2faEnabled((err, result) => { + if (err) reject(err); + else resolve(result); + }) +); + +// Disable 2FA for the current user +await new Promise((resolve, reject) => + Accounts.disableUser2fa((err) => { + if (err) reject(err); + else resolve(); + }) +); +``` + +### Logging in with 2FA + +When a user has 2FA enabled, the standard `Meteor.loginWithPassword` call will fail with an error prompting for a code. Use the dedicated method instead: + +```js +try { + await Meteor.loginWithPasswordAnd2faCode( + "ada@lovelace.com", + "mypassword", + totpCode + ); +} catch (err) { + console.error("Login failed:", err); +} +``` + +### Effect on password reset and email verification + +When 2FA is enabled, completing a password reset (`Accounts.resetPassword`) or email verification (`Accounts.verifyEmail`) will **not** automatically log the user in. The user must perform a full login (including the 2FA step) manually afterward. + +### Configuration + +```js +Accounts.config({ + loginTokenExpirationHours: 1, // how long a TOTP window stays valid (default: 1 hour) + tokenSequenceLength: 6, // TOTP code length (default: 6) +}); +``` + ## OAuth login Meteor supports popular login providers through OAuth out of the box. -### Facebook, Google, and more +### Adding an OAuth provider -Here's a complete list of login providers for which Meteor actively maintains core packages: +Meteor maintains packages for popular login providers. Add one or more to your app: -1. Facebook with `accounts-facebook` -2. Google with `accounts-google` -3. GitHub with `accounts-github` -4. Twitter with `accounts-twitter` -5. Meetup with `accounts-meetup` -6. Meteor Developer Accounts with `accounts-meteor-developer` +```bash +meteor add accounts-facebook # Facebook +meteor add accounts-google # Google +meteor add accounts-github # GitHub +meteor add accounts-twitter # Twitter +meteor add accounts-meetup # Meetup +meteor add accounts-meteor-developer # Meteor Developer Accounts +``` -### Logging in +Each package adds a `Meteor.loginWith` function and registers the service in the OAuth configuration UI. -If you are using an off-the-shelf login UI like `accounts-ui`, you don't need to write any code after adding the relevant package. If you are building a login experience from scratch, you can log in programmatically using the [`Meteor.loginWith`](/api/accounts#Meteor-loginWithExternalService) function: +### Logging in programmatically + +You can log in with any configured OAuth provider using the `Meteor.loginWith` function: ```js try { - await Meteor.loginWithFacebookAsync({ - requestPermissions: ['user_friends', 'public_profile', 'email'] + await Meteor.loginWithFacebook({ + requestPermissions: ["user_friends", "public_profile", "email"], }); // successful login! } catch (err) { // handle error - console.error('Login failed:', err); + console.error("Login failed:", err); } ``` @@ -234,9 +481,43 @@ try { There are a few points to know about configuring OAuth login: 1. **Client ID and secret.** It's best to keep your OAuth secret keys outside of your source code, and pass them in through Meteor.settings. Read how in the [Security article](/tutorials/security/security#api-keys). -2. **Redirect URL.** On the OAuth provider's side, you'll need to specify a *redirect URL*. The URL will look like: `https://www.example.com/_oauth/facebook`. Replace `facebook` with the name of the service you are using. Note that you will need to configure two URLs - one for your production app, and one for your development environment, where the URL might be something like `http://localhost:3000/_oauth/facebook`. +2. **Redirect URL.** On the OAuth provider's side, you'll need to specify a _redirect URL_. The URL will look like: `https://www.example.com/_oauth/facebook`. Replace `facebook` with the name of the service you are using. Note that you will need to configure two URLs - one for your production app, and one for your development environment, where the URL might be something like `http://localhost:3000/_oauth/facebook`. 3. **Permissions.** Each login service provider should have documentation about which permissions are available. If you want additional permissions to the user's data when they log in, pass some of these strings in the `requestPermissions` option. +### Server-side hooks for OAuth + +You can customize how OAuth accounts are created and updated on the server using these hooks. Each can only be registered once: + +```js +// Called before processing an external login. Return false to block the login. +Accounts.beforeExternalLogin((serviceName, serviceData, user) => { + // e.g. only allow logins from a specific GitHub org + if (serviceName === "github" && !serviceData.orgs?.includes("my-org")) { + return false; + } + return true; +}); + +// Provide additional lookup logic to find an existing user for an external login. +// Useful for linking accounts when the external service email matches an existing user. +Accounts.setAdditionalFindUserOnExternalLogin( + ({ serviceName, serviceData }) => { + if (serviceData.email) { + return Accounts.findUserByEmail(serviceData.email); + } + } +); + +// Called on every external login to update the user document. +// Return a modified user object to apply changes. +Accounts.onExternalLogin((options, user) => { + // Merge the latest profile data from the OAuth provider + user.profile = user.profile || {}; + user.profile.name = options.serviceData.name; + return user; +}); +``` + ### Calling service API for more data If your app supports or even requires login with an external service such as Facebook, it's natural to also want to use that service's API to request additional data about that user. @@ -273,39 +554,44 @@ On the server, each connection has a different logged in user, so there is no gl ```js // Accessing this.userId inside a publication -Meteor.publish('lists.private', function() { +Meteor.publish("lists.private", function () { if (!this.userId) { return this.ready(); } - return Lists.find({ - userId: this.userId - }, { - fields: Lists.publicFields - }); + return Lists.find( + { + userId: this.userId, + }, + { + fields: Lists.publicFields, + } + ); }); ``` ```js // Accessing this.userId inside a Method Meteor.methods({ - async 'todos.updateText'({ todoId, newText }) { + async "todos.updateText"({ todoId, newText }) { new SimpleSchema({ todoId: { type: String }, - newText: { type: String } + newText: { type: String }, }).validate({ todoId, newText }); const todo = await Todos.findOneAsync(todoId); if (!todo.editableBy(this.userId)) { - throw new Meteor.Error('todos.updateText.unauthorized', - 'Cannot edit todos in a private list that is not yours'); + throw new Meteor.Error( + "todos.updateText.unauthorized", + "Cannot edit todos in a private list that is not yours" + ); } await Todos.updateAsync(todoId, { - $set: { text: newText } + $set: { text: newText }, }); - } + }, }); ``` @@ -381,17 +667,17 @@ The best way to store your custom data onto the `Meteor.users` collection is to ```js // Using address schema from schema.org const newMailingAddress = { - addressCountry: 'US', - addressLocality: 'Seattle', - addressRegion: 'WA', - postalCode: '98052', - streetAddress: "20341 Whitworth Institute 405 N. Whitworth" + addressCountry: "US", + addressLocality: "Seattle", + addressRegion: "WA", + postalCode: "98052", + streetAddress: "20341 Whitworth Institute 405 N. Whitworth", }; await Meteor.users.updateAsync(userId, { $set: { - mailingAddress: newMailingAddress - } + mailingAddress: newMailingAddress, + }, }); ``` @@ -405,7 +691,7 @@ Sometimes, you want to set a field when the user first creates their account. Yo // Generate user initials after Facebook login Accounts.onCreateUser((options, user) => { if (!user.services.facebook) { - throw new Error('Expected login with Facebook only.'); + throw new Error("Expected login with Facebook only."); } const { first_name, last_name } = user.services.facebook; @@ -424,7 +710,7 @@ Accounts.onCreateUser((options, user) => { Note that the `user` object provided doesn't have an `_id` field yet. If you need to do something with the new user's ID inside this function, you can generate the ID yourself: ```js -import { Random } from 'meteor/random'; +import { Random } from "meteor/random"; // Generate a todo list for each new user Accounts.onCreateUser(async (options, user) => { @@ -450,7 +736,9 @@ Rather than dealing with the specifics of this field, it can be helpful to ignor ```js // Deny all client-side updates to user documents Meteor.users.deny({ - update() { return true; } + update() { + return true; + }, }); ``` @@ -459,21 +747,21 @@ Meteor.users.deny({ If you want to access the custom data you've added to the `Meteor.users` collection in your UI, you'll need to publish it to the client. The most important thing to keep in mind is that user documents contain private data about your usersβ€”hashed passwords and access keys for external APIs. This means it's critically important to filter the fields of the user document that you send to any client. ```js -Meteor.publish('Meteor.users.initials', function ({ userIds }) { +Meteor.publish("Meteor.users.initials", function ({ userIds }) { // Validate the arguments to be what we expect new SimpleSchema({ userIds: { type: Array }, - 'userIds.$': { type: String } + "userIds.$": { type: String }, }).validate({ userIds }); // Select only the users that match the array of IDs passed in const selector = { - _id: { $in: userIds } + _id: { $in: userIds }, }; // Only return one field, `initials` const options = { - fields: { initials: 1 } + fields: { initials: 1 }, }; return Meteor.users.find(selector, options); @@ -492,10 +780,14 @@ const user = await Meteor.userAsync({ fields: { "profile.name": 1 } }); const name = user?.profile?.name; // check if an email exists without fetching their entire document: -const userExists = !!await Accounts.findUserByEmail(email, { fields: { _id: 1 } }); +const userExists = !!(await Accounts.findUserByEmail(email, { + fields: { _id: 1 }, +})); // get the user id from a userName: -const user = await Accounts.findUserByUsername(userName, { fields: { _id: 1 } }); +const user = await Accounts.findUserByUsername(userName, { + fields: { _id: 1 }, +}); const userId = user?._id; ``` @@ -509,7 +801,7 @@ Accounts.config({ createdAt: 1, profile: 1, services: 1, - } + }, }); ``` @@ -521,21 +813,27 @@ Accounts.config({ defaultFieldSelector: { myBigArray: 0 } }); ## Roles and permissions -One of the main reasons you might want to add a login system to your app is to have permissions for your data. For example, if you were running a forum, you would want administrators or moderators to be able to delete any post, but normal users can only delete their own. This uncovers two different types of permissions: +Once users are logged in, you'll often want to control what each user can do. This uncovers two different types of permissions: 1. Role-based permissions 2. Per-document permissions -### alanning:roles +### roles -The most popular package for role-based permissions in Meteor is [`alanning:roles`](https://atmospherejs.com/alanning/roles). For example, here is how you would make a user into an administrator, or a moderator: +Meteor ships a core [`roles`](/packages/roles) package for role-based permissions. Add it to your app: + +```bash +meteor add roles +``` + +Here is how you would make a user into an administrator, or a moderator: ```js // Give Alice the 'admin' role -await Roles.addUsersToRolesAsync(aliceUserId, 'admin', Roles.GLOBAL_GROUP); +await Roles.addUsersToRolesAsync(aliceUserId, "admin", Roles.GLOBAL_GROUP); // Give Bob the 'moderator' role for a particular category -await Roles.addUsersToRolesAsync(bobsUserId, 'moderator', categoryId); +await Roles.addUsersToRolesAsync(bobsUserId, "moderator", categoryId); ``` Now, let's say you wanted to check if someone was allowed to delete a particular forum post: @@ -545,21 +843,21 @@ const forumPost = await Posts.findOneAsync(postId); const canDelete = await Roles.userIsInRoleAsync( userId, - ['admin', 'moderator'], + ["admin", "moderator"], forumPost.categoryId ); if (!canDelete) { - throw new Meteor.Error('unauthorized', - 'Only admins and moderators can delete posts.'); + throw new Meteor.Error( + "unauthorized", + "Only admins and moderators can delete posts." + ); } await Posts.removeAsync(postId); ``` -Note that we can check for multiple roles at once, and if someone has a role in the `GLOBAL_GROUP`, they are considered as having that role in every group. - -Read more in the [`alanning:roles` package documentation](https://atmospherejs.com/alanning/roles). +Note that you can check for multiple roles at once, and if someone has a role in `GLOBAL_GROUP`, they are considered as having that role in every group. ### Per-document permissions @@ -572,7 +870,7 @@ Lists.helpers({ return false; } return this.userId === userId; - } + }, }); ``` @@ -582,8 +880,10 @@ Now, we can call this simple function to determine if a particular user is allow const list = await Lists.findOneAsync(listId); if (!list.editableBy(userId)) { - throw new Meteor.Error('unauthorized', - 'Only list owners can edit private lists.'); + throw new Meteor.Error( + "unauthorized", + "Only list owners can edit private lists." + ); } ``` @@ -592,12 +892,15 @@ Learn more about how to use collection helpers in the [Collections article](/tut ## Best practices summary 1. **Use accounts-password** for email/password login and add OAuth packages as needed. -2. **Validate new users** with `Accounts.validateNewUser` to ensure required fields. -3. **Use case-insensitive queries** with `Accounts.findUserByEmail` and `Accounts.findUserByUsername`. -4. **Customize email templates** using `Accounts.emailTemplates` for professional communications. -5. **Never use the profile field** for sensitive dataβ€”deny client-side writes to user documents. -6. **Add custom data to top-level fields** on user documents, not nested in profile. -7. **Always filter fields** when publishing user data to clients. -8. **Use alanning:roles** for role-based access control. -9. **Use collection helpers** for per-document permissions. -10. **Configure defaultFieldSelector** to optimize user document fetching. +2. **Validate new users** with `Accounts.validateNewUser` to ensure required fields are present. +3. **Use `Accounts.createUserAsync`** (or `Accounts.createUserVerifyingEmail`) instead of the callback-based `createUser`. +4. **Use case-insensitive queries** with `Accounts.findUserByEmail` and `Accounts.findUserByUsername` β€” never query `Meteor.users` directly by email or username. +5. **Enable `ambiguousErrorMessages: true`** (the default) to prevent user enumeration attacks. +6. **Customize email templates** using `Accounts.emailTemplates` for professional-looking communications. +7. **Never use the profile field** for sensitive data β€” deny client-side writes with `Meteor.users.deny({ update() { return true; } })`. +8. **Add custom data to top-level fields** on user documents, not nested inside `profile`. +9. **Always filter fields** when publishing user data to clients β€” never expose password hashes or access tokens. +10. **Consider Argon2** for new applications by enabling `argon2Enabled: true` in `Accounts.config()`. +11. **Add 2FA** with `accounts-2fa` for applications with elevated security requirements. +12. **Use `accounts-passwordless`** to offer a frictionless, password-free login experience. +13. **Configure `defaultFieldSelector`** to avoid loading large user documents on every login. From dd6890a2cda47fce3aa2bc20da60772299eb6775 Mon Sep 17 00:00:00 2001 From: Gabriel Grubba Date: Mon, 30 Mar 2026 21:32:01 -0300 Subject: [PATCH 38/44] DOCS: check if methods are being correctly used --- v3-docs/docs/tutorials/accounts/accounts.md | 59 ++++++++++++++------- 1 file changed, 40 insertions(+), 19 deletions(-) diff --git a/v3-docs/docs/tutorials/accounts/accounts.md b/v3-docs/docs/tutorials/accounts/accounts.md index 05d5329b07..a23a373312 100644 --- a/v3-docs/docs/tutorials/accounts/accounts.md +++ b/v3-docs/docs/tutorials/accounts/accounts.md @@ -91,10 +91,10 @@ Users can associate more than one email address with their account. Meteor store await Accounts.addEmailAsync(userId, "work@example.com"); // Remove an address (server) -Accounts.removeEmail(userId, "old@example.com"); +await Accounts.removeEmail(userId, "old@example.com"); // Send a verification email to a specific address (server) -Accounts.sendVerificationEmail(userId, "work@example.com"); +await Accounts.sendVerificationEmail(userId, "work@example.com"); ``` A common pattern is to record a "primary" email address β€” the one used for notifications and password resets β€” as a top-level field on the user document: @@ -209,10 +209,14 @@ If you need to generate a token without sending an email (for example, to build ```js // Generate a password reset token (server) -const { token } = Accounts.generateResetToken(userId, email, "resetPassword"); +const { token } = await Accounts.generateResetToken( + userId, + email, + "resetPassword" +); // Generate an email verification token (server) -const { token } = Accounts.generateVerificationToken(userId, email); +const { token } = await Accounts.generateVerificationToken(userId, email); ``` The email is generated using the email templates from [`Accounts.emailTemplates`](/api/accounts#Accounts-emailTemplates), and includes links generated with `Accounts.urls`. @@ -226,7 +230,11 @@ Accounts.onResetPasswordLink(async (token, done) => { // Display the password reset UI, get the new password... try { - await Accounts.resetPassword(token, newPassword); + await new Promise((resolve, reject) => + Accounts.resetPassword(token, newPassword, (err) => + err ? reject(err) : resolve() + ) + ); // Resume normal operation done(); } catch (err) { @@ -248,10 +256,10 @@ If you have customized the URL, you will need to add a new route to your router #### Completing the process -When the user submits the form, you need to call the appropriate function to commit their change to the database. Both functions return a `Promise`: +When the user submits the form, you need to call the appropriate function to commit their change to the database. Both functions are callback-based; wrap them in a `Promise` to use with `async/await`: -1. [`Accounts.resetPassword(token, newPassword)`](/api/accounts#Accounts-resetPassword) β€” use this both for resetting the password and enrolling a new user; it accepts both kinds of tokens. Logs in the user after a successful reset (unless 2FA is enabled β€” see [Two-Factor Authentication](#two-factor-authentication-accounts-2fa)). -2. [`Accounts.verifyEmail(token)`](/api/accounts#Accounts-verifyEmail) β€” logs in the user after a successful verification (unless 2FA is enabled). +1. [`Accounts.resetPassword(token, newPassword, callback)`](/api/accounts#Accounts-resetPassword) β€” use this both for resetting the password and enrolling a new user; it accepts both kinds of tokens. Logs in the user after a successful reset (unless 2FA is enabled β€” see [Two-Factor Authentication](#two-factor-authentication-accounts-2fa)). +2. [`Accounts.verifyEmail(token, callback)`](/api/accounts#Accounts-verifyEmail) β€” logs in the user after a successful verification (unless 2FA is enabled). After you have called one of the two functions above or the user has cancelled the process, call the `done` function you got in the link callback. @@ -324,25 +332,35 @@ When the user clicks the link in their email (or copies the token), call: ```js // Client -await Meteor.passwordlessLoginWithToken({ email: "ada@lovelace.com" }, token); +await new Promise((resolve, reject) => + Meteor.passwordlessLoginWithToken( + { email: "ada@lovelace.com" }, + token, + (err) => (err ? reject(err) : resolve()) + ) +); ``` If the user has [Two-Factor Authentication](#two-factor-authentication-accounts-2fa) enabled, use the 2FA variant instead: ```js -await Meteor.passwordlessLoginWithTokenAnd2faCode( - { email: "ada@lovelace.com" }, - token, - totpCode +await new Promise((resolve, reject) => + Meteor.passwordlessLoginWithTokenAnd2faCode( + { email: "ada@lovelace.com" }, + token, + totpCode, + (err) => (err ? reject(err) : resolve()) + ) ); ``` ### Automatic URL-based login -Add `Accounts.autoLoginWithToken()` to your client startup code to detect when the URL contains a login token (e.g. from an email link) and log the user in automatically: +The `accounts-passwordless` package automatically detects when the URL contains a `loginToken` query parameter (e.g. from an email link) and logs the user in. This is handled by `Accounts.autoLoginWithToken()`, which the package calls internally on startup β€” **you do not need to call it yourself**. + +If you need to trigger the check manually (for example, after programmatically updating the URL), you can call it directly: ```js -// client-side startup Accounts.autoLoginWithToken(); ``` @@ -418,10 +436,13 @@ When a user has 2FA enabled, the standard `Meteor.loginWithPassword` call will f ```js try { - await Meteor.loginWithPasswordAnd2faCode( - "ada@lovelace.com", - "mypassword", - totpCode + await new Promise((resolve, reject) => + Meteor.loginWithPasswordAnd2faCode( + "ada@lovelace.com", + "mypassword", + totpCode, + (err) => (err ? reject(err) : resolve()) + ) ); } catch (err) { console.error("Login failed:", err); From 94a1e7cc95b074f0a11db0fbb9cf23bac15691f9 Mon Sep 17 00:00:00 2001 From: Frederico Maia Date: Mon, 30 Mar 2026 21:38:35 -0300 Subject: [PATCH 39/44] Refactor collections documentation to improve clarity and update examples. --- v3-docs/docs/api/collections.md | 68 +++++++++++++++++---------------- 1 file changed, 35 insertions(+), 33 deletions(-) diff --git a/v3-docs/docs/api/collections.md b/v3-docs/docs/api/collections.md index 70ce0492fd..d37fbb0e66 100644 --- a/v3-docs/docs/api/collections.md +++ b/v3-docs/docs/api/collections.md @@ -140,7 +140,7 @@ the value of the document's `_id` field (though it's OK to leave it out). // An animal class that takes a document in its constructor. class Animal { constructor(doc) { - _.extend(this, doc); + Object.assign(this, doc); } makeNoise() { @@ -354,17 +354,21 @@ Meteor.methods({ }); ``` -```js [client.js] +```jsx [client.jsx] // When the 'give points' button in the admin dashboard is pressed, give 5 // points to the current player. The new score will be immediately visible on // everyone's screens. -Template.adminDashboard.events({ - "click .give-points"() { - Players.update(Session.get("currentPlayer"), { - $inc: { score: 5 }, - }); - }, -}); +import { useTracker } from 'meteor/react-meteor-data'; + +function AdminDashboard() { + const currentPlayer = useTracker(() => Session.get('currentPlayer')); + + const handleGivePoints = () => { + Players.update(currentPlayer, { $inc: { score: 5 } }); + }; + + return ; +} ``` @@ -446,13 +450,15 @@ Meteor.startup(async () => { }); ``` -```js [client.js] +```jsx [client.jsx] // When the 'remove' button is clicked on a chat message, delete that message. -Template.chat.events({ - "click .remove"() { - Messages.remove(this._id); - }, -}); +function ChatMessage({ message }) { + const handleRemove = () => { + Messages.remove(message._id); + }; + + return ; +} ``` ::: @@ -606,7 +612,7 @@ Posts.allow({ Posts.deny({ update(userId, doc, fields, modifier) { // Can't change owners. - return _.contains(fields, "owner"); + return fields.includes("owner"); }, async remove(userId, doc) { @@ -665,7 +671,7 @@ if no `deny` rules return `true` and at least one `allow` rule returns -The methods (like `update` or `insert`) you call on the resulting _raw_ collection return promises and can be used outside of a Fiber. +The methods (like `update` or `insert`) you call on the resulting _raw_ collection return promises. @@ -819,8 +825,8 @@ the matching documents. -On the server, if `callback` yields, other calls to `callback` may occur while -the first call is waiting. If strict sequential execution is necessary, use +On the server, if `callback` is async, other calls to `callback` may occur while +the first call is awaiting. If strict sequential execution is necessary, use `forEach` instead. ::: warning @@ -1005,7 +1011,7 @@ setTimeout(() => handle.stop(), 5000); -`Mongo.ObjectID` follows the same API as the [Node MongoDB driver `ObjectID`](http://mongodb.github.io/node-mongodb-native/3.0/api/ObjectID.html) +`Mongo.ObjectID` follows the same API as the [Node MongoDB driver `ObjectID`](https://mongodb.github.io/node-mongodb-native/6.16/classes/ObjectId.html) class. Note that you must use the `equals` method (or [`EJSON.equals`](./EJSON.md#EJSON-equals)) to compare them; the `===` operator will not work. If you are writing generic code that needs to deal with `_id` fields that may be either strings or `ObjectID`s, use @@ -1060,7 +1066,7 @@ But they can also contain more complicated tests: } ``` -See the [complete documentation](http://docs.mongodb.org/manual/reference/operator/). +See the [complete documentation](https://www.mongodb.com/docs/manual/reference/operator/). ## Modifiers {#modifiers} @@ -1085,10 +1091,10 @@ supported by [validated updates](#Mongo-Collection-allow).) ```js // Find the document with ID '123' and completely replace it. -Users.update({ _id: "123" }, { name: "Alice", friends: ["Bob"] }); +await Users.updateAsync({ _id: "123" }, { name: "Alice", friends: ["Bob"] }); ``` -See the [full list of modifiers](http://docs.mongodb.org/manual/reference/operator/update/). +See the [full list of modifiers](https://www.mongodb.com/docs/manual/reference/operator/update/). ## Sort specifiers {#sortspecifiers} @@ -1145,7 +1151,7 @@ object as well. However, such field specifiers can not be used with from a [publish function](./meteor.md#Meteor-publish). They may be used with [`fetch`](#Mongo-Cursor-fetch), [`findOne`](#Mongo-Collection-findOne), [`forEach`](#Mongo-Cursor-forEach), and [`map`](#Mongo-Cursor-map). -Field +Field operators such as `$` and `$elemMatch` are not available on the client side yet. @@ -1153,7 +1159,7 @@ yet. A more advanced example: ```js -Users.insert({ +await Users.insertAsync({ alterEgos: [ { name: "Kira", alliance: "murderer" }, { name: "L", alliance: "police" }, @@ -1161,13 +1167,13 @@ Users.insert({ name: "Yagami Light", }); -Users.findOne({}, { fields: { "alterEgos.name": 1, _id: 0 } }); +await Users.findOneAsync({}, { fields: { "alterEgos.name": 1, _id: 0 } }); // Returns { alterEgos: [{ name: 'Kira' }, { name: 'L' }] } ``` -See +See the MongoDB docs for details of the nested field rules and array behavior. ## Connecting to your database {#mongo_url} @@ -1184,7 +1190,7 @@ If you want to use oplog tailing for livequeries, you should also set `MONGO_OPLOG_URL` (generally you'll need a special user with oplog access, but the detail can differ depending on how you host your MongoDB. Read more [here](https://github.com/meteor/docs/blob/master/long-form/oplog-observe-driver.md)). -> As of Meteor 1.4, you must ensure you set the `replicaSet` parameter on your +> You must ensure you set the `replicaSet` parameter on your > `METEOR_OPLOG_URL` ## MongoDB connection options {#mongo_connection_options} @@ -1198,10 +1204,8 @@ You can use your Meteor settings file to set the options in a property called `options` inside `packages` > `mongo`, these values will be provided as options for MongoDB in the connect method. -> this option was introduced in Meteor 1.10.2 - For example, you may want to specify a certificate for your -TLS connection ([see the options here](https://mongodb.github.io/node-mongodb-native/3.5/tutorials/connect/tls/)) then you could use these options: +TLS connection ([see the options here](https://mongodb.github.io/node-mongodb-native/6.16/fundamentals/connection/tls/)) then you could use these options: ```json "packages": { @@ -1260,7 +1264,6 @@ mongodb://:@[server-1],[server-2],[server-3]/my-database?rep ### Mongo Oplog Options {#mongo-oplog-options} -> Oplog options were introduced in Meteor 2.15.1 If you set the [`MONGO_OPLOG_URL`](/cli/environment-variables.html#mongo-oplog-url) env var, Meteor will use MongoDB's Oplog to show efficient, real time updates to your users via your subscriptions. Due to how Meteor's Oplog implementation is built behind the scenes, if you have certain collections where you expect **big amounts of write operations**, this might lead to **big CPU spikes on your meteor app server, even if you have no publications/subscriptions on any data/documents of these collections**. For more information on this, please have a look into [this blog post from 2016](https://blog.meteor.com/tuning-meteor-mongo-livedata-for-scalability-13fe9deb8908), [this github discussion from 2022](https://github.com/meteor/meteor/discussions/11842) or [this meteor forums post from 2023](https://forums.meteor.com/t/cpu-spikes-due-to-oplog-updates-without-subscriptions/60028). @@ -1296,4 +1299,3 @@ you need to call it before any other package using Mongo connections is initialized so you need to add this code in a package and add it above the other packages, like accounts-base in your `.meteor/packages` file. -> this option was introduced in Meteor 1.4 From 647a61f8b898a5aa5ed8e8a4ff7b27eef4641225 Mon Sep 17 00:00:00 2001 From: Frederico Maia Date: Mon, 30 Mar 2026 21:54:31 -0300 Subject: [PATCH 40/44] Enhance collections documentation by having React and Blaze examples for 'give points' and 'remove' button functionalities, improving clarity and consistency across frameworks. --- v3-docs/docs/api/collections.md | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/v3-docs/docs/api/collections.md b/v3-docs/docs/api/collections.md index d37fbb0e66..ec17cb4620 100644 --- a/v3-docs/docs/api/collections.md +++ b/v3-docs/docs/api/collections.md @@ -354,7 +354,7 @@ Meteor.methods({ }); ``` -```jsx [client.jsx] +```jsx [client.jsx (React)] // When the 'give points' button in the admin dashboard is pressed, give 5 // points to the current player. The new score will be immediately visible on // everyone's screens. @@ -371,6 +371,20 @@ function AdminDashboard() { } ``` +```js [client.js (Blaze)] +// When the 'give points' button in the admin dashboard is pressed, give 5 +// points to the current player. The new score will be immediately visible on +// everyone's screens. +Template.adminDashboard.events({ + "click .give-points"() { + Players.update(Session.get("currentPlayer"), { + $inc: { score: 5 }, + }); + }, +}); +``` + +::: You can use `update` to perform a Mongo upsert by setting the `upsert` option to true. You can also use the [`upsert`](#Mongo-Collection-upsert) method to perform an @@ -450,7 +464,7 @@ Meteor.startup(async () => { }); ``` -```jsx [client.jsx] +```jsx [client.jsx (React)] // When the 'remove' button is clicked on a chat message, delete that message. function ChatMessage({ message }) { const handleRemove = () => { @@ -461,6 +475,15 @@ function ChatMessage({ message }) { } ``` +```js [client.js (Blaze)] +// When the 'remove' button is clicked on a chat message, delete that message. +Template.chat.events({ + "click .remove"() { + Messages.remove(this._id); + }, +}); +``` + ::: From c14e11df73d8329a01a86c1ec00fdfc2d0882819 Mon Sep 17 00:00:00 2001 From: Frederico Maia Date: Mon, 30 Mar 2026 22:00:00 -0300 Subject: [PATCH 41/44] Update collections documentation to reflect that new Meteor 3.x projects do not include the `autopublish` and `insecure` packages by default. --- v3-docs/docs/api/collections.md | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/v3-docs/docs/api/collections.md b/v3-docs/docs/api/collections.md index ec17cb4620..e504b99395 100644 --- a/v3-docs/docs/api/collections.md +++ b/v3-docs/docs/api/collections.md @@ -87,16 +87,9 @@ that supports Mongo-style [`find`](#Mongo-Collection-find), [`insert`](#Mongo-Co [`update`](#Mongo-Collection-update), and [`remove`](#Mongo-Collection-remove) operations. (On both the client and the server, this scratchpad is implemented using Minimongo.) -By default, Meteor automatically publishes every document in your -collection to each connected client. To turn this behavior off, remove -the `autopublish` package, in your terminal: - -```bash -meteor remove autopublish -``` - -and instead call [`Meteor.publish`](./meteor.md#Meteor-publish) to specify which parts of -your collection should be published to which users. +New Meteor 3.x projects do **not** include the `autopublish` package, so you need +to call [`Meteor.publish`](./meteor.md#Meteor-publish) to specify which parts of your collection +should be published to which users. ```js // client.js @@ -105,7 +98,7 @@ your collection should be published to which users. // written to the server-side database a fraction of a second later, and a // fraction of a second after that, it will be synchronized down to any other // clients that are subscribed to a query that includes it (see -// `Meteor.subscribe` and `autopublish`). +// `Meteor.subscribe`). const Posts = new Mongo.Collection("posts"); Posts.insert({ title: "Hello world", body: "First post" }); @@ -664,12 +657,8 @@ applications. In insecure mode, if you haven't set up any `allow` or `deny` rules on a collection, then all users have full write access to the collection. This is the only effect of insecure mode. If you call `allow` or `deny` at all on a collection, even `Posts.allow({})`, then access is checked -just like normal on that collection. **New Meteor projects start in insecure -mode by default.** To turn it off just run in your terminal: - -```bash -meteor remove insecure -``` +just like normal on that collection. New Meteor 3.x projects do **not** include +the `insecure` package by default. From 0c5e43ca73f87b040d8edf22bd202eefe5e72c6e Mon Sep 17 00:00:00 2001 From: Frederico Maia Date: Tue, 31 Mar 2026 06:00:11 -0300 Subject: [PATCH 42/44] Update README files for `autopublish` and `insecure` packages to reflect changes in Meteor 3.x. --- packages/autopublish/README.md | 2 +- packages/insecure/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/autopublish/README.md b/packages/autopublish/README.md index d26a14f8e1..2f4d258cdf 100644 --- a/packages/autopublish/README.md +++ b/packages/autopublish/README.md @@ -4,4 +4,4 @@ Publish all server collections to the client. This package is useful for prototyping an app without worrying about which clients have access to certain data, but should be removed as soon as the app needs to restrict which data is seen by the client. -The `autopublish` package is automatically added to every Meteor app by `meteor create`. \ No newline at end of file +As of Meteor 3.x, the `autopublish` package is **not** included in new projects by default. To add it for prototyping, run `meteor add autopublish`. \ No newline at end of file diff --git a/packages/insecure/README.md b/packages/insecure/README.md index e1b992a249..f1319fa3d4 100644 --- a/packages/insecure/README.md +++ b/packages/insecure/README.md @@ -4,4 +4,4 @@ Allow almost all collection methods, such as `insert`, `update`, and `remove`, to be called from the client. This package is useful for prototyping an app without worrying about database permissions, but should be removed as soon as the app needs to restrict database access. -The `insecure` package is automatically added to every Meteor app by `meteor create`. \ No newline at end of file +As of Meteor 3.x, the `insecure` package is **not** included in new projects by default. To add it for prototyping, run `meteor add insecure`. \ No newline at end of file From f7b4c1a4a0a8c656534101ef962531a974fd2661 Mon Sep 17 00:00:00 2001 From: Frederico Maia Date: Tue, 31 Mar 2026 06:12:12 -0300 Subject: [PATCH 43/44] Update collections documentation to correct the `MONGO_OPLOG_URL` reference for oplog tailing configuration. --- v3-docs/docs/api/collections.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/v3-docs/docs/api/collections.md b/v3-docs/docs/api/collections.md index e504b99395..268cd57ab5 100644 --- a/v3-docs/docs/api/collections.md +++ b/v3-docs/docs/api/collections.md @@ -1203,7 +1203,7 @@ If you want to use oplog tailing for livequeries, you should also set the detail can differ depending on how you host your MongoDB. Read more [here](https://github.com/meteor/docs/blob/master/long-form/oplog-observe-driver.md)). > You must ensure you set the `replicaSet` parameter on your -> `METEOR_OPLOG_URL` +> `MONGO_OPLOG_URL` ## MongoDB connection options {#mongo_connection_options} From 718b35babfa12dabbce5e307a48e80b22480b29a Mon Sep 17 00:00:00 2001 From: Gabriel Grubba Date: Tue, 31 Mar 2026 21:34:35 -0300 Subject: [PATCH 44/44] DOC: fix ssl issue --- .../meteor-versions/metadata.generated.js | 26 +++++++++---------- .../docs/generators/meteor-versions/script.js | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/v3-docs/docs/generators/meteor-versions/metadata.generated.js b/v3-docs/docs/generators/meteor-versions/metadata.generated.js index a7ae0d966d..c13ffa9d89 100644 --- a/v3-docs/docs/generators/meteor-versions/metadata.generated.js +++ b/v3-docs/docs/generators/meteor-versions/metadata.generated.js @@ -2,55 +2,55 @@ export default { "versions": [ { "version": "v3.0", - "url": "https://release-3-0.docs.meteor.com/" + "url": "https://release-3-0.docs-online.meteor.com/" }, { "version": "v3.0.2", - "url": "https://release-3-0-2.docs.meteor.com/" + "url": "https://release-3-0-2.docs-online.meteor.com/" }, { "version": "v3.0.3", - "url": "https://release-3-0-3.docs.meteor.com/" + "url": "https://release-3-0-3.docs-online.meteor.com/" }, { "version": "v3.0.4", - "url": "https://release-3-0-4.docs.meteor.com/" + "url": "https://release-3-0-4.docs-online.meteor.com/" }, { "version": "v3.1.0", - "url": "https://release-3-1-0.docs.meteor.com/" + "url": "https://release-3-1-0.docs-online.meteor.com/" }, { "version": "v3.1.1", - "url": "https://release-3-1-1.docs.meteor.com/" + "url": "https://release-3-1-1.docs-online.meteor.com/" }, { "version": "v3.1.2", - "url": "https://release-3-1-2.docs.meteor.com/" + "url": "https://release-3-1-2.docs-online.meteor.com/" }, { "version": "v3.2.0", - "url": "https://release-3-2-0.docs.meteor.com/" + "url": "https://release-3-2-0.docs-online.meteor.com/" }, { "version": "v3.2.2", - "url": "https://release-3-2-2.docs.meteor.com/" + "url": "https://release-3-2-2.docs-online.meteor.com/" }, { "version": "v3.3.0", - "url": "https://release-3-3-0.docs.meteor.com/" + "url": "https://release-3-3-0.docs-online.meteor.com/" }, { "version": "v3.3.1", - "url": "https://release-3-3-1.docs.meteor.com/" + "url": "https://release-3-3-1.docs-online.meteor.com/" }, { "version": "v3.3.2", - "url": "https://release-3-3-2.docs.meteor.com/" + "url": "https://release-3-3-2.docs-online.meteor.com/" }, { "version": "v3.4.0", - "url": "https://release-3-4-0.docs.meteor.com/", + "url": "https://release-3-4-0.docs-online.meteor.com/", "isCurrent": true } ], diff --git a/v3-docs/docs/generators/meteor-versions/script.js b/v3-docs/docs/generators/meteor-versions/script.js index ad3900fa9e..fe5dcda488 100644 --- a/v3-docs/docs/generators/meteor-versions/script.js +++ b/v3-docs/docs/generators/meteor-versions/script.js @@ -2,7 +2,7 @@ const _fs = require("fs"); const fs = _fs.promises; const getDocsUrl = (version = "") => - `https://release-${version}.docs.meteor.com/`; + `https://release-${version}.docs-online.meteor.com/`; exports.generateMeteorVersions = async () => { console.log("Reading meteor versions...");