@molecule/api-uploads-encrypted
Provider bond · storage · API (Node) · v1.1.1 · Apache-2.0
Encrypting upload provider for molecule.dev — encrypts every file before another upload provider stores it, decrypts on read.
npm install @molecule/api-uploads-encryptednpm · Source on GitHub · Implements @molecule/api-uploads
How it works
@molecule/api-uploads-encrypted is a provider bond on the API (Node) side: it implements the storage core interface (@molecule/api-uploads) with a concrete vendor or library behind it.
Your code calls the core; you wire this provider once at startup. Swapping vendors later is one line in that wiring, not a rewrite.
import { setProvider } from '@molecule/api-uploads'
import { createProvider } from '@molecule/api-uploads-encrypted'
import { createProvider as createS3Provider } from '@molecule/api-uploads-s3'
const s3Provider = createS3Provider({ bucket: 'my-app-files' })
// Uses the encryption provider bonded to @molecule/api-encryption
// (e.g. @molecule/api-encryption-aes) for every file.
const encrypted = createProvider({
inner: s3Provider,
context: ({ fieldname }) => fieldname,
})
setProvider(encrypted)
// Later: read a file back as plaintext, asserting which object it must be.
const stream = await encrypted.getFile('enc1.YXZhdGFy.3f1c…', { expectContext: 'avatar' })Works with: @molecule/api-encryption, @molecule/api-uploads
Reference
Encrypting upload provider for molecule.dev.
Wraps any other upload provider: every file is encrypted before the inner provider stores it and decrypted on the way back, so the store (S3, R2, a disk, a mirror) never sees plaintext.
Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit
src/index.tsJSDoc, not this file.
Quick Start
import { setProvider } from '@molecule/api-uploads'
import { createProvider } from '@molecule/api-uploads-encrypted'
import { createProvider as createS3Provider } from '@molecule/api-uploads-s3'
const s3Provider = createS3Provider({ bucket: 'my-app-files' })
// Uses the encryption provider bonded to @molecule/api-encryption
// (e.g. @molecule/api-encryption-aes) for every file.
const encrypted = createProvider({
inner: s3Provider,
context: ({ fieldname }) => fieldname,
})
setProvider(encrypted)
// Later: read a file back as plaintext, asserting which object it must be.
const stream = await encrypted.getFile('enc1.YXZhdGFy.3f1c…', { expectContext: 'avatar' })
Type
provider
Installation
npm install @molecule/api-uploads-encrypted @molecule/api-analytics @molecule/api-encryption @molecule/api-uploads
API
Interfaces
EncryptedUploadContextInfo
What the context function is told about each file.
interface EncryptedUploadContextInfo {
/** The form field name the file was submitted under. */
fieldname: string
/** The original filename. */
filename: string
/** The file's MIME type. */
mimeType: string
}
EncryptedUploadsConfig
Configuration for EncryptedUploadProvider.
interface EncryptedUploadsConfig {
/** The upload provider that stores the ciphertext (S3, filesystem, a mirror…). */
inner: UploadProvider
/**
* The encryption provider to use. Defaults to the provider bonded to
* `@molecule/api-encryption`, resolved lazily on every call (so it may be
* bonded after this provider is created). It must implement stream
* encryption (`encryptStream`/`decryptStream`).
*/
encryption?: EncryptionProvider
/**
* Returns the additional authenticated data (AAD) bound to a file. The same
* string is required to decrypt it, and it is stored — readable — in the
* file's id, so never put a secret in it. Must return a non-empty string.
* Defaults to the file's `fieldname`.
*/
context?: (info: EncryptedUploadContextInfo) => string
/**
* Plaintext bytes per authenticated chunk, passed to `encryptStream` for
* providers that accept it (the AES bond allows 4 KiB to 16 MiB). Omit to
* use the encryption provider's default.
*/
chunkBytes?: number
}
EncryptedGetFileOptions
Options for EncryptedUploadProvider.getFile.
interface EncryptedGetFileOptions {
/**
* The context the caller expects this file to carry. When it differs from
* the context stored in the id, `getFile` throws an
* `EncryptionContextMismatchError` before reading anything. Ignored for
* legacy (non-encrypted) ids, which carry no context.
*/
expectContext?: string
}
ParsedEncryptedId
The decoded parts of an encrypted file id.
interface ParsedEncryptedId {
/** The id the inner provider assigned to the ciphertext object. */
innerId: string
/** The context (AAD) the file was encrypted with. */
context: string
}
EncryptedUploadProvider
An upload provider that encrypts every file before handing it to inner
and decrypts it on the way back.
interface EncryptedUploadProvider extends UploadProvider {
/** The provider that stores the ciphertext. */
readonly inner: UploadProvider
/**
* Encrypts `stream` and uploads the ciphertext through `inner`. The returned
* file's `id` is an encrypted id (`enc1.<base64url context>.<inner id>`);
* its `size` is the CIPHERTEXT size the inner provider reports.
*/
upload(
fieldname: string,
stream: NodeJS.ReadableStream,
info: FileInfo,
onError: (error: Error) => void,
): UploadedFile
/** Aborts an in-progress upload, passing through to `inner`. */
abortUpload(file: UploadedFile): Promise<void>
/**
* Returns a stream of the decrypted file, `null` when the inner provider has
* no such object. Tampering, a wrong context or truncation surface as an
* `EncryptionStreamError` on the returned stream.
*/
getFile(id: string, options?: EncryptedGetFileOptions): Promise<NodeJS.ReadableStream | null>
/** Deletes the stored object through `inner`. */
deleteFile(id: string): Promise<void>
/** Decodes an encrypted id; `null` for a non-encrypted (legacy) id. */
parseId(id: string): ParsedEncryptedId | null
/** Whether `id` is an encrypted id produced by this provider. */
isEncryptedId(id: string): boolean
}
Functions
encodeEncryptedId(context, innerId)
Encodes an encrypted id from its context and the inner provider's id.
function encodeEncryptedId(context: string, innerId: string): string
context— The context (AAD) the file was encrypted with.innerId— The inner provider's id for the ciphertext object.
Returns: The encrypted id.
parseEncryptedId(id)
Decodes an encrypted id.
function parseEncryptedId(id: string): ParsedEncryptedId | null
id— Any file id.
Returns: The inner id and context, or null when id is not an encrypted id.
createProvider(config)
Creates an upload provider that encrypts every file before config.inner
stores it and decrypts it on read, so the store never sees plaintext.
function createProvider(config: EncryptedUploadsConfig): EncryptedUploadProvider
config— The inner provider, and optionally the encryption provider, the per-file context function and the chunk size.
Returns: The encrypting upload provider.
Constants
ENCRYPTED_ID_PREFIX
Prefix every encrypted id starts with. The 1 is the id format version.
const ENCRYPTED_ID_PREFIX: 'enc1.'
ENCRYPTION_UNAVAILABLE_ERROR_NAME
Error name used when the encryption provider cannot encrypt streams.
const ENCRYPTION_UNAVAILABLE_ERROR_NAME: 'EncryptionUnavailableError'
ENCRYPTION_CONTEXT_MISMATCH_ERROR_NAME
Error name used when getFile's expectContext differs from the id's context.
const ENCRYPTION_CONTEXT_MISMATCH_ERROR_NAME: 'EncryptionContextMismatchError'
UPLOAD_TOO_LARGE_ERROR_NAME
error.name when the multipart parser's size limit cut the upload (nothing stored).
const UPLOAD_TOO_LARGE_ERROR_NAME: 'UploadTooLargeError'
UPLOAD_SOURCE_CLOSED_ERROR_NAME
error.name when the upload stream closed before it ended (nothing stored).
const UPLOAD_SOURCE_CLOSED_ERROR_NAME: 'UploadSourceClosedError'
INVALID_ENCRYPTION_CONTEXT_ERROR_NAME
Error name used when the context function returns an unusable value.
const INVALID_ENCRYPTION_CONTEXT_ERROR_NAME: 'InvalidEncryptionContextError'
Core Interface
Implements @molecule/api-uploads interface.
Injection Notes
Requirements
Peer dependencies:
@molecule/api-encryption^1.1.0@molecule/api-uploads^1.0.1
Runtime Dependencies
-
@molecule/api-analytics -
@molecule/api-encryption -
@molecule/api-uploads -
What is stored. The ciphertext uses the
mol-aead-chunked-v2framing from@molecule/api-encryption(ENCRYPTED_STREAM_FORMAT): a header, then independently authenticated chunks. A flipped bit, reordered or dropped chunks, a wrong context and a truncated object all fail authentication. The AES bond's objects start with theMOLAEAD2magic. -
Needs stream encryption. The encryption provider (by default the one bonded to
@molecule/api-encryption, resolved on every call) must implementencryptStream/decryptStream— e.g.@molecule/api-encryption-aes. If it does not,upload()reports anEncryptionUnavailableErrorthroughonError, rejectsuploadPromise, and stores nothing. It NEVER falls back to storing plaintext. -
Ids.
upload()returns ids of the formenc1.<base64url(utf8 context)>.<inner id>. Store and pass them back unchanged; they are opaque. The context is IN the id sogetFile(id)can supply the same additional authenticated data, which means the id reveals the context string. Choose contexts that are fine to appear in a private object key (backup:<projectId>, the form fieldname) — never a secret. The context defaults to the fieldname; passcontext: (info) => stringto bind files to something more specific (the context must be non-empty). -
A cut upload is a failed upload. A multipart parser's size limit (busboy's
limitevent, after which the stream ends normally) and a source that closes before it ends both fail the upload —onErrorwith anUploadTooLargeError/UploadSourceClosedError,uploadPromiserejects, the partial ciphertext is removed — rather than sealing the truncated body as a complete object. -
What the inner store sees. The filename and MIME type travel to the inner provider as given (an S3 store keeps the MIME type as the object's content type); only the bytes are encrypted.
-
Reading.
getFile(id)returns a stream of plaintext. Plaintext is released one chunk at a time, only after that chunk's tag verifies; on tampering, a wrong context or truncation the returned stream is destroyed with anEncryptionStreamError, so always handle itserrorevent (or usestream/promisespipeline) and discard partial output on error. Itscodeseparates a damaged object (auth) from a key configuration problem (unknown-key,unknown-key-version) and an early end that may be transport (truncated).getFile(id, { expectContext })throws anEncryptionContextMismatchErrorbefore reading anything when the id was written under a different context — use it when the caller knows which object this must be. -
Sizes. A file's
sizeis the CIPHERTEXT size the inner provider reports (slightly larger than the plaintext). Track the plaintext size yourself if you need it. -
Migration from plaintext. An id without the
enc1.prefix is treated as a legacy plaintext object stored before this provider was bonded:getFile,deleteFileandabortUploadpass it to the inner provider unchanged (anexpectContextis accepted but there is nothing to check). New uploads are always encrypted; useisEncryptedId(id)to find the legacy ones and re-upload them to encrypt them. -
Key rotation is the encryption bond's job: rotate there and keep the old keys available (the AES bond's
priorKeys), and existing objects stay readable — the key version travels in each object's header. -
Composes with
@molecule/api-uploads-mirror. Wrap the mirror —createProvider({ inner: mirror })— so encryption happens once and every copy holds the same ciphertext. -
Errors never include plaintext, keys or ciphertext. Failures are reported to
@molecule/api-analyticsasbond.failure(uploads-encrypted).
E2E Tests
Integration checklist — drive the real UI (live preview, no mocks), adapt each item to this app's actual screens/flows, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip:
- Uploading a valid file through the UI shows progress/confirmation and the file appears in the user's file list.
- The uploaded content is retrievable: opening/downloading it returns the same content (an uploaded image actually renders).
- A disallowed file type is rejected with a visible error and does NOT appear in the list.
- An over-the-cap file is rejected cleanly (visible error, no partial phantom entry).
- Ownership is enforced: a second signed-in user cannot retrieve the first user's file by its id/URL (404 — not the file).
- Deleting a file removes it from the list, and it stays gone (and unretrievable) after a full reload.