oficina / slides Public

Preserve Home sessions on storage failures and pin reviewed adapter

claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev 6f01b9407cbac09f10c6efb93d6f8e8af4ed4ae1 parent 433e39c
10 files changed, +233 −45
  • go.mod +1 −1
  • go.sum +2 −2
  • internal/slides/app.go +6 −8
  • internal/slides/auth.go +11 −8
  • internal/slides/config.go +1 −1
  • internal/slides/handlers.go +5 −6
  • internal/slides/home.go +25 −7
  • internal/slides/home_test.go +175 −2
  • internal/slides/presentation.go +5 −7
  • internal/slides/render.go +2 −3

CI passed — run details

diff --git a/go.mod b/go.mod
index 8f3df20..5f37914 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,7 @@ module amadan.net/oficina/slides
go 1.25.0
require (
+ amadan.net/oficina/home/connect v0.0.0-20260909233352-868f189c5c77
amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489
amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327
@@ -15,7 +16,6 @@ require (
)
require (
- amadan.net/oficina/home/connect v0.0.0-20260909231415-9a780c687d4f // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
diff --git a/go.sum b/go.sum
index 646882f..401dab1 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,5 @@
-amadan.net/oficina/home/connect v0.0.0-20260909231415-9a780c687d4f h1:DLcotgutTVa/LeYiCTmBH+MO0O8EEJ2RN97+5zFxndE=
-amadan.net/oficina/home/connect v0.0.0-20260909231415-9a780c687d4f/go.mod h1:jsd1+E2JaW2m1Afqh7CHjSbdxYSRk2UPAEbxeSQ8zyo=
+amadan.net/oficina/home/connect v0.0.0-20260909233352-868f189c5c77 h1:eKlXkS4kkVxsFTDtGxnVj0+rfVc50alQLw8o6TeSRQM=
+amadan.net/oficina/home/connect v0.0.0-20260909233352-868f189c5c77/go.mod h1:jsd1+E2JaW2m1Afqh7CHjSbdxYSRk2UPAEbxeSQ8zyo=
amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 h1:RPKtyry/71i06o8L+gjAaUyexbmG56kO1YPs601URAM=
amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k=
amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd h1:jlFSt95M1ontOg+VePU+kIJpChtWqDQAbnmlxqCnbgc=
diff --git a/internal/slides/app.go b/internal/slides/app.go
index b2cda4b..d9987b4 100644
--- a/internal/slides/app.go
+++ b/internal/slides/app.go
@@ -1,22 +1,20 @@
package slides
import (
- "amadan.net/oficina/home/connect"
- "amadan.net/oficina/home/handoff"
"context"
"net/http"
- "github.com/go-chi/chi/v5"
-
+ "amadan.net/oficina/home/connect"
+ "amadan.net/oficina/home/handoff"
+ "amadan.net/oficina/slides/internal/slides/core"
+ "amadan.net/oficina/slides/internal/slides/live"
+ "amadan.net/oficina/slides/internal/slides/present"
rauth "amadan.net/rastrillo/rastrillo/auth"
"amadan.net/rastrillo/rastrillo/csrf"
"amadan.net/rastrillo/rastrillo/db"
"amadan.net/rastrillo/rastrillo/jobs"
"amadan.net/rastrillo/rastrillo/migrate"
-
- "amadan.net/oficina/slides/internal/slides/core"
- "amadan.net/oficina/slides/internal/slides/live"
- "amadan.net/oficina/slides/internal/slides/present"
+ "github.com/go-chi/chi/v5"
)
// New wires the whole app: schema, sign-in, router, static files. It
diff --git a/internal/slides/auth.go b/internal/slides/auth.go
index b018d5e..8df7734 100644
--- a/internal/slides/auth.go
+++ b/internal/slides/auth.go
@@ -18,7 +18,6 @@
package slides
import (
- "amadan.net/oficina/home/connect"
"context"
"database/sql"
"errors"
@@ -29,15 +28,15 @@ import (
"strings"
"time"
- "gorm.io/gorm"
- "gorm.io/gorm/clause"
-
+ "amadan.net/oficina/home/connect"
+ "amadan.net/oficina/home/connect/linkstore"
+ "amadan.net/oficina/slides/internal/slides/present"
"amadan.net/rastrillo/rastrillo"
rauth "amadan.net/rastrillo/rastrillo/auth"
"amadan.net/rastrillo/rastrillo/mail"
"amadan.net/rastrillo/rastrillo/sessions"
-
- "amadan.net/oficina/slides/internal/slides/present"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
)
// signinPath is where the sign-in page lives. It must keep matching
@@ -384,8 +383,12 @@ func (a *app) currentUser(w http.ResponseWriter, r *http.Request) (User, session
if s.Method == connect.HomeMethod {
u, err := a.homeUser(r, s)
if err != nil {
- a.revokeHome(w, r, s.Subject)
- return User{}, sessionOffboarded
+ if errors.Is(err, linkstore.ErrRefused) || errors.Is(err, errHomeIdentity) {
+ a.revokeHome(w, r, s.Subject)
+ return User{}, sessionOffboarded
+ }
+ a.logger.Error("slides: resolve Home user", "err", err)
+ return User{}, sessionNone
}
return u, sessionOK
}
diff --git a/internal/slides/config.go b/internal/slides/config.go
index 867da56..85c0af4 100644
--- a/internal/slides/config.go
+++ b/internal/slides/config.go
@@ -13,12 +13,12 @@
package slides
import (
- "amadan.net/oficina/home/connect"
"fmt"
"log/slog"
"net/url"
"strings"
+ "amadan.net/oficina/home/connect"
"amadan.net/rastrillo/rastrillo/blobs"
"amadan.net/rastrillo/rastrillo/mail"
)
diff --git a/internal/slides/handlers.go b/internal/slides/handlers.go
index b499397..96698d5 100644
--- a/internal/slides/handlers.go
+++ b/internal/slides/handlers.go
@@ -1,18 +1,17 @@
package slides
import (
- "amadan.net/oficina/home/connect"
- "amadan.net/rastrillo/rastrillo/sessions"
"context"
"log/slog"
- rauth "amadan.net/rastrillo/rastrillo/auth"
- "amadan.net/rastrillo/rastrillo/db"
- "amadan.net/rastrillo/rastrillo/jobs"
-
+ "amadan.net/oficina/home/connect"
"amadan.net/oficina/slides/internal/slides/core"
"amadan.net/oficina/slides/internal/slides/live"
"amadan.net/oficina/slides/internal/slides/present"
+ rauth "amadan.net/rastrillo/rastrillo/auth"
+ "amadan.net/rastrillo/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo/jobs"
+ "amadan.net/rastrillo/rastrillo/sessions"
)
// app holds what every handler needs: the database, the logger, the
diff --git a/internal/slides/home.go b/internal/slides/home.go
index a3ef86f..ee8728d 100644
--- a/internal/slides/home.go
+++ b/internal/slides/home.go
@@ -3,7 +3,9 @@ package slides
import (
"bytes"
"context"
+ "database/sql"
"errors"
+ "fmt"
"net/http"
"amadan.net/oficina/home/connect"
@@ -13,9 +15,10 @@ import (
"amadan.net/rastrillo/rastrillo/csrf"
"amadan.net/rastrillo/rastrillo/migrate"
"amadan.net/rastrillo/rastrillo/sessions"
+ "gorm.io/gorm"
)
-var homeSchema = migrate.Merge(migrate.MustFromFS(handoff.Migrations, "home_handoff"), migrate.MustFromFS(linkstore.Migrations, "home_connect"))
+var homeSchema = migrate.Merge(migrate.MustFromFS(handoff.Migrations, "home_handoff"), migrate.MustFromFS(linkstore.Migrations, linkstore.SchemaNamespace))
var errHomeIdentity = errors.New("slides: Home identity refused")
func neutralSession(s sessions.Session) connect.Session {
@@ -48,14 +51,23 @@ func (a *app) initHome(c *connect.DecodedCredentials) error {
return connect.Person{}, errHomeIdentity
}
var u User
- if err := a.db.G.WithContext(ctx).Where("email = ?", normalizeAddress(s.Subject)).Take(&u).Error; err != nil || !a.allowed.allows(u.Email) {
- return connect.Person{}, errHomeIdentity
+ if err := a.db.G.WithContext(ctx).Where("email = ?", normalizeAddress(s.Subject)).Take(&u).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return connect.Person{}, sql.ErrNoRows
+ }
+ return connect.Person{}, err
+ }
+ if !a.allowed.allows(u.Email) {
+ return connect.Person{}, sql.ErrNoRows
}
return connect.Person{ID: u.ID, Label: u.Email}, nil
}, LookupPerson: func(ctx context.Context, q linkstore.Queryer, id int64) (string, error) {
var email string
- if err := q.QueryRowContext(ctx, "SELECT email FROM users WHERE id = ?", id).Scan(&email); err != nil || !a.allowed.allows(email) {
- return "", errHomeIdentity
+ if err := q.QueryRowContext(ctx, "SELECT email FROM users WHERE id = ?", id).Scan(&email); err != nil {
+ return "", err
+ }
+ if !a.allowed.allows(email) {
+ return "", sql.ErrNoRows
}
return email, nil
}, Render: a.renderHome})
@@ -89,8 +101,14 @@ func (a *app) homeUser(r *http.Request, s sessions.Session) (User, error) {
return User{}, err
}
var u User
- if err := a.db.G.WithContext(r.Context()).Where("id = ?", person.ID).Take(&u).Error; err != nil || !a.allowed.allows(u.Email) {
- return User{}, errHomeIdentity
+ if err := a.db.G.WithContext(r.Context()).Where("id = ?", person.ID).Take(&u).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return User{}, linkstore.ErrRefused
+ }
+ return User{}, fmt.Errorf("%w: %v", linkstore.ErrStorage, err)
+ }
+ if !a.allowed.allows(u.Email) {
+ return User{}, linkstore.ErrRefused
}
return u, nil
}
diff --git a/internal/slides/home_test.go b/internal/slides/home_test.go
index 608df90..25a87a4 100644
--- a/internal/slides/home_test.go
+++ b/internal/slides/home_test.go
@@ -1,11 +1,11 @@
package slides
import (
- "amadan.net/rastrillo/rastrillo/assertion"
"bytes"
"context"
"encoding/base64"
"encoding/json"
+ "errors"
"net/http"
"net/http/httptest"
"net/url"
@@ -15,10 +15,13 @@ import (
"time"
"amadan.net/oficina/home/connect"
+ "amadan.net/oficina/home/connect/linkstore"
"amadan.net/oficina/home/handoff"
"amadan.net/rastrillo/rastrillo"
+ "amadan.net/rastrillo/rastrillo/assertion"
assertioncrypto "amadan.net/rastrillo/rastrillo/crypto"
"amadan.net/rastrillo/rastrillo/sessions"
+ "gorm.io/gorm"
)
const homeTestOrigin = "https://slides.example.com"
@@ -64,10 +67,38 @@ func homeSession(t *testing.T, a *app, subject, method string) *http.Cookie {
}
func homeLinked(t *testing.T, a *app, u User) {
t.Helper()
- if err := a.db.G.Exec("INSERT INTO home_identity_links(issuer,subject,person_id) VALUES(?,?,?)", a.cfg.Home.Issuer, "home-person", u.ID).Error; err != nil {
+ ctx := context.Background()
+ now := time.Now()
+ c, err := connect.DecodeCredentials(a.cfg.Home, a.cfg.Origin)
+ if err != nil {
+ t.Fatal(err)
+ }
+ store, err := linkstore.New(a.db.Writer(), c.DEK, a.cfg.Origin, c.Issuer, "oficina/slides", func(ctx context.Context, q linkstore.Queryer, id int64) (string, error) {
+ var address string
+ err := q.QueryRowContext(ctx, "SELECT email FROM users WHERE id=?", id).Scan(&address)
+ return address, err
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ local := linkstore.Local{PersonID: u.ID, SessionToken: base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{17}, 32)), AuthTime: now}
+ nonce := base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{19}, 16))
+ init, err := store.Start(ctx, local, "/", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := store.BindNonce(ctx, init, local, nonce, now); err != nil {
+ t.Fatal(err)
+ }
+ proof, err := store.Complete(ctx, init, local, linkstore.Home{Issuer: c.Issuer, Subject: "home-person", Nonce: nonce, AuthTime: now}, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := store.Confirm(ctx, proof, local, now); err != nil {
t.Fatal(err)
}
}
+
func TestHomeExistingIdentityNoCreationAndStableActors(t *testing.T) {
mux, a, u := homeFixture(t)
homeLinked(t, a, u)
@@ -409,3 +440,145 @@ func TestHomeLocalPluginRecoveryRotatesThenReturnsToLink(t *testing.T) {
t.Fatal("recovery seeded deck")
}
}
+
+func TestHomeTransientReadFailureKeepsAllSessions(t *testing.T) {
+ mux, a, u := homeFixture(t)
+ homeLinked(t, a, u)
+ cookie := homeSession(t, a, "home-person", connect.HomeMethod)
+ _ = homeSession(t, a, "home-person", connect.HomeMethod)
+ if err := a.db.G.Callback().Query().Before("gorm:query").Register("home_test:transient", func(tx *gorm.DB) {
+ if tx.Statement.Table == "users" {
+ tx.AddError(errors.New("synthetic transient read failure"))
+ }
+ }); err != nil {
+ t.Fatal(err)
+ }
+ r := httptest.NewRequest("GET", homeTestOrigin+"/", nil)
+ r.AddCookie(cookie)
+ w := httptest.NewRecorder()
+ mux.ServeHTTP(w, r)
+ if w.Code != 500 {
+ t.Fatal("storage read did not fail", w.Code)
+ }
+ var n int64
+ a.db.G.Table("sessions").Count(&n)
+ if n != 2 {
+ t.Fatal("transient read revoked sessions", n)
+ }
+ for _, c := range w.Result().Cookies() {
+ if c.Name == cookie.Name {
+ t.Fatal("transient read mutated session cookie")
+ }
+ }
+}
+func TestHomeFinishRemovedPersonIsAdmissionRefusal(t *testing.T) {
+ for _, remove := range []string{"deleted", "denied"} {
+ t.Run(remove, func(t *testing.T) {
+ mux, a, u, key := homeFixtureKey(t)
+ homeLinked(t, a, u)
+ start := httptest.NewRecorder()
+ mux.ServeHTTP(start, httptest.NewRequest("GET", homeTestOrigin+handoff.StartPath, nil))
+ target, _ := url.Parse(start.Header().Get("Location"))
+ if start.Code != 303 {
+ t.Fatal("start", start.Code)
+ }
+ now := time.Now().Unix()
+ id, _ := assertion.RandomID()
+ token, err := assertion.Sign(key, assertion.Claims{Version: 1, Issuer: a.cfg.Home.Issuer, Audience: homeTestOrigin, Subject: "home-person", KeyID: "test", ID: id, Nonce: target.Query().Get("nonce"), IssuedAt: now, ExpiresAt: now + 90, AuthTime: now})
+ if err != nil {
+ t.Fatal(err)
+ }
+ callbackReq := httptest.NewRequest("POST", homeTestOrigin+handoff.CallbackPath, strings.NewReader(url.Values{"assertion": {token}}.Encode()))
+ callbackReq.Header.Set("Origin", a.cfg.Home.Issuer)
+ callbackReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ callback := httptest.NewRecorder()
+ mux.ServeHTTP(callback, callbackReq)
+ if callback.Code != 303 {
+ t.Fatal("callback", callback.Code)
+ }
+ if remove == "deleted" {
+ if err := a.db.G.Delete(&u).Error; err != nil {
+ t.Fatal(err)
+ }
+ } else {
+ a.allowed = allowlist{addrs: map[string]struct{}{}}
+ }
+ finishReq := httptest.NewRequest("GET", homeTestOrigin+handoff.FinishPath, nil)
+ for _, response := range []*httptest.ResponseRecorder{start, callback} {
+ for _, c := range response.Result().Cookies() {
+ if c.MaxAge >= 0 {
+ finishReq.AddCookie(c)
+ }
+ }
+ }
+ finish := httptest.NewRecorder()
+ mux.ServeHTTP(finish, finishReq)
+ if finish.Code != 403 {
+ t.Fatal("admission became storage failure", finish.Code, finish.Body.String())
+ }
+ for _, c := range finish.Result().Cookies() {
+ if c.Name == a.homeSessions.CookieName() {
+ t.Fatal("removed person received session")
+ }
+ }
+ })
+ }
+}
+
+func TestHomeEditorPresentationAndExportUseExistingOwner(t *testing.T) {
+ mux, a, u := homeFixture(t)
+ homeLinked(t, a, u)
+ a.seedSampleDeckFor(context.Background(), u)
+ var deck Deck
+ if err := a.db.G.Where("owner_id=?", u.ID).Take(&deck).Error; err != nil {
+ t.Fatal(err)
+ }
+ home := homeSession(t, a, "home-person", connect.HomeMethod)
+ local := homeSession(t, a, u.Email, "magiclink")
+ request := func(method, path string, cookie *http.Cookie) *httptest.ResponseRecorder {
+ r := httptest.NewRequest(method, homeTestOrigin+path, nil)
+ r.AddCookie(cookie)
+ if method == "POST" {
+ r.Header.Set("Origin", homeTestOrigin)
+ }
+ w := httptest.NewRecorder()
+ mux.ServeHTTP(w, r)
+ return w
+ }
+ base := "/d/" + deck.Sqid
+ for _, path := range []string{base + "/edit", base + "/updates?since=0", "/agents"} {
+ if w := request("GET", path, home); w.Code != 200 {
+ t.Fatal("Home route", path, w.Code)
+ }
+ }
+ present := request("POST", base+"/present", home)
+ if present.Code != 303 {
+ t.Fatal("present", present.Code)
+ }
+ if notes := request("GET", present.Header().Get("Location"), home); notes.Code != 200 {
+ t.Fatal("Home presenter lost access", notes.Code)
+ }
+ export := request("POST", base+"/export", home)
+ if export.Code != 303 {
+ t.Fatal("export", export.Code)
+ }
+ status := export.Header().Get("Location")
+ if page := request("GET", status, home); page.Code != 200 {
+ t.Fatal("Home job status", page.Code, page.Body.String())
+ }
+ if page := request("GET", status, local); page.Code != 200 {
+ t.Fatal("local method lost same owner's job", page.Code)
+ }
+ deadline := time.Now().Add(30 * time.Second)
+ for time.Now().Before(deadline) {
+ pdf := request("GET", status+".pdf", home)
+ if pdf.Code == 200 {
+ if !bytes.HasPrefix(pdf.Body.Bytes(), []byte("%PDF-")) {
+ t.Fatal("export not PDF")
+ }
+ return
+ }
+ time.Sleep(25 * time.Millisecond)
+ }
+ t.Fatal("Home export did not become readable PDF")
+}
diff --git a/internal/slides/presentation.go b/internal/slides/presentation.go
index 6002043..8cf083a 100644
--- a/internal/slides/presentation.go
+++ b/internal/slides/presentation.go
@@ -42,7 +42,6 @@
package slides
import (
- "amadan.net/oficina/home/connect"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
@@ -59,15 +58,14 @@ import (
"strings"
"time"
- "github.com/go-chi/chi/v5"
- "github.com/skip2/go-qrcode"
- "gorm.io/gorm"
-
- "amadan.net/rastrillo/rastrillo"
-
+ "amadan.net/oficina/home/connect"
"amadan.net/oficina/slides/internal/slides/deck"
"amadan.net/oficina/slides/internal/slides/present"
slidehtml "amadan.net/oficina/slides/internal/slides/render"
+ "amadan.net/rastrillo/rastrillo"
+ "github.com/go-chi/chi/v5"
+ "github.com/skip2/go-qrcode"
+ "gorm.io/gorm"
)
// --- what a session is, and how long it lasts ---
diff --git a/internal/slides/render.go b/internal/slides/render.go
index 16212ab..1ed4ee8 100644
--- a/internal/slides/render.go
+++ b/internal/slides/render.go
@@ -1,16 +1,15 @@
package slides
import (
- "amadan.net/oficina/home/connect"
"bytes"
"embed"
"html/template"
"net/http"
+ "amadan.net/oficina/home/connect"
+ "amadan.net/oficina/slides/internal/slides/icons"
"amadan.net/rastrillo/rastrillo"
"amadan.net/rastrillo/rastrillo/ui"
-
- "amadan.net/oficina/slides/internal/slides/icons"
)
//go:embed templates static