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>
5 files changed,
+74
−11
home/db.go+8 −2home/home_test.go+5 −5home/main.go+4 −3home/store.go+8 −1home/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 { |