rails-markup 1.2.4 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c558fe97e361a67cf9bea35dda5c4477c631d14d4fe3883a252e7df41a9ffce5
4
- data.tar.gz: e1c04335a8337fbb2a9f05031c12980be2fbf4eeffd6dc7c767840801238a910
3
+ metadata.gz: c2efa8f1fca5e20c13c63a7157b47553cb893df6384bb0799b57b1dd9caa2bb6
4
+ data.tar.gz: 5b244141b9d9bab054f7baa2f15dd3af9560e0ee3aaefcaeaef81c5e4e3541bd
5
5
  SHA512:
6
- metadata.gz: 879ed444ed7087e294ae9ac493463f6e1ce93be51e1a32743c75e4a4de730eaa5c25637511feea145c4ec3825ccb716391f158528febd7c3e80acc46a382c14e
7
- data.tar.gz: 4d6fefa5e2fd4f5f04aa60a0b8ba6d0ea7cfa6447d1303ed252d4b3a92dbe71b3b586337e7f60010c1236d34441c1adf36a449b8e24dfff50b19154ef3cc83be
6
+ metadata.gz: d6b1da9212c6140e1db0767b6a27b972497063657a8b5f23d28e1ba84d21d1ed606473182516730708bd7f29447df448baf967f34cf291de40374b724b984cd4
7
+ data.tar.gz: 73f188f0de27d3fc628bc31388daa1a6e0048a124c3d1a0dfb91dcf9be0b048470179c4b47be58b403c2850653064d005fde99f10738bb394433c4cd17ba0cbd
@@ -381,7 +381,7 @@
381
381
  this._boundMouseUp = (e) => self._handleMouseUp(e);
382
382
  this._boundClick = (e) => self._handleClick(e);
383
383
  this._boundKeyDown = (e) => self._handleKeyDown(e);
384
- this._boundTouchStart = (e) => { if (self.active && e.touches[0]) { const t = e.touches[0]; self._handleMouseDown({ clientX: t.clientX, clientY: t.clientY }); } };
384
+ this._boundTouchStart = (e) => { if (self.active && e.touches[0]) { const t = e.touches[0]; const el = document.elementFromPoint(t.clientX, t.clientY); if (el && !self._isToolbar(el) && e.cancelable) e.preventDefault(); self._handleMouseDown({ clientX: t.clientX, clientY: t.clientY }); } };
385
385
  this._boundTouchEnd = (e) => { if (self.active && e.changedTouches[0]) { const t = e.changedTouches[0]; const el = document.elementFromPoint(t.clientX, t.clientY); if (el && !self._isToolbar(el)) { e.preventDefault(); self._handleMouseUp({ clientX: t.clientX, clientY: t.clientY, preventDefault(){}, stopPropagation(){} }); } } };
386
386
 
387
387
  // Turbo Frames — partial DOM update, reposition pins
@@ -512,7 +512,14 @@
512
512
 
513
513
  _handleMouseDown(event) {
514
514
  const el = document.elementFromPoint(event.clientX, event.clientY);
515
- if (el && !this._isToolbar(el)) this.clickedElement = el;
515
+ if (el && !this._isToolbar(el)) {
516
+ this.clickedElement = el;
517
+ // Suppress the press so host controls (buttons, drag handles, form
518
+ // fields) don't act before mouseup/click is blocked. Guard for the
519
+ // synthetic object passed from the touchstart handler.
520
+ if (typeof event.preventDefault === "function") event.preventDefault();
521
+ if (typeof event.stopPropagation === "function") event.stopPropagation();
522
+ }
516
523
  },
517
524
 
518
525
  async _handleMouseUp(event) {
@@ -707,6 +714,7 @@
707
714
  id: this.nextId,
708
715
  clientId: this._newClientId(),
709
716
  serverId: null,
717
+ serverRevision: 0,
710
718
  syncState: "pending",
711
719
  serverUpdatedAt: null,
712
720
  dirtyFields: [],
@@ -1035,13 +1043,17 @@
1035
1043
 
1036
1044
  // ---- Storage ----
1037
1045
 
1038
- _storageKey() { return "rm-annotations"; },
1046
+ _storageKey() {
1047
+ const endpoint = (this.endpoint || "/feedback/api").replace(/\/+$/, "") || "/";
1048
+ return `rm-annotations:${encodeURIComponent(endpoint)}`;
1049
+ },
1039
1050
  _pageUrl() { return window.location.pathname + window.location.search; },
1040
- _pageStorageKey() { return "rm-annotations:" + this._pageUrl(); },
1051
+ _pageStorageKey() { return this._storageKey() + ":" + this._pageUrl(); },
1041
1052
 
1042
1053
  _saveToStorage() {
1043
1054
  try {
1044
- // Save all annotations to a single global key
1055
+ // Cross-tab storage-event merging is deferred: safely reconciling ordered
1056
+ // upserts and tombstones needs conflict semantics, not a last-write merge.
1045
1057
  localStorage.setItem(this._storageKey(), JSON.stringify({
1046
1058
  annotations: this.annotations,
1047
1059
  nextId: this.nextId,
@@ -1066,12 +1078,16 @@
1066
1078
  _queueLocalMutation(type, annotation, dirtyFields) {
1067
1079
  const currentEntry = this.outbox[annotation.clientId];
1068
1080
  const revision = Math.max(annotation.revision || 0, currentEntry?.revision || 0) + 1;
1081
+ const baseRevision = Number.isInteger(currentEntry?.baseRevision)
1082
+ ? currentEntry.baseRevision
1083
+ : (Number.isInteger(annotation.serverRevision) ? annotation.serverRevision : 0);
1069
1084
 
1070
1085
  if (type === "delete") {
1071
1086
  this.outbox[annotation.clientId] = {
1072
1087
  type: "delete",
1073
1088
  clientId: annotation.clientId,
1074
1089
  revision,
1090
+ baseRevision,
1075
1091
  syncState: "pending"
1076
1092
  };
1077
1093
  } else {
@@ -1083,6 +1099,7 @@
1083
1099
  type: "upsert",
1084
1100
  clientId: annotation.clientId,
1085
1101
  revision,
1102
+ baseRevision,
1086
1103
  syncState: "pending",
1087
1104
  annotation: this._desiredState(annotation),
1088
1105
  dirtyFields: annotation.dirtyFields.slice()
@@ -1197,6 +1214,15 @@
1197
1214
  this._markSyncFailed(snapshot);
1198
1215
  continue;
1199
1216
  }
1217
+ if (result.kind === "conflict") {
1218
+ const pulled = await this._pullAnnotations();
1219
+ if (pulled && this.outbox[clientId]) {
1220
+ clientIds.push(clientId);
1221
+ continue;
1222
+ }
1223
+ this._scheduleSyncRetry();
1224
+ break;
1225
+ }
1200
1226
  if (result.kind === "malformed") {
1201
1227
  if (!this._outboxEntryMatches(snapshot)) {
1202
1228
  clientIds.push(clientId);
@@ -1225,7 +1251,10 @@
1225
1251
  signal: AbortSignal.timeout(5000)
1226
1252
  };
1227
1253
  if (snapshot.type === "upsert") {
1228
- options.body = JSON.stringify(Object.assign({}, snapshot.annotation, { dirtyFields: snapshot.dirtyFields || [] }));
1254
+ options.body = JSON.stringify(Object.assign({}, snapshot.annotation, {
1255
+ dirtyFields: snapshot.dirtyFields || [],
1256
+ baseRevision: Number.isInteger(snapshot.baseRevision) ? snapshot.baseRevision : 0
1257
+ }));
1229
1258
  }
1230
1259
 
1231
1260
  const response = await fetch(request.url, options);
@@ -1263,6 +1292,7 @@
1263
1292
  if ([408, 425, 429].includes(status) || status >= 500) {
1264
1293
  return { kind: "retryable", retryAfter: this._retryAfterDelay(response) };
1265
1294
  }
1295
+ if (status === 409 && snapshot.type === "upsert") return { kind: "conflict" };
1266
1296
  if (status >= 400) return { kind: "terminal" };
1267
1297
  if (!response.ok) return { kind: "retryable" };
1268
1298
  if (snapshot.type === "delete") return { kind: "success", data: null };
@@ -1286,7 +1316,7 @@
1286
1316
  const required = [
1287
1317
  "id", "clientId", "userId", "authorName", "content", "intent", "severity",
1288
1318
  "status", "selectedText", "pageUrl", "target", "metadata", "thread",
1289
- "createdAt", "updatedAt"
1319
+ "createdAt", "updatedAt", "revision"
1290
1320
  ];
1291
1321
  if (!required.every(key => Object.prototype.hasOwnProperty.call(data, key))) return false;
1292
1322
  if (typeof data.id !== "string" || data.id.length === 0) return false;
@@ -1302,6 +1332,7 @@
1302
1332
  if (!this._plainObject(data.target) || !this._plainObject(data.metadata)) return false;
1303
1333
  if (!Array.isArray(data.thread)) return false;
1304
1334
  if (!this._validServerTimestamp(data.createdAt) || !this._validServerTimestamp(data.updatedAt)) return false;
1335
+ if (!Number.isInteger(data.revision) || data.revision < 0) return false;
1305
1336
  return true;
1306
1337
  },
1307
1338
 
@@ -1335,6 +1366,7 @@
1335
1366
  return;
1336
1367
  }
1337
1368
  annotation.serverId = server.id;
1369
+ annotation.serverRevision = server.revision;
1338
1370
  annotation.userId = server.userId;
1339
1371
  annotation.authorName = server.authorName;
1340
1372
  annotation.createdAt = server.createdAt;
@@ -1436,7 +1468,6 @@
1436
1468
 
1437
1469
  _loadFromStorage() {
1438
1470
  try {
1439
- // Load from global key first
1440
1471
  let raw = localStorage.getItem(this._storageKey());
1441
1472
  if (raw) {
1442
1473
  const data = JSON.parse(raw);
@@ -1448,8 +1479,11 @@
1448
1479
  ? data.legacyMigrations
1449
1480
  : {};
1450
1481
  }
1451
- // Migrate any old per-page annotations into global store
1452
- const migratedKeys = this._migratePageAnnotations();
1482
+ // Pre-1.3 storage had no endpoint identity. The first configured endpoint
1483
+ // to load on this origin claims it once; successful consolidation removes
1484
+ // the source keys so another endpoint cannot import the same data later.
1485
+ const migratedKeys = this._migrateUnnamespacedStorage();
1486
+ migratedKeys.push(...this._migratePageAnnotations());
1453
1487
  this._normalizeStoredState();
1454
1488
  this._recordLegacyMigrations();
1455
1489
  if (this._saveToStorage()) this._cleanupMigratedKeys(migratedKeys);
@@ -1458,9 +1492,57 @@
1458
1492
  } catch (e) { console.warn("[rails-markup] load failed:", e); }
1459
1493
  },
1460
1494
 
1495
+ _migrateUnnamespacedStorage() {
1496
+ const sourceKeys = [];
1497
+ for (let index = 0; index < localStorage.length; index++) {
1498
+ const key = localStorage.key(index);
1499
+ if (key === "rm-annotations" || (key && key.startsWith("rm-annotations:/"))) sourceKeys.push(key);
1500
+ }
1501
+
1502
+ const migratedKeys = [];
1503
+ const legacyAnnotations = [];
1504
+ const legacyOutbox = {};
1505
+ const consolidatedClientIds = new Set(
1506
+ this.annotations.map(annotation => annotation.clientId).filter(clientId => this._validClientId(clientId))
1507
+ );
1508
+ sourceKeys.forEach(key => {
1509
+ try {
1510
+ const data = JSON.parse(localStorage.getItem(key));
1511
+ if (!this._plainObject(data)) return;
1512
+ const hasAnnotations = Array.isArray(data.annotations);
1513
+ const hasOutbox = key === "rm-annotations" && this._plainObject(data.outbox);
1514
+ if (!hasAnnotations && !hasOutbox) return;
1515
+
1516
+ if (hasAnnotations) {
1517
+ data.annotations.forEach((annotation, index) => {
1518
+ if (!this._plainObject(annotation)) return;
1519
+ const fingerprint = this._legacyMigrationFingerprint(key, index, annotation);
1520
+ const migratedClientId = this.legacyMigrations[fingerprint];
1521
+ if (this._validClientId(migratedClientId) && consolidatedClientIds.has(migratedClientId)) return;
1522
+ if (this._validClientId(migratedClientId)) annotation.clientId = migratedClientId;
1523
+ if (this._validClientId(annotation.clientId) && consolidatedClientIds.has(annotation.clientId)) return;
1524
+ Object.defineProperty(annotation, "_legacyMigrationFingerprint", {
1525
+ configurable: true,
1526
+ value: fingerprint
1527
+ });
1528
+ legacyAnnotations.push(annotation);
1529
+ if (this._validClientId(annotation.clientId)) consolidatedClientIds.add(annotation.clientId);
1530
+ });
1531
+ }
1532
+ if (hasOutbox) Object.assign(legacyOutbox, data.outbox);
1533
+ if (Number.isInteger(data.nextId) && data.nextId > this.nextId) this.nextId = data.nextId;
1534
+ migratedKeys.push(key);
1535
+ } catch {}
1536
+ });
1537
+
1538
+ this.annotations = legacyAnnotations.concat(this.annotations);
1539
+ this.outbox = Object.assign({}, legacyOutbox, this.outbox);
1540
+ return migratedKeys;
1541
+ },
1542
+
1461
1543
  _migratePageAnnotations() {
1462
- // Find and merge any legacy per-page annotation keys
1463
- const prefix = "rm-annotations:";
1544
+ // Find and merge per-page annotation keys only within this endpoint namespace.
1545
+ const prefix = this._storageKey() + ":";
1464
1546
  const migratedKeys = [];
1465
1547
  const seenIds = new Set(this.annotations.map(a => a.id));
1466
1548
  const consolidatedClientIds = new Set(this.annotations.map(a => a.clientId).filter(clientId => this._validClientId(clientId)));
@@ -1530,6 +1612,11 @@
1530
1612
  }
1531
1613
  if (annotation.serverId == null) annotation.serverId = annotation.server_id ?? null;
1532
1614
  if (annotation.serverUpdatedAt == null) annotation.serverUpdatedAt = annotation.server_updated_at ?? null;
1615
+ if (!Number.isInteger(annotation.serverRevision) || annotation.serverRevision < 0) {
1616
+ annotation.serverRevision = Number.isInteger(annotation.server_revision) && annotation.server_revision >= 0
1617
+ ? annotation.server_revision
1618
+ : 0;
1619
+ }
1533
1620
  if (!Array.isArray(annotation.dirtyFields)) annotation.dirtyFields = [];
1534
1621
  annotation.pageUrl = annotation.pageUrl || annotation.pathname || this._pageUrl();
1535
1622
  annotation.pathname = annotation.pageUrl;
@@ -1537,6 +1624,8 @@
1537
1624
  return { annotation, index };
1538
1625
  });
1539
1626
 
1627
+ this._normalizeOutboxEnvelopes();
1628
+
1540
1629
  const byClientId = new Map();
1541
1630
  normalized.forEach(candidate => {
1542
1631
  const current = byClientId.get(candidate.annotation.clientId);
@@ -1549,7 +1638,10 @@
1549
1638
  this._assignDisplayIds();
1550
1639
  this.annotations.forEach(annotation => {
1551
1640
  const mapped = annotation.serverId != null;
1552
- const queued = Boolean(this.outbox[annotation.clientId]);
1641
+ const queuedEntry = this.outbox[annotation.clientId];
1642
+ const queued = Boolean(queuedEntry);
1643
+ const annotationRevision = Number.isInteger(annotation.revision) && annotation.revision >= 0 ? annotation.revision : 0;
1644
+ annotation.revision = annotationRevision;
1553
1645
  annotation.syncState = (queued && annotation.syncState === "failed")
1554
1646
  ? "failed"
1555
1647
  : ((queued || !mapped) ? "pending" : "synced");
@@ -1557,6 +1649,10 @@
1557
1649
  annotation.dirtyFields = this._legacyDirtyFields(annotation);
1558
1650
  this.outbox[annotation.clientId] = {
1559
1651
  type: "upsert",
1652
+ clientId: annotation.clientId,
1653
+ revision: annotation.revision,
1654
+ baseRevision: annotation.serverRevision,
1655
+ syncState: "pending",
1560
1656
  annotation: this._desiredState(annotation),
1561
1657
  dirtyFields: annotation.dirtyFields.slice()
1562
1658
  };
@@ -1564,6 +1660,52 @@
1564
1660
  });
1565
1661
  },
1566
1662
 
1663
+ _normalizeOutboxEnvelopes() {
1664
+ const normalized = {};
1665
+
1666
+ Object.entries(this.outbox).forEach(([storedClientId, candidate]) => {
1667
+ if (!this._plainObject(candidate)) return;
1668
+
1669
+ const nestedClientId = candidate.annotation?.clientId;
1670
+ const clientId = this._validClientId(nestedClientId)
1671
+ ? nestedClientId
1672
+ : (this._validClientId(candidate.clientId)
1673
+ ? candidate.clientId
1674
+ : (this._validClientId(storedClientId) ? storedClientId : null));
1675
+ if (!clientId) return;
1676
+
1677
+ const type = candidate.type === "delete"
1678
+ ? "delete"
1679
+ : ((candidate.type === "upsert" || this._plainObject(candidate.annotation)) ? "upsert" : null);
1680
+ if (!type) return;
1681
+
1682
+ const annotation = this.annotations.find(record => record.clientId === clientId);
1683
+ const candidateRevision = Number.isInteger(candidate.revision) && candidate.revision >= 0 ? candidate.revision : 0;
1684
+ const annotationRevision = Number.isInteger(annotation?.revision) && annotation.revision >= 0 ? annotation.revision : 0;
1685
+ const candidateBaseRevision = Number.isInteger(candidate.baseRevision) && candidate.baseRevision >= 0
1686
+ ? candidate.baseRevision
1687
+ : 0;
1688
+ const annotationBaseRevision = Number.isInteger(annotation?.serverRevision) && annotation.serverRevision >= 0
1689
+ ? annotation.serverRevision
1690
+ : 0;
1691
+ const envelope = Object.assign({}, candidate, {
1692
+ type,
1693
+ clientId,
1694
+ revision: Math.max(candidateRevision, annotationRevision),
1695
+ baseRevision: Math.max(candidateBaseRevision, annotationBaseRevision),
1696
+ syncState: candidate.syncState === "failed" ? "failed" : "pending"
1697
+ });
1698
+
1699
+ if (type === "upsert") {
1700
+ envelope.annotation = Object.assign({}, candidate.annotation, { clientId });
1701
+ envelope.dirtyFields = this._mergeDirtyFields(candidate.dirtyFields || envelope.annotation.dirtyFields || []);
1702
+ }
1703
+ normalized[clientId] = envelope;
1704
+ });
1705
+
1706
+ this.outbox = normalized;
1707
+ },
1708
+
1567
1709
  _isNewerLocalRecord(candidate, current) {
1568
1710
  const timestamp = value => {
1569
1711
  const parsed = Date.parse(value || "");
@@ -1764,6 +1906,7 @@
1764
1906
  if (!annotation) return;
1765
1907
  entry.annotation = this._desiredState(annotation);
1766
1908
  entry.dirtyFields = (annotation.dirtyFields || []).slice();
1909
+ entry.baseRevision = Number.isInteger(annotation.serverRevision) ? annotation.serverRevision : 0;
1767
1910
  });
1768
1911
  });
1769
1912
  if (!committed) return false;
@@ -1797,6 +1940,7 @@
1797
1940
  annotation.pathname = server.pageUrl;
1798
1941
  }
1799
1942
  annotation.serverId = server.id;
1943
+ annotation.serverRevision = server.revision;
1800
1944
  annotation.userId = server.userId;
1801
1945
  annotation.authorName = server.authorName;
1802
1946
  annotation.createdAt = server.createdAt;
@@ -1812,6 +1956,7 @@
1812
1956
  id: null,
1813
1957
  clientId: server.clientId,
1814
1958
  serverId: server.id,
1959
+ serverRevision: server.revision,
1815
1960
  userId: server.userId,
1816
1961
  authorName: server.authorName,
1817
1962
  syncState: "synced",
@@ -1834,6 +1979,9 @@
1834
1979
  },
1835
1980
 
1836
1981
  _serverRepresentationIsStale(annotation, server) {
1982
+ if (Number.isInteger(annotation.serverRevision) && Number.isInteger(server.revision)) {
1983
+ return server.revision < annotation.serverRevision;
1984
+ }
1837
1985
  const localTimestamp = Date.parse(annotation.serverUpdatedAt || "");
1838
1986
  const serverTimestamp = Date.parse(server.updatedAt || "");
1839
1987
  return Number.isFinite(localTimestamp) && Number.isFinite(serverTimestamp) && serverTimestamp < localTimestamp;
@@ -59,23 +59,29 @@ module RailsMarkup
59
59
 
60
60
  dirty_fields = normalized_dirty_fields
61
61
  return render_invalid_dirty_fields unless dirty_fields
62
+ base_revision = normalized_base_revision
63
+ return render_invalid_base_revision unless base_revision
62
64
 
63
65
  attributes = browser_attributes
64
66
  return render_invalid_status if dirty_fields.include?("status") && !Annotation::STATUSES.include?(attributes["status"])
65
67
 
66
68
  annotation = Annotation.find_or_initialize_by(client_uuid: client_uuid)
67
69
  created = annotation.new_record?
68
- apply_desired_state(annotation, attributes, dirty_fields)
69
- annotation.save!
70
+ save_browser_state!(annotation, attributes, dirty_fields, base_revision)
70
71
  fire_create_callback(annotation) if created
71
72
  render json: annotation.as_api_json
73
+ rescue Annotation::RevisionConflict => error
74
+ render_revision_conflict(error)
72
75
  rescue ActiveRecord::RecordInvalid => error
73
76
  render json: { errors: error.record.errors.full_messages }, status: :unprocessable_entity
74
77
  rescue ActiveRecord::RecordNotUnique
75
78
  annotation = Annotation.find_by!(client_uuid: client_uuid)
76
- apply_desired_state(annotation, attributes, dirty_fields)
77
- annotation.save!
78
- render json: annotation.as_api_json
79
+ begin
80
+ save_browser_state!(annotation, attributes, dirty_fields, base_revision)
81
+ render json: annotation.as_api_json
82
+ rescue Annotation::RevisionConflict => error
83
+ render_revision_conflict(error)
84
+ end
79
85
  end
80
86
 
81
87
  # DELETE /feedback/api/annotations/:client_uuid
@@ -183,9 +189,25 @@ module RailsMarkup
183
189
  permitted.to_h.stringify_keys
184
190
  end
185
191
 
186
- def apply_desired_state(annotation, attributes, dirty_fields)
187
- assign_current_user(annotation) if annotation.new_record?
188
- annotation.apply_browser_state(attributes, dirty_fields: dirty_fields)
192
+ def save_browser_state!(annotation, attributes, dirty_fields, base_revision)
193
+ if annotation.new_record?
194
+ assign_current_user(annotation)
195
+ annotation.apply_browser_state(
196
+ attributes,
197
+ dirty_fields: dirty_fields,
198
+ base_revision: base_revision
199
+ )
200
+ annotation.save!
201
+ else
202
+ annotation.with_lock do
203
+ annotation.apply_browser_state(
204
+ attributes,
205
+ dirty_fields: dirty_fields,
206
+ base_revision: base_revision
207
+ )
208
+ annotation.save!
209
+ end
210
+ end
189
211
  end
190
212
 
191
213
  def normalized_route_uuid
@@ -201,6 +223,11 @@ module RailsMarkup
201
223
  fields if (fields - ALLOWED_DIRTY_FIELDS).empty?
202
224
  end
203
225
 
226
+ def normalized_base_revision
227
+ revision = params[:baseRevision]
228
+ revision if revision.is_a?(Integer) && revision >= 0
229
+ end
230
+
204
231
  def client_supplied_author?
205
232
  metadata = params[:metadata]
206
233
  metadata.respond_to?(:key?) && (metadata.key?(:author) || metadata.key?("author"))
@@ -226,6 +253,17 @@ module RailsMarkup
226
253
  render json: { error: "invalid status" }, status: :unprocessable_entity
227
254
  end
228
255
 
256
+ def render_invalid_base_revision
257
+ render json: { error: "base revision must be a non-negative integer" }, status: :unprocessable_entity
258
+ end
259
+
260
+ def render_revision_conflict(error)
261
+ render json: {
262
+ error: "revision conflict",
263
+ annotation: error.annotation.as_api_json
264
+ }, status: :conflict
265
+ end
266
+
229
267
  def normalize_target(target)
230
268
  case target
231
269
  when String then { "selector" => target }
@@ -223,10 +223,22 @@ module RailsMarkup
223
223
  # each (not find_each) so the export keeps the scope's :recent ordering —
224
224
  # find_each ignores ORDER BY and batches by primary key.
225
225
  scope.each do |ann|
226
- csv << [ann.id, ann.status, ann.intent, ann.severity, ann.content, ann.page_url,
227
- ann.author_name, ann.selected_text, ann.created_at.iso8601, ann.updated_at.iso8601]
226
+ csv << [ann.id, ann.status, ann.intent, ann.severity,
227
+ csv_safe(ann.content), csv_safe(ann.page_url),
228
+ csv_safe(ann.author_name), csv_safe(ann.selected_text),
229
+ ann.created_at.iso8601, ann.updated_at.iso8601]
228
230
  end
229
231
  end
230
232
  end
233
+
234
+ # Neutralize CSV/spreadsheet formula injection: a leading =, +, -, @, or
235
+ # control char makes Excel/Sheets evaluate the cell as a formula. Prefix
236
+ # such values with an apostrophe so they're treated as literal text.
237
+ def csv_safe(value)
238
+ str = value.to_s
239
+ return str unless str.match?(/\A[=+\-@\t\r]/)
240
+
241
+ "'#{str}"
242
+ end
231
243
  end
232
244
  end
@@ -54,11 +54,17 @@ module RailsMarkup
54
54
  end
55
55
 
56
56
  def authenticate_token!
57
- # Development allow all requests without token (dev server may bind to LAN IP)
58
- return if Rails.env.development?
57
+ # Development convenience: skip token auth so you can reach the API from
58
+ # another device on your LAN. This permits unauthenticated reads and
59
+ # writes from any network peer — set
60
+ # config.require_api_token_in_development to lock it down.
61
+ return if Rails.env.development? && !RailsMarkup.config.require_api_token_in_development
59
62
 
60
63
  token = RailsMarkup.config.api_token
61
- return head(:not_found) if token.nil?
64
+ # Treat a nil OR blank token as "external API disabled" — otherwise a
65
+ # token of "" would make secure_compare("", "") true and authenticate
66
+ # every request (including ones with no Authorization header).
67
+ return head(:not_found) if token.nil? || token.to_s.strip.empty?
62
68
 
63
69
  provided = request.headers["Authorization"]&.delete_prefix("Bearer ")
64
70
  head(:unauthorized) unless ActiveSupport::SecurityUtils.secure_compare(provided.to_s, token)
@@ -5,6 +5,15 @@ require "digest/sha1"
5
5
 
6
6
  module RailsMarkup
7
7
  class Annotation < ActiveRecord::Base
8
+ class RevisionConflict < StandardError
9
+ attr_reader :annotation
10
+
11
+ def initialize(annotation)
12
+ @annotation = annotation
13
+ super("annotation revision conflict")
14
+ end
15
+ end
16
+
8
17
  self.table_name = RailsMarkup.config.table_name
9
18
 
10
19
  INTENTS = %w[fix change question approve].freeze
@@ -99,43 +108,66 @@ module RailsMarkup
99
108
  metadata&.dig("author")
100
109
  end
101
110
 
102
- def apply_browser_state(attributes, dirty_fields: [])
103
- assign_attributes(attributes.slice(*BROWSER_ATTRIBUTES))
104
- self.metadata = (metadata || {}).merge(attributes.fetch("metadata", {}).slice(*BROWSER_METADATA_KEYS))
105
- self.status = attributes["status"] if dirty_fields.include?("status")
111
+ def apply_browser_state(attributes, dirty_fields:, base_revision:)
112
+ raise RevisionConflict, self unless base_revision == revision
113
+
114
+ dirty_fields.each do |field|
115
+ if BROWSER_ATTRIBUTES.include?(field)
116
+ public_send("#{field}=", attributes[field]) if attributes.key?(field)
117
+ elsif field == "metadata" && attributes.key?("metadata")
118
+ self.metadata = (metadata || {}).merge(attributes["metadata"].slice(*BROWSER_METADATA_KEYS))
119
+ elsif field == "status" && attributes.key?("status")
120
+ self.status = attributes["status"]
121
+ end
122
+ end
123
+ self.revision += 1 if changed?
106
124
  self
107
125
  end
108
126
 
109
127
  def acknowledge!
110
- return self if status == "acknowledged" # idempotent re-acknowledging is a no-op
111
- raise "Cannot acknowledge a #{status} annotation" unless status == "pending"
112
-
113
- update!(status: "acknowledged")
128
+ # Lock + reload so a concurrent resolve!/dismiss! can't be clobbered:
129
+ # without it, an acknowledge validated against a stale "pending" could
130
+ # write "acknowledged" over an already-resolved record.
131
+ with_lock do
132
+ return self if status == "acknowledged" # idempotent — re-acknowledging is a no-op
133
+ raise "Cannot acknowledge a #{status} annotation" unless status == "pending"
134
+
135
+ update!(status: "acknowledged", revision: revision + 1)
136
+ end
137
+ self
114
138
  end
115
139
 
116
140
  def resolve!(summary: nil)
117
- return self if status == "resolved" # idempotent re-resolving is a no-op
118
- raise "Cannot resolve a #{status} annotation" unless status.in?(%w[pending acknowledged])
141
+ # with_lock reloads under a row lock so a concurrent reply/resolve can't
142
+ # read a stale thread and silently drop the other write on save.
143
+ with_lock do
144
+ return self if status == "resolved" # idempotent — re-resolving is a no-op
145
+ raise "Cannot resolve a #{status} annotation" unless status.in?(%w[pending acknowledged])
119
146
 
120
- transaction do
121
147
  add_thread_entry(role: "agent", message: summary) if summary.present?
122
- update!(status: "resolved")
148
+ update!(status: "resolved", revision: revision + 1)
123
149
  end
150
+ self
124
151
  end
125
152
 
126
153
  def dismiss!(reason: nil)
127
- return self if status == "dismissed" # idempotent — re-dismissing is a no-op
128
- raise "Cannot dismiss a #{status} annotation" unless status.in?(%w[pending acknowledged])
154
+ with_lock do
155
+ return self if status == "dismissed" # idempotent — re-dismissing is a no-op
156
+ raise "Cannot dismiss a #{status} annotation" unless status.in?(%w[pending acknowledged])
129
157
 
130
- transaction do
131
158
  add_thread_entry(role: "agent", message: reason) if reason.present?
132
- update!(status: "dismissed")
159
+ update!(status: "dismissed", revision: revision + 1)
133
160
  end
161
+ self
134
162
  end
135
163
 
136
164
  def add_reply!(message:, role: "agent")
137
- add_thread_entry(role: role, message: message)
138
- save!
165
+ with_lock do
166
+ add_thread_entry(role: role, message: message)
167
+ self.revision += 1
168
+ save!
169
+ end
170
+ self
139
171
  end
140
172
 
141
173
  def as_api_json
@@ -154,7 +186,8 @@ module RailsMarkup
154
186
  metadata: metadata,
155
187
  thread: thread,
156
188
  createdAt: created_at&.iso8601,
157
- updatedAt: updated_at&.iso8601
189
+ updatedAt: updated_at&.iso8601,
190
+ revision: revision
158
191
  }
159
192
  end
160
193
 
@@ -1,5 +1,11 @@
1
1
  <%# Rails Markup annotation toolbar — self-contained, no Tailwind/Stimulus dependency %>
2
2
  <% if RailsMarkup.config.toolbar_enabled %>
3
+ <%# Authorization sentinel: present only when this partial renders (i.e. the
4
+ layout gate authorized the request). The turbo:load handler below checks for
5
+ it after each navigation and tears the toolbar down if it's gone — otherwise
6
+ the document-level listener would recreate the toolbar (and expose cached
7
+ annotations) after a logout Turbo visit whose new body omits the partial. %>
8
+ <span id="rm-toolbar-gate" hidden aria-hidden="true"></span>
3
9
  <script>
4
10
  <%== File.read(File.expand_path("../../../assets/javascripts/rails_markup/toolbar.js", __dir__)) %>
5
11
  </script>
@@ -19,9 +25,23 @@
19
25
  };
20
26
  // Init immediately (DOM is ready — script is at end of body)
21
27
  RailsMarkupToolbar.init(opts);
22
- // Re-init after Turbo Drive navigations (DOMContentLoaded won't fire again)
28
+ // Tear down before Turbo snapshots the page for its cache — otherwise the
29
+ // cached DOM keeps a toolbar root with no live listeners, and on restore
30
+ // init() would early-return on that dead root instead of rebinding.
31
+ document.addEventListener("turbo:before-cache", function() {
32
+ if (window.RailsMarkupToolbar && typeof RailsMarkupToolbar.destroy === "function") {
33
+ RailsMarkupToolbar.destroy();
34
+ }
35
+ });
36
+ // After Turbo Drive navigations (DOMContentLoaded won't fire again), only
37
+ // re-init when the new page is still authorized (gate sentinel present);
38
+ // otherwise tear the toolbar down so it can't persist past a logout.
23
39
  document.addEventListener("turbo:load", function() {
24
- RailsMarkupToolbar.init(opts);
40
+ if (document.getElementById("rm-toolbar-gate")) {
41
+ RailsMarkupToolbar.init(opts);
42
+ } else if (window.RailsMarkupToolbar && typeof RailsMarkupToolbar.destroy === "function") {
43
+ RailsMarkupToolbar.destroy();
44
+ }
25
45
  });
26
46
  })();
27
47
  </script>
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddRevisionToRailsMarkupAnnotations < ActiveRecord::Migration[7.0]
4
+ def change
5
+ table = RailsMarkup.config.table_name
6
+ return unless table_exists?(table)
7
+ return if column_exists?(table, :revision)
8
+
9
+ add_column table, :revision, :integer, null: false, default: 0
10
+ end
11
+ end
@@ -5,7 +5,10 @@ class CreateRailsMarkupAnnotations < ActiveRecord::Migration<%= migration_versio
5
5
 
6
6
  create_table :<%= options[:table_name] %> do |t|
7
7
  t.bigint :user_id
8
- t.string :page_url, null: false
8
+ # 2048 to match the model's page_url length validation. The default
9
+ # string limit (255) would raise DB errors on PostgreSQL/MySQL for
10
+ # model-valid long URLs (SQLite is lax, so tests wouldn't catch it).
11
+ t.string :page_url, limit: 2048, null: false
9
12
  t.send json_type, :target, default: {}
10
13
  t.text :content, null: false
11
14
  t.string :intent, null: false, default: "change"
@@ -15,12 +18,23 @@ class CreateRailsMarkupAnnotations < ActiveRecord::Migration<%= migration_versio
15
18
  t.send json_type, :metadata, default: {}
