@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-encrypted

npm · 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.ts JSDoc, 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-v2 framing 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 the MOLAEAD2 magic.

  • Needs stream encryption. The encryption provider (by default the one bonded to @molecule/api-encryption, resolved on every call) must implement encryptStream/decryptStream — e.g. @molecule/api-encryption-aes. If it does not, upload() reports an EncryptionUnavailableError through onError, rejects uploadPromise, and stores nothing. It NEVER falls back to storing plaintext.

  • Ids. upload() returns ids of the form enc1.<base64url(utf8 context)>.<inner id>. Store and pass them back unchanged; they are opaque. The context is IN the id so getFile(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; pass context: (info) => string to 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 limit event, after which the stream ends normally) and a source that closes before it ends both fail the upload — onError with an UploadTooLargeError / UploadSourceClosedError, uploadPromise rejects, 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 an EncryptionStreamError, so always handle its error event (or use stream/promises pipeline) and discard partial output on error. Its code separates 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 an EncryptionContextMismatchError before 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 size is 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, deleteFile and abortUpload pass it to the inner provider unchanged (an expectContext is accepted but there is nothing to check). New uploads are always encrypted; use isEncryptedId(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-analytics as bond.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.