MainSDK
Search⌘K
Interfaces

Platform, OS and network

Local files

disk

Read and write the folders your server app declares, and nothing else. Every use is checked, recorded with its file and line, and shown in mainsdk info.

Building for 0.3ships as a beta APIPhase 1 · Core, public betaSpec app-permissionsFree in your app, Apache-2.0

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

disk gives a server-side app its own folders for a cache, a report or a content folder. The app declares them in main.yaml, and any other path is refused.

It works like the phone permission for files. The app never reaches node:fs directly; mainsdk check fails on that (MAIN-PERM-0001). disk is server side only, and it is free on every plan.

Use disk for files the app owns. For files users send you, use files: uploads are scanned, private to their owner and never read from a path the app chose.

At a glance

permissions:
  mode: enforce
  disk:
    read: [content]
    write: [.cache]

Calls

  • disk.read(path)
  • disk.readText(path)
  • disk.write(path, data)
  • disk.list(path)
  • disk.exists(path)
  • disk.delete(path)
  • createDisk(perm)
  • validateDisk(disk)
  • disk.temp(fn, { prefix? })
  • disk.remove(path)
  • disk.space(path)
  • disk.usage(path)

Guarantees

Whatever the driver.

  • Paths outside the declared folders are refused, including through symlinks
  • A folder declared for write can also be read; nothing else is reachable
  • Every use is recorded with the calling file and line
  • Server side only; the browser has no disk

Drivers

1 drivers. Select one to see its line in main.yaml.

main.yamlBuilt in (default)
disk:
  driver: builtin

Change this one line to swap providers; your calls stay the same.Built-in and local drivers integration

Codes

CodeWhere it appearsMeaning
MAIN-PERM-0001Checkmainsdk check, before deployRaw node:fs, node:os or node:child_process used outside a driver
MAIN-PERM-0003Checkmainsdk check, before deployA disk path, a system fact or a program found in the code isn't declared under permissions
MAIN-PERM-0008Checkmainsdk check, before deployA disk value is invalid: a max or reserve that isn't a size or a percentage, a reserve over 50%, or temp set to a folder
MAIN-PERM-0002RuntimeError responses and logsIn enforce mode, a disk path, a system fact or a program is used but not declared under permissions (403)
MAIN-PERM-0006RuntimeError responses and logsSee the spec
MAIN-PERM-0007RuntimeError responses and logsSee the spec

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
Declared read and write folders under permissions.diskPhase 10.3app-permissions
Enforce and observe modesPhase 10.3app-permissions
Undeclared folder use found by mainsdk checkPhase 10.3app-permissions
Decisions shown by mainsdk infoPhase 10.3app-permissions

Quick start

main.yaml
permissions:
  mode: enforce
  disk:
    read: [content]
    write: [.cache]
lib/cache.ts
import { disk } from "mainsdk";

export async function saveRates(rates: unknown) {
  // allowed: .cache is under permissions.disk.write
  await disk.write(".cache/rates.json", JSON.stringify(rates));
}

export async function readArticle(slug: string) {
  // allowed: content is under permissions.disk.read
  return disk.readText(`content/${slug}.md`);
}

A path outside the declared folders is refused with MAIN-PERM-0002 (403) in enforce mode.

Configuration

Every key below lives under permissions:. Paths are relative to the app folder.

main.yaml
permissions:
  mode: enforce             # observe | enforce
  disk:
    read: [content]         # folders the app may read
    write:                  # folders the app may write; these are readable too
      - { path: .cache, max: 500MB }
      - out
    temp: { max: 1GB }      # allow disk.temp; max covers all live temporary folders together
    reserve: 5%             # refuse writes when free space is below this
KeyTypeWhat it does
modestringenforce refuses an undeclared use with MAIN-PERM-0002. observe lets it run and records it as a refusal that was not enforced, so you can read what the app uses before you enforce
disk.readlistFolders the app may read. A folder in write is readable without listing it here too
disk.writelistFolders the app may create, change and remove files in. An entry is a path, or { path, max } to cap the folder's size
disk.temptrue or { max }Turns on disk.temp. max caps all live temporary folders together
disk.reservesize or percentWrites fail while the filesystem has less free space than this. Default 1%; 0 turns it off; a percentage above 50% is invalid

Declare the narrowest folder you can. disk.write: [.] lets the app write everything in its folder, which is almost never what you want.

What you get

Build a disk yourself with createDisk(perm), which takes a function that returns the permission layer. validateDisk(permissions.disk) checks the permissions.disk section and returns one issue per problem, which mainsdk check reports as MAIN-PERM-0008.

CallWhat it does
disk.read(path)Read a file as bytes (Uint8Array)
disk.readText(path)Read a file as UTF-8 text
disk.write(path, data)Write a string or bytes; missing parent folders are created. The write is atomic: a reader sees the old file or the new one, never half of it, and the file keeps its mode
disk.list(path)List the names in a folder, sorted
disk.exists(path)true when the path exists
disk.delete(path)Remove a file or folder, recursively; a missing path is not an error
disk.remove(path)Deprecated alias of disk.delete
disk.temp(fn, { prefix })Run fn(dir) with a private temporary folder (mode 0700) that is removed when fn settles, whether it returns or throws. Needs permissions.disk.temp
disk.space(path)Free and total bytes of the filesystem that holds a readable folder
disk.usage(path){ bytes, max, measuredAt } for a write folder, from the last sample

