-module(outbox). -export([construct/4, sign/2, cid_of/1, publish/2]). %% Outbox envelope construction + signing per design §3.1. %% %% construct/4 builds an unsigned activity envelope from caller-supplied %% (Type, ActorId, Published, Object). The envelope's `:id` field is %% derived from the host `cid:to_string` BIF over a skeleton tag, so %% recipients can address the activity by its content hash. The %% returned property list is the canonical key-sorted form that %% `envelope:canonical_bytes/1` operates on. %% %% sign/2 takes the unsigned envelope plus a KeySpec proplist that %% mirrors a `public_keys` entry: `[{key_id, _}, {algorithm, _}, %% {value, KeyMaterial}]`. It computes the v1 HMAC stand-in %% `crypto:hash(sha256, <>)` %% — the same scheme `envelope:verify_signature/2` checks — and %% appends a `:signature` pair. %% %% Real Ed25519 / RSA signing arrives in milestone 2 once %% `crypto:sign_ed25519/2` BIFs land; the API shape doesn't change. %% construct/4 — Type and ActorId are atoms; Published is an %% integer timestamp the caller supplies (no clock BIF in this %% port; the HTTP layer / outbox:publish caller injects it). %% Object can be any term, including a property list of inner %% fields. construct(Type, ActorId, Published, Object) -> Skeleton = [{actor, ActorId}, {object, Object}, {published, Published}, {type, Type}], Id = cid:to_string({activity_envelope, Skeleton}), [{actor, ActorId}, {id, Id}, {object, Object}, {published, Published}, {type, Type}]. %% sign/2 — KeySpec carries key_id, algorithm, value (key material). sign(Envelope, KeySpec) -> {ok, KeyId} = envelope:get_field(key_id, KeySpec), {ok, Alg} = envelope:get_field(algorithm, KeySpec), {ok, KM} = envelope:get_field(value, KeySpec), CB = envelope:canonical_bytes(Envelope), SigValue = crypto:hash(sha256, <>), Sig = [{algorithm, Alg}, {key_id, KeyId}, {value, SigValue}], Envelope ++ [{signature, Sig}]. %% cid_of/1 — extract the :id field from a constructed envelope. %% Convenience for callers that don't want to thread the CID %% separately when both the envelope and its ID matter. cid_of(Envelope) -> {ok, Id} = envelope:get_field(id, Envelope), Id. %% publish/2 — the outbound activity pipeline orchestrator. %% %% Request shape: [{type, T}, {object, O}] %% Context shape: [{actor_id, A}, {published, P}, {key_spec, KS}, %% {actor_state, AS}, {log, L}] %% %% Returns: %% {ok, [{cid, Cid}, {activity, Signed}], NewLog} — happy path %% {error, Reason, LogState} — validation halted %% %% Stages run in order: envelope shape, signature, replay. The %% replay check uses the log state pre-append, so if the caller %% publishes the same Request twice with the same Published %% timestamp the second call halts with {error, replay, _}. %% %% Projection-scheduler dispatch (the async fold the design calls %% for) is deferred to Step 7 — once the projection gen_server %% exists, this function will broadcast `Signed` to it. publish(Request, Context) -> Type = envelope_field(type, Request), Object = envelope_field(object, Request), ActorId = envelope_field(actor_id, Context), Published = envelope_field(published, Context), KeySpec = envelope_field(key_spec, Context), ActorState = envelope_field(actor_state, Context), LogState = envelope_field(log, Context), Unsigned = construct(Type, ActorId, Published, Object), Signed = sign(Unsigned, KeySpec), Stages = [ fun (A) -> pipeline:stage_envelope(A) end, pipeline:stage_signature(ActorState), pipeline:stage_replay(LogState) ], case pipeline:run_stages(Signed, Stages) of ok -> {ok, NewLog, _Seq} = log:append(LogState, Signed), broadcast(Signed, envelope_field(projections, Context)), Result = [{cid, cid_of(Signed)}, {activity, Signed}], {ok, Result, NewLog}; {error, Reason} -> {error, Reason, LogState} end. %% broadcast/2 — fire-and-forget cast to each named projection. %% Missing/nil/empty list is a no-op; the publish API does not %% require projections to exist. Activity is the post-sign Signed %% envelope (same value that landed in the log). broadcast(_Activity, nil) -> ok; broadcast(_Activity, []) -> ok; broadcast(Activity, [Name | Rest]) -> projection:async_fold(Name, Activity), broadcast(Activity, Rest). envelope_field(K, PL) -> case envelope:get_field(K, PL) of {ok, V} -> V; not_found -> nil end.