diff --git a/services/api/.env b/services/api/.env index 8a527fdd..c689f8f0 100644 --- a/services/api/.env +++ b/services/api/.env @@ -5,7 +5,7 @@ SERVER_PORT=2300 # Main DB Config MONGO_URI=mongodb://localhost/bedrock_dev -MONGO_DEBUG= +MONGO_DEBUG=false # Default admin account for dashboard login ADMIN_NAME=Marlon Brando diff --git a/services/api/.env.production b/services/api/.env.production index e998a5a3..a6a21abf 100644 --- a/services/api/.env.production +++ b/services/api/.env.production @@ -1,3 +1,2 @@ -# This file is for local use only. Do NOT put secrets in here. -MONGO_URI=mongodb://localhost/bedrock_production -UPLOADS_GCS_BUCKET=bedrock-production-uploads \ No newline at end of file +export MONGO_URI=mongodb://localhost/bedrock_production +export UPLOADS_GCS_BUCKET=bedrock-production-uploads \ No newline at end of file diff --git a/services/api/.env.staging b/services/api/.env.staging index a4bb5c10..52a322cb 100644 --- a/services/api/.env.staging +++ b/services/api/.env.staging @@ -1,3 +1,2 @@ -# This file is for local use only. Do NOT put secrets in here. -MONGO_URI=mongodb://localhost/bedrock_staging -UPLOADS_GCS_BUCKET=bedrock-staging-uploads \ No newline at end of file +export MONGO_URI=mongodb://localhost/bedrock_staging +export UPLOADS_GCS_BUCKET=bedrock-staging-uploads \ No newline at end of file diff --git a/services/api/__mocks__/twilio.js b/services/api/__mocks__/twilio.js index d02faf72..686ee014 100644 --- a/services/api/__mocks__/twilio.js +++ b/services/api/__mocks__/twilio.js @@ -1,7 +1,8 @@ const crypto = require('crypto'); +const config = require('@bedrockio/config'); const twilio = jest.requireActual('twilio'); -const { AUTH_TOKEN } = process.env; +const AUTH_TOKEN = config.get('TWILIO_AUTH_TOKEN'); let sentMessages; let createdRooms; diff --git a/services/api/package.json b/services/api/package.json index 89b6f161..2dd4a340 100644 --- a/services/api/package.json +++ b/services/api/package.json @@ -7,21 +7,22 @@ "node": ">=20" }, "scripts": { - "start": "node --env-file=.env --watch src/index.js", + "start": "node --watch src/index.js", "debug": "MONGO_DEBUG=true yarn start", - "staging": "node --env-file=.env --env-file=.env.staging --watch src/index.js", - "production": "node --env-file=.env --env-file=.env.production --watch src/index.js", - "start:production": "node --env-file=.env src/index", + "staging": "source .env.staging && node --watch src/index.js", + "production": "source .env.production && node --watch src/index.js", + "start:production": "node src/index", "lint": "eslint", - "test": "node --env-file=.env ./node_modules/.bin/jest", - "test:watch": "node --env-file=.env ./node_modules/.bin/jest --watch -i", + "test": "jest", + "test:watch": "jest --watch -i", "fixtures:load": "./scripts/fixtures/load", "fixtures:reload": "./scripts/fixtures/reload", "fixtures:export": "./scripts/fixtures/export", "docs:generate": "./scripts/docs/generate" }, "dependencies": { - "@bedrockio/fixtures": "^1.3.0", + "@bedrockio/config": "^2.2.3", + "@bedrockio/fixtures": "^1.2.5", "@bedrockio/logger": "^1.0.8", "@bedrockio/model": "^0.7.3", "@bedrockio/yada": "^1.2.3", diff --git a/services/api/scripts/fixtures/reload b/services/api/scripts/fixtures/reload index c9229b87..3e9671be 100755 --- a/services/api/scripts/fixtures/reload +++ b/services/api/scripts/fixtures/reload @@ -1,5 +1,6 @@ #!/usr/bin/env node +const config = require('@bedrockio/config'); const logger = require('@bedrockio/logger'); const { initialize } = require('../../src/utils/database'); const { loadFixtures } = require('../../src/utils/fixtures'); @@ -8,7 +9,7 @@ const readline = require('readline'); // Ensure models are loaded. require('../../src/models'); -const { ENV_NAME } = process.env; +const ENV_NAME = config.get('ENV_NAME'); const rl = readline.createInterface({ input: process.stdin, diff --git a/services/api/src/app.js b/services/api/src/app.js index dfe2bda0..74a6cd40 100644 --- a/services/api/src/app.js +++ b/services/api/src/app.js @@ -9,11 +9,12 @@ const serializeMiddleware = require('./utils/middleware/serialize'); const organizationMiddleware = require('./utils/middleware/organization'); const { applicationMiddleware } = require('./utils/middleware/application'); const { loadDefinition } = require('./utils/openapi'); -const logger = require('@bedrockio/logger'); const Sentry = require('@sentry/node'); const routes = require('./routes'); +const config = require('@bedrockio/config'); +const logger = require('@bedrockio/logger'); -const { ENV_NAME, SENTRY_DSN } = process.env; +const ENV_NAME = config.get('ENV_NAME'); const app = new Koa(); @@ -73,9 +74,9 @@ app.on('error', (err, ctx) => { } }); -if (SENTRY_DSN) { +if (config.has('SENTRY_DSN')) { Sentry.init({ - dsn: SENTRY_DSN, + dsn: config.get('SENTRY_DSN'), environment: ENV_NAME, }); } diff --git a/services/api/src/index.js b/services/api/src/index.js index a4c39a31..4704e714 100644 --- a/services/api/src/index.js +++ b/services/api/src/index.js @@ -1,10 +1,13 @@ const logger = require('@bedrockio/logger'); +const config = require('@bedrockio/config'); const { initialize } = require('./utils/database'); const { loadFixtures } = require('./utils/fixtures'); const app = require('./app'); -const { ENV_NAME, SERVER_PORT, SERVER_HOST, APP_NAME, ADMIN_EMAIL, ADMIN_PASSWORD } = process.env; +const ENV_NAME = config.get('ENV_NAME'); +const PORT = config.get('SERVER_PORT', 'number'); +const HOST = config.get('SERVER_HOST'); if (process.env.NODE_ENV === 'production') { logger.setupGoogleCloud({ @@ -21,11 +24,15 @@ module.exports = (async () => { if (ENV_NAME === 'development') { await loadFixtures(); } - app.listen(SERVER_PORT, SERVER_HOST, () => { - logger.info(`Started on port //${SERVER_HOST}:${SERVER_PORT}`); + app.listen(PORT, HOST, () => { + logger.info(`Started on port //${HOST}:${PORT}`); if (ENV_NAME === 'development') { logger.info('-----------------------------------------------------------------'); - logger.info(`${APP_NAME} Admin Login ${ADMIN_EMAIL}:${ADMIN_PASSWORD} (dev env only)`); + logger.info( + `${config.get('APP_NAME')} Admin Login ${config.get('ADMIN_EMAIL')}:${config.get( + 'ADMIN_PASSWORD' + )} (dev env only)` + ); logger.info('-----------------------------------------------------------------'); } }); diff --git a/services/api/src/routes/auth/apple/utils.js b/services/api/src/routes/auth/apple/utils.js index 21990673..19cd9c42 100644 --- a/services/api/src/routes/auth/apple/utils.js +++ b/services/api/src/routes/auth/apple/utils.js @@ -1,8 +1,9 @@ const verifyAppleToken = require('verify-apple-id-token').default; +const config = require('@bedrockio/config'); const { clearAuthenticators } = require('../../../utils/auth/authenticators'); -const { APPLE_SERVICE_ID } = process.env; +const APPLE_SERVICE_ID = config.get('APPLE_SERVICE_ID'); async function verifyToken(token) { const payload = await verifyAppleToken({ diff --git a/services/api/src/routes/auth/google/utils.js b/services/api/src/routes/auth/google/utils.js index 43861dea..2f7b46f6 100644 --- a/services/api/src/routes/auth/google/utils.js +++ b/services/api/src/routes/auth/google/utils.js @@ -1,9 +1,10 @@ const { OAuth2Client } = require('google-auth-library'); +const config = require('@bedrockio/config'); const { clearAuthenticators } = require('../../../utils/auth/authenticators'); const client = new OAuth2Client(); -const { GOOGLE_CLIENT_ID } = process.env; +const GOOGLE_CLIENT_ID = config.get('GOOGLE_CLIENT_ID'); async function verifyToken(token) { const ticket = await client.verifyIdToken({ diff --git a/services/api/src/routes/auth/passkey/utils.js b/services/api/src/routes/auth/passkey/utils.js index bf26dd02..8ad499f6 100644 --- a/services/api/src/routes/auth/passkey/utils.js +++ b/services/api/src/routes/auth/passkey/utils.js @@ -1,7 +1,10 @@ const SimpleWebAuthn = require('@simplewebauthn/server'); +const config = require('@bedrockio/config'); + const { clearAuthenticators, getRequiredAuthenticator } = require('../../../utils/auth/authenticators'); -const { APP_NAME, APP_URL } = process.env; +const APP_NAME = config.get('APP_NAME'); +const APP_URL = config.get('APP_URL'); // Human-readable app name. const rpName = APP_NAME; @@ -11,7 +14,7 @@ const rpName = APP_NAME; const rpID = new URL(APP_URL).hostname; // The URL at which registrations and authentications should occur -const origin = APP_URL; +const origin = config.get('APP_URL'); async function generateRegistrationOptions(user) { // Only allow a single passkey at a time. diff --git a/services/api/src/routes/auth/totp/utils.js b/services/api/src/routes/auth/totp/utils.js index 2a5eeccc..541746a4 100644 --- a/services/api/src/routes/auth/totp/utils.js +++ b/services/api/src/routes/auth/totp/utils.js @@ -1,8 +1,9 @@ const speakeasy = require('speakeasy'); +const config = require('@bedrockio/config'); const { clearAuthenticators, getRequiredAuthenticator } = require('../../../utils/auth/authenticators'); -const { APP_NAME } = process.env; +const APP_NAME = config.get('APP_NAME'); function generateTotp() { const secret = createSecret(); diff --git a/services/api/src/utils/auth/tokens.js b/services/api/src/utils/auth/tokens.js index 0b3e7634..bacec913 100644 --- a/services/api/src/utils/auth/tokens.js +++ b/services/api/src/utils/auth/tokens.js @@ -1,7 +1,8 @@ const jwt = require('jsonwebtoken'); +const config = require('@bedrockio/config'); const { nanoid } = require('nanoid'); -const { JWT_SECRET } = process.env; +const JWT_SECRET = config.get('JWT_SECRET'); // All expires are expressed in seconds (jwt spec) const expiresIn = { diff --git a/services/api/src/utils/csv.js b/services/api/src/utils/csv.js index 68898d49..0db34e7c 100644 --- a/services/api/src/utils/csv.js +++ b/services/api/src/utils/csv.js @@ -2,6 +2,7 @@ const { PassThrough } = require('stream'); const csv = require('fast-csv'); const yd = require('@bedrockio/yada'); +const config = require('@bedrockio/config'); const mongoose = require('mongoose'); const { get, startCase } = require('lodash'); @@ -9,7 +10,7 @@ const { serializeObject } = require('./serialize'); const formatter = Intl.NumberFormat('us'); -const { API_URL } = process.env; +const API_URL = config.get('API_URL'); const DEFAULT_OPTIONS = { readableHeaders: true, diff --git a/services/api/src/utils/database.js b/services/api/src/utils/database.js index f115280a..7bfd777e 100644 --- a/services/api/src/utils/database.js +++ b/services/api/src/utils/database.js @@ -1,8 +1,7 @@ const mongoose = require('mongoose'); +const config = require('@bedrockio/config'); const logger = require('@bedrockio/logger'); -const { MONGO_URI, MONGO_DEBUG } = process.env; - mongoose.Promise = Promise; // https://mongoosejs.com/docs/migrating_to_6.html#no-more-deprecation-warning-options @@ -26,9 +25,9 @@ exports.flags = flags; exports.initialize = async function initialize() { mongoose.set('strictQuery', false); - await mongoose.connect(MONGO_URI, flags); + await mongoose.connect(config.get('MONGO_URI'), flags); - if (MONGO_DEBUG) { + if (config.get('MONGO_DEBUG', 'boolean')) { mongoose.set('debug', true); } diff --git a/services/api/src/utils/messaging/mailer.js b/services/api/src/utils/messaging/mailer.js index e47d638b..f1f54351 100644 --- a/services/api/src/utils/messaging/mailer.js +++ b/services/api/src/utils/messaging/mailer.js @@ -5,9 +5,15 @@ const htmlToText = require('html-to-text'); const { loadTemplate, interpolate, escapeHtml } = require('./utils'); +const config = require('@bedrockio/config'); const logger = require('@bedrockio/logger'); -const { ENV_NAME, APP_NAME, POSTMARK_API_KEY, POSTMARK_FROM, POSTMARK_DEV_EMAIL, POSTMARK_WEBHOOK_KEY } = process.env; +const ENV_NAME = config.get('ENV_NAME'); +const APP_NAME = config.get('APP_NAME'); +const POSTMARK_FROM = config.get('POSTMARK_FROM'); +const POSTMARK_API_KEY = config.get('POSTMARK_API_KEY'); +const POSTMARK_DEV_EMAIL = config.get('POSTMARK_DEV_EMAIL'); +const POSTMARK_WEBHOOK_KEY = config.get('POSTMARK_WEBHOOK_KEY'); const TEMPLATE_DIR = path.join(__dirname, '../../emails'); diff --git a/services/api/src/utils/messaging/sms.js b/services/api/src/utils/messaging/sms.js index d5bb16e6..ffd23110 100644 --- a/services/api/src/utils/messaging/sms.js +++ b/services/api/src/utils/messaging/sms.js @@ -1,10 +1,18 @@ const path = require('path'); const twilio = require('twilio'); +const config = require('@bedrockio/config'); const logger = require('@bedrockio/logger'); const { interpolate, loadTemplate } = require('./utils'); -const { API_URL, ENV_NAME, AUTH_TOKEN, ACCOUNT_SID, TEST_NUMBER, FROM_NUMBER, WEBHOOK_URL } = process.env; +const API_URL = config.get('API_URL'); +const ENV_NAME = config.get('ENV_NAME'); + +const AUTH_TOKEN = config.get('TWILIO_AUTH_TOKEN'); +const ACCOUNT_SID = config.get('TWILIO_ACCOUNT_SID'); +const TEST_NUMBER = config.get('TWILIO_TEST_NUMBER'); +const FROM_NUMBER = config.get('TWILIO_FROM_NUMBER'); +const WEBHOOK_URL = config.get('TWILIO_WEBHOOK_URL'); const TEMPLATE_DIR = path.join(__dirname, '../../sms'); diff --git a/services/api/src/utils/messaging/utils.js b/services/api/src/utils/messaging/utils.js index d81505d4..4e605af1 100644 --- a/services/api/src/utils/messaging/utils.js +++ b/services/api/src/utils/messaging/utils.js @@ -2,11 +2,12 @@ const fs = require('fs/promises'); const path = require('path'); const Mustache = require('mustache'); const frontmatter = require('front-matter'); +const config = require('@bedrockio/config'); const { memoize } = require('lodash'); // Environment vars -const ENV = process.env; +const ENV = config.getAll(); // Mustache utils diff --git a/services/api/src/utils/middleware/__tests__/tokens.js b/services/api/src/utils/middleware/__tests__/tokens.js index 4a423582..2582a9a4 100644 --- a/services/api/src/utils/middleware/__tests__/tokens.js +++ b/services/api/src/utils/middleware/__tests__/tokens.js @@ -1,6 +1,6 @@ const jwt = require('jsonwebtoken'); +const config = require('@bedrockio/config'); -const { JWT_SECRET } = process.env; const { validateToken } = require('../tokens'); const { context } = require('../../testing'); @@ -53,14 +53,14 @@ describe('validateToken', () => { it('should fail if expired', async () => { const middleware = validateToken(); - const token = jwt.sign({ kid: 'user' }, JWT_SECRET, { expiresIn: 0 }); + const token = jwt.sign({ kid: 'user' }, config.get('JWT_SECRET'), { expiresIn: 0 }); const ctx = context({ headers: { authorization: `Bearer ${token}` } }); await expect(middleware(ctx)).rejects.toHaveProperty('message', 'jwt expired'); }); it('should work with valid secret and not expired', async () => { const middleware = validateToken(); - const token = jwt.sign({ kid: 'user', attribute: 'value' }, JWT_SECRET); + const token = jwt.sign({ kid: 'user', attribute: 'value' }, config.get('JWT_SECRET')); const ctx = context({ headers: { authorization: `Bearer ${token}` } }); await middleware(ctx, () => { expect(ctx.state.jwt.attribute).toBe('value'); @@ -69,7 +69,7 @@ describe('validateToken', () => { it('should only validate the token once when called multiple times', async () => { const middleware = validateToken(); - const token = jwt.sign({ kid: 'user', attribute: 'value' }, JWT_SECRET); + const token = jwt.sign({ kid: 'user', attribute: 'value' }, config.get('JWT_SECRET')); const ctx = context({ headers: { authorization: `Bearer ${token}` } }); let tmp; @@ -92,7 +92,7 @@ describe('validateToken', () => { describe('optional validation', () => { it('should validateToken when token exists', async () => { const middleware = validateToken({ optional: true }); - const token = jwt.sign({ kid: 'user', attribute: 'value' }, JWT_SECRET); + const token = jwt.sign({ kid: 'user', attribute: 'value' }, config.get('JWT_SECRET')); const ctx = context({ headers: { authorization: `Bearer ${token}` } }); await middleware(ctx, () => { expect(ctx.state.jwt.attribute).toBe('value'); @@ -111,7 +111,7 @@ describe('validateToken', () => { const optional = validateToken({ optional: true }); const required = validateToken(); - const token = jwt.sign({ kid: 'user', attribute: 'value' }, JWT_SECRET); + const token = jwt.sign({ kid: 'user', attribute: 'value' }, config.get('JWT_SECRET')); const ctx = context({ headers: { authorization: `Bearer ${token}` } }); await optional(ctx, () => {}); diff --git a/services/api/src/utils/middleware/authenticate.js b/services/api/src/utils/middleware/authenticate.js index 57203712..d039e5c5 100644 --- a/services/api/src/utils/middleware/authenticate.js +++ b/services/api/src/utils/middleware/authenticate.js @@ -1,8 +1,9 @@ const { validateToken } = require('./tokens'); const { User } = require('../../models'); const compose = require('koa-compose'); +const config = require('@bedrockio/config'); -const { ENV_NAME } = process.env; +const ENV_NAME = config.get('ENV_NAME'); function authenticate(options = {}) { const { optional } = options; diff --git a/services/api/src/utils/middleware/error-handler.js b/services/api/src/utils/middleware/error-handler.js index bdef6aa9..84973e83 100644 --- a/services/api/src/utils/middleware/error-handler.js +++ b/services/api/src/utils/middleware/error-handler.js @@ -1,6 +1,6 @@ +const config = require('@bedrockio/config'); const { isSchemaError } = require('@bedrockio/yada'); - -const { ENV_NAME } = process.env; +const ENV_NAME = config.get('ENV_NAME'); async function errorHandler(ctx, next) { try { diff --git a/services/api/src/utils/middleware/organization.js b/services/api/src/utils/middleware/organization.js index d211c2d4..8137c2f2 100644 --- a/services/api/src/utils/middleware/organization.js +++ b/services/api/src/utils/middleware/organization.js @@ -1,6 +1,7 @@ const { Organization } = require('../../models'); +const config = require('@bedrockio/config'); -const { DEFAULT_ORGANIZATION_NAME } = process.env; +const DEFAULT_ORGANIZATION_NAME = config.get('DEFAULT_ORGANIZATION_NAME'); async function organization(ctx, next) { const identifier = ctx.request.get('organization') || ''; diff --git a/services/api/src/utils/openapi.js b/services/api/src/utils/openapi.js index 25d8c31e..995d5897 100644 --- a/services/api/src/utils/openapi.js +++ b/services/api/src/utils/openapi.js @@ -3,6 +3,8 @@ const path = require('path'); const crypto = require('crypto'); const { Stream } = require('stream'); const mongoose = require('mongoose'); +const config = require('@bedrockio/config'); + const { get, set, merge, isEmpty, without, camelCase, kebabCase, startCase } = require('lodash'); const pluralize = mongoose.pluralize(); @@ -11,7 +13,6 @@ const PACKAGE_FILE = path.resolve(__dirname, '../../package.json'); const DEFINITION_FILE = path.resolve(__dirname, '../../openapi.json'); const EDITABLE_FIELDS = ['title', 'summary', 'description']; -const { API_URL } = process.env; let definition; @@ -62,7 +63,7 @@ async function generateDefinition() { }, servers: [ { - url: API_URL, + url: config.get('API_URL'), }, ], paths: generatePaths(require('../routes')), diff --git a/services/api/src/utils/uploads.js b/services/api/src/utils/uploads.js index b13c1a38..459bf45d 100644 --- a/services/api/src/utils/uploads.js +++ b/services/api/src/utils/uploads.js @@ -4,17 +4,19 @@ const path = require('path'); const https = require('https'); const { copyFile, writeFile } = require('fs/promises'); +const config = require('@bedrockio/config'); const logger = require('@bedrockio/logger'); const mime = require('mime-types'); const Readable = require('stream').Readable; const { Storage } = require('@google-cloud/storage'); const { Upload } = require('../models'); -const { UPLOADS_STORE, UPLOADS_GCS_BUCKET } = process.env; +const BUCKET_NAME = config.get('UPLOADS_GCS_BUCKET'); +const UPLOADS_STORE = config.get('UPLOADS_STORE'); const storage = new Storage(); -const bucket = storage.bucket(UPLOADS_GCS_BUCKET); +const bucket = storage.bucket(BUCKET_NAME); async function createUploads(arg, options) { const files = Array.isArray(arg) ? arg : [arg]; @@ -79,7 +81,7 @@ async function uploadGcs(file, upload) { const destination = getUploadFilename(upload); const gcsFile = bucket.file(destination); - logger.info('Uploading gcs %s -> gs://%s/%s', filename, UPLOADS_GCS_BUCKET, destination); + logger.info('Uploading gcs %s -> gs://%s/%s', filename, BUCKET_NAME, destination); if (buffer) { await gcsFile.save(buffer); @@ -124,7 +126,7 @@ function getUploadFilename(upload) { } function getGcsFile(upload) { - const bucket = storage.bucket(UPLOADS_GCS_BUCKET); + const bucket = storage.bucket(BUCKET_NAME); return bucket.file(getUploadFilename(upload)); } diff --git a/services/api/yarn.lock b/services/api/yarn.lock index ae297e81..cb27ee66 100644 --- a/services/api/yarn.lock +++ b/services/api/yarn.lock @@ -298,11 +298,17 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== -"@bedrockio/fixtures@^1.3.0": - version "1.3.0" - resolved "https://registry.yarnpkg.com/@bedrockio/fixtures/-/fixtures-1.3.0.tgz#80b2154a0c4f858b2628c4f708caf846e6ab4f2d" - integrity sha512-JrJEsIULg+0nXVZZyFAE4DKsMxz3MMbXvNXJowoC8rDwVK2ZBXdT+pe7biDlKoNmsRGdzosIBM24EUgFEo1vmg== +"@bedrockio/config@^2.2.2", "@bedrockio/config@^2.2.3": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@bedrockio/config/-/config-2.2.3.tgz#bac37d12e36a99ec16668c8b5d1e05dec9658fa9" + integrity sha512-jfOcZIs63S0GaWQjh5vVIISr4b2vA0CWgm630N0FDB6wlW+O0Fsjox92Agx5nggVpSnNYWqzjuKaUb5RY3/ECw== + +"@bedrockio/fixtures@^1.2.5": + version "1.2.5" + resolved "https://registry.yarnpkg.com/@bedrockio/fixtures/-/fixtures-1.2.5.tgz#43aed9428d49bcec25a60a0fb39cb70643db7025" + integrity sha512-4gt5dZQuDT8Eu2IJK92XbhpCpL/9Vyc1esypjuB5O8Mt/SEKwL8R4Hjj50WcJ8qfAohLJIU7KIQOFUDR/9YVkg== dependencies: + "@bedrockio/config" "^2.2.2" "@bedrockio/logger" "^1.0.3" glob "^8.1.0" jszip "^3.10.1"