MainSDK
Search⌘K
How-to

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.

Building for 0.4

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
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.

You need

1. Turn on the driver

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

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.

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

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

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

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).

If it fails

CodeCauseFix
MAIN-PROTECT-0001The policy does not allow this open.Check with protect.policy.check(asset, { as }).
MAIN-PROTECT-0002Opened before releaseAt.Wait for the release time; retryAfter says how long.
MAIN-PROTECT-0003The asset was revoked or erased.Protect and share a new copy.
MAIN-PROTECT-0005The change needs a second person.Pass approvals from another person.
MAIN-PROTECT-0015No 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-0026The 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

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet