Build an Immutable Case File Vault

Store immutable case files in B2 and rebuild a MongoDB index.

Introduction

30 Second Summary

A file list can look complete even after the files behind it are gone. For evidence, that gap can turn a tidy catalog into a dead end.

In this project, you will build a local case-file dashboard that stores protected evidence in Backblaze B2 Cloud Storage while MongoDB Atlas serves as a searchable index. You will erase that index on purpose before rebuilding it from immutable manifests.

What You'll Build

You'll erase the demo index before watching the same case card return from protected manifests with its original fingerprint plus a working evidence link.

By the end of this project, you'll have:

  • A private evidence dashboard that uploads a harmless sample file to the vault before opening its exact stored version through a five-minute pre-signed URL.
  • A retention proof that shows Backblaze Object Lock rejecting deletion of the exact version during active compliance retention.
  • A recoverable MongoDB index that reappears from immutable JSON manifests after you erase the demo records.
  • Secret Mission: Build a server-side integrity audit that downloads every exact stored version to compare its SHA-256 fingerprint with the immutable manifest.

Are there any prerequisites?

You'll need an existing MongoDB Atlas deployment with its connection string, plus Node.js, Visual Studio Code, and a harmless local sample file. Your sample file must contain only synthetic content because this local learning prototype is not designed for real client data.

Before We Start

Protected evidence objects plus immutable recovery manifests in Backblaze B2 Cloud Storage remain authoritative. MongoDB Atlas is a disposable searchable index that you can reconstruct from those manifests.

Build the Metadata-Only Prototype

A searchable catalog can look complete even after the original file has vanished. This step makes that weakness visible by saving evidence metadata in MongoDB Atlas while deliberately discarding the uploaded bytes.

You will build a local Next.js dashboard that calculates a SHA-256 fingerprint. The first upload will produce a convincing record with no recoverable evidence behind it.

In this step, get ready to:
  • Verify that Node.js meets the project's minimum version.
  • Connect the metadata-only API to your existing MongoDB Atlas deployment.
  • Upload a synthetic file and observe the missing evidence bytes.
Verify Node.js and install the dependencies

The MongoDB driver requires Node.js 20.19.0 or newer. Checking the runtime first prevents confusing dependency failures later.

  • Open the Terminal app through macOS search.
  • Check your installed Node.js version by running this command:
node --version

What does this command do?

The command prints the Node.js version available in this terminal. The project can continue when the number is 20.19.0 or higher.

✔️ I see version 20.19.0 or higher

Your runtime is ready. That clears the first compatibility check for the dashboard.

ⓧ I see an older version

Your current Node.js release is below the project requirement. Upgrade it before installing the dependencies.

  • Visit the official Node.js download page.
  • Download a macOS installer for Node.js 20.19.0 or newer.
  • Run the downloaded installer.
  • Close the current Terminal window.
  • Open a new Terminal window through macOS search.
  • Check the upgraded version by running this command:
node --version

What should I see?

The new terminal should report Node.js 20.19.0 or higher. A fresh terminal ensures the upgraded runtime is available on its command path.

ⓧ Command not found

Node.js is unavailable to this terminal. Install it before creating the project.

  • Visit the official Node.js download page.
  • Download a macOS installer for Node.js 20.19.0 or newer.
  • Run the downloaded installer.
  • Open a new Terminal window through macOS search.
  • Confirm the installation by running this command:
node --version

What should I see?

The terminal should now print a version number. Continue when that number is 20.19.0 or higher.

Visual Studio Code will hold the application files. Create the project on your Desktop so its location stays easy to find.

  • Press Cmd+Space to open macOS search.
  • Type Visual Studio Code.
  • Press Enter to open Visual Studio Code.
  • Select File from the application menu.
  • Select Open Folder.
  • Select your Desktop in the folder dialog.
  • Click New Folder.
  • Name the folder immutable-case-vault.
  • Click Open to load the folder in Visual Studio Code.

The package manifest pins every dependency used by the finished project. Fixed versions keep the code consistent throughout the later vault steps.

  • Create package.json inside the immutable-case-vault folder with the New File control in the Explorer view.
  • Add this package manifest:
{
  "name": "immutable-case-vault",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": {
    "node": ">=20.19.0"
  },
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "@aws-sdk/client-s3": "3.1127.0",
    "@aws-sdk/s3-request-presigner": "3.1127.0",
    "mongodb": "7.6.0",
    "next": "16.3.4",
    "react": "19.2.8",
    "react-dom": "19.2.8"
  }
}

What does this file control?

  • The engines entry records the minimum compatible Node.js version.
  • The scripts entries provide commands for development and production.
  • The dependencies entries pin Next.js, React, MongoDB, and the storage libraries used later.
  • Save package.json.
  • Select Terminal from the Visual Studio Code menu.
  • Select New Terminal.
  • Install the dependencies by running:
npm install

What does this command do?

The command reads package.json and downloads its pinned dependencies. It also creates the local dependency directory used by Next.js.

The first installation can take a minute while npm downloads the packages. When it finishes, the terminal returns to its prompt without an installation error.

Dependency installation failed?

Confirm that the integrated terminal is inside the immutable-case-vault folder. Check that package.json has been saved with valid JSON.

Still stuck? Help me diagnose my npm installation failure

Build the metadata-only API

The API creates a fingerprint before writing a searchable record to MongoDB. With no Backblaze configuration present, it marks the record as metadata-only and releases the file bytes after the request ends.

Why start with metadata only?

A filename, size, and digest can make a database record look authoritative. The missing bytes prove that metadata alone cannot preserve the evidence.

  • Create .gitignore inside the immutable-case-vault folder with the Explorer view's New File control.
  • Add these exclusions:
node_modules
.next
.env*
!.env.local.example

What do these exclusions protect?

The file keeps downloaded packages, generated Next.js output, and local environment files out of version control. The example environment file remains available as a safe configuration template.

  • Save .gitignore.
  • Confirm that .gitignore appears in the Explorer view.
  • Create .env.local.example inside the immutable-case-vault folder.
  • Add the complete environment template:
MONGODB_URI=your-mongodb-connection-string-here
AWS_ACCESS_KEY_ID=your-backblaze-application-key-id-here
AWS_SECRET_ACCESS_KEY=your-backblaze-application-key-here
AWS_REGION=your-backblaze-region-here
AWS_ENDPOINT_URL_S3=your-backblaze-s3-endpoint-here
AWS_S3_BUCKET=your-backblaze-bucket-name-here

Why list the B2 settings now?

The template documents the final server configuration without containing live credentials. This step supplies only MONGODB_URI, so the application stays in metadata-only mode.

  • Save .env.local.example.
  • Create .env.local inside the same folder.
  • Add this single setting:
MONGODB_URI=your-mongodb-connection-string-here

How is this value used?

Next.js loads MONGODB_URI into the server environment. The MongoDB helper uses it to connect to your existing Atlas deployment.

Connection strings contain credentials. Keep yours inside .env.local and away from screenshots or shared code.

  • Replace your-mongodb-connection-string-here with your existing MongoDB Atlas connection string.
  • Save .env.local.
  • Confirm that the file contains no Backblaze or AWS settings.
  • Create a lib folder inside immutable-case-vault with the Explorer view's New Folder control.
  • Create mongodb.js inside the lib folder.
  • Add the MongoDB connection helper:
import { MongoClient } from "mongodb";

const uri = process.env.MONGODB_URI;

if (!uri) {
  throw new Error("Add MONGODB_URI to .env.local");
}

const globalForMongo = globalThis;
const clientPromise =
  globalForMongo.__caseVaultMongoPromise ?? new MongoClient(uri).connect();

if (process.env.NODE_ENV !== "production") {
  globalForMongo.__caseVaultMongoPromise = clientPromise;
}

export async function evidenceCollection() {
  const client = await clientPromise;
  return client.db("case_vault").collection("evidence");
}