16
19
  t.send json_type, :thread, default: []
17
20
  t.string :client_uuid, limit: 64, null: false
21
+ t.integer :revision, null: false, default: 0
18
22
 
19
23
  t.timestamps
20
24
  end
21
25
 
22
26
  add_index :<%= options[:table_name] %>, [:status, :created_at]
23
- add_index :<%= options[:table_name] %>, :page_url
27
+ # A full 2048-char index blows past MySQL/InnoDB's key-length limit on
28
+ # utf8mb4, so index only a prefix there; other adapters index the column.
29
+ if connection.adapter_name.downcase.include?("mysql")
30
+ add_index :<%= options[:table_name] %>, :page_url, length: 191
31
+ else
32
+ # PostgreSQL indexes the full column so `where(page_url:)` lookups stay
33
+ # fast. A btree entry tops out near 2704 bytes, so a pathological
34
+ # multibyte URL approaching the 2048-char cap could exceed it; real URLs
35
+ # are far shorter. Switch to an expression index if that ever bites you.
36
+ add_index :<%= options[:table_name] %>, :page_url
37
+ end
24
38
  add_index :<%= options[:table_name] %>, :user_id
25
39
  add_index :<%= options[:table_name] %>, :client_uuid, unique: true
26
40
  end
@@ -21,6 +21,20 @@ module RailsMarkup
21
21
  class_option :table_name, type: :string, default: "rails_markup_annotations",
