StateService.vala 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. using Invercargill;
  2. using Invercargill.DataStructures;
  3. using InvercargillJson;
  4. using Inversion;
  5. namespace Statum {
  6. /**
  7. * Error domain for state service operations.
  8. */
  9. public errordomain StateError {
  10. /** No slot is cached for the given key. */
  11. SLOT_NOT_FOUND,
  12. /**
  13. * A frame offered for restore is past its snapshot's `invalid_after`
  14. * and must not be re-cached (SA-4: the replay-forever hole).
  15. */
  16. FRAME_EXPIRED,
  17. }
  18. public class StateService : Object {
  19. private const int RETRANSMIT_SECONDS = 60; // 1 minute
  20. private const int EXPIRY_SECONDS = 604800; // 7 days
  21. private Cryptography.EncryptionProvider encryption_provider = inject<Cryptography.EncryptionProvider>();
  22. private Cryptography.SigningProvider signing_provider = inject<Cryptography.SigningProvider>();
  23. /**
  24. * The realtime channel service. Injected (singleton) so that every state
  25. * mutation auto-pushes the resulting frame to subscribed channels. The
  26. * dependency is one-way: {@link ChannelService} never injects this
  27. * service, so there is no DI cycle.
  28. */
  29. private ChannelService channel_service = inject<ChannelService>();
  30. private Dictionary<string, Slot> slots = new Dictionary<string, Slot>();
  31. public Slot new_slot(Scope slot_scope, State initial_state) {
  32. var id = new uint8[32];
  33. Sodium.Random.random_bytes(id);
  34. initial_state.last_touched = new DateTime.now_utc();
  35. var slot = new Slot() {
  36. id = Base64.encode(id),
  37. scope = slot_scope,
  38. type_name = initial_state.type_name,
  39. current_state = initial_state,
  40. };
  41. slots.set(slot.id, slot);
  42. return slot;
  43. }
  44. /**
  45. * Reads the cached slot for `id` without mutating it.
  46. *
  47. * Liveness is now driven by the `transmit_after`/`invalid_after` windows
  48. * and explicit {@link update} calls, so reading a slot no longer touches
  49. * its `last_touched`.
  50. */
  51. public Slot? get_slot(string id) {
  52. Slot? slot = null;
  53. slots.try_get(id, out slot);
  54. return slot;
  55. }
  56. public Model.FrameDto? sign_slot(string id, bool unidirectional = false) throws GLib.Error {
  57. var slot = get_slot(id);
  58. if (slot == null) {
  59. return null;
  60. }
  61. return sign_slot_for((!)slot, unidirectional);
  62. }
  63. /**
  64. * Mutates the slot cached under `key` to `state`, signs the resulting
  65. * snapshot into a frame, auto-pushes that frame to any channel
  66. * subscribed to `key` (a no-op when nothing is subscribed), and returns
  67. * the signed frame so the caller can reuse it for its response `set`
  68. * directive (one sign → push + response).
  69. */
  70. public async Model.FrameDto update(string key, State state) throws GLib.Error {
  71. Slot? slot = null;
  72. if (!slots.try_get(key, out slot) || slot == null) {
  73. throw new StateError.SLOT_NOT_FOUND(@"No slot is cached for key \"$key\"");
  74. }
  75. state.last_touched = new DateTime.now_utc();
  76. slot.current_state = state;
  77. var frame = sign_slot_for(slot, false);
  78. var frame_json = serialize_frame(frame);
  79. yield channel_service.push_set(key, frame_json);
  80. return frame;
  81. }
  82. /**
  83. * Removes the slot cached under `key` and auto-pushes a `clear` event to
  84. * any channel subscribed to it (a no-op when nothing is subscribed).
  85. */
  86. public async void clear_slot(string key) {
  87. slots.remove(key);
  88. yield channel_service.push_clear(key);
  89. }
  90. // ------------------------------------------------------------------
  91. // Global slots (deterministic-key broadcast state)
  92. // ------------------------------------------------------------------
  93. public const string GLOBAL_KEY_PREFIX = "global:";
  94. /**
  95. * Creates (or updates) a global slot with the deterministic key
  96. * `"global:" + type_name`. Global slots share one key across all clients,
  97. * so every subscriber sees the same updates.
  98. */
  99. public Slot new_global_slot(string type_name, Scope slot_scope, State state) {
  100. var key = GLOBAL_KEY_PREFIX + type_name;
  101. state.last_touched = new DateTime.now_utc();
  102. state.type_name = type_name;
  103. Slot? existing = null;
  104. if (slots.try_get(key, out existing) && existing != null) {
  105. ((!)existing).current_state = state;
  106. return (!)existing;
  107. }
  108. var slot = new Slot() {
  109. id = key,
  110. scope = slot_scope,
  111. type_name = type_name,
  112. current_state = state
  113. };
  114. slots.set(key, slot);
  115. return slot;
  116. }
  117. /** Updates a global slot by type name (sign + push to all subscribers). */
  118. public async Model.FrameDto update_global(string type_name, State state) throws GLib.Error {
  119. return yield update(GLOBAL_KEY_PREFIX + type_name, state);
  120. }
  121. /**
  122. * Typed read-mutate-write of a global slot: maps its current public data
  123. * to `TPublic`, applies `mutator`, maps back, signs and pushes. Used from
  124. * background contexts (timers, webhooks) that have no request scope.
  125. *
  126. * Like {@link DirectiveBuilder.update_held}, JSON-array fields the typed
  127. * model cannot read back are carried forward unchanged rather than
  128. * dropped.
  129. */
  130. public async void update_global_typed<TPublic>(string type_name, owned UpdateMutator<TPublic> mutator) throws GLib.Error {
  131. var key = GLOBAL_KEY_PREFIX + type_name;
  132. var slot = get_slot(key);
  133. if (slot == null || ((!)slot).current_state == null) {
  134. throw new StateError.SLOT_NOT_FOUND(@"No global slot for type \"$type_name\"");
  135. }
  136. var current = ((!)slot).current_state;
  137. var model = GObjectMapping.from_properties_typed<TPublic>(typeof(TPublic),
  138. current.public_data ?? new PropertyDictionary());
  139. mutator(model);
  140. var new_state = new State() {
  141. type_name = current.type_name,
  142. public_data = GObjectMapping.preserving_arrays(current.public_data ?? new PropertyDictionary(),
  143. GObjectMapping.to_properties((Object) model)),
  144. private_data = current.private_data ?? new PropertyDictionary()
  145. };
  146. yield update(key, new_state);
  147. }
  148. /** Clears a global slot by type name (remove + push clear to subscribers). */
  149. public async void clear_global(string type_name) {
  150. yield clear_slot(GLOBAL_KEY_PREFIX + type_name);
  151. }
  152. /**
  153. * Re-caches the slot described by `frame` from its signed snapshot.
  154. *
  155. * Security (SA-4): the snapshot's `invalid_after` is enforced here — a
  156. * frame past its invalidation time is rejected (and any cached slot for
  157. * that key dropped) instead of being restored, so a replayed frame
  158. * cannot keep a slot alive forever. On a successful restore the
  159. * snapshot's `as_at` becomes the slot's `last_touched` rather than
  160. * "now": a restored slot must stay expirable by {@link cleanup} — the
  161. * client only holds it because the server lost it, and refreshing the
  162. * touch clock would let scheduled replays pin slots indefinitely.
  163. *
  164. * @return `true` when the slot was restored, `false` when a slot was
  165. * already cached under the same key (no-op).
  166. * @throws StateError.FRAME_EXPIRED when the frame is past its
  167. * `invalid_after` (after the signature verified).
  168. */
  169. public bool restore_slot(Model.FrameDto frame) throws GLib.Error {
  170. signing_provider.verify_frame(frame);
  171. var content_element = new JsonElement.from_string(frame.content);
  172. var json_object = content_element.as<JsonObject>();
  173. var snapshot = Model.SnapshotDto.get_mapper().materialise(json_object);
  174. if (snapshot.invalid_after != null
  175. && ((!)snapshot.invalid_after).compare(new DateTime.now_utc()) <= 0) {
  176. // Expired frame: drop anything cached under its key so the
  177. // stale slot does not linger either.
  178. slots.remove(snapshot.slot.key);
  179. throw new StateError.FRAME_EXPIRED("Frame for slot \"" + snapshot.slot.key + "\" is past its invalid_after");
  180. }
  181. var slot = get_slot(snapshot.slot.key);
  182. if (slot != null) {
  183. // No-op: slot not empty.
  184. return false;
  185. }
  186. slot = new Slot() {
  187. id = snapshot.slot.key,
  188. type_name = snapshot.slot.slot_type,
  189. scope = snapshot.slot.scope,
  190. current_state = new State() {
  191. type_name = snapshot.slot.slot_type,
  192. public_data = snapshot.public,
  193. private_data = snapshot.private != null
  194. ? encryption_provider.read_properties(snapshot.slot.key, snapshot.private)
  195. : new PropertyDictionary(),
  196. last_touched = snapshot.as_at
  197. }
  198. };
  199. slots.set(slot.id, slot);
  200. return true;
  201. }
  202. /**
  203. * Evicts slots whose `invalid_after` window has elapsed — i.e. slots that
  204. * have not been touched for {@link EXPIRY_SECONDS}. The client resends a
  205. * fresh frame via `/_statum/slots` when it next needs one of these.
  206. */
  207. public void cleanup() {
  208. var cutoff = new DateTime.now_utc().add_seconds(-EXPIRY_SECONDS);
  209. var to_remove = slots.values
  210. .where(s => s.current_state.last_touched.compare(cutoff) < 0)
  211. .to_series();
  212. foreach (var item in to_remove) {
  213. slots.remove(item.id);
  214. }
  215. }
  216. private Model.FrameDto sign_slot_for(Slot slot, bool unidirectional) throws GLib.Error {
  217. var slot_dto = new Model.SlotDto() {
  218. key = slot.id,
  219. scope = slot.scope,
  220. slot_type = slot.type_name
  221. };
  222. var timestamp = new DateTime.now_utc();
  223. var snapshot_dto = new Model.SnapshotDto() {
  224. as_at = timestamp,
  225. slot = slot_dto,
  226. transmit_after = unidirectional ? null : timestamp.add_seconds(RETRANSMIT_SECONDS),
  227. invalid_after = timestamp.add_seconds(EXPIRY_SECONDS),
  228. @private = encryption_provider.author_properties(slot.id, slot.current_state.private_data),
  229. @public = slot.current_state.public_data
  230. };
  231. var json = new JsonElement.from_properties(Model.SnapshotDto.get_mapper().map_from(snapshot_dto)).stringify();
  232. return signing_provider.author_frame(json);
  233. }
  234. private static string serialize_frame(Model.FrameDto frame) throws GLib.Error {
  235. return new JsonElement.from_properties(Model.FrameDto.get_mapper().map_from(frame)).stringify();
  236. }
  237. }
  238. }