S3-compatible home
An S3-compatible bucket is the durable home for teams and power users who
already run object storage: Amazon S3, Cloudflare R2, Backblaze B2, a
self-hosted MinIO. The user brings a bucket and a key pair; the store writes one
backup object there, encrypted when you give it a backup password and a
readable ZIP otherwise (requireEncryption defaults to false). There is no
AWS SDK and no serverless signer in the way: the browser builds and signs every
request itself.
One call
import { selfstore } from 'selfstore';
// Never write a readable backup, whatever a later code path forgets.
const store = await selfstore('my-app', { requireEncryption: true });
const outcome = await store.connectS3(
{
endpoint: 'https://s3.eu-west-3.amazonaws.com', // or your R2 / B2 / MinIO origin
region: 'eu-west-3',
bucket: 'my-app-backups',
key: 'ada/my-app.zip', // the object key the backup lives at
accessKeyId,
secretAccessKey,
},
{ password: backupPassword }, // encrypts a new backup from its first write
);
// 'started' | 'merged' | 'manual' | 'cancelled'
As with every home, two passwords can be in play: the bucket credential (the
access key pair, how the store reaches S3) and the backup password that
encrypts the object itself. The backup password goes into the connect call: it
encrypts a new object from its first write and opens one that is already
encrypted. store.protect(...) is not a substitute before connecting: it needs
a connected destination and throws NOT_CONNECTED without one, and called
afterwards it rewrites the backup encrypted, but the first one has already been
written in the bucket in clear. With requireEncryption: true, a connect
without a password throws ENCRYPTION_REQUIRED instead of writing anything.
Signed in the browser, no SDK
selfstore signs each request with AWS Signature V4 using WebCrypto (HMAC-SHA256). The secret key derives a per-request signature locally and never leaves the device - only the signature, the access-key id and the signed headers travel. That is stronger than a bearer token on the wire, and it is why S3 works with no broker: the signing that a serverless function usually does happens in the page.
- https is enforced (loopback aside for a local MinIO), so object bytes and the signature are never sent in the clear.
- The secret key is sealed at rest under the same non-extractable per-device key as the WebDAV credential - it survives a reload but resists casual inspection of IndexedDB (not code in your origin; see the security guide).
- Path-style by default (
endpoint/bucket/key), which every provider and a plain MinIO accept. PassforcePathStyle: falsefor virtual-hosted-style (bucket.endpoint/key) where you prefer it.
The bucket needs CORS
The request comes from your app’s browser origin, so the bucket must allow it. A minimal rule (adjust the origin and methods to your setup):
[
{
"AllowedOrigins": ["https://your-app.example"],
"AllowedMethods": ["GET", "PUT", "HEAD"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag"]
}
]
Your app’s Content-Security-Policy must also allow the endpoint host in
connect-src. A missing CORS rule is the usual first-run stumble; a
connectS3 that never resolves is almost always this.
Reading the outcomes
Same contract as every home. started wrote this device’s data to an empty
key; merged folded an existing backup in; cancelled means the credentials
were rejected (wrong key, or the object is encrypted and you passed no
password - PASSWORD_REQUIRED is thrown before anything changes). A later
403 from S3 surfaces as a genuine access loss (re-enter the keys); a 404
just means the object does not exist yet and the key is writable.
On the advanced store
Driving createLocalStore yourself, or attaching S3 as a
resilience replica:
import { s3Target } from 'selfstore/advanced';
const target = await s3Target.connect({
kv: store.flowHost.kv,
config: { endpoint, region, bucket, key, accessKeyId, secretAccessKey },
});
await store.advanced.attachTarget(target, { password: backupPassword });