From 07a9784d1c67d1af80e57f56c2ff366987aebd54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Fri, 22 May 2026 11:40:36 +0100 Subject: [PATCH] fix(share): route share links through gist.githack so big tasks render (#1008) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(share): route share links through gist.githack so big tasks render gistpreview.github.io fetches gists via the public GitHub API, which returns `content: ""` for files past GitHub's per-response content budget (~1 MB). For non-trivial sessions, share.html and snapshot.json land past that budget and gistpreview's `document.write(content)` renders a blank page. gist.githack.com proxies the raw file content directly, so multi-MB share.html renders correctly — at the cost of a one-time Cloudflare anti-phishing interstitial on first visit, accepted as the price of fidelity for big tasks. displayURL now normalises bare gist URLs and unpinned githack URLs to the canonical `gist.githack.com///raw/share.html` form; legacy gistpreview URLs pass through unchanged (the owner is not recoverable from a gistpreview URL, so users can revoke + re-share to get the githack form going forward). * fix(share): address PR #1008 review feedback - displayURL now re-pins legacy gistpreview URLs stored without /share.html so they hit the styled view instead of silently falling back to the alphabetically-first README.md (flagged by Greptile). - Spec: unify two stale gistpreview references (API endpoint description and the publish scenario) to the canonical githack form, with a note that legacy gistpreview rows pass through. Flagged by CodeRabbit and Claude — the spec is the public contract; one stale mention undermines the migration. --------- Co-authored-by: Kandev Agent --- .../internal/task/share/gist_backend.go | 86 ++++++++++--------- .../internal/task/share/gist_backend_test.go | 11 ++- apps/backend/internal/task/share/gist_html.go | 16 ++-- .../internal/task/share/gist_html_test.go | 28 +++--- .../internal/task/share/gist_markdown.go | 11 +-- .../internal/task/share/gist_markdown_test.go | 4 +- apps/backend/internal/task/share/http.go | 40 +++++---- apps/backend/internal/task/share/http_test.go | 49 +++++++---- docs/specs/public-share-links/spec.md | 50 +++++++---- 9 files changed, 174 insertions(+), 121 deletions(-) diff --git a/apps/backend/internal/task/share/gist_backend.go b/apps/backend/internal/task/share/gist_backend.go index e873522475..f4727f57e0 100644 --- a/apps/backend/internal/task/share/gist_backend.go +++ b/apps/backend/internal/task/share/gist_backend.go @@ -33,9 +33,9 @@ func (b *GistBackend) Name() string { return BackendGitHubGist } // Upload marshals the snapshot to JSON, builds the rendered share.html and // a README, then creates a secret gist with all three. The returned URL is -// the rendered view served via gistpreview.github.io — that's the link -// users share. The gist itself is preserved in the database via the -// externalID so we can still delete it on revoke. +// the rendered view served via gist.githack.com — that's the link users +// share. The gist itself is preserved in the database via the externalID +// so we can still delete it on revoke. func (b *GistBackend) Upload(ctx context.Context, snap *Snapshot) (string, string, error) { body, err := json.MarshalIndent(snap, "", " ") if err != nil { @@ -71,66 +71,74 @@ func (b *GistBackend) Upload(ctx context.Context, snap *Snapshot) (string, strin } // renderedURLForGist returns the public rendering URL we hand to the user -// for a given gist. We route through gistpreview.github.io, a static -// GitHub-Pages site that fetches the gist via the public API and renders -// an HTML file client-side. We use it instead of raw.githack.com so -// visitors don't hit the "One more step" anti-phishing interstitial. +// for a given gist. We route through gist.githack.com, a Cloudflare-backed +// CDN that proxies the gist's raw file content directly. // -// Critically: we append "/share.html" to the URL because gistpreview's -// file-picker logic picks the first iterated file unless one is named -// "index.html". Our gists contain README.md, share.html, snapshot.json — -// without the explicit suffix, gistpreview picks README.md (alphabetical -// first), renders it raw, and the page appears unstyled. The explicit -// filename pins it to share.html. +// Why githack and not gistpreview.github.io: gistpreview fetches gists +// through the GitHub gists API, which serves `content: ""` for any file +// past GitHub's per-response content budget (~1 MB combined across all +// files). For non-trivial sessions, share.html / snapshot.json land past +// that budget, and gistpreview renders a blank page. githack proxies the +// raw file directly, so multi-megabyte share.html renders correctly. // -// https://gist.github.com// → https://gistpreview.github.io/?/share.html -// https://gist.github.com/ → https://gistpreview.github.io/?/share.html +// Trade-off: githack shows a one-time anti-phishing interstitial the first +// time a user visits any githack URL — accepted as the price of fidelity +// for big tasks. // -// Returns "" if the URL doesn't look like a gist URL — callers fall back -// to whatever was stored. +// We append "/raw/share.html" to pin the styled HTML view; without an +// explicit filename githack can't pick the right file. +// +// https://gist.github.com// → https://gist.githack.com///raw/share.html +// +// Returns "" if the URL is missing the owner segment (anonymous gist) — +// callers fall back to whatever was stored. func renderedURLForGist(gistHTMLURL string) string { - id := gistIDFromGistHTMLURL(gistHTMLURL) - if id == "" { + owner, id := ownerAndIDFromGistHTMLURL(gistHTMLURL) + if owner == "" || id == "" { return "" } - return gistpreviewURL(id) + return githackURL(owner, id) } -// gistpreviewURL builds the gistpreview.github.io URL for a gist id, -// explicitly pinning the file to share.html. -func gistpreviewURL(gistID string) string { - return "https://gistpreview.github.io/?" + gistID + "/share.html" +// githackURL builds the gist.githack.com raw-render URL for a gist, +// explicitly pinning the file to share.html so the styled view loads. +func githackURL(owner, id string) string { + return "https://gist.githack.com/" + owner + "/" + id + "/raw/share.html" } -// gistIDFromGistHTMLURL extracts the gist ID from a github.com gist URL. -// Returns "" for anything that doesn't match the expected shape. -func gistIDFromGistHTMLURL(gistHTMLURL string) string { +// ownerAndIDFromGistHTMLURL parses a github.com gist URL of the form +// `https://gist.github.com//`. Anonymous gists (no owner +// segment) return ("", ""); githack needs both to address the file. +func ownerAndIDFromGistHTMLURL(gistHTMLURL string) (string, string) { const prefix = "https://gist.github.com/" if !strings.HasPrefix(gistHTMLURL, prefix) { - return "" + return "", "" } rest := strings.Trim(strings.TrimPrefix(gistHTMLURL, prefix), "/") if rest == "" { - return "" + return "", "" } - // "/" or "" — the ID is always the last segment. parts := strings.Split(rest, "/") - return parts[len(parts)-1] + if len(parts) < 2 || parts[0] == "" || parts[1] == "" { + return "", "" + } + return parts[0], parts[1] } -// gistIDFromGithackURL pulls the gist ID out of a stored raw.githack URL -// (legacy rows that were written before the gistpreview switchover). -// Returns "" for anything that doesn't match. -func gistIDFromGithackURL(url string) string { +// ownerAndIDFromGithackURL extracts (owner, id) from a stored githack URL +// of the form `https://gist.githack.com///raw/...`. Used by +// displayURL to re-pin /raw/share.html on rows whose stored URL targets +// a different file. Returns ("", "") on anything that doesn't match. +func ownerAndIDFromGithackURL(url string) (string, string) { const prefix = "https://gist.githack.com/" if !strings.HasPrefix(url, prefix) { - return "" + return "", "" } parts := strings.Split(strings.TrimPrefix(url, prefix), "/") - if len(parts) < 2 || parts[1] == "" { - return "" + if len(parts) < 2 || parts[0] == "" || parts[1] == "" { + return "", "" } - return parts[1] + return parts[0], parts[1] } // Delete removes the gist. A 404 from GitHub is returned to the caller as-is diff --git a/apps/backend/internal/task/share/gist_backend_test.go b/apps/backend/internal/task/share/gist_backend_test.go index 3462906078..afd2887fd8 100644 --- a/apps/backend/internal/task/share/gist_backend_test.go +++ b/apps/backend/internal/task/share/gist_backend_test.go @@ -43,10 +43,13 @@ func TestGistBackend_Upload_CreatesSecretGistWithExpectedFiles(t *testing.T) { t.Fatalf("expected non-empty id/url, got %q / %q", id, url) } - // The returned URL is the rendered gistpreview.github.io viewer URL, - // NOT the raw gist URL — that's what the user shares. - if !strings.HasPrefix(url, "https://gistpreview.github.io/?") { - t.Fatalf("expected gistpreview.github.io URL, got %q", url) + // The returned URL is the gist.githack.com raw-render URL, NOT the + // raw gist URL — githack proxies the full file content so big shares + // render even when GitHub's gists API would have returned an empty + // content field. + if !strings.HasPrefix(url, "https://gist.githack.com/") || + !strings.HasSuffix(url, "/raw/share.html") { + t.Fatalf("expected gist.githack.com /raw/share.html URL, got %q", url) } gists := mock.Gists() diff --git a/apps/backend/internal/task/share/gist_html.go b/apps/backend/internal/task/share/gist_html.go index 69ca33ba14..a2f4dd159e 100644 --- a/apps/backend/internal/task/share/gist_html.go +++ b/apps/backend/internal/task/share/gist_html.go @@ -26,14 +26,14 @@ func BuildShareHTML(snap *Snapshot) string { b.WriteString("\n\n") writeHTMLHead(&b, snap) b.WriteString("\n") - // Inline the stylesheet at the top of rather than in so it - // survives renderers (notably gistpreview.github.io) that copy only the - // body content into their own page — dropping and any \n") diff --git a/apps/backend/internal/task/share/gist_html_test.go b/apps/backend/internal/task/share/gist_html_test.go index 519195af29..46be2dc40e 100644 --- a/apps/backend/internal/task/share/gist_html_test.go +++ b/apps/backend/internal/task/share/gist_html_test.go @@ -182,8 +182,10 @@ func TestRenderedURLForGist(t *testing.T) { input string want string }{ - {"authenticated_gist", "https://gist.github.com/jane/abc123", "https://gistpreview.github.io/?abc123/share.html"}, - {"anonymous_gist", "https://gist.github.com/abc123", "https://gistpreview.github.io/?abc123/share.html"}, + {"authenticated_gist", "https://gist.github.com/jane/abc123", "https://gist.githack.com/jane/abc123/raw/share.html"}, + // Anonymous gists lack the owner segment githack needs to address the + // file; we return "" so callers fall back to whatever was stored. + {"anonymous_gist", "https://gist.github.com/abc123", ""}, {"wrong_host", "https://example.com/jane/abc123", ""}, {"empty", "", ""}, } @@ -197,23 +199,25 @@ func TestRenderedURLForGist(t *testing.T) { } } -func TestGistIDFromGithackURL(t *testing.T) { +func TestOwnerAndIDFromGithackURL(t *testing.T) { t.Parallel() cases := []struct { - name string - input string - want string + name string + input string + wantOwner string + wantID string }{ - {"valid_legacy_url", "https://gist.githack.com/jane/abc123/raw/share.html", "abc123"}, - {"wrong_host", "https://example.com/jane/abc123/raw/share.html", ""}, - {"too_short", "https://gist.githack.com/jane", ""}, - {"empty", "", ""}, + {"valid_url", "https://gist.githack.com/jane/abc123/raw/share.html", "jane", "abc123"}, + {"wrong_host", "https://example.com/jane/abc123/raw/share.html", "", ""}, + {"too_short", "https://gist.githack.com/jane", "", ""}, + {"empty", "", "", ""}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { - if got := gistIDFromGithackURL(tc.input); got != tc.want { - t.Fatalf("got %q, want %q", got, tc.want) + owner, id := ownerAndIDFromGithackURL(tc.input) + if owner != tc.wantOwner || id != tc.wantID { + t.Fatalf("got (%q,%q), want (%q,%q)", owner, id, tc.wantOwner, tc.wantID) } }) } diff --git a/apps/backend/internal/task/share/gist_markdown.go b/apps/backend/internal/task/share/gist_markdown.go index eec5bc54b9..98add8368a 100644 --- a/apps/backend/internal/task/share/gist_markdown.go +++ b/apps/backend/internal/task/share/gist_markdown.go @@ -8,11 +8,12 @@ import ( ) // BuildGistREADME renders the README that appears at the top of the gist -// page on github.com. The primary user-facing rendering is share.html (served -// via raw.githack.com); the README is mostly a "go to the pretty view" -// pointer plus a markdown fallback for anyone who lands on the gist directly. -// renderedURL is the raw.githack.com link to share.html; pass "" if it's not -// known yet (the README is regenerated post-upload with the real link). +// page on github.com. The primary user-facing rendering is share.html +// (served via gist.githack.com); the README is mostly a "go to the pretty +// view" pointer plus a markdown fallback for anyone who lands on the gist +// directly. renderedURL is the gist.githack.com link to share.html; pass +// "" if it's not known yet (the README is regenerated post-upload with +// the real link). func BuildGistREADME(snap *Snapshot, renderedURL string) string { if snap == nil { return "# kandev share\n" diff --git a/apps/backend/internal/task/share/gist_markdown_test.go b/apps/backend/internal/task/share/gist_markdown_test.go index 9908049f7a..05e53b1515 100644 --- a/apps/backend/internal/task/share/gist_markdown_test.go +++ b/apps/backend/internal/task/share/gist_markdown_test.go @@ -45,7 +45,7 @@ func TestBuildGistREADME_FullConversation(t *testing.T) { Redaction: RedactionLog{AppliedRules: []string{RuleAbsPath}}, } - md := BuildGistREADME(snap, "https://gistpreview.github.io/?mock-gist-1/share.html") + md := BuildGistREADME(snap, "https://gist.githack.com/jane/mock-gist-1/raw/share.html") assertContains(t, md, "# Investigate flaky test") assertContains(t, md, "claude-acp") assertContains(t, md, "claude-opus-4-7") @@ -76,7 +76,7 @@ func TestBuildGistREADME_NilAndEmpty(t *testing.T) { t.Fatal("nil snapshot should still produce a non-empty README") } empty := &Snapshot{Task: TaskMeta{Title: "Untitled"}} - got := BuildGistREADME(empty, "https://gistpreview.github.io/?g1/share.html") + got := BuildGistREADME(empty, "https://gist.githack.com/jane/g1/raw/share.html") if !strings.Contains(got, "_(No messages.)_") { t.Fatalf("expected empty-messages placeholder, got: %s", got) } diff --git a/apps/backend/internal/task/share/http.go b/apps/backend/internal/task/share/http.go index 2929e74ddb..4a55cf2167 100644 --- a/apps/backend/internal/task/share/http.go +++ b/apps/backend/internal/task/share/http.go @@ -70,36 +70,44 @@ func toShareResponse(s *Share) shareResponse { } // displayURL returns the URL we want clients to use when opening a share. -// We always prefer the gistpreview.github.io rendered view (the static -// renderer pulls the gist's first HTML file via the GitHub API). Three -// stored formats are normalised on the way out: +// We always prefer the gist.githack.com rendered view (it proxies the raw +// gist file directly, avoiding the GitHub gists-API content budget that +// makes gistpreview.github.io render blank for big snapshots). Stored +// formats are normalised on the way out: // -// - bare gist URL (https://gist.github.com//) — old rows +// - bare gist URL (https://gist.github.com//) — older rows // written before share.html landed in the gist -// - raw.githack URL (https://gist.githack.com///raw/share.html) -// — rows written during the brief raw.githack window -// - already a gistpreview URL — passthrough +// - already a githack URL — re-pinned to /raw/share.html so rows that +// targeted a different file (or no file) land on the styled view +// - legacy gistpreview URL — passed through (owner is not recoverable +// from gistpreview shape, so we can't upgrade to githack), but +// re-pinned with /share.html when stored without a filename so +// pre-existing rows still hit the styled HTML rather than the +// alphabetically-first README.md // // Anything we don't recognise is returned unchanged. func displayURL(stored string) string { if stored == "" { return "" } - // Already a gistpreview URL — if the caller pinned share.html, keep it; - // otherwise re-pin it so gistpreview doesn't fall back to README.md. + // Already a githack URL — re-pin /raw/share.html. + if owner, id := ownerAndIDFromGithackURL(stored); owner != "" && id != "" { + return githackURL(owner, id) + } + // Bare gist URL → convert to githack. + if rendered := renderedURLForGist(stored); rendered != "" { + return rendered + } + // Legacy gistpreview URL — pass through, but re-pin /share.html for + // rows stored without a filename so they don't silently fall back to + // rendering README.md. const previewPrefix = "https://gistpreview.github.io/?" if strings.HasPrefix(stored, previewPrefix) { id := strings.TrimPrefix(stored, previewPrefix) if i := strings.Index(id, "/"); i >= 0 { id = id[:i] } - return gistpreviewURL(id) - } - if rendered := renderedURLForGist(stored); rendered != "" { - return rendered - } - if id := gistIDFromGithackURL(stored); id != "" { - return gistpreviewURL(id) + return previewPrefix + id + "/share.html" } return stored } diff --git a/apps/backend/internal/task/share/http_test.go b/apps/backend/internal/task/share/http_test.go index 8012593457..c0c22acb6a 100644 --- a/apps/backend/internal/task/share/http_test.go +++ b/apps/backend/internal/task/share/http_test.go @@ -52,9 +52,9 @@ func newGinRouter(h *HTTPHandlers) *gin.Engine { func TestHTTP_Create_HappyPath(t *testing.T) { t.Parallel() reader := completedSession() - // Real GistBackend returns the gistpreview.github.io rendered URL — - // match that shape so the test mirrors production. - backend := &mockBackend{nextID: "gist-x", nextURL: "https://gistpreview.github.io/?gist-x/share.html"} + // Real GistBackend returns the gist.githack.com rendered URL — match + // that shape so the test mirrors production. + backend := &mockBackend{nextID: "gist-x", nextURL: "https://gist.githack.com/jane/gist-x/raw/share.html"} handlers, _ := newHandlersForTest(t, reader, backend, true) router := newGinRouter(handlers) @@ -69,7 +69,7 @@ func TestHTTP_Create_HappyPath(t *testing.T) { if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("decode: %v", err) } - if body.URL != "https://gistpreview.github.io/?gist-x/share.html" { + if body.URL != "https://gist.githack.com/jane/gist-x/raw/share.html" { t.Fatalf("unexpected url: %s", body.URL) } } @@ -201,22 +201,35 @@ func TestDisplayURL_NormalizesAllFormats(t *testing.T) { want string }{ { - "bare_gist_url_old_share", + "bare_gist_url_converted_to_githack", "https://gist.github.com/jane/abc123", - "https://gistpreview.github.io/?abc123/share.html", - }, - { - "legacy_githack_url_gets_rewritten", "https://gist.githack.com/jane/abc123/raw/share.html", - "https://gistpreview.github.io/?abc123/share.html", }, { - "already_gistpreview_with_share_html_passthrough", + "githack_url_passthrough", + "https://gist.githack.com/jane/abc123/raw/share.html", + "https://gist.githack.com/jane/abc123/raw/share.html", + }, + { + "githack_without_filename_repinned", + "https://gist.githack.com/jane/abc123", + "https://gist.githack.com/jane/abc123/raw/share.html", + }, + { + // Legacy gistpreview rows (written before the githack switch) + // lack the owner segment we need to build a githack URL, so + // they pass through. Users on those rows still get a working + // link for small tasks; revoking + re-sharing surfaces the + // githack URL going forward. + "legacy_gistpreview_with_share_html_passthrough", "https://gistpreview.github.io/?abc123/share.html", "https://gistpreview.github.io/?abc123/share.html", }, { - "older_gistpreview_without_filename_repinned", + // Older gistpreview rows stored without /share.html still get + // re-pinned so gistpreview doesn't silently fall back to + // rendering README.md. + "legacy_gistpreview_without_filename_repinned", "https://gistpreview.github.io/?abc123", "https://gistpreview.github.io/?abc123/share.html", }, @@ -248,13 +261,14 @@ func TestHTTP_List_NormalizesLegacyURLsOnDisplay(t *testing.T) { handlers, svc := newHandlersForTest(t, reader, backend, true) router := newGinRouter(handlers) - // Seed two legacy rows from earlier URL formats — the API should - // surface both as gistpreview URLs. + // Seed two legacy rows from earlier URL formats — bare gist and a + // githack URL with no filename pinned. Both should surface as the + // canonical /raw/share.html githack form. for _, row := range []*Share{ {ID: "old-1", TaskSessionID: "s-1", Backend: BackendGitHubGist, ExternalID: "abc123", ExternalURL: "https://gist.github.com/jane/abc123"}, {ID: "old-2", TaskSessionID: "s-1", Backend: BackendGitHubGist, ExternalID: "def456", - ExternalURL: "https://gist.githack.com/jane/def456/raw/share.html"}, + ExternalURL: "https://gist.githack.com/jane/def456"}, } { if err := svc.repo.Create(context.Background(), row); err != nil { t.Fatalf("seed %s: %v", row.ID, err) @@ -275,8 +289,9 @@ func TestHTTP_List_NormalizesLegacyURLsOnDisplay(t *testing.T) { t.Fatalf("expected 2 shares, got %d", len(body.Shares)) } for _, s := range body.Shares { - if !strings.HasPrefix(s.URL, "https://gistpreview.github.io/?") { - t.Fatalf("expected gistpreview URL, got %q", s.URL) + if !strings.HasSuffix(s.URL, "/raw/share.html") || + !strings.HasPrefix(s.URL, "https://gist.githack.com/") { + t.Fatalf("expected gist.githack.com /raw/share.html URL, got %q", s.URL) } } } diff --git a/docs/specs/public-share-links/spec.md b/docs/specs/public-share-links/spec.md index ef705dbb8f..8862f79b20 100644 --- a/docs/specs/public-share-links/spec.md +++ b/docs/specs/public-share-links/spec.md @@ -155,9 +155,10 @@ POST /api/v1/tasks/:taskId/sessions/:sessionId/shares?dry_run=true GET /api/v1/tasks/:taskId/sessions/:sessionId/shares -> 200 { shares: [{ id, url, created_at, revoked_at, snapshot_size_bytes }] } Lists every share row (including revoked) for the session. The - `url` field is always normalised to the current rendering URL - (gistpreview.github.io/?/share.html) regardless of what's - stored. + `url` field is always normalised to the canonical githack rendering + URL (gist.githack.com///raw/share.html) regardless of + what's stored; legacy gistpreview.github.io rows are passed + through (re-pinned to /share.html when the filename is missing). DELETE /api/v1/shares/:shareId -> 204 @@ -187,23 +188,35 @@ type Backend interface { ### Share URL format -The user-facing share URL routes through **gistpreview.github.io** — a -static GitHub-Pages renderer that fetches the gist via the GitHub API and -injects the HTML client-side. We use it instead of raw.githack.com because -raw.githack shows a "One more step" anti-phishing interstitial on HTML -files served from gists. +The user-facing share URL routes through **gist.githack.com** — a +Cloudflare-backed CDN that proxies the gist's raw file content directly. -Format: `https://gistpreview.github.io/?/share.html` +We initially shipped via `gistpreview.github.io` (a static GitHub-Pages +renderer that fetches gists through the public GitHub API), but the gists +API returns `content: ""` for any file past GitHub's per-response content +budget (~1 MB combined across files). For non-trivial sessions, the +generated `share.html` lands past that budget, and gistpreview renders a +blank page. githack proxies the raw HTML directly, so even multi-megabyte +share.html renders correctly. -The `/share.html` suffix is required: gistpreview's file picker defaults -to the first iterated file (alphabetically `README.md` in our gists), so -without an explicit filename the page renders raw markdown instead of the -styled HTML. +The trade-off is that githack shows a one-time anti-phishing interstitial +the first time a user visits any githack URL on a given device — accepted +as the price of fidelity for big tasks. -`displayURL` on the API layer normalises every legacy URL format -(`gist.github.com//`, `gist.githack.com///raw/share.html`, -or a gistpreview URL missing the filename) to the canonical -gistpreview-with-share.html form on every response. +Format: `https://gist.githack.com///raw/share.html` + +The `/raw/share.html` suffix is required: githack can't pick a default +file without an explicit path. + +`displayURL` on the API layer normalises stored URL formats to the +canonical githack-with-share.html form on every response: + +- `gist.github.com//` → githack +- `gist.githack.com//` (no filename) → re-pinned to `/raw/share.html` +- Legacy `gistpreview.github.io/?/share.html` → passed through + unchanged. The owner is not recoverable from a gistpreview URL, so we + cannot auto-upgrade. Users can revoke + re-share to get a githack URL + for those rows. ## State machine @@ -295,7 +308,8 @@ There is no "draft" or "scheduled" state; publish is synchronous. - **GIVEN** the preview dialog is open, **WHEN** the user clicks "Publish to GitHub Gist", **THEN** kandev uploads a secret gist containing `snapshot.json`, `share.html`, and `README.md`, inserts a `task_shares` - row, and the dialog displays the gistpreview URL with a copy button. + row, and the dialog displays the gist.githack.com URL with a copy + button. - **GIVEN** an active share row, **WHEN** the user clicks Revoke, **THEN** the gist is deleted from GitHub, the row is updated with `revoked_at`,