rails-markup 1.3.0 → 1.4.1

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: d852927076b8b5e1774675692c45b575eb9dd6337c934637e90c693310d96f4d
4
- data.tar.gz: 3608bdd64056b7cb3373de2cd4ea0797a1e6dc3e021457ec96697a4d3f8c7919
3
+ metadata.gz: 665bd02a5e8830d8565296c7623f4136513a61a2c1574e68c0c2b6802daf7ea2
4
+ data.tar.gz: fd6290208722a52ecc255f3d01cdf017de31010157f6c67cc4386423e848eb98
5
5
  SHA512:
6
- metadata.gz: ac1ee4649e024d9a3fa306bf3fefb9f6443c2d89cec4d33de6b504831123228f1db3cc2e20414914fdec41150e832ea077d76cfe1c606859c2177626eb643251
7
- data.tar.gz: e8942c6bcbff10487ab62170039b6c62c0c0ffe87a59cb77ac8df87792c6365109d222451fc8756831fad1284b1336f7d0adff3dbf3698b257e2c0e2b1346899
6
+ metadata.gz: 32afe3f8d8ea940731cde32f9524738189ca7d54b1bc1d124adc8888adb9fce35347647bc5844ca15dac6c918a546cef2d9ca048e0901e98ebc87f17db34ec4e
7
+ data.tar.gz: 2c173c2623816601f0cc5ab730960cc035f2523a14adad0de4d130163f358d47562959f8fbd6c4b9e2e2ea9a0442e6d711dc6ba35070d89ae7d23e17c6cb2b15
@@ -43,6 +43,7 @@
43
43
  _syncMaxRetryDelay: 30000,
44
44
  _syncMalformedLimit: 3,
45
45
  _syncUnavailable: null,
46
+ legacyStorageEndpoint: null,
46
47
 
47
48
  // Drawing state
48
49
  drawingMode: null, // null | "arrow" | "rect" | "highlight"
@@ -73,6 +74,7 @@
73
74
  this.size = opts.size || "default";
74
75
  this.fabVisible = opts.fabVisible !== false;
75
76
  this.enableScreenshots = opts.enableScreenshots !== false;
77
+ this.legacyStorageEndpoint = opts.legacyStorageEndpoint || this.legacyStorageEndpoint;
76
78
  this.healthIntervalMs = (opts.healthInterval || 60) * 1000;
77
79
 
78
80
  if (document.getElementById("rm-toolbar-root")) {
@@ -714,6 +716,7 @@
714
716
  id: this.nextId,
715
717
  clientId: this._newClientId(),
716
718
  serverId: null,
719
+ serverRevision: 0,
717
720
  syncState: "pending",
718
721
  serverUpdatedAt: null,
719
722
  dirtyFields: [],
@@ -1077,12 +1080,16 @@
1077
1080
  _queueLocalMutation(type, annotation, dirtyFields) {
1078
1081
  const currentEntry = this.outbox[annotation.clientId];
1079
1082
  const revision = Math.max(annotation.revision || 0, currentEntry?.revision || 0) + 1;
1083
+ const baseRevision = Number.isInteger(currentEntry?.baseRevision)
1084
+ ? currentEntry.baseRevision
1085
+ : (Number.isInteger(annotation.serverRevision) ? annotation.serverRevision : 0);
1080
1086
 
1081
1087
  if (type === "delete") {
1082
1088
  this.outbox[annotation.clientId] = {
1083
1089
  type: "delete",
1084
1090
  clientId: annotation.clientId,
1085
1091
  revision,
1092
+ baseRevision,
1086
1093
  syncState: "pending"
1087
1094
  };
1088
1095
  } else {
@@ -1094,6 +1101,7 @@
1094
1101
  type: "upsert",
1095
1102
  clientId: annotation.clientId,
1096
1103
  revision,
1104
+ baseRevision,
1097
1105
  syncState: "pending",
1098
1106
  annotation: this._desiredState(annotation),
1099
1107
  dirtyFields: annotation.dirtyFields.slice()
@@ -1208,6 +1216,16 @@
1208
1216
  this._markSyncFailed(snapshot);
1209
1217
  continue;
1210
1218
  }
1219
+ if (result.kind === "conflict") {
1220
+ const resolution = this._reconcileSyncConflict(snapshot, result);
1221
+ await this._pullAnnotations();
1222
+ if (resolution === "retry" && this.outbox[clientId]) {
1223
+ clientIds.push(clientId);
1224
+ continue;
1225
+ }
1226
+ this._resetSyncRetry();
1227
+ continue;
1228
+ }
1211
1229
  if (result.kind === "malformed") {
1212
1230
  if (!this._outboxEntryMatches(snapshot)) {
1213
1231
  clientIds.push(clientId);
@@ -1235,8 +1253,15 @@
1235
1253
  redirect: "manual",
1236
1254
  signal: AbortSignal.timeout(5000)
1237
1255
  };
1238
- if (snapshot.type === "upsert") {
1239
- options.body = JSON.stringify(Object.assign({}, snapshot.annotation, { dirtyFields: snapshot.dirtyFields || [] }));
1256
+ if (snapshot.type === "delete") {
1257
+ options.body = JSON.stringify({
1258
+ baseRevision: Number.isInteger(snapshot.baseRevision) ? snapshot.baseRevision : 0
1259
+ });
1260
+ } else {
1261
+ options.body = JSON.stringify(Object.assign({}, snapshot.annotation, {
1262
+ dirtyFields: snapshot.dirtyFields || [],
1263
+ baseRevision: Number.isInteger(snapshot.baseRevision) ? snapshot.baseRevision : 0
1264
+ }));
1240
1265
  }
1241
1266
 
1242
1267
  const response = await fetch(request.url, options);
@@ -1274,6 +1299,7 @@
1274
1299
  if ([408, 425, 429].includes(status) || status >= 500) {
1275
1300
  return { kind: "retryable", retryAfter: this._retryAfterDelay(response) };
1276
1301
  }
1302
+ if (status === 409) return this._classifyConflictResponse(snapshot, response);
1277
1303
  if (status >= 400) return { kind: "terminal" };
1278
1304
  if (!response.ok) return { kind: "retryable" };
1279
1305
  if (snapshot.type === "delete") return { kind: "success", data: null };
@@ -1292,12 +1318,31 @@
1292
1318
  }
1293
1319
  },
1294
1320
 
1321
+ async _classifyConflictResponse(snapshot, response) {
1322
+ const contentType = response.headers.get("Content-Type") || "";
1323
+ if (!contentType.toLowerCase().includes("application/json")) return { kind: "malformed" };
1324
+
1325
+ try {
1326
+ const body = await response.json();
1327
+ if (!this._plainObject(body) || !Object.prototype.hasOwnProperty.call(body, "annotation")) {
1328
+ return { kind: "malformed" };
1329
+ }
1330
+ if (body.annotation === null && snapshot.type === "upsert") {
1331
+ return { kind: "conflict", missing: true, data: null };
1332
+ }
1333
+ if (!this._validServerAnnotation(body.annotation, snapshot.clientId)) return { kind: "malformed" };
1334
+ return { kind: "conflict", missing: false, data: body.annotation };
1335
+ } catch {
1336
+ return { kind: "malformed" };
1337
+ }
1338
+ },
1339
+
1295
1340
  _validServerAnnotation(data, expectedClientId) {
1296
1341
  if (!this._plainObject(data)) return false;
1297
1342
  const required = [
1298
1343
  "id", "clientId", "userId", "authorName", "content", "intent", "severity",
1299
1344
  "status", "selectedText", "pageUrl", "target", "metadata", "thread",
1300
- "createdAt", "updatedAt"
1345
+ "createdAt", "updatedAt", "revision"
1301
1346
  ];
1302
1347
  if (!required.every(key => Object.prototype.hasOwnProperty.call(data, key))) return false;
1303
1348
  if (typeof data.id !== "string" || data.id.length === 0) return false;
@@ -1313,6 +1358,7 @@
1313
1358
  if (!this._plainObject(data.target) || !this._plainObject(data.metadata)) return false;
1314
1359
  if (!Array.isArray(data.thread)) return false;
1315
1360
  if (!this._validServerTimestamp(data.createdAt) || !this._validServerTimestamp(data.updatedAt)) return false;
1361
+ if (!Number.isInteger(data.revision) || data.revision < 0) return false;
1316
1362
  return true;
1317
1363
  },
1318
1364
 
@@ -1346,6 +1392,7 @@
1346
1392
  return;
1347
1393
  }
1348
1394
  annotation.serverId = server.id;
1395
+ annotation.serverRevision = server.revision;
1349
1396
  annotation.userId = server.userId;
1350
1397
  annotation.authorName = server.authorName;
1351
1398
  annotation.createdAt = server.createdAt;
@@ -1364,6 +1411,57 @@
1364
1411
  annotation.syncState = "synced";
1365
1412
  },
