Capabilities
A capability is a power your app declares in its manifest. If you have not declared it, it does not exist in your app's world — there is nothing to import, nothing bound at runtime, and nothing to misconfigure.
{
"capabilities": ["database", "storage"],
"database": {
"tables": { "notes": { "fields": { "text": { "type": "string" } } } }
},
"storage": {} // {} means "the platform defaults"
}That is the whole pattern, and it is worth stating plainly because it drives everything below:
- Name the capability in the
capabilitiesarray. - Configure it in the matching top-level block —
databasefor"database",storagefor"storage". - Use it through the typed surface that appears in your server code:
ctx.db,ctx.storage.
The two halves are checked against each other, in both directions. A database block with no
"database" capability is an error; the "database" capability with no database block is an error
too, and the message tells you to write {} if you meant the defaults. You cannot half-declare a
capability, and you cannot end up with a configured-but-forbidden one. Naming the same capability twice
is also an error.
#Deny by default
ctx in a server handler carries ctx.auth and ctx.env — and then only the capabilities you
declared. There is no ctx.fetch and no ambient client for anything. An app that declares no
capabilities can still serve queries, mutations, and endpoints; it simply has no way to reach any
resource.
This is enforced in the type system, not just at runtime. ctx.storage in an app that has not declared
storage is a compile error — the property is not on the context type at all, and xeer check
reports XE1205 against src/server.ts. So is calling ctx.storage.put from a query, because a
query only ever gets the read-only half of the surface.
This is deliberate, and it is what makes the manifest useful as a description rather than a suggestion. Reading one file tells you every external thing an app can touch. That holds for a person skimming a pull request and for an agent deciding whether a change is safe, and it holds because there is no second place to look.
It also means adding a capability is a visible, reviewable change to a checked-in file, not a line of code buried in a handler. And because the manifest is hashed into every build artifact's identity, the version you can roll back to is the version whose declared powers you already reviewed.
There are two capabilities today: database and storage.
#database
The one most apps start with. Declaring it gives you a typed, transactional, per-app database reachable
as ctx.db in every server handler.
#Tables, fields, indexes
{
"capabilities": ["database"],
"database": {
"tables": {
"cards": {
"fields": {
"title": { "type": "string", "maxLength": 200 },
"status": { "type": "string", "maxLength": 32 },
"workspaceId": { "type": "string", "maxLength": 96 },
"dueAt": { "type": "datetime", "optional": true }
},
"indexes": {
"by_workspace": ["workspaceId"],
"by_workspace_status": ["workspaceId", "status"]
}
}
}
}
}Field types are string, number, boolean, datetime, bytes, and json. Every field is required
unless you write "optional": true. Every row additionally gets id, createdAt, and updatedAt from
the runtime — you cannot declare those yourself, and you cannot write to them.
Indexes are not an optimisation here; they are the query surface. A filter must match { id } or a
declared prefix of a declared index. With by_workspace_status above you can filter by
{ workspaceId } or by { workspaceId, status }, and nothing else. That constraint is why every query
an app can express is one an index serves — there is no accidental table scan to discover in
production, and no EXPLAIN to read.
The complete rules — identifier patterns, maxLength, database.version — are in the
manifest reference.
#The typed surface
import { defineServer, query } from '@impetik/xeer/server';
export default defineServer({
queries: {
'cards.list': query({
input: { workspaceId: 'string' },
handler: (ctx, input) => ctx.db.table('cards')
.find({ where: { workspaceId: input.workspaceId } }),
}),
},
});ctx.db.table('cards') is generated from the manifest: the table name is checked, the row type has your
fields, where accepts only your index prefixes. Seven methods, no query builder, no SQL. See
Server functions and the database.
#Policies: authorization in the data layer
The database capability carries an authorization model with it. Declare a table owned-per-user or
scoped-to-a-workspace once, in defineServer, and the rule is applied to every read and write of
that table — not in each handler, where one omission is a data leak.
import { defineServer, ownedTable, workspaceTable } from '@impetik/xeer/server';
export default defineServer({
authPolicies: {
drafts: ownedTable(), // rows belong to one user, via `ownerId`
cards: workspaceTable(), // rows belong to a workspace, via `workspaceId`
},
// …
});Under ownedTable(), ctx.db.table('drafts').all() returns only the caller's rows, an insert stamps
ownerId with the caller's own id and refuses any other value, and an update that would move a row to
someone else is refused. workspaceTable() does the same against ctx.auth.workspaceIds, and a caller
with no membership reads an empty list. Full details in Auth.
#Local development, no provisioning
xeer dev gives you a real database from the first run: no connection string, no migration command, no
container. Change a table in the manifest and the local schema follows. xeer state reset --state dev --confirm <app> clears it; xeer export and xeer import move it in and out as a readable JSON
document, so an incompatible schema change during development does not cost you your test data.
Deploying carries the schema with the artifact. A compatible change — a new table, a new optional
field, a widened maxLength, a required field made optional — is applied on deploy. An incompatible one
is refused with both schema identities and the exact difference, rather than being applied and hoped
for.
#storage
An object store that belongs to your app alone, for the bytes a database row is the wrong shape for: uploads, avatars, attachments, generated files.
{
"capabilities": ["database", "storage"],
"database": { "tables": { "photos": { "fields": { "key": { "type": "string", "maxLength": 512 } } } } },
"storage": {}
}There is no bucket name, no region, no credential, and no tenancy parameter anywhere. Isolation is
implicit: one store per app, per environment. Your xeer dev objects, your preview deployment's, and
production's never see each other, and no call you can write reaches another app's.
#The surface
ctx.storage splits exactly the way ctx.db does — a query gets the read-only half, a mutation or an
endpoint gets all of it:
// Available in queries, mutations, and endpoints
get(key: string): Promise<{ key, size, contentType, uploadedAt, bytes } | null>;
head(key: string): Promise<{ key, size, contentType, uploadedAt } | null>;
list(options?: { prefix?: string; cursor?: string; limit?: number }): Promise<{
objects: readonly { key, size, contentType, uploadedAt }[];
cursor: string | null;
}>;
// Mutations and endpoints only
put(key: string, value: Uint8Array | string, options?: { contentType?: string }):
Promise<{ key, size, contentType, uploadedAt }>;
delete(key: string): Promise<void>;Five methods. size is a number, uploadedAt is a real Date, and bytes is a Uint8Array — get
hands you the whole object, not a stream.
import { defineServer, mutation, query } from '@impetik/xeer/server';
export default defineServer({
queries: {
'files.read': query({
input: { key: 'string' },
handler: async (ctx, input) => {
const object = await ctx.storage.get(input.key);
return object === null ? null : { type: object.contentType, size: object.size };
},
}),
'files.list': query({
input: { prefix: 'string' },
handler: async (ctx, input) => {
const page = await ctx.storage.list({ prefix: input.prefix });
return page.objects.map((object) => object.key);
},
}),
},
mutations: {
'files.write': mutation({
input: { key: 'string', text: 'string' },
handler: async (ctx, input) => {
const stored = await ctx.storage.put(input.key, input.text, { contentType: 'text/plain' });
// Map the receipt into a plain object — see the note below.
return { key: stored.key, size: stored.size, contentType: stored.contentType };
},
}),
'files.remove': mutation({
input: { key: 'string' },
handler: (ctx, input) => ctx.storage.delete(input.key),
}),
},
});A missing key is null from get and head, not a thrown error. delete on a missing key succeeds.
put replaces whatever was there and hands back a receipt.
Returning a storage receipt straight out of an operation does not work. An operation's
output type has to be a plain data value, and put, get, head, and
list return interfaces — so handler: (ctx, input) => ctx.storage.put(…)
compiles on the server but arrives at the caller typed as never. Pick out the fields you
want, as above. Returning the raw bytes from a get is rarely what you want
anyway; serve them from an endpoint instead.
#Keys
A key is a slash-separated path, and the rules are stricter than "any string the store accepts" on purpose — a key ends up in URLs, log lines, and audit rows, and one that reads as a path traversal invites code that treats it as one.
- Each segment may use letters, digits, and
! . _ ~ ( ) ' * -and the space character. ASCII only. - No leading slash, no trailing slash, no empty segment (
//), and no.or..segment anywhere. - At most 512 bytes, measured as UTF-8.
uploads/42/avatar.png ✓
notes/2026-07-25/a.txt ✓
../secret ✗ dot segment
/leading ✗ leading slash
double//slash ✗ empty segment
émoji.png ✗ not ASCII
a:b.png ✗ colon is not in the charsetA list prefix follows the same safety rules but may end in a slash or mid-segment, because that is
how you ask for what a human would call a folder: list({ prefix: 'uploads/42/' }), or
list({ prefix: 'uploads/4' }) to match uploads/42 and uploads/47 alike.
#Paging
list returns up to 100 objects by default and at most 1000. Keep calling with the cursor it gives
you until the cursor is null:
let cursor: string | null = null;
const keys: string[] = [];
do {
const page = await ctx.storage.list({ prefix: 'uploads/', ...(cursor ? { cursor } : {}) });
keys.push(...page.objects.map((object) => object.key));
cursor = page.cursor;
} while (cursor !== null);The cursor is opaque. Pass back what you were given; never construct one.
#Getting bytes in from a browser
The browser POSTs to one of your endpoints, and the handler stores the body:
'POST /api/upload': endpoint(async (request, ctx) => {
const bytes = new Uint8Array(await request.arrayBuffer());
// Mint the key yourself, and record ownership as a row.
const key = `uploads/${crypto.randomUUID()}.png`;
const stored = await ctx.storage.put(key, bytes, { contentType: 'image/png' });
await ctx.db.table('photos').insert({ key: stored.key, ownerId: ctx.auth.appUserId });
return Response.json({ key: stored.key, size: stored.size }, { status: 201 });
}),Do not build a key out of ctx.auth.appUserId. A local
appUserId looks like local:alice:1f0qk3, and : is not a legal key
character — so uploads/${ctx.auth.appUserId}/photo.png is refused under xeer dev
even though the same line would work once deployed. Mint your own key and keep ownership in a row, as
above. You get authorization for free that way: put the row's table under
ownedTable() and only the owner can look the key up.
An upload is bounded by your requestBytes budget — 1 MiB by default
and 4 MiB at most. Over it, the request is refused with 413 request_too_large before your handler is
called. There is no signed-upload URL and no upload token: every byte goes through a handler you
wrote, which is also why every upload is subject to your own authorization. If you need to store an
object larger than 4 MiB, your server has to assemble it.
Objects are not publicly served either. A read goes through one of your own queries or endpoints, so
ctx.auth decides who sees what — the same as a database row.
#Limits
| Default | Maximum | |
|---|---|---|
maxObjectBytes — one object | 25 MiB | 25 MiB |
readBytes — read per handler call | 32 MiB | 128 MiB |
writeBytes — written per handler call | 32 MiB | 128 MiB |
"storage": { "maxObjectBytes": 1048576, "writeBytes": 4194304 }All three may be lowered, never raised past the platform ceiling — a value above it is a build error,
not a silent clamp. maxObjectBytes already defaults to its ceiling, so it can only go down. The two
budgets are per handler invocation and reset on every call; head and list cost nothing against the
read budget, while get charges the object's size.
Exceeding one is a refusal your caller sees as 400 operation_failed (or 500 endpoint_failed from an
endpoint), with a message naming the limit — the same shape as any other refusal. See refusing a
call.
#Local development
Storage is fully emulated by xeer dev, xeer preview, and xeer test. No account, no network, no
setup — the store exists on the first put.
xeer devandxeer previewobjects persist across restarts, and the two modes are separate.xeer testgets a fresh, empty store for every run and throws it away afterwards.xeer state reset --state dev|preview|testclears the objects for that mode along with its database.xeer exportandxeer importmove database records only. Stored objects are not in the export document — if you need them somewhere else, read them out through a handler you write.
#Why not just add an SDK client?
Every framework eventually faces the question of how an app reaches the outside world, and the usual answer is "import a client and put credentials in an environment variable." Xeer's answer is different on purpose, and the trade is explicit.
What you give up: you cannot reach a resource the framework does not model. Today that means a database
and an object store, plus whatever you can do over fetch from a handler using a key from
ctx.env.
What you get: the manifest is a complete and trustworthy inventory of an app's powers. Local development needs no credentials for anything the framework provides. Authorization for a modelled resource is enforced where the data is, not where somebody remembered to check. And a reviewer — human or otherwise — can tell what an app is able to do by reading one file, in one place, every time.
#Next
- Manifest reference — every field and every rule.
- Server functions and the database — the table API in full.
- Auth — policies, guards, and how to test them.