What does this helper do?

  • The shared connection promise prevents development reloads from opening unnecessary MongoDB connections.
  • The evidenceCollection() helper consistently targets the case_vault database.
  • Every demo record is stored in the evidence collection.
  • Save lib/mongodb.js.
  • Create b2.js inside the lib folder.
  • Add the configuration check used by metadata-only mode:
const requiredVariables = [
  "AWS_ACCESS_KEY_ID",
  "AWS_SECRET_ACCESS_KEY",
  "AWS_REGION",
  "AWS_ENDPOINT_URL_S3",
  "AWS_S3_BUCKET",
];

export function hasVaultConfig() {
  return requiredVariables.every((name) => Boolean(process.env[name]));
}

What does this check prove?

The function returns true only when every storage setting exists. Your current .env.local contains none of them, so uploads follow the metadata-only branch.

  • Save lib/b2.js.
  • Create an app folder inside immutable-case-vault.
  • Create an api folder inside app.
  • Create an evidence folder inside app/api.
  • Create route.js inside app/api/evidence.
  • Start the route with its imports and listing behavior:
import { createHash, randomUUID } from "node:crypto";
import { hasVaultConfig } from "../../../lib/b2.js";
import { evidenceCollection } from "../../../lib/mongodb.js";

const MAX_DEMO_FILE_BYTES = 10 * 1024 * 1024;

export async function GET() {
  try {
    const collection = await evidenceCollection();
    const documents = await collection
      .find({ demo: true })
      .sort({ createdAt: -1 })
      .toArray();

    const items = documents.map(({ _id, shareToken, ...document }) => ({
      ...document,
      sharePath: shareToken
        ? `/api/view?token=${encodeURIComponent(shareToken)}`
        : null,
    }));

    return Response.json({ items });
  } catch (error) {
    return Response.json({ error: error.message }, { status: 500 });
  }
}

What does the listing route do?

  • The query returns only records marked with demo: true.
  • The newest records appear first because the query sorts by createdAt.
  • The response removes the raw share token before returning each record to the browser.
  • Continue app/api/evidence/route.js with the upload validation:
export async function POST(request) {
  try {
    const formData = await request.formData();
    const file = formData.get("file");
    const caseId = String(formData.get("caseId") ?? "").trim();
    const description = String(formData.get("description") ?? "").trim();

    if (!file || typeof file.arrayBuffer !== "function") {
      return Response.json({ error: "Choose a file." }, { status: 400 });
    }
    if (!caseId || !description) {
      return Response.json(
        { error: "Case ID and description are required." },
        { status: 400 },
      );
    }
    if (file.size > MAX_DEMO_FILE_BYTES) {
      return Response.json(
        { error: "Use a synthetic demo file no larger than 10 MiB." },
        { status: 400 },
      );
    }

    const buffer = Buffer.from(await file.arrayBuffer());
    const evidenceId = randomUUID();
    const createdAt = new Date().toISOString();

What does the validation protect?

  • The route requires a file, case ID, and description before creating a record.
  • The MAX_DEMO_FILE_BYTES limit keeps each synthetic upload at 10 MiB or less.
  • The uploaded bytes move into a buffer so the route can calculate a deterministic fingerprint.
  • Finish the upload route with the fingerprint and metadata-only record:
    const sha256 = createHash("sha256").update(buffer).digest("hex");
    const contentType = file.type || "application/octet-stream";
    const commonRecord = {
      evidenceId,
      caseId,
      description,
      originalName: file.name,
      contentType,
      size: file.size,
      sha256,
      createdAt,
      demo: true,
    };
    const collection = await evidenceCollection();

    if (!hasVaultConfig()) {
      const metadataOnlyRecord = { ...commonRecord, vaulted: false };
      await collection.updateOne(
        { evidenceId },
        { $set: metadataOnlyRecord },
        { upsert: true },
      );
      return Response.json(
        {
          message:
            "Metadata saved, but the file bytes were discarded because B2 is not configured.",
        },
        { status: 201 },
      );
    }
  } catch (error) {
    return Response.json({ error: error.message }, { status: 500 });
  }
}

What happens to an upload?

  • The sha256 value fingerprints the exact uploaded bytes.
  • The common record captures the case details without embedding the file buffer.
  • The missing B2 settings produce vaulted: false and a MongoDB upsert.
  • The request ends without storing the uploaded bytes anywhere.
  • Save app/api/evidence/route.js.
  • Start the development server from the integrated terminal by running:
npm run dev

What does this command start?

The package script starts the Next.js development server. It keeps control of this terminal while the application is running.

Before checking the route, do you expect MongoDB to return file bytes or a JSON index response?

  • Visit http://localhost:3000/api/evidence in your browser.

You should see a JSON response containing an items array. This proves that the API can read the demo index from MongoDB.

API route not loading?

Check that the terminal is still running the development server. Confirm that route.js sits inside app/api/evidence.

A MongoDB connection error usually points to the value in .env.local or the network access settings on your existing Atlas deployment.

Still stuck? Help me debug the metadata-only evidence API

Add the dashboard and expose the missing bytes

The browser needs a clear way to compare a searchable metadata record with recoverable evidence. The dashboard displays both states through its badge and evidence-link controls.

  • Create layout.js inside the existing app folder.
  • Add the root layout:
import "./globals.css";

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

What does the layout provide?

The root layout wraps every route in the required HTML structure. Its stylesheet import applies the shared dashboard design.

  • Save app/layout.js.
  • Create page.js inside the app folder.
  • Start the client component with its response helper and state:
"use client";

import { useEffect, useState } from "react";

async function readResponse(response) {
  const body = await response.json();
  if (!response.ok) {
    throw new Error(body.error ?? "Request failed");
  }
  return body;
}

export default function Home() {
  const [items, setItems] = useState([]);
  const [message, setMessage] = useState("Ready.");
  const [busy, setBusy] = useState(false);

  async function refresh() {
    const response = await fetch("/api/evidence");
    const body = await readResponse(response);
    setItems(body.items);
  }

  useEffect(() => {
    refresh().catch((error) => setMessage(error.message));
  }, []);

How does the dashboard load records?

  • The client boundary enables browser state and event handlers.
  • The refresh() function reads the evidence API.
  • The effect loads the current index when the dashboard mounts.
  • Continue app/page.js with the upload handler:
  async function uploadEvidence(event) {
    event.preventDefault();
    setBusy(true);
    setMessage("Uploading...");

    try {
      const form = event.currentTarget;
      const response = await fetch("/api/evidence", {
        method: "POST",
        body: new FormData(form),
      });
      const body = await readResponse(response);
      form.reset();
      setMessage(body.message);
      await refresh();
    } catch (error) {
      setMessage(error.message);
    } finally {
      setBusy(false);
    }
  }

How does an upload reach the API?

The handler packages the form as multipart data and posts it to the evidence route. A successful response clears the form and reloads the index.

  • Continue app/page.js with the shared action handler:
  async function postAction(path, payload) {
    setBusy(true);
    setMessage("Working...");

    try {
      const response = await fetch(path, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(payload ?? {}),
      });
      const body = await readResponse(response);
      setMessage(body.message);
      await refresh();
    } catch (error) {
      setMessage(error.message);
    } finally {
      setBusy(false);
    }
  }

Why include the action handler now?

The dashboard uses one handler for later lock and recovery controls. In this prototype, it establishes the final component structure without changing the metadata-only upload path.

  • Continue app/page.js with the header and upload form:
  return (
    <main>
      <header className="hero">
        <p className="eyebrow">Free-first recoverable storage demo</p>
        <h1>Immutable Case File Vault</h1>
        <p>
          Backblaze B2 holds the evidence and recovery manifest. MongoDB is a
          disposable search index.
        </p>
      </header>

      <section className="panel">
        <h2>Add synthetic evidence</h2>
        <form onSubmit={uploadEvidence}>
          <label>
            Case ID
            <input name="caseId" defaultValue="CASE-001" maxLength="80" required />
          </label>
          <label>
            Description
            <input
              name="description"
              defaultValue="Synthetic interview note"
              maxLength="200"
              required
            />
          </label>

What does this interface capture?

The form starts with a synthetic case ID and description. These values become searchable fields in the MongoDB record.

  • Continue app/page.js with the file input and recovery controls:
          <label>
            File
            <input name="file" type="file" required />
          </label>
          <button disabled={busy} type="submit">
            Upload evidence
          </button>
        </form>
      </section>

      <section className="panel controls">
        <h2>Recovery controls</h2>
        <button disabled={busy} onClick={() => postAction("/api/reset")}>
          Reset MongoDB Index
        </button>
        <button disabled={busy} onClick={() => postAction("/api/rebuild")}>
          Rebuild from B2
        </button>
        <button disabled={busy} onClick={() => refresh()}>
          Refresh
        </button>
        <p className="message">{message}</p>
      </section>

What will these controls prove later?

The controls reserve the dashboard workflow for deleting and reconstructing the disposable index. Their routes arrive after the immutable vault exists.

  • Finish app/page.js with the evidence cards:
      <section>
        <h2>Evidence index</h2>
        {items.length === 0 ? (
          <p className="empty">No demo index records found.</p>
        ) : (
          <div className="grid">
            {items.map((item) => (
              <article className="card" key={item.evidenceId}>
                <span className={item.vaulted ? "badge safe" : "badge warning"}>
                  {item.vaulted ? "Vaulted" : "Metadata only"}
                </span>
                <h3>{item.originalName}</h3>
                <p>Case: {item.caseId}</p>
                <p>{item.description}</p>
                <p>Size: {item.size.toLocaleString()} bytes</p>
                <p className="hash">SHA-256: {item.sha256}</p>
                <div className="actions">
                  {item.sharePath ? (
                    <a href={item.sharePath} rel="noreferrer" target="_blank">
                      Open evidence
                    </a>
                  ) : (
                    <span className="disabledLink">Open evidence unavailable</span>
                  )}

How does the card expose the gap?

Each card displays the metadata and SHA-256 fingerprint returned by the API. A record without a share path shows that no evidence link can be opened.

  • Add the final card control and closing elements:
                  {item.vaulted && (
                    <button
                      disabled={busy}
                      onClick={() =>
                        postAction("/api/lock-test", {
                          evidenceId: item.evidenceId,
                        })
                      }
                    >
                      Test immutable delete
                    </button>
                  )}
                </div>
              </article>
            ))}
          </div>
        )}
      </section>
    </main>
  );
}

When does the delete test appear?

The immutable-delete button is conditional on item.vaulted. Metadata-only records cannot trigger a vault test because they have no stored object version.

  • Save app/page.js.
  • Return to http://localhost:3000 in your browser.

You should now see the unstyled dashboard with its synthetic evidence form and recovery controls. That visible page confirms the component structure is complete.

  • Create globals.css inside the app folder.
  • Add the page foundation and typography:
:root {
  color-scheme: dark;
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
  background: #07111f;
  color: #e7eef8;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-height: 100vh;
  background: linear-gradient(145deg, #07111f, #10243b);
}

button,
input {
  font: inherit;
}

What does this styling establish?

These rules establish the dark color scheme and consistent sizing model. Form controls inherit the same typography as the dashboard.

  • Save app/globals.css.
  • Refresh the dashboard in your browser.

You should see the page switch to a dark blue gradient with light text.

  • Continue app/globals.css with the page and form layout:
main {
  width: min(1040px, 92vw);
  margin: 0 auto;
  padding: 48px 0 80px;
}

.hero {
  margin-bottom: 24px;
}

.eyebrow {
  color: #70d7ff;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

h1,
h2,
h3,
p {
  margin-top: 0;
}

.panel,
.card {
  border: 1px solid #2b4764;
  border-radius: 16px;
  background: rgba(10, 28, 47, 0.9);
  padding: 20px;
  margin-bottom: 20px;
}

What changes in this section?

The dashboard gains a centered content column. Panels and evidence cards gain visible boundaries that separate each workflow.

  • Save app/globals.css.
  • Refresh the dashboard in your browser.

You should see the content centered inside bordered panels.

  • Continue app/globals.css with the form and button styles:
form {
  display: grid;
  gap: 14px;
}

label {
  display: grid;
  gap: 6px;
}

input {
  width: 100%;
  border: 1px solid #466985;
  border-radius: 10px;
  background: #07111f;
  color: #ffffff;
  padding: 10px 12px;
}

button,
a {
  display: inline-block;
  border: 0;
  border-radius: 10px;
  background: #2bb6e8;
  color: #04101b;
  cursor: pointer;
  font-weight: 700;
  padding: 10px 14px;
  text-decoration: none;
}

What changes in the form?

The form fields gain spacing and high-contrast input surfaces. Buttons and future evidence links share the same action styling.

  • Save app/globals.css.
  • Refresh the dashboard in your browser.

You should see full-width inputs and blue action buttons inside the form panel.

  • Finish app/globals.css with the card and status styles:
button:disabled {
  cursor: wait;
  opacity: 0.55;
}

.controls button {
  margin: 0 8px 8px 0;
}

.message {
  min-height: 24px;
  margin-top: 12px;
}

.grid {
  display: grid;
  gap: 16px;
  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}

.badge {
  display: inline-block;
  border-radius: 999px;
  margin-bottom: 12px;
  padding: 5px 9px;
  font-size: 0.8rem;
  font-weight: 800;
}

.safe {
  background: #83f1b4;
  color: #062814;
}

.warning {
  background: #ffd166;
  color: #2b1b00;
}

How are states distinguished?

The responsive grid arranges evidence cards according to the available width. Green and amber badges distinguish vaulted records from metadata-only records.

  • Add the final fingerprint and action styles:
.hash {
  overflow-wrap: anywhere;
  color: #a8bfd3;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.8rem;
}

.actions {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  align-items: center;
}

.disabledLink,
.empty {
  color: #9db0c3;
}

Why style the fingerprint separately?

SHA-256 values are long and need safe line wrapping inside narrow cards. The muted unavailable-link style makes the missing evidence action clear.

  • Save app/globals.css.
  • Create sample-evidence.txt inside the immutable-case-vault folder.
  • Add this harmless sample content:
Synthetic evidence for CASE-001.
This file contains no real client information.

Why use synthetic evidence?

The project handles storage credentials and retention controls without production security safeguards. A synthetic file demonstrates the workflow without exposing real client information.

  • Save sample-evidence.txt.
  • Return to http://localhost:3000 in your browser.

Before you upload the sample, do you expect the database record to include a working evidence link?

  • Keep CASE-001 in the Case ID field.
  • Keep Synthetic interview note in the Description field.
  • Select sample-evidence.txt in the File field.
  • Click Upload evidence.

You should see a new evidence card with an amber Metadata only badge. The card shows the case ID, byte size, and SHA-256 fingerprint.

The card displays Open evidence unavailable instead of an evidence link. This is the intended shortfall: MongoDB preserved a convincing index record after the file bytes were discarded.

Upload did not create a card?

Check the status message below the recovery controls. Confirm that the development terminal shows no MongoDB connection or JavaScript syntax error.

If the card says Vaulted, remove the Backblaze settings from .env.local and restart the development server.

Still stuck? Help me debug my metadata-only upload

That card exposes the exact design flaw this prototype was built to reveal. Next, you will move the evidence bytes and a recovery manifest into an immutable Backblaze B2 vault.

Create the Immutable B2 Vault

Your metadata-only prototype exposed the gap in a database-first design. MongoDB can describe an evidence file after the original bytes have disappeared.

This step makes Backblaze B2 Cloud Storage the durable source of truth. Your Next.js app stores each file beside an immutable recovery manifest.

In this step, get ready to:
  • Create a private B2 bucket with one-day compliance retention.
  • Create a bucket-scoped application key and configure the local environment.
  • Upload evidence files and recovery manifests to the immutable vault.
Create the private locked bucket

Object Lock applies retention to object versions. Default Bucket Retention protects every evidence file and manifest uploaded after you enable the setting.

Enabling Object Lock is a permanent bucket-level decision. The one-day compliance period also blocks deletion of each protected version until its retention expires.

  • Visit the Backblaze account page in your browser.
  • Create your Backblaze account.
  • Sign in to the Backblaze web console.
  • Select B2 Cloud Storage in the navigation.
  • Click Buckets.
  • Click Create a Bucket.
  • Enter a unique bucket name: your unique B2 bucket name.
  • Choose Private for the bucket privacy setting.
  • Enable Object Lock for the bucket.
  • Create the bucket.
  • Click Object Lock: Enabled in the new bucket.
  • Set Default Bucket Retention to 1 day.
  • Confirm that the retention mode is Compliance.
  • Click Save changes.
  • Confirm that the bucket settings show one-day Default Bucket Retention.

What does compliance retention protect?

Compliance retention prevents any user from deleting a protected object version before its retention date. The date can be extended by a suitably scoped client.

This project configures the default before uploading anything. The same protection therefore covers the evidence object and its recovery manifest.

Create scoped credentials and configure the app

A bucket-scoped application key limits the app to this vault. The key provides the S3-compatible credentials that the server reads from .env.local.

  • Return to Buckets in the Backblaze web console.
  • Open the bucket named your unique B2 bucket name.
  • Record its Endpoint: your Backblaze B2 S3 endpoint.
  • Identify the region as the second segment of the endpoint.
  • Record the region: your Backblaze region.

How do I identify the region?

An endpoint such as s3.us-west-001.backblazeb2.com uses us-west-001 as its region. Your endpoint can contain a different region segment.

  • Click Application Keys in the Backblaze web console.
  • Click Add a New Application Key.
  • Give the key the name immutable-case-vault.
  • Select your unique B2 bucket name under Allow Access to Bucket(s).
  • Enable Allow List All Bucket Names.
  • Select Read and Write.
  • Click Create New Key.

The application key is a live credential that Backblaze reveals during creation. Keep this page private while you transfer the values directly into the ignored .env.local file.

  • Record the displayed keyID: your Backblaze keyID.
  • Keep the displayed applicationKey available for the next action.
  • Switch back to .env.local in Visual Studio Code.
  • Keep the existing MONGODB_URI line unchanged.
  • Add these five settings below the existing line:
AWS_ACCESS_KEY_ID=your-backblaze-application-key-id-here
AWS_SECRET_ACCESS_KEY=your-backblaze-application-key-here
AWS_REGION=your-backblaze-region-here
AWS_ENDPOINT_URL_S3=your-backblaze-s3-endpoint-here
AWS_S3_BUCKET=your-backblaze-bucket-name-here

What do these settings control?

  • AWS_ACCESS_KEY_ID identifies the bucket-scoped application key.
  • AWS_SECRET_ACCESS_KEY authenticates the server with the private application key.
  • AWS_REGION and AWS_ENDPOINT_URL_S3 direct the SDK to the correct B2 S3-compatible endpoint.
  • AWS_S3_BUCKET tells the app which private bucket stores the evidence.
  • Replace your-backblaze-application-key-id-here with your Backblaze keyID.
  • Replace your-backblaze-application-key-here with the displayed application key.
  • Replace your-backblaze-region-here with your Backblaze region.
  • Replace your-backblaze-s3-endpoint-here with your Backblaze B2 S3 endpoint.
  • Replace your-backblaze-bucket-name-here with your unique B2 bucket name.
  • Save .env.local.
  • Close the Backblaze key reveal page.
  • Stop the development server with the terminal interrupt shortcut.
  • Restart the development server by running:
npm run dev

Why restart the server?

The development process reads the new environment values when it starts. Restarting makes the five B2 settings available to the server-side route.

Your terminal returns the local address http://localhost:3000 when the server is ready.

Server not starting?

  • Check that every environment setting has a value after its equals sign.
  • Confirm that MONGODB_URI still contains your existing MongoDB Atlas connection string.
  • Confirm that .env.local remains inside the immutable-case-vault folder.

Still stuck? Help me diagnose my Next.js environment configuration without asking me to share any credential values.

Connect uploads to the vault

The AWS SDK for JavaScript v3 gives your server access to B2 through its S3-compatible API. The helper centralizes uploads plus the exact-version operations used later in the project.

The upload route keeps the metadata-only fallback. Once every B2 setting exists, the same route stores the original bytes and a JSON recovery manifest before indexing that manifest in MongoDB.

  • Create b2.js inside the existing lib folder using the Visual Studio Code Explorer sidebar.
  • Add the SDK imports and configuration checks to lib/b2.js with this code:
import {
  DeleteObjectCommand,
  GetObjectCommand,
  GetObjectRetentionCommand,
  ListObjectsV2Command,
  PutObjectCommand,
  S3Client,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const requiredVariables = [
  "AWS_ACCESS_KEY_ID",
  "AWS_SECRET_ACCESS_KEY",
  "AWS_REGION",
  "AWS_ENDPOINT_URL_S3",
  "AWS_S3_BUCKET",
];

let client;

export function hasVaultConfig() {
  return requiredVariables.every((name) => Boolean(process.env[name]));
}

What does this code do?

  • The imports provide the object upload, download, listing, retention, deletion, and URL-signing operations used by the vault.
  • requiredVariables lists every environment setting needed before the app enables vault mode.
  • hasVaultConfig() preserves the metadata-only fallback when any required setting is absent.
  • Append the client, bucket, and upload helpers to lib/b2.js with this code:
export function getB2Client() {
  if (!hasVaultConfig()) {
    throw new Error(`Missing B2 configuration: ${requiredVariables.join(", ")}`);
  }

  client ??= new S3Client({});
  return client;
}

export function getBucketName() {
  if (!process.env.AWS_S3_BUCKET) {
    throw new Error("Missing AWS_S3_BUCKET");
  }
  return process.env.AWS_S3_BUCKET;
}

export async function putObject({ key, body, contentType }) {
  return getB2Client().send(
    new PutObjectCommand({
      Bucket: getBucketName(),
      Key: key,
      Body: body,
      ContentType: contentType,
    }),
  );
}

How do the core helpers work?

  • getB2Client() creates one reusable S3 client after confirming that the configuration is complete.
  • getBucketName() prevents object operations when the bucket setting is missing.
  • putObject() sends bytes or JSON to the configured bucket with the correct content type.
  • Save lib/b2.js.
  • Check its syntax without executing the module by running:
node --check lib/b2.js

What does this check prove?

The Node.js check mode parses the file without running its B2 operations. Your terminal returns to the prompt without a syntax error when these first helpers are valid.

  • Append the manifest-reading helper to lib/b2.js with this code:
export async function getJsonObject(key) {
  const response = await getB2Client().send(
    new GetObjectCommand({
      Bucket: getBucketName(),
      Key: key,
    }),
  );

  if (!response.Body) {
    throw new Error(`Manifest ${key} had no response body`);
  }

  return JSON.parse(await response.Body.transformToString());
}

How are manifests read?

getJsonObject() downloads a manifest from B2. It converts the response stream into text before parsing the stored JSON.

  • Append the paginated manifest listing helper to lib/b2.js with this code:
export async function listManifestKeys() {
  const keys = [];
  let continuationToken;

  do {
    const page = await getB2Client().send(
      new ListObjectsV2Command({
        Bucket: getBucketName(),
        Prefix: "manifests/",
        ContinuationToken: continuationToken,
      }),
    );

    for (const object of page.Contents ?? []) {
      if (object.Key) keys.push(object.Key);
    }

    continuationToken = page.IsTruncated
      ? page.NextContinuationToken
      : undefined;
  } while (continuationToken);

  return keys;
}

Why paginate the manifest list?

listManifestKeys() collects every current object under manifests/. The continuation token lets the rebuild process move through more than one response page.

  • Save lib/b2.js.
  • Check the expanded helper by running:
node --check lib/b2.js

What should the terminal show?

The terminal returns to the prompt without a syntax error. This confirms that both manifest helpers parse correctly.

  • Append the exact-version retention and deletion helpers to lib/b2.js with this code:
export async function getRetention({ key, versionId }) {
  const response = await getB2Client().send(
    new GetObjectRetentionCommand({
      Bucket: getBucketName(),
      Key: key,
      VersionId: versionId,
    }),
  );

  return response.Retention;
}

export async function deleteVersion({ key, versionId }) {
  return getB2Client().send(
    new DeleteObjectCommand({
      Bucket: getBucketName(),
      Key: key,
      VersionId: versionId,
    }),
  );
}

Why keep the version ID?

B2 buckets retain object versions. These helpers use VersionId to inspect or delete the exact immutable version recorded during upload.

  • Append the temporary read-link helper to lib/b2.js with this code:
export async function createReadUrl({ key, versionId }) {
  const command = new GetObjectCommand({
    Bucket: getBucketName(),
    Key: key,
    VersionId: versionId,
    ResponseContentDisposition: "inline",
  });

  return getSignedUrl(getB2Client(), command, { expiresIn: 300 });
}

How does the read link stay controlled?

createReadUrl() signs access to one exact private object version. The generated URL expires after 300 seconds.

  • Save lib/b2.js.
  • Check the completed helper by running:
node --check lib/b2.js

What does a clean result mean?

A return to the terminal prompt confirms that the complete B2 helper has valid JavaScript syntax. The upload route can now import its configuration and object-storage functions.

Seeing a syntax error in the helper?

  • Check that each helper was appended below the previous closing brace.
  • Confirm that the import from @aws-sdk/client-s3 includes every command shown in the first code block.
  • Compare the punctuation around the nested command objects with the full file below.

Need another pair of eyes? Help me find the syntax error in lib/b2.js without asking me to share my .env.local values.

✔️ Awesome, I've got everything!

Great. Save lib/b2.js before updating the evidence route.

ⓧ I'd like to double check the full code

import {
  DeleteObjectCommand,
  GetObjectCommand,
  GetObjectRetentionCommand,
  ListObjectsV2Command,
  PutObjectCommand,
  S3Client,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const requiredVariables = [
  "AWS_ACCESS_KEY_ID",
  "AWS_SECRET_ACCESS_KEY",
  "AWS_REGION",
  "AWS_ENDPOINT_URL_S3",
  "AWS_S3_BUCKET",
];

let client;

export function hasVaultConfig() {
  return requiredVariables.every((name) => Boolean(process.env[name]));
}

export function getB2Client() {
  if (!hasVaultConfig()) {
    throw new Error(`Missing B2 configuration: ${requiredVariables.join(", ")}`);
  }

  client ??= new S3Client({});
  return client;
}

export function getBucketName() {
  if (!process.env.AWS_S3_BUCKET) {
    throw new Error("Missing AWS_S3_BUCKET");
  }
  return process.env.AWS_S3_BUCKET;
}

export async function putObject({ key, body, contentType }) {
  return getB2Client().send(
    new PutObjectCommand({
      Bucket: getBucketName(),
      Key: key,
      Body: body,
      ContentType: contentType,
    }),
  );
}

export async function getJsonObject(key) {
  const response = await getB2Client().send(
    new GetObjectCommand({
      Bucket: getBucketName(),
      Key: key,
    }),
  );

  if (!response.Body) {
    throw new Error(`Manifest ${key} had no response body`);
  }

  return JSON.parse(await response.Body.transformToString());
}

export async function listManifestKeys() {
  const keys = [];
  let continuationToken;

  do {
    const page = await getB2Client().send(
      new ListObjectsV2Command({
        Bucket: getBucketName(),
        Prefix: "manifests/",
        ContinuationToken: continuationToken,
      }),
    );

    for (const object of page.Contents ?? []) {
      if (object.Key) keys.push(object.Key);
    }

    continuationToken = page.IsTruncated
      ? page.NextContinuationToken
      : undefined;
  } while (continuationToken);

  return keys;
}

export async function getRetention({ key, versionId }) {
  const response = await getB2Client().send(
    new GetObjectRetentionCommand({
      Bucket: getBucketName(),
      Key: key,
      VersionId: versionId,
    }),
  );

  return response.Retention;
}

export async function deleteVersion({ key, versionId }) {
  return getB2Client().send(
    new DeleteObjectCommand({
      Bucket: getBucketName(),
      Key: key,
      VersionId: versionId,
    }),
  );
}

export async function createReadUrl({ key, versionId }) {
  const command = new GetObjectCommand({
    Bucket: getBucketName(),
    Key: key,
    VersionId: versionId,
    ResponseContentDisposition: "inline",
  });

  return getSignedUrl(getB2Client(), command, { expiresIn: 300 });
}

How to use this reference

Compare this complete file with lib/b2.js from top to bottom. Keep every import and helper in the same order.

  • Switch to app/api/evidence/route.js in Visual Studio Code.
  • Replace its import section with this final import group:
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { hasVaultConfig, putObject } from "../../../lib/b2.js";
import { evidenceCollection } from "../../../lib/mongodb.js";

What changed in the imports?

randomBytes creates a capability token for controlled access. hasVaultConfig() selects the storage mode while putObject() writes the evidence and manifest to B2.

  • Save app/api/evidence/route.js.
  • Check the route syntax by running:
node --check app/api/evidence/route.js

What should happen?

The terminal returns to the prompt without a syntax error. This confirms that the route can resolve the completed B2 helper imports.

  • Find the closing brace of the existing if (!hasVaultConfig()) fallback inside POST().
  • Insert the object naming and evidence upload logic immediately below that fallback:
    const safeName =
      file.name.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(-120) ||
      "evidence.bin";
    const objectKey = `evidence/${evidenceId}/${safeName}`;
    const manifestKey = `manifests/${evidenceId}.json`;
    const shareToken = randomBytes(32).toString("hex");
    const shareExpiresAt = new Date(
      Date.now() + 24 * 60 * 60 * 1000,
    ).toISOString();

    const upload = await putObject({
      key: objectKey,
      body: buffer,
      contentType,
    });

    if (!upload.VersionId) {
      throw new Error("Backblaze did not return an object version ID.");
    }

How is the evidence object prepared?

  • safeName removes unsafe filename characters before building a unique evidence key.
  • objectKey and manifestKey separate original file bytes from reconstruction metadata.
  • shareToken provides a random capability for the viewer route.
  • upload.VersionId captures the exact immutable B2 version that later checks must target.
  • Append the manifest upload and MongoDB upsert directly below the version check:
    const manifest = {
      ...commonRecord,
      vaulted: true,
      objectKey,
      objectVersionId: upload.VersionId,
      manifestKey,
      shareToken,
      shareExpiresAt,
    };

    await putObject({
      key: manifestKey,
      body: Buffer.from(JSON.stringify(manifest, null, 2)),
      contentType: "application/json",
    });

    await collection.updateOne(
      { evidenceId },
      { $set: manifest },
      { upsert: true },
    );

    return Response.json(
      { message: "Evidence and recovery manifest stored in B2." },
      { status: 201 },
    );

Why store the manifest twice?

The B2 copy is the immutable recovery record. The MongoDB copy provides the fast searchable index used by the dashboard.

The manifest preserves the case details, SHA-256 fingerprint, object key, exact version ID, and share capability needed for reconstruction.

  • Save app/api/evidence/route.js.
  • Check the completed route by running:
node --check app/api/evidence/route.js

What does the final syntax check confirm?

The terminal returns to the prompt without a syntax error. Your route now contains both the metadata-only path and the B2 vault path.

Route check reporting an error?

  • Confirm that the new vault logic sits after the complete metadata-only fallback block.
  • Check that the manifest code appears before the existing catch block.
  • Confirm that the final imports reference ../../../lib/b2.js and ../../../lib/mongodb.js.

Need help locating the mismatch? Help me compare app/api/evidence/route.js with the supplied complete route without asking me to share credentials.

✔️ Awesome, I've got everything!

Your evidence route is ready. Save the file before testing a fresh upload.

ⓧ I'd like to double check the full code

import { createHash, randomBytes, randomUUID } from "node:crypto";
import { hasVaultConfig, putObject } from "../../../lib/b2.js";
import { evidenceCollection } from "../../../lib/mongodb.js";

const MAX_DEMO_FILE_BYTES = 10 * 1024 * 1024;

export async function GET() {
  try {
    const collection = await evidenceCollection();
    const documents = await collection
      .find({ demo: true })
      .sort({ createdAt: -1 })
      .toArray();

    const items = documents.map(({ _id, shareToken, ...document }) => ({
      ...document,
      sharePath: shareToken
        ? `/api/view?token=${encodeURIComponent(shareToken)}`
        : null,
    }));

    return Response.json({ items });
  } catch (error) {
    return Response.json({ error: error.message }, { status: 500 });
  }
}

export async function POST(request) {
  try {
    const formData = await request.formData();
    const file = formData.get("file");
    const caseId = String(formData.get("caseId") ?? "").trim();
    const description = String(formData.get("description") ?? "").trim();

    if (!file || typeof file.arrayBuffer !== "function") {
      return Response.json({ error: "Choose a file." }, { status: 400 });
    }
    if (!caseId || !description) {
      return Response.json(
        { error: "Case ID and description are required." },
        { status: 400 },
      );
    }
    if (file.size > MAX_DEMO_FILE_BYTES) {
      return Response.json(
        { error: "Use a synthetic demo file no larger than 10 MiB." },
        { status: 400 },
      );
    }

    const buffer = Buffer.from(await file.arrayBuffer());
    const evidenceId = randomUUID();
    const createdAt = new Date().toISOString();
    const sha256 = createHash("sha256").update(buffer).digest("hex");
    const contentType = file.type || "application/octet-stream";
    const commonRecord = {
      evidenceId,
      caseId,
      description,
      originalName: file.name,
      contentType,
      size: file.size,
      sha256,
      createdAt,
      demo: true,
    };
    const collection = await evidenceCollection();

    if (!hasVaultConfig()) {
      const metadataOnlyRecord = { ...commonRecord, vaulted: false };
      await collection.updateOne(
        { evidenceId },
        { $set: metadataOnlyRecord },
        { upsert: true },
      );
      return Response.json(
        {
          message:
            "Metadata saved, but the file bytes were discarded because B2 is not configured.",
        },
        { status: 201 },
      );
    }

    const safeName =
      file.name.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(-120) ||
      "evidence.bin";
    const objectKey = `evidence/${evidenceId}/${safeName}`;
    const manifestKey = `manifests/${evidenceId}.json`;
    const shareToken = randomBytes(32).toString("hex");
    const shareExpiresAt = new Date(
      Date.now() + 24 * 60 * 60 * 1000,
    ).toISOString();

    const upload = await putObject({
      key: objectKey,
      body: buffer,
      contentType,
    });

    if (!upload.VersionId) {
      throw new Error("Backblaze did not return an object version ID.");
    }

    const manifest = {
      ...commonRecord,
      vaulted: true,
      objectKey,
      objectVersionId: upload.VersionId,
      manifestKey,
      shareToken,
      shareExpiresAt,
    };

    await putObject({
      key: manifestKey,
      body: Buffer.from(JSON.stringify(manifest, null, 2)),
      contentType: "application/json",
    });

    await collection.updateOne(
      { evidenceId },
      { $set: manifest },
      { upsert: true },
    );

    return Response.json(
      { message: "Evidence and recovery manifest stored in B2." },
      { status: 201 },
    );
  } catch (error) {
    return Response.json({ error: error.message }, { status: 500 });
  }
}

How to use this reference

Compare this complete file with app/api/evidence/route.js. Pay close attention to the transition from the metadata-only fallback into the B2 upload path.

Before you upload the sample, do you expect the fresh card to remain Metadata only or switch to Vaulted?

  • Return to http://localhost:3000 in your browser.
  • Refresh the dashboard.
  • Keep CASE-001 as the case ID.
  • Keep Synthetic interview note as the description.
  • Choose sample-evidence.txt from the immutable-case-vault folder.
  • Click Upload evidence.

You should see a fresh card marked Vaulted. The status message confirms that the evidence and recovery manifest were stored in B2.

  • Return to B2 Cloud Storage in the Backblaze web console.
  • Click Buckets.
  • Open your unique B2 bucket name.
  • Open Browse Files.
  • Inspect the fresh object under evidence/.
  • Confirm that the evidence object shows Compliance Mode with an Object Lock expiration.
  • Inspect the matching JSON object under manifests/.
  • Confirm that the manifest also shows Compliance Mode with an Object Lock expiration.

That closes the storage gap. The original bytes and their recovery manifest now survive independently of the MongoDB index.

Fresh card still says Metadata only?

  • Confirm that all five AWS settings exist in .env.local.
  • Confirm that the endpoint includes the complete B2 host name.
  • Restart the development server after correcting any environment value.

Need help tracing the configuration? Help me diagnose why hasVaultConfig() is false without asking me to reveal any credential values.

Your private vault now stores an exact evidence version and an immutable reconstruction manifest. Next, you will challenge that protection with a delete attempt and open the private file through a temporary read-only link.

Test Retention and Share Evidence

Your previous step connected the dashboard to Backblaze B2 Cloud Storage and MongoDB Atlas. Each Vaulted card now points to an exact stored object version.

A vault claim needs proof. In this step, you will test the active Object Lock retention against a permanent deletion request. You will also open private evidence without exposing the application key.

In this step, get ready to:
  • Test whether active compliance retention blocks deletion of an exact object version.
  • Create an expiring read-only link for private evidence.
  • Confirm that the browser can open the retained file.
Add the lock-test route

The stored VersionId identifies the exact evidence version protected by retention. The route checks that version before issuing its permanent deletion request.

  • Use the Explorer controls in Visual Studio Code from earlier to create app/api/lock-test/route.js inside the existing app/api folder.

You should see route.js inside the new lock-test folder.

  • Start the lock-test route by pasting this first section into app/api/lock-test/route.js:
import { deleteVersion, getRetention } from "../../../lib/b2.js";
import { evidenceCollection } from "../../../lib/mongodb.js";

export async function POST(request) {
  try {
    const { evidenceId } = await request.json();
    const collection = await evidenceCollection();
    const record = await collection.findOne({ evidenceId, vaulted: true });

    if (!record) {
      return Response.json({ error: "Vaulted evidence not found." }, { status: 404 });
    }

    const retention = await getRetention({
      key: record.objectKey,
      versionId: record.objectVersionId,
    });
    const retentionActive =
      retention?.Mode === "COMPLIANCE" &&
      retention.RetainUntilDate instanceof Date &&
      retention.RetainUntilDate > new Date();

What does this route section do?

  • The route uses evidenceId to find one vaulted MongoDB record.
  • The record supplies the exact objectKey and objectVersionId stored during upload.
  • The retention check requires COMPLIANCE mode with a future RetainUntilDate.
  • Complete the route by pasting this second section directly below the existing code:
    if (!retentionActive) {
      return Response.json(
        { error: "Active COMPLIANCE retention was not confirmed. Stop here." },
        { status: 409 },
      );
    }

    try {
      await deleteVersion({
        key: record.objectKey,
        versionId: record.objectVersionId,
      });
      return Response.json(
        { error: "The exact version was deleted unexpectedly. Review bucket retention." },
        { status: 409 },
      );
    } catch (deleteError) {
      return Response.json({
        message: `Delete rejected while COMPLIANCE retention is active until ${retention.RetainUntilDate.toISOString()}.`,
        errorName: deleteError.name,
      });
    }
  } catch (error) {
    return Response.json({ error: error.message }, { status: 500 });
  }
}

How is the deletion test controlled?

  • The route stops before deletion unless it confirms active compliance retention.
  • The deletion request includes the stored VersionId, so it targets the exact evidence version.
  • The expected rejection becomes the success message displayed by the dashboard.
  • Save app/api/lock-test/route.js.
  • Return to the dashboard at http://localhost:3000.

This test requests permanent deletion of the exact stored version. The route first confirms that retention is active, which keeps the test bounded to the protected object.

Before you click the control, do you think the exact version can be removed while its compliance retention is active? Keep your answer in mind.

  • Click Test immutable delete on a Vaulted card.

You should see a message that deletion was rejected while compliance retention remains active. That is the vault claim proven with the exact retained version.

Delete test not showing a rejection?

  • Confirm that the card says Vaulted before running the test.
  • Confirm that the scoped application key still has Read and Write access to the project bucket.
  • Return to Browse Files in Backblaze to confirm that the object shows Compliance Mode with a future Object Lock expiration.

Still stuck? Help me diagnose why my exact-version retention test is not returning the expected rejected deletion.

✔️ Awesome, I've got everything!

Your lock-test route now confirms retention before attempting the exact-version deletion.

ⓧ I'd like to double check the full code

import { deleteVersion, getRetention } from "../../../lib/b2.js";
import { evidenceCollection } from "../../../lib/mongodb.js";

export async function POST(request) {
  try {
    const { evidenceId } = await request.json();
    const collection = await evidenceCollection();
    const record = await collection.findOne({ evidenceId, vaulted: true });

    if (!record) {
      return Response.json({ error: "Vaulted evidence not found." }, { status: 404 });
    }

    const retention = await getRetention({
      key: record.objectKey,
      versionId: record.objectVersionId,
    });
    const retentionActive =
      retention?.Mode === "COMPLIANCE" &&
      retention.RetainUntilDate instanceof Date &&
      retention.RetainUntilDate > new Date();

    if (!retentionActive) {
      return Response.json(
        { error: "Active COMPLIANCE retention was not confirmed. Stop here." },
        { status: 409 },
      );
    }

    try {
      await deleteVersion({
        key: record.objectKey,
        versionId: record.objectVersionId,
      });
      return Response.json(
        { error: "The exact version was deleted unexpectedly. Review bucket retention." },
        { status: 409 },
      );
    } catch (deleteError) {
      return Response.json({
        message: `Delete rejected while COMPLIANCE retention is active until ${retention.RetainUntilDate.toISOString()}.`,
        errorName: deleteError.name,
      });
    }
  } catch (error) {
    return Response.json({ error: error.message }, { status: 500 });
  }
}
Add the temporary viewer route

A private bucket requires authorization before anyone can download its objects. The viewer route turns the random share token into a five-minute pre-signed URL for one exact immutable version.

  • Use the Explorer controls in Visual Studio Code to create app/api/view/route.js inside the existing app/api folder.

You should see route.js inside the new view folder.

  • Start the viewer route by pasting this first section into app/api/view/route.js:
import { createReadUrl } from "../../../lib/b2.js";
import { evidenceCollection } from "../../../lib/mongodb.js";

export async function GET(request) {
  try {
    const token = new URL(request.url).searchParams.get("token");
    if (!token) {
      return Response.json({ error: "Missing share token." }, { status: 400 });
    }

    const collection = await evidenceCollection();
    const record = await collection.findOne({
      shareToken: token,
      vaulted: true,
    });

    if (!record) {
      return Response.json({ error: "Share link not found." }, { status: 404 });
    }

How does the share token work?

  • The route reads the random token from the request URL.
  • MongoDB resolves that token to a vaulted record without sending the application key to the browser.
  • A missing or unknown token stops before a B2 read URL is created.
  • Complete the viewer route by pasting this second section directly below the existing code:
    if (new Date(record.shareExpiresAt) <= new Date()) {
      return Response.json({ error: "Share link expired." }, { status: 410 });
    }

    const signedUrl = await createReadUrl({
      key: record.objectKey,
      versionId: record.objectVersionId,
    });

    return Response.redirect(signedUrl, 302);
  } catch (error) {
    return Response.json({ error: error.message }, { status: 500 });
  }
}

How is read access limited?

  • The application rejects a token after its stored shareExpiresAt time.
  • The B2 helper signs GetObjectCommand for the exact objectVersionId.
  • The generated B2 URL expires after expiresIn: 300, which limits it to five minutes.
  • Save app/api/view/route.js.
  • Return to the dashboard tab.

Before you open the file, do you think the browser needs to receive your Backblaze application key? Keep your answer in mind.

  • Click Open evidence on the same Vaulted card.

You should see the private sample open in a new browser tab through the temporary URL. Your application key stays on the server.

Private evidence not opening?

  • Confirm that you clicked Open evidence on a Vaulted card with an available link.
  • Refresh the dashboard if the card was uploaded before the B2 configuration was added.
  • Upload a fresh copy of sample-evidence.txt if the existing record says Metadata only.

Still stuck? Help me diagnose why my private evidence link does not open the exact Backblaze B2 object version.

✔️ Awesome, I've got everything!

Your viewer route now validates the share token before redirecting to an expiring exact-version URL.

ⓧ I'd like to double check the full code

import { createReadUrl } from "../../../lib/b2.js";
import { evidenceCollection } from "../../../lib/mongodb.js";

export async function GET(request) {
  try {
    const token = new URL(request.url).searchParams.get("token");
    if (!token) {
      return Response.json({ error: "Missing share token." }, { status: 400 });
    }

    const collection = await evidenceCollection();
    const record = await collection.findOne({
      shareToken: token,
      vaulted: true,
    });

    if (!record) {
      return Response.json({ error: "Share link not found." }, { status: 404 });
    }
    if (new Date(record.shareExpiresAt) <= new Date()) {
      return Response.json({ error: "Share link expired." }, { status: 410 });
    }

    const signedUrl = await createReadUrl({
      key: record.objectKey,
      versionId: record.objectVersionId,
    });

    return Response.redirect(signedUrl, 302);
  } catch (error) {
    return Response.json({ error: error.message }, { status: 500 });
  }
}
Try another browser-viewable file

The vault preserves each file's content type. Backblaze B2 also supports byte-range delivery, which lets compatible browsers seek through stored audio or video.

  • Return to the local dashboard.
  • Choose a harmless PDF, audio file, or video file in the File field.
  • Click Upload evidence.

You should see a fresh card marked Vaulted with its size and SHA-256 fingerprint.

Before you open the new item, what browser behavior do you expect from its stored content type? Keep your answer in mind.

  • Click Open evidence on the new Vaulted card.

You should see the PDF or media file open through its temporary read-only URL. Compatible audio and video files include the browser's normal playback controls.

You have now proven both sides of controlled evidence access. The retained version resists deletion while an expiring link still allows authorized reading. Next, you will erase the disposable MongoDB index and rebuild it from the protected manifests.

Rebuild the MongoDB Index

Your vault now rejects deletion of an actively retained version. It also opens that exact version through a temporary read-only link.

The searchable MongoDB index is still disposable. Atlas Free does not provide backups, so immutable manifests in Backblaze B2 become the recovery source for this demo.

In this step, get ready to:
  • Add a reset endpoint that removes only demo index records.
  • Add a rebuild endpoint that restores records from JSON manifests.
  • Prove the recovered card keeps its case ID, SHA-256 fingerprint, and working evidence link.
Add the demo-only reset route

The reset endpoint turns index loss into something you can see. This Next.js Route Handler removes documents marked for the demo while leaving every B2 object untouched.

  • In the Explorer sidebar in Visual Studio Code, create app/api/reset/route.js inside the immutable-case-vault folder.

You should see route.js inside the new reset folder.

  • Add the demo-only reset behavior to app/api/reset/route.js by pasting this code:
import { evidenceCollection } from "../../../lib/mongodb.js";

export async function POST() {
  try {
    const collection = await evidenceCollection();
    const result = await collection.deleteMany({ demo: true });
    return Response.json({
      message: `Removed ${result.deletedCount} demo index record(s). B2 was not touched.`,
    });
  } catch (error) {
    return Response.json({ error: error.message }, { status: 500 });
  }
}

What does this route do?

  • The route uses evidenceCollection() to access the existing case_vault.evidence collection.
  • The deleteMany({ demo: true }) filter limits the reset to demo documents.
  • The response reports how many index records were removed. Backblaze B2 receives no deletion request.
  • Save app/api/reset/route.js.

Before you reset the index, what do you expect the dashboard to show when every demo document disappears?

  • Switch back to the running dashboard at http://localhost:3000.
  • Click Reset MongoDB Index in the recovery controls.

You should see zero evidence cards. The status message reports the removed record count and confirms that B2 was untouched.

You have made the index loss visible. Your protected evidence still exists outside MongoDB.

Still seeing evidence cards?

  • Confirm the route file is saved at app/api/reset/route.js.
  • Check the development server terminal for a route compilation error.
  • Refresh the dashboard after the reset response completes.

Still stuck? Help me troubleshoot why my Next.js reset route is not removing demo MongoDB records

Add the manifest rebuild route

Each manifest holds the fields needed to reconstruct a searchable record. The rebuild route reads every current manifest key before restoring valid demo records with an upsert.

  • In the Explorer sidebar, create app/api/rebuild/route.js inside the immutable-case-vault folder.

You should see route.js inside the new rebuild folder.

  • Add the rebuild route frame to app/api/rebuild/route.js by pasting this code:
import {
  getJsonObject,
  hasVaultConfig,
  listManifestKeys,
} from "../../../lib/b2.js";
import { evidenceCollection } from "../../../lib/mongodb.js";

export async function POST() {
  try {
    if (!hasVaultConfig()) {
      return Response.json({ error: "B2 is not configured." }, { status: 503 });
    }

    const collection = await evidenceCollection();
    const keys = await listManifestKeys();
    const failedKeys = [];
    let restored = 0;

    return Response.json({
      message: `Restored ${restored} record(s) from B2 manifests. Failed manifests: ${failedKeys.length}.`,
      failedKeys,
    });
  } catch (error) {
    return Response.json({ error: error.message }, { status: 500 });
  }
}

What does the route frame do?

  • The configuration guard stops recovery when the B2 connection settings are absent.
  • The listManifestKeys() helper returns every current object key under manifests/.
  • The counters prepare the final response to report successful restorations and unreadable manifests.
  • Find let restored = 0; in app/api/rebuild/route.js.
  • Insert this recovery loop directly below that line:
    for (const key of keys) {
      try {
        const manifest = await getJsonObject(key);
        if (manifest.demo === true && manifest.evidenceId) {
          await collection.updateOne(
            { evidenceId: manifest.evidenceId },
            { $set: manifest },
            { upsert: true },
          );
          restored += 1;
        }
      } catch {
        failedKeys.push(key);
      }
    }

How does recovery work?

  • The loop reads each manifest with getJsonObject().
  • The condition accepts only demo manifests that contain an evidenceId.
  • The updateOne() call replaces matching fields from the manifest. The upsert: true option creates the document when no matching record exists.
  • A failed manifest is recorded without stopping recovery of the remaining objects.
  • Save app/api/rebuild/route.js.

Before you rebuild, do you think the app can restore the card without another file upload?

  • Switch back to the running dashboard.
  • Click Rebuild from B2.

You should see the vaulted evidence card return. The status message reports the number of restored records and any failed manifests.

That is the recovery proof. The immutable manifest has recreated the disposable MongoDB record.

Did the rebuild restore zero records?

  • Confirm app/api/rebuild/route.js contains the recovery loop directly below let restored = 0;.
  • Check that the B2 settings remain present in .env.local.
  • Confirm your earlier vaulted upload created a manifest object under manifests/.

Still stuck? Help me troubleshoot why my rebuild route is not restoring MongoDB records from B2 manifests

✔️ Awesome, I've got everything!

Your rebuild route now checks the B2 configuration, reads every manifest, and upserts valid demo records into MongoDB.

ⓧ I'd like to double check the full code

import {
  getJsonObject,
  hasVaultConfig,
  listManifestKeys,
} from "../../../lib/b2.js";
import { evidenceCollection } from "../../../lib/mongodb.js";

export async function POST() {
  try {
    if (!hasVaultConfig()) {
      return Response.json({ error: "B2 is not configured." }, { status: 503 });
    }

    const collection = await evidenceCollection();
    const keys = await listManifestKeys();
    const failedKeys = [];
    let restored = 0;

    for (const key of keys) {
      try {
        const manifest = await getJsonObject(key);
        if (manifest.demo === true && manifest.evidenceId) {
          await collection.updateOne(
            { evidenceId: manifest.evidenceId },
            { $set: manifest },
            { upsert: true },
          );
          restored += 1;
        }
      } catch {
        failedKeys.push(key);
      }
    }

    return Response.json({
      message: `Restored ${restored} record(s) from B2 manifests. Failed manifests: ${failedKeys.length}.`,
      failedKeys,
    });
  } catch (error) {
    return Response.json({ error: error.message }, { status: 500 });
  }
}

What does the complete route do?

  • The route retrieves the complete manifest key list through the existing paginated helper.
  • Each valid manifest restores one MongoDB document through an upsert.
  • The response separates successful restorations from manifest read failures.
Verify the recovered evidence

A returned card proves the manifest restored searchable metadata. The final check confirms that the recovered share token still reaches the exact retained object version.

  • Compare the restored card's case ID with the case ID shown before the reset.
  • Compare the restored card's SHA-256 fingerprint with the fingerprint shown before the reset.

Before you open the evidence, do you expect the recovered share token to reach the same immutable file?

  • Click Open evidence on the restored card.

A new browser tab should show the contents of sample-evidence.txt. The restored index has reopened the exact B2 object through its recovered share token.

You have proved the full recovery path. MongoDB can disappear while the protected evidence and its reconstruction manifest remain authoritative.

Secret mission

Verify Every Stored File

Retention protects each stored version from deletion or replacement. Add a server-side integrity audit that downloads every vaulted demo object and proves its bytes still match the SHA-256 fingerprint in the immutable manifest.

Clean Up Your Resources

The first 10 GB of B2 storage is free. Storage beyond that allowance starts at $6.95 per TB per month.

Free egress covers up to three times your average monthly storage. Additional egress costs $0.01 per GB unless an eligible partner path applies.

This demo uses tiny synthetic files. Real case media can exceed the free allowance quickly because B2 keeps object versions by default.

Choose the delete option if you no longer need the vault. Pausing the local server does not remove stored B2 data.

Nice Work!

Nice Work!

You did it! Your local Next.js case-file vault now treats Backblaze B2 Cloud Storage as the protected source of truth while MongoDB Atlas acts as a rebuildable search index.

You've learned how to:

  • Expose the weakness of a metadata-only prototype. Store each evidence file with an immutable recovery manifest under one-day Object Lock retention.
  • Target an exact object version to prove its deletion is rejected during compliance retention. Open the private file through a five-minute pre-signed read URL without exposing the application key.
  • Erase the disposable MongoDB index. Reconstruct every record from immutable B2 manifests. Confirm the restored card keeps its case ID. Verify its SHA-256 fingerprint stays unchanged.
  • Secret Mission: Build a server-side integrity audit that downloads every exact stored version. Recalculate each fingerprint from the file bytes. Confirm allMatch reports true for all vaulted demo records.

Ready to quiz yourself?