Every path is resolved against the app folder and then checked against the declared folders, after symlinks are followed. A link that points out of a declared folder is refused.

Temporary folders

disk.temp(fn) is for work you want gone afterwards, such as rendering a file with a program. Inside fn, the folder is readable and writable by disk and is a valid cwd for system.run. After fn settles it is removed, and a write to it fails. At start, folders named mainsdk-<project>-* that are older than 24 hours are removed, so a crash does not leave them; other projects' folders are not touched.

lib/render.ts
import { join } from "node:path";
import { disk, system } from "mainsdk";

export function wordCount(text: string) {
  return disk.temp(async (dir) => {
    await disk.write(join(dir, "a.txt"), text);
    const out = await system.run("wc", ["-w", "a.txt"], { cwd: dir });
    return Number.parseInt(out.stdout, 10);
  });
}

This needs temp: true and processes: [wc] under permissions.

Size limits and free space

A write that would take a folder past its max fails with MAIN-PERM-0006 (507) before anything is written. While free space is under reserve, writes fail with MAIN-PERM-0007 (507), health() lists disk as degraded, and reads and removes keep working. Sizes are sampled about every minute and the SDK adds what it writes in between, so a folder's size is approximate: other processes can write between samples. mainsdk info shows each write folder's size, limit and free space.

Examples

Cache an upstream answer

lib/rates.ts
import { disk, http } from "mainsdk";

export async function rates() {
  try {
    return JSON.parse(await disk.readText(".cache/rates.json"));
  } catch {
    const fresh = await http.json("https://api.partner.com/v1/rates");
    await disk.write(".cache/rates.json", JSON.stringify(fresh));
    return fresh;
  }
}

Build a list of content pages

scripts/index-content.ts
import { disk } from "mainsdk";

const files = (await disk.list("content")).filter((f) => f.endsWith(".md"));
await disk.write("out/index.json", JSON.stringify(files));

The script needs content under disk.read and out under disk.write.

Find out what a declared folder is used for

Run discovery to list every folder the code touches, the file and line that touch it, and whether main.yaml declares it:

bash
npx mainsdk discover

See Declare what your app uses.

Errors and checks

CodeWhenFix
MAIN-PERM-0001mainsdk check found node:fs (or node:os, node:child_process) imported outside a driverUse disk for files
MAIN-PERM-0002A path outside the declared folders was used (403, enforce mode)Declare the folder under permissions.disk, or remove the code that asks
MAIN-PERM-0003mainsdk check found a folder the code uses that permissions doesn't declareDeclare it; npx mainsdk discover prints the lines
MAIN-PERM-0006A write would take a folder past its max (507); nothing was writtenRemove files, or raise max under permissions.disk.write
MAIN-PERM-0007Free space is under permissions.disk.reserve (507)Free space on the disk, or lower reserve if the margin is too wide
MAIN-PERM-0008mainsdk check found an invalid max or reserve, a reserve over 50%, or temp given a pathWrite a size such as 2GB or a percentage such as 5%; temp is true or { max }

Swapping drivers

disk has no driver to swap. It always uses the real filesystem of the machine the app runs on, and the folders you declare are the whole surface. To store files for users, or to keep files off the machine, use files, which does have drivers. See Swap a driver.

Testing

createTestApp() from @mainsdk/testing reads your permissions and uses the real disk, relative to the folder the test runs in. Use a folder the test owns, and remove what it writes.

lib/cache.test.ts
import assert from "node:assert/strict";
import { it } from "node:test";
import { createTestApp } from "@mainsdk/testing";
import { disk } from "mainsdk";

it("app-permissions AC1 refuses a folder that is not declared", async () => {
  createTestApp({ yaml: "schema: 1\nproject: t\npermissions:\n  disk:\n    write: [.cache]\n" });
  await disk.write(".cache/a.txt", "x");
  assert.equal(await disk.readText(".cache/a.txt"), "x");
  await assert.rejects(disk.readText("/etc/hosts"), { code: "MAIN-PERM-0002" });
  await disk.remove(".cache");
});

The test app's permissions.decisions() lists each decision with its file and line. A fake disk is planned.

Security and privacy

  • Deny by default. Only the folders named under permissions.disk are reachable.
  • Symlinks are resolved before the check, so a link cannot lead out of a declared folder.
  • Every use is recorded as a decision with the calling file and line. mainsdk info lists the ones the app made.
  • Writes are atomic and cannot fill the disk past reserve or a folder past its max.
  • No secrets are read from disk by this interface. Keep them in secrets.
  • Free on every plan.

FAQ

Can a browser read disk? No. It runs on the server only.

Where do user uploads go? To files. disk is for the app's own folders.

Does observe let everything through? Yes, and it records each use. Read the decisions, add the folders the app needs, then switch to enforce.

Why does write also allow reads? A folder the app writes is one it reads back, such as a cache. Declaring it twice is not needed.

Next

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet