Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
121 changes: 121 additions & 0 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ tags:
description: AI session and agent observability
- name: plans
description: Ephemeral plan review artifacts
- name: shares
description: Author-side keyring of paste shares created on this machine

paths:
# ====================
Expand Down Expand Up @@ -968,6 +970,83 @@ paths:
"500":
$ref: "#/components/responses/InternalError"

# ====================
# Shares (author keyring)
# ====================
/shares:
get:
operationId: listShares
tags: [shares]
summary: List authored shares from the local keyring
responses:
"200":
description: List of shares (newest first)
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Share"
"500":
$ref: "#/components/responses/InternalError"

post:
operationId: upsertShare
tags: [shares]
summary: Insert or replace a share keyring entry
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpsertShareRequest"
responses:
"200":
description: Share stored
content:
application/json:
schema:
$ref: "#/components/schemas/Share"
"400":
$ref: "#/components/responses/BadRequest"
"500":
$ref: "#/components/responses/InternalError"

/shares/{shareId}:
parameters:
- name: shareId
in: path
required: true
description: Share ID (server-generated by the paste host)
schema:
type: string

get:
operationId: getShare
tags: [shares]
summary: Get a single share keyring entry
responses:
"200":
description: Share record
content:
application/json:
schema:
$ref: "#/components/schemas/Share"
"404":
$ref: "#/components/responses/NotFound"
"500":
$ref: "#/components/responses/InternalError"

delete:
operationId: deleteShare
tags: [shares]
summary: Remove a share from the keyring (idempotent)
responses:
"204":
description: Share removed (or absent — same response)
"500":
$ref: "#/components/responses/InternalError"

# ====================
# Issue-Label Associations (project-scoped)
# ====================
Expand Down Expand Up @@ -1854,6 +1933,48 @@ components:
label:
type: string

# ====================
# Share Schemas
# ====================
Share:
type: object
required: [id, kind, url, key_b64url, edit_token, created_at]
properties:
id:
type: string
kind:
$ref: "#/components/schemas/ShareKind"
url:
type: string
key_b64url:
type: string
edit_token:
type: string
plan_file:
type: string
created_at:
type: string
format: date-time
ShareKind:
type: string
enum: [local, shared]
UpsertShareRequest:
type: object
required: [id, kind, url, key_b64url, edit_token]
properties:
id:
type: string
kind:
$ref: "#/components/schemas/ShareKind"
url:
type: string
key_b64url:
type: string
edit_token:
type: string
plan_file:
type: string

# ====================
# Comment Schemas
# ====================
Expand Down
8 changes: 8 additions & 0 deletions cmd/arc/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/fatih/color"
"github.com/sentiolabs/arc/internal/client"
"github.com/sentiolabs/arc/internal/project"
"github.com/sentiolabs/arc/internal/sharesconfig"
"github.com/sentiolabs/arc/internal/types"
"github.com/sentiolabs/arc/internal/version"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -331,6 +332,13 @@ func init() {
rootCmd.PersistentFlags().BoolVar(&outputJSON, "json", false, "Output as JSON")
rootCmd.PersistentFlags().StringVar(&configPath, "config", "", "Config file path")

// Wire sharesconfig to talk to arc-server over HTTP. The factory is
// invoked lazily so flag/env/config resolution happens at command time,
// not at process start.
sharesconfig.SetClientFactory(func() (sharesconfig.Client, error) {
return getClient()
})

// Add commands
rootCmd.AddCommand(projectCmd)
rootCmd.AddCommand(listCmd)
Expand Down
28 changes: 14 additions & 14 deletions cmd/arc/share.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,16 +163,16 @@ var shareApproveCmd = &cobra.Command{

var shareUpdateCmd = &cobra.Command{
Use: "update <id-or-url> <plan-file>",
Short: "Replace the encrypted plan content (uses edit_token from shares.json)",
Short: "Replace the encrypted plan content (uses the edit_token from the local arc keyring)",
// `update` takes exactly the share ref AND the plan file path.
Args: cobra.ExactArgs(shareUpdateArgCount),
RunE: runShareUpdate,
}

const shareUpdateArgCount = 2

// shareKindLocal / shareKindShared label the resolved server in the saved
// shares.json registry and surface in `arc share list` output.
// shareKindLocal / shareKindShared label the resolved server in the local
// arc keyring and surface in `arc share list` output.
const (
shareKindLocal = "local"
shareKindShared = "shared"
Expand All @@ -182,7 +182,7 @@ const defaultShareServer = "https://arcplanner.sentiolabs.io"

var shareDeleteCmd = &cobra.Command{
Use: "delete <id-or-url>",
Short: "Delete a share (uses edit_token from shares.json)",
Short: "Delete a share (uses the edit_token from the local arc keyring)",
Args: cobra.ExactArgs(1),
SilenceUsage: true,
RunE: runShareDelete,
Expand Down Expand Up @@ -283,7 +283,7 @@ func runShareCreate(cmd *cobra.Command, args []string) error {
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")
fmt.Println("Edit token saved to the local arc keyring")
return nil
}

Expand Down Expand Up @@ -326,7 +326,7 @@ func printAuthorURL(ref string) error {
}
s, _ := sharesconfig.Find(id)
if s == nil || s.EditToken == "" || s.KeyB64Url == "" {
return fmt.Errorf("no edit_token for share %s in ~/.arc/shares.json "+
return fmt.Errorf("no edit_token for share %s in the local arc keyring "+
"(--author-url requires a share registered on this machine)", id)
}
fmt.Printf("%s/share/%s#k=%s&t=%s\n",
Expand Down Expand Up @@ -384,7 +384,7 @@ func runShareUpdate(cmd *cobra.Command, args []string) error {
}
s, _ := sharesconfig.Find(id)
if s == nil || s.EditToken == "" {
return fmt.Errorf("no edit_token for share %s in ~/.arc/shares.json", id)
return fmt.Errorf("no edit_token for share %s in the local arc keyring", id)
}
plain := planPlaintext{
Version: 1,
Expand All @@ -406,7 +406,7 @@ func runShareDelete(cmd *cobra.Command, args []string) error {
s, _ := sharesconfig.Find(id)
if s == nil || s.EditToken == "" {
if !shareDeleteForce {
return fmt.Errorf("no edit_token for share %s in ~/.arc/shares.json "+
return fmt.Errorf("no edit_token for share %s in the local arc keyring "+
"(use --force to remove the local entry)", id)
}
_, _ = fmt.Fprintf(os.Stderr, "warning: no edit_token for %s; skipping server delete\n", id)
Expand Down Expand Up @@ -809,7 +809,7 @@ func printCommentEntries(entries []commentEntry) {
//
// Plan content is exposed via exactly one of two fields:
// - `file` — absolute path on disk. Set when the share is registered in
// shares.json AND the file is readable. Agent reads it directly.
// the local arc keyring AND the file is readable. Agent reads it directly.
// - `markdown_b64` — base64-encoded markdown. Set when there's no local
// file (e.g. an agent consuming a shared URL it didn't create). Base64
// avoids JSON escape bloat (every \n and \" doubles the byte count and
Expand All @@ -828,7 +828,7 @@ type bundlePlan struct {
Title string `json:"title,omitempty"`
AuthorName string `json:"author_name,omitempty"`
// File is the absolute path the agent should Edit. Present iff the
// share is in shares.json and the file is readable.
// share is in the local arc keyring and the file is readable.
File string `json:"file,omitempty"`
// MarkdownB64 is the plan content, base64-encoded. Present iff File
// is not set. Decode with standard base64 (RawStdEncoding-compatible).
Expand All @@ -852,7 +852,7 @@ type bundleResolvedAnchor struct {
// emitBundle assembles and prints the JSON bundle for `--json` output.
//
// Plan content sourcing:
// - If the share is in shares.json AND the recorded file is readable,
// - If the share is in the local arc keyring AND the recorded file is readable,
// emit `file` (absolute path) and run anchor resolution against the
// file's current bytes. The agent reads the file directly.
// - Otherwise, emit `markdown_b64` containing the encrypted blob's
Expand Down Expand Up @@ -925,7 +925,7 @@ func emitBundle(id string, plan *planPlaintext, entries []commentEntry) error {

// resolveShareRef parses a share reference which may be either a full share
// URL (e.g. https://arcplanner.sentiolabs.io/share/abc12345#k=KEY) or a bare
// share ID known to ~/.arc/shares.json.
// share ID known to the local arc keyring.
func resolveShareRef(ref string) (id, server string, key []byte, err error) {
if strings.Contains(ref, "://") {
return resolveShareURL(ref)
Expand All @@ -942,8 +942,8 @@ func resolveShareRef(ref string) (id, server string, key []byte, err error) {
}

// resolveShareURL is the URL branch of resolveShareRef, split out to keep the
// nesting depth low. Falls back to ~/.arc/shares.json for the key if the URL
// has no fragment.
// nesting depth low. Falls back to the local arc keyring for the key if the
// URL has no fragment.
func resolveShareURL(ref string) (id, server string, key []byte, err error) {
u, perr := url.Parse(ref)
if perr != nil {
Expand Down
48 changes: 39 additions & 9 deletions cmd/arc/share_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ package main

import (
"bytes"
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"io"
Expand All @@ -17,23 +15,52 @@ import (
"github.com/labstack/echo/v4"
_ "modernc.org/sqlite"

"github.com/sentiolabs/arc/internal/api"
"github.com/sentiolabs/arc/internal/client"
"github.com/sentiolabs/arc/internal/paste"
pastesqlite "github.com/sentiolabs/arc/internal/paste/sqlite"
"github.com/sentiolabs/arc/internal/sharesconfig"
"github.com/sentiolabs/arc/internal/storage/sqlite"
)

func startTestPasteServer(t *testing.T) *httptest.Server {
t.Helper()
db, err := sql.Open("sqlite", ":memory:")
// Open an arc storage backed by a temp-file sqlite db so the full
// migration set (including 017_shares.sql) is applied. The paste
// subsystem migrations are run by sqlite.New itself, so the same db
// connection serves both /api/paste and /api/v1/shares.
dbPath := filepath.Join(t.TempDir(), "test.db")
store, err := sqlite.New(dbPath)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := pastesqlite.Apply(context.Background(), db); err != nil {
t.Fatalf("apply migrations: %v", err)
t.Fatalf("sqlite.New: %v", err)
}
t.Cleanup(func() { _ = store.Close() })

e := echo.New()
paste.NewHandlers(pastesqlite.New(db)).Register(e.Group("/api/paste"))
return httptest.NewServer(e)
paste.NewHandlers(pastesqlite.New(store.DB())).Register(e.Group("/api/paste"))

// Mount the share keyring routes on /api/v1 against the same store so
// sharesconfig.{Load,Add,Find,Remove} hits a real handler chain.
apiSrv := api.New(api.Config{Store: store})
apiSrv.RegisterShareRoutes(e.Group("/api/v1"))

srv := httptest.NewServer(e)
t.Cleanup(srv.Close)

// Inject a client pointed at this test server so sharesconfig calls
// from CLI command code reach our in-process handlers. Restore the
// production factory on test exit so other tests in this package
// (and the CLI's main init) still see the real getClient wiring.
sharesconfig.SetClientFactory(func() (sharesconfig.Client, error) {
return client.New(srv.URL), nil
})
t.Cleanup(func() {
sharesconfig.SetClientFactory(func() (sharesconfig.Client, error) {
return getClient()
})
})

return srv
}

func TestShareCreateRoundTrip(t *testing.T) {
Expand Down Expand Up @@ -824,6 +851,9 @@ func TestShareShowAuthorURL(t *testing.T) {
func TestShareShowAuthorURLMissingShare(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
// Bring up an empty in-process server so sharesconfig.Find returns
// a clean ErrShareNotFound rather than trying to dial localhost:7432.
_ = startTestPasteServer(t)

shareShowAuthorURL = true
defer func() { shareShowAuthorURL = false }()
Expand Down
Loading
Loading