1366
1413
 
1414
+ _reconcileSyncConflict(snapshot, conflict) {
1415
+ if (!this._outboxEntryMatches(snapshot)) return "stop";
1416
+
1417
+ let resolution = "stop";
1418
+ const committed = this._commitLocalStateChange(() => {
1419
+ if (!this._outboxEntryMatches(snapshot)) return;
1420
+ const entry = this.outbox[snapshot.clientId];
1421
+ const annotation = this.annotations.find(candidate => candidate.clientId === snapshot.clientId);
1422
+
1423
+ if (snapshot.type === "delete") {
1424
+ delete this.outbox[snapshot.clientId];
1425
+ if (annotation) this._mergePulledAnnotation(annotation, conflict.data, null);
1426
+ else this.annotations.push(this._annotationFromServer(conflict.data));
1427
+ this._assignDisplayIds();
1428
+ return;
1429
+ }
1430
+
1431
+ if (conflict.missing) {
1432
+ if (entry.missingConflictRebased) {
1433
+ entry.syncState = "failed";
1434
+ if (annotation) annotation.syncState = "failed";
1435
+ return;
1436
+ }
1437
+ entry.baseRevision = 0;
1438
+ entry.missingConflictRebased = true;
1439
+ if (annotation) {
1440
+ annotation.serverId = null;
1441
+ annotation.serverRevision = 0;
1442
+ }
1443
+ resolution = "retry";
1444
+ return;
1445
+ }
1446
+
1447
+ if (!annotation) {
1448
+ entry.syncState = "failed";
1449
+ return;
1450
+ }
1451
+ this._mergePulledAnnotation(annotation, conflict.data, entry);
1452
+ entry.annotation = this._desiredState(annotation);
1453
+ entry.dirtyFields = (annotation.dirtyFields || []).slice();
1454
+ entry.baseRevision = conflict.data.revision;
1455
+ delete entry.missingConflictRebased;
1456
+ resolution = "retry";
1457
+ });
1458
+ if (!committed) return "stop";
1459
+ this._renderPins();
1460
+ this._rebuildList();
1461
+ this._updateCount();
1462
+ return resolution;
1463
+ },
1464
+
1367
1465
  _markSyncFailed(snapshot) {
1368
1466
  if (!this._outboxEntryMatches(snapshot)) return;
1369
1467
  this._commitLocalStateChange(() => {
@@ -1447,8 +1545,6 @@
1447
1545
 
1448
1546
  _loadFromStorage() {
1449
1547
  try {
1450
- // Old unnamespaced keys are intentionally not read because they may belong
1451
- // to another user or toolbar mount on the same origin.
1452
1548
  let raw = localStorage.getItem(this._storageKey());
1453
1549
  if (raw) {
1454
1550
  const data = JSON.parse(raw);
@@ -1460,8 +1556,11 @@
1460
1556
  ? data.legacyMigrations
1461
1557
  : {};
1462
1558
  }
1463
- // Migrate any old per-page annotations into global store
1464
- const migratedKeys = this._migratePageAnnotations();
1559
+ // Pre-1.3 bare storage has no endpoint provenance, so it is left intact
1560
+ // unless the host explicitly designates this endpoint. Page-qualified
1561
+ // legacy keys are only claimed by a toolbar currently on that exact page.
1562
+ const migratedKeys = this._migrateUnnamespacedStorage();
1563
+ migratedKeys.push(...this._migratePageAnnotations());
1465
1564
  this._normalizeStoredState();
1466
1565
  this._recordLegacyMigrations();
1467
1566
  if (this._saveToStorage()) this._cleanupMigratedKeys(migratedKeys);
@@ -1470,6 +1569,58 @@
1470
1569
  } catch (e) { console.warn("[rails-markup] load failed:", e); }
1471
1570
  },
1472
1571
 
1572
+ _migrateUnnamespacedStorage() {
1573
+ const sourceKeys = [];
1574
+ const designatedEndpoint = (this.legacyStorageEndpoint || "").replace(/\/+$/, "");
1575
+ const currentEndpoint = (this.endpoint || "").replace(/\/+$/, "");
1576
+ const claimBareStorage = Boolean(designatedEndpoint) && designatedEndpoint === currentEndpoint;
1577
+ const currentPageKey = `rm-annotations:${this._pageUrl()}`;
1578
+ for (let index = 0; index < localStorage.length; index++) {
1579
+ const key = localStorage.key(index);
1580
+ if ((claimBareStorage && key === "rm-annotations") || key === currentPageKey) sourceKeys.push(key);
1581
+ }
1582
+
1583
+ const migratedKeys = [];
1584
+ const legacyAnnotations = [];
1585
+ const legacyOutbox = {};
1586
+ const consolidatedClientIds = new Set(
1587
+ this.annotations.map(annotation => annotation.clientId).filter(clientId => this._validClientId(clientId))
1588
+ );
1589
+ sourceKeys.forEach(key => {
1590
+ try {
1591
+ const data = JSON.parse(localStorage.getItem(key));
1592
+ if (!this._plainObject(data)) return;
1593
+ const hasAnnotations = Array.isArray(data.annotations);
1594
+ const hasOutbox = key === "rm-annotations" && this._plainObject(data.outbox);
1595
+ if (!hasAnnotations && !hasOutbox) return;
1596
+
1597
+ if (hasAnnotations) {
1598
+ data.annotations.forEach((annotation, index) => {
1599
+ if (!this._plainObject(annotation)) return;
1600
+ const fingerprint = this._legacyMigrationFingerprint(key, index, annotation);
1601
+ const migratedClientId = this.legacyMigrations[fingerprint];
1602
+ if (this._validClientId(migratedClientId) && consolidatedClientIds.has(migratedClientId)) return;
1603
+ if (this._validClientId(migratedClientId)) annotation.clientId = migratedClientId;
1604
+ if (this._validClientId(annotation.clientId) && consolidatedClientIds.has(annotation.clientId)) return;
1605
+ Object.defineProperty(annotation, "_legacyMigrationFingerprint", {
1606
+ configurable: true,
1607
+ value: fingerprint
1608
+ });
1609
+ legacyAnnotations.push(annotation);
1610
+ if (this._validClientId(annotation.clientId)) consolidatedClientIds.add(annotation.clientId);
1611
+ });
1612
+ }
1613
+ if (hasOutbox) Object.assign(legacyOutbox, data.outbox);
1614
+ if (Number.isInteger(data.nextId) && data.nextId > this.nextId) this.nextId = data.nextId;
1615
+ migratedKeys.push(key);
1616
+ } catch {}
1617
+ });
1618
+
1619
+ this.annotations = legacyAnnotations.concat(this.annotations);
1620
+ this.outbox = Object.assign({}, legacyOutbox, this.outbox);
1621
+ return migratedKeys;
1622
+ },
1623
+
1473
1624
  _migratePageAnnotations() {
1474
1625
  // Find and merge per-page annotation keys only within this endpoint namespace.
1475
1626
  const prefix = this._storageKey() + ":";
@@ -1542,6 +1693,11 @@
1542
1693
  }
1543
1694
  if (annotation.serverId == null) annotation.serverId = annotation.server_id ?? null;
1544
1695
  if (annotation.serverUpdatedAt == null) annotation.serverUpdatedAt = annotation.server_updated_at ?? null;
1696
+ if (!Number.isInteger(annotation.serverRevision) || annotation.serverRevision < 0) {
1697
+ annotation.serverRevision = Number.isInteger(annotation.server_revision) && annotation.server_revision >= 0
1698
+ ? annotation.server_revision
1699
+ : 0;
1700
+ }
1545
1701
  if (!Array.isArray(annotation.dirtyFields)) annotation.dirtyFields = [];
1546
1702
  annotation.pageUrl = annotation.pageUrl || annotation.pathname || this._pageUrl();
1547
1703
  annotation.pathname = annotation.pageUrl;
@@ -1576,6 +1732,7 @@
1576
1732
  type: "upsert",
1577
1733
  clientId: annotation.clientId,
1578
1734
  revision: annotation.revision,
1735
+ baseRevision: annotation.serverRevision,
1579
1736
  syncState: "pending",
1580
1737
  annotation: this._desiredState(annotation),
1581
1738
  dirtyFields: annotation.dirtyFields.slice()
@@ -1606,10 +1763,17 @@
1606
1763
  const annotation = this.annotations.find(record => record.clientId === clientId);
1607
1764
  const candidateRevision = Number.isInteger(candidate.revision) && candidate.revision >= 0 ? candidate.revision : 0;
1608
1765
  const annotationRevision = Number.isInteger(annotation?.revision) && annotation.revision >= 0 ? annotation.revision : 0;
1766
+ const candidateBaseRevision = Number.isInteger(candidate.baseRevision) && candidate.baseRevision >= 0
1767
+ ? candidate.baseRevision
1768
+ : 0;
1769
+ const annotationBaseRevision = Number.isInteger(annotation?.serverRevision) && annotation.serverRevision >= 0
1770
+ ? annotation.serverRevision
1771
+ : 0;
1609
1772
  const envelope = Object.assign({}, candidate, {
1610
1773
  type,
1611
1774
  clientId,
1612
1775
  revision: Math.max(candidateRevision, annotationRevision),
1776
+ baseRevision: Math.max(candidateBaseRevision, annotationBaseRevision),
1613
1777
  syncState: candidate.syncState === "failed" ? "failed" : "pending"
1614
1778
  });
1615
1779
 
@@ -1823,6 +1987,7 @@
1823
1987
  if (!annotation) return;
1824
1988
  entry.annotation = this._desiredState(annotation);
1825
1989
  entry.dirtyFields = (annotation.dirtyFields || []).slice();
1990
+ entry.baseRevision = Number.isInteger(annotation.serverRevision) ? annotation.serverRevision : 0;
1826
1991
  });
1827
1992
  });
