@seanmozeik/supabash-fs
v0.7.0
Published
Authenticated Supabase Storage and Postgres workspace filesystems for Just Bash, Apply Patch, and durable revisions.
Downloads
3,513
Maintainers
Readme
supabash-fs
@seanmozeik/supabash-fs mounts one verified Supabase workspace as a writable
filesystem for Just Bash. A workspace
can use Supabase Storage or the package-owned Postgres schema. Agents edit a
staged in-memory tree. The host commits, inspects history, and restores. No tool
call publishes durable state by itself.
The Storage backend remains byte-oriented and lazy. The Postgres backend is an explicit UTF-8 text tree with an atomic commit transaction and a snapshot pinned to one immutable revision.
Agents can also receive a composed filesystem with private writable mounts and shared read-only snapshots. The host commits each writable workspace separately. See shared filesystems and publishing for the full publisher/reader workflow, table-backed sources, revisions and migration to 0.6.
The public API can still change before version 1.0.
Architecture
One opened Workspace is the unit of work:
Supabash.openverifies a user bearer token and derives the Storage prefix from that user ID. Callers cannot supply a user ID, root, or prefix.Supabash.openPostgresverifies the same bearer token and opens one canonical workspace identifier. Database RLS checks that the verified subject owns it.Supabash.openDelegatedandSupabash.openPostgresDelegatedare separate trusted-host paths. They mount only the Storage prefix or Postgres workspace that a short-lived signed capability admits. Storage capabilities are verified in process against an Ed25519 public key. Postgres capabilities are verified inside the database.workspace.fsis a Just BashIFileSystem. Bash, Apply Patch, and optional image inspection all use this same staged tree.commitpublishes staged edits and returns an immutable transaction receipt.checkpoint,checkpoints,deleteCheckpoint,history,diff,readRevision,restore, andpurgeare host APIs. They are not model tools.
Authorization is not command-string filtering. The real boundaries are the virtual filesystem, canonical path checks, the verified user or capability, and the backend authorization policy. The command policy is a damage limiter.
The root export does not load ai, @ai-sdk/openai, or bash-tool. AI SDK
tools live on @seanmozeik/supabash-fs/ai-sdk.
Install
bun add @seanmozeik/supabash-fs just-bashjust-bash is a peer dependency. ai, @ai-sdk/openai, and bash-tool are
optional peers for the AI SDK export. The package does not use Effect or a
Node.js filesystem.
Deno 2 can load the package through npm compatibility:
import { Supabash } from 'npm:@seanmozeik/supabash-fs';
import { Bash } from 'npm:just-bash/browser';When deno run uses a restricted environment allow-list, also allow reads of
__MINIMATCH_TESTING_PLATFORM__. Just Bash's pattern-matching dependency reads
that name during import. The variable does not need a value.
The optional AI SDK export loads its supported provider package. Under a
restricted Deno permission set, that dependency also needs read access to its
installed npm files, environment access for OPENAI_API_KEY and
OPENAI_BASE_URL, and system-information access. The package's clean-consumer
gate type-checks and runs both exports from the packed tarball under Deno 2.
Configure Supabase Storage
Create a private Storage bucket. The examples in this document use workspaces.
Apply the four policies in
examples/storage-policies.sql. Change the
bucket name in that file if you use a different name.
The policies allow an authenticated user to access only object names whose first path segment is that user's Supabase Auth ID. Keep these policies even if the package runs only in server functions. Package path checks and Storage RLS are separate security barriers.
The package needs all four object operations:
SELECTfor list, file information, download, and upsert checks.INSERTfor new objects.UPDATEfor object replacement through upsert.DELETEfor removed files and replaced entry types.
Do not make the bucket public. Do not pass a service-role key to
Supabash.open. Delegated access is a separate API with its own trust
boundary, documented below.
Open a user workspace
Pass the incoming request, the Supabase URL, the publishable key, and the bucket name. The request must contain the user's bearer access token.
import { Supabash } from '@seanmozeik/supabash-fs';
import { Bash } from 'just-bash/browser';
export const runCommand = async (
request: Request,
command: string,
config: { publishableKey: string; supabaseUrl: string },
) => {
const workspace = await Supabash.open({
bucket: 'workspaces',
publishableKey: config.publishableKey,
request,
supabaseUrl: config.supabaseUrl,
});
const bash = new Bash({ cwd: '/', fs: workspace.fs });
const result = await bash.exec(command);
if (result.exitCode !== 0) {
await workspace.discard();
return { result };
}
const receipt = await workspace.commit({
context: { actor: 'workspace', correlationId: crypto.randomUUID() },
});
return { receipt, result };
};Use just-bash/browser in edge runtimes. It excludes commands that need
Node.js or an operating-system filesystem.
Supabash.open() does this work before it lists Storage objects:
- It accepts only a Supabase publishable key or a legacy anon key.
- It reads one bearer token from the supplied
Request. - It rejects a user token with the
service_roleclaim. - It calls
supabase.auth.getUser(token)to verify the session. - It derives the object prefix from the verified user ID.
The API does not accept a user ID or an object prefix. Extra caller properties cannot select a different root. Every Storage call uses the user's bearer token, so the configured RLS policies also check each operation.
Configure Supabase Postgres
Run the versioned install asset as the Supabase database owner:
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 \
-f node_modules/@seanmozeik/supabash-fs/sql/postgres/0001_install.sql \
-f node_modules/@seanmozeik/supabash-fs/sql/postgres/0002_lazy_reads.sql \
-f node_modules/@seanmozeik/supabash-fs/sql/postgres/0003_versioned_entries.sqlThe package exports the same file as
@seanmozeik/supabash-fs/postgres/install.sql. The matching removal asset is
@seanmozeik/supabash-fs/postgres/remove.sql. For runtime file access, import
POSTGRES_INSTALL_SQL_URL or POSTGRES_REMOVE_SQL_URL from the package root
and pass the URL to the Bun, Deno, or Node file API.
The install creates the private supabash schema, the non-login
supabash_api execution role, FORCE RLS policies, and public RPCs. Authenticated
and service-role clients receive no direct table access. Normal RPC calls derive
ownership from the verified JWT subject.
Create a workspace once, then store its returned canonical identifier:
const workspace = await Supabash.createPostgresWorkspace({ publishableKey, request, supabaseUrl });Open it through the Postgres backend:
const mounted = await Supabash.openPostgres({ publishableKey, request, supabaseUrl, workspace });mounted.committedSnapshot() returns a frozen, detached copy of the pinned
database revision. Each document includes its stored body, metadata, canonical
rendered content, hashes, and byte sizes. It does not include staged edits.
After a successful commit, it describes the new committed revision.
The API accepts no owner, root, prefix, or secondary slug. The caller supplies only the canonical workspace identifier. RLS decides whether the verified subject can use it.
The Postgres backend persists regular UTF-8 files. It derives directories from
paths. It rejects invalid UTF-8, NUL text, symbolic links, mode changes, and
durable empty directories with UNSUPPORTED_CONTENT. Its public capabilities
state these limits:
mounted.capabilities;
// {
// backend: 'postgres',
// content: 'utf8-text-tree',
// durableEmptyDirectories: false,
// modes: false,
// symbolicLinks: false,
// }Just Bash still supports reads, recursive grep and find, redirection,
append, sed -i, nested file paths, moves, and deletes. /bin, /usr, /dev,
/proc, and /tmp belong to the shell adapter. They never enter a snapshot or
commit.
Writable Bash and Apply Patch
Normal file edits inside the mounted root are allowed: redirection, sed,
mv, rm, mkdir, and similar Just Bash commands. The host still decides
when those staged edits become durable.
import { applyPatch, Supabash } from '@seanmozeik/supabash-fs';
import { Bash } from 'just-bash/browser';
const workspace = await Supabash.open({
bucket: 'workspaces',
publishableKey,
request,
supabaseUrl,
});
const bash = new Bash({ cwd: '/', fs: workspace.fs });
await bash.exec(String.raw`printf 'alpha\n' > /notes.md`);
const patched = await applyPatch(workspace, {
diff: '-alpha\n+beta\n',
path: '/notes.md',
type: 'update_file',
});
if (patched.status !== 'completed') {
await workspace.discard();
} else {
await workspace.commit();
}An optional document codec can parse agent-visible YAML frontmatter into a stored text body plus one flat mapping of string, number, boolean, or null values. Postgres and the client share one canonical renderer for that stored shape. Opening the same workspace without a codec still projects the same YAML file, so metadata changes participate in the same revision, diff, checkpoint, and restore history as body changes.
import { createYamlFrontmatterCodec, Supabash } from '@seanmozeik/supabash-fs';
const documentCodec = createYamlFrontmatterCodec({
validate(metadata, path) {
if (typeof metadata.description !== 'string') {
throw new TypeError(`${path} needs a description`);
}
},
});
const mounted = await Supabash.openPostgres({
documentCodec,
publishableKey,
request,
supabaseUrl,
workspace,
});For this visible file:
---
description: A short route to the document's distinct context
---
# Pacing after demanding work
Protect recovery time after a long call.Postgres stores description in the revision entry's metadata column and
stores the Markdown beginning at the heading in the content-addressed body.
The projected filesystem reconstructs the complete file.
applyPatch supports create_file, update_file with optional moveTo, and
delete_file. Paths go through the same canonical parser as the filesystem.
A failed operation does not leave a partial local mutation. Batches default to
all-or-nothing; pass { mode: 'ordered' } to keep earlier successful edits.
Apply Patch never calls commit. Bash never calls commit.
AI SDK tools
import { createWorkspaceFileSystemView } from '@seanmozeik/supabash-fs';
import { createTools } from '@seanmozeik/supabash-fs/ai-sdk';
import { generateText } from 'ai';
const filesystem = createWorkspaceFileSystemView(workspace.fs, {
root: '/memory',
hiddenRoots: ['private'],
});
const bound = await createTools({
filesystem,
bash: { policyOptions: { allowNetwork: false } },
applyPatch: true,
viewImage: { enabled: false },
});
const result = await generateText({ model, tools: bound.tools, prompt });
bound.filesystem === filesystem; // trueThe factory binds all tools to the supplied filesystem. It can be a workspace's
fs, a scoped view, or createMountedFileSystem(...).fs. Tools stage edits; the
host retains the original workspace handle for commit, history and restore.
createWorkspaceFileSystemView presents one subtree as / and hides configured
paths from tools. createMountedFileSystem composes independent sources under
visible mount points such as /memories and /docs. Use the latter when the
agent needs personal writable files alongside shared read-only content. Both
Bash and Apply Patch use the exact same filesystem and permission checks.
Optional view_image reads only from the supplied filesystem, allowlists image MIME
types, enforces a byte limit before decoding, and rejects symbolic-link
escapes. Its model output is an AI SDK file-content part with the detected MIME
type and filename. The production build keeps this implementation in a
separate chunk and loads it only when viewImage.enabled is true.
Tool text is truncated with a stable \n[truncated]\n marker. Errors and
outputs redact bearer tokens, signed URLs, and secret-looking keys.
bash-tool does not expose a typed preflight hook, so the AI SDK adapter
wraps execute and inspects the command first. That is a damage limiter, not
an authorization boundary. Just Bash also receives a 30-second wall-clock
deadline by default. Set bash.limits.maxExecutionTimeMs to a positive safe
integer to change it.
This package does not compact model context. Context management belongs to the AI runtime.
Hosts can add normal Just Bash commands with bash.customCommands. Add each
command name to bash.policyOptions.extraAllowCommands so the command policy
can inspect pipelines and compound syntax before Just Bash runs it:
import { defineCommand } from 'just-bash/browser';
const count = defineCommand('count', async (_args, context) => ({
exitCode: 0,
stderr: '',
stdout: String(context.stdin.length),
}));
await createTools({
filesystem: workspace.fs,
bash: { customCommands: [count], policyOptions: { extraAllowCommands: ['count'] } },
});Command policy
import { createCommandPolicy } from '@seanmozeik/supabash-fs';
const policy = createCommandPolicy({
allowNetwork: false,
extraDenyCommands: ['reboot'],
inspectors: [
{
inspect: (command) =>
command.includes('prod-secret')
? { allow: false, code: 'custom-deny', reason: 'Blocked by host policy.' }
: { allow: true },
},
],
});The default policy parses the complete command with Unbash. It can inspect
ordinary pipelines, redirections, substitutions, literal variables, loops,
conditionals, find -exec, process substitutions, and grouped commands.
Command and path values that can only be known at execution time run normally
inside Just Bash. Concrete nested commands remain visible to the policy, so a
literal blocked command inside a substitution is still denied. The policy
denies or bounds:
- statically resolved paths outside the virtual root or reserved
.supabashsegments - recursive operations whose statically resolved target is the mounted root
- excessive command length, pipeline depth, or segment count
- network commands when network is disabled
- host-process escapes
- malformed syntax that cannot be parsed
Do not treat command-string filtering as the security boundary.
Revisions, recovery, and retention
const staged = workspace.changes();
const marker = await workspace.checkpoint({
idempotencyKey: 'safe-before-job-1',
label: 'safe',
retentionClass: 'short-lived',
});
const receipt = await workspace.commit({
context: { actor: 'host', correlationId: 'job-1', idempotencyKey: 'job-1' },
});
const page = await workspace.history({ limit: 20 });
const diff = await workspace.diff({
from: { checkpoint: marker.checkpointId },
to: { staged: true },
});
const previous = await workspace.readRevision(marker.revision);
const plan = await workspace.restore(marker.revision);
await workspace.commit({ context: { actor: 'host', correlationId: 'restore-1' } });
const checkpoints = await workspace.checkpoints();
await workspace.deleteCheckpoint(marker.checkpointId);
await workspace.purge({ dryRun: true, maxRevisions: 50 });changesreturns the current staged set without a durable write.checkpointnames the current complete revision. It does not publish staged edits. Its idempotency key returns the same marker on retry.checkpointslists pinned markers with their labels and retention classes.deleteCheckpointreleases a marker so retention can remove its revision.commitpublishes staged changes and returns an immutable receipt.discarddrops uncommitted changes only. A clean read-only delegated workspace can call it as a no-op; discarding staged changes requires write permission.historyreads committed transactions with cursor pagination. An unknown cursor fails withREVISION_NOT_FOUNDinstead of restarting the page.diffcompares two committed revisions, a checkpoint, or staged state. Text file add/delete/modify entries include apreviewtruncated topreviewBytes(default 8_192). PasspreviewBytes: 0to skip bodies.readRevisionreturns a read-only historical view.restorerebuilds the live tree the same wayopenanddiscarddo, then stages the difference against the current baseline. It does not commit. The next successful commit recordsmetadata.sourceRevisionautomatically.purgenever makes a retained revision unreadable and can dry-run.
Visible files stay in their filesystem paths. History lives under a private namespace that the virtual filesystem cannot list, read, write, move, link, or delete:
<verified-scope>/
notes.md
.supabash/
objects/<sha256>
revisions/<revision>.json
transactions/<transaction>/intent.json
transactions/<transaction>/complete.json
transactions/<transaction>/abort.json
checkpoints/<checkpoint>.json
idempotency/<key>.json
head.jsonThe path parser reserves .supabash and .supabash-directory. Storage
listings filter the private namespace before constructing filesystem entries.
A retry with the same idempotency key and the same operation returns the first
receipt. Reusing that key for different changes or context fails with
IDEMPOTENCY_CONFLICT. Restore creates a new forward transaction when the host
commits it. It never rewrites earlier history.
Storage consistency
Supabase Storage does not provide an atomic multi-object transaction or a conditional compare-and-swap write. This package does not claim atomic revision publish.
The write sequence is:
- freeze staged mutation and hash uploads
- acquire the optional per-scope commit lease
- recover any earlier interrupted transaction
- run the final conflict and quota checks
- write an intent record and initial recovery snapshot when needed
- upload visible objects and delete removed paths
- write content-addressed bodies, the revision manifest, and
complete.json - write the idempotency receipt and update
head.jsonlast
A network failure can stop the sequence after some uploads. An intent without
a complete record is rolled back to the current head, or to its captured
initial snapshot when no head exists, before the next workspace opens. Recovery
writes an abort marker only after that rollback succeeds. A complete record
whose final head write failed is adopted instead. Retry commit() on the same
workspace after a transient failure; the transaction fingerprint and optional
idempotency key prevent a second logical commit. The last published revision
also remains readable through readRevision throughout recovery. A lost
optional lease fails with COMMIT_COORDINATION before a complete revision is
published. When a coordinator is present, workspace open and partial-discard
recovery acquire the same per-scope lease before they inspect an unresolved
intent.
When no coordinator is supplied, a check-to-write race remains. Another writer
can change an object between conflict preflight and upload. Applications that
need strict per-scope serialization should supply a CommitCoordinator.
Without that coordinator, an opener can also race an active publisher while it
decides whether an incomplete intent needs recovery.
interface CommitCoordinator {
readonly acquire: (input: {
readonly scope: string;
readonly transactionId: string;
}) => Promise<{ readonly lost: () => Promise<boolean>; readonly release: () => Promise<void> }>;
}The package does not require Postgres. A caller can implement the coordinator with a database lock, queue, Durable Object, or another system.
Postgres consistency
The Postgres backend loads the current head and its immutable manifest without file bodies. File reads fetch individual bodies at that pinned revision. Repeated filesystem reads share the downloaded body. A commit rebuilds its snapshot from accepted changes without reading unchanged files.
Apply sql/postgres/0002_lazy_reads.sql and then 0003_versioned_entries.sql
once after the foundation installation, before upgrading clients to 0.7.0.
Their exports are @seanmozeik/supabash-fs/postgres/lazy-reads.sql and
@seanmozeik/supabash-fs/postgres/versioned-entries.sql.
await workspace.committedSnapshot() explicitly loads a complete detached
snapshot in one bulk request; workspace.committedRevision() reads only the revision.
Its commit RPC takes a transaction-scoped advisory lock, checks the expected head, validates the complete change set, and writes current documents, workspace-local content-addressed bodies, revision metadata, changed file versions, receipt changes, and the new head in one transaction.
A stale head returns HTTP 409 with SUPABASH_COMMIT_CONFLICT, which the client
maps to COMMIT_CONFLICT. Expected conflicts do not use retry-class SQLSTATE
40001. Any other error rolls back the full transaction. Restore stages a
target revision and the next commit creates a new forward revision.
Commit, checkpoint, and purge have a one-second lock-wait limit. Lock timeout,
deadlock, and serialization failure map to retryable COMMIT_COORDINATION.
Callers must bound retries and add backoff. A stale revision requires reopening
and reconsidering the changes. A delegated read of an older pinned revision
requires history as well as read. Expired grants and purged revisions fail
explicitly.
Version 0.7.0 stores unchanged file entries once across successive revisions. Sequence intervals select the correct file version without replaying changes. Purge removes a closed interval only when no retained revision needs it. Distinct upsert batches of at least 16 files use set-based database writes. Mixed mutations keep their ordered semantics. Both paths validate receipts and roll back the whole transaction on failure. Existing revisions keep their original manifests. The upgrade seeds entries from current documents under a database lock; schedule it as a database migration. Opening a workspace still transfers its full path index. Large workspaces and aggregate traffic therefore need measured database and application capacity.
Operation events
Postgres options accept an optional synchronous observer. It has no network destination and sends nothing unless the host supplies a callback:
const workspace = await Supabash.openPostgres({
observability: {
onOperation(event) {
operationEvents.push(event);
},
},
publishableKey,
request,
supabaseUrl,
workspace: workspaceId,
});Events can include backend kind, operation, duration, outcome, typed error code, document count, UTF-8 byte count, measured serialized payload bytes, change count, and replay or conflict outcome. They do not include document bodies, paths, tokens, user IDs, workspace IDs, correlation IDs, metadata, or raw error objects. Observer failures do not change workspace behavior. A host can record these events to compare snapshot, projection, and commit latency later.
Indexing feed
Commit receipts and history records expose a stable cursor for later text, vector, or graph indexes. Those indexes are not an authority and are not implemented here.
Each receipt includes logical revision, parent revision, transaction ID, opaque
scope digest, changed paths, change kinds, before and after hashes, ETags and
sizes when available, move relationships, entry kinds, committed time, actor,
cause, correlation ID, idempotency key, metadata, schema version, and cursor.
History follows the published parent chain, so commits with equal timestamps
still have one causal order. A page cursor names the last transaction returned.
An indexer should:
- read only committed transactions through
history({ cursor }) - fetch new bodies through the scoped read API or
readRevision - ignore uncommitted state
- resume from
receipt.cursor/record.cursor - treat restore as a normal forward transaction
- rebuild from one retained revision when needed
Delegated access
Trusted background jobs can open one scoped workspace when no live user JWT
exists. This is not openAsUser. The host signs a short-lived compact JWS and
passes it to Supabash.openDelegated or Supabash.openPostgresDelegated.
Storage capabilities are signed with Ed25519. The delegate verifies them
itself, so it must not be able to mint them, and it holds only keyed public
keys. Postgres capabilities are signed with HMAC-SHA256 and verified inside the
database, so the delegate holds nothing at all. Both signing keys are
CryptoKey values.
import {
createDelegatedCapability,
CAPABILITY_SCHEMA_VERSION,
Supabash,
} from '@seanmozeik/supabash-fs';
const capability = await createDelegatedCapability({
claims: {
aud: 'supabash-jobs',
bucket: 'workspaces',
corr: 'job-1',
exp: Math.floor(Date.now() / 1000) + 300,
iat: Math.floor(Date.now() / 1000),
iss: 'https://example.invalid/issuer',
nonce: 'job-1',
ops: ['read', 'write', 'commit', 'history'],
origin: supabaseUrl,
prefix: userId,
sub: 'job-1',
sv: CAPABILITY_SCHEMA_VERSION,
},
keyId: 'k1',
privateKey,
});
const workspace = await Supabash.openDelegated({
bucket: 'workspaces',
capability,
serviceRoleKey,
supabaseUrl,
verifier: {
audience: 'supabash-jobs',
issuer: 'https://example.invalid/issuer',
origin: supabaseUrl,
publicKeys: { k1: publicKey },
nonceStore,
},
});openDelegated options must never be passed to createTools or retained on
Workspace. The model and Just Bash never receive the signing key, the
service-role credential, or unverified claims.
The capability binds issuer, audience, subject, origin, bucket, exact prefix, allowed operations, issued-at, expiry, nonce, correlation ID, and schema version. Verification rejects expired tokens, future issued-at times outside clock skew, the wrong issuer or audience, the wrong project, bucket, or prefix, a changed subject, and an invalid signature. A host nonce store prevents replay. The default maximum capability lifetime is 900 seconds. The nonce is consumed only after signature, scope, bucket, origin, and workspace open checks succeed, so a transient Storage failure does not destroy a valid retry.
Storage RLS alone may not express this server-side delegation. After the
capability verifies, the package uses a trusted Supabase client clamped to the
signed prefix. That is a host trust boundary: anyone who can call
openDelegated with a valid capability and the service-role key can read and
write that prefix.
Postgres delegation
Postgres delegation uses schema version 3 and a different signature scheme,
because a different party verifies it. The database is the only verifier, so
the capability is a compact HS256 JWS signed with a secret that the minting
host and the database share. The job that presents the capability never holds
that secret and needs no verification key at all.
The database owner registers the key once. The database mints the secret and returns it exactly once, so no caller writes a secret into SQL statement text:
select public.supabash_register_capability_verifier(
p_key_id => 'k1',
p_issuer => 'https://issuer.example',
p_audience => 'supabash-jobs',
p_origin => 'https://project.example'
);The secret is stored in supabash.capability_secrets, which grants nothing to
any role, and is read only by the SQL verification path. Put the returned value
in the minting host's environment, for example
supabase secrets set SUPABASH_CAPABILITY_SECRET=<value>. Rotate with the same
call, or register a second p_key_id for an overlap window and then
select public.supabash_revoke_capability_verifier('k1').
The signed claims replace bucket and prefix with backend: 'postgres' and
one canonical workspace UUID:
import {
createPostgresDelegatedCapability,
importCapabilitySecret,
POSTGRES_CAPABILITY_SCHEMA_VERSION,
Supabash,
} from '@seanmozeik/supabash-fs';
const secretKey = await importCapabilitySecret(capabilitySecret);
const capability = await createPostgresDelegatedCapability({
claims: {
aud: 'supabash-jobs',
backend: 'postgres',
corr: 'job-2',
exp: Math.floor(Date.now() / 1000) + 300,
iat: Math.floor(Date.now() / 1000),
iss: 'https://issuer.example',
nonce: 'job-2',
ops: ['read', 'write', 'commit', 'history'],
origin: supabaseUrl,
sub: 'job-2',
sv: POSTGRES_CAPABILITY_SCHEMA_VERSION,
workspace: workspaceId,
},
keyId: 'k1',
secretKey,
});
const workspace = await Supabash.openPostgresDelegated({
capability,
expectedOperations: ['read', 'write', 'commit', 'history'],
serviceRoleKey,
supabaseUrl,
});openPostgresDelegated requires read because opening a Workspace projects
its pinned text snapshot into the staged filesystem. Add only the other
operations that the job needs. A restore capability can plan a forward
restore without also granting history.
When expectedOperations is present, the granted operation set must be exactly
that set. Extra, missing, or duplicate operations fail before capability
exchange or workspace load. The returned workspace exposes frozen delegation
details: actor, correlation ID, operations, subject, and workspace. These come
from the grant that the database minted after it verified the signature, not
from the presented claims and not from caller-supplied identity fields. The
package reads the presented claims only to refuse an obviously wrong request
early and to detect a response that does not match the capability it sent.
The exchange RPC is owned by a least-privileged role that holds no privilege on
the secret table, so it never sees the secret. It calls one narrow function
that keeps its definer rights with the database owner, recomputes the MAC with
extensions.hmac, compares it under a fresh random blind, and returns only a
boolean. The RPC then consumes the nonce and returns an opaque short-lived
grant bound to the signed workspace and operations. Service-role RPC calls
cannot select a workspace without this grant.
Why HMAC and not Ed25519: verifying an Ed25519 signature in SQL needed
pgsodium, which Supabase has deprecated and no longer ships on PostgreSQL 17,
and which a migration cannot install. pgcrypto offers no public-key signature
verification. Moving verification into the process that opens the session was
the other option, and it was rejected: that process holds the service role, so
it would both verify the capability and assert the result, and a caller holding
only the service role could then mint its own authority. Keeping verification
in SQL keeps that caller unable to forge anything.
What the symmetric secret changes: any party holding the secret can mint a
capability, so the minting host and the database are now equally trusted for
minting. The database owner could already insert grants directly, so the set of
principals that can create authority does not grow. What is given up is
distinguishing several mutually distrusting minters by key. Since the database
is the only verifier, no other principal can observe that difference. Residual
risks: a leaked secret lets an attacker mint capabilities for any workspace
until the key is rotated; a leaked capability is still bounded by its
workspace, ops, exp, and single-use nonce; and the minting host must
keep the secret out of the delegate's environment, because a delegate that
holds it stops being bounded by its capability. Unlike the Ed25519 scheme, the
signing secret is now inside the database, so a database dump contains it and
anyone holding a dump can mint. Rotation is the answer to a suspected dump
leak.
The boundary rests only on grants this package owns. supabash.capability_secrets
grants nothing to anon, authenticated, or service_role; row level security
is enabled on it without force, so the installing owner keeps access and every
other role is denied twice; and the only path to the secret is a definer-rights
function that returns a boolean. Forging a capability therefore needs the
minting host's secret, database-owner access, or a copy of the database. The
install refuses to run if any PostgREST role holds a privilege on that table or
usage on the supabash schema.
supabase_vault is not used, and this is a deliberate reversal. On a stock
Supabase project service_role holds select and delete on vault.secrets
and vault.decrypted_secrets and execute on vault.create_secret and
vault.update_secret; a live install proved it. The vault would give this
secret no protection from the one role the design exists to constrain, and the
only remaining barrier would be the PostgREST exposed-schema setting, which
this package does not control. Revoking the platform's grants would be fragile,
because a platform upgrade can restore them.
Threat model, in short:
- Changing the subject, prefix, workspace, or operation set in a copied token fails signature checks.
- Copying a valid capability to another bucket or origin fails verification.
- A caller holding only the service-role key and the capability format cannot mint a capability. The Ed25519 private key and the Postgres capability secret are both outside its reach.
- A parent prefix is not implied by a child prefix.
- Expiry and optional nonce stores block stale or replayed jobs.
- A compromised model prompt cannot select a bucket, user, or credential.
- Tool input and output redact tokens, signed URLs, and capabilities.
- Cross-user history, diff, and restore fail because each workspace is scoped to one verified prefix.
Options and limits
interface SupabashOptions {
readonly bucket: string;
readonly coordinator?: CommitCoordinator;
readonly fetch?: typeof globalThis.fetch;
readonly limits?: WorkspaceLimits;
readonly maxFileSystemBytes?: number;
readonly publishableKey: string;
readonly request: Request;
readonly supabaseUrl: string;
readonly uploadConcurrency?: number;
}
interface PostgresWorkspaceOptions {
readonly workspace: string;
readonly documentCodec?: TextDocumentCodec;
readonly fetch?: typeof globalThis.fetch;
readonly limits?: WorkspaceLimits;
readonly maxFileSystemBytes?: number;
readonly observability?: WorkspaceObservability;
readonly publishableKey: string;
readonly request: Request;
readonly supabaseUrl: string;
}Documented defaults:
| Limit | Default |
| ----------------------------- | ---------- |
| maxVisibleFiles | 10_000 |
| maxPathLength | 1_024 |
| maxFileSize | 10_485_760 |
| maxStagedBytes | 52_428_800 |
| maxPatchSize | 1_048_576 |
| maxCommandLength | 32_768 |
| maxBashOutput | 262_144 |
| maxExecutionTimeMs | 30_000 |
| viewImage.maxBytes | 5_242_880 |
| maxHistoryPageSize | 100 |
| maxDiffPreviewBytes | 8_192 |
| maxTransactionMetadataBytes | 16_384 |
| maxRevisions purge hint | 50 |
| uploadConcurrency | 4 |
maxFileSystemBytes uses Just Bash's 1,073,741,824-byte in-memory default when
it is omitted. Configured limits must be safe integers in their documented
range. Invalid values and runtime quota failures use QUOTA_EXCEEDED. Limits
fail before a durable mutation when possible.
Errors
All package errors are SupabashError values:
| Code | Meaning |
| ---------------------- | ------------------------------------------------------------ |
| AUTHENTICATION | The bearer token is missing, malformed, or not verified. |
| AUTHORIZATION | A key, verified identity, bucket, or root is unsafe. |
| COMMIT_CONFLICT | A changed remote entry no longer matches the opened version. |
| COMMIT_COORDINATION | The optional commit lease was lost. |
| COMMIT_IN_PROGRESS | Code tried to mutate or commit an active commit. |
| EXPIRED_CAPABILITY | The delegated capability has expired. |
| HISTORY_CORRUPTION | A history record could not be parsed. |
| IDEMPOTENCY_CONFLICT | An idempotency key was reused for a different commit. |
| INVALID_CAPABILITY | The delegated capability is not acceptable. |
| INVALID_PATCH | The V4A patch could not be applied. |
| INVALID_PATH | A virtual path is unsafe or invalid. |
| PARTIAL_COMMIT | Remote writes stopped before a complete revision. |
| POLICY_DENIED | The command policy denied a Bash command. |
| QUOTA_EXCEEDED | A configured limit was exceeded. |
| REVISION_NOT_FOUND | The requested revision is missing. |
| STORAGE | A Supabase Storage operation failed. |
| UNSUPPORTED_CONTENT | The path or bytes cannot be used for this operation. |
SupabashError.path identifies the affected virtual path when one is
available. The original error is available through error.cause.
Use isSupabashError(error) to recognize these errors across separately bundled
entry points, where JavaScript constructor identity can differ.
error.retryable is true when Supabash has classified the cause as transient.
error.outcomeUnknown is true when a transport failure may have hidden a
successful durable mutation. Use the exported
isRetryableSupabashError(error) and
isUnknownOutcomeSupabashError(error) helpers instead of retrying every
STORAGE error. Supabash does not retry automatically. When the outcome is
unknown, repeat an operation only when it is idempotent or after reconciliation.
For a Postgres commit, the workspace retains the same transaction and generated
context across an immediate retry, so the database can return the existing
result without creating a second logical commit.
Session-verification transport and server failures also return retryable
STORAGE errors. Invalid sessions return non-retryable AUTHENTICATION errors.
Keep retries bounded, use backoff with jitter, and count retries against the
host's request admission limit. An unknown mutation outcome still requires
idempotency or reconciliation, even when its error is retryable.
Set a limit on active workspace operations at the application boundary. Count work across all application workers that share the database; a separate large queue in each worker can overload the same connection pool. Reject or defer excess arrivals with a bounded queue and deadline. Increasing concurrency past capacity can reduce throughput and increase failures. Use the Modal capacity harness to find a limit for the deployment's actual workload, file sizes, and latency target.
Runtime and package size
The package builds for browser, Deno, and edge runtimes. It uses web-standard
Request, fetch, crypto, Blob, and encoding APIs. It imports Just Bash
from just-bash/browser.
Opening a workspace lists every object below the prefix. Regular file bodies stay lazy. A very large object count increases open time.
The root bundle is tree-shakeable relative to the AI SDK export. Importing
@seanmozeik/supabash-fs must not resolve ai, @ai-sdk/openai, or
bash-tool. Import @seanmozeik/supabash-fs/ai-sdk only when those optional
peers are installed. A clean root-only package test confirms that a missing AI
peer is named in the import error. The image implementation is a separate
dynamic chunk and is not loaded when image support is disabled.
Just Bash is an in-process virtual shell. It does not start a container and does not provide operating-system isolation.
Development
Install Bun 1.4 and Deno 2, then install the pinned dependencies:
bun install --frozen-lockfilejust verify
just live
just live-postgresjust live expects a local Supabase API at SUPABASH_TEST_SUPABASE_URL plus a
publishable key, service-role key, and two user tokens or emails. It creates
the workspaces bucket when needed and runs the Deno suite. Point those
variables at a Docker stack, not a hosted project.
just live-postgres installs the package SQL in a disposable Docker-backed
Supabase database, runs the authenticated and delegated Deno integration
suite, removes all package-owned database objects and synthetic users, and
checks that cleanup succeeded. Its required environment variables are listed
by scripts/run-postgres-integration.sh when one is missing.
The gate checks formatting, lint, TypeScript, source size, tests, the browser build, Deno type resolution, production dependencies, package contents, clean Bun consumers, and a clean Deno consumer of the packed tarball. This repository does not add CI.
Licence
MIT. See THIRD_PARTY_NOTICES.md for source and dependency notices.
