Skip to content

Gate note ledger links by owner role - #1826

Merged
stefan-burke merged 4 commits into
mainfrom
split/owner-note-links
Jul 14, 2026
Merged

Gate note ledger links by owner role#1826
stefan-burke merged 4 commits into
mainfrom
split/owner-note-links

Conversation

@stefan-burke

@stefan-burke stefan-burke commented Jul 14, 2026

Copy link
Copy Markdown
Member

A stored note can carry a markdown link to the ledger. The refund notes do, for example. But the ledger pages are owner-only, so every other admin saw a link that only 404s when they clicked it.

What changes

  • A note's ledger link now shows as plain words for anyone who isn't an owner. Owners still get the link. A rendered link is a promise that it works, so a viewer who can't open it gets the words instead.
  • This applies everywhere a note body renders: the attendee banner, the notes summary above the attendee list, the notes summary on the listing Overview and Attendees (roster) tabs, and the delete-note confirmation page.

How the link is stripped

The earlier approach was a regex that only matched inline [text](url) links. It missed reference links ([text][ref]), collapsed references, shortcuts, automatic links (<https://…>), and links nested inside lists, blockquotes, or table cells.

This PR replaces it with a token-aware Markdown walker (withoutLinksTo in src/shared/markdown.ts). It parses the note with marked's lexer, walks the token tree, and rewrites each link whose target starts with the forbidden prefix (here, /admin/ledger) back to its plain text — stitching the source back together byte-for-byte so all the safe markdown around it (list markers, table pipes, quote markers, code spans) is preserved. A code span that happens to look like a link is left untouched, and the markdown structure never breaks.

Scope

In scope for this PR (PR 5 of the staged-checkout split):

  • src/shared/markdown.ts — the token-aware withoutLinksTo helper.
  • src/ui/templates/admin/attendee-notes.tsxNoteBody takes an isOwner flag; NoteBox, AttendeeNotesSection, AttendeeNotesSummary, and the delete-note page thread it through.
  • Owner-role plumbing through the attendee page banner, the attendees list, and the listing Overview + roster panels.
  • Tests: withoutLinksTo unit tests covering every link form and nesting, plus note/banner rendering tests that assert owners keep the link and non-owners get plain text.

Out of scope (handled by other PRs in the split, not brought in here): deleted-listing display, pending-checkout UI, held-cash actions, and other attendee-page changes from the mixed source commits.

Acceptance

  • Every rendered link is reachable by the viewer's role.
  • Owners retain the ledger links.
  • Non-owners retain readable note text and formatting without a dead link.

Summary by CodeRabbit

  • Access Control

    • Owner-level administrators can continue to open ledger links from attendee notes.
    • Non-owner administrators now consistently see ledger references as plain text (not clickable), including on attendee pages and admin listing panels.
  • Bug Fixes

    • Improved owner-aware rendering for attendee system notes, aligning behavior across notes views and note deletion confirmations.
    • Enhanced markdown processing to reliably demote disallowed ledger links without breaking existing formatting (including tables and complex markdown).
  • Tests

    • Expanded markdown and UI test coverage for owner vs non-owner rendering across inline and complex contexts (lists, quotes, tables).

A stored note can carry a markdown link to the ledger (the refund notes
do), but the ledger pages are owner-only — every other admin saw a link
that only 404s. Note bodies now demote an /admin/ledger link to its
plain text for non-owners, threaded through the attendee banner, the
notes summaries above the attendee/listing lists, and the delete-note
page. Owners keep the link.

The link-stripping uses a token-aware Markdown walker (withoutLinksTo)
rather than a regex, so it handles inline, reference, collapsed-
reference, shortcut, and automatic links, including links nested in
lists, blockquotes, tables, and code spans — preserving all the safe
markdown around them. A code span that looks like a link is left alone,
and the markdown structure (list markers, table pipes, quote markers)
is preserved byte-for-byte.
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Admin attendee and listing views now propagate owner state into note rendering. Non-owners see ledger references as plain text, while owners retain links. A token-aware markdown helper preserves surrounding formatting, with tests covering link forms and nested structures.

Changes

Owner-only ledger link rendering

Layer / File(s) Summary
Markdown link demotion
src/shared/markdown.ts, test/shared/markdown.test.ts
Adds withoutLinksTo to unwrap matching links while preserving markdown source formatting across nested structures and multiple link syntaxes.
Owner-aware note rendering
src/ui/templates/admin/attendee-notes.tsx, src/ui/templates/admin/attendee-page.tsx, test/ui/templates/admin/*
Threads isOwner through note sections, summaries, banners, and delete confirmation rendering, with owner and non-owner ledger-link assertions.
Owner state propagation
src/features/admin/..., src/ui/templates/admin/attendees-list.tsx, src/ui/templates/admin/listings/*
Derives owner state from session or ledger visibility and passes it through attendee and listing templates.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AdminRequest
  participant attendeePage
  participant attendeeBanner
  participant AttendeeNotesSection
  participant withoutLinksTo
  AdminRequest->>attendeePage: load attendee data
  attendeePage->>attendeeBanner: pass isOwner
  attendeeBanner->>AttendeeNotesSection: pass notes and isOwner
  AttendeeNotesSection->>withoutLinksTo: rewrite ledger links for non-owners
  withoutLinksTo-->>AttendeeNotesSection: return rendered note markdown
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: owner-based gating of ledger links in notes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch split/owner-note-links

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/features/admin/attendee-page.ts (1)

199-208: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Security Misconfiguration (CWE-863): Incorrect Authorization

Reachability: Internal

Inline owner check duplicates the shared isOwnerRole helper.

This call computes isOwner via ctx.session.adminLevel === "owner", while the sibling loader in src/features/admin/listing-page-data.ts uses the shared isOwnerRole(ctx.session.adminLevel) helper for the same concept. See the consolidated comment for the cross-file recommendation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/admin/attendee-page.ts` around lines 199 - 208, Update the
banner loader’s isOwner value in attendeeBanner to use the shared isOwnerRole
helper with ctx.session.adminLevel, matching the sibling loader’s established
owner-role logic and removing the inline comparison.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/shared/markdown.ts`:
- Around line 145-155: Update rewriteTable to tolerate cells whose normalized
text cannot be located in token.raw, as happens with escaped pipes. Remove the
assertion that requires every locateSourceParts index to be nonnegative, and
return the original raw table or otherwise skip unmatched cells while preserving
rewriting for cells with valid source positions.

In `@src/ui/templates/admin/attendee-notes.tsx`:
- Line 270: Update the NoteBody invocation in the attendee-notes component to
compute isOwner with the shared isOwnerRole(session.adminLevel) helper,
importing isOwnerRole from `#shared/types.ts` as needed, instead of comparing
adminLevel to "owner" inline.

---

Outside diff comments:
In `@src/features/admin/attendee-page.ts`:
- Around line 199-208: Update the banner loader’s isOwner value in
attendeeBanner to use the shared isOwnerRole helper with ctx.session.adminLevel,
matching the sibling loader’s established owner-role logic and removing the
inline comparison.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a566c025-f51d-4a82-87ad-4d777e5539e1

📥 Commits

Reviewing files that changed from the base of the PR and between 6e53f1c and 5470b83.

📒 Files selected for processing (12)
  • src/features/admin/attendee-page.ts
  • src/features/admin/listing-page-data.ts
  • src/shared/markdown.ts
  • src/ui/templates/admin/attendee-notes.tsx
  • src/ui/templates/admin/attendee-page.tsx
  • src/ui/templates/admin/attendees-list.tsx
  • src/ui/templates/admin/listings/overview.tsx
  • src/ui/templates/admin/listings/roster.tsx
  • src/ui/templates/admin/listings/types.ts
  • test/shared/markdown.test.ts
  • test/ui/templates/admin/attendee-notes.test.tsx
  • test/ui/templates/admin/attendee-page.test.ts

Comment thread src/shared/markdown.ts Outdated
Comment thread src/ui/templates/admin/attendee-notes.tsx Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5470b83875

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/ui/templates/admin/attendee-notes.tsx Outdated
Comment thread src/shared/markdown.ts
…D clone

Address four unresolved review threads on PR #1826:

1. Escaped-pipe table crash (CodeRabbit + Codex): marked normalizes
   backslash-escaped pipes (\| to |) in cell.text, so locating that
   normalized text against token.raw misses — the assert threw and the
   whole note 500'd. Removed the assert and filter to matched cells
   (same gap-preserving pattern rewriteChildren already uses), so the
   table still renders and links in matched cells are still demoted.

2. Inline isOwner check (CodeRabbit): attendees-notes.tsx and the
   attendee-page banner handler both reimplemented isOwnerRole as a
   raw string comparison. Both now call the shared isOwnerRole helper.

3. Attendee ledger-tab links (Codex): PaymentDetails links to
   /admin/attendees/:id/ledger (the owner-only ledger tab), but the
   filter only caught /admin/ledger. Generalized withoutLinksTo to
   accept a predicate (string | function) so isOwnerOnlyLink catches
   both URL shapes a note might carry.

4. CPD clone (CI): the { notes, isOwner } prop pair was repeated in
   AttendeeNotesSection and attendeeBanner. Extracted NotesViewProps
   as the shared type; Section uses it directly, Summary and Banner
   extend it. The 19-token clone between attendee-notes.tsx and
   attendee-page.tsx is eliminated.

Regression tests: escaped-pipe table no longer crashes (exact output
assertion), predicate matcher catches the attendee ledger-tab URL, and
the notes section demotes the attendee tab link for non-owners while
owners keep it.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/features/admin/attendee-page.ts (1)

200-209: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

isOwner: ctx.session.adminLevel === "owner" should use isOwnerRole(), same as line 189 in this file.

This inline comparison is the exact pattern already fixed at line 189 (ContactHistory's isOwner) via isOwnerRole(ctx.session.adminLevel). The banner loader's isOwner at line 203 still re-derives the check inline, so the two owner-checks in this file can silently diverge if isOwnerRole's definition ever changes.

♻️ Suggested fix
   banner: async ({ attendee }, ctx) =>
     attendeeBanner({
       attendee,
-      isOwner: ctx.session.adminLevel === "owner",
+      isOwner: isOwnerRole(ctx.session.adminLevel),
       notes: await getNotesForAttendee(
         attendee.id,
         await requireRequestPrivateKey(),
       ),
       statuses: await attendeeStatuses.getAll(),
     }),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/admin/attendee-page.ts` around lines 200 - 209, Update the
isOwner field in the banner loader to call isOwnerRole(ctx.session.adminLevel),
matching the existing ContactHistory owner check, and remove the inline
admin-level comparison.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/shared/markdown.ts`:
- Around line 191-196: Remove the redundant assert in rewriteToken’s link branch
and pass the required token.tokens directly to rewriteChildren. Keep the
existing linkMatches check and rewriting behavior unchanged.

---

Outside diff comments:
In `@src/features/admin/attendee-page.ts`:
- Around line 200-209: Update the isOwner field in the banner loader to call
isOwnerRole(ctx.session.adminLevel), matching the existing ContactHistory owner
check, and remove the inline admin-level comparison.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 5d9d570c-fe21-4e31-8449-780e2c21f424

📥 Commits

Reviewing files that changed from the base of the PR and between 5470b83 and de9876b.

📒 Files selected for processing (6)
  • src/features/admin/attendee-page.ts
  • src/shared/markdown.ts
  • src/ui/templates/admin/attendee-notes.tsx
  • src/ui/templates/admin/attendee-page.tsx
  • test/shared/markdown.test.ts
  • test/ui/templates/admin/attendee-notes.test.tsx

Comment thread src/shared/markdown.ts

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: de9876ba3b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/shared/markdown.ts
// still demoted.
return rewriteSourceParts(
token.raw,
parts.filter(({ index }) => index >= 0),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Demote links in skipped table cells

When the escaped-pipe cell itself contains the ledger link, cell.text no longer occurs in token.raw (\| is normalized to |), so this filter drops that cell and rewriteSourceParts copies its raw markdown unchanged. For a non-owner viewing a note table like | [ledger](/admin/ledger/x)\|copy |, the owner-only link still renders as an <a>, which breaks the PR goal that forbidden note links are demoted everywhere.

Useful? React with 👍 / 👎.

Comment on lines +65 to +66
href.startsWith("/admin/ledger") ||
(href.startsWith("/admin/attendees/") && href.includes("/ledger"));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Match full same-site ledger URLs

When an operator stores a full URL copied from the browser, for example https://tickets.example/admin/ledger/attendee/5 or the attendee ledger tab URL, this predicate leaves it untouched because it only matches root-relative hrefs. renderMarkdown permits https: links, while these ledger pages are owner-only, so managers can still see a clickable forbidden link instead of plain note text whenever the note stores the absolute URL.

Useful? React with 👍 / 👎.

Comment thread src/shared/markdown.ts
if (token.type === "link") {
if (!linkMatches(matcher, token.href)) return token.raw;
assert(token.tokens, "Markdown link has no parsed text");
return rewriteChildren(token.text, token.tokens, matcher);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent demoted URL text from being re-linked

When the forbidden link's label is itself a URL, this returns that URL as bare markdown and NoteBody immediately passes it through renderMarkdown, where Marked auto-links bare https://... text again. For a non-owner note like [https://tickets.example/admin/ledger/attendee/5](/admin/ledger/attendee/5), the href matches and is demoted here, but the final HTML still contains a clickable owner-only ledger link.

Useful? React with 👍 / 👎.

…ormatting

CodeRabbit flagged that Tokens.Link.tokens is declared as required in
marked 18.x (tokens: Token[], not tokens?: Token[]), so the
assert(token.tokens, ...) guard could never fire — it only added an
impossible throw path. Removed the assert and pass token.tokens straight
through to rewriteChildren. The assert was also the only @std/assert
usage, so that import is gone too.

Also applies Biome's formatting fixes from deno task lint: import order
in attendee-notes.tsx (type imports first), the NotesViewProps type
annotation in attendee-page.tsx, and trailing whitespace in
attendee-page.ts and markdown.test.ts.
@stefan-burke
stefan-burke enabled auto-merge July 14, 2026 21:02
@stefan-burke
stefan-burke added this pull request to the merge queue Jul 14, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/shared/markdown.ts (1)

145-159: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Authorization Bypass (CWE-863): Incorrect Authorization

Reachability: External
● Entry
  src/ui/templates/admin/attendee-notes.tsx:75
  withoutLinksTo
│
▼
● Sink
  src/shared/markdown.ts

Handle escaped pipes in table cells before preserving the raw cell. rewriteTable() skips any unmatched cell, so a forbidden link in the same cell as an escaped pipe can survive unchanged and stay clickable for non-owners. Parse table cells from the raw source or demote links in unmatched cells too, and add a regression test for a link + escaped pipe in the same cell.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/shared/markdown.ts` around lines 145 - 159, The rewriteTable function
must handle table cells containing escaped pipes instead of skipping them as
unmatched raw cells. Update its source-part parsing or fallback path so links in
every unmatched cell are still passed through rewriteToken and demoted, while
preserving escaped-pipe content; add a regression test covering a forbidden link
and escaped pipe in the same cell.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/shared/markdown.ts`:
- Around line 145-159: The rewriteTable function must handle table cells
containing escaped pipes instead of skipping them as unmatched raw cells. Update
its source-part parsing or fallback path so links in every unmatched cell are
still passed through rewriteToken and demoted, while preserving escaped-pipe
content; add a regression test covering a forbidden link and escaped pipe in the
same cell.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: dfb3bf5c-3f22-4cfd-8566-5a708c836128

📥 Commits

Reviewing files that changed from the base of the PR and between de9876b and 0411204.

📒 Files selected for processing (5)
  • src/features/admin/attendee-page.ts
  • src/shared/markdown.ts
  • src/ui/templates/admin/attendee-notes.tsx
  • src/ui/templates/admin/attendee-page.tsx
  • test/shared/markdown.test.ts

Merged via the queue into main with commit 3afeca0 Jul 14, 2026
3 checks passed
@stefan-burke
stefan-burke deleted the split/owner-note-links branch July 14, 2026 21:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant