MIRAGE ships S3Resource in two runtimes:
@struktoai/mirage-node, signs requests server-side using @aws-sdk/client-s3.
@struktoai/mirage-browser, stays secret-free: your backend hands out presigned URLs and the browser fetches them with fetch().
Credentials (IAM user, access keys) are created the same way in both runtimes, see AWS S3 Credentials.
Node (server-side)
pnpm add @struktoai/mirage-node
import { MountMode, Workspace } from '@struktoai/mirage-node'
import { S3Resource } from '@struktoai/mirage-node'
const s3 = new S3Resource({
bucket: process.env.AWS_S3_BUCKET!,
region: process.env.AWS_DEFAULT_REGION ?? 'us-east-1',
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
})
const ws = new Workspace({ '/bucket/': s3 }, { mode: MountMode.READ })
const res = await ws.execute('ls /bucket/')
console.log(res.stdoutText)
If ~/.aws/credentials is configured, you can omit accessKeyId/secretAccessKey and the AWS SDK picks up the default profile.
Browser (presigned URLs)
pnpm add @struktoai/mirage-browser
The browser S3Resource never sees AWS credentials. You implement one function, presignedUrlProvider, that your frontend calls to get a signed URL for each S3 operation. The actual signing happens on your server, using any S3-signing SDK you prefer.
1. Server: sign URLs on demand
Using @aws-sdk/s3-request-presigner:
import {
CopyObjectCommand,
DeleteObjectCommand,
GetObjectCommand,
HeadObjectCommand,
ListObjectsV2Command,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
const client = new S3Client({ region: process.env.AWS_DEFAULT_REGION })
const BUCKET = process.env.AWS_S3_BUCKET!
app.post('/presign', async (req, res) => {
const { path, op, opts } = req.body as {
path: string
op: 'GET' | 'PUT' | 'HEAD' | 'DELETE' | 'LIST' | 'COPY'
opts?: Record<string, unknown>
}
const key = path.replace(/^\/+/, '')
const ttl = typeof opts?.ttlSec === 'number' ? opts.ttlSec : 300
let cmd
switch (op) {
case 'GET': cmd = new GetObjectCommand({ Bucket: BUCKET, Key: key }); break
case 'PUT': cmd = new PutObjectCommand({ Bucket: BUCKET, Key: key, ContentType: opts?.contentType as string | undefined }); break
case 'HEAD': cmd = new HeadObjectCommand({ Bucket: BUCKET, Key: key }); break
case 'DELETE': cmd = new DeleteObjectCommand({ Bucket: BUCKET, Key: key }); break
case 'LIST': cmd = new ListObjectsV2Command({
Bucket: BUCKET,
Prefix: opts?.listPrefix as string | undefined,
Delimiter: opts?.listDelimiter as string | undefined,
ContinuationToken: opts?.listContinuationToken as string | undefined,
}); break
case 'COPY': cmd = new CopyObjectCommand({
Bucket: BUCKET,
Key: key,
CopySource: `${BUCKET}/${opts?.copySource as string}`,
}); break
}
const url = await getSignedUrl(client, cmd, { expiresIn: ttl })
res.json({ url })
})
Scope the presigner to the minimal operations your app needs. Read-only viewers only need GET/HEAD/LIST; a file manager needs the full set.
2. Browser: wire it up
import { MountMode, S3Resource, Workspace } from '@struktoai/mirage-browser'
const s3 = new S3Resource({
bucket: 'my-bucket',
presignedUrlProvider: async (path, op, opts) => {
const r = await fetch('/presign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, op, opts }),
})
const { url } = (await r.json()) as { url: string }
return url
},
})
const ws = new Workspace({ '/bucket/': s3 }, { mode: MountMode.READ })
const res = await ws.execute('cat /bucket/hello.txt')
console.log(res.stdoutText)
Presigned URLs reach the S3 host directly from the browser. Your bucket must allow cross-origin requests from your app’s origin, otherwise every fetch() fails with TypeError: Failed to fetch (the browser drops the response at the CORS preflight).
[
{
"AllowedOrigins": ["http://localhost:5173", "https://app.example.com"],
"AllowedMethods": ["GET", "PUT", "HEAD", "DELETE", "POST"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag", "Content-Length", "Content-Type", "Last-Modified"],
"MaxAgeSeconds": 3000
}
]
aws s3api put-bucket-cors --bucket $AWS_S3_BUCKET --cors-configuration file://cors.json
Or use the helper in the browser example, it signs the PutBucketCors call with your existing env credentials and applies the same policy to all configured buckets in one shot:
cd examples/typescript/browser
npx tsx scripts/configure-cors.ts http://localhost:5173 https://app.example.com
CORS is origin-exact. http://localhost:5173 ≠ http://localhost:5174 ≠ https://app.example.com. Include every origin you’ll serve the page from.
See S3 Setup for credential setup.
Scoping a resource to a key prefix
Pass keyPrefix to scope every operation to a subpath of the bucket:
const s3 = new S3Resource({
bucket: 'app-data',
region: 'eu-west-1',
keyPrefix: `users/${userId}/`,
})
When set, every read/write/list/stat/copy/rename/delete operation is transparently scoped to that bucket subpath. Agents see clean paths like /data/notes.md; the underlying bucket key is users/${userId}/data/notes.md. Useful for multi-tenant systems. Pairs naturally with STS AssumeRole session policies for AWS-side enforcement. Unset behavior is unchanged.
Normalization: leading slashes are stripped and a trailing slash is added automatically. Both undefined and an empty string are treated as “no prefix.”
In YAML-based configs the snake-case spelling key_prefix is also accepted and is automatically mapped to keyPrefix at construction.
Browser variant — security noteFor mirage-browser’s S3Resource (which uses presignedUrlProvider), keyPrefix flows through the same core code path, but a client-controlled prefix is not a security boundary — a malicious client can ignore or alter it before signing. For real isolation, enforce the prefix inside your server-side presignedUrlProvider implementation (and in the IAM/STS session policy backing those credentials). Treat the client-side keyPrefix as ergonomics only.