# supabash-fs

`@seanmozeik/supabash-fs` mounts one verified Supabase workspace as a writable
filesystem for [Just Bash](https://github.com/vercel-labs/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](docs/shared-filesystems.md) 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:

1. `Supabash.open` verifies a user bearer token and derives the Storage prefix
   from that user ID. Callers cannot supply a user ID, root, or prefix.
2. `Supabash.openPostgres` verifies the same bearer token and opens one canonical
   workspace identifier. Database RLS checks that the verified subject owns it.
3. `Supabash.openDelegated` and `Supabash.openPostgresDelegated` are 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.
4. `workspace.fs` is a Just Bash `IFileSystem`. Bash, Apply Patch, and optional
   image inspection all use this same staged tree.
5. `commit` publishes staged edits and returns an immutable transaction receipt.
6. `checkpoint`, `checkpoints`, `deleteCheckpoint`, `history`, `diff`,
   `readRevision`, `restore`, and `purge` are 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` or `@ai-sdk/openai`. AI SDK tools live on
`@seanmozeik/supabash-fs/ai-sdk`.

## Install

```sh
bun add @seanmozeik/supabash-fs @supabase/supabase-js
```

`@supabase/supabase-js` is a peer (`>=2.112.1 <3`). `ai` and `@ai-sdk/openai`
are optional peers for the AI SDK export. Just Bash, YAML and Unbash are bundled;
consumers do not install Just Bash or bash-tool. Import `Bash`, `defineCommand`,
`InMemoryFs`, and the types `CustomCommand`, `IFileSystem`, `FsStat` from this
package. See [edge packaging](docs/edge-packaging.md).

Deno 2 can load the package through npm compatibility:

```ts
import { Supabash } from 'npm:@seanmozeik/supabash-fs';
import { Bash } from 'npm:@seanmozeik/supabash-fs';
```

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`](./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:

- `SELECT` for list, file information, download, and upsert checks.
- `INSERT` for new objects.
- `UPDATE` for object replacement through upsert.
- `DELETE` for 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.

```ts
import { Supabash } from '@seanmozeik/supabash-fs';
import { Bash } from '@seanmozeik/supabash-fs';

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 };
};
```

The bundled shell uses Just Bash’s browser build, which excludes the host
filesystem and Node-only execution backends.

`Supabash.open()` does this work before it lists Storage objects:

1. It accepts only a Supabase publishable key or a legacy anon key.
2. It reads one bearer token from the supplied `Request`.
3. It rejects a user token with the `service_role` claim.
4. It calls `supabase.auth.getUser(token)` to verify the session.
5. 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:

```sh
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.sql \
  -f node_modules/@seanmozeik/supabash-fs/sql/postgres/0004_redact_retention.sql
```

The 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:

```ts
const workspace = await Supabash.createPostgresWorkspace({ publishableKey, request, supabaseUrl });
```

Open it through the Postgres backend:

```ts
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:

```ts
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.

```ts
import { applyPatch, Supabash } from '@seanmozeik/supabash-fs';
import { Bash } from '@seanmozeik/supabash-fs';

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.

```ts
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:

```md
---
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

```ts
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; // true
```

The 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.

The AI SDK adapter owns its bash tool and inspects each command before execution.
Its model-facing description and schema preserve bash-tool 1.3.19’s surface.
Policy inspection 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:

```ts
import { defineCommand } from '@seanmozeik/supabash-fs';

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

```ts
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 `.supabash` segments
- 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

```ts
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 });
```

- `changes` returns the current staged set without a durable write.
- `checkpoint` names the current complete revision. It does not publish staged
  edits. Its idempotency key returns the same marker on retry.
- `checkpoints` lists pinned markers with their labels and retention classes.
  `deleteCheckpoint` releases a marker so retention can remove its revision.
- `commit` publishes staged changes and returns an immutable receipt.
- `discard` drops uncommitted changes only. A clean read-only delegated
  workspace can call it as a no-op; discarding staged changes requires write
  permission.
- `history` reads committed transactions with cursor pagination. An unknown
  cursor fails with `REVISION_NOT_FOUND` instead of restarting the page.
- `diff` compares two committed revisions, a checkpoint, or staged state.
  Text file add/delete/modify entries include a `preview` truncated to
  `previewBytes` (default 8_192). Pass `previewBytes: 0` to skip bodies.
- `readRevision` returns a read-only historical view.
- `restore` rebuilds the live tree the same way `open` and `discard` do, then
  stages the difference against the current baseline. It does not commit. The
  next successful commit records `metadata.sourceRevision` automatically.
- `purge` never 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:

```text
<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.json
```

The 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:

1. freeze staged mutation and hash uploads
2. acquire the optional per-scope commit lease
3. recover any earlier interrupted transaction
4. run the final conflict and quota checks
5. write an intent record and initial recovery snapshot when needed
6. upload visible objects and delete removed paths
7. write content-addressed bodies, the revision manifest, and `complete.json`
8. write the idempotency receipt and update `head.json` last

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.

```ts
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.
For the redaction/retention API, also apply `0004_redact_retention.sql`.
Their exports are `@seanmozeik/supabash-fs/postgres/lazy-reads.sql`,
`@seanmozeik/supabash-fs/postgres/versioned-entries.sql` and
`@seanmozeik/supabash-fs/postgres/redact-retention.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:

```ts
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:

1. read only committed transactions through `history({ cursor })`
2. fetch new bodies through the scoped read API or `readRevision`
3. ignore uncommitted state
4. resume from `receipt.cursor` / `record.cursor`
5. treat restore as a normal forward transaction
6. 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.

```ts
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:

```sql
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:

```ts
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

```ts
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](scripts/stress/hill/README.md) to find a limit for the
deployment's actual workload, file sizes, and latency target.

## Runtime and package size

The package targets Supabase Edge (Deno) and uses web-standard APIs only:
`Request`, `fetch`, `crypto`, `Blob`, and encoding APIs. It also loads and is
smoke-tested under Bun and Node. Published JavaScript has no `node:*` imports.

Compression is unsupported. The command policy denies `gzip`, `gunzip`, `zcat`,
`rg -z` / `--search-zip` (including short-flag clusters), and `rg --pre`
preprocessors, which could invoke compression outside inspection. The build
replaces Just Bash’s `node:zlib` with a throwing stub. Ordinary `rg` searches
remain available. See the [compression audit](docs/compression-audit.md) for
call sites and the existing limits of static policy inspection.

Both exports are minified, tree-shaken ESM built with tsdown, including bundled
declarations. Runtime dependencies are inlined; there is no separate edge export.
The build rejects every `node:*` import and every external package other than
the declared peers.
The default-policy-blocked HTML converter is omitted, including turndown and
@mixmark-io/domino. Explicitly allowing `html-to-markdown` no longer enables its
built-in converter; supply a custom command if needed.

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` or `@ai-sdk/openai`. 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:

```sh
bun install --frozen-lockfile
```

```sh
just verify
just live
just live-postgres
```

`just 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 edge
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](./THIRD_PARTY_NOTICES.md) for source and
dependency notices.

### Forgetting and history retention (Postgres)

After committing a forget edit, redact the older versions of its paths:

```ts
await workspace.redact({
  paths: ['/memories/preferences.md'],
  before: forgetReceipt.revision,
  metadataKeys: ['summary'],
  clearCause: true,
});
const floor = await workspace.restoreFloor();
await workspace.purge({ maxRevisions: 50, keepAfterRevision: oldestPendingRevision });
const page = await workspace.history({ cursor, cursorMissing: 'oldest' });
```

Redaction replaces historical entries with tombstones, deletes unreferenced
bodies, and creates a SQL restore fence. It preserves current files and refuses
uncovered current copies of selected bodies. `readRevision` throws `REDACTED`;
restore throws `RESTORE_CROSSES_REDACTION` for targets before the fence. Delegates
need the distinct `redact` operation. Dry runs are available through `dryRun: true`.

Every Postgres commit checks the redaction epoch captured when its workspace opened.
After another worker redacts, ordinary commits fail with `REDACTION_INVALIDATED`,
even when the head has not changed. The host must cancel that worker, reopen the
workspace, rebuild its operation from current permitted inputs, and retry. Retire
old snapshots, historical views, and sessions with shared mounts; cached detached
bodies are local copies and can still be read until the host replaces them.
Historical lazy SQL loads of redacted bodies fail with `REDACTED`.

A successful `redact()` discards the caller's staged changes and cached snapshot,
then reloads the current tree and epoch so it can continue safely. Commit forget
edits first. Uncertain outcomes clear and invalidate the caller until it reopens or
successfully retries redaction. Raw SQL commits must send `p_redaction_epoch` from
the opened manifest's `redactionEpoch` string; omitted epochs fail closed.

Existing 0.7.0 databases apply the idempotent
`sql/postgres/0004_redact_retention.sql` migration. See
[redaction, retention, and host integration](docs/redaction-retention.md) for the
complete API, deduplication guarantees, metadata scope, SQL functions, and upgrade
instructions. Storage supports the retention/cursor options but not redaction.