1828
1993
  if (!committed) return false;
@@ -1856,6 +2021,7 @@
1856
2021
  annotation.pathname = server.pageUrl;
1857
2022
  }
1858
2023
  annotation.serverId = server.id;
2024
+ annotation.serverRevision = server.revision;
1859
2025
  annotation.userId = server.userId;
1860
2026
  annotation.authorName = server.authorName;
1861
2027
  annotation.createdAt = server.createdAt;
@@ -1871,6 +2037,7 @@
1871
2037
  id: null,
1872
2038
  clientId: server.clientId,
1873
2039
  serverId: server.id,
2040
+ serverRevision: server.revision,
1874
2041
  userId: server.userId,
1875
2042
  authorName: server.authorName,
1876
2043
  syncState: "synced",
@@ -1893,6 +2060,9 @@
1893
2060
  },
1894
2061
 
1895
2062
  _serverRepresentationIsStale(annotation, server) {
2063
+ if (Number.isInteger(annotation.serverRevision) && Number.isInteger(server.revision)) {
2064
+ return server.revision < annotation.serverRevision;
2065
+ }
1896
2066
  const localTimestamp = Date.parse(annotation.serverUpdatedAt || "");
1897
2067
  const serverTimestamp = Date.parse(server.updatedAt || "");
1898
2068
  return Number.isFinite(localTimestamp) && Number.isFinite(serverTimestamp) && serverTimestamp < localTimestamp;
@@ -59,31 +59,50 @@ 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
82
88
  def destroy
83
89
  client_uuid = normalized_route_uuid
84
90
  return render_invalid_uuid unless client_uuid
91
+ base_revision = normalized_base_revision
92
+ return render_invalid_base_revision unless base_revision
93
+
94
+ annotation = Annotation.find_by(client_uuid: client_uuid)
95
+ return head :no_content unless annotation
96
+
97
+ annotation.with_lock do
98
+ raise Annotation::RevisionConflict, annotation unless annotation.revision == base_revision
85
99
 
86
- Annotation.find_by(client_uuid: client_uuid)&.destroy!
100
+ annotation.destroy!
101
+ end
102
+ head :no_content
103
+ rescue Annotation::RevisionConflict => error
104
+ render_revision_conflict(error)
105
+ rescue ActiveRecord::RecordNotFound
87
106
  head :no_content
88
107
  end
89
108
 
@@ -183,9 +202,25 @@ module RailsMarkup
183
202
  permitted.to_h.stringify_keys
184
203
  end
185
204
 
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)
205
+ def save_browser_state!(annotation, attributes, dirty_fields, base_revision)
206
+ if annotation.new_record?
207
+ assign_current_user(annotation)
208
+ annotation.apply_browser_state(
209
+ attributes,
210
+ dirty_fields: dirty_fields,
211
+ base_revision: base_revision
212
+ )
213
+ annotation.save!
214
+ else
215
+ annotation.with_lock do
216
+ annotation.apply_browser_state(
217
+ attributes,
218
+ dirty_fields: dirty_fields,
219
+ base_revision: base_revision
220
+ )
221
+ annotation.save!
222
+ end
223
+ end
189
224
  end
190
225
 
191
226
  def normalized_route_uuid
@@ -201,6 +236,11 @@ module RailsMarkup
201
236
  fields if (fields - ALLOWED_DIRTY_FIELDS).empty?
202
237
  end
203
238
 
239
+ def normalized_base_revision
240
+ revision = params[:baseRevision]
241
+ revision if revision.is_a?(Integer) && revision >= 0
242
+ end
243
+
204
244
  def client_supplied_author?
205
245
  metadata = params[:metadata]
206
246
  metadata.respond_to?(:key?) && (metadata.key?(:author) || metadata.key?("author"))
@@ -226,6 +266,17 @@ module RailsMarkup
226
266
  render json: { error: "invalid status" }, status: :unprocessable_entity
227
267
  end
228
268
 
269
+ def render_invalid_base_revision
270
+ render json: { error: "base revision must be a non-negative integer" }, status: :unprocessable_entity
271
+ end
272
+
273
+ def render_revision_conflict(error)
274
+ render json: {
275
+ error: "revision conflict",
276
+ annotation: error.annotation.new_record? ? nil : error.annotation.as_api_json
277
+ }, status: :conflict
278
+ end
279
+
229
280
  def normalize_target(target)
230
281
  case target
231
282
  when String then { "selector" => target }
@@ -106,7 +106,10 @@ module RailsMarkup
106
106
  return redirect_to root_path, alert: "Invalid status for bulk dismiss."
107
107
  end
108
108
 
109
- count = Annotation.where(status: status).update_all(status: "dismissed")
109
+ # Bump revision per row so a stale toolbar edit (which checks baseRevision)
110
+ # conflicts (409) instead of silently overwriting this bulk dismiss.
111
+ count = Annotation.where(status: status)
112
+ .update_all("status = 'dismissed', revision = revision + 1")
110
113
  redirect_to root_path(status: "dismissed"), notice: "#{count} annotations dismissed."
111
114
  end
112
115
 
@@ -122,7 +125,11 @@ module RailsMarkup
122
125
  when "transition"
123
126
  new_status = params[:status]
124
127
  if Annotation::STATUSES.include?(new_status)
125
- @annotation.update!(status: new_status)
128
+ # Lock + bump revision so a concurrent toolbar edit conflicts (409)
129
+ # instead of this board move silently losing to (or clobbering) it.
130
+ @annotation.with_lock do
131
+ @annotation.update!(status: new_status, revision: @annotation.revision + 1)
132
+ end
126
133
  return head :ok
127
134
  else
128
135
  return render json: { error: "invalid status" }, status: :unprocessable_entity
@@ -61,7 +61,10 @@ module RailsMarkup
61
61
  return if Rails.env.development? && !RailsMarkup.config.require_api_token_in_development
62
62
 
63
63
  token = RailsMarkup.config.api_token
64
- 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?
65
68
 
66
69
  provided = request.headers["Authorization"]&.delete_prefix("Bearer ")
67
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
@@ -17,6 +26,10 @@ module RailsMarkup
17
26
  LEGACY_SESSION_PATTERN = /\Arm-[0-9a-f]{16}\z/i
18
27
  LEGACY_CLIENT_ID_LIMIT = 256
19
28
  LEGACY_UUID_NAMESPACE = "265e7cf0-8be6-5e21-8f31-a582cfde8646"
29
+ # Bound thread growth so large or many reply/summary/reason messages (incl.
30
+ # via the MCP tools) can't grow a row without limit.
31
+ MAX_THREAD_ENTRIES = 500
32
+ MAX_THREAD_MESSAGE = 5000
20
33
 
21
34
  # Optional user association — no FK constraint, engine doesn't know host users table
22
35
  belongs_to :user, optional: true
@@ -36,6 +49,7 @@ module RailsMarkup
36
49
  validates :severity, inclusion: { in: SEVERITIES }
37
50
  validates :status, inclusion: { in: STATUSES }
38
51
  validate :thread_must_be_array
52
+ validate :thread_within_limits
39
53
 
40
54
  def self.valid_client_uuid?(value)
41
55
  value.is_a?(String) && CLIENT_UUID_PATTERN.match?(value)
@@ -99,18 +113,33 @@ module RailsMarkup
99
113
  metadata&.dig("author")
100
114
  end
101
115
 
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")
116
+ def apply_browser_state(attributes, dirty_fields:, base_revision:)
117
+ raise RevisionConflict, self unless base_revision == revision
118
+
119
+ dirty_fields.each do |field|
120
+ if BROWSER_ATTRIBUTES.include?(field)
121
+ public_send("#{field}=", attributes[field]) if attributes.key?(field)
122
+ elsif field == "metadata" && attributes.key?("metadata")
123
+ self.metadata = (metadata || {}).merge(attributes["metadata"].slice(*BROWSER_METADATA_KEYS))
124
+ elsif field == "status" && attributes.key?("status")
125
+ self.status = attributes["status"]
126
+ end
127
+ end
128
+ self.revision += 1 if changed?
106
129
  self
107
130
  end
108
131
 
109
132
  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"
133
+ # Lock + reload so a concurrent resolve!/dismiss! can't be clobbered:
134
+ # without it, an acknowledge validated against a stale "pending" could
135
+ # write "acknowledged" over an already-resolved record.
136
+ with_lock do
137
+ return self if status == "acknowledged" # idempotent — re-acknowledging is a no-op
138
+ raise "Cannot acknowledge a #{status} annotation" unless status == "pending"
112
139
 
113
- update!(status: "acknowledged")
140
+ update!(status: "acknowledged", revision: revision + 1)
141
+ end
142
+ self
114
143
  end
115
144
 
116
145
  def resolve!(summary: nil)
@@ -121,7 +150,7 @@ module RailsMarkup
121
150
  raise "Cannot resolve a #{status} annotation" unless status.in?(%w[pending acknowledged])
122
151
 
123
152
  add_thread_entry(role: "agent", message: summary) if summary.present?
124
- update!(status: "resolved")
153
+ update!(status: "resolved", revision: revision + 1)
125
154
  end
126
155
  self
127
156
  end
@@ -132,7 +161,7 @@ module RailsMarkup
132
161
  raise "Cannot dismiss a #{status} annotation" unless status.in?(%w[pending acknowledged])
133
162
 
134
163
  add_thread_entry(role: "agent", message: reason) if reason.present?
135
- update!(status: "dismissed")
164
+ update!(status: "dismissed", revision: revision + 1)
136
165
  end
137
166
  self
138
167
  end
@@ -140,6 +169,7 @@ module RailsMarkup
140
169
  def add_reply!(message:, role: "agent")
141
170
  with_lock do
142
171
  add_thread_entry(role: role, message: message)
172
+ self.revision += 1
143
173
  save!
144
174
  end
145
175
  self
@@ -161,7 +191,8 @@ module RailsMarkup
161
191
  metadata: metadata,
162
192
  thread: thread,
163
193
  createdAt: created_at&.iso8601,
164
- updatedAt: updated_at&.iso8601
194
+ updatedAt: updated_at&.iso8601,
195
+ revision: revision
165
196
  }
166
197
  end
167
198
 
@@ -182,5 +213,15 @@ module RailsMarkup
182
213
  def thread_must_be_array
183
214
  errors.add(:thread, "must be an array") unless thread.is_a?(Array)
184
215
  end
216
+
217
+ def thread_within_limits
218
+ return unless thread.is_a?(Array)
219
+
220
+ errors.add(:thread, "cannot exceed #{MAX_THREAD_ENTRIES} entries") if thread.size > MAX_THREAD_ENTRIES
221
+
222
+ if thread.any? { |entry| entry.is_a?(Hash) && entry["message"].to_s.length > MAX_THREAD_MESSAGE }
223
+ errors.add(:thread, "entry message cannot exceed #{MAX_THREAD_MESSAGE} characters")
224
+ end
225
+ end
185
226
  end
186
227
  end
@@ -25,6 +25,14 @@
25
25
  };
26
26
  // Init immediately (DOM is ready — script is at end of body)
27
27
  RailsMarkupToolbar.init(opts);
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
+ });
28
36
  // After Turbo Drive navigations (DOMContentLoaded won't fire again), only
29
37
  // re-init when the new page is still authorized (gate sentinel present);
30
38
  // otherwise tear the toolbar down so it can't persist past a logout.
@@ -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
@@ -18,6 +18,7 @@ class CreateRailsMarkupAnnotations < ActiveRecord::Migration<%= migration_versio
18
18
  t.send json_type, :metadata, default: {}
19
19
  t.send json_type, :thread, default: []
20
20
  t.string :client_uuid, limit: 64, null: false
21
+ t.integer :revision, null: false, default: 0
21
22
 
22
23
  t.timestamps
23
24
  end
@@ -28,6 +29,10 @@ class CreateRailsMarkupAnnotations < ActiveRecord::Migration<%= migration_versio
28
29
  if connection.adapter_name.downcase.include?("mysql")
29
30
  add_index :<%= options[:table_name] %>, :page_url, length: 191
30
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.
31
36
  add_index :<%= options[:table_name] %>, :page_url
32
37
  end
33
38
  add_index :<%= options[:table_name] %>, :user_id
@@ -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"
@@ -76,7 +90,10 @@ module RailsMarkup
76
90
  # Upgrade the pre-1.2.3 partial-existence gate, which rendered the
77
91
  # toolbar for every visitor, to the admin-gated block. Leave any
78
92
  # custom (hand-edited) block untouched so we don't clobber it.
79
- legacy = /^[ \t]*<%#\s*Rails Markup annotation toolbar\s*%>\r?\n[ \t]*<%\s*if\s+lookup_context\.exists\?.*?<%\s*end\s*%>\r?\n?/m
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
80
97
  if content =~ legacy
81
98
  gsub_file layout_path, legacy, toolbar_block
82
99
  say_status :update, "upgraded toolbar to admin-gated render in #{layout_path}", :green
@@ -643,29 +643,30 @@ module RailsMarkup
643
643
  "local"
644
644
  end
645
645
 
646
- # Merge configs per-key with local taking precedence over global over codex.
647
- # Merging (rather than returning the first non-empty config) means a local
648
- # config that only defines dev values doesn't shadow production credentials
649
- # stored globally or in the codex config.
650
- def resolve_mcp_env
651
- merged = {}
652
- # Reverse so the highest-precedence scope (local) is applied last and wins.
653
- McpConfig::SCOPES.reverse_each do |scope|
646
+ # Return the raw env of the highest-precedence scope (local → global codex)
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)
653
+ McpConfig::SCOPES.each do |scope|
654
654
  config = McpConfig.new(scope: scope)
655
655
  next unless config.exist?
656
656
 
657
- # Skip blank values so an empty key in a higher-precedence config
658
- # doesn't clobber a real value from a lower-precedence one.
659
- config.raw_env.each do |key, value|
660
- merged[key] = value unless value.to_s.strip.empty?
661
- end
657
+ env = config.raw_env
658
+ return env if keys.any? { |k| env[k].to_s.strip != "" }
662
659
  end
663
660
 
664
- merged
661
+ {}
665
662
  end
666
663
 
667
664
  def resolve_env(production)
668
- 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
669
670
 
670
671
  if production
671
672
  base_url = options[:url] || mcp_env["RAILS_MARKUP_PROD_URL"]
@@ -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.
@@ -52,17 +54,17 @@ module RailsMarkup
52
54
  end
53
55
 
54
56
  def do_OPTIONS(req, res)
55
- cors(res)
57
+ cors(req, res)
56
58
  res.status = 204
57
59
  end
58
60
 
59
61
  def do_GET(req, res)
60
- cors(res)
62
+ cors(req, res)
61
63
  route(req, res)
62
64
  end
63
65
 
64
66
  def do_POST(req, res)
65
- cors(res)
67
+ cors(req, res)
66
68
  route(req, res)
67
69
  end
68
70
 
@@ -161,6 +163,10 @@ module RailsMarkup
161
163
  return not_found(res) unless annotation
162
164
 
163
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)
164
170
  end
165
171
 
166
172
  # --- SSE ---
@@ -173,8 +179,6 @@ module RailsMarkup
173
179
  res["Content-Type"] = "text/event-stream"
174
180
  res["Cache-Control"] = "no-cache"
175
181
  res["Connection"] = "keep-alive"
176
- res["Access-Control-Allow-Origin"] = "*"
177
-
178
182
  res.chunked = true
179
183
  res.body = proc do |out|
180
184
  sub = @store.subscribe(session_id) do |data|
@@ -200,11 +204,26 @@ module RailsMarkup
200
204
 
201
205
  # --- Helpers ---
202
206
 
203
- def cors(res)
204
- port = @server[:Port] rescue 4747
205
- 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)
206
210
  res["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
207
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
208
227
  end
209
228
 
210
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
@@ -22,6 +22,9 @@ module RailsMarkup
22
22
  class ToolError < StandardError; end
23
23
  class TargetError < ToolError; end
24
24
 
25
+ MAX_ACTION_MESSAGE_LENGTH = 5_000
26
+ MAX_ACTION_MESSAGE_BYTES = 5_000
27
+
25
28
  ENV_SCHEMA = {
26
29
  environment: {
27
30
  type: "string",
@@ -74,7 +77,11 @@ module RailsMarkup
74
77
  properties: {
75
78
  action: { type: "string", enum: %w[acknowledge resolve], description: "State transition to apply." },
76
79
  annotationId: { type: "string", description: "The annotation ID" },
77
- summary: { type: "string", description: "Optional resolution summary; valid only for resolve." },
80
+ summary: {
81
+ type: "string",
82
+ maxLength: MAX_ACTION_MESSAGE_LENGTH,
83
+ description: "Optional resolution summary; valid only for resolve."
84
+ },
78
85
  **ENV_SCHEMA
79
86
  },
80
87
  required: %w[action annotationId],
@@ -89,7 +96,7 @@ module RailsMarkup
89
96
  type: "object",
90
97
  properties: {
91
98
  annotationId: { type: "string", description: "The annotation ID" },
92
- message: { type: "string", description: "Reply message" },
99
+ message: { type: "string", maxLength: MAX_ACTION_MESSAGE_LENGTH, description: "Reply message" },
93
100
  **ENV_SCHEMA
94
101
  },
95
102
  required: %w[annotationId message],
@@ -104,7 +111,7 @@ module RailsMarkup
104
111
  type: "object",
105
112
  properties: {
106
113
  annotationId: { type: "string", description: "The annotation ID" },
107
- reason: { type: "string", description: "Reason for dismissal" },
114
+ reason: { type: "string", maxLength: MAX_ACTION_MESSAGE_LENGTH, description: "Reason for dismissal" },
108
115
  **ENV_SCHEMA
109
116
  },
110
117
  required: %w[annotationId reason],
@@ -372,11 +379,14 @@ module RailsMarkup
372
379
  return "action must be acknowledge or resolve." unless %w[acknowledge resolve].include?(args["action"])
373
380
  return "annotationId must be a non-empty string." unless nonempty_string?(args["annotationId"])
374
381
  return "summary must be a string." if args.key?("summary") && !args["summary"].is_a?(String)
382
+ return oversized_action_message_error("summary", args["summary"]) if oversized_action_message?(args["summary"])
375
383
  return "summary is only valid for resolve." if args["action"] != "resolve" && args.key?("summary")
376
384
  when "rails_markup_reply"
377
385
  return "annotationId and message must be non-empty strings." unless nonempty_string?(args["annotationId"]) && nonempty_string?(args["message"])
386
+ return oversized_action_message_error("message", args["message"]) if oversized_action_message?(args["message"])
378
387
  when "rails_markup_dismiss"
379
388
  return "annotationId and reason must be non-empty strings." unless nonempty_string?(args["annotationId"]) && nonempty_string?(args["reason"])
389
+ return oversized_action_message_error("reason", args["reason"]) if oversized_action_message?(args["reason"])
380
390
  when "rails_markup_watch"
381
391
  return "sessionId must be a non-empty string." if args.key?("sessionId") && !nonempty_string?(args["sessionId"])
382
392
  unless !args.key?("timeoutSeconds") || numeric_between?(args["timeoutSeconds"], 0, 300)
@@ -400,6 +410,14 @@ module RailsMarkup
400
410
  value.is_a?(Numeric) && value.finite? && value.between?(minimum, maximum)
401
411
  end
402
412
 
413
+ def oversized_action_message?(value)
414
+ value.is_a?(String) && value.bytesize > MAX_ACTION_MESSAGE_BYTES
415
+ end
416
+
417
+ def oversized_action_message_error(name, _value)
418
+ "#{name} exceeds #{MAX_ACTION_MESSAGE_BYTES} bytes."
419
+ end
420
+
403
421
  def invalid_arguments_response(id, _unknown)
404
422
  tool_error_response(id, "Remove unsupported arguments.")
405
423
  end
@@ -589,6 +607,10 @@ module RailsMarkup
589
607
  # ── Watch mode ────────────────────────────────────────────
590
608
 
591
609
  def handle_watch(args)
610
+ unless @store.respond_to?(:supports_subscriptions?) && @store.supports_subscriptions?
611
+ raise ToolError, "Watch is unsupported in HTTP proxy (mcp-only) mode; poll with rails_markup_read instead."
612
+ end
613
+
592
614
  sub = nil
593
615
  timeout = [args["timeoutSeconds"]&.to_i || 120, 300].min
594
616
  batch_window = [args["batchWindowSeconds"]&.to_i || 10, 60].min
@@ -7,20 +7,35 @@ 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
+ MAX_THREAD_MESSAGE_BYTES = 5_000
26
+ INTENTS = %w[fix change question approve].freeze
27
+ SEVERITIES = %w[suggestion important blocking].freeze
16
28
 
17
29
  attr_reader :sessions
18
30
 
19
- def initialize
31
+ def initialize(max_annotations_per_session: MAX_ANNOTATIONS_PER_SESSION,
32
+ max_annotation_bytes: MAX_ANNOTATION_BYTES)
20
33
  @sessions = {}
21
34
  @annotations_index = {} # id -> annotation (O(1) lookup)
22
35
  @subscribers = [] # SSE callbacks: [session_id, callback]
23
36
  @mutex = Mutex.new
37
+ @max_annotations_per_session = max_annotations_per_session
38
+ @max_annotation_bytes = max_annotation_bytes
24
39
  end
25
40
 
26
41
  # --- Sessions ---
@@ -53,6 +68,9 @@ module RailsMarkup
53
68
 
54
69
  def create_annotation(session_id:, target:, content:, intent: "change", severity: "suggestion",
55
70
  selected_text: nil, metadata: {})
71
+ annotation_bytes = validate_annotation!(
72
+ target:, content:, intent:, severity:, selected_text:, metadata:
73
+ )
56
74
  id = SecureRandom.hex(8)
57
75
  annotation = Annotation.new(
58
76
  id: id,
@@ -73,6 +91,7 @@ module RailsMarkup
73
91
  session = @sessions[session_id]
74
92
  return nil unless session
75
93
 
94
+ enforce_session_capacity!(session, annotation_bytes)
76
95
  session.annotations << annotation
77
96
  @annotations_index[id] = annotation
78
97
  end
@@ -101,35 +120,64 @@ module RailsMarkup
101
120
  # --- Status transitions ---
102
121
 
103
122
  def acknowledge(annotation_id)
104
- update_status(annotation_id, "acknowledged")
123
+ @mutex.synchronize do
124
+ ann = @annotations_index[annotation_id]
125
+ return nil unless ann
126
+ return ann if ann.status == "acknowledged"
127
+
128
+ validate_transition!(ann, "acknowledged", from: %w[pending])
129
+ ann.status = "acknowledged"
130
+ ann
131
+ end
105
132
  end
106
133
 
107
134
  def resolve(annotation_id, summary: nil)
108
- ann = update_status(annotation_id, "resolved")
109
- return nil unless ann
135
+ changed = false
136
+ ann = @mutex.synchronize do
137
+ annotation = @annotations_index[annotation_id]
138
+ return nil unless annotation
139
+ return annotation if annotation.status == "resolved"
140
+
141
+ validate_transition!(annotation, "resolved", from: %w[pending acknowledged])
142
+ append_thread_message!(annotation, summary) unless summary.nil? || summary == ""
143
+ annotation.status = "resolved"
144
+ changed = true
145
+ annotation
146
+ end
147
+ return ann unless changed
110
148
 
111
- ann.thread << { role: "agent", message: summary, timestamp: Time.now.iso8601 } if summary
112
149
  notify(ann.session_id, type: "annotation_update", annotation: serialize_annotation(ann),
113
150
  status: "resolved", summary: summary)
114
151
  ann
115
152
  end
116
153
 
117
154
  def dismiss(annotation_id, reason: nil)
118
- ann = update_status(annotation_id, "dismissed")
119
- return nil unless ann
155
+ changed = false
156
+ ann = @mutex.synchronize do
157
+ annotation = @annotations_index[annotation_id]
158
+ return nil unless annotation
159
+ return annotation if annotation.status == "dismissed"
160
+
161
+ validate_transition!(annotation, "dismissed", from: %w[pending acknowledged])
162
+ append_thread_message!(annotation, reason) unless reason.nil? || reason == ""
163
+ annotation.status = "dismissed"
164
+ changed = true
165
+ annotation
166
+ end
167
+ return ann unless changed
120
168
 
121
- ann.thread << { role: "agent", message: reason, timestamp: Time.now.iso8601 } if reason
122
169
  notify(ann.session_id, type: "annotation_update", annotation: serialize_annotation(ann),
123
170
  status: "dismissed", reason: reason)
124
171
  ann
125
172
  end
126
173
 
127
174
  def reply(annotation_id, message:)
128
- ann = get_annotation(annotation_id)
129
- return nil unless ann
175
+ ann = @mutex.synchronize do
176
+ annotation = @annotations_index[annotation_id]
177
+ return nil unless annotation
130
178
 
131
- @mutex.synchronize do
132
- ann.thread << { role: "agent", message: message, timestamp: Time.now.iso8601 }
179
+ append_thread_message!(annotation, message)
180
+ annotation
133
181
  end
134
182
  notify(ann.session_id, type: "annotation_update", annotation: serialize_annotation(ann),
135
183
  status: ann.status, message: message)
@@ -148,6 +196,10 @@ module RailsMarkup
148
196
  @mutex.synchronize { @subscribers.delete(sub) }
149
197
  end
150
198
 
199
+ def supports_subscriptions?
200
+ true
201
+ end
202
+
151
203
  # --- Serialization ---
152
204
 
153
205
  def serialize_session(session)
@@ -179,27 +231,109 @@ module RailsMarkup
179
231
 
180
232
  private
181
233
 
182
- def update_status(annotation_id, new_status)
183
- ann = get_annotation(annotation_id)
184
- return nil unless ann
234
+ def validate_transition!(annotation, new_status, from:)
235
+ return if from.include?(annotation.status)
185
236
 
186
- @mutex.synchronize { ann.status = new_status }
187
- ann
237
+ raise ValidationError, "cannot transition #{annotation.status} annotation to #{new_status}"
238
+ end
239
+
240
+ def append_thread_message!(annotation, message)
241
+ validate_string!("message", message, maximum: MAX_THREAD_MESSAGE_BYTES)
242
+ new_thread = annotation.thread + [{ role: "agent", message: message, timestamp: Time.now.iso8601 }]
243
+ enforce_thread_capacity!(annotation, new_thread)
244
+ annotation.thread = new_thread
245
+ end
246
+
247
+ def enforce_thread_capacity!(annotation, new_thread)
248
+ current_bytes = aggregate_annotation_bytes
249
+ proposed_bytes = current_bytes - annotation_storage_bytes(annotation) +
250
+ annotation_storage_bytes(annotation, thread: new_thread)
251
+ return if proposed_bytes <= @max_annotation_bytes
252
+
253
+ raise CapacityError, "aggregate annotation byte limit of #{@max_annotation_bytes} reached"
188
254
  end
189
255
 
190
256
  def notify(session_id, data)
257
+ subscribers = @mutex.synchronize do
258
+ @subscribers.select { |sid, _callback| sid.nil? || sid == session_id }
259
+ end
191
260
  dead = []
192
- @mutex.synchronize do
193
- @subscribers.each do |sub|
194
- sid, callback = sub
195
- next unless sid.nil? || sid == session_id
261
+ subscribers.each do |sub|
262
+ _sid, callback = sub
263
+ callback.call(data)
264
+ rescue StandardError
265
+ dead << sub
266
+ end
267
+ @mutex.synchronize { dead.each { |sub| @subscribers.delete(sub) } } unless dead.empty?
268
+ end
196
269
 
197
- callback.call(data)
198
- rescue StandardError
199
- dead << sub
200
- end
201
- dead.each { |s| @subscribers.delete(s) }
270
+ def validate_annotation!(target:, content:, intent:, severity:, selected_text:, metadata:)
271
+ validate_target!(target)
272
+ validate_string!("content", content, maximum: MAX_CONTENT_BYTES)
273
+ validate_optional_string!("selected_text", selected_text, maximum: MAX_SELECTED_TEXT_BYTES)
274
+ raise ValidationError, "intent is invalid" unless INTENTS.include?(intent)
275
+ raise ValidationError, "severity is invalid" unless SEVERITIES.include?(severity)
276
+ raise ValidationError, "metadata must be an object" unless metadata.nil? || metadata.is_a?(Hash)
277
+
278
+ metadata_bytes = JSON.generate(metadata || {}).bytesize
279
+ raise ValidationError, "metadata exceeds #{MAX_METADATA_BYTES} bytes" if metadata_bytes > MAX_METADATA_BYTES
280
+
281
+ JSON.generate(
282
+ target:, content:, intent:, severity:, selected_text:, metadata: metadata || {}, thread: []
283
+ ).bytesize
284
+ rescue JSON::GeneratorError, Encoding::UndefinedConversionError
285
+ raise ValidationError, "annotation fields must be JSON serializable"
286
+ end
287
+
288
+ def validate_string!(name, value, maximum:)
289
+ raise ValidationError, "#{name} must be a non-empty string" unless value.is_a?(String) && !value.empty?
290
+ raise ValidationError, "#{name} exceeds #{maximum} bytes" if value.bytesize > maximum
291
+ end
292
+
293
+ def validate_target!(target)
294
+ if target.is_a?(String)
295
+ return validate_string!("target", target, maximum: MAX_TARGET_BYTES)
202
296
  end
297
+ raise ValidationError, "target must be a non-empty string or object" unless target.is_a?(Hash)
298
+
299
+ target_bytes = JSON.generate(target).bytesize
300
+ raise ValidationError, "target exceeds #{MAX_TARGET_BYTES} bytes" if target_bytes > MAX_TARGET_BYTES
301
+ end
302
+
303
+ def validate_optional_string!(name, value, maximum:)
304
+ return if value.nil?
305
+
306
+ raise ValidationError, "#{name} must be a string" unless value.is_a?(String)
307
+ raise ValidationError, "#{name} exceeds #{maximum} bytes" if value.bytesize > maximum
308
+ end
309
+
310
+ def enforce_session_capacity!(session, incoming_bytes)
311
+ if session.annotations.length >= @max_annotations_per_session
312
+ raise CapacityError, "session annotation limit of #{@max_annotations_per_session} reached"
313
+ end
314
+
315
+ current_bytes = aggregate_annotation_bytes
316
+ return if current_bytes + incoming_bytes <= @max_annotation_bytes
317
+
318
+ raise CapacityError, "aggregate annotation byte limit of #{@max_annotation_bytes} reached"
319
+ end
320
+
321
+ def aggregate_annotation_bytes
322
+ @sessions.values.sum do |stored_session|
323
+ stored_session.annotations.sum { |annotation| annotation_storage_bytes(annotation) }
324
+ end
325
+ end
326
+
327
+ def annotation_storage_bytes(annotation, thread: annotation.thread)
328
+ JSON.generate(
329
+ target: annotation.target,
330
+ content: annotation.content,
331
+ intent: annotation.intent,
332
+ severity: annotation.severity,
333
+ selected_text: annotation.selected_text,
334
+ metadata: annotation.metadata,
335
+ thread: thread
336
+ ).bytesize
203
337
  end
204
338
 
205
339
  def evict_stale_sessions
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsMarkup
4
- VERSION = "1.3.0"
4
+ VERSION = "1.4.1"
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.3.0
4
+ version: 1.4.1
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