DEV Community

Cover image for Building a user-isolated Gemini journal on Cloud Run
Arpan Patra
Arpan Patra

Posted on

Building a user-isolated Gemini journal on Cloud Run

I spent a day building Waypoint for the Google Gen AI APAC Ideathon. The brief was a "Personal Gemini Journal": Firebase Auth, multi-turn Gemini, per-user Firestore isolation, and API keys in Secret Manager. This is what I built on top of that, and the things that nearly went wrong.

Live: https://waypoint-796599668154.us-central1.run.app
Code: https://github.com/imarpanpatra/waypoint

What it does

You write about your day. Gemini talks it through with you as a real conversation. When you close a session it is distilled into its bearing, one sentence naming the thread underneath the surface topics, plus a mood and energy score, themes, and the unfinished items you mentioned.

Every closed session is embedded, so you can later ask "what was I stuck on in August?" and get an answer cited to dated entries. Each session also produces publication drafts: a LinkedIn post, an X thread, a blog outline and a video idea, grounded strictly in what you actually wrote.

The isolation decision

The requirement was "zero cross-user leakage". The usual approach is a global collection with a userId field and a .where() clause on every query. That works right up until someone forgets one.

Instead every document lives under the owner's subtree, and the uid is only ever read from the verified token:

export function userRoot(uid) {
  if (typeof uid !== 'string' || !uid) {
    throw new Error('userRoot() requires a verified uid.');
  }
  return firestore().collection('users').doc(uid);
}
Enter fullscreen mode Exit fullscreen mode

No route accepts a user id from a request body, query string or header. There is no code path that can be pointed at another account, because the reference is constructed from the decoded token at the point of use.

That matters most for semantic search. A shared vector index is where cross-tenant leaks usually show up in RAG systems. Here the candidate set is read from users/{uid}/sessions, so isolation is structural rather than filtered.

Server-authoritative writes

The starter rules in the codelab suggest:

allow read, write: if request.auth != null && request.auth.uid == userId;
Enter fullscreen mode Exit fullscreen mode

That is correctly isolated, but it lets a client write directly to the database, which means server-side validation, sanitisation and embedding generation can all be skipped by anyone willing to open a console. My rules deny client writes entirely:

match /users/{userId} {
  allow read: if isOwner(userId);
  allow write: if false;

  match /private/{docId} {
    allow read, write: if false;
  }
}
Enter fullscreen mode Exit fullscreen mode

The private/ subcollection holds the Discord webhook URL, readable by nobody on the client, not even the owner. The API returns only a masked form.

Proving it instead of claiming it

Documentation that says a system is isolated is worth very little. So the app has a /security page that runs six probes in your browser against the live deployment:

  1. Read your own profile document. Should succeed.
  2. Read a document under a different uid. Should be permission-denied.
  3. Write directly to your own tree from the browser. Should be permission-denied.
  4. Call the API with no bearer token. Should be HTTP 401.
  5. Submit the cloud metadata address as a Discord webhook. Should be HTTP 400.
  6. Download the page's own JavaScript and scan it for Google API key literals.

Five of the six are supposed to fail. That is the point.

SSRF, the feature that needed the most care

Waypoint can push a weekly digest to a Discord webhook you own. A user-supplied URL that the server then fetches is a textbook SSRF primitive. On Cloud Run, pointing it at 169.254.169.254 would hand over the runtime service account token.

The control is a hostname allow-list, not a deny-list, because DNS rebinding and redirects defeat deny-lists:

const ALLOWED_HOSTS = new Set([
  'discord.com', 'discordapp.com', 'ptb.discord.com', 'canary.discord.com',
]);
const WEBHOOK_PATH =
  /^\/api\/(?:v\d+\/)?webhooks\/\d{5,25}\/[A-Za-z0-9_-]{20,120}$/;
Enter fullscreen mode Exit fullscreen mode

Plus https only, no embedded credentials, redirect: 'error' so an allowed host cannot bounce the request somewhere internal, and an 8 second timeout so a hung third party cannot pin an instance open.

There are eleven test cases for this, including the metadata server over https with a valid webhook path, which is the one that isolates the host check from the scheme check.

Three things that nearly went wrong

The free tier is regional

I nearly deployed to asia-south1 because it is closer to home. Cloud Run's Always Free allowance only applies in us-central1, us-east1 and us-west1. Worse, the same value sets the Firestore location, which is permanent once the database is created. Getting that wrong means tearing the project down and starting again.

A caught error still killed the process

The optional Maps secret lookup failed cleanly, logged a tidy warning, and then the service died a tick later.

Secret Manager's client builds its gRPC stub lazily and holds the promise internally. When credentials cannot be resolved, that internal promise rejects with nobody awaiting it, and under Node 22 an unhandled rejection terminates the process. Catching the error from accessSecretVersion is not enough:

const client = getClient();
await client.initialize();   // forces stub creation inside this try block
const [version] = await client.accessSecretVersion({ name: resource });
Enter fullscreen mode Exit fullscreen mode

I confirmed this was the real fix rather than a coincidence by removing the process-level safety net and checking the service still stayed up.

Cloud Run gives you two URLs

A new-style service-projectnumber.region.run.app and a legacy service-hash-uc.a.run.app. Both route to the same service, and Firebase Auth will refuse sign-in on whichever one you did not add to authorised domains.

Verifying the security controls actually work

There are 31 tests covering the pure functions where a regression would be silent: the SSRF allow-list, the prompt-injection fence a user must not be able to escape, the sanitiser that strips zero-width and bidi characters, and undefined-stripping before Firestore writes.

I confirmed they catch regressions by deliberately breaking things. Disabling the SSRF host allow-list turns four tests red. Injecting a dangerouslySetInnerHTML and a fake API key is caught by the pre-deploy scanner. A test that has never failed has not been shown to work.

Prompt injection

Journal text is untrusted input. It gets wrapped in a delimiter the user cannot forge, and every system instruction states that content inside it is data, never instructions:

export function asUntrustedData(text) {
  const escaped = String(text)
    .split(FENCE_OPEN).join('[fence]')
    .split(FENCE_CLOSE).join('[/fence]');
  return `${FENCE_OPEN}\n${escaped}\n${FENCE_CLOSE}`;
}
Enter fullscreen mode Exit fullscreen mode

Stripping the delimiters from user text first is what stops someone closing the fence early and escaping into instruction context.

This is mitigation, not a solution. The blast radius is deliberately small: the model has no tools, no function calling and no database write access, so a successful injection affects the text of one reply and cannot reach data.

Model resilience

No call site names a model. Everything routes through a fallback ladder that retries on recoverable upstream statuses, so one unavailable model cannot take the application down:

const DEFAULT_LADDER = [
  'gemini-3.6-flash',
  'gemini-3.1-flash-lite',
  'gemini-flash-latest',
  'gemini-3.7-flash',
];

const RECOVERABLE = new Set([429, 500, 503, 404]);
Enter fullscreen mode Exit fullscreen mode

The ladder is overridable with an environment variable, so a model rename is a gcloud run services update rather than a rebuild and redeploy.

Worth stealing

Two ideas I would use again on anything multi-tenant.

Make isolation structural, not conditional. If the only way to address data is a helper that takes a verified uid and builds the path from it, there is no query left to forget a filter on.

Ship a page that tries to break your own app. The security page took an hour and it is the thing I would show first. It converts a claim into something a reviewer can check in ten seconds from their own browser.

The full source, the threat model, and the deployment steps are all in the repo: https://github.com/imarpanpatra/waypoint

#AccelerateAIwithCloudRun

Top comments (0)