{"slug": "building-the-foundation-claudius-runs-on", "title": "Building the foundation Claudius runs on", "summary": "Néstor Daza details the authentication and provisioning foundation for Claudius, his Claude-based chatbot, in the third part of a tutorial series. The system uses Auth.js v5 with Google OAuth and a MongoDB adapter, resolving user roles server-side with a clear precedence: admin email, member allowlist, then guest. Roles are cached in a JWT, and provisioning uses an aggregation pipeline with $ifNull to set defaults without overwriting admin changes.", "body_md": "*This tutorial was written by Néstor Daza.*\n\n*This is the third article in a series about building Claudius, my own Claude-based chatbot (Github). The previous article discussed the MongoDB data model to use for the app.*\n\nThe [previous article](https://dev.to/mongodb/no-messages-table-the-data-model-behind-my-own-claude-based-chatbot-54jp) decided the shape of the data. None of it matters until the app around it is working, and getting it there is the unglamorous half of this phase. It comes down to three things: an identity system the client cannot tamper with, proof that **Claudius** can reach the two services it depends on, and the deployment realities that decide whether any of it runs at all. This is the boring work that quietly decides whether a project survives contact with production.\n\nAny Google account on Earth can sign into Claudius safely because a user's role is never something the client sends. It is decided on the server every time.\n\nOne piece of this lives outside the code. The Google provider needs an OAuth (Open Authorization) client that you register once in the Google Cloud Console, and the client identifier and secret from that registration are set in corresponding env variables. These setup steps live in the Auth.js and Google documentation, so I am not repeating them here.\n\nSign-in runs on Auth.js v5 with the Google provider and the MongoDB adapter. There are three roles, admin, member, and guest, and they resolve in exactly one place on the server, with a clear precedence:\n\n```\nexport async function resolveRole(email: string | null | undefined): Promise<Role> {\n  if (!email) return \"guest\";\n\n  const normalized = email.toLowerCase();\n  if (normalized === env.ADMIN_EMAIL.toLowerCase()) return \"admin\";\n\n  const settings = await settingsCol();\n  const allowlist = await settings.findOne({ _id: \"allowlist\" });\n  if (allowlist && \"emails\" in allowlist) {\n    const allowed = allowlist.emails.some((e) => e.toLowerCase() === normalized);\n    if (allowed) return \"member\";\n  }\n\n  return \"guest\";\n}\n```\n\nThe bootstrap admin email wins outright, an allowlist of member emails in the settings collection comes next, and everyone else falls through to guest, the least-privileged default. The client supplies a Google identity and nothing more. It never names its own role, so there is no request a user can forge to promote themselves.\n\nThe session uses a JWT (JSON Web Token) rather than a database-backed session, so the resolved role is stored in the token and is available without a database read on every request. That choice has one small downside: the token caches the role until it refreshes, so adding someone to the member allowlist takes effect on their next sign-in, not the moment you edit the list. For a personal project, that is a fine trade.\n\nThe wiring is small: on first sign-in, the jwt callback calls `provisionUser`\n\nand stores the returned role on the token, and the session callback then copies it onto `session.user`\n\nfor the app to read.\n\nProvisioning is the other half of sign-in. The adapter has already written a bare user document, just the name, email, and image Google returns. It fills in the Claudius-specific fields with a single aggregation-pipeline update built around `$ifNull`\n\n, so each field defaults only when it is absent, and an admin's later change is never overwritten. The role is the exception, recomputed every time, since that is the field on which the whole security model rests:\n\n```\nexport async function provisionUser(\n  userId: string,\n  email: string | null | undefined,\n): Promise<Role> {\n  const role = await resolveRole(email);\n  const users = await usersCol();\n\n  const resetsAt = new Date(Date.now() + 24 * 60 * 60 * 1000);\n\n  await users.updateOne({ _id: new ObjectId(userId) }, [\n    {\n      $set: {\n        role,\n        allowedModels: { $ifNull: [\"$allowedModels\", null] },\n        monthlyTokenBudget: { $ifNull: [\"$monthlyTokenBudget\", null] },\n        status: { $ifNull: [\"$status\", \"active\"] },\n        dailyMessageCount: {\n          $ifNull: [\"$dailyMessageCount\", { count: 0, resetsAt }],\n        },\n      },\n    },\n  ]);\n\n  return role;\n}\n```\n\nOne TypeScript detail in this layer was a bit of a pain. Augmenting the session type works through the `next-auth`\n\nmodule as you would expect, but the JWT type does not! It is only re-exported from `next-auth/jwt`\n\nand lives in `@auth/core/jwt`\n\n, so a declaration written against `next-auth/jwt`\n\nsilently fails to merge, and the token field you were trying to type lands as unknown instead. You have to target the real module:\n\n```\ndeclare module \"next-auth\" {\n  interface Session {\n    user: {\n      id: string;\n      role: Role;\n    } & DefaultSession[\"user\"];\n  }\n}\n\n// JWT lives in @auth/core/jwt; next-auth/jwt only re-exports it, so the\n// augmentation must target the real module to merge rather than shadow.\ndeclare module \"@auth/core/jwt\" {\n  interface JWT {\n    uid?: string;\n    role?: Role;\n  }\n}\n```\n\nThat silent failure is the worst kind: when there are no errors, the type just quietly goes wrong.\n\nThe last piece of the skeleton is a single protected route that proves the app can talk to the things it depends on. Any signed-in role can call `/api/health`\n\n. It always pings Atlas, and given `?probe=bedrock`\n\nit goes one step further and makes a one-token Converse call against Claude Haiku 4.5, to prove the credentials can invoke a real model. The probe is the interesting part: capped at a single output token, wrapped so no AWS internals leak back to the caller, and returning the counts read from the Converse usage metadata.\n\n```\nexport async function bedrockHealthProbe(): Promise<BedrockHealth> {\n  try {\n    const llm = new ChatBedrockConverse({\n      model: HAIKU_INFERENCE_PROFILE,\n      region: env.AWS_REGION,\n      credentials: {\n        accessKeyId: env.AWS_ACCESS_KEY_ID,\n        secretAccessKey: env.AWS_SECRET_ACCESS_KEY,\n      },\n      maxTokens: 1,\n      temperature: 0,\n    });\n\n    const response = await llm.invoke([[\"human\", \"ping\"]]);\n\n    const usage = response.usage_metadata as TokenUsage | undefined;\n\n    return {\n      ok: true,\n      inputTokens: usage?.input_tokens ?? null,\n      outputTokens: usage?.output_tokens ?? null,\n    };\n  } catch {\n    // Do not surface the underlying AWS/SDK error to callers.\n    return { ok: false, error: \"Bedrock probe failed\" };\n  }\n}\n```\n\nOn a live run, it comes back with the result you want:\n\n```\n{\"ok\":true,\"inputTokens\":8,\"outputTokens\":1}\n```\n\nEight tokens in, one token out. That is the signal you want from a health check on an AI app, not only that the network is up, but that the credentials work and the meter is running.\n\nThe models, the probe, and the rest of the app are seeded into `settings`\n\nas a catalog. Each entry carries a real Bedrock cross-region inference profile ID rather than a bare model ID, which is the correct choice for cross-region routing:\n\n```\n[\n{\"id\":\"haiku\",\"inferenceProfileId\":\"us.anthropic.claude-haiku-4-5-20251001-v1:0\"},\n  {\"id\":\"sonnet\",\"inferenceProfileId\":\"us.anthropic.claude-sonnet-4-6\"},\n  {\"id\":\"opus\",\"inferenceProfileId\":\"us.anthropic.claude-opus-4-8\"}\n]\n```\n\nEach entry also carries per-million-token input and output pricing, which a later phase uses to enforce tier costs. For now, those fields are a deliberate placeholder, to be confirmed against current Bedrock pricing before any billing math depends on them, so I am not quoting numbers here that would only mislead.\n\nAs we work through this series, each development phase validates only the environment variables it uses, rather than declaring the project's entire surface up front. So far, we need just eight, for the database, Google auth, Bedrock, and the admin bootstrap, so the env schema gates exactly those. A missing or malformed value throws at startup, naming the offending variable and never printing its value:\n\n``` js\nconst EnvSchema = z.object({\n  MONGODB_URI: z.string().url(),\n  AUTH_SECRET: z.string().min(1),\n  AUTH_GOOGLE_ID: z.string().min(1),\n  AUTH_GOOGLE_SECRET: z.string().min(1),\n  AWS_ACCESS_KEY_ID: z.string().min(1),\n  AWS_SECRET_ACCESS_KEY: z.string().min(1),\n  AWS_REGION: z.string().min(1),\n  ADMIN_EMAIL: z.string().email(),\n});\n\nconst parsed = EnvSchema.safeParse(process.env);\nif (!parsed.success) {\n  // List the offending variable names only, never their values.\n  const offenders = parsed.error.issues\n    .map((issue) => issue.path.join(\".\"))\n    .join(\", \");\n  throw new Error(\n    `Invalid environment configuration. Check these variables: ${offenders}`,\n  );\n}\nexport const env = parsed.data;\n```\n\nOther variables that will be introduced later, like Tavily for web search, Voyage for embeddings, Blob for file storage, and LangSmith for tracing, are not here yet.\n\nThe skeleton works, but it did not the first time, and the failures are perhaps the most useful part of this build report.\n\nThe first was the most painful: the connection string has no database path, so anything that does not explicitly name the database falls back to `test`\n\n, which the Atlas credentials cannot touch. It bit twice, first in the index script:\n\n```\nMongoServerError: not authorized on test to execute command { listCollections: 1, ... $db: \"test\" }\n```\n\nThen again, after deployment, in production sign-in, because the Auth.js adapter does not go through the app's database helper and quietly defaults to `test`\n\nuntil it is told otherwise:\n\n```\n[auth][error] AdapterError\n[auth][cause]: MongoServerError: not authorized on test to execute command\n{ find: \"accounts\", ... $db: \"test\" }\n```\n\nThe fix is to name the database explicitly in both places. The adapter gets its own `databaseName`\n\n:\n\n```\n// databaseName is set explicitly because the connection string has no default\n// database; without it the adapter would target \"test\" (which the Atlas\n// credentials cannot access).\nexport const { handlers, auth, signIn, signOut } = NextAuth({\n  adapter: MongoDBAdapter(clientPromise, { databaseName: DB_NAME }),\n  ...authConfig,\n});\n```\n\nAnd the app reaches the database through one helper that names it the same way. That helper is also where the second gotcha lives. A naive new `MongoClient()`\n\nper request would leak a connection pool on every serverless invocation and every dev hot reload, so the client is cached on `globalThis`\n\nas a single instance, and `DB_NAME`\n\nis a constant rather than an env var because the database name is neither secret nor environment-specific:\n\n``` js\nconst globalForMongo = globalThis as unknown as {\n  _claudiusMongoClientPromise?: Promise<MongoClient>;\n};\n\nconst client = new MongoClient(env.MONGODB_URI);\n\nexport const clientPromise: Promise<MongoClient> =\n  globalForMongo._claudiusMongoClientPromise ??\n  (globalForMongo._claudiusMongoClientPromise = client.connect());\n\nexport const DB_NAME = \"claudius\";\n\nexport async function getDb(): Promise<Db> {\n  const connected = await clientPromise;\n  return connected.db(DB_NAME);\n}\n```\n\nThe adapter is handed the same `clientPromise`\n\n, so auth, and the app shares one client and one pool rather than opening two. Making them agree on a single driver version took one line at the repo root, since the adapter and the app can otherwise resolve different ones:\n\n```\n\"overrides\": { \"mongodb\": \"7.3.0\" }\n```\n\nThe third is a monorepo trap. The shared package is consumed as raw TypeScript and transpiled by the app's build through `transpilePackages: [\"@claudius/shared\"]`\n\n, with no separate build step. That is convenient, but it means every runtime dependency the shared code imports has to resolve from the app workspace, because Vercel scopes its install to the app's root directory. The rule that keeps the build green is to declare a runtime dependency in every workspace that imports it, even when that looks like duplication.\n\nThe last is the kind of thing that only shows up on deploy. A lockfile generated on macOS omitted the Linux binaries that Tailwind's build needs, and Vercel's Linux install could not find them:\n\n```\nError: Cannot find module '../lightningcss.linux-x64-gnu.node'\n```\n\nDeclaring those binaries as `optionalDependencies`\n\nputs them in the lockfile for every platform, and the deploy goes green:\n\n```\n\"optionalDependencies\": {\n  \"@tailwindcss/oxide-linux-x64-gnu\": \"4.3.1\",\n  \"lightningcss-linux-x64-gnu\": \"1.32.0\"\n}\n```\n\nNo chat yet, but the foundation is real. Identity resolves on the server and rides safely in the token. The health route confirms **Claudius** can reach both Atlas and Bedrock, and the configuration fails loud and early when something is missing. The data model from the last article now has something solid underneath it.\n\nThe role-resolution logic is unit-tested, but the actual sign-in is an OAuth round trip, and that needs a real browser, not just this headless build. Confirming that an admin email lands as admin on the page and on the persisted user document is the one step I finish by hand.\n\nNext is the streaming chat backbone, where the conversation finally gets a place to live, and I open the real checkpoint document I have been promising since the start.", "url": "https://wpnews.pro/news/building-the-foundation-claudius-runs-on", "canonical_source": "https://dev.to/mongodb/building-the-foundation-claudius-runs-on-5hl5", "published_at": "2026-08-04 15:21:07+00:00", "updated_at": "2026-08-04 15:50:23.358389+00:00", "lang": "en", "topics": ["developer-tools", "artificial-intelligence"], "entities": ["Néstor Daza", "Claudius", "Auth.js", "Google", "MongoDB", "Claude"], "alternates": {"html": "https://wpnews.pro/news/building-the-foundation-claudius-runs-on", "markdown": "https://wpnews.pro/news/building-the-foundation-claudius-runs-on.md", "text": "https://wpnews.pro/news/building-the-foundation-claudius-runs-on.txt", "jsonld": "https://wpnews.pro/news/building-the-foundation-claudius-runs-on.jsonld"}}