Amarula.MessageSecretStore behaviour (amarula v0.5.2)
View SourcePluggable 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.ETSThe 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
@type adapter_state() :: term()
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
@callback count(adapter_state(), profile()) :: non_neg_integer()
Number of stored entries for profile (for tests/introspection).
@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.
@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).
@callback new(opts :: keyword()) :: adapter_state()
Initialise adapter state from opts. Called once per connection.
@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
@spec count(Amarula.MessageSecretStore.Scope.t(), profile()) :: non_neg_integer()
Number of stored entries for profile.
@spec default_adapter() :: module()
The adapter used when config gives bare opts / no :message_secret_store.
@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.
@spec get(Amarula.MessageSecretStore.Scope.t(), profile(), String.t()) :: {:ok, entry()} | :error
Fetch a stored entry by id, or :error.
@spec put(Amarula.MessageSecretStore.Scope.t(), profile(), String.t(), entry()) :: :ok
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.
@spec scope(map()) :: Amarula.MessageSecretStore.Scope.t()
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.