package bot // Card validation — the Go third of the card contract (web/static/cards.js · // LChatCore Card). The task runner is where cards get AUTHORED (the model // calls the card tool, task.go), so validating here turns a malformed card // into a tool_result the model can fix, instead of a card that silently falls // back to text on every device. Same rules, same vectors // (test/event-vectors/cards.json), fail closed on anything unknown. import ( "fmt" "strings" ) const cardSchema = 1 const ( maxCardBlocks = 32 maxCardListItems = 32 maxCardKVRows = 16 maxCardChips = 4 maxCardText = 4000 maxCardLabel = 200 maxCardURL = 2048 ) func cardStr(v any, max int) (string, bool) { s, ok := v.(string) if !ok || s == "" || len(s) > max { return "", false } return s, true } // cardOptStr distinguishes "absent" (ok, empty) from "present but wrong". func cardOptStr(m map[string]any, key string, max int) (string, bool) { v, present := m[key] if !present || v == nil { return "", true } s, ok := v.(string) if !ok || len(s) > max { return "", false } return s, true } func cardHTTPSURL(v any) (string, bool) { s, ok := cardStr(v, maxCardURL) if !ok { return "", false } lower := strings.ToLower(s) if !strings.HasPrefix(lower, "https://") && !strings.HasPrefix(lower, "http://") { return "", false } return s, true } // normalizeCard validates a card value (decoded JSON) into its canonical // form, or errors — the fail-closed contract shared with both renderers. func normalizeCard(v any) (map[string]any, error) { m, ok := v.(map[string]any) if !ok { return nil, fmt.Errorf("card must be an object") } if s, ok := m["schema"].(float64); !ok || s != cardSchema { return nil, fmt.Errorf("unknown card schema (want %d)", cardSchema) } blocks, err := normalizeCardBlocks(m["blocks"], true) if err != nil { return nil, err } return map[string]any{"schema": cardSchema, "blocks": blocks}, nil } func normalizeCardBlocks(v any, allowSection bool) ([]any, error) { list, ok := v.([]any) if !ok || len(list) == 0 || len(list) > maxCardBlocks { return nil, fmt.Errorf("blocks must be a non-empty array of at most %d", maxCardBlocks) } out := make([]any, 0, len(list)) for i, b := range list { n, err := normalizeCardBlock(b, allowSection) if err != nil { return nil, fmt.Errorf("block %d: %w", i, err) } out = append(out, n) } return out, nil } func normalizeCardBlock(v any, allowSection bool) (map[string]any, error) { m, ok := v.(map[string]any) if !ok { return nil, fmt.Errorf("not an object") } kind, _ := m["b"].(string) switch kind { case "heading": text, ok := cardStr(m["text"], maxCardLabel) if !ok { return nil, fmt.Errorf("heading needs text") } sub, ok := cardOptStr(m, "sub", maxCardLabel) if !ok { return nil, fmt.Errorf("bad heading sub") } out := map[string]any{"b": "heading", "text": text} if sub != "" { out["sub"] = sub } return out, nil case "text": text, ok := cardStr(m["text"], maxCardText) if !ok { return nil, fmt.Errorf("text block needs text") } return map[string]any{"b": "text", "text": text}, nil case "item": text, ok := cardStr(m["text"], maxCardText) if !ok { return nil, fmt.Errorf("item needs text") } source, ok := cardOptStr(m, "source", maxCardLabel) if !ok { return nil, fmt.Errorf("bad item source") } out := map[string]any{"b": "item", "text": text} if source != "" { out["source"] = source } if u, present := m["url"]; present && u != nil { url, ok := cardHTTPSURL(u) if !ok { return nil, fmt.Errorf("item url must be https") } out["url"] = url } return out, nil case "list": items, ok := m["items"].([]any) if !ok || len(items) == 0 || len(items) > maxCardListItems { return nil, fmt.Errorf("list needs 1-%d items", maxCardListItems) } out := make([]any, 0, len(items)) for _, it := range items { s, ok := cardStr(it, maxCardText) if !ok { return nil, fmt.Errorf("list items must be strings") } out = append(out, s) } return map[string]any{"b": "list", "items": out}, nil case "kv": rows, ok := m["rows"].([]any) if !ok || len(rows) == 0 || len(rows) > maxCardKVRows { return nil, fmt.Errorf("kv needs 1-%d rows", maxCardKVRows) } out := make([]any, 0, len(rows)) for _, r := range rows { pair, ok := r.([]any) if !ok || len(pair) != 2 { return nil, fmt.Errorf("kv rows are [key, value] pairs") } k, ok1 := cardStr(pair[0], maxCardLabel) val, ok2 := cardStr(pair[1], maxCardText) if !ok1 || !ok2 { return nil, fmt.Errorf("kv rows are [key, value] pairs") } out = append(out, []any{k, val}) } return map[string]any{"b": "kv", "rows": out}, nil case "divider": return map[string]any{"b": "divider"}, nil case "section": if !allowSection { return nil, fmt.Errorf("sections don't nest") } title, ok := cardOptStr(m, "title", maxCardLabel) if !ok { return nil, fmt.Errorf("bad section title") } blocks, err := normalizeCardBlocks(m["blocks"], false) if err != nil { return nil, err } out := map[string]any{"b": "section", "blocks": blocks} if title != "" { out["title"] = title } return out, nil case "chips": chips, ok := m["chips"].([]any) if !ok || len(chips) == 0 || len(chips) > maxCardChips { return nil, fmt.Errorf("chips needs 1-%d entries", maxCardChips) } out := make([]any, 0, len(chips)) for _, c := range chips { cm, ok := c.(map[string]any) if !ok { return nil, fmt.Errorf("chips are objects") } label, ok := cardStr(cm["label"], maxCardLabel) if !ok { return nil, fmt.Errorf("chip needs a label") } url, ok := cardHTTPSURL(cm["url"]) if !ok { return nil, fmt.Errorf("chip needs an https url (v1 chips are links)") } out = append(out, map[string]any{"label": label, "url": url}) } return map[string]any{"b": "chips", "chips": out}, nil default: return nil, fmt.Errorf("unknown block type %q", kind) } }