rails-markup 1.3.0 → 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: d852927076b8b5e1774675692c45b575eb9dd6337c934637e90c693310d96f4d
4
- data.tar.gz: 3608bdd64056b7cb3373de2cd4ea0797a1e6dc3e021457ec96697a4d3f8c7919
3
+ metadata.gz: c2efa8f1fca5e20c13c63a7157b47553cb893df6384bb0799b57b1dd9caa2bb6
4
+ data.tar.gz: 5b244141b9d9bab054f7baa2f15dd3af9560e0ee3aaefcaeaef81c5e4e3541bd
5
5
  SHA512:
6
- metadata.gz: ac1ee4649e024d9a3fa306bf3fefb9f6443c2d89cec4d33de6b504831123228f1db3cc2e20414914fdec41150e832ea077d76cfe1c606859c2177626eb643251
7
- data.tar.gz: e8942c6bcbff10487ab62170039b6c62c0c0ffe87a59cb77ac8df87792c6365109d222451fc8756831fad1284b1336f7d0adff3dbf3698b257e2c0e2b1346899
6
+ metadata.gz: d6b1da9212c6140e1db0767b6a27b972497063657a8b5f23d28e1ba84d21d1ed606473182516730708bd7f29447df448baf967f34cf291de40374b724b984cd4
7
+ data.tar.gz: 73f188f0de27d3fc628bc31388daa1a6e0048a124c3d1a0dfb91dcf9be0b048470179c4b47be58b403c2850653064d005fde99f10738bb394433c4cd17ba0cbd
@@ -714,6 +714,7 @@
714
714
  id: this.nextId,
715
715
  clientId: this._newClientId(),
716
716
  serverId: null,
717
+ serverRevision: 0,
717
718
  syncState: "pending",
718
719
  serverUpdatedAt: null,
719
720
  dirtyFields: [],
@@ -1077,12 +1078,16 @@
1077
1078
  _queueLocalMutation(type, annotation, dirtyFields) {
1078
1079
  const currentEntry = this.outbox[annotation.clientId];
1079
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);
1080
1084
 
1081
1085
  if (type === "delete") {
1082
1086
  this.outbox[annotation.clientId] = {
1083
1087
  type: "delete",
1084
1088
  clientId: annotation.clientId,
1085
1089
  revision,
1090
+ baseRevision,
1086
1091
  syncState: "pending"
1087
1092
  };
1088
1093
  } else {
@@ -1094,6 +1099,7 @@
1094
1099
  type: "upsert",
1095
1100
  clientId: annotation.clientId,
1096
1101
  revision,
1102
+ baseRevision,
1097
1103
  syncState: "pending",
1098
1104
  annotation: this._desiredState(annotation),
1099
1105
  dirtyFields: annotation.dirtyFields.slice()
@@ -1208,6 +1214,15 @@
1208
1214
  this._markSyncFailed(snapshot);
1209
1215
  continue;
1210
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
+ }
1211
1226
  if (result.kind === "malformed") {
1212
1227
  if (!this._outboxEntryMatches(snapshot)) {
1213
1228
  clientIds.push(clientId);
@@ -1236,7 +1251,10 @@
1236
1251
  signal: AbortSignal.timeout(5000)
1237
1252
  };
1238
1253
  if (snapshot.type === "upsert") {
1239
- 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
+ }));
1240
1258
  }
1241
1259
 
1242
1260
  const response = await fetch(request.url, options);
@@ -1274,6 +1292,7 @@
1274
1292
  if ([408, 425, 429].includes(status) || status >= 500) {
1275
1293
  return { kind: "retryable", retryAfter: this._retryAfterDelay(response) };
1276
1294
  }
1295
+ if (status === 409 && snapshot.type === "upsert") return { kind: "conflict" };
1277
1296
  if (status >= 400) return { kind: "terminal" };
1278
1297
  if (!response.ok) return { kind: "retryable" };
1279
1298
  if (snapshot.type === "delete") return { kind: "success", data: null };
@@ -1297,7 +1316,7 @@
1297
1316
  const required = [
1298
1317
  "id", "clientId", "userId", "authorName", "content", "intent", "severity",
1299
1318
  "status", "selectedText", "pageUrl", "target", "metadata", "thread",
1300
- "createdAt", "updatedAt"
1319
+ "createdAt", "updatedAt", "revision"
1301
1320
  ];
1302
1321
  if (!required.every(key => Object.prototype.hasOwnProperty.call(data, key))) return false;
1303
1322
  if (typeof data.id !== "string" || data.id.length === 0) return false;
@@ -1313,6 +1332,7 @@
1313
1332
  if (!this._plainObject(data.target) || !this._plainObject(data.metadata)) return false;
1314
1333
  if (!Array.isArray(data.thread)) return false;
1315
1334
  if (!this._validServerTimestamp(data.createdAt) || !this._validServerTimestamp(data.updatedAt)) return false;
1335
+ if (!Number.isInteger(data.revision) || data.revision < 0) return false;
1316
1336
  return true;
1317
1337
  },
1318
1338
 
@@ -1346,6 +1366,7 @@
1346
1366
  return;
1347
1367
  }
1348
1368
  annotation.serverId = server.id;
1369
+ annotation.serverRevision = server.revision;
1349
1370
  annotation.userId = server.userId;
1350
1371
  annotation.authorName = server.authorName;
1351
1372
  annotation.createdAt = server.createdAt;
@@ -1447,8 +1468,6 @@
1447
1468
 
1448
1469
  _loadFromStorage() {
1449
1470
  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
1471
  let raw = localStorage.getItem(this._storageKey());
1453
1472
  if (raw) {
1454
1473
  const data = JSON.parse(raw);
@@ -1460,8 +1479,11 @@
1460
1479
  ? data.legacyMigrations
1461
1480
  : {};
1462
1481
  }
1463
- // Migrate any old per-page annotations into global store
1464
- 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());
1465
1487
  this._normalizeStoredState();
1466
1488
  this._recordLegacyMigrations();
1467
1489
  if (this._saveToStorage()) this._cleanupMigratedKeys(migratedKeys);
@@ -1470,6 +1492,54 @@
1470
1492
  } catch (e) { console.warn("[rails-markup] load failed:", e); }
1471
1493
  },
1472
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
+
1473
1543
  _migratePageAnnotations() {
1474
1544
  // Find and merge per-page annotation keys only within this endpoint namespace.
1475
1545
  const prefix = this._storageKey() + ":";
@@ -1542,6 +1612,11 @@
1542
1612
  }
1543
1613
  if (annotation.serverId == null) annotation.serverId = annotation.server_id ?? null;
1544
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
+ }
1545
1620
  if (!Array.isArray(annotation.dirtyFields)) annotation.dirtyFields = [];
1546
1621
  annotation.pageUrl = annotation.pageUrl || annotation.pathname || this._pageUrl();
1547
1622
  annotation.pathname = annotation.pageUrl;
@@ -1576,6 +1651,7 @@
1576
1651
  type: "upsert",
1577
1652
  clientId: annotation.clientId,
1578
1653
  revision: annotation.revision,
1654
+ baseRevision: annotation.serverRevision,
1579
1655
  syncState: "pending",
1580
1656
  annotation: this._desiredState(annotation),
1581
1657
  dirtyFields: annotation.dirtyFields.slice()
@@ -1606,10 +1682,17 @@
1606
1682
  const annotation = this.annotations.find(record => record.clientId === clientId);
1607
1683
  const candidateRevision = Number.isInteger(candidate.revision) && candidate.revision >= 0 ? candidate.revision : 0;
1608
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;
1609
1691
  const envelope = Object.assign({}, candidate, {
1610
1692
  type,
1611
1693
  clientId,
1612
1694
  revision: Math.max(candidateRevision, annotationRevision),
1695
+ baseRevision: Math.max(candidateBaseRevision, annotationBaseRevision),
1613
1696
  syncState: candidate.syncState === "failed" ? "failed" : "pending"
1614
1697
  });
1615
1698
 
@@ -1823,6 +1906,7 @@
1823
1906
  if (!annotation) return;
1824
1907
  entry.annotation = this._desiredState(annotation);
1825
1908
  entry.dirtyFields = (annotation.dirtyFields || []).slice();
1909
+ entry.baseRevision = Number.isInteger(annotation.serverRevision) ? annotation.serverRevision : 0;
1826
1910
  });
1827
1911
  });
1828
1912
  if (!committed) return false;
@@ -1856,6 +1940,7 @@
1856
1940
  annotation.pathname = server.pageUrl;
1857
1941
  }
1858
1942
  annotation.serverId = server.id;
1943
+ annotation.serverRevision = server.revision;
1859
1944
  annotation.userId = server.userId;
1860
1945
  annotation.authorName = server.authorName;
1861
1946
  annotation.createdAt = server.createdAt;
@@ -1871,6 +1956,7 @@
1871
1956
  id: null,
1872
1957
  clientId: server.clientId,
1873
1958
  serverId: server.id,
1959
+ serverRevision: server.revision,
1874
1960
  userId: server.userId,
1875
1961
  authorName: server.authorName,
1876
1962
  syncState: "synced",
@@ -1893,6 +1979,9 @@
1893
1979
  },
1894
1980
 
1895
1981
  _serverRepresentationIsStale(annotation, server) {
1982
+ if (Number.isInteger(annotation.serverRevision) && Number.isInteger(server.revision)) {
1983
+ return server.revision < annotation.serverRevision;
1984
+ }
1896
1985
  const localTimestamp = Date.parse(annotation.serverUpdatedAt || "");
1897
1986
  const serverTimestamp = Date.parse(server.updatedAt || "");
1898
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 }
@@ -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
@@ -99,18 +108,33 @@ 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"
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"
112
134
 
113
- update!(status: "acknowledged")
135
+ update!(status: "acknowledged", revision: revision + 1)
136
+ end
137
+ self
114
138
  end
115
139
 
116
140
  def resolve!(summary: nil)
@@ -121,7 +145,7 @@ module RailsMarkup
121
145
  raise "Cannot resolve a #{status} annotation" unless status.in?(%w[pending acknowledged])
122
146
 
123
147
  add_thread_entry(role: "agent", message: summary) if summary.present?
124
- update!(status: "resolved")
148
+ update!(status: "resolved", revision: revision + 1)
125
149
  end
126
150
  self
127
151
  end
@@ -132,7 +156,7 @@ module RailsMarkup
132
156
  raise "Cannot dismiss a #{status} annotation" unless status.in?(%w[pending acknowledged])
133
157
 
134
158
  add_thread_entry(role: "agent", message: reason) if reason.present?
135
- update!(status: "dismissed")
159
+ update!(status: "dismissed", revision: revision + 1)
136
160
  end
137
161
  self
138
162
  end
@@ -140,6 +164,7 @@ module RailsMarkup
140
164
  def add_reply!(message:, role: "agent")
141
165
  with_lock do
142
166
  add_thread_entry(role: role, message: message)
167
+ self.revision += 1
143
168
  save!
144
169
  end
145
170
  self
@@ -161,7 +186,8 @@ module RailsMarkup
161
186
  metadata: metadata,
162
187
  thread: thread,
163
188
  createdAt: created_at&.iso8601,
164
- updatedAt: updated_at&.iso8601
189
+ updatedAt: updated_at&.iso8601,
190
+ revision: revision
165
191
  }
166
192
  end
167
193
 
@@ -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
@@ -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
@@ -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
@@ -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.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.3.0
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