From 64642251775a925ea2a1bf6e13a2a0bc6aaba713 Mon Sep 17 00:00:00 2001 From: terra tauri Date: Thu, 25 Jun 2026 14:53:16 -0700 Subject: [PATCH] feat: pin publish / unpublish for public share links Add `pin publish [--ttl ]` to mint a login-free public capability link for an existing share, and `pin unpublish ` to revoke one. Publish POSTs the explicit confirm sentinel the server requires, so this is the deliberate "safety off" action. Default TTL 7d (server caps at 30d). The public URL is the only thing on stdout (safe to pipe); a loud warning + expiry go to stderr. unpublish accepts a raw token or the full public URL (lifts ?token=). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 14 +++++ main.go | 173 +++++++++++++++++++++++++++++++++++++++++++++++++++ main_test.go | 26 +++++++- 3 files changed, 212 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c56471b..c0892b5 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ Subcommands: | `pin login [--device]` | Sign in. `--device` for SSH / headless boxes. | | `pin share ` | Upload an HTML or MDX file. Prints the share URL. | | `pin get ` | Fetch a share's MDX source to stdout. `--html` for the rendered form. | +| `pin publish ` | Make a share publicly viewable (no login) via a capability link. `--ttl ` sets the lifetime (default 7d, max 30d). Prints the public URL. | +| `pin unpublish ` | Revoke a public link before it expires. | | `pin components` | List the MDX components available in pin, grouped by category. | | `pin components get ` | Show one component's props + example. | | `pin components dump` | Print every component's full detail. | @@ -40,6 +42,18 @@ Environment variables: Credentials are stored in your OS keychain (macOS Keychain / Windows Credential Manager / libsecret on Linux) with a 0600 file fallback at `~/.config/pin/credentials.json` for headless boxes. +### Public sharing + +Shares are private by default — only `@bitcomplete.io` accounts can open `https://pin.bitcomplete.dev/p/{id}`. To share a specific artifact with someone outside the org, `pin publish` mints a login-free capability link: + +```sh +pin publish 01HX... # → https://pin.bitcomplete.dev/public/p/01HX...?token=... +pin publish 01HX... --ttl 24h # default 7 days, max 30 +pin unpublish # kill the link early +``` + +This is a deliberate, separate step — you can't publish by accident. The link works for anyone who has it (no login) until it expires or you revoke it; anyone without the exact token gets a plain `404`. + ## How auth works `pin login` does an OAuth 2.1 PKCE loopback flow against the pin server (which in turn handles Google SSO for the human). `pin login --device` falls back to the RFC 8628 device-code flow when no local browser is available. diff --git a/main.go b/main.go index dfcfa4b..d99c5dc 100644 --- a/main.go +++ b/main.go @@ -59,6 +59,10 @@ func main() { os.Exit(runShare(os.Args[2:])) case "get": os.Exit(runGet(os.Args[2:])) + case "publish": + os.Exit(runPublish(os.Args[2:])) + case "unpublish": + os.Exit(runUnpublish(os.Args[2:])) case "components": os.Exit(runComponents(os.Args[2:])) case "whoami": @@ -82,6 +86,10 @@ Usage: pin share Upload an HTML or MDX file. Prints the share URL. pin get Fetch a share's MDX source to stdout. --html for the rendered HTML form. + pin publish Make a share publicly viewable (no login) via a + capability link. --ttl sets the lifetime + (default 7d, max 30d). Prints the public URL. + pin unpublish Revoke a public link before it expires. pin components List MDX components, grouped by category. pin components get Show one component's props + example. pin components dump Print every component's full detail. @@ -545,6 +553,171 @@ func runGet(args []string) int { return 0 } +// ----- publish / unpublish ----- + +// runPublish flips an existing share public by minting a capability +// token, then prints a login-free URL. This is the deliberate "safety +// off" action — invoking it is the conscious step; the server still +// requires the explicit confirm sentinel we send below. +func runPublish(args []string) int { + var idArg, ttl string + for i := 0; i < len(args); i++ { + a := args[i] + switch { + case a == "--ttl": + if i+1 >= len(args) { + fmt.Fprintln(os.Stderr, "pin publish: --ttl needs a value, e.g. --ttl 24h") + return 2 + } + i++ + ttl = args[i] + case strings.HasPrefix(a, "--ttl="): + ttl = strings.TrimPrefix(a, "--ttl=") + case strings.HasPrefix(a, "-"): + fmt.Fprintf(os.Stderr, "pin publish: unknown flag %q\n", a) + return 2 + case idArg == "": + idArg = a + default: + fmt.Fprintln(os.Stderr, "pin publish: too many arguments") + return 2 + } + } + if idArg == "" { + fmt.Fprintln(os.Stderr, "usage: pin publish [--ttl 7d]") + return 2 + } + + id, hostFromURL := parseShareRef(idArg) + if id == "" { + fmt.Fprintf(os.Stderr, "pin publish: not a share id or URL: %q\n", idArg) + return 2 + } + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + + c, err := loadCreds() + if err != nil { + fmt.Fprintf(os.Stderr, "pin publish: not logged in. Run `pin login`.\n") + return 1 + } + c, err = ensureFreshAccess(ctx, c) + if err != nil { + fmt.Fprintf(os.Stderr, "pin publish: refresh: %v\n", err) + return 1 + } + + base := hostFromURL + if base == "" { + base = host() + } + reqBody := map[string]string{"confirm": "publish-public"} + if ttl != "" { + reqBody["ttl"] = ttl + } + body, _ := json.Marshal(reqBody) + + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, base+"/api/pins/"+id+"/public", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.AccessToken) + req.Header.Set("X-Agent", agent()) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + fmt.Fprintf(os.Stderr, "pin publish: %v\n", err) + return 1 + } + defer resp.Body.Close() + rbody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + fmt.Fprintf(os.Stderr, "pin publish: http %d: %s\n", resp.StatusCode, strings.TrimSpace(string(rbody))) + return 1 + } + var out struct { + PublicURL string `json:"public_url"` + ExpiresAt string `json:"expires_at"` + TTLDays int `json:"ttl_days"` + } + if err := json.Unmarshal(rbody, &out); err != nil { + fmt.Fprintf(os.Stderr, "pin publish: parse: %v\nbody: %s\n", err, rbody) + return 1 + } + // Warn loudly on stderr; the URL itself stays the only thing on stdout + // so it's safe to pipe/capture. + fmt.Fprintf(os.Stderr, "⚠ public — anyone with this link can view it, no login. Expires %s (%dd). Revoke with `pin unpublish`.\n", out.ExpiresAt, out.TTLDays) + fmt.Println(out.PublicURL) + return 0 +} + +// runUnpublish revokes a public link. Accepts the raw token or the full +// public URL (from which it lifts the ?token= value). +func runUnpublish(args []string) int { + if len(args) != 1 { + fmt.Fprintln(os.Stderr, "usage: pin unpublish ") + return 2 + } + token, hostFromURL := parsePublicRef(args[0]) + if token == "" { + fmt.Fprintf(os.Stderr, "pin unpublish: no token in %q\n", args[0]) + return 2 + } + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + + c, err := loadCreds() + if err != nil { + fmt.Fprintf(os.Stderr, "pin unpublish: not logged in. Run `pin login`.\n") + return 1 + } + c, err = ensureFreshAccess(ctx, c) + if err != nil { + fmt.Fprintf(os.Stderr, "pin unpublish: refresh: %v\n", err) + return 1 + } + + base := hostFromURL + if base == "" { + base = host() + } + body, _ := json.Marshal(map[string]string{"token": token}) + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, base+"/api/pins/public/revoke", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.AccessToken) + req.Header.Set("X-Agent", agent()) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + fmt.Fprintf(os.Stderr, "pin unpublish: %v\n", err) + return 1 + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + rbody, _ := io.ReadAll(resp.Body) + fmt.Fprintf(os.Stderr, "pin unpublish: http %d: %s\n", resp.StatusCode, strings.TrimSpace(string(rbody))) + return 1 + } + fmt.Fprintln(os.Stderr, "revoked — the public link no longer works") + return 0 +} + +// parsePublicRef extracts the capability token from either a bare token +// or a full public URL like +// "https://pin.bitcomplete.dev/public/p/{id}?token=…". Returns (token, +// host) where host is "" unless a URL was passed. +func parsePublicRef(ref string) (token, hostFromURL string) { + ref = strings.TrimSpace(ref) + if strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") { + u, err := url.Parse(ref) + if err != nil || u.Host == "" { + return "", "" + } + return u.Query().Get("token"), u.Scheme + "://" + u.Host + } + return ref, "" +} + // parseShareRef accepts a bare ULID, a "p/{id}" path, or a full URL // like "https://pin.bitcomplete.dev/p/{id}". Returns (id, host) where // host is "" unless a URL was passed (in which case it overrides diff --git a/main_test.go b/main_test.go index 07d613d..730d6f7 100644 --- a/main_test.go +++ b/main_test.go @@ -19,7 +19,7 @@ func TestParseShareRef(t *testing.T) { // Garbage paths return empty id. {"", "", ""}, {"https://pin.bitcomplete.dev/", "", ""}, - {"01HX YZ", "", ""}, // space in id + {"01HX YZ", "", ""}, // space in id {"foo/bar/01HXYZ", "", ""}, } for _, tc := range cases { @@ -30,3 +30,27 @@ func TestParseShareRef(t *testing.T) { } } } + +func TestParsePublicRef(t *testing.T) { + cases := []struct { + in string + wantToken string + wantHost string + }{ + // A bare token passes through, no host. + {"abc123_tok", "abc123_tok", ""}, + {" abc123_tok ", "abc123_tok", ""}, + // Full public URL → token lifted from the query, host preserved. + {"https://pin.bitcomplete.dev/public/p/01HXYZ?token=abc123", "abc123", "https://pin.bitcomplete.dev"}, + {"http://localhost:8080/public/p/01HXYZ?token=xy_z-9", "xy_z-9", "http://localhost:8080"}, + // URL with no token → empty token (caller errors out). + {"https://pin.bitcomplete.dev/public/p/01HXYZ", "", "https://pin.bitcomplete.dev"}, + } + for _, tc := range cases { + gotTok, gotHost := parsePublicRef(tc.in) + if gotTok != tc.wantToken || gotHost != tc.wantHost { + t.Errorf("parsePublicRef(%q) = (%q, %q), want (%q, %q)", + tc.in, gotTok, gotHost, tc.wantToken, tc.wantHost) + } + } +}