22
22
  desc: "Database table name for annotations (must match config.table_name)"
23
23
 
24
+ # Conservative SQL identifier: leading letter/underscore, then letters,
25
+ # digits, underscores. Blocks names that would produce invalid Ruby/SQL or
26
+ # inject into the migration/initializer templates (e.g. "feedback-items",
27
+ # quotes, newlines).
28
+ TABLE_NAME_PATTERN = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/
29
+
30
+ def validate_table_name
31
+ return if options[:table_name].match?(TABLE_NAME_PATTERN)
32
+
33
+ raise Thor::Error,
34
+ "Invalid --table-name #{options[:table_name].inspect}: use only letters, " \
35
+ "digits, and underscores, starting with a letter or underscore."
36
+ end
37
+
24
38
  def copy_migration
25
39
  migration_template "create_rails_markup_annotations.rb.erb",
26
40
  "db/migrate/create_rails_markup_annotations.rb"
@@ -70,8 +84,22 @@ module RailsMarkup
70
84
  <% end %>
71
85
  ERB
72
86
 
73
- if File.read(File.join(destination_root, layout_path)).include?("rails_markup/shared/toolbar")
74
- say_status :skip, "toolbar already present in #{layout_path}", :yellow
87
+ content = File.read(File.join(destination_root, layout_path))
88
+
89
+ if content.include?("rails_markup/shared/toolbar")
90
+ # Upgrade the pre-1.2.3 partial-existence gate, which rendered the
91
+ # toolbar for every visitor, to the admin-gated block. Leave any
92
+ # custom (hand-edited) block untouched so we don't clobber it.
93
+ # Match the exact historical generated block (allowing ERB trim tags)
94
+ # — including its own render line — so we never swallow a hand-written
95
+ # block that merely happens to contain lookup_context…end.
96
+ legacy = /^[ \t]*<%#\s*Rails Markup annotation toolbar\s*%>\r?\n[ \t]*<%-?\s*if\s+lookup_context\.exists\?\("rails_markup\/shared\/toolbar".*?-?%>\r?\n[ \t]*<%=\s*render\s+"rails_markup\/shared\/toolbar"\s*-?%>\r?\n[ \t]*<%-?\s*end\s*-?%>\r?\n?/m
97
+ if content =~ legacy
98
+ gsub_file layout_path, legacy, toolbar_block
99
+ say_status :update, "upgraded toolbar to admin-gated render in #{layout_path}", :green
100
+ else
101
+ say_status :skip, "toolbar already present in #{layout_path} (left as-is)", :yellow
102
+ end
75
103
  return
76
104
  end
77
105
 
@@ -39,8 +39,10 @@ module RailsMarkup
39
39
  bin/markup server --port 5000 # custom port
40
40
  DESC
41
41
  method_option :port, type: :numeric, default: 4747, desc: "HTTP server port"
42
+ method_option :host, type: :string, default: "127.0.0.1",
43
+ desc: "Bind address (loopback by default; use 0.0.0.0 to expose on the LAN)"
42
44
  def server
43
- srv = RailsMarkup::Server.new(port: options[:port])
45
+ srv = RailsMarkup::Server.new(port: options[:port], bind: options[:host])
44
46
  srv.start
45
47
  end
46
48
 
@@ -641,21 +643,30 @@ module RailsMarkup
641
643
  "local"
642
644
  end
643
645
 
644
- # Check local first, then fall back to global/codex configs.
645
- def resolve_mcp_env
646
+ # Return the raw env of the highest-precedence scope (local → globalcodex)
647
+ # that defines any of the given keys. URL, token, and mount are always read
648
+ # from this single scope so a token is never paired with a URL from another
649
+ # scope (credential provenance). A local config that only defines dev values
650
+ # is skipped for production lookups, so it neither shadows nor captures the
651
+ # production credentials stored globally/in codex.
652
+ def scoped_env(*keys)
646
653
  McpConfig::SCOPES.each do |scope|
647
654
  config = McpConfig.new(scope: scope)
648
655
  next unless config.exist?
649
656
 
650
657
  env = config.raw_env
651
- return env unless env.empty?
658
+ return env if keys.any? { |k| env[k].to_s.strip != "" }
652
659
  end
653
660
 
654
661
  {}
655
662
  end
656
663
 
657
664
  def resolve_env(production)
658
- mcp_env = resolve_mcp_env
665
+ mcp_env = if production
666
+ scoped_env("RAILS_MARKUP_PROD_URL", "RAILS_MARKUP_PROD_TOKEN")
667
+ else
668
+ scoped_env("RAILS_MARKUP_DEV_URL", "RAILS_MARKUP_DEV_TOKEN")
669
+ end
659
670
 
660
671
  if production
661
672
  base_url = options[:url] || mcp_env["RAILS_MARKUP_PROD_URL"]
@@ -674,6 +685,12 @@ module RailsMarkup
674
685
  return nil
675
686
  end
676
687
 
688
+ unless base_url.to_s.downcase.start_with?("https://")
689
+ say "Refusing to send the production token over an insecure connection.", :red
690
+ say "Production URL must use HTTPS (got: #{base_url})", :red
691
+ return nil
692
+ end
693
+
677
694
  { base_url: base_url, token: token, mount_path: mount }
678
695
  else
679
696
  base_url = options[:url] || mcp_env["RAILS_MARKUP_DEV_URL"]
@@ -15,6 +15,13 @@ module RailsMarkup
15
15
  # Set to nil to disable external API.
16
16
  attr_accessor :api_token
17
17
 
18
+ # In development the external API skips token auth by default so you can
19
+ # reach it from another device on your LAN (e.g. your phone). That allows
20
+ # unauthenticated reads AND writes from any network peer. Set to true to
21
+ # require the bearer token even in development.
22
+ # Default: false (open in development)
23
+ attr_accessor :require_api_token_in_development
24
+
18
25
  # Database table name for annotations.
19
26
  attr_accessor :table_name
20
27
 
@@ -77,6 +84,7 @@ module RailsMarkup
77
84
  def initialize
78
85
  @base_controller_class = "RailsMarkup::ApplicationController"
79
86
  @api_token = nil
87
+ @require_api_token_in_development = false
80
88
  @table_name = "rails_markup_annotations"
81
89
  @per_page = 25
82
90
  @toolbar_accent = "indigo"
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "webrick"
4
4
  require "json"
5
+ require "ipaddr"
6
+ require "uri"
5
7
 
6
8
  module RailsMarkup
7
9
  # HTTP server providing REST API + SSE for the browser-side annotation controller.
@@ -9,15 +11,22 @@ module RailsMarkup
9
11
  class HttpServer
10
12
  attr_reader :port, :store
11
13
 
12
- def initialize(store:, port: 4747, logger: nil)
14
+ attr_reader :bind
15
+
16
+ def initialize(store:, port: 4747, bind: "127.0.0.1", logger: nil)
13
17
  @store = store
14
18
  @port = port
19
+ @bind = bind
15
20
  @logger = logger || WEBrick::Log.new($stderr, WEBrick::Log::WARN)
16
21
  end
17
22
 
18
23
  def start
24
+ # Bind to loopback by default — this store server is unauthenticated, so
25
+ # binding to 0.0.0.0 would expose it to the whole LAN. Pass bind: "0.0.0.0"
26
+ # (bin/markup server --host 0.0.0.0) to deliberately expose it.
19
27
  @server = WEBrick::HTTPServer.new(
20
28
  Port: @port,
29
+ BindAddress: @bind,
21
30
  Logger: @logger,
22
31
  AccessLog: [],
23
32
  DoNotReverseLookup: true
@@ -45,17 +54,17 @@ module RailsMarkup
45
54
  end
46
55
 
47
56
  def do_OPTIONS(req, res)
48
- cors(res)
57
+ cors(req, res)
49
58
  res.status = 204
50
59
  end
51
60
 
52
61
  def do_GET(req, res)
53
- cors(res)
62
+ cors(req, res)
54
63
  route(req, res)
55
64
  end
56
65
 
57
66
  def do_POST(req, res)
58
- cors(res)
67
+ cors(req, res)
59
68
  route(req, res)
60
69
  end
61
70
 
@@ -154,6 +163,10 @@ module RailsMarkup
154
163
  return not_found(res) unless annotation
155
164
 
156
165
  json_response(res, @store.serialize_annotation(annotation), status: 201)
166
+ rescue Store::ValidationError => error
167
+ json_response(res, { error: error.message }, status: 422)
168
+ rescue Store::CapacityError => error
169
+ json_response(res, { error: error.message }, status: 507)
157
170
  end
158
171
 
159
172
  # --- SSE ---
@@ -166,8 +179,6 @@ module RailsMarkup
166
179
  res["Content-Type"] = "text/event-stream"
167
180
  res["Cache-Control"] = "no-cache"
168
181
  res["Connection"] = "keep-alive"
169
- res["Access-Control-Allow-Origin"] = "*"
170
-
171
182
  res.chunked = true
172
183
  res.body = proc do |out|
173
184
  sub = @store.subscribe(session_id) do |data|
@@ -193,11 +204,26 @@ module RailsMarkup
193
204
 
194
205
  # --- Helpers ---
195
206
 
196
- def cors(res)
197
- port = @server[:Port] rescue 4747
198
- res["Access-Control-Allow-Origin"] = "http://localhost:#{port}"
207
+ def cors(req, res)
208
+ origin = req["Origin"]
209
+ res["Access-Control-Allow-Origin"] = origin if loopback_origin?(origin)
199
210
  res["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
200
211
  res["Access-Control-Allow-Headers"] = "Content-Type"
212
+ res["Vary"] = "Origin"
213
+ end
214
+
215
+ def loopback_origin?(origin)
216
+ return false unless origin.is_a?(String)
217
+
218
+ uri = URI.parse(origin)
219
+ return false unless %w[http https].include?(uri.scheme)
220
+ return false if uri.host.nil? || uri.userinfo || uri.query || uri.fragment
221
+ return false unless uri.path.empty?
222
+ return true if uri.host.casecmp?("localhost")
223
+
224
+ IPAddr.new(uri.host).loopback?
225
+ rescue URI::InvalidURIError, IPAddr::InvalidAddressError
226
+ false
201
227
  end
202
228
 
203
229
  def json_response(res, data, status: 200)
@@ -79,7 +79,11 @@ module RailsMarkup
79
79
  data
80
80
  end
81
81
 
82
- # Watch not supported via HTTP proxy — return empty after timeout
82
+ def supports_subscriptions?
83
+ false
84
+ end
85
+
86
+ # Watch is rejected by McpServer before subscription in proxy mode.
83
87
  def subscribe(_session_id = nil, &_block)
84
88
  nil
85
89
  end
@@ -589,6 +589,10 @@ module RailsMarkup
589
589
  # ── Watch mode ────────────────────────────────────────────
590
590
 
591
591
  def handle_watch(args)
592
+ unless @store.respond_to?(:supports_subscriptions?) && @store.supports_subscriptions?
593
+ raise ToolError, "Watch is unsupported in HTTP proxy (mcp-only) mode; poll with rails_markup_read instead."
594
+ end
595
+
592
596
  sub = nil
593
597
  timeout = [args["timeoutSeconds"]&.to_i || 120, 300].min
594
598
  batch_window = [args["batchWindowSeconds"]&.to_i || 10, 60].min
@@ -9,8 +9,9 @@ module RailsMarkup
9
9
  class Server
10
10
  attr_reader :store
11
11
 
12
- def initialize(port: 4747, mcp_only: false)
12
+ def initialize(port: 4747, bind: "127.0.0.1", mcp_only: false)
13
13
  @port = port
14
+ @bind = bind
14
15
  @mcp_only = mcp_only
15
16
  @store = Store.new
16
17
  end
@@ -44,8 +45,8 @@ module RailsMarkup
44
45
  if port_available?(@port)
45
46
  # We own the port — start HTTP + MCP with shared in-memory store
46
47
  http_thread = Thread.new do
47
- http = HttpServer.new(store: @store, port: @port)
48
- $stderr.puts "[rails-markup] HTTP server listening on port #{@port}"
48
+ http = HttpServer.new(store: @store, port: @port, bind: @bind)
49
+ $stderr.puts "[rails-markup] HTTP server listening on #{@bind}:#{@port}"
49
50
  http.start
50
51
  end
51
52
 
@@ -63,7 +64,7 @@ module RailsMarkup
63
64
  end
64
65
 
65
66
  def port_available?(port)
66
- server = TCPServer.new("0.0.0.0", port)
67
+ server = TCPServer.new(@bind, port)
67
68
  server.close
68
69
  true
69
70
  rescue Errno::EADDRINUSE
@@ -7,20 +7,34 @@ module RailsMarkup
7
7
  # In-memory store for sessions and annotations.
8
8
  # Ephemeral by design — data lives for one coding session.
9
9
  class Store
10
+ class ValidationError < StandardError; end
11
+ class CapacityError < StandardError; end
12
+
10
13
  Session = Struct.new(:id, :url, :metadata, :created_at, :annotations, keyword_init: true)
11
14
  Annotation = Struct.new(:id, :session_id, :target, :content, :intent, :severity, :status,
12
15
  :selected_text, :metadata, :created_at, :thread, keyword_init: true)
13
16
 
14
17
  MAX_SESSIONS = 100
15
18
  SESSION_TTL = 4 * 3600 # 4 hours
19
+ MAX_ANNOTATIONS_PER_SESSION = 1_000
20
+ MAX_ANNOTATION_BYTES = 25_000_000
21
+ MAX_CONTENT_BYTES = 5_000
22
+ MAX_TARGET_BYTES = 16_384
23
+ MAX_SELECTED_TEXT_BYTES = 2_000
24
+ MAX_METADATA_BYTES = 65_536
25
+ INTENTS = %w[fix change question approve].freeze
26
+ SEVERITIES = %w[suggestion important blocking].freeze
16
27
 
17
28
  attr_reader :sessions
18
29
 
19
- def initialize
30
+ def initialize(max_annotations_per_session: MAX_ANNOTATIONS_PER_SESSION,
31
+ max_annotation_bytes: MAX_ANNOTATION_BYTES)
20
32
  @sessions = {}
21
33
  @annotations_index = {} # id -> annotation (O(1) lookup)
22
34
  @subscribers = [] # SSE callbacks: [session_id, callback]
23
35
  @mutex = Mutex.new
36
+ @max_annotations_per_session = max_annotations_per_session
37
+ @max_annotation_bytes = max_annotation_bytes
24
38
  end
25
39
 
26
40
  # --- Sessions ---
@@ -53,6 +67,9 @@ module RailsMarkup
53
67
 
54
68
  def create_annotation(session_id:, target:, content:, intent: "change", severity: "suggestion",
55
69
  selected_text: nil, metadata: {})
70
+ annotation_bytes = validate_annotation!(
71
+ target:, content:, intent:, severity:, selected_text:, metadata:
72
+ )
56
73
  id = SecureRandom.hex(8)
57
74
  annotation = Annotation.new(
58
75
  id: id,
@@ -73,6 +90,7 @@ module RailsMarkup
73
90
  session = @sessions[session_id]
74
91
  return nil unless session
75
92
 
93
+ enforce_session_capacity!(session, annotation_bytes)
76
94
  session.annotations << annotation
77
95
  @annotations_index[id] = annotation
78
96
  end
@@ -148,6 +166,10 @@ module RailsMarkup
148
166
  @mutex.synchronize { @subscribers.delete(sub) }
149
167
  end
150
168
 
169
+ def supports_subscriptions?
170
+ true
171
+ end
172
+
151
173
  # --- Serialization ---
152
174
 
153
175
  def serialize_session(session)
@@ -188,18 +210,81 @@ module RailsMarkup
188
210
  end
189
211
 
190
212
  def notify(session_id, data)
213
+ subscribers = @mutex.synchronize do
214
+ @subscribers.select { |sid, _callback| sid.nil? || sid == session_id }
215
+ end
191
216
  dead = []
192
- @mutex.synchronize do
193
- @subscribers.each do |sub|
194
- sid, callback = sub
195
- next unless sid.nil? || sid == session_id
217
+ subscribers.each do |sub|
218
+ _sid, callback = sub
219
+ callback.call(data)
220
+ rescue StandardError
221
+ dead << sub
222
+ end
223
+ @mutex.synchronize { dead.each { |sub| @subscribers.delete(sub) } } unless dead.empty?
224
+ end
196
225
 
197
- callback.call(data)
198
- rescue StandardError
199
- dead << sub
200
- end
201
- dead.each { |s| @subscribers.delete(s) }
226
+ def validate_annotation!(target:, content:, intent:, severity:, selected_text:, metadata:)
227
+ validate_target!(target)
228
+ validate_string!("content", content, maximum: MAX_CONTENT_BYTES)
229
+ validate_optional_string!("selected_text", selected_text, maximum: MAX_SELECTED_TEXT_BYTES)
230
+ raise ValidationError, "intent is invalid" unless INTENTS.include?(intent)
231
+ raise ValidationError, "severity is invalid" unless SEVERITIES.include?(severity)
232
+ raise ValidationError, "metadata must be an object" unless metadata.nil? || metadata.is_a?(Hash)
233
+
234
+ metadata_bytes = JSON.generate(metadata || {}).bytesize
235
+ raise ValidationError, "metadata exceeds #{MAX_METADATA_BYTES} bytes" if metadata_bytes > MAX_METADATA_BYTES
236
+
237
+ JSON.generate(
238
+ target:, content:, intent:, severity:, selected_text:, metadata: metadata || {}
239
+ ).bytesize
240
+ rescue JSON::GeneratorError, Encoding::UndefinedConversionError
241
+ raise ValidationError, "annotation fields must be JSON serializable"
242
+ end
243
+
244
+ def validate_string!(name, value, maximum:)
245
+ raise ValidationError, "#{name} must be a non-empty string" unless value.is_a?(String) && !value.empty?
246
+ raise ValidationError, "#{name} exceeds #{maximum} bytes" if value.bytesize > maximum
247
+ end
248
+
249
+ def validate_target!(target)
250
+ if target.is_a?(String)
251
+ return validate_string!("target", target, maximum: MAX_TARGET_BYTES)
202
252
  end
253
+ raise ValidationError, "target must be a non-empty string or object" unless target.is_a?(Hash)
254
+
255
+ target_bytes = JSON.generate(target).bytesize
256
+ raise ValidationError, "target exceeds #{MAX_TARGET_BYTES} bytes" if target_bytes > MAX_TARGET_BYTES
257
+ end
258
+
259
+ def validate_optional_string!(name, value, maximum:)
260
+ return if value.nil?
261
+
262
+ raise ValidationError, "#{name} must be a string" unless value.is_a?(String)
263
+ raise ValidationError, "#{name} exceeds #{maximum} bytes" if value.bytesize > maximum
264
+ end
265
+
266
+ def enforce_session_capacity!(session, incoming_bytes)
267
+ if session.annotations.length >= @max_annotations_per_session
268
+ raise CapacityError, "session annotation limit of #{@max_annotations_per_session} reached"
269
+ end
270
+
271
+ current_bytes = @sessions.values.sum do |stored_session|
272
+ stored_session.annotations.sum { |annotation| annotation_storage_bytes(annotation) }
273
+ end
274
+ return if current_bytes + incoming_bytes <= @max_annotation_bytes
275
+
276
+ raise CapacityError, "aggregate annotation byte limit of #{@max_annotation_bytes} reached"
277
+ end
278
+
279
+ def annotation_storage_bytes(annotation)
280
+ JSON.generate(
281
+ target: annotation.target,
282
+ content: annotation.content,
283
+ intent: annotation.intent,
284
+ severity: annotation.severity,
285
+ selected_text: annotation.selected_text,
286
+ metadata: annotation.metadata
287
+ ).bytesize
203
288
  end
204
289
 
205
290
  def evict_stale_sessions
@@ -214,6 +299,17 @@ module RailsMarkup
214
299
  false
215
300
  end
216
301
  end
302
+
303
+ # Hard cap: if every session is still fresh we'd otherwise grow without
304
+ # bound. Evict the oldest sessions to make room for the incoming one.
305
+ return if @sessions.size < MAX_SESSIONS
306
+
307
+ overflow = @sessions.size - MAX_SESSIONS + 1
308
+ oldest = @sessions.values.sort_by(&:created_at).first(overflow)
309
+ oldest.each do |session|
310
+ session.annotations.each { |a| @annotations_index.delete(a.id) }
311
+ @sessions.delete(session.id)
312
+ end
217
313
  end
218
314
  end
219
315
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsMarkup
4
- VERSION = "1.2.4"
4
+ VERSION = "1.4.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-markup
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.4
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - InventList
@@ -139,6 +139,7 @@ files:
139
139
  - config/routes.rb
140
140
  - db/migrate/20260720000000_add_client_uuid_to_rails_markup_annotations.rb
141
141
  - db/migrate/20260721000000_backfill_rails_markup_client_uuids.rb
142
+ - db/migrate/20260726000000_add_revision_to_rails_markup_annotations.rb
142
143
  - lib/generators/rails_markup/install/templates/auth_controller.rb.erb
143
144
  - lib/generators/rails_markup/install/templates/bin_markup.erb
144
145
  - lib/generators/rails_markup/install/templates/create_rails_markup_annotations.rb.erb