package main import ( "context" "encoding/json" "flag" "fmt" "os" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store" ) // sessionCommand is the `school session` entry point. It reads and writes the // per-child harness session state in the SQLite `session` table (AGENTS.md ยง7). // // The state is a secret (browser cookies / localStorage). `show` prints only a // summary; the state itself is only ever written to the file named by --out or // read from the file named by --file, with mode 0600. func sessionCommand(args []string) { if len(args) == 0 { fmt.Fprintln(os.Stderr, sessionUsage) os.Exit(2) } sub := args[0] fs := flag.NewFlagSet("session "+sub, flag.ContinueOnError) var ( child string out string file string asJSON bool ) fs.StringVar(&child, "child", "", "child name (Harry or Finn)") fs.StringVar(&out, "out", "", "get: write the state to this file (mode 0600)") fs.StringVar(&file, "file", "", "put: read the state from this file") fs.BoolVar(&asJSON, "json", false, "print the summary as JSON") _ = fs.Parse(args[1:]) switch sub { case "get": if child == "" || out == "" { fmt.Fprintln(os.Stderr, "school session get: --child and --out are required") os.Exit(2) } case "put": if child == "" || file == "" { fmt.Fprintln(os.Stderr, "school session put: --child and --file are required") os.Exit(2) } case "show", "clear": if child == "" && sub == "clear" { fmt.Fprintln(os.Stderr, "school session clear: --child is required") os.Exit(2) } default: fmt.Fprintln(os.Stderr, sessionUsage) os.Exit(2) } if err := runSession(sub, child, out, file, asJSON); err != nil { fmt.Fprintln(os.Stderr, "school session:", err) os.Exit(1) } } const sessionUsage = `usage: school session get --child NAME --out FILE school session put --child NAME --file FILE school session show [--child NAME] [--json] school session clear --child NAME` func runSession(sub, child, out, file string, asJSON bool) error { ctx := context.Background() cfg, err := config.LoadDB() if err != nil { return err } st, err := store.Open(cfg.DBPath) if err != nil { return err } defer st.Close() if err := st.Migrate(ctx); err != nil { return err } switch sub { case "get": return sessionGet(ctx, st, child, out) case "put": return sessionPut(ctx, st, child, file) case "show": return sessionShow(ctx, st, child, asJSON) case "clear": return sessionClear(ctx, st, child) } return fmt.Errorf("unknown session subcommand %q", sub) } func sessionGet(ctx context.Context, st *store.Store, child, out string) error { c, err := st.ChildByName(ctx, child) if err != nil { return err } if c == nil { return fmt.Errorf("unknown child %q", child) } sess, err := st.GetSession(ctx, c.ID) if err != nil { return err } if sess == nil { // No stored session is a normal state: the fetcher logs in fresh. fmt.Printf("child=%s no stored session\n", c.Name) return nil } if err := os.WriteFile(out, []byte(sess.State), 0o600); err != nil { return fmt.Errorf("write session file: %w", err) } fmt.Printf("child=%s bytes=%d out=%s\n", c.Name, len(sess.State), out) return nil } func sessionPut(ctx context.Context, st *store.Store, child, file string) error { c, err := st.ChildByName(ctx, child) if err != nil { return err } if c == nil { return fmt.Errorf("unknown child %q", child) } raw, err := os.ReadFile(file) if err != nil { return fmt.Errorf("read session file: %w", err) } if len(raw) == 0 { return fmt.Errorf("session file %s is empty", file) } if err := st.PutSession(ctx, c.ID, string(raw)); err != nil { return err } cookies, origins, ok := describeState(string(raw)) fmt.Printf("child=%s bytes=%d cookies=%d origins=%d valid_json=%t\n", c.Name, len(raw), cookies, origins, ok) return nil } func sessionClear(ctx context.Context, st *store.Store, child string) error { c, err := st.ChildByName(ctx, child) if err != nil { return err } if c == nil { return fmt.Errorf("unknown child %q", child) } if err := st.DeleteSession(ctx, c.ID); err != nil { return err } fmt.Printf("child=%s cleared\n", c.Name) return nil } // sessionSummary is what `show` prints: never the state itself. type sessionSummary struct { Child string `json:"child"` Bytes int `json:"bytes"` Cookies int `json:"cookies"` Origins int `json:"origins"` ValidJSON bool `json:"valid_json"` UpdatedAt string `json:"updated_at"` } func sessionShow(ctx context.Context, st *store.Store, child string, asJSON bool) error { var sessions []store.ChildSession if child != "" { c, err := st.ChildByName(ctx, child) if err != nil { return err } if c == nil { return fmt.Errorf("unknown child %q", child) } sess, err := st.GetSession(ctx, c.ID) if err != nil { return err } if sess != nil { sessions = append(sessions, *sess) } } else { var err error sessions, err = st.ListSessions(ctx) if err != nil { return err } } out := make([]sessionSummary, 0, len(sessions)) for _, s := range sessions { cookies, origins, ok := describeState(s.State) out = append(out, sessionSummary{ Child: s.Child, Bytes: len(s.State), Cookies: cookies, Origins: origins, ValidJSON: ok, UpdatedAt: s.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"), }) } if asJSON { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(out) } if len(out) == 0 { fmt.Println("no stored sessions") return nil } for _, s := range out { fmt.Printf("%-6s bytes=%-7d cookies=%-3d origins=%-3d valid_json=%-5t updated_at=%s\n", s.Child, s.Bytes, s.Cookies, s.Origins, s.ValidJSON, s.UpdatedAt) } return nil } // describeState reports the shape of a fetched storage state without revealing // any value. It lets `session show` prove a usable session without printing a // secret. func describeState(state string) (cookies, origins int, ok bool) { var parsed struct { Cookies []json.RawMessage `json:"cookies"` Origins []json.RawMessage `json:"origins"` } if err := json.Unmarshal([]byte(state), &parsed); err != nil { return 0, 0, false } return len(parsed.Cookies), len(parsed.Origins), true }