carlos / platform Public

console: GET /api/cli/apps/{account}/{app}/logs — the carlos logs read

applog.ReadWindow (hour-clipped, cross-box merge) + Cursor for follow;
handler shares instanceGate (app tokens may read their own logs), tails
the window by default, pages strictly-after with an opaque cursor, 1h
default window, truncation always stated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 6567e49efbf0fa3f30b00ad523cd5d7dc6d68124 parent 644df4e
7 files changed, +594 −0
  • internal/applog/cursor.go +49 −0
  • internal/applog/cursor_test.go +35 −0
  • internal/applog/read.go +97 −0
  • internal/applog/read_test.go +106 −0
  • internal/console/apilogs.go +153 −0
  • internal/console/apilogs_test.go +151 −0
  • internal/console/authcli.go +3 −0
diff --git a/internal/applog/cursor.go b/internal/applog/cursor.go
new file mode 100644
index 000000000..3748ca849
--- /dev/null
+++ b/internal/applog/cursor.go
@@ -0,0 +1,49 @@
+package applog
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "time"
+)
+
+// Cursor names a position in the merged timeline — the last record a reader
+// has seen, by exactly the (TS, Box, Seq) triple MergeRecords orders by.
+// Opaque base64 on the wire so its shape is the server's to evolve.
+type Cursor struct {
+ TS time.Time `json:"ts"`
+ Box string `json:"box"`
+ Seq uint64 `json:"seq"`
+}
+
+// Before reports whether c sits strictly before r in merge order — the
+// follow test: records with c.Before(r) are new since the cursor.
+func (c Cursor) Before(r Record) bool {
+ if !c.TS.Equal(r.TS) {
+ return c.TS.Before(r.TS)
+ }
+ if c.Box != r.Box {
+ return c.Box < r.Box
+ }
+ return c.Seq < r.Seq
+}
+
+// EncodeCursor renders the position just after r.
+func EncodeCursor(r Record) string {
+ b, _ := json.Marshal(Cursor{TS: r.TS, Box: r.Box, Seq: r.Seq}) // cannot fail on this shape
+ return base64.RawURLEncoding.EncodeToString(b)
+}
+
+// DecodeCursor parses what EncodeCursor produced; ok=false for anything
+// else (including future shapes this build cannot read — the caller treats
+// that as "no cursor", which re-tails rather than erroring a follow).
+func DecodeCursor(s string) (Cursor, bool) {
+ b, err := base64.RawURLEncoding.DecodeString(s)
+ if err != nil {
+ return Cursor{}, false
+ }
+ var c Cursor
+ if json.Unmarshal(b, &c) != nil || c.TS.IsZero() {
+ return Cursor{}, false
+ }
+ return c, true
+}
diff --git a/internal/applog/cursor_test.go b/internal/applog/cursor_test.go
new file mode 100644
index 000000000..433bdd818
--- /dev/null
+++ b/internal/applog/cursor_test.go
@@ -0,0 +1,35 @@
+package applog
+
+import (
+ "testing"
+ "time"
+)
+
+func TestCursorRoundTripAndOrder(t *testing.T) {
+ r := Record{TS: time.Date(2026, 8, 10, 12, 0, 30, 500, time.UTC), Box: "b1", Seq: 42, Msg: "m"}
+ c, ok := DecodeCursor(EncodeCursor(r))
+ if !ok || !c.TS.Equal(r.TS) || c.Box != "b1" || c.Seq != 42 {
+ t.Fatalf("round trip: %+v %v", c, ok)
+ }
+ // Before mirrors MergeRecords' (ts, box, seq) order exactly.
+ if c.Before(r) {
+ t.Fatal("a cursor at r is not before r")
+ }
+ for _, after := range []Record{
+ {TS: r.TS.Add(time.Nanosecond), Box: "a0", Seq: 1},
+ {TS: r.TS, Box: "b2", Seq: 1},
+ {TS: r.TS, Box: "b1", Seq: 43},
+ } {
+ if !c.Before(after) {
+ t.Fatalf("cursor should be before %+v", after)
+ }
+ }
+ if c.Before(Record{TS: r.TS, Box: "b1", Seq: 41}) {
+ t.Fatal("cursor before an earlier seq")
+ }
+ for _, bad := range []string{"", "!!!", "bm90IGpzb24"} {
+ if _, ok := DecodeCursor(bad); ok {
+ t.Errorf("DecodeCursor(%q) ok", bad)
+ }
+ }
+}
diff --git a/internal/applog/read.go b/internal/applog/read.go
new file mode 100644
index 000000000..d0c41ea83
--- /dev/null
+++ b/internal/applog/read.go
@@ -0,0 +1,97 @@
+package applog
+
+import (
+ "context"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// ReadWindow assembles one app's merged timeline from the bucket: every
+// box's chunks whose hour directories intersect [f.Since, f.Until], decoded,
+// filtered through f, merged in (ts, box, seq) order. Hour pruning happens
+// at the PREFIX level — an hour outside the window is never listed, which
+// is what makes a 1h read over 7d of history cheap (spec §3).
+//
+// collectCap bounds memory against a pathological window: past it the
+// OLDEST records go (the tail is what a log reader came for), and the
+// second return says so. Store errors fail the read — a partial answer
+// that looks whole is the one thing this must never produce.
+func ReadWindow(ctx context.Context, s release.Store, acct, app string, f Filter, collectCap int) ([]Record, bool, error) {
+ boxDirs, err := s.ListDirs(ctx, Prefix(acct, app))
+ if err != nil {
+ return nil, false, err
+ }
+ trimmed := false
+ perBox := make([][]Record, 0, len(boxDirs))
+ for _, boxDir := range boxDirs {
+ hourDirs, err := s.ListDirs(ctx, boxDir)
+ if err != nil {
+ return nil, false, err
+ }
+ var recs []Record
+ for _, hourDir := range hourDirs { // lexically sorted = chronological
+ if !hourIntersects(hourNameOfDir(hourDir), f.Since, f.Until) {
+ continue
+ }
+ keys, err := s.List(ctx, hourDir)
+ if err != nil {
+ return nil, false, err
+ }
+ for _, key := range keys { // lexically sorted = seq order
+ body, _, err := s.Get(ctx, key)
+ if err != nil {
+ return nil, false, err
+ }
+ chunk, err := DecodeChunk(body)
+ if err != nil {
+ return nil, false, err
+ }
+ for _, r := range chunk {
+ if f.Match(r) {
+ recs = append(recs, r)
+ }
+ }
+ if over := len(recs) - collectCap; over > 0 {
+ recs = recs[over:]
+ trimmed = true
+ }
+ }
+ }
+ perBox = append(perBox, recs)
+ }
+ merged := MergeRecords(perBox...)
+ if over := len(merged) - collectCap; over > 0 {
+ merged = merged[over:]
+ trimmed = true
+ }
+ return merged, trimmed, nil
+}
+
+// hourIntersects reports whether the hour directory named hour overlaps
+// [since, until] (zero = unbounded). Unparsable names never match — an
+// alien directory is not silently read as data.
+func hourIntersects(hour string, since, until time.Time) bool {
+ start, err := time.ParseInLocation(hourFormat, hour, time.UTC)
+ if err != nil {
+ return false
+ }
+ end := start.Add(time.Hour)
+ if !since.IsZero() && !end.After(since) {
+ return false
+ }
+ if !until.IsZero() && start.After(until) {
+ return false
+ }
+ return true
+}
+
+// hourNameOfDir extracts the final path segment of a "/"-terminated prefix.
+func hourNameOfDir(dir string) string {
+ trimmed := strings.TrimSuffix(dir, "/")
+ if i := strings.LastIndexByte(trimmed, '/'); i >= 0 {
+ return trimmed[i+1:]
+ }
+ return trimmed
+}
diff --git a/internal/applog/read_test.go b/internal/applog/read_test.go
new file mode 100644
index 000000000..17883a1a8
--- /dev/null
+++ b/internal/applog/read_test.go
@@ -0,0 +1,106 @@
+package applog
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// seedWindow writes chunks for two boxes across three hours.
+func seedWindow(t *testing.T, s release.Store) {
+ t.Helper()
+ ctx := context.Background()
+ write := func(box, hour string, seq uint64, recs []Record) {
+ t.Helper()
+ body, err := EncodeChunk(recs)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.Put(ctx, ChunkKey("bde", "lpay", box, hour, seq, len(body)), body, ""); err != nil {
+ t.Fatal(err)
+ }
+ }
+ ts := func(h, m int) time.Time { return time.Date(2026, 8, 10, h, m, 0, 0, time.UTC) }
+ write("box1", "2026-08-10-10", 100, []Record{
+ {TS: ts(10, 5), Box: "box1", Seq: 100, Stream: StreamApp, Host: "h1", Msg: "old line"},
+ })
+ write("box1", "2026-08-10-11", 200, []Record{
+ {TS: ts(11, 10), Box: "box1", Seq: 200, Stream: StreamApp, Host: "h1", Msg: "b1 app"},
+ {TS: ts(11, 30), Box: "box1", Seq: 201, Stream: StreamPlatform, Level: LevelError, Host: "h1", Msg: "wake failed: x"},
+ })
+ write("box2", "2026-08-10-11", 500, []Record{
+ {TS: ts(11, 20), Box: "box2", Seq: 500, Stream: StreamApp, Host: "h2", Msg: "b2 app"},
+ })
+}
+
+func TestReadWindowMergesAndClipsHours(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ seedWindow(t, s)
+ ctx := context.Background()
+
+ // Window covering hour 11 only: the hour-10 chunk is never even listed.
+ f := Filter{Since: time.Date(2026, 8, 10, 11, 0, 0, 0, time.UTC)}
+ recs, trimmed, err := ReadWindow(ctx, s, "bde", "lpay", f, 1000)
+ if err != nil || trimmed {
+ t.Fatalf("read: %v trimmed=%v", err, trimmed)
+ }
+ want := []string{"b1 app", "b2 app", "wake failed: x"} // (ts, box, seq) order
+ if len(recs) != len(want) {
+ t.Fatalf("got %d records: %+v", len(recs), recs)
+ }
+ for i, m := range want {
+ if recs[i].Msg != m {
+ t.Fatalf("order[%d] = %q, want %q", i, recs[i].Msg, m)
+ }
+ }
+
+ // Stream filter composes.
+ recs, _, err = ReadWindow(ctx, s, "bde", "lpay", Filter{Stream: StreamPlatform}, 1000)
+ if err != nil || len(recs) != 1 || recs[0].Level != LevelError {
+ t.Fatalf("platform filter: %v %+v", err, recs)
+ }
+
+ // Unbounded window sees all three hours.
+ recs, _, err = ReadWindow(ctx, s, "bde", "lpay", Filter{}, 1000)
+ if err != nil || len(recs) != 4 {
+ t.Fatalf("unbounded: %v, %d records", err, len(recs))
+ }
+
+ // collectCap keeps the TAIL and says it trimmed.
+ recs, trimmed, err = ReadWindow(ctx, s, "bde", "lpay", Filter{}, 2)
+ if err != nil || !trimmed || len(recs) != 2 {
+ t.Fatalf("capped: %v trimmed=%v n=%d", err, trimmed, len(recs))
+ }
+ if recs[len(recs)-1].Msg != "wake failed: x" {
+ t.Fatalf("cap dropped the tail: %+v", recs)
+ }
+
+ // An app with no logs at all is an empty answer, not an error.
+ recs, _, err = ReadWindow(ctx, s, "bde", "ghost", Filter{}, 1000)
+ if err != nil || len(recs) != 0 {
+ t.Fatalf("empty app: %v %+v", err, recs)
+ }
+}
+
+func TestHourIntersects(t *testing.T) {
+ since := time.Date(2026, 8, 10, 11, 0, 0, 0, time.UTC)
+ for hour, want := range map[string]bool{
+ "2026-08-10-10": false, // ends exactly at since — nothing at/after since lives there
+ "2026-08-10-11": true,
+ "2026-08-10-12": true,
+ "not-an-hour": false,
+ } {
+ if got := hourIntersects(hour, since, time.Time{}); got != want {
+ t.Errorf("hourIntersects(%q, since) = %v, want %v", hour, got, want)
+ }
+ }
+ until := time.Date(2026, 8, 10, 11, 30, 0, 0, time.UTC)
+ if hourIntersects("2026-08-10-12", time.Time{}, until) {
+ t.Error("an hour starting after until intersects")
+ }
+ if !hourIntersects("2026-08-10-11", since, until) {
+ t.Error("the window's own hour should intersect")
+ }
+}
diff --git a/internal/console/apilogs.go b/internal/console/apilogs.go
new file mode 100644
index 000000000..ae7d177e5
--- /dev/null
+++ b/internal/console/apilogs.go
@@ -0,0 +1,153 @@
+// The CLI's logs API — the read side of `carlos logs` (spec
+// 2026-08-10-carlos-logs-design.md §6): one owner-gated GET that merges the
+// app's shipped chunks across boxes, filters, and pages. It shares the
+// instance family's gate (instanceGate) deliberately: same principal proof,
+// same byte-identical 404, same rate limiter — and an APP token may read
+// its own app's logs, which is the same trust the instance verbs already
+// extend it.
+package console
+
+import (
+ "context"
+ "log"
+ "net/http"
+ "regexp"
+ "strconv"
+ "time"
+
+ "github.com/carlosframework/platform/internal/applog"
+)
+
+const (
+ // logsDefaultLimit / logsMaxLimit bound one answer (spec §6). The
+ // response's `truncated` says when the window held more.
+ logsDefaultLimit = 1000
+ logsMaxLimit = 5000
+ // logsCollectCap bounds what one read will hold in memory while
+ // assembling the window — the tail survives, and `truncated` reports it.
+ logsCollectCap = 50000
+ // logsDefaultWindow applies when the caller sends no `since`: an
+ // unbounded read would walk every hour of retention for no stated
+ // reason; a caller that wants more says so.
+ logsDefaultWindow = time.Hour
+)
+
+// logsResponse is the wire shape both the CLI and the console tab read.
+type logsResponse struct {
+ Records []applog.Record `json:"records"`
+ Truncated bool `json:"truncated"`
+ Cursor string `json:"cursor,omitempty"`
+}
+
+// handleAPILogs answers `carlos logs`: GET with since/until/stream/host/
+// level/grep/field/limit/cursor query params. Without a cursor the answer
+// is the TAIL of the window (the last `limit` records — what a log reader
+// came for); with one it is the records strictly after that position, which
+// is what makes `-f` a plain 2s poll of this same endpoint.
+func (s *Server) handleAPILogs(w http.ResponseWriter, r *http.Request) {
+ _, acct, app, ok := s.instanceGate(w, r)
+ if !ok {
+ return
+ }
+ f, limit, badParam := logsFilterFromQuery(r, time.Now())
+ if badParam != "" {
+ jsonError(w, http.StatusBadRequest, badParam)
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ defer cancel()
+ recs, trimmed, err := applog.ReadWindow(ctx, s.cfg.Releases, acct.Sqid, app, f, logsCollectCap)
+ if err != nil {
+ log.Printf("console: api logs %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not read the logs")
+ return
+ }
+
+ resp := logsResponse{Truncated: trimmed}
+ if cur, ok := applog.DecodeCursor(r.URL.Query().Get("cursor")); ok {
+ // Follow: everything strictly after the cursor, oldest first.
+ var after []applog.Record
+ for _, rec := range recs {
+ if cur.Before(rec) {
+ after = append(after, rec)
+ }
+ }
+ if len(after) > limit {
+ after = after[:limit]
+ resp.Truncated = true
+ }
+ resp.Records = after
+ } else {
+ // Tail: the last `limit` records of the window.
+ if len(recs) > limit {
+ recs = recs[len(recs)-limit:]
+ resp.Truncated = true
+ }
+ resp.Records = recs
+ }
+ if n := len(resp.Records); n > 0 {
+ resp.Cursor = applog.EncodeCursor(resp.Records[n-1])
+ } else if c := r.URL.Query().Get("cursor"); c != "" {
+ resp.Cursor = c // nothing new — the caller keeps polling from here
+ }
+ if resp.Records == nil {
+ resp.Records = []applog.Record{} // "no logs" is [], not null
+ }
+ writeJSON(w, http.StatusOK, resp)
+}
+
+// logsFilterFromQuery builds the applog.Filter (plus record limit) from the
+// request. The empty badParam means the params parsed; anything else is the
+// 400 text, naming the parameter in the member's own terms.
+func logsFilterFromQuery(r *http.Request, now time.Time) (f applog.Filter, limit int, badParam string) {
+ q := r.URL.Query()
+ var err error
+ if f.Since, err = applog.ParseTimeArg(q.Get("since"), now); err != nil {
+ return f, 0, "since: " + err.Error()
+ }
+ if f.Until, err = applog.ParseTimeArg(q.Get("until"), now); err != nil {
+ return f, 0, "until: " + err.Error()
+ }
+ if f.Since.IsZero() && q.Get("cursor") == "" {
+ f.Since = now.Add(-logsDefaultWindow)
+ }
+ if v := q.Get("stream"); v != "" {
+ if !applog.ValidStream(v) {
+ return f, 0, "stream: want app, platform, edge or release"
+ }
+ f.Stream = v
+ }
+ f.Host = q.Get("host")
+ if v := q.Get("level"); v != "" {
+ if !applog.ValidLevel(v) {
+ return f, 0, "level: want debug, info, warn or error"
+ }
+ f.Level = v
+ }
+ if v := q.Get("grep"); v != "" {
+ re, err := regexp.Compile(v)
+ if err != nil {
+ return f, 0, "grep: " + err.Error()
+ }
+ f.Grep = re
+ }
+ for _, kv := range q["field"] {
+ k, v, err := applog.ParseFieldArg(kv)
+ if err != nil {
+ return f, 0, err.Error()
+ }
+ if f.Fields == nil {
+ f.Fields = map[string]string{}
+ }
+ f.Fields[k] = v
+ }
+ limit = logsDefaultLimit
+ if v := q.Get("limit"); v != "" {
+ n, err := strconv.Atoi(v)
+ if err != nil || n < 1 {
+ return f, 0, "limit: want a positive integer"
+ }
+ limit = min(n, logsMaxLimit)
+ }
+ return f, limit, ""
+}
diff --git a/internal/console/apilogs_test.go b/internal/console/apilogs_test.go
new file mode 100644
index 000000000..85b1bcbf7
--- /dev/null
+++ b/internal/console/apilogs_test.go
@@ -0,0 +1,151 @@
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/applog"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func logsURL(fx *consoleFixture, acct, app, query string) string {
+ u := fx.ts.URL + "/api/cli/apps/" + acct + "/" + app + "/logs"
+ if query != "" {
+ u += "?" + query
+ }
+ return u
+}
+
+// seedLogChunks ships a small timeline for titogo: an app line and a wake
+// failure, recent enough for the default 1h window.
+func seedLogChunks(t *testing.T, fx *consoleFixture, now time.Time) []applog.Record {
+ t.Helper()
+ recs := []applog.Record{
+ {TS: now.Add(-10 * time.Minute), Box: "box1", Host: "titogo.example.com",
+ Stream: applog.StreamApp, Msg: "listening on socket", Seq: 100,
+ Fields: map[string]string{"request_id": "r1"}},
+ {TS: now.Add(-5 * time.Minute), Box: "box1", Host: "titogo.example.com",
+ Stream: applog.StreamPlatform, Level: applog.LevelError,
+ Msg: "wake failed: nothing is promoted for this instance's channel", Seq: 101},
+ }
+ body, err := applog.EncodeChunk(recs)
+ if err != nil {
+ t.Fatal(err)
+ }
+ key := applog.ChunkKey(release.OpsAccount, "titogo", "box1", applog.HourOf(recs[0].TS), recs[0].Seq, len(body))
+ if _, err := fx.rel.Put(context.Background(), key, body, ""); err != nil {
+ t.Fatal(err)
+ }
+ return recs
+}
+
+// TestAPILogsTailFiltersAndFollow drives the endpoint end to end: default
+// tail, stream/level/grep/field filters, and the cursor follow loop.
+func TestAPILogsTailFiltersAndFollow(t *testing.T) {
+ fx, token := instanceFixture(t, "code-logs-1")
+ now := time.Now().UTC()
+ seedLogChunks(t, fx, now)
+
+ get := func(query string) logsResponse {
+ t.Helper()
+ status, _, body := rawReq(t, http.MethodGet, logsURL(fx, fx.sqid, "titogo", query), token, "")
+ if status != http.StatusOK {
+ t.Fatalf("GET ?%s = %d: %s", query, status, body)
+ }
+ var resp logsResponse
+ if err := json.Unmarshal([]byte(body), &resp); err != nil {
+ t.Fatalf("bad JSON: %v: %s", err, body)
+ }
+ return resp
+ }
+
+ // Default window: both records, oldest first, cursor present.
+ resp := get("")
+ if len(resp.Records) != 2 || resp.Records[0].Msg != "listening on socket" {
+ t.Fatalf("tail: %+v", resp)
+ }
+ if resp.Cursor == "" || resp.Truncated {
+ t.Fatalf("cursor/truncated: %+v", resp)
+ }
+
+ // Filters compose: stream, level, grep, field.
+ if r := get("stream=platform"); len(r.Records) != 1 || r.Records[0].Level != "error" {
+ t.Fatalf("stream filter: %+v", r.Records)
+ }
+ if r := get("level=error"); len(r.Records) != 1 {
+ t.Fatalf("level filter: %+v", r.Records)
+ }
+ if r := get("grep=promoted"); len(r.Records) != 1 || r.Records[0].Stream != "platform" {
+ t.Fatalf("grep filter: %+v", r.Records)
+ }
+ if r := get("field=request_id%3Dr1"); len(r.Records) != 1 || r.Records[0].Stream != "app" {
+ t.Fatalf("field filter: %+v", r.Records)
+ }
+ if r := get("host=titogo.example.com"); len(r.Records) != 2 {
+ t.Fatalf("host filter: %+v", r.Records)
+ }
+ if r := get("host=other.example.com"); len(r.Records) != 0 {
+ t.Fatalf("host mismatch filter: %+v", r.Records)
+ }
+
+ // limit=1 tails to the NEWEST record and says truncated.
+ r := get("limit=1")
+ if len(r.Records) != 1 || r.Records[0].Stream != "platform" || !r.Truncated {
+ t.Fatalf("limit tail: %+v", r)
+ }
+
+ // Follow: the cursor from the full read sees nothing new, keeps itself.
+ full := get("")
+ fr := get("cursor=" + full.Cursor)
+ if len(fr.Records) != 0 || fr.Cursor != full.Cursor {
+ t.Fatalf("follow at tip: %+v", fr)
+ }
+ // A new record lands; the same cursor now yields exactly it.
+ extra := []applog.Record{{TS: now.Add(-time.Minute), Box: "box1",
+ Host: "titogo.example.com", Stream: applog.StreamApp, Msg: "fresh line", Seq: 102}}
+ body, err := applog.EncodeChunk(extra)
+ if err != nil {
+ t.Fatal(err)
+ }
+ key := applog.ChunkKey(release.OpsAccount, "titogo", "box1", applog.HourOf(extra[0].TS), 102, len(body))
+ if _, err := fx.rel.Put(context.Background(), key, body, ""); err != nil {
+ t.Fatal(err)
+ }
+ fr2 := get("cursor=" + full.Cursor)
+ if len(fr2.Records) != 1 || fr2.Records[0].Msg != "fresh line" {
+ t.Fatalf("follow after new record: %+v", fr2)
+ }
+}
+
+// TestAPILogsRefusals: a non-member's read is the family's byte-identical
+// 404; bad parameters name themselves in a 400.
+func TestAPILogsRefusals(t *testing.T) {
+ fx, _ := instanceFixture(t, "code-logs-2")
+ eve := cliToken(t, fx, "eve@keymail.dev", "code-logs-eve")
+ want := canonicalNotFound(t)
+ status, h, body := rawReq(t, http.MethodGet, logsURL(fx, fx.sqid, "titogo", ""), eve, "")
+ assertNotFound(t, "logs as non-member", want, status, h, body)
+
+ _, token := func() (*consoleFixture, string) { return fx, cliToken(t, fx, "alice@keymail.dev", "code-logs-3") }()
+ for _, q := range []string{"since=garbage", "stream=nope", "level=shouting", "grep=%5B", "field=nokey", "limit=zero"} {
+ status, _, body := rawReq(t, http.MethodGet, logsURL(fx, fx.sqid, "titogo", q), token, "")
+ if status != http.StatusBadRequest {
+ t.Errorf("?%s = %d, want 400 (%s)", q, status, body)
+ }
+ }
+
+ // An app with no logs at all: an empty list, not an error and not null.
+ status, _, body = rawReq(t, http.MethodGet, logsURL(fx, fx.sqid, "titogo", ""), token, "")
+ if status != http.StatusOK || !json.Valid([]byte(body)) {
+ t.Fatalf("empty logs: %d %s", status, body)
+ }
+ var resp logsResponse
+ if err := json.Unmarshal([]byte(body), &resp); err != nil || resp.Records == nil {
+ t.Fatalf("records must be [], got %s", body)
+ }
+ _ = fmt.Sprintf("%v", resp)
+}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 29b0facbb..168d53380 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -266,6 +266,9 @@ func (s *Server) registerCLIRoutes() {
// so a host named "instances-manifest" could still never reach it.
s.mux.HandleFunc("PUT /api/cli/apps/{account}/{app}/instances-manifest", s.handleAPIInstanceEnable)
s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/instances", s.handleAPIInstanceList)
+ // The logs read (`carlos logs`, apilogs.go) — same gate, same 404
+ // discipline, and an app token may read its own app's logs.
+ s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/logs", s.handleAPILogs)
s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/instances", s.handleAPIInstanceCreate)
s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/instances/{host}", s.handleAPIInstanceGet)
s.mux.HandleFunc("DELETE /api/cli/apps/{account}/{app}/instances/{host}", s.handleAPIInstanceDelete)