eleven / ocho Public

home: day-coarse last_seen + version-as-string on the wire (#550 Phase 0) (#612)

Two small pre-S3 fixes from the plan: link-token lookups rewrite last_seen
only when it's over a day stale (an object-store backend must not pay a CAS
write per authenticated read), and PUT /api/blob's JSON version becomes an
opaque string like the ETag (both clients already parse it as one — web
String()s it, native decodes string-or-number).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell a0f8e89867bb974d6e6685a675c3bb684eb9d5c2 parent 6e90cda
5 files changed, +74 −11
  • home/db.go +8 −2
  • home/home_test.go +5 −5
  • home/main.go +4 −3
  • home/store.go +8 −1
  • home/store_test.go +49 −0
diff --git a/home/db.go b/home/db.go
index 10de81f..8cc6178 100644
--- a/home/db.go
+++ b/home/db.go
@@ -309,13 +309,19 @@ func (db *sqliteStore) createLinkToken(userID string) (string, error) {
return tok, err
}
-// linkTokenUser resolves a token hash to its user and bumps last_seen.
+// linkTokenUser resolves a token hash to its user and bumps last_seen — but
+// only when the recorded value is over a day stale. last_seen exists solely
+// for the devices list, where day granularity is plenty, and on a CAS'd
+// object store a write-per-read would turn every authenticated GET into a
+// PUT (#550 Phase 0).
func (db *sqliteStore) linkTokenUser(tokenHash string) (string, bool) {
var uid string
if db.sql.QueryRow("SELECT user_id FROM link_tokens WHERE token_hash = ?", tokenHash).Scan(&uid) != nil {
return "", false
}
- db.sql.Exec("UPDATE link_tokens SET last_seen = ? WHERE token_hash = ?", time.Now().Unix(), tokenHash)
+ now := time.Now().Unix()
+ db.sql.Exec("UPDATE link_tokens SET last_seen = ? WHERE token_hash = ? AND last_seen < ?",
+ now, tokenHash, now-lastSeenGranularity)
return uid, true
}
diff --git a/home/home_test.go b/home/home_test.go
index 5a4a4bf..5e71706 100644
--- a/home/home_test.go
+++ b/home/home_test.go
@@ -106,11 +106,11 @@ func TestBlobPutGet304(t *testing.T) {
if got := r.Header.Get("ETag"); got != `"1"` {
t.Fatalf("PUT ETag: want %q, got %q", `"1"`, got)
}
- // The response's version is (still) a bare JSON number on the wire —
- // Phase 0 item 4 of #550 changes it to a string, deliberately, not by
- // accident of a storage refactor.
- if body, _ := io.ReadAll(r.Body); !strings.Contains(string(body), `"version":1`) {
- t.Fatalf("PUT body should carry version as a JSON number, got %s", body)
+ // The response's version is an opaque JSON string, like the ETag (#550
+ // Phase 0) — a future backend's non-numeric versions must not need a wire
+ // change.
+ if body, _ := io.ReadAll(r.Body); !strings.Contains(string(body), `"version":"1"`) {
+ t.Fatalf("PUT body should carry version as a JSON string, got %s", body)
}
r = do(t, "GET", ts.URL+"/api/blob", tok, nil, nil)
diff --git a/home/main.go b/home/main.go
index 504f11e..50fdd55 100644
--- a/home/main.go
+++ b/home/main.go
@@ -378,9 +378,10 @@ func (s *Server) putBlobHandler(w http.ResponseWriter, r *http.Request) {
return
}
w.Header().Set("ETag", `"`+newVersion+`"`)
- // The version is a JSON number today (json.Number keeps the wire byte-for-
- // byte identical); Phase 0 item 4 of #550 switches it to a plain string.
- writeJSON(w, http.StatusOK, map[string]any{"version": json.Number(newVersion)})
+ // The version is an opaque string on the wire, like the ETag — clients
+ // already treat it that way (#550 Phase 0), so a future backend's
+ // non-numeric versions need no client change.
+ writeJSON(w, http.StatusOK, map[string]any{"version": newVersion})
}
func (s *Server) deleteBlobHandler(w http.ResponseWriter, r *http.Request) {
diff --git a/home/store.go b/home/store.go
index 10ee11e..01eb494 100644
--- a/home/store.go
+++ b/home/store.go
@@ -17,6 +17,11 @@ const blobKeepVersions = 5
// within this window, so a stolen link token can never reduce access.
const ceremonyWindow = 5 * time.Minute
+// lastSeenGranularity is how stale a link token's last_seen must be before a
+// lookup rewrites it (seconds). Day-coarse keeps the devices list honest while
+// sparing an object-store backend a write per authenticated read.
+const lastSeenGranularity = int64(24 * 60 * 60)
+
// errBadVersion reports an If-Match version string the store cannot even
// interpret (as opposed to one that merely mismatches, which is a conflict).
// The handler maps it to 400; a mismatch maps to 409.
@@ -85,7 +90,9 @@ type vaultStore interface {
// createLinkToken mints a bearer token for a user and returns the plaintext
// (shown/stored client-side once; only the hash persists in the store).
createLinkToken(uid string) (token string, err error)
- // linkTokenUser resolves a token HASH to its user and bumps last_seen.
+ // linkTokenUser resolves a token HASH to its user, bumping last_seen at
+ // most once per lastSeenGranularity (day-coarse — the devices list doesn't
+ // need better, and an object store shouldn't pay a write per read).
// Callers hash the presented plaintext (hashToken) — the store is keyed by
// hash and never sees a live token after minting.
linkTokenUser(tokenHash string) (uid string, ok bool)
diff --git a/home/store_test.go b/home/store_test.go
index 740ceb9..18b8591 100644
--- a/home/store_test.go
+++ b/home/store_test.go
@@ -41,6 +41,55 @@ func TestSQLitePutBlobBadVersion(t *testing.T) {
}
}
+// SQLite-specific (backdating needs SQL): last_seen bumps at most once per
+// lastSeenGranularity — an hour-stale value stays put, a two-day-stale one
+// refreshes. Guards the no-write-per-read property an object store depends on.
+func TestSQLiteLastSeenCoarse(t *testing.T) {
+ st, err := openSQLiteStore(t.TempDir() + "/store.db")
+ if err != nil {
+ t.Fatalf("openSQLiteStore: %v", err)
+ }
+ if err := st.createUser("u1"); err != nil {
+ t.Fatalf("createUser: %v", err)
+ }
+ tok, err := st.createLinkToken("u1")
+ if err != nil {
+ t.Fatalf("createLinkToken: %v", err)
+ }
+ lastSeen := func() int64 {
+ var v int64
+ if err := st.sql.QueryRow("SELECT last_seen FROM link_tokens WHERE token_hash = ?",
+ hashToken(tok)).Scan(&v); err != nil {
+ t.Fatalf("read last_seen: %v", err)
+ }
+ return v
+ }
+ backdate := func(by int64) int64 {
+ v := lastSeen() - by
+ if _, err := st.sql.Exec("UPDATE link_tokens SET last_seen = ? WHERE token_hash = ?",
+ v, hashToken(tok)); err != nil {
+ t.Fatalf("backdate: %v", err)
+ }
+ return v
+ }
+
+ fresh := backdate(3600) // an hour stale: within granularity, must not rewrite
+ if _, ok := st.linkTokenUser(hashToken(tok)); !ok {
+ t.Fatalf("linkTokenUser: want ok")
+ }
+ if got := lastSeen(); got != fresh {
+ t.Fatalf("hour-stale last_seen rewritten: want %d, got %d", fresh, got)
+ }
+
+ stale := backdate(2 * lastSeenGranularity) // two days stale: must refresh
+ if _, ok := st.linkTokenUser(hashToken(tok)); !ok {
+ t.Fatalf("linkTokenUser: want ok")
+ }
+ if got := lastSeen(); got <= stale {
+ t.Fatalf("two-day-stale last_seen not refreshed: still %d", got)
+ }
+}
+
// testVaultStore runs the shared contract suite against one backend.
func testVaultStore(t *testing.T, newStore func(t *testing.T) vaultStore) {
mkCred := func(id string) *webauthn.Credential {