S3RemoteStore
Store the encrypted DDO envelope in an S3 bucket — AWS S3, Exoscale SOS, MinIO or any other S3-compatible store.
Usage
Exoscale SOS, with both key pairs from the environment:
const = new ({
: 'https://sos-de-fra-1.exo.io', // https://sos-<zone>.exo.io
: 'de-fra-1', // the zone
: 'my-ddos',
: 'ddo/',
: {
: ..!,
: ..!
},
: {
: ..!,
: ..!
}
}) A MinIO server that you reach on 127.0.0.1:9000 and the node reaches as minio:9000.
endpoint may be plain http:// here because it is a loopback host; nodeEndpoint may
always be http://, because only the node connects to it:
const = new ({
: 'http://127.0.0.1:9000', // where you upload
: 'http://minio:9000', // what the node is told
: true, // MinIO uses path-style addressing
: 'ddos',
: 'ddo/',
: {
: ..!,
: ..!
},
: {
: ..!,
: ..!
}
})It needs no node client and no AWS SDK: requests are signed with SigV4 over fetch and
WebCrypto. See remote stores for the setup checklist.
Parameters
options
- Type:
S3RemoteStoreOptions
| Option | Type | Default | Purpose |
|---|---|---|---|
endpoint | string | — | Where this store uploads, e.g. https://sos-de-fra-1.exo.io. No scheme means https://. Plain http:// only on localhost, 127.0.0.0/8, ::1 and *.localhost, or with allowInsecureTransport. No user:password@ in the URL. Required. |
nodeEndpoint | string | endpoint | The endpoint written into the pointer, for when the node reaches the bucket differently than you do. May be http:// (e.g. http://minio:9000 inside Docker): nautilus never connects to it. The pointer gets it (or endpoint) with an explicit, lowercase scheme; none means https://. No user:password@ in the URL. |
region | string | 'us-east-1' | As on the node. For Exoscale, the zone. |
bucket | string | — | Required. An S3 bucket name: 3–63 lowercase letters, digits, dots and hyphens, starting and ending with a letter or digit. |
prefix | string | '' | Prepended to every object key as is, e.g. 'ddo/' (without the /, 'ddo' gives ddo<DID hash>/…). Scope the read key to it. |
forcePathStyle | boolean | false | Path-style addressing (endpoint/bucket/key) for uploads. true for MinIO, and required when endpoint is an IP address or localhost. |
nodeForcePathStyle | boolean | forcePathStyle | The addressing style written into the pointer. Required to be true (directly or through forcePathStyle) when nodeEndpoint is an IP address or localhost. |
writeCredentials | { accessKeyId, secretAccessKey } | — | Uploads and removes. Never written anywhere. Required. |
readCredentials | { accessKeyId, secretAccessKey } | — | Written, node-encrypted, into the on-chain pointer. Must be read-only. Required. |
allowSharedCredentials | boolean | false | Accept one key pair for both. The write key then sits in every pointer, forever, and a one-time console.warn says so. |
allowWritableReadKey | boolean | false | Let check() pass, with a warning, although the read key can write or delete. |
allowInsecureTransport | boolean | false | Accept a plain http:// endpoint on a non-loopback host. |
requestTimeoutMs | number | 30000 | Timeout of each request, body included. A finite number of milliseconds, more than 0; above 2^31 − 1 it is clamped to that. A network error or a 500/502/503/504 is retried once, freshly signed; a timeout is not. |
fetchImpl | typeof fetch | fetch | Inject a custom fetch. |
now | () => Date | () => new Date() | The clock used for signing. For tests only. |
Both credential options are S3Credentials, { accessKeyId: string; secretAccessKey: string }.
The constructor throws when:
- a required option is missing or blank (naming it);
- the read and write access key ids are the same, unless
allowSharedCredentialsis set; endpointis plainhttp://on a non-loopback host, unlessallowInsecureTransportis set;endpointornodeEndpointhas a scheme other thanhttp://orhttps://;endpointornodeEndpointcarries a user name or password (user:password@host). Requests are signed with the key pairs and sent to the host alone, and the endpoint would otherwise reach the pointer (PublishResponse.stored.pointerincluded, where only the secret key is redacted) and error messages. The message does not repeat the URL;requestTimeoutMsis not a finite number of milliseconds above 0;bucketbreaks the S3 naming rules (it goes into the request URL), or contains dots whileendpointishttps://with virtual-host addressing:bucket.with.dots.hostdoes not match the endpoint's certificate, so setforcePathStyle: true;prefixhas an empty,.or..segment, starts with/or contains a backslash (it may end in/). S3 does not normalise keys but URL handling does, so the upload and the node's read would reach different objects;endpointornodeEndpointis an IP address orlocalhostand the matching addressing style is virtual-host, which cannot work there (bucket.127.0.0.1). The message names the option to set:forcePathStyle: true, ornodeForcePathStyle: true.
put
Uploads the envelope with the write key to <prefix><DID hash>/<envelope sha256>.json and
returns an S3Pointer, the same shape as ocean.js's S3FileObject:
const : S3Pointer = {
: 's3',
: {
: 'http://minio:9000', // nodeEndpoint, or endpoint, with a lowercase scheme
: 'us-east-1',
: 'ddos',
: 'ddo/<DID hash>/<envelope sha256>.json',
: '<read key>', // readCredentials, never the write key
: '<read secret>',
: true // nodeForcePathStyle, or forcePathStyle
}
}The <DID hash> is the hex part of the did:ope: DID. Object keys are deterministic, and an edit writes a sibling object instead of overwriting: the object an
on-chain pointer refers to never changes, so its hash keeps matching, and a failed edit
leaves the live version intact.
check
Called by publish,
completePublish and edit
before their first transaction. It:
- uploads a small probe with the write key, at a random key shaped like a real object,
<prefix>nautilus-store-check/<64 random hex>.json, so a policy that grants access only to<prefix>*/*is probed as well; - reads it back with the read key, as the node will (though through
endpoint, notnodeEndpoint), and fails if the content differs; - makes sure the read key can neither PUT nor DELETE: it tries both with the read key,
the PUT at another such key. Only an explicit
403 AccessDeniedcounts as "cannot"; any other answer, a bare403from a proxy included, fails the check, because it says nothing about the key. If the read key can write or delete, the check fails — or, withallowWritableReadKey, warns. Skipped when both keys are the same (allowSharedCredentials); - deletes every probe it wrote with the write key, also when a step above failed. A failed
cleanup never hides the first error: both are thrown together as an
AggregateError. A cleanup failure on its own fails the check too, sinceremove()would not work either. A probe that is already gone (404) counts as cleaned up.
check() does not try version-level actions. On a versioned bucket, a read key allowed
s3:DeleteObjectVersion (or to change version ACLs or retention) passes it, so make sure its
policy grants s3:GetObject and nothing that writes. Deleting a probe there leaves its
version behind a delete marker; a lifecycle rule on <prefix>nautilus-store-check/ removes
them.
S3 errors are turned into actionable messages: a signature mismatch (check the secret,
region and forcePathStyle), an unknown access key, a bucket in another region (naming
it, when S3 does), a redirect (not followed with a signed request; it means region or
endpoint does not match the bucket), a missing bucket, a missing object, or access denied with
the permission the key needs (s3:GetObject for the read key, s3:PutObject and
s3:DeleteObject for the write key). Network errors and timeouts name the method, bucket,
key, the key's role and the origin.
verify
await .(, ) Called by publish, completePublish and edit right before the metadata transaction. It
reads the stored envelope back with the read key, which also proves the key in the
pointer can read the object, and throws unless it hashes, the way the node hashes it, to the
on-chain metadata hash. It reads through endpoint, not nodeEndpoint. Only pointers into
this store's bucket and under its prefix are accepted.
remove
const { } = await .()
// later, once the asset is revoked for good: a live asset needs this creation envelope
await .(.) Deletes the object behind a pointer this store returned, with the write key — for example
PublishResponse.stored.pointer, whose secret is
redacted (only bucket and objectKey are used), or an older version after an edit. Throws
for anything but an s3 pointer into this store's bucket whose objectKey has the exact
shape put() writes, <prefix><DID hash>/<sha256>.json, and for an object key with an
empty, . or .. segment, a leading / or a backslash. An object that is already gone
(404 NoSuchKey) counts as removed.
nautilus calls it itself only for an envelope whose metadata transaction was never sent, or
mined and reverted; see
RemoteStore.remove.
In the browser
Every request is a signed fetch with authorization, content-type, x-amz-content-sha256
and x-amz-date headers, so the browser sends a CORS preflight and the bucket must allow your
app's origin for GET, PUT and DELETE with those headers. The host header is signed too;
browsers set it themselves, to the same value, so the signature still matches. A minimal rule
set for AWS S3:
{
"CORSRules": [
{
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["GET", "PUT", "DELETE"],
"AllowedHeaders": ["authorization", "content-type", "x-amz-content-sha256", "x-amz-date"],
"MaxAgeSeconds": 3600
}
]
}See browser use for how to apply it on AWS, Exoscale SOS and MinIO, and why the write key must not ship in a public web app.