@marsnme/mcp-gateway 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marsnme/mcp-gateway",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "mcpName": "io.github.Marsmanleo/marsnme",
5
5
  "private": false,
6
6
  "description": "Agent-agnostic, LLM-agnostic memory backend for MCP-compatible tools",
@@ -49,4 +49,4 @@
49
49
  "engines": {
50
50
  "node": ">=20"
51
51
  }
52
- }
52
+ }
@@ -0,0 +1,74 @@
1
+ // MARS-422: bearer bypass guard (RED first — see scripts/tests/test_auth_guard.mjs)
2
+ import test from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { shouldRequireBearer } from '../../lib/auth-guard.mjs';
5
+
6
+ const base = { requireBearer: true, bypassForPrivate: true };
7
+
8
+ test('public request via Cloudflare edge must require bearer even with private X-Forwarded-Host', () => {
9
+ const headers = {
10
+ 'cf-ray': '8b1f9c2d4e5f-HKG',
11
+ 'cf-connecting-ip': '203.0.113.7',
12
+ 'x-forwarded-host': '127.0.0.1',
13
+ host: 'coco-mcp.marsgroup.asia',
14
+ };
15
+ assert.equal(
16
+ shouldRequireBearer({ ...base, headers, remoteAddress: '127.0.0.1' }),
17
+ true,
18
+ 'spoofed X-Forwarded-Host from the internet must NOT bypass auth'
19
+ );
20
+ });
21
+
22
+ test('edge request from a LAN socket peer still requires bearer', () => {
23
+ const headers = { 'cf-ray': 'x', 'x-forwarded-host': '192.168.100.102' };
24
+ assert.equal(shouldRequireBearer({ ...base, headers, remoteAddress: '192.168.100.102' }), true);
25
+ });
26
+
27
+ test('request with cf-connecting-ip only (no cf-ray) is also treated as edge', () => {
28
+ const headers = { 'cf-connecting-ip': '203.0.113.7', 'x-forwarded-host': '10.0.0.5' };
29
+ assert.equal(shouldRequireBearer({ ...base, headers, remoteAddress: '127.0.0.1' }), true);
30
+ });
31
+
32
+ test('loopback caller without edge headers keeps the private bypass', () => {
33
+ assert.equal(
34
+ shouldRequireBearer({ ...base, headers: { host: '127.0.0.1:18790' }, remoteAddress: '127.0.0.1' }),
35
+ false
36
+ );
37
+ });
38
+
39
+ test('LAN caller without edge headers keeps the private bypass (SSH tunnel case)', () => {
40
+ // Pi's coco-memory-local reaches the CT as 192.168.100.x over ssh -L.
41
+ const headers = { host: '192.168.100.102:18790', 'x-forwarded-host': '127.0.0.1' };
42
+ assert.equal(shouldRequireBearer({ ...base, headers, remoteAddress: '192.168.100.102' }), false);
43
+ assert.equal(shouldRequireBearer({ ...base, headers, remoteAddress: '10.4.5.6' }), false);
44
+ assert.equal(shouldRequireBearer({ ...base, headers, remoteAddress: '172.20.1.9' }), false);
45
+ });
46
+
47
+ test('public peer without edge headers must NOT bypass', () => {
48
+ const headers = { 'x-forwarded-host': '127.0.0.1', host: '127.0.0.1:18790' };
49
+ assert.equal(
50
+ shouldRequireBearer({ ...base, headers, remoteAddress: '203.0.113.7' }),
51
+ true,
52
+ 'a public client reaching the port directly must not be trusted'
53
+ );
54
+ });
55
+
56
+ test('requireBearer=false disables auth entirely (unchanged behaviour)', () => {
57
+ const headers = { 'cf-ray': 'x', 'x-forwarded-host': '127.0.0.1' };
58
+ assert.equal(
59
+ shouldRequireBearer({ requireBearer: false, bypassForPrivate: true, headers, remoteAddress: '127.0.0.1' }),
60
+ false
61
+ );
62
+ });
63
+
64
+ test('bypassForPrivate=false always requires bearer', () => {
65
+ assert.equal(
66
+ shouldRequireBearer({
67
+ requireBearer: true,
68
+ bypassForPrivate: false,
69
+ headers: { host: '127.0.0.1:18790' },
70
+ remoteAddress: '127.0.0.1',
71
+ }),
72
+ true
73
+ );
74
+ });
package/server.mjs CHANGED
@@ -5,6 +5,7 @@ import { execFileSync } from 'node:child_process';
5
5
  import { AsyncLocalStorage } from 'node:async_hooks';
6
6
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
7
7
  import { dirname } from 'node:path';
8
+ import { shouldRequireBearer as guardShouldRequireBearer } from './lib/auth-guard.mjs';
8
9
 
9
10
  const PROFILE_CONFIGS = {
10
11
  coco: {
@@ -13,7 +14,7 @@ const PROFILE_CONFIGS = {
13
14
  defaultPort: 18790,
14
15
  gatewayDir: 'coco-mcp-gateway',
15
16
  publicHostSuffix: 'coco-mcp.marsgroup.asia',
16
- sourceWhitelist: ['perplexity', 'cursor', 'warp', 'openclaw', 'hermes', 'draft', 'grok'],
17
+ sourceWhitelist: ['perplexity', 'cursor', 'warp', 'openclaw', 'hermes', 'draft', 'grok', 'omp'],
17
18
  recallBodyEnum: ['coco', 'toto', 'system'],
18
19
  digestDefaultOrigin: 'hermes-coco-digest',
19
20
  memoryIngestToolName: 'memory_ingest',
@@ -24,6 +25,7 @@ const PROFILE_CONFIGS = {
24
25
  'perplexity-coco',
25
26
  'cursor-coco',
26
27
  'warp-coco',
28
+ 'omp-coco',
27
29
  'leo-manual',
28
30
  'hermes-coco-digest'
29
31
  ],
@@ -261,7 +263,8 @@ const SOURCE_TO_AGENT_BODY_MAP = new Map([
261
263
  ['warp', 'warp'],
262
264
  ['openclaw', 'desktop'],
263
265
  ['hermes', 'desktop'],
264
- ['draft', 'desktop']
266
+ ['draft', 'desktop'],
267
+ ['omp', 'omp']
265
268
  ]);
266
269
  const DEFAULT_AGENT_BODY = String(process.env.MCP_AGENT_BODY || '')
267
270
  .trim()
@@ -613,7 +616,7 @@ function buildTools() {
613
616
  {
614
617
  name: 'session_close',
615
618
  description:
616
- `Save a ${PROFILE.displayName} session close summary before ending a conversation. The summary is stored as a short-term memory with 7-day retention, providing context for the next session boot. After saving, automatically promotes up to 5 soon-expiring short-term memories to long-term storage (alert window 48h); promote failures never fail the close. Include what was accomplished, what is still pending, and any important context for continuity.`,
619
+ `Save a ${PROFILE.displayName} session close summary before ending a conversation. The summary is stored as a short-term memory with 7-day retention, providing context for the next session boot. After saving, automatically promotes up to 5 soon-expiring short-term memories to long-term storage (alert window 48h); promote failures never fail the close. Also auto-posts a session-close broadcast to the CoCo family board (board_posts) so other bodies see what was accomplished/pending on next session_boot; board post failures never fail the close. Include what was accomplished, what is still pending, and any important context for continuity.`,
617
620
  inputSchema: {
618
621
  type: 'object',
619
622
  properties: {
@@ -638,6 +641,26 @@ function buildTools() {
638
641
  note: {
639
642
  type: 'string',
640
643
  description: 'Short context handoff for the recipient body (used with `to`)'
644
+ },
645
+ body_name: {
646
+ type: 'string',
647
+ description: 'Optional posting body name for the auto board_post (e.g. "四哥", "三哥"). Defaults to profile name.'
648
+ },
649
+ auto_board_post: {
650
+ type: 'boolean',
651
+ default: true,
652
+ description: 'When true (default), auto-post a session-close broadcast to the family board. Set false to skip.'
653
+ },
654
+ board_type: {
655
+ type: 'string',
656
+ enum: ['stuck', 'turn', 'collide', 'gate'],
657
+ default: 'turn',
658
+ description: 'Board post type for the auto broadcast (default "turn")'
659
+ },
660
+ board_topic: {
661
+ type: 'string',
662
+ default: 'session-close',
663
+ description: 'Board topic tag for the auto broadcast (default "session-close")'
641
664
  }
642
665
  },
643
666
  required: ['source', 'summary'],
@@ -816,6 +839,80 @@ function buildTools() {
816
839
  },
817
840
  additionalProperties: false
818
841
  }
842
+ },
843
+ // ── MARS-318: Board v0 ─────────────────────────────────────
844
+ {
845
+ name: 'board_read',
846
+ description: 'Read unread board posts (the CoCo family wall). Returns posts not yet acknowledged by the calling body. Called automatically by session_boot, but can also be called manually.',
847
+ inputSchema: {
848
+ type: 'object',
849
+ properties: {
850
+ body: { type: 'string', description: 'The body name reading the board (e.g. "三哥", "四哥", "大家姐", "leo")' },
851
+ limit: { type: 'number', minimum: 1, maximum: 50, default: 10, description: 'Maximum posts to return' },
852
+ topic: { type: 'string', description: 'Optional topic filter' },
853
+ include_acked: { type: 'boolean', default: false, description: 'Include already-acknowledged posts' }
854
+ },
855
+ required: ['body'],
856
+ additionalProperties: false
857
+ }
858
+ },
859
+ {
860
+ name: 'board_post',
861
+ description: 'Post a message to the CoCo family board (wall). to="all" broadcasts to the wall; to="<body>" is a private note.',
862
+ inputSchema: {
863
+ type: 'object',
864
+ properties: {
865
+ from_source: { type: 'string', description: 'Source platform (warp / cursor / perplexity / grok / hermes / draft)' },
866
+ from_body: { type: 'string', description: 'Posting body name (e.g. "三哥", "leo")' },
867
+ to: { type: 'string', default: 'all', description: '"all" for wall broadcast; specific body name for private note' },
868
+ topic: { type: 'string', description: 'Topic tag (e.g. "board-v0", "egress")' },
869
+ type: { type: 'string', enum: ['stuck', 'turn', 'collide', 'gate'], default: 'turn', description: 'Post type' },
870
+ text: { type: 'string', description: 'Post content (max 2000 chars)' },
871
+ expires_at: { type: 'string', description: 'Optional expiry in ISO 8601. null = permanent.' }
872
+ },
873
+ required: ['from_source', 'from_body', 'text'],
874
+ additionalProperties: false
875
+ }
876
+ },
877
+ {
878
+ name: 'board_ack',
879
+ description: 'Acknowledge (mark as read) one or more board posts for a specific body.',
880
+ inputSchema: {
881
+ type: 'object',
882
+ properties: {
883
+ body: { type: 'string', description: 'The body acknowledging (e.g. "三哥")' },
884
+ ids: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 50, description: 'Board post UUIDs to acknowledge' }
885
+ },
886
+ required: ['body', 'ids'],
887
+ additionalProperties: false
888
+ }
889
+ },
890
+ // MARS-327: board_get_full — retrieve full text of a single board post
891
+ {
892
+ name: 'board_get_full',
893
+ description: 'Get the full untruncated text of a single board post. Use when board_read or session_boot shows truncated=true and you need the complete content.',
894
+ inputSchema: {
895
+ type: 'object',
896
+ properties: {
897
+ id: { type: 'string', description: 'Board post UUID' }
898
+ },
899
+ required: ['id'],
900
+ additionalProperties: false
901
+ }
902
+ },
903
+ // board_resolve — mark posts as globally resolved
904
+ {
905
+ name: 'board_resolve',
906
+ description: 'Resolve one or more board posts globally. Resolved posts are hidden from board_read and session_boot for ALL bodies (unlike ack which is per-body). Use to close out stuck/gate posts that have been addressed.',
907
+ inputSchema: {
908
+ type: 'object',
909
+ properties: {
910
+ body: { type: 'string', description: 'The body resolving (e.g. "三哥")' },
911
+ ids: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 50, description: 'Board post UUIDs to resolve' }
912
+ },
913
+ required: ['body', 'ids'],
914
+ additionalProperties: false
915
+ }
819
916
  }
820
917
  ];
821
918
  }
@@ -1609,6 +1706,8 @@ function estimateToolUsageTokens(toolName, args = {}) {
1609
1706
  appendTokenCharCount(safeArgs.summary, state);
1610
1707
  appendTokenCharCount(safeArgs.topics, state);
1611
1708
  appendTokenCharCount(safeArgs.mood, state);
1709
+ appendTokenCharCount(safeArgs.body_name, state);
1710
+ appendTokenCharCount(safeArgs.board_topic, state);
1612
1711
  break;
1613
1712
  case 'session_boot':
1614
1713
  appendTokenCharCount(safeArgs.topic, state);
@@ -2440,6 +2539,54 @@ async function runDailyBoot(args = {}) {
2440
2539
  }
2441
2540
  }
2442
2541
 
2542
+ // MARS-318: Board v0 — auto-fetch unread wall posts for this body
2543
+ let boardUnread = [];
2544
+ const boardBody = bodyName || inferAgentBodyFromSource(source) || DB_PROFILE;
2545
+ try {
2546
+ const bQuery = new URLSearchParams();
2547
+ bQuery.set('select', 'id,from_source,from_body,to,topic,type,text,acked_by,created_at');
2548
+ bQuery.set('order', 'created_at.desc');
2549
+ bQuery.set('limit', '5');
2550
+ bQuery.set('archived_at', 'is.null');
2551
+ bQuery.set('resolved_at', 'is.null');
2552
+ bQuery.set('or', `(to.eq.all,to.eq.${boardBody})`);
2553
+ const bResult = await supabaseRequest(`/rest/v1/board_posts?${bQuery.toString()}`, { profile: DB_PROFILE });
2554
+ const bPosts = Array.isArray(bResult) ? bResult : [];
2555
+ const BOOT_BOARD_PREVIEW_MAX = 160;
2556
+ // MARS-328: priority types surface first
2557
+ const BOARD_PRIORITY_TYPES = new Set(['gate', 'stuck']);
2558
+ boardUnread = bPosts
2559
+ .filter(p => {
2560
+ const ab = p.acked_by && typeof p.acked_by === 'object' ? p.acked_by : {};
2561
+ return !ab[boardBody];
2562
+ })
2563
+ .map(p => {
2564
+ const fullText = p.text || '';
2565
+ const preview = fullText.slice(0, BOOT_BOARD_PREVIEW_MAX);
2566
+ return {
2567
+ id: p.id,
2568
+ from: `${p.from_body}@${p.from_source}`,
2569
+ to: p.to,
2570
+ topic: p.topic || null,
2571
+ type: p.type,
2572
+ text: preview,
2573
+ truncated: fullText.length > BOOT_BOARD_PREVIEW_MAX,
2574
+ full_length: fullText.length,
2575
+ preview_length: preview.length,
2576
+ created_at: p.created_at
2577
+ };
2578
+ })
2579
+ .sort((a, b) => {
2580
+ const aPri = BOARD_PRIORITY_TYPES.has(a.type) ? 0 : 1;
2581
+ const bPri = BOARD_PRIORITY_TYPES.has(b.type) ? 0 : 1;
2582
+ if (aPri !== bPri) return aPri - bPri;
2583
+ return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
2584
+ });
2585
+ } catch {
2586
+ // board_posts table may not exist yet — degrade gracefully
2587
+ boardUnread = [];
2588
+ }
2589
+
2443
2590
  return {
2444
2591
  ok: true,
2445
2592
  profile: DB_PROFILE,
@@ -2447,6 +2594,9 @@ async function runDailyBoot(args = {}) {
2447
2594
  source,
2448
2595
  date: dateKey,
2449
2596
  notes,
2597
+ board_unread: boardUnread,
2598
+ // MARS-328: remind body to post stuck/gate when blocked mid-session
2599
+ board_reminder: '中途卡住請用 board_post type=stuck 上牆,唔好等到 session_close。',
2450
2600
  heartbeat_id: savedHeartbeat?.id || null,
2451
2601
  agent_name: queries.agent_name,
2452
2602
  last_topic: previousTopic || null,
@@ -2580,6 +2730,51 @@ async function runDailyClose(args = {}) {
2580
2730
  }
2581
2731
  }
2582
2732
 
2733
+ // Auto-post a session-close broadcast to the CoCo family board.
2734
+ // Mirrors board_post insert; failures never break session_close (summary already stored).
2735
+ let boardPostResult = null;
2736
+ const autoBoardPost = args.auto_board_post !== false;
2737
+ if (autoBoardPost) {
2738
+ try {
2739
+ const boardFromSource = source;
2740
+ const boardFromBody = normalizeOptionalText(args.body_name, 60) || DB_PROFILE;
2741
+ const boardTo = 'all';
2742
+ const boardType = ['stuck', 'turn', 'collide', 'gate'].includes(args.board_type)
2743
+ ? args.board_type
2744
+ : 'turn';
2745
+ const boardTopic = normalizeOptionalText(args.board_topic, 60) || 'session-close';
2746
+ const boardText = String(summary || '').slice(0, 2000);
2747
+ if (boardText) {
2748
+ const boardPayload = {
2749
+ from_source: boardFromSource,
2750
+ from_body: boardFromBody,
2751
+ to: boardTo,
2752
+ topic: boardTopic,
2753
+ type: boardType,
2754
+ text: boardText
2755
+ };
2756
+ const boardInserted = await supabaseRequest(
2757
+ '/rest/v1/board_posts?select=id,from_source,from_body,to,topic,type,text,created_at',
2758
+ {
2759
+ method: 'POST',
2760
+ profile: DB_PROFILE,
2761
+ prefer: 'return=representation',
2762
+ body: [boardPayload]
2763
+ }
2764
+ );
2765
+ boardPostResult = { posted: true, id: boardInserted?.[0]?.id || null };
2766
+ } else {
2767
+ boardPostResult = { posted: false, reason: 'empty summary' };
2768
+ }
2769
+ } catch (error) {
2770
+ // board_posts table may not exist yet, or insert failed — degrade gracefully.
2771
+ console.warn(`[session_close] auto board_post failed: ${String(error?.message || error)}`);
2772
+ boardPostResult = { posted: false, error: String(error?.message || error) };
2773
+ }
2774
+ } else {
2775
+ boardPostResult = { posted: false, skipped: true };
2776
+ }
2777
+
2583
2778
  return {
2584
2779
  ok: true,
2585
2780
  profile: DB_PROFILE,
@@ -2591,6 +2786,7 @@ async function runDailyClose(args = {}) {
2591
2786
  mood: mood || null,
2592
2787
  tool_usage_daily_summary: toolUsageDailySummary,
2593
2788
  note: noteResult,
2789
+ board_post: boardPostResult,
2594
2790
  promoted_count: promotedCount,
2595
2791
  expiry_alert: {
2596
2792
  soon_expiring_count: soonExpiringCount,
@@ -2660,6 +2856,15 @@ function evaluatePromotionCandidate(memory, nowTs, longTermTagSet) {
2660
2856
  reason: '已 promoted'
2661
2857
  };
2662
2858
  }
2859
+ // Heartbeat / session_close lifecycle memories should never be promoted —
2860
+ // they contain query strings that pollute recall results with high similarity.
2861
+ if (isLifecycleMemoryRow(memory)) {
2862
+ return {
2863
+ recommend: 'N',
2864
+ score: 0,
2865
+ reason: 'lifecycle memory (heartbeat/session_close) 唔應升級'
2866
+ };
2867
+ }
2663
2868
  const reasons = [];
2664
2869
  let score = 0;
2665
2870
 
@@ -4258,6 +4463,204 @@ async function callTool(name, args = {}) {
4258
4463
  };
4259
4464
  }
4260
4465
 
4466
+ // ── MARS-318: Board v0 handlers ──────────────────────────
4467
+ if (toolName === 'board_read') {
4468
+ const body = String(args.body || '').trim();
4469
+ if (!body) throw new Error('body is required');
4470
+ const limitRaw = Number(args.limit ?? 10);
4471
+ const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(50, Math.trunc(limitRaw))) : 10;
4472
+ const includeAcked = Boolean(args.include_acked);
4473
+ const topic = args.topic ? String(args.topic).trim() : '';
4474
+
4475
+ const query = new URLSearchParams();
4476
+ query.set('select', 'id,from_source,from_body,to,topic,type,text,acked_by,expires_at,archived_at,created_at');
4477
+ query.set('order', 'created_at.desc');
4478
+ query.set('limit', String(limit));
4479
+ query.set('archived_at', 'is.null');
4480
+ query.set('resolved_at', 'is.null');
4481
+ // Wall posts (to=all) + posts addressed to this body
4482
+ query.set('or', `(to.eq.all,to.eq.${body})`);
4483
+ if (topic) query.set('topic', `eq.${topic}`);
4484
+ // Exclude expired
4485
+ query.set('or', `(expires_at.is.null,expires_at.gt.${new Date().toISOString()})`);
4486
+
4487
+ const result = await supabaseRequest(`/rest/v1/board_posts?${query.toString()}`, { profile: DB_PROFILE });
4488
+ const posts = Array.isArray(result) ? result : [];
4489
+
4490
+ // Filter out already-acked posts unless include_acked
4491
+ const filtered = includeAcked
4492
+ ? posts
4493
+ : posts.filter(p => {
4494
+ const ackedBy = p.acked_by && typeof p.acked_by === 'object' ? p.acked_by : {};
4495
+ return !ackedBy[body];
4496
+ });
4497
+
4498
+ const BOARD_PREVIEW_MAX = 160;
4499
+ // MARS-328: gate/stuck surface first
4500
+ const BOARD_READ_PRIORITY_TYPES = new Set(['gate', 'stuck']);
4501
+ const mapped = filtered.map(p => {
4502
+ const fullText = p.text || '';
4503
+ const preview = fullText.slice(0, BOARD_PREVIEW_MAX);
4504
+ return {
4505
+ id: p.id,
4506
+ from: `${p.from_body}@${p.from_source}`,
4507
+ to: p.to,
4508
+ topic: p.topic || null,
4509
+ type: p.type,
4510
+ text: preview,
4511
+ truncated: fullText.length > BOARD_PREVIEW_MAX,
4512
+ full_length: fullText.length,
4513
+ preview_length: preview.length,
4514
+ created_at: p.created_at,
4515
+ acked: !!(p.acked_by && p.acked_by[body])
4516
+ };
4517
+ }).sort((a, b) => {
4518
+ const aPri = BOARD_READ_PRIORITY_TYPES.has(a.type) ? 0 : 1;
4519
+ const bPri = BOARD_READ_PRIORITY_TYPES.has(b.type) ? 0 : 1;
4520
+ if (aPri !== bPri) return aPri - bPri;
4521
+ return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
4522
+ });
4523
+ return {
4524
+ ok: true,
4525
+ count: mapped.length,
4526
+ body,
4527
+ posts: mapped
4528
+ };
4529
+ }
4530
+
4531
+ if (toolName === 'board_post') {
4532
+ const fromSource = String(args.from_source || '').trim();
4533
+ const fromBody = String(args.from_body || '').trim();
4534
+ const to = String(args.to || 'all').trim();
4535
+ const topic = args.topic ? String(args.topic).trim() : null;
4536
+ const type = args.type || 'turn';
4537
+ const text = String(args.text || '').trim();
4538
+ const expiresAt = args.expires_at ? String(args.expires_at).trim() : null;
4539
+
4540
+ if (!fromSource) throw new Error('from_source is required');
4541
+ if (!fromBody) throw new Error('from_body is required');
4542
+ if (!text) throw new Error('text is required');
4543
+ if (text.length > 2000) throw new Error('text too long (max 2000 chars)');
4544
+
4545
+ const payload = {
4546
+ from_source: fromSource,
4547
+ from_body: fromBody,
4548
+ to,
4549
+ topic,
4550
+ type,
4551
+ text
4552
+ };
4553
+ if (expiresAt) payload.expires_at = expiresAt;
4554
+
4555
+ const result = await supabaseRequest(
4556
+ '/rest/v1/board_posts?select=id,from_source,from_body,to,topic,type,text,created_at',
4557
+ {
4558
+ method: 'POST',
4559
+ profile: DB_PROFILE,
4560
+ prefer: 'return=representation',
4561
+ body: [payload]
4562
+ }
4563
+ );
4564
+ const inserted = result?.[0] ?? null;
4565
+ return {
4566
+ ok: true,
4567
+ inserted,
4568
+ text_length: text.length
4569
+ };
4570
+ }
4571
+
4572
+ if (toolName === 'board_ack') {
4573
+ const body = String(args.body || '').trim();
4574
+ const ids = Array.isArray(args.ids) ? args.ids.map(id => String(id).trim()).filter(Boolean) : [];
4575
+ if (!body) throw new Error('body is required');
4576
+ if (!ids.length) throw new Error('ids is required (at least 1)');
4577
+
4578
+ const now = new Date().toISOString();
4579
+ let acked = 0;
4580
+ const notFoundIds = [];
4581
+ for (const id of ids) {
4582
+ // Read current acked_by, merge, write back
4583
+ const rows = await supabaseRequest(
4584
+ `/rest/v1/board_posts?id=eq.${id}&select=id,acked_by`,
4585
+ { profile: DB_PROFILE }
4586
+ );
4587
+ const row = rows?.[0];
4588
+ if (!row) { notFoundIds.push(id); continue; }
4589
+ const ackedBy = row.acked_by && typeof row.acked_by === 'object' ? { ...row.acked_by } : {};
4590
+ ackedBy[body] = now;
4591
+ await supabaseRequest(
4592
+ `/rest/v1/board_posts?id=eq.${id}`,
4593
+ {
4594
+ method: 'PATCH',
4595
+ profile: DB_PROFILE,
4596
+ prefer: 'return=minimal',
4597
+ body: { acked_by: ackedBy }
4598
+ }
4599
+ );
4600
+ acked++;
4601
+ }
4602
+ return { ok: true, acked, body, not_found_ids: notFoundIds };
4603
+ }
4604
+
4605
+ // board_resolve — mark posts as globally resolved (hidden for all bodies)
4606
+ if (toolName === 'board_resolve') {
4607
+ const body = String(args.body || '').trim();
4608
+ const ids = Array.isArray(args.ids) ? args.ids.map(id => String(id).trim()).filter(Boolean) : [];
4609
+ if (!body) throw new Error('body is required');
4610
+ if (!ids.length) throw new Error('ids is required (at least 1)');
4611
+
4612
+ const now = new Date().toISOString();
4613
+ let resolved = 0;
4614
+ const notFoundIds = [];
4615
+ for (const id of ids) {
4616
+ const rows = await supabaseRequest(
4617
+ `/rest/v1/board_posts?id=eq.${id}&select=id,resolved_at`,
4618
+ { profile: DB_PROFILE }
4619
+ );
4620
+ const row = rows?.[0];
4621
+ if (!row) { notFoundIds.push(id); continue; }
4622
+ if (row.resolved_at) continue; // already resolved
4623
+ await supabaseRequest(
4624
+ `/rest/v1/board_posts?id=eq.${id}`,
4625
+ {
4626
+ method: 'PATCH',
4627
+ profile: DB_PROFILE,
4628
+ prefer: 'return=minimal',
4629
+ body: { resolved_at: now }
4630
+ }
4631
+ );
4632
+ resolved++;
4633
+ }
4634
+ return { ok: true, resolved, body, not_found_ids: notFoundIds };
4635
+ }
4636
+
4637
+ // MARS-327: board_get_full — single post full text retrieval
4638
+ if (toolName === 'board_get_full') {
4639
+ const id = String(args.id || '').trim();
4640
+ if (!id) throw new Error('id is required');
4641
+
4642
+ const query = new URLSearchParams();
4643
+ query.set('id', `eq.${id}`);
4644
+ query.set('select', 'id,from_source,from_body,to,topic,type,text,acked_by,expires_at,archived_at,created_at');
4645
+ const rows = await supabaseRequest(`/rest/v1/board_posts?${query.toString()}`, { profile: DB_PROFILE });
4646
+ const post = rows?.[0];
4647
+ if (!post) throw new Error(`board post not found: ${id}`);
4648
+
4649
+ return {
4650
+ ok: true,
4651
+ post: {
4652
+ id: post.id,
4653
+ from: `${post.from_body}@${post.from_source}`,
4654
+ to: post.to,
4655
+ topic: post.topic || null,
4656
+ type: post.type,
4657
+ text: post.text || '',
4658
+ full_length: (post.text || '').length,
4659
+ created_at: post.created_at
4660
+ }
4661
+ };
4662
+ }
4663
+
4261
4664
  throw new Error(`Unknown tool: ${name}`);
4262
4665
  } finally {
4263
4666
  await writeToolUsageTelemetry({
@@ -4319,15 +4722,14 @@ function isPublicHost(host) {
4319
4722
  }
4320
4723
 
4321
4724
  function shouldRequireBearer(req) {
4322
- if (!REQUIRE_BEARER) return false;
4323
- const host = extractRequestHost(req);
4324
- if (BYPASS_BEARER_FOR_PRIVATE && isPrivateHost(host)) {
4325
- return false;
4326
- }
4327
- if (isPublicHost(host)) {
4328
- return true;
4329
- }
4330
- return true;
4725
+ // MARS-422: never trust client-supplied X-Forwarded-Host on edge traffic.
4726
+ // See lib/auth-guard.mjs and scripts/tests/test_auth_guard.mjs.
4727
+ return guardShouldRequireBearer({
4728
+ headers: req.headers,
4729
+ remoteAddress: req.socket?.remoteAddress,
4730
+ requireBearer: REQUIRE_BEARER,
4731
+ bypassForPrivate: BYPASS_BEARER_FOR_PRIVATE
4732
+ });
4331
4733
  }
4332
4734
 
4333
4735
  function oauthProtectedResourceMetadata(baseUrl) {