Local files
diskRead 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.
app-permissionsFree 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.
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]import { disk } from "mainsdk";
// allowed only if .cache is under permissions.disk.write
await disk.write(".cache/rates.json", JSON.stringify(rates));
const cached = await disk.readText(".cache/rates.json");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");
});Drivers
1 drivers. Select one to see its line in main.yaml.
disk:
driver: builtinChange this one line to swap providers; your calls stay the same.Built-in and local drivers integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-PERM-0001 | Checkmainsdk check, before deploy | Raw node:fs, node:os or node:child_process used outside a driver |
MAIN-PERM-0003 | Checkmainsdk check, before deploy | A disk path, a system fact or a program found in the code isn't declared under permissions |
MAIN-PERM-0008 | Checkmainsdk check, before deploy | A 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-0002 | RuntimeError responses and logs | In enforce mode, a disk path, a system fact or a program is used but not declared under permissions (403) |
MAIN-PERM-0006 | RuntimeError responses and logs | See the spec |
MAIN-PERM-0007 | RuntimeError responses and logs | See the spec |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| Declared read and write folders under permissions.disk | Phase 1 | 0.3 | app-permissions |
| Enforce and observe modes | Phase 1 | 0.3 | app-permissions |
| Undeclared folder use found by mainsdk check | Phase 1 | 0.3 | app-permissions |
| Decisions shown by mainsdk info | Phase 1 | 0.3 | app-permissions |
Quick start
permissions:
mode: enforce
disk:
read: [content]
write: [.cache]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.
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| Key | Type | What it does |
|---|---|---|
mode | string | enforce 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.read | list | Folders the app may read. A folder in write is readable without listing it here too |
disk.write | list | Folders the app may create, change and remove files in. An entry is a path, or { path, max } to cap the folder's size |
disk.temp | true or { max } | Turns on disk.temp. max caps all live temporary folders together |
disk.reserve | size or percent | Writes 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.
| Call | What 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.
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
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
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:
npx mainsdk discoverSee Declare what your app uses.
Errors and checks
| Code | When | Fix |
|---|---|---|
MAIN-PERM-0001 | mainsdk check found node:fs (or node:os, node:child_process) imported outside a driver | Use disk for files |
MAIN-PERM-0002 | A path outside the declared folders was used (403, enforce mode) | Declare the folder under permissions.disk, or remove the code that asks |
MAIN-PERM-0003 | mainsdk check found a folder the code uses that permissions doesn't declare | Declare it; npx mainsdk discover prints the lines |
MAIN-PERM-0006 | A write would take a folder past its max (507); nothing was written | Remove files, or raise max under permissions.disk.write |
MAIN-PERM-0007 | Free space is under permissions.disk.reserve (507) | Free space on the disk, or lower reserve if the margin is too wide |
MAIN-PERM-0008 | mainsdk check found an invalid max or reserve, a reserve over 50%, or temp given a path | Write 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.
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.diskare 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 infolists the ones the app made. - Writes are atomic and cannot fill the disk past
reserveor a folder past itsmax. - 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
- Server facts and programs: the operating system facts and programs the app may use.
- Network permissions: the outbound side of the same permission model.
- Declare what your app uses: run discovery and write the permissions block.
Was this page helpful?