Amarula.MessageSecretStore behaviour (amarula v0.5.3)

View Source

Pluggable store of inbound messages' messageContextInfo.messageSecret, keyed by message id — the key material needed to decrypt a later secretEncryptedMessage MESSAGE_EDIT envelope targeting that message (issue #30; see Amarula.Protocol.Messages.EditEnvelope).

Each entry is %{secret: binary(), sender: String.t()}: the message's secret and its original, server-attested sender (used to reject an edit forged by a different group member who also knows the secret).

This is a separate concern from Amarula.Storage: it holds ephemeral, bounded state (secrets for the ~15-minute edit window), not durable account state, so it has its own behaviour, adapters, and config key.

Adapters

  • Amarula.MessageSecretStore.ETS — in-memory, per-connection (the default). Lost on restart, which is usually fine: an edit lands within minutes of the original. TTL-bounded so it can't grow without limit.
  • Amarula.MessageSecretStore.ReadOnly — backed by your own message store; Amarula keeps no copy and only reads. See "Using your own store".

Select one via the connection config :message_secret_store (a {adapter, opts} spec, a bare adapter module, a bare opts list → the default adapter, or a prebuilt Scope), or set the default for all connections:

config :amarula, message_secret_store_adapter: Amarula.MessageSecretStore.ETS

The behaviour is open: any module implementing it can be passed as the adapter.

Using your own store

If your application already persists received messages (you must, to render an edit at all), the built-in cache is a redundant second copy — and it does not survive a connection restart. Point the store at what you already have with Amarula.MessageSecretStore.ReadOnly (or your own behaviour module): you own writes — Amarula never stores anything — and on an edit it reads the target message's secret back from you by msg_id. Retention (and surviving restarts) is then whatever your store already does.

On receive, persist the secret (Amarula.Msg.message_secret/1) and the server-attested sender (msg.from) alongside the message, then serve them back:

# in your :messages_upsert handler
for m <- messages do
  MyApp.Messages.save(m.id, secret: Amarula.Msg.message_secret(m), sender: m.from)
end

# at connect
Amarula.new(%{
  profile: :primary,
  message_secret_store:
    {Amarula.MessageSecretStore.ReadOnly,
     get: fn _profile, msg_id ->
       case MyApp.Messages.fetch(msg_id) do
         {:ok, %{secret: s, sender: j}} when is_binary(s) -> {:ok, %{secret: s, sender: j}}
         _ -> :error
       end
     end}
})

Scoping

Like Amarula.Storage, every call carries the connection profile, so one adapter instance serves many connections. The dispatch handle is a MessageSecretStore.Scope (adapter + state).

Summary

Types

Adapter-specific state, returned by the adapter's new/1.

A stored entry: the message's secret and its server-attested sender.

The connection identity (its :profile).

Callbacks

Number of stored entries for profile (for tests/introspection).

Optional. Create any process-owned local resource the adapter needs, owned by the calling process — for the ETS adapter, the named table. Called from Connection.init, so the table is owned by Connection and dies (recreated empty) with it. Adapters with no such resource don't implement this.

Fetch a stored entry by msg_id, or :error on a miss (or past retention).

Initialise adapter state from opts. Called once per connection.

Optional. Stash entry under msg_id for profile, staying within bound. A read-only adapter backed by the consumer's own store (e.g. Amarula.MessageSecretStore.ReadOnly) does not implement this — the consumer owns writes, so Amarula never writes here.

Functions

Number of stored entries for profile.

The adapter used when config gives bare opts / no :message_secret_store.

Create the adapter's process-owned local resource (the ETS table, for the default adapter), owned by the caller — call this from Connection.init. A no-op for adapters that don't own such a resource.

Fetch a stored entry by id, or :error.

Stash a received message's secret + sender. A no-op for read-only adapters (those that don't implement put/4) — the consumer's store already has it.

Build a MessageSecretStore.Scope from a connection config. Reads :message_secret_store (a {adapter, opts} spec, a bare adapter module, a bare opts list, or a prebuilt Scope); when absent, uses default_adapter/0.

Types

adapter_state()

@type adapter_state() :: term()

Adapter-specific state, returned by the adapter's new/1.

entry()

@type entry() :: %{secret: binary(), sender: String.t()}

A stored entry: the message's secret and its server-attested sender.

profile()

@type profile() :: atom() | String.t()

The connection identity (its :profile).

Callbacks

count(adapter_state, profile)

@callback count(adapter_state(), profile()) :: non_neg_integer()

Number of stored entries for profile (for tests/introspection).

ensure_local(adapter_state, profile)

(optional)
@callback ensure_local(adapter_state(), profile()) :: :ok

Optional. Create any process-owned local resource the adapter needs, owned by the calling process — for the ETS adapter, the named table. Called from Connection.init, so the table is owned by Connection and dies (recreated empty) with it. Adapters with no such resource don't implement this.

get(adapter_state, profile, msg_id)

@callback get(adapter_state(), profile(), msg_id :: String.t()) :: {:ok, entry()} | :error

Fetch a stored entry by msg_id, or :error on a miss (or past retention).

new(opts)

@callback new(opts :: keyword()) :: adapter_state()

Initialise adapter state from opts. Called once per connection.

put(adapter_state, profile, msg_id, entry)

(optional)
@callback put(adapter_state(), profile(), msg_id :: String.t(), entry()) :: :ok

Optional. Stash entry under msg_id for profile, staying within bound. A read-only adapter backed by the consumer's own store (e.g. Amarula.MessageSecretStore.ReadOnly) does not implement this — the consumer owns writes, so Amarula never writes here.

Functions

count(scope, profile)

Number of stored entries for profile.

default_adapter()

@spec default_adapter() :: module()

The adapter used when config gives bare opts / no :message_secret_store.

ensure_local(scope, profile)

@spec ensure_local(Amarula.MessageSecretStore.Scope.t(), profile()) :: :ok

Create the adapter's process-owned local resource (the ETS table, for the default adapter), owned by the caller — call this from Connection.init. A no-op for adapters that don't own such a resource.

get(scope, profile, msg_id)

@spec get(Amarula.MessageSecretStore.Scope.t(), profile(), String.t()) ::
  {:ok, entry()} | :error

Fetch a stored entry by id, or :error.

put(scope, profile, msg_id, entry)

Stash a received message's secret + sender. A no-op for read-only adapters (those that don't implement put/4) — the consumer's store already has it.

scope(config)

Build a MessageSecretStore.Scope from a connection config. Reads :message_secret_store (a {adapter, opts} spec, a bare adapter module, a bare opts list, or a prebuilt Scope); when absent, uses default_adapter/0.