Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
dd5d3af
feat(share): parseShareFragment helper extracts k and t from URL hash
bfirestone Apr 30, 2026
24ad0c3
refactor(share): simplify parseShareFragment with || null
bfirestone Apr 30, 2026
19ff619
refactor(share): use parseShareFragment in page onMount
bfirestone Apr 30, 2026
33e15a4
feat(share): token-based isAuthor; auto-populate name from author URL
bfirestone Apr 30, 2026
5c3b012
fix(share): clarify empty-name fallback; use isAuthor for resolve loc…
bfirestone Apr 30, 2026
9a4bf3e
refactor(share): funnel author-only events through postAuthorEvent
bfirestone Apr 30, 2026
88e9877
feat(share): drop Sign in button; chip becomes rename affordance
bfirestone Apr 30, 2026
75fdab9
feat(share): NamePromptModal accepts initialName for rename flow
bfirestone Apr 30, 2026
d2d4e8d
feat(share): print share + author URLs from arc share create
bfirestone Apr 30, 2026
1e607b2
test(share): prefer strings.SplitSeq over Split for ranging
bfirestone Apr 30, 2026
4de273b
feat(share): arc share show --author-url reprints the author URL
bfirestone Apr 30, 2026
d7697da
docs(review): document share + author URLs, drop sign-in language
bfirestone Apr 30, 2026
717cf4b
docs(runbook): cover share+author URL manual scenarios
bfirestone Apr 30, 2026
f20a2ae
docs(share): correct --author flag help and resolveAuthor doc to toke…
bfirestone Apr 30, 2026
4270d13
fix(share): seed NamePromptModal name inside onMount to silence state…
bfirestone Apr 30, 2026
83a441f
feat(share): add author-only Share-link stamp in the header
bfirestone Apr 30, 2026
83f4961
feat(share): inline name capture in FloatingToolbar (replaces lazy mo…
bfirestone Apr 30, 2026
6dff735
feat(share): annotation retraction (delete) for original commenters
bfirestone Apr 30, 2026
1862c7e
fix(share): inline marks for multi-block annotations
bfirestone Apr 30, 2026
aeaf15f
fix(share): inline marks for blocks with internal multi-line structure
bfirestone Apr 30, 2026
f79a578
fix(share): inline marks for partial mid-block selections across stru…
bfirestone Apr 30, 2026
242bcae
fix(share): inline marks for <br> and block-boundary selections
bfirestone Apr 30, 2026
0c3a596
chore(web): allow Vite proxy backend override via ARC_PASTE_BACKEND
bfirestone Apr 30, 2026
29092fe
feat(share): tighten name-capture toolbar UX
bfirestone Apr 30, 2026
2b752f4
fix(share): triple-click selection no longer dismisses toolbar
bfirestone Apr 30, 2026
90b1ebd
fix: wrap replay events signature for lint
bfirestone Apr 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 88 additions & 11 deletions cmd/arc/share.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,21 @@ type editEvent struct {
CreatedAt string `json:"created_at"`
}

// retractionEvent is the original commenter taking their annotation back.
// Replay marks the target comment with status='retracted'; the printers
// and JSON bundle filter retracted entries out of the default output so
// downstream LLM consumers don't act on revoked material. The encrypted
// event remains in the log as audit. Only an event whose author_name
// matches the target's author_name takes effect — plan author canNOT
// retract someone else's comment (they have Reject for that).
type retractionEvent struct {
Kind string `json:"kind"` // always "retraction"
ID string `json:"id"`
CommentID string `json:"comment_id"`
AuthorName string `json:"author_name"`
CreatedAt string `json:"created_at"`
}

// --- commands ---

var shareCmd = &cobra.Command{
Expand Down Expand Up @@ -180,6 +195,7 @@ var (
shareCreateTitle string
shareCommentsAccepted bool
shareCommentsJSON bool
shareShowAuthorURL bool
)

func init() {
Expand All @@ -191,11 +207,15 @@ func init() {
shareCreateCmd.Flags().StringVar(&shareCreateAuthor, "author", "",
"Author name embedded in the plan (precedence: flag > share_author in "+
"cli-config.json > $ARC_SHARE_AUTHOR > `git config user.name`). "+
"Reviewers entering this exact name gain Accept/Resolve/Reject controls.")
"Auto-populates the name chip when the Author URL is opened and is "+
"used for display attribution. Author privileges are gated by the "+
"&t=<edit_token> in the Author URL, not by this name.")
shareCreateCmd.Flags().StringVar(&shareCreateTitle, "title", "",
"Optional plan title shown in the share UI header (defaults to the filename)")
shareCommentsCmd.Flags().BoolVar(&shareCommentsAccepted, "accepted-only", false, "Only print accepted comments")
shareCommentsCmd.Flags().BoolVar(&shareCommentsJSON, "json", false, "Output as JSON")
shareShowCmd.Flags().BoolVar(&shareShowAuthorURL, "author-url", false,
"Print the author URL (includes edit_token) instead of the plan content")

shareCmd.AddCommand(shareCreateCmd, shareListCmd, shareShowCmd, shareCommentsCmd,
sharePullCmd, shareApproveCmd, shareUpdateCmd, shareDeleteCmd)
Expand Down Expand Up @@ -254,8 +274,12 @@ func runShareCreate(cmd *cobra.Command, args []string) error {
}); err != nil {
return err
}
fmt.Printf("Share URL: %s/share/%s#k=%s\n", strings.TrimRight(server, "/"), resp.ID, keyB64)
fmt.Printf("Edit token: %s (saved in ~/.arc/shares.json — keep safe)\n", resp.EditToken)
trimmedServer := strings.TrimRight(server, "/")
fmt.Printf("Share URL (send to reviewers):\n %s/share/%s#k=%s\n\n",
trimmedServer, resp.ID, keyB64)
fmt.Printf("Author URL (keep private — gives you Accept/Resolve):\n %s/share/%s#k=%s&t=%s\n\n",
trimmedServer, resp.ID, keyB64, resp.EditToken)
fmt.Println("Edit token saved to ~/.arc/shares.json")
return nil
}

Expand All @@ -275,6 +299,9 @@ func runShareList(cmd *cobra.Command, args []string) error {
}

func runShareShow(cmd *cobra.Command, args []string) error {
if shareShowAuthorURL {
return printAuthorURL(args[0])
}
id, server, key, err := resolveShareRef(args[0])
if err != nil {
return err
Expand All @@ -287,6 +314,22 @@ func runShareShow(cmd *cobra.Command, args []string) error {
return nil
}

func printAuthorURL(ref string) error {
// Resolve to id; accept either bare id or full share URL.
id, _, _, err := resolveShareRef(ref)
if err != nil {
return err
}
s, _ := sharesconfig.Find(id)
if s == nil || s.EditToken == "" || s.KeyB64Url == "" {
return fmt.Errorf("no edit_token for share %s in ~/.arc/shares.json "+
"(--author-url requires a share registered on this machine)", id)
}
fmt.Printf("%s/share/%s#k=%s&t=%s\n",
strings.TrimRight(s.URL, "/"), id, s.KeyB64Url, s.EditToken)
return nil
}

func runShareComments(cmd *cobra.Command, args []string) error {
id, server, key, err := resolveShareRef(args[0])
if err != nil {
Expand Down Expand Up @@ -377,10 +420,11 @@ func runShareDelete(cmd *cobra.Command, args []string) error {
//
// Returns "" if none of these produce a value.
//
// The author name is the only thing that lets the share UI distinguish the
// plan owner from reviewers — when a visitor enters this exact name in the
// SPA's name prompt, they get Accept/Resolve/Reject controls. Without it,
// nobody is recognized as the author and the controls stay hidden for all.
// The author name is used for: (a) auto-populating the name chip when the
// Author URL is opened in the SPA, (b) display attribution on resolution and
// edit events, and (c) the replay-time guard in events.ts that drops forged
// edits/resolutions. Author UI privileges (Accept/Resolve/Reject) are gated
// by the &t=<edit_token> in the Author URL fragment, not by this name.
func resolveAuthor(flag string) string {
if s := strings.TrimSpace(flag); s != "" {
return s
Expand Down Expand Up @@ -563,8 +607,8 @@ func printComments(server, id string, key []byte, acceptedOnly, asJSON bool) err
return err
}
decoded := decodeAndSortEvents(events, key)
comments, resolutions := replayEvents(decoded, plan.AuthorName)
entries := buildCommentEntries(comments, resolutions, acceptedOnly)
comments, resolutions, retracted := replayEvents(decoded, plan.AuthorName)
entries := buildCommentEntries(comments, resolutions, retracted, acceptedOnly)
if asJSON {
return emitBundle(id, plan, entries)
}
Expand Down Expand Up @@ -609,9 +653,13 @@ func decodeAndSortEvents(events []paste.Event, key []byte) []decodedEvent {
return out
}

func replayEvents(events []decodedEvent, planAuthor string) (map[string]commentEvent, map[string]resolutionEvent) {
func replayEvents(
events []decodedEvent,
planAuthor string,
) (map[string]commentEvent, map[string]resolutionEvent, map[string]bool) {
comments := map[string]commentEvent{}
resolutions := map[string]resolutionEvent{}
retracted := map[string]bool{}
for _, d := range events {
switch d.kind {
case "comment":
Expand All @@ -620,9 +668,31 @@ func replayEvents(events []decodedEvent, planAuthor string) (map[string]commentE
applyResolutionEvent(d.raw, planAuthor, resolutions)
case "edit":
applyEditEvent(d.raw, planAuthor, comments)
case "retraction":
applyRetractionEvent(d.raw, comments, retracted)
}
}
return comments, resolutions
return comments, resolutions, retracted
}

// applyRetractionEvent marks the target comment as retracted iff the event's
// author_name matches the comment's original author_name. Plan author canNOT
// retract someone else's comment — they have Reject (with reply) for that
// purpose. Forged retractions are silently dropped, matching the edit-event
// authorization model.
func applyRetractionEvent(raw json.RawMessage, comments map[string]commentEvent, retracted map[string]bool) {
var r retractionEvent
if err := json.Unmarshal(raw, &r); err != nil {
return
}
target, ok := comments[r.CommentID]
if !ok {
return
}
if r.AuthorName != target.AuthorName {
return
}
retracted[r.CommentID] = true
}

func applyCommentEvent(raw json.RawMessage, comments map[string]commentEvent) {
Expand Down Expand Up @@ -680,12 +750,19 @@ func applyEditEvent(raw json.RawMessage, planAuthor string, comments map[string]
func buildCommentEntries(
comments map[string]commentEvent,
resolutions map[string]resolutionEvent,
retracted map[string]bool,
acceptedOnly bool,
) []commentEntry {
// Build a deterministic-order list of comment entries so multiple runs
// produce identical output (Go map iteration is randomized).
entries := make([]commentEntry, 0, len(comments))
for cid, c := range comments {
// Retracted comments are filtered from default output; the encrypted
// event still lives in the log for audit but downstream LLM consumers
// shouldn't see (and act on) revoked material.
if retracted[cid] {
continue
}
status := "open"
reply := ""
if res, ok := resolutions[cid]; ok {
Expand Down
176 changes: 176 additions & 0 deletions cmd/arc/share_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,77 @@ func TestRunShareCommentsRoundTrip(t *testing.T) {
}
}

// TestRunShareCommentsHidesRetracted verifies that a retraction event from the
// comment's original author removes the comment from `arc share comments`
// default output, while a forged retraction (mismatched author_name) is
// silently dropped at replay time. The encrypted retraction event remains in
// the log for audit.
func TestRunShareCommentsHidesRetracted(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
srv := startTestPasteServer(t)
defer srv.Close()

plan := filepath.Join(t.TempDir(), "p.md")
_ = os.WriteFile(plan, []byte("# P"), 0o600)
shareCreateServer = srv.URL
if err := runShareCreate(shareCreateCmd, []string{plan}); err != nil {
t.Fatal(err)
}
f, _ := sharesconfig.Load()
s := f.Shares[0]
keyBytes := mustDecodeKey(t, s.KeyB64Url)

postEv := func(t *testing.T, payload map[string]any) {
t.Helper()
blob, iv, err := paste.EncryptJSON(payload, keyBytes)
if err != nil {
t.Fatalf("encrypt: %v", err)
}
body, _ := json.Marshal(paste.AppendEventRequest{Blob: blob, IV: iv})
if err := postRaw(t, srv.URL+"/api/paste/"+s.ID+"/blobs", body); err != nil {
t.Fatal(err)
}
}

// Two comments: Alice's (which she'll retract) and Bob's (which she can't).
postEv(t, map[string]any{
"kind": "comment", "id": "c1", "author_name": "Alice", "comment_type": "comment",
"body": "regret posting this",
"anchor": map[string]any{"line_start": 1, "line_end": 1, "quoted_text": "P"},
"created_at": "2026-04-29T00:00:00Z",
})
postEv(t, map[string]any{
"kind": "comment", "id": "c2", "author_name": "Bob", "comment_type": "comment",
"body": "stays visible",
"anchor": map[string]any{"line_start": 1, "line_end": 1, "quoted_text": "P"},
"created_at": "2026-04-29T00:01:00Z",
})

// Alice retracts her own — must take effect.
postEv(t, map[string]any{
"kind": "retraction", "id": "x1", "comment_id": "c1", "author_name": "Alice",
"created_at": "2026-04-29T00:02:00Z",
})
// Mallory tries to retract Bob's — must be silently dropped.
postEv(t, map[string]any{
"kind": "retraction", "id": "x2", "comment_id": "c2", "author_name": "Mallory",
"created_at": "2026-04-29T00:03:00Z",
})

out := captureStdout(t, func() { _ = runShareComments(shareCommentsCmd, []string{s.ID}) })

if strings.Contains(out, "regret posting this") {
t.Errorf("retracted comment must be hidden; got:\n%s", out)
}
if !strings.Contains(out, "stays visible") {
t.Errorf("forged retraction should not have removed Bob's comment; got:\n%s", out)
}
if !strings.Contains(out, "Bob") {
t.Errorf("expected Bob's comment to still be present; got:\n%s", out)
}
}

// TestRunShareCommentsAppliesEdits verifies that an `edit` event from the
// comment's original author rewrites the body shown by `arc share comments`.
// This locks in CLI parity with the SPA's replay logic.
Expand Down Expand Up @@ -516,6 +587,57 @@ func captureStdout(t *testing.T, fn func()) string {
// global `configPath` at a temp file, and from the user's git identity by
// running each subtest with $PATH cleared so `git` is unavailable (the
// helper falls back silently to "" on git failure).
func TestShareCreatePrintsBothURLs(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
srv := startTestPasteServer(t)
defer srv.Close()

plan := filepath.Join(t.TempDir(), "plan.md")
_ = os.WriteFile(plan, []byte("# Hello\n\nBody."), 0o600)

// Capture stdout.
r, w, _ := os.Pipe()
stdout := os.Stdout
os.Stdout = w
defer func() { os.Stdout = stdout }()

shareCreateServer = srv.URL
if err := runShareCreate(shareCreateCmd, []string{plan}); err != nil {
t.Fatalf("runShareCreate: %v", err)
}
_ = w.Close()
out, _ := io.ReadAll(r)
output := string(out)

if !strings.Contains(output, "Share URL") {
t.Errorf("expected 'Share URL' label, got: %s", output)
}
if !strings.Contains(output, "Author URL") {
t.Errorf("expected 'Author URL' label, got: %s", output)
}
if !strings.Contains(output, "send to reviewers") {
t.Errorf("expected reviewer guidance, got: %s", output)
}
if !strings.Contains(output, "keep private") {
t.Errorf("expected privacy guidance for author URL, got: %s", output)
}
if strings.Contains(output, "Edit token: ") {
t.Errorf("raw 'Edit token: <hex>' line should not appear in output: %s", output)
}
if !strings.Contains(output, "Edit token saved to") {
t.Errorf("expected pointer to shares.json, got: %s", output)
}

// Author URL must contain &t=.
for line := range strings.SplitSeq(output, "\n") {
if strings.Contains(line, "/share/") && strings.Contains(line, "&t=") {
return
}
}
t.Errorf("expected an author URL line with &t=<token>, got: %s", output)
}

func TestResolveAuthor(t *testing.T) {
// Save & restore globals touched by the helper. Env vars use t.Setenv
// which auto-restores; configPath is package-global so we restore manually.
Expand Down Expand Up @@ -660,6 +782,60 @@ func TestResolveServer(t *testing.T) {
}
}

func TestShareShowAuthorURL(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
srv := startTestPasteServer(t)
defer srv.Close()

// Create a share so shares.json has an entry.
plan := filepath.Join(t.TempDir(), "plan.md")
_ = os.WriteFile(plan, []byte("# Hi"), 0o600)
shareCreateServer = srv.URL
if err := runShareCreate(shareCreateCmd, []string{plan}); err != nil {
t.Fatal(err)
}
f, _ := sharesconfig.Load()
id := f.Shares[0].ID
editToken := f.Shares[0].EditToken
keyB64 := f.Shares[0].KeyB64Url

// Capture stdout for `arc share show <id> --author-url`.
r, w, _ := os.Pipe()
stdout := os.Stdout
os.Stdout = w
defer func() { os.Stdout = stdout }()

shareShowAuthorURL = true
defer func() { shareShowAuthorURL = false }()
if err := runShareShow(shareShowCmd, []string{id}); err != nil {
t.Fatalf("runShareShow: %v", err)
}
_ = w.Close()
out, _ := io.ReadAll(r)
got := strings.TrimSpace(string(out))

want := srv.URL + "/share/" + id + "#k=" + keyB64 + "&t=" + editToken
if got != want {
t.Errorf("author URL mismatch:\n got: %q\nwant: %q", got, want)
}
}

func TestShareShowAuthorURLMissingShare(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)

shareShowAuthorURL = true
defer func() { shareShowAuthorURL = false }()
err := runShareShow(shareShowCmd, []string{"abc12345"})
if err == nil {
t.Fatal("expected error for missing share, got nil")
}
if !strings.Contains(err.Error(), "abc12345") {
t.Errorf("error should reference share id, got: %v", err)
}
}

// writeShareServerConfig points the global configPath at a temp cli-config.json
// containing the given share_server (omitted entirely when empty).
func writeShareServerConfig(t *testing.T, server string) {
Expand Down
Loading
Loading