Protected files
protectProtect a file so it opens only under its policy on a TAPS Node, can be released at a set time, revoked in seconds and records every open.
protect-tapsFree in your app, Apache-2.0Status 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.
protect keeps a file under your control after you share it: it stays ciphertext until a TAPS Node issues a licence, so you can time-release it, revoke it in seconds and see who opened it.
For a walk-through, see Protect a file so only the right people open it.
At a glance
protect:
driver: taps-node # local in dev and tests
url: https://taps.acme.dev
share-domain: open.acme.dev
key: secret:MAINSDK_PROTECT_KEY
mode: viewer # viewer | wrapper
suite: TAPS-CS1 # TAPS-CS1 | TAPS-CS2
renew: 10s # licence renewal, 5s to 5m
mcp: false # true adds the taps_* tools to the MCP serverimport { protect, share } from "mainsdk";
const asset = await protect(paper, { label: "sensitive", policy: { releaseAt: "2027-03-01T09:00:00Z" } });
const { url } = await share(asset, { to: examiners }); // a getqr.id link that opens the TAPS Web Readerapp/papers/protect.test.ts
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
const app = createTestApp({ yaml: "schema: 1\nproject: papers\n" });
const asset = await app.tenancy.as({ user: "u1" }, () =>
app.protect(new TextEncoder().encode("Q1 paper"), { label: "sensitive" }),
);
const opened = await app.tenancy.as({ user: "u1" }, () => app.protect.open(asset.id));
assert.equal((await opened.text()).text, "Q1 paper");Calls
protect(file, { label, policy })protect.get(asset)protect.list({ cursor })protect.update(asset, { policy, label })protect.versions.create(asset, file)protect.revoke(asset, { reason, person })protect.erase(asset, { reason, approvals })protect.transfer(asset, { to })protect.activity(asset)protect.usage(asset)protect.open(asset, { purpose })protect.policy.check(asset, { as, agent, purpose })protect.policy.simulate(asset, policy)protect.policy.lint(policy)protect.zones.list()protect.zones.update(zones, { approvals })protect.threshold.get(asset)protect.share(asset, { to })protect.share.get(link)protect.share.list(asset)protect.share.update(link, change)protect.share.revoke(link)protect.share.stats(link, { consent })protect.people.create(asset, { person })protect.people.list(asset)protect.people.remove(asset, person)protect.agents(asset, { allowed })protect.delegate.create({ agent, assets, actions, purposes, expiresIn })protect.delegate.get(id)protect.delegate.list()protect.delegate.revoke(id)protect.approvals.list({ state })protect.approvals.get(id)protect.approvals.update(id, { decision })protect.query(assets, { question, purpose })protect.mcp.tools()protect.mcp.manifest()protect.mcp.verify(manifest, tools)protect.attest.agent(agent)protect.attest.verify(evidence, { kind })protect.inspect(asset, { function })protect.environment.get()protect.ledger.list({ asset })protect.ledger.proof(asset)protect.ledger.verify()protect.ledger.export()protect.leaks.create({ asset, evidence })protect.leaks.trace(caseId, { approvals })protect.lockdown.create({ reason, approvals })protect.lockdown.delete(id, { approvals })protect.channels.list()protect.channels.events(pattern)protect.channels.watch(pattern, fn)protect.channels.security()createProtect(deps)new ProtectError(code, message, opts?)PROTECT_CHANNELS(const)PROTECT_DRIVERS(const)localProtect(deps)lintPolicy(policy)parseProtectSettings(raw)protectHosts(settings)routeShare(base, built, { protect })
Guarantees
Whatever the driver.
- Ciphertext without a licence
- No licence before releaseAt, or before t of n parties release
- Revocation takes effect within one renew interval (10 s by default)
- Opens, denials and revocations in the MainSDK decision log and the TAPS ledger
- Agents act only inside a delegation from the person they act for
- Share links and QR codes through getqr.id
- No post-quantum claim until TAPS-CS3 is registered
Drivers
4 drivers. Select one to see its line in main.yaml.
protect:
driver: localChange this one line to swap providers; your calls stay the same.Built-in and local drivers integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-PROTECT-0020 | Checkmainsdk check, before deploy | A @taps/* package is imported outside @mainsdk/protect-taps |
MAIN-PROTECT-0021 | Checkmainsdk check, before deploy | The protect section is invalid: unknown key or driver, no url or share-domain for a taps-* driver, no key, suite: TAPS-CS3, renew outside 5s to 5m, or a threshold party without a URL |
MAIN-PROTECT-0022 | Checkmainsdk check, before deploy | A production build resolves @taps/* from a folder (link: or file:) instead of the registry |
MAIN-PROTECT-0001 | RuntimeError responses and logs | Licence denied by the policy (403); the TAPS error type is in taps, the failed condition only in the decision log |
MAIN-PROTECT-0002 | RuntimeError responses and logs | Time-locked: before releaseAt, or the release round isn't available yet (403, retryAfter) |
MAIN-PROTECT-0003 | RuntimeError responses and logs | The asset, the person's access, the link or the licence was revoked, or the asset was erased (403; 410 when erased) |
MAIN-PROTECT-0004 | RuntimeError responses and logs | No such asset, version, link, delegation or approval in this tenant (404, same body either way) |
MAIN-PROTECT-0005 | RuntimeError responses and logs | A person must approve first; the problem carries approvalId and retryAfter (403) |
MAIN-PROTECT-0006 | RuntimeError responses and logs | The approver declined, or the approval timed out (403) |
MAIN-PROTECT-0007 | RuntimeError responses and logs | Delegation invalid: wider than the person, expired, revoked, used up or deeper than 3 (403) |
MAIN-PROTECT-0008 | RuntimeError responses and logs | A query or play budget, or a delegation's maxUses, is used up (429) |
MAIN-PROTECT-0009 | RuntimeError responses and logs | A session, print, device or recipient-verification rate limit was passed (429, retryAfter) |
MAIN-PROTECT-0010 | RuntimeError responses and logs | Suite not supported: TAPS-CS3 is refused until it is registered; no post-quantum protection is claimed (400) |
MAIN-PROTECT-0011 | RuntimeError responses and logs | Threshold release not reached: fewer than t parties have released (403, retryable) |
MAIN-PROTECT-0012 | RuntimeError responses and logs | The asset or its label is locked down, suspended or quarantined (423) |
MAIN-PROTECT-0013 | RuntimeError responses and logs | Step-up required: the policy needs a fresher or stronger sign-in, listed in requirements (401) |
MAIN-PROTECT-0014 | RuntimeError responses and logs | The TAPS Node, a threshold party or the sealing service is unreachable (503, retryable); nothing opens without a licence |
MAIN-PROTECT-0015 | RuntimeError responses and logs | Invalid input: unknown policy key, label pan, a zone id that isn't opaque, t outside 2 to n, releaseAt in the past, an unknown party or an undeclared agent (400) |
MAIN-PROTECT-0016 | RuntimeError responses and logs | The file's version is superseded; location names the current version (409) |
MAIN-PROTECT-0017 | RuntimeError responses and logs | The ledger is stale or the registration is pending (503, retryAfter) |
MAIN-PROTECT-0018 | RuntimeError responses and logs | A query answer was refused by the output controls: the whole document, a verbatim span over the limit, or a denied field (422) |
MAIN-PROTECT-0019 | RuntimeError responses and logs | The driver's profile lacks the capability the call needs, such as query or web3 (501) |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| Protect, get, list, update, versions, revoke, erase, transfer, activity and usage | Phase 1 | 0.4 | protect-taps#local-core |
| Policy by person, device, network zone, geo, time and purpose | Phase 1 | 0.4 | protect-taps#policy |
| Release on time and threshold release | Phase 1 | 0.4 | protect-taps#release |
| Share through getqr.id into the TAPS Web Reader | Phase 1 | 0.4 | protect-taps#share |
| Agent delegation and human approval | Phase 1 | 0.4 | protect-taps#agents |
| Compute-only query | Phase 1 | 0.4 | protect-taps#query |
| Signed taps_* MCP tools | Phase 1 | 0.4 | protect-taps#mcp |
| Attestation, inspection, holdings and assistive access | Phase 1 | 0.4 | protect-taps#attest-inspect |
| Channels, decision log, ledger and security events | Phase 1 | 0.4 | protect-taps#rails |
| Post-quantum claim gate | Phase 1 | 0.4 | protect-taps#pq-gate |
| TAPS Node, hosted and SoveDocs drivers in @mainsdk/protect-taps | Phase 1 | 0.4 | protect-taps#driver-package |
MainSDK Cloud
Everything above is free in your app. Cloud adds these on top, by plan:
| Plan | What it adds |
|---|---|
| Fleet | Estate view of protected files, and revoke every file an app or person shared since a date |
Quick start
The local driver needs no setup. Protect a file, then share it. The share link is a getqr.id URL that opens the TAPS Web Reader.
protect:
driver: local # taps-node in productionapp/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 examiners = form.getAll("examiner").map(String);
const asset = await protect(paper, {
label: "sensitive",
policy: { releaseAt: "2027-03-01T09:00:00Z" }, // nobody opens it before this time
});
const { url } = await protect.share(asset.id, { to: examiners });
return Response.json({ url, asset: asset.id });
}use mainsdk::prelude::*;
let app = App::test();
Context::new().tenant("tnt_a").user("u1").scope(async {
let id = app.protect().open("u1", b"plan").await?;
app.protect().share(&id, "u1", "u2").await?;
assert_eq!(app.protect().get(&id, "u2").await?, b"plan");
Ok::<_, MainError>(())
}).await?;Every call reads the tenant, the user and the agent from the request context. Outside a request, run the call as a person with tenant.as({ user }, fn); an explicit user or agent option overrides the context for one call and is audited. With no user, a call fails with MAIN-PROTECT-0015.
Configuration
protect:
driver: taps-node # local | taps-node | taps-hosted | sovedocs
url: https://taps.acme.dev # the TAPS Node (not for local)
share-domain: open.acme.dev # where the Web Reader runs (not for local)
key: secret:MAINSDK_PROTECT_KEY # the app's issuer identity
mode: viewer # viewer | wrapper
suite: TAPS-CS1 # TAPS-CS1 | TAPS-CS2
renew: 10s # licence renewal, 5s to 5m
threshold:
board: https://taps.board.example
regulator: https://taps.regulator.example
mcp: false
environment: https://seal.acme.dev| Key | Type | What it does |
|---|---|---|
driver | string | local fakes a TAPS Node in process (the default). taps-node is a self-hosted TAPS Node, taps-hosted a hosted one, sovedocs a SoveDocs tenant's Node. |
url | URL | The TAPS Node. Required for the taps-* drivers and added to the driver's hosts, so the network allowlist lets it through. |
share-domain | host | The tenant's TAPS share domain, where the Web Reader runs. Required for the taps-* drivers. |
key | secret | The app's issuer identity: an ed25519 PKCS#8 key, as mainsdk seal keygen writes it. Required for the taps-* drivers; read through secrets, never from code. |
mode | string | The default delivery. viewer gives pages and a text layer; wrapper also allows read(). |
suite | string | TAPS-CS1 (default) or TAPS-CS2. TAPS-CS3 is refused until it is registered. |
renew | duration | How often a licence renews: the bound on revocation. Default 10s. |
threshold | map | Party names for policy.threshold.parties; for the taps-* drivers each is the party's Node URL. |
mcp | boolean | true adds the taps_* tools to the app's MCP server. |
environment | URL | The TAPS sealing service for releaseAt and threshold assets (taps-* drivers). |
mainsdk check reports a bad section as MAIN-PROTECT-0021, a @taps/* import outside the driver package as MAIN-PROTECT-0020, a production build that resolves @taps/* from a folder as MAIN-PROTECT-0022, and the local driver in production as MAIN-CHECK-0026.
The policy
Policy is default deny and runs on every licence request and every renew. The owner is never restricted by their own policy.
| Key | Meaning |
|---|---|
people | User ids, team:<id>, role:<name> and email addresses |
devices | { managed, attested } |
zones | Opaque network zone ids such as z-7f3a |
geo | { allow, exclude, confidence }, ISO 3166-1 countries |
time | { from, until, hours, weekdays, timeZone } |
purposes | What agents may do; train only when named |
releaseAt | RFC 3339; no licence before it, for anyone |
threshold | { t, parties }: t of the named parties must release |
holdings | Token-gated access through a chain driver |
offline, watermark, assistiveTools | Offline window, watermark, assistive tools |
Labels public, internal, confidential and restricted are the TAPS labels. personal maps to confidential; sensitive, child and phi map to restricted. The label pan is refused: card numbers are never protected or stored.
What you get
- A protected file carries its own identity, provenance and policy, and stays ciphertext until a TAPS Node issues a licence.
- Release at a set time, or when enough parties agree.
- Revoke in seconds, and see who opened it.
- App agents read it only inside a delegation.
- Use it for exam papers nobody can open early, tenders, results or any document you may need to pull back.
- Built on TAPS (Trust Anchor Provenance System), HubXero Labs' open protocol.
Calls
| Group | Calls |
|---|---|
| Assets | protect(), protect.get(), protect.list(), protect.update(), protect.versions.create(), protect.revoke(), protect.erase(), protect.transfer(), protect.activity(), protect.usage() |
| Opening | protect.open() returns licence, pages(), page(), text(), read(), print(), renew() and close() |
| Policy and zones | protect.policy.check(), .simulate(), .lint(), protect.zones.list(), .update(), protect.threshold.get() |
| Sharing | protect.share(), .get(), .list(), .update(), .revoke(), .stats(); protect.people.create(), .list(), .remove() |
| Agents | protect.agents(), protect.delegate.create(), .get(), .list(), .revoke(), protect.approvals.list(), .get(), .update(), protect.query() |
| MCP | protect.mcp.tools(), .manifest(), .verify() |
| Attestation and inspection | protect.attest.agent(), .verify(), protect.inspect(), protect.environment.get() |
| Ledger and response | protect.ledger.list(), .proof(), .verify(), .export(), protect.leaks.create(), .trace(), protect.lockdown.create(), .delete() |
| Events | protect.channels.list(), .events(), .watch(), .security() |
Lists return { items, next }. Changes that need a second pair of eyes (protect.erase(), protect.transfer(), lowering a label with protect.update(), protect.zones.update(), protect.lockdown.create() and .delete(), protect.leaks.trace()) take approvals from people other than the caller, and fail with MAIN-PROTECT-0005 without them.
Examples
Release an exam paper at a set time
Before releaseAt, no licence is issued and the attempt is written to the decision log and protect.activity().
import { protect } from "mainsdk";
export async function scheduleRelease(paper: File, examiners: string[]) {
const asset = await protect(paper, {
label: "sensitive",
policy: { people: examiners, releaseAt: "2027-03-01T09:00:00Z" },
});
return asset.id;
}Open a file
protect.open() asks for a licence for the current user or agent and returns nothing without one. A viewer gives pages and the accessible text layer; only mode: "wrapper" allows read(). text() returns { page, blocks, text }.
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();
}
}Revoke a file in seconds
A revoked asset, person or link fails the next open and the next renew, so an open session stops within one renew interval.
import { auth, protect } from "mainsdk";
export async function POST(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 });
}protect.revoke(id, { reason, person }) stops one person and leaves the rest.
See who opened a file
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;
const opens = await protect.activity(id); // { items, next }
return Response.json(opens);
}Open only inside a network zone
import { protect } from "mainsdk";
export async function officeOnly(paper: File, approvedBy: string) {
await protect.zones.update(
{ "z-7f3a": { name: "Head office", cidrs: ["203.0.113.0/24"] } },
{ approvals: [{ by: approvedBy }] },
);
return protect(paper, { label: "confidential", policy: { zones: ["z-7f3a"] } });
}Zone ids are opaque; protect.policy.lint() flags a name such as head-office.
Require several parties to release
Name each party under protect.threshold in main.yaml first; a party that isn't named fails with MAIN-PROTECT-0015. For the local driver a name with no URL is enough:
protect:
driver: local
threshold: { board: null, regulator: null, centre: null }import { protect } from "mainsdk";
export async function boardRelease(minutes: File) {
const asset = await protect(minutes, {
label: "restricted",
policy: {
threshold: { t: 2, parties: ["board", "regulator", "centre"] },
releaseAt: "2027-03-01T09:00:00Z",
},
});
return protect.threshold.get(asset.id); // { t: 2, n: 3, released: [] }
}No licence is issued until t parties have released their shares, and not before releaseAt when both are set.
Let an agent work within a delegation
An app agent acts only inside a delegation from the person it acts for, never wider than that person. An action in approve.for waits for the person's approval.
Run delegate.create as the person (in a signed-in request, or inside tenant.as({ user }, fn)): a delegation always starts with a person, and an agent can't make one (MAIN-PROTECT-0007).
import { protect } from "mainsdk";
export async function letReaderSummarise(assetId: string) {
const delegation = await protect.delegate.create({
agent: "reader-bot", // declared under agents in main.yaml
assets: [assetId],
actions: ["view", "print"],
purposes: ["summarize"],
expiresIn: "1h",
approve: { for: ["print"], timeout: "15m" },
});
return delegation.id;
}
export async function approvePrint(approvalId: string) {
return protect.approvals.update(approvalId, { decision: "approve" });
}protect.query() answers a question over protected assets without returning their bytes: answers are marked generated, carry citations and never hold a verbatim span over 25 tokens. An agent never approves its own request (MAIN-AGENT-0003).
Share through getqr.id
share(asset, { to }) and protect.share() return a getqr.id link and QR whose target is the TAPS link on your share domain. Outside the test kit they use your getqr.id key (secret:GETQR_API_KEY, see Share links and QR codes); the kit makes local links. The TAPS link token is never in the getqr.id path, in logs or in channel data, and every link page shows "Powered by getqr.id".
import { protect, share } from "mainsdk";
export async function sharePaper(assetId: string, email: string) {
const link = await protect.share(assetId, { to: [email], expiresIn: "7d", maxOpens: 3 });
return { url: link.url, id: link.id };
}
export async function stop(linkId: string) {
await share.revoke(linkId); // revokes the getqr.id link and the TAPS link
}Reference
Every call also takes the call options user, agent and signal. Signatures are in mainsdk/protect.
Policy
| Call | Returns | What it does |
|---|---|---|
protect.policy.simulate(asset, policy) | { gained, lost } | Compares a new policy with the asset's current one without changing anything. An invalid policy fails with MAIN-PROTECT-0015. |
Share links
A link is { id, url, qr, asset, expiresAt, opens }. The url and qr are getqr.id; the TAPS token is never in them. A link still being registered fails with MAIN-PROTECT-0017.
| Call | Returns | What it does |
|---|---|---|
protect.share.get(link) | The link | Reads one link. |
protect.share.list(asset, { cursor, limit }) | { items, next } | Lists the links of an asset. |
protect.share.update(link, change) | The link | Changes expiresIn, maxOpens (a whole number from 1) or actions. |
protect.share.revoke(link, { keepPeople }) | Nothing | Revokes the link and its getqr.id short link. If getqr.id is unreachable the link is already revoked, and the call fails with MAIN-PROTECT-0014; call it again to finish. |
protect.share.stats(link, { consent }) | { opens, optedIn } | Counts opens. Without consent: true it fails with MAIN-SHARE-0004. |
People
| Call | Returns | What it does |
|---|---|---|
protect.people.list(asset, { cursor, limit }) | { items, next } | Lists the direct grants on an asset. |
protect.people.remove(asset, person) | Nothing | Removes a person's grant. |
Delegations and approvals
| Call | Returns | What it does |
|---|---|---|
protect.delegate.get(id) | The delegation | Reads one delegation. Its state is active, revoked, expired or used-up. |
protect.delegate.list({ agent, cursor, limit }) | { items, next } | Lists delegations, optionally for one agent. |
protect.delegate.revoke(id, { reason }) | Nothing | Revokes a delegation. |
protect.approvals.get(id) | The approval request | Reads one pending or decided approval. Its state is pending, approved, denied or expired. |
MCP tools
| Call | Returns | What it does |
|---|---|---|
protect.mcp.manifest() | { tools, keyId, signature } | The name and description hash of each taps_* tool, signed by the app's key. |
protect.mcp.verify(manifest, tools) | boolean | Checks the taps_* entries of a tools list against a signed manifest. It returns false when the key, the signature, a tool or a description does not match. |
Attestation, ledger and lockdown
| Call | Returns | What it does |
|---|---|---|
protect.attest.verify(evidence, { kind }) | { kind, accepted, root, reason? } | Appraises evidence bytes of kind device, environment or renderer. The driver must support attestation. |
protect.ledger.proof(asset) | { proof, verdict } | Proves an asset's registration is in the ledger. The verdict is valid, invalid or unanchored. |
protect.lockdown.delete(id, { approvals }) | Nothing | Lifts a lockdown. It needs approvals from other people. |
Events
| Call | Returns | What it does |
|---|---|---|
protect.channels.events(pattern) | ChannelEvent[] | Events seen on the channels, optionally filtered by a pattern. |
protect.channels.watch(pattern, fn) | A function | Calls fn for each matching event. Call the returned function to stop. |
Building it yourself
mainsdk builds protect for you from main.yaml. These are for driver authors, tests and tools.
| Name | What it is |
|---|---|
createProtect(deps) | Builds the interface on a driver, with the settings and the SDK's services. |
localProtect(deps) | Builds the local driver. |
new ProtectError(code, message, opts?) | The error every call throws: a MAIN-PROTECT-* code, with the TAPS type in taps and the approval in approvalId when there is one. |
PROTECT_CHANNELS(const) | The eight mainsdk:protect.* channel names. |
PROTECT_DRIVERS(const) | The driver ids a protect section may name: local, taps-node, taps-hosted and sovedocs. |
lintPolicy(policy) | Checks a policy and returns its issues; protect.policy.lint() calls it. |
parseProtectSettings(raw) | Reads and checks the protect section, returning { settings, issues }. |
protectHosts(settings) | The hosts the driver reaches, so the network allowlist can declare them. Empty for local. |
routeShare(base, built, { protect }) | Routes share(asset, { to }) and share.revoke() for a protected asset to protect, and everything else to the plain getqr.id share. |
Protecting web requests is a different tool
protect guards files. The headers, CSRF checks, CORS rules and cookie flags that guard your web responses are inbound protection, and showing user or model text as safe HTML is sanitize; both are covered in that guide and have no interface page of their own.
Channels, logs and the ledger
Every decision and side effect goes on one of eight mainsdk:protect.* channels, to the decision log (log.audit, with the actor, on_behalf_of, purpose and policy hash) and to the ledger. Channel data is ids, codes and kinds only: never content, names, emails, tokens or IP addresses.
| Channel | Fires on |
|---|---|
mainsdk:protect.create | Every protect() and protect.versions.create() |
mainsdk:protect.licence | Every licence request and renew, issued or denied |
mainsdk:protect.revoke | Every revoke of an asset, person, link or delegation, and every erase |
mainsdk:protect.share | Every link made, changed or revoked, and every grant |
mainsdk:protect.delegate | Every delegation made, refused or revoked, and every approval decision |
mainsdk:protect.query | Every query, answered or refused |
mainsdk:protect.inspect | Every inspection verdict |
mainsdk:protect.policy | Every policy, zone, label, lockdown or owner change |
The local ledger is append-only and hash-chained. protect.ledger.verify() finds any edit, deletion or reorder, and protect.ledger.export() returns an in-toto Statement in a DSSE envelope, signed by the app's key. Security events are raised for protect.denied_burst (20 denials of one asset in a minute), protect.leak_reported, protect.lockdown and protect.quarantined; read them with protect.channels.security().
Errors and checks
| Case | What happens | Fix |
|---|---|---|
| The policy does not allow the open | MAIN-PROTECT-0001; the TAPS type (zone-denied, geo-denied, purpose-denied) is in taps, and the failed condition only in the decision log | Check with protect.policy.check() |
Opened before releaseAt | MAIN-PROTECT-0002 with retryAfter | Wait for the release time |
| Opened after a revoke or erase | MAIN-PROTECT-0003 (410 when erased) | Protect and share a new copy |
Fewer than t parties released | MAIN-PROTECT-0011 | Wait for more parties |
| A change needs a second person | MAIN-PROTECT-0005 | Pass approvals from another person |
| The TAPS Node is unreachable | MAIN-PROTECT-0014; nothing opens, the rest of the app keeps serving | Check protect.url and the network |
A call asks for suite TAPS-CS3 | MAIN-PROTECT-0010; no protection beyond CS1 and CS2 is claimed | Use the default suite |
See Errors for every MAIN-PROTECT-* code.
Swapping drivers
Change one line of main.yaml; no call changes.
protect:
driver: taps-hosted # was taps-nodeThe local driver is a fake for development and tests, never a Node: mainsdk check refuses it in production (MAIN-CHECK-0026). Assets protected on local stay in development; moving assets between drivers is not specified. A driver that lacks a capability says so in its profile (query, threshold, timelock, inspect, web3, delivery); the call then fails with MAIN-PROTECT-0019.
Testing
createTestApp() from @mainsdk/testing runs the local driver offline with the test clock, so releaseAt, time windows, renewal and expiry move with app.clock. Run each call as a person with app.tenancy.as().
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
const app = createTestApp({ yaml: "schema: 1\nproject: papers\n" });
const asset = await app.tenancy.as({ user: "u1" }, () =>
app.protect(new TextEncoder().encode("Q1 paper"), { label: "sensitive" }),
);
const opened = await app.tenancy.as({ user: "u1" }, () => app.protect.open(asset.id));
assert.equal((await opened.text()).text, "Q1 paper");The kit adds app.protect.signals({ ip, country, confidence, managed, attested }), app.protect.party(name).release(asset) and .withhold(asset), and app.protect.chain(holdings). The suite in @mainsdk/testing runs every call on any driver and lists the cases a profile lacks as skipped, by name.
Security and privacy
- A protected file is ciphertext without a licence, wherever a copy ends up. Content keys are wrapped with keys, and every unwrap is an audit record.
- Each user is a tenant-pairwise principal to TAPS: no user id, email or name reaches a Node or ledger in clear.
- Every asset belongs to one tenant and opens only in its data region. Another tenant gets the same
MAIN-PROTECT-0004as for an unknown id. - Every request to a Node, party or sealing service goes through the network layer, so the allowlist, timeouts and retries apply.
- MainSDK claims no post-quantum protection for
protect. TheTAPS-CS3suite is refused until it is registered.
What Cloud adds
protect is free in your app. The Fleet plan adds an estate view of protected files and a response action to revoke every file an app or person shared since a date. See Free in-app and Cloud.
FAQ
How is this different from a share link? A share link controls the link; once someone downloads the file, the copy is theirs. A protected file stays ciphertext and asks the TAPS Node for a licence every time it opens.
Do readers need to install anything? No. Links open in the TAPS Web Reader.
What does not work offline or on local? Offline licences, the wrapper session's device-sealed keys and DRM key delivery are out of scope for 0.4. A TAPS Node, a threshold party and a sealing service are not run inside MainSDK.
Next
- TAPS Node and SoveDocs: the real drivers and how they install.
- Share links and QR codes: how protected files are shared.
- Policy decisions: the decision log opens are written to.
- Free in-app and Cloud: what Fleet adds.
Was this page helpful?