Domain-Aware Note URLs Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Return full domain-aware URLs for notes in both GraphQL (NoteView.url) and MCP search/similar results, preferring the first custom domain route if one exists.
Architecture: Add a reverse route index (customDomainRoutes map[int64]struct{Host,Path string}) to NoteViews, populated during RegisterNoteRoutes. A new ResolveFullURL(note, publicURL) method uses this index. MCP adds NoteURL(*model.NoteView) string to its Env interface; GraphQL resolver calls ResolveFullURL directly via the existing LatestNoteViews().
Tech Stack: Go, gqlgen (GraphQL code generation), moq (mock generation), Playwright (E2E)
Task 1: Add reverse route index + ResolveFullURL to NoteViews
Files:
-
Modify:
internal/model/note.go -
Test:
internal/model/note_test.go -
Step 1: Add the field to NoteViews struct
In internal/model/note.go, add "net/url" to imports (after "strings"):
import (
"fmt"
"html/template"
"net/url"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
rl2 "trip2g/internal/russkayalatinica2"
"unicode"
"github.com/yuin/goldmark/ast"
)
Add the field to NoteViews struct (after the DomainSitemaps field, around line 312):
// customDomainRoutes is a reverse index: noteID -> first custom domain route.
// Populated during RegisterNoteRoutes. Used by ResolveFullURL.
customDomainRoutes map[int64]struct{ Host, Path string }
- Step 2: Initialize the field in NewNoteViews
In NewNoteViews() (around line 964), add initialization:
func NewNoteViews() *NoteViews {
return &NoteViews{
Map: make(map[string]*NoteView),
PathMap: make(map[string]*NoteView),
Subgraphs: make(map[string]*NoteSubgraph),
RouteMap: make(map[string]map[string]*NoteView),
DomainSitemaps: make(map[string][]byte),
customDomainRoutes: make(map[int64]struct{ Host, Path string }),
}
}
- Step 3: Populate the reverse index in RegisterNoteRoutes
In RegisterNoteRoutes (around line 1248), add reverse index population inside the existing loop:
func (nv *NoteViews) RegisterNoteRoutes(note *NoteView) {
if len(note.Routes) == 0 {
return
}
if nv.RouteMap == nil {
nv.RouteMap = make(map[string]map[string]*NoteView)
}
for _, r := range note.Routes {
if nv.RouteMap[r.Host] == nil {
nv.RouteMap[r.Host] = make(map[string]*NoteView)
}
// Empty Path means "use note's own Permalink" (set when user writes "foo.com" without path).
path := r.Path
if path == "" {
path = note.Permalink
}
nv.RouteMap[r.Host][path] = note
// Reverse index: first custom domain route wins.
if r.Host != "" {
if _, exists := nv.customDomainRoutes[note.PathID]; !exists {
nv.customDomainRoutes[note.PathID] = struct{ Host, Path string }{r.Host, path}
}
}
}
}
- Step 4: Replace the ResolveURL stub with ResolveFullURL
Replace the stub (around line 978):
// ResolveFullURL returns the full URL for a note.
// If the note has a custom domain route, the first one is used with the scheme
// derived from publicURL. Otherwise falls back to publicURL + note.Permalink.
func (nv *NoteViews) ResolveFullURL(note *NoteView, publicURL string) string {
if r, ok := nv.customDomainRoutes[note.PathID]; ok {
u, _ := url.Parse(publicURL)
scheme := "https"
if u != nil && u.Scheme != "" {
scheme = u.Scheme
}
return scheme + "://" + r.Host + r.Path
}
return publicURL + note.Permalink
}
Keep the old ResolveURL method renamed or deleted — check for callers first:
grep -rn "ResolveURL\b" /home/alexes/projects2/trip2g --include="*.go"
If ResolveURL has no other callers, delete it.
- Step 5: Write failing tests
Add to internal/model/note_test.go:
func TestResolveFullURL(t *testing.T) {
const publicURL = "https://main.example.com"
t.Run("no custom domain returns publicURL+permalink", func(t *testing.T) {
nv := NewNoteViews()
note := &NoteView{PathID: 1, Permalink: "/my-note"}
require.Equal(t, "https://main.example.com/my-note", nv.ResolveFullURL(note, publicURL))
})
t.Run("first custom domain route is used", func(t *testing.T) {
nv := NewNoteViews()
note := &NoteView{
PathID: 2,
Permalink: "/my-note",
Routes: []ParsedRoute{
{Host: "custom.io", Path: "/custom-path"},
},
}
nv.RegisterNoteRoutes(note)
require.Equal(t, "https://custom.io/custom-path", nv.ResolveFullURL(note, publicURL))
})
t.Run("http scheme is preserved from publicURL", func(t *testing.T) {
nv := NewNoteViews()
note := &NoteView{
PathID: 3,
Permalink: "/note",
Routes: []ParsedRoute{{Host: "custom.io", Path: "/path"}},
}
nv.RegisterNoteRoutes(note)
require.Equal(t, "http://custom.io/path", nv.ResolveFullURL(note, "http://localhost:8081"))
})
t.Run("empty route path falls back to permalink", func(t *testing.T) {
nv := NewNoteViews()
note := &NoteView{
PathID: 4,
Permalink: "/note-permalink",
Routes: []ParsedRoute{{Host: "custom.io", Path: ""}},
}
nv.RegisterNoteRoutes(note)
require.Equal(t, "https://custom.io/note-permalink", nv.ResolveFullURL(note, publicURL))
})
t.Run("first of multiple custom domains wins", func(t *testing.T) {
nv := NewNoteViews()
note := &NoteView{
PathID: 5,
Permalink: "/note",
Routes: []ParsedRoute{
{Host: "first.io", Path: "/a"},
{Host: "second.io", Path: "/b"},
},
}
nv.RegisterNoteRoutes(note)
require.Equal(t, "https://first.io/a", nv.ResolveFullURL(note, publicURL))
})
}
- Step 6: Run tests — expect them to fail
cd /home/alexes/projects2/trip2g && go test ./internal/model/... -run TestResolveFullURL -v
Expected: FAIL (method doesn't exist yet — you wrote it in step 4, so actually this should pass now).
- Step 7: Run tests — verify they pass
cd /home/alexes/projects2/trip2g && go test ./internal/model/... -v
Expected: all model tests PASS.
- Step 8: Commit
git add internal/model/note.go internal/model/note_test.go
git commit -m "feat(model): add reverse route index and ResolveFullURL to NoteViews"
Task 2: Add NoteURL to MCP Env + update URL building
Files:
-
Modify:
internal/case/mcp/resolve.go -
Regenerate:
internal/case/mcp/mocks_test.go -
Modify:
internal/case/mcp/resolve_test.go -
Modify:
cmd/server/main.go -
Step 1: Add NoteURL to the MCP Env interface
In internal/case/mcp/resolve.go, add NoteURL to the Env interface (after PublicURL()):
type Env interface {
similarnotes.Env
model.FederationClientFactory
SearchLatestNotes(query string) ([]model.SearchResult, error)
LatestNoteChunks() []model.NoteChunk
OpenAI() *openai.Client
PublicURL() string
NoteURL(note *model.NoteView) string
Logger() logger.Logger
FederationSecretByKBURL(ctx context.Context, kbURL string) (db.FederationSecret, bool, error)
FederationSecretByKID(ctx context.Context, kid string) (db.FederationSecret, bool, error)
ListFederationSecretSubgraphsByKID(ctx context.Context, kid string) ([]string, error)
DecryptData([]byte) ([]byte, error)
FederationMaxDepth() int
}
- Step 2: Update searchResultItemFromNote signature
Change searchResultItemFromNote to accept a URL resolver func instead of a plain string:
func searchResultItemFromNote(note *model.NoteView, score float64, noteURL func(*model.NoteView) string) SearchResultItem {
item := SearchResultItem{
Title: note.Title,
NoteID: note.PathID,
NotePath: note.Path,
Href: note.Permalink,
URL: noteURL(note),
Kind: noteKind(note),
Score: score,
TOC: buildNoteTOC(note.Headings),
}
// ... rest of the function unchanged
- Step 3: Update buildSearchPayload signature and call site
Change buildSearchPayload:
func buildSearchPayload(query string, results []model.SearchResult, noteURL func(*model.NoteView) string, chunks []model.NoteChunk) SearchResultPayload {
payload := SearchResultPayload{Query: query}
for _, r := range results {
if r.NoteView == nil {
continue
}
item := searchResultItemFromNote(r.NoteView, r.Score, noteURL)
// ... rest unchanged
Update the call site (around line 339):
payload := buildSearchPayload(args.Query, results, env.NoteURL, env.LatestNoteChunks())
- Step 4: Update buildSimilarPayload and its call sites
Change buildSimilarPayload:
func buildSimilarPayload(sourceNote *model.NoteView, results []graphmodel.SimilarNote, noteURL func(*model.NoteView) string) SimilarResultPayload {
payload := SimilarResultPayload{
Source: searchResultItemFromNote(sourceNote, 1, noteURL),
}
for _, r := range results {
if r.Note == nil || r.Note.NoteView == nil {
continue
}
payload.Results = append(payload.Results, searchResultItemFromNote(r.Note.NoteView, r.Score, noteURL))
}
return payload
}
Update the text output line in resolveSimilar (around line 622):
sb.WriteString(fmt.Sprintf("%d. %s (%.2f)\n %s\n %s\n\n", i+1, note.Title, r.Score, note.Path, env.NoteURL(note)))
Update the buildSimilarPayload call (around line 628):
return successResponse(id, structuredToolResult(sb.String(), buildSimilarPayload(sourceNote, results, env.NoteURL)))
- Step 5: Implement NoteURL on app
In cmd/server/main.go, add after the PublicURL() method (around line 1501):
func (a *app) NoteURL(note *model.NoteView) string {
return a.LatestNoteViews().ResolveFullURL(note, a.config.PublicURL)
}
- Step 6: Verify the build compiles
cd /home/alexes/projects2/trip2g && go build ./...
Expected: compile errors in mocks_test.go because NoteURL is not yet in the mock. That's fine — the mock regeneration fixes it.
- Step 7: Regenerate the MCP mock
cd /home/alexes/projects2/trip2g/internal/case/mcp && go generate .
This regenerates mocks_test.go adding NoteURLFunc func(*model.NoteView) string and the NoteURL method.
- Step 8: Update existing tests to add NoteURLFunc
In internal/case/mcp/resolve_test.go, every EnvMock that tests search or similar results needs NoteURLFunc. There are 8 occurrences of PublicURLFunc.
For each EnvMock setup that had:
PublicURLFunc: func() string {
return "https://markavrelii.2pub.me"
},
Add alongside it:
NoteURLFunc: func(note *appmodel.NoteView) string {
return "https://markavrelii.2pub.me" + note.Permalink
},
(Use whichever base URL that test's PublicURLFunc returns.)
For test setups that use PublicURLFunc: func() string { return "https://bob.team.io/..." } or other URLs, use that same base URL in the NoteURLFunc.
- Step 9: Run MCP unit tests
cd /home/alexes/projects2/trip2g && go test ./internal/case/mcp/... -v
Expected: all PASS.
- Step 10: Commit
git add internal/case/mcp/resolve.go internal/case/mcp/mocks_test.go internal/case/mcp/resolve_test.go cmd/server/main.go
git commit -m "feat(mcp): use domain-aware NoteURL in search and similar results"
Task 3: Add url field to GraphQL NoteView
Files:
-
Modify:
internal/graph/schema.graphqls -
Modify:
internal/graph/schema.resolvers.go(after gqlgen) -
Step 1: Add the field to the schema
In internal/graph/schema.graphqls, add url to the NoteView type (after permalink):
type NoteView {
id: String!
path: String!
title: String!
content: String!
html: String!
permalink: String!
url: String! @goField(forceResolver: true)
free: Boolean!
# ... rest unchanged
- Step 2: Regenerate GraphQL code
cd /home/alexes/projects2/trip2g && make gqlgen
Expected: generates resolver stub func (r *noteViewResolver) URL(...) in schema.resolvers.go.
- Step 3: Implement the resolver
In internal/graph/schema.resolvers.go, find the generated stub and implement it:
// URL is the resolver for the url field.
func (r *noteViewResolver) URL(ctx context.Context, obj *appmodel.NoteView) (string, error) {
return r.env(ctx).LatestNoteViews().ResolveFullURL(obj, r.env(ctx).PublicURL()), nil
}
- Step 4: Build to verify
cd /home/alexes/projects2/trip2g && go build ./...
Expected: compiles without errors.
- Step 5: Commit
git add internal/graph/schema.graphqls internal/graph/schema.resolvers.go internal/graph/generated.go
git commit -m "feat(graphql): add url field to NoteView with domain-aware resolution"
Task 4: Add seedvault note with custom domain route
Files:
-
Create:
testdata/seedvault/mcp-url-test.md -
Step 1: Create the seed note
Create testdata/seedvault/mcp-url-test.md:
---
title: MCP URL Test Note
publish: true
free: true
route: customdomain.test/mcp-url-test
---
# MCP URL Test Note
This note is served on a custom domain for E2E URL resolution testing.
Unique phrase: xyzzy-mcp-url-test-sentinel.
The route: customdomain.test/mcp-url-test frontmatter means this note's URL should resolve to http://customdomain.test/mcp-url-test (http because the dev server uses http).
- Step 2: Commit
git add testdata/seedvault/mcp-url-test.md
git commit -m "test(seed): add note with custom domain route for URL E2E tests"
Task 5: E2E test — GraphQL NoteView.url
Files:
-
Create:
e2e/note-url.spec.js -
Step 1: Write the failing test
Create e2e/note-url.spec.js:
// @ts-check
/**
* E2E tests for NoteView.url field — verifies domain-aware URL resolution
* in GraphQL. A note with `route: customdomain.test/mcp-url-test` must return
* a url starting with http://customdomain.test/, not APP_URL.
*/
import { test, expect } from '@playwright/test';
import { graphqlSignIn, USER_TOKEN_COOKIE_NAME } from './helpers/auth.js';
const APP_URL = process.env.APP_URL || 'http://localhost:8081';
const NOTE_URL_QUERY = `
query {
notePaths(filter: { like: "mcp-url-test.md" }) {
latestNoteView {
path
permalink
url
}
}
}
`;
const REGULAR_NOTE_QUERY = `
query {
notePaths(filter: { like: "team-status.md" }) {
latestNoteView {
path
permalink
url
}
}
}
`;
test.describe('NoteView.url field', () => {
let cookie;
test.beforeAll(async ({ request }) => {
const jwt = await graphqlSignIn(request);
cookie = `${USER_TOKEN_COOKIE_NAME}=${jwt}`;
});
test('custom domain note url uses the custom domain', async ({ request }) => {
const res = await request.post(`${APP_URL}/graphql`, {
headers: { 'Content-Type': 'application/json', Cookie: cookie },
data: { query: NOTE_URL_QUERY },
});
expect(res.ok()).toBeTruthy();
const body = await res.json();
expect(body.errors).toBeUndefined();
const notePaths = body.data.notePaths;
expect(notePaths.length).toBeGreaterThan(0);
const noteView = notePaths[0].latestNoteView;
expect(noteView).not.toBeNull();
expect(noteView.url).toMatch(/^http:\/\/customdomain\.test\//);
expect(noteView.url).toBe('http://customdomain.test/mcp-url-test');
});
test('regular note url uses APP_URL', async ({ request }) => {
const res = await request.post(`${APP_URL}/graphql`, {
headers: { 'Content-Type': 'application/json', Cookie: cookie },
data: { query: REGULAR_NOTE_QUERY },
});
expect(res.ok()).toBeTruthy();
const body = await res.json();
expect(body.errors).toBeUndefined();
const notePaths = body.data.notePaths;
expect(notePaths.length).toBeGreaterThan(0);
const noteView = notePaths[0].latestNoteView;
expect(noteView).not.toBeNull();
expect(noteView.url).toMatch(new RegExp(`^${APP_URL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/`));
});
});
- Step 2: Run E2E tests (expect pass after server restart with new seed)
cd /home/alexes/projects2/trip2g && npm run test:e2e -- --grep "NoteView.url"
Expected: both tests PASS (requires the dev server to be running with the seedvault loaded).
- Step 3: Commit
git add e2e/note-url.spec.js
git commit -m "test(e2e): verify NoteView.url uses custom domain when route is set"
Task 6: E2E test — MCP search domain-aware URLs
Files:
-
Create:
e2e/mcp-url.spec.js -
Step 1: Write the failing test
Create e2e/mcp-url.spec.js:
// @ts-check
/**
* E2E tests for MCP search URL resolution.
* A note with `route: customdomain.test/mcp-url-test` must appear in
* search results with url = http://customdomain.test/mcp-url-test.
* Regular notes must have urls starting with APP_URL.
*
* JSON field names (from MCP types.go):
* SearchResultPayload: { query, results[] }
* SearchResultItem: { note_path, url, title, href, note_id, score }
* Response: { result: { content[], structuredContent } }
*/
import { test, expect } from '@playwright/test';
import { graphqlSignIn, createPersonalToken, revokePersonalToken, USER_TOKEN_COOKIE_NAME } from './helpers/auth.js';
const APP_URL = process.env.APP_URL || 'http://localhost:8081';
const MCP_URL = `${APP_URL}/_system/mcp`;
async function mcpCall(request, method, params = {}, token) {
const res = await request.post(MCP_URL, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
data: { jsonrpc: '2.0', id: 1, method, params },
});
expect(res.ok()).toBeTruthy();
return res.json();
}
// Returns SearchResultPayload: { query, results: [{ note_path, url, ... }] }
async function mcpSearch(request, query, token) {
const resp = await mcpCall(request, 'tools/call', {
name: 'search',
arguments: { query },
}, token);
expect(resp.error).toBeUndefined();
return resp.result?.structuredContent;
}
test.describe.serial('MCP search URL resolution', () => {
let adminRequest;
let adminCookie;
let token;
let tokenId;
test.beforeAll(async ({ playwright }) => {
adminRequest = await playwright.request.newContext({ baseURL: APP_URL });
const jwt = await graphqlSignIn(adminRequest);
adminCookie = `${USER_TOKEN_COOKIE_NAME}=${jwt}`;
const result = await createPersonalToken(adminRequest, APP_URL, adminCookie, {
name: 'e2e-mcp-url',
expiresInDays: 1,
});
token = result.plaintextToken;
tokenId = result.id;
// Initialize MCP session
await mcpCall(adminRequest, 'initialize', {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'e2e-test', version: '1' },
}, token);
});
test.afterAll(async () => {
if (tokenId) {
await revokePersonalToken(adminRequest, APP_URL, adminCookie, tokenId).catch(() => {});
}
await adminRequest?.dispose();
});
test('custom domain note appears with custom domain URL', async () => {
const payload = await mcpSearch(adminRequest, 'xyzzy-mcp-url-test-sentinel', token);
expect(payload).not.toBeNull();
expect(payload.results?.length).toBeGreaterThan(0);
const customNote = payload.results.find(r => r.note_path === 'mcp-url-test.md');
expect(customNote).toBeDefined();
expect(customNote.url).toBe('http://customdomain.test/mcp-url-test');
});
test('regular note appears with main domain URL', async () => {
const payload = await mcpSearch(adminRequest, 'Weekly team status', token);
expect(payload).not.toBeNull();
expect(payload.results?.length).toBeGreaterThan(0);
const regularNote = payload.results.find(r => r.note_path === 'team-status.md');
expect(regularNote).toBeDefined();
expect(regularNote.url).toMatch(new RegExp(`^${APP_URL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/`));
});
});
- Step 2: Run E2E tests
cd /home/alexes/projects2/trip2g && npm run test:e2e -- --grep "MCP search URL"
Expected: both tests PASS.
- Step 3: Commit
git add e2e/mcp-url.spec.js
git commit -m "test(e2e): verify MCP search returns custom domain URLs"