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 +4 -4
- data/app/assets/javascripts/rails_markup/toolbar.js +161 -13
- data/app/controllers/rails_markup/annotations_controller.rb +46 -8
- data/app/controllers/rails_markup/dashboard_controller.rb +14 -2
- data/app/controllers/rails_markup/external/annotations_controller.rb +9 -3
- data/app/models/rails_markup/annotation.rb +52 -19
- data/app/views/rails_markup/shared/_toolbar.html.erb +22 -2
- data/db/migrate/20260726000000_add_revision_to_rails_markup_annotations.rb +11 -0
- data/lib/generators/rails_markup/install/templates/create_rails_markup_annotations.rb.erb +16 -2
- data/lib/generators/rails_markup/install_generator.rb +30 -2
- data/lib/rails_markup/cli.rb +22 -5
- data/lib/rails_markup/configuration.rb +8 -0
- data/lib/rails_markup/http_server.rb +35 -9
- data/lib/rails_markup/http_store_proxy.rb +5 -1
- data/lib/rails_markup/mcp_server.rb +4 -0
- data/lib/rails_markup/server.rb +5 -4
- data/lib/rails_markup/store.rb +106 -10
- data/lib/rails_markup/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c2efa8f1fca5e20c13c63a7157b47553cb893df6384bb0799b57b1dd9caa2bb6
|
|
4
|
+
data.tar.gz: 5b244141b9d9bab054f7baa2f15dd3af9560e0ee3aaefcaeaef81c5e4e3541bd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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))
|
|
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() {
|
|
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 "
|
|
1051
|
+
_pageStorageKey() { return this._storageKey() + ":" + this._pageUrl(); },
|
|
1041
1052
|
|
|
1042
1053
|
_saveToStorage() {
|
|
1043
1054
|
try {
|
|
1044
|
-
//
|
|
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, {
|
|
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
|
-
//
|
|
1452
|
-
|
|
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
|
|
1463
|
-
const prefix = "
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
187
|
-
|
|
188
|
-
|
|
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,
|
|
227
|
-
ann.
|
|
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
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
|
data/lib/rails_markup/cli.rb
CHANGED
|
@@ -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
|
-
#
|
|
645
|
-
|
|
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)
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
198
|
-
res["Access-Control-Allow-Origin"]
|
|
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
|
-
|
|
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
|
data/lib/rails_markup/server.rb
CHANGED
|
@@ -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
|
|
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(
|
|
67
|
+
server = TCPServer.new(@bind, port)
|
|
67
68
|
server.close
|
|
68
69
|
true
|
|
69
70
|
rescue Errno::EADDRINUSE
|
data/lib/rails_markup/store.rb
CHANGED
|
@@ -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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
data/lib/rails_markup/version.rb
CHANGED
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.
|
|
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
|