---
title: "Protect a file so only the right people open it"
description: "Protect a file with protect, release it at a set time, share it through getqr.id, see who opened it and revoke it in seconds, all on the local driver first."
section: "guides"
status: "Building for 0.4"
spec: "protect-taps"
canonical: "https://mainsdk.dev/docs/guides/protect-an-app"
updated: "2026-10-28"
---

# Protect a file so only the right people open it

> Protect a file with protect, release it at a set time, share it through getqr.id, see who opened it and revoke it in seconds, all on the local driver first.

Status: Building for 0.4 · Canonical: https://mainsdk.dev/docs/guides/protect-an-app · Updated: 2026-10-28

You will protect a file so it stays ciphertext until its policy allows an open, hold it until a release time, share it with named people, read who opened it, and revoke it. You build it on the `local` driver, which fakes a TAPS Node in your process, so it needs no account. It takes about 20 minutes.

> **Note:** `protect` is in 0.4 (spec `protect-taps`) and experimental. The `local` driver is for development and tests only: `mainsdk check` refuses it in production (`MAIN-CHECK-0026`). A real TAPS Node needs the `@mainsdk/protect-taps` package in this repository; nothing is published to npm.

## You need

- A MainSDK app with a `main.yaml` (see [Thirty-minute path](https://mainsdk.dev/docs/getting-started/thirty-minutes)) and a signed-in user (see [Sign-in and sessions](https://mainsdk.dev/docs/interfaces/auth)).
- For real links outside tests, a getqr.id key as `secret:GETQR_API_KEY` (see [Share links and QR codes](https://mainsdk.dev/docs/interfaces/share)).

## 1. Turn on the driver

```yaml title="main.yaml"
protect:
  driver: local          # taps-node in production
```

`local` is the default, so a missing `protect` section behaves the same.

## 2. Protect a file with a release time

```ts title="app/api/papers/route.ts"
import { auth, protect } from "mainsdk";

export async function POST(req: Request) {
  await auth.require(req, { role: "exam-admin" });
  const form = await req.formData();
  const paper = form.get("paper") as File;

  const asset = await protect(paper, {
    label: "sensitive",
    policy: { releaseAt: "2027-03-01T09:00:00Z" },   // no licence before this time, for anyone
  });
  return Response.json({ asset: asset.id });
}
```

You should get an asset with an id such as `urn:taps:asset:01M5…` and `state: "active"`. `label` is one of `public`, `internal`, `confidential`, `restricted`, or `personal`, `sensitive`, `child`, `phi`. Policy is default deny; add `people`, `zones`, `geo`, `time` or `purposes` to narrow it further.

## 3. Open it

`protect.open()` asks for a licence for the current user and returns the content only when the policy allows it.

```ts title="app/papers/read.ts"
import { protect } from "mainsdk";

export async function readPaper(id: string) {
  const opened = await protect.open(id);
  try {
    return (await opened.text()).text;   // the text layer a screen reader uses
  } finally {
    await opened.close();
  }
}
```

Before `releaseAt`, the open fails with `MAIN-PROTECT-0002` and a `retryAfter`, and the attempt is written to the decision log. After it, the open succeeds. Pages are available from `opened.pages()`; `read()` needs `mode: wrapper`.

## 4. Share it

```ts title="app/papers/share.ts"
import { protect } from "mainsdk";

export async function sharePaper(assetId: string, email: string) {
  const link = await protect.share(assetId, { to: [email], expiresIn: "7d", maxOpens: 3 });
  return link.url;   // a getqr.id link that opens the TAPS Web Reader
}
```

Recipients open the file in the Web Reader with nothing to install. The TAPS link token is never in the getqr.id path, your logs or channel data.

## 5. See who opened it, and revoke

```ts title="app/api/assets/[id]/route.ts"
import { auth, protect } from "mainsdk";

export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
  await auth.require(req, { role: "exam-admin" });
  const { id } = await params;
  return Response.json(await protect.activity(id));   // { items: [{ at, who, action, decision }], next }
}

export async function DELETE(req: Request, { params }: { params: Promise<{ id: string }> }) {
  await auth.require(req, { role: "exam-admin", fresh: "5m" });
  const { id } = await params;
  await protect.revoke(id, { reason: "paper leaked" });
  return new Response(null, { status: 204 });
}
```

A revoked asset fails the next open and the next licence renewal, so an open session stops within one `renew` interval (10 seconds by default). `protect.revoke(id, { person })` stops one person and leaves the rest.

## 6. Test it on the fake clock

```ts title="app/papers/protect.test.ts"
import assert from "node:assert/strict";
import { it } from "node:test";
import { createTestApp } from "@mainsdk/testing";

it("protect-taps releases a paper only after releaseAt", async () => {
  const app = createTestApp({ yaml: "schema: 1\nproject: papers\n" });
  await app.tenancy.as({ user: "u1" }, async () => {
    const asset = await app.protect(new TextEncoder().encode("Q1 paper"), {
      label: "sensitive",
      policy: { releaseAt: "2027-03-01T09:00:00Z" },
    });
    await assert.rejects(app.protect.open(asset.id), { code: "MAIN-PROTECT-0002" });
    app.clock.set(Date.parse("2027-03-01T09:00:01Z"));
    const opened = await app.protect.open(asset.id);
    assert.equal((await opened.text()).text, "Q1 paper");
    await opened.close();
    await app.protect.revoke(asset.id, { reason: "test" });
    await assert.rejects(app.protect.open(asset.id), { code: "MAIN-PROTECT-0003" });
  });
});
```

## Verify

```bash
npx mainsdk check --env prod
```

A pass has no `MAIN-PROTECT` finding and no `MAIN-CHECK-0026`. With `driver: local` in production the check fails; switch the driver to a TAPS Node with `protect.url`, `protect.share-domain` and `protect.key: secret:MAINSDK_PROTECT_KEY` first (see [TAPS Node and SoveDocs](https://mainsdk.dev/docs/integrations/taps)).

## If it fails

| Code | Cause | Fix |
| --- | --- | --- |
| `MAIN-PROTECT-0001` | The policy does not allow this open. | Check with `protect.policy.check(asset, { as })`. |
| `MAIN-PROTECT-0002` | Opened before `releaseAt`. | Wait for the release time; `retryAfter` says how long. |
| `MAIN-PROTECT-0003` | The asset was revoked or erased. | Protect and share a new copy. |
| `MAIN-PROTECT-0005` | The change needs a second person. | Pass `approvals` from another person. |
| `MAIN-PROTECT-0015` | No user in the context, or an unknown party or agent. | Run inside a signed-in request or `tenant.as({ user }, fn)`; name parties under `protect.threshold`. |
| `MAIN-CHECK-0026` | The `local` driver in production. | Use `taps-node`, `taps-hosted` or `sovedocs`. |

## For agents

```text
Goal: protect files this app shares, with a release time and revocation.
Read: docs/specs/protect-taps.md, this page, main.yaml.
Touch only: main.yaml (protect section), files that store or share documents.
Do: replace plain share of sensitive files with protect() then protect.share(); set label and policy; add a test on the fake clock.
Verify: pnpm mainsdk check --env prod --json; node --test on the new test.
Never use the local driver in production, put a TAPS link token in a log, or approve a change on behalf of another person.
```

## Next

- [Protected files](https://mainsdk.dev/docs/interfaces/protect): every call, policy key, the ledger and the driver notes.
- [Share links and QR codes](https://mainsdk.dev/docs/interfaces/share): how protected files are shared through getqr.id.
- [Show untrusted text safely and protect web responses](https://mainsdk.dev/docs/guides/sanitize-and-protect-inbound): headers, CSRF, CORS and sanitized HTML.
