package main import ( "context" "encoding/json" "errors" "flag" "fmt" "io" "mime/multipart" "net/http" "net/http/cookiejar" "os" "strings" "time" "textmachine/platform/internal/auth" "textmachine/platform/internal/login" "textmachine/platform/internal/money" "textmachine/platform/internal/pgstore" ) // seed.go: one command that makes a development stand usable — a test account with credit and a book // that has been through the whole intake (П-16, owner 14.08). // // It exists so the frontend is built against the LIVE platform instead of against its own mocks, and // the shape follows from that purpose: everything a USER does, this command does over HTTP through // the same routes, with a real session. It signs in through the development sign-in and uploads // through `POST /v0/books`. The one thing it does from the admin side is the credit, because that is // what an operator does — there is no purchase path and there will not be one in the beta. // // Nothing is INSERTed into the read model directly, and that is the whole point of the exercise: a // seed that wrote rows itself would produce a stand whose books never went through intake, so the // first bug the frontend hit would be in a path the seed had quietly skipped. // seedTimeout bounds the whole walk, including waiting for the engine to cut the demo book. const seedTimeout = 5 * time.Minute func seed(ctx context.Context, store *pgstore.Store, args []string, out io.Writer) error { fs := flag.NewFlagSet("seed", flag.ContinueOnError) base := fs.String("url", "http://127.0.0.1:8080", "base URL of the running tmplatformd") subject := fs.String("subject", os.Getenv("TM_PLATFORM_DEV_LOGIN"), "the development identity, as the daemon's TM_PLATFORM_DEV_LOGIN names it") usd := fs.String("usd", "25", "credit to grant the test account, in dollars") source := fs.String("source", "", "a book file to upload; empty uses the built-in demo fragment") // Empty = whatever this deployment declares. A default pair here would be a second place the // supported languages are written down, and the first one to go stale. srcLang := fs.String("source-lang", "", "source language CODE; empty asks the deployment") dstLang := fs.String("target-lang", "", "target language CODE; empty asks the deployment") if err := fs.Parse(args); err != nil { return err } // Trimmed, because the daemon trims (config.Load) and this side has to name the SAME identity. // The two read one environment variable, and a value with a stray newline in it made them // disagree about who had just signed in: the sign-in created the account and spent its one-time // grant, and the lookup right below then found nothing and ended the walk. sub := strings.TrimSpace(*subject) if sub == "" { return errors.New("seed needs --subject (or TM_PLATFORM_DEV_LOGIN in the environment): it is the identity the daemon's development sign-in issues") } grant, err := money.ParseUSD(*usd) if err != nil { return err } ctx, cancel := context.WithTimeout(ctx, seedTimeout) defer cancel() c, err := newSeedClient(strings.TrimSuffix(*base, "/")) if err != nil { return err } // 1. The account, through the door a user walks. The daemon creates it — with its signup grant — // on the first sign-in, so nothing here has to know how an account is made. if err := c.devLogin(ctx); err != nil { return err } userID, err := store.UserByIdentity(ctx, login.DevProvider, sub) if err != nil { return fmt.Errorf("the sign-in succeeded but no account was found for it: %w", err) } _, _ = fmt.Fprintf(out, "account %s (identity %s/%s)\n", userID, login.DevProvider, sub) // 2. The credit, from the admin side — which is where credit comes from in the beta. // // Through the SAME helper the `grant` command uses, and not a second copy of it. That helper // exists for two rules this call has to obey as much as an operator's does: "applied" and "the // key was already spent" are different outcomes and the caller is told which one it got, and a // balance read that fails AFTER a committed write is a note on the line rather than a failure of // the write. The key is deterministic on the account, so seeding a stand twice tops it up once and // says so out loud. if err := write(ctx, store, out, userID, "seed-"+userID, func(id string, now time.Time) (bool, error) { return store.Grant(ctx, userID, grant, "admin", id, "development stand", now) }, "credit granted "+grant.USD()+" to "+userID); err != nil { return err } // 3. The book, through the contract's own intake. The response says `parsing`, and the walk is // only finished when the engine has cut it — so the command waits and reports the verdict // rather than leaving a half-seeded stand that looks fine. body, name, err := demoSource(*source) if err != nil { return err } if *srcLang == "" || *dstLang == "" { pair, err := c.firstAvailablePair(ctx) if err != nil { return err } if *srcLang == "" { *srcLang = pair.Source } if *dstLang == "" { *dstLang = pair.Target } } book, err := c.upload(ctx, uploadForm{ SourceLang: *srcLang, TargetLang: *dstLang, Filename: name, Body: body, }) if err != nil { return err } _, _ = fmt.Fprintf(out, "book %s (%s)\n", book.ID, book.Status) final, err := c.awaitIntake(ctx, book.ID) if err != nil { return err } _, _ = fmt.Fprintf(out, "intake %s, %d chapters\n", final.Status, final.ChapterCount) if final.Status != "not_started" { return fmt.Errorf("the demo book ended intake as %q: the stand cannot parse books, and the daemon's log says why", final.Status) } _, _ = fmt.Fprintf(out, "\nThe stand is seeded. To use it from a browser on this host, open the app and run\n"+ " await fetch('/auth/dev-login', {method: 'POST'})\n"+ "in the console once — that is the same call this command made, and it sets the session cookie.\n") return nil } // seedClient is a user of the platform, as far as the platform can tell: a cookie jar and nothing // else. type seedClient struct { base string http *http.Client } func newSeedClient(base string) (*seedClient, error) { jar, err := cookiejar.New(nil) if err != nil { return nil, err } return &seedClient{base: base, http: &http.Client{Jar: jar}}, nil } // do sends one request as the app under development sends it. // // ⚠ The client header is not ceremony and it is not a workaround: an unsafe request presented by // COOKIE has to carry it (auth.CSRF), because the stdlib's cross-origin check allows a request that // bears neither Sec-Fetch-Site nor Origin — which a pre-2023 browser posting a cross-site form is — // and `POST /books` takes multipart/form-data, a content type that triggers no preflight. // running this command against a live stand, which is the point of running it: the seed walks the // same doors the frontend does, so a door the frontend has to open correctly is one this command // has to open correctly too. func (c *seedClient) do(req *http.Request) (*http.Response, error) { req.Header.Set(auth.ClientHeader, "tmplatformctl-seed") req.Header.Set("User-Agent", "tmplatformctl/seed") return c.http.Do(req) } func (c *seedClient) devLogin(ctx context.Context) error { req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/auth/dev-login", nil) if err != nil { return err } resp, err := c.do(req) if err != nil { return fmt.Errorf("the daemon at %s could not be reached: %w", c.base, err) } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return fmt.Errorf("%s has no development sign-in: set TM_PLATFORM_DEV_LOGIN on the daemon (it is refused alongside an OIDC provider)", c.base) } if resp.StatusCode != http.StatusNoContent { return fmt.Errorf("the development sign-in answered %s", resp.Status) } return nil } // uploadForm is one intake request, in the order the wire requires. type uploadForm struct { // Title empty is the DECLARED way to ask for "name it from the file" (canon §BookIntake), which // is what a seeded stand wants: the demo book's name is the demo file's. Title, SourceLang, TargetLang, Filename string Body io.Reader } type seedBook struct { ID string `json:"id"` Status string `json:"status"` ChapterCount int `json:"chapter_count"` } // upload streams the multipart body, with the FILE LAST. // // Both halves matter and neither is decoration. The order is the route's own rule — a streaming // reader takes the parts in wire order and cannot write the book's row before the languages that row // requires — and streaming through a pipe rather than a buffer is what makes this command usable on // a real book instead of only on the demo fragment. func (c *seedClient) upload(ctx context.Context, in uploadForm) (seedBook, error) { pr, pw := io.Pipe() mw := multipart.NewWriter(pw) go func() { err := func() error { for _, kv := range [][2]string{ {"title", in.Title}, {"source_lang", in.SourceLang}, {"target_lang", in.TargetLang}, } { if err := mw.WriteField(kv[0], kv[1]); err != nil { return err } } part, err := mw.CreateFormFile("file", in.Filename) if err != nil { return err } if _, err := io.Copy(part, in.Body); err != nil { return err } return mw.Close() }() _ = pw.CloseWithError(err) }() req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/v0/books", pr) if err != nil { return seedBook{}, err } req.Header.Set("Content-Type", mw.FormDataContentType()) resp, err := c.do(req) if err != nil { return seedBook{}, fmt.Errorf("the upload did not finish: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusCreated { return seedBook{}, fmt.Errorf("the intake answered %s: %s", resp.Status, firstLine(resp.Body)) } var book seedBook if err := json.NewDecoder(resp.Body).Decode(&book); err != nil { return seedBook{}, fmt.Errorf("the intake's answer did not decode: %w", err) } return book, nil } // awaitIntake polls the book card until the walk ends. Polling and not waiting on anything: intake is // finished by a sweep or a queue worker, deliberately not by the request that started it. func (c *seedClient) awaitIntake(ctx context.Context, id string) (seedBook, error) { t := time.NewTicker(2 * time.Second) defer t.Stop() for { card, err := c.book(ctx, id) if err != nil { return seedBook{}, err } if card.Status != "parsing" && card.Status != "uploading" { return card, nil } select { case <-ctx.Done(): return seedBook{}, fmt.Errorf("the demo book is still %q after %s; the daemon's log says what it is waiting for", card.Status, seedTimeout) case <-t.C: } } } func (c *seedClient) book(ctx context.Context, id string) (seedBook, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+"/v0/books/"+id, nil) if err != nil { return seedBook{}, err } resp, err := c.do(req) if err != nil { return seedBook{}, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return seedBook{}, fmt.Errorf("reading the book answered %s", resp.Status) } var detail struct { Book seedBook `json:"book"` } if err := json.NewDecoder(resp.Body).Decode(&detail); err != nil { return seedBook{}, err } return detail.Book, nil } // firstAvailablePair asks the deployment what it can translate, so the walk works wherever it runs. func (c *seedClient) firstAvailablePair(ctx context.Context) (seedPair, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+"/v0/capabilities", nil) if err != nil { return seedPair{}, err } resp, err := c.do(req) if err != nil { return seedPair{}, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return seedPair{}, fmt.Errorf("reading the capabilities answered %s", resp.Status) } var caps struct { Pairs []seedPair `json:"language_pairs"` } if err := json.NewDecoder(resp.Body).Decode(&caps); err != nil { return seedPair{}, err } for _, p := range caps.Pairs { if p.State == "available" { return p, nil } } return seedPair{}, errors.New("this deployment declares no available language pair: set TM_PLATFORM_LANGUAGE_PAIRS, or name one with --source-lang/--target-lang") } type seedPair struct { Source string `json:"source"` Target string `json:"target"` State string `json:"state"` } func firstLine(r io.Reader) string { b, _ := io.ReadAll(io.LimitReader(r, 512)) if i := strings.IndexByte(string(b), '\n'); i >= 0 { return string(b[:i]) } return string(b) } // demoSource is the book the stand gets when the operator names none. // // ⚠ It reads as Chinese because the only prompt pack in this repository is zh-ru, so a demo book in // any other pair would be one the engine legitimately refuses to translate — and a seeded stand that // cannot run its own demo book teaches the wrong lesson. Nothing about the pair is decided here: // --source, --source-lang and --target-lang are the whole of it, and the pack a book needs is the // engine's data, not this command's. func demoSource(path string) (io.Reader, string, error) { if path != "" { f, err := os.Open(path) if err != nil { return nil, "", err } return f, f.Name(), nil } var b strings.Builder for chapter := 1; chapter <= 3; chapter++ { fmt.Fprintf(&b, "第%d章 演示\n", chapter) for range 20 { b.WriteString("这是一个用于开发环境的演示文本。") } b.WriteString("\n\n") } return strings.NewReader(b.String()), "demo.txt", nil }