package main import ( "context" "crypto/rand" "fmt" "log/slog" "os" "time" "atlas9.dev/c/core" "atlas9.dev/c/core/dbi" "atlas9.dev/c/core/envelope" "atlas9.dev/c/core/iam" "atlas9.dev/c/demo/boot" "atlas9.dev/c/demo/boot/bootdb" "atlas9.dev/c/demo/lib" "atlas9.dev/c/demo/lib/access" "atlas9.dev/c/demo/lib/domains" "atlas9.dev/c/demo/lib/profiles" "atlas9.dev/c/demo/lib/sso" "atlas9.dev/c/demo/lib/todos" ) func main() { slog.SetDefault(slog.New(boot.LogHandler(os.Stdout))) err := run() if err != nil { slog.Error(err.Error()) os.Exit(1) } } func run() error { // Load config config, err := boot.LoadConfig("config.toml") if err != nil { return err } if err := os.MkdirAll("data", 0755); err != nil { return fmt.Errorf("creating data dir: %w", err) } force := os.Getenv("ATLAS9_FORCE_SEED") == "1" if force { // A forced reseed fully replaces the database: drop the SQLite files so // the schema is recreated from scratch. A partial row-delete would leave // tables that aren't tied to tenants/users behind — notably sessions, // whose rows outlive a deleted user and keep a stale login "valid". if err := removeSqliteFiles(config.Database.Path); err != nil { return fmt.Errorf("removing database files: %w", err) } } // DB connection and migration db, err := bootdb.Database(config.Database.Path) if err != nil { return err } defer db.Close() keyPath := config.Encryption.KeyFile.Path if keyPath == "" { keyPath = "data/keyfile" } if _, err := os.Stat(keyPath); err != nil { if !os.IsNotExist(err) { return fmt.Errorf("checking key file: %w", err) } var key [32]byte if _, err := rand.Read(key[:]); err != nil { return fmt.Errorf("generating key: %w", err) } if err := os.WriteFile(keyPath, []byte(fmt.Sprintf("%x\n", key)), 0600); err != nil { return fmt.Errorf("writing key file: %w", err) } slog.Info("generated key file", "path", keyPath) } wrapper, err := envelope.LoadKeyFile(keyPath) if err != nil { return err } // Seeding runs with an AllowAll guard, so stores skip access checks and the // seed context needs no elevated access. srv := boot.NewServer(config.Server.BaseURL, db, nil, nil, config, wrapper, lib.AppRoles, access.AllowAll{}) ctx := context.TODO() if !force { var count int if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&count); err != nil { return fmt.Errorf("checking users: %w", err) } if count > 0 { slog.Info("database already seeded, skipping") return nil } } return dbi.ReadWrite(ctx, db, func(tx dbi.DBI) error { s := seeder{ctx: ctx, tx: tx, srv: srv} // Users. Every user's password is "password". test := s.user("test@eabuc.com", "Test User") s.systemGrant(test, "support") alice := s.user("alice@eabuc.com", "Alice Anderson") bob := s.user("bob@eabuc.com", "Bob Barnes") carol := s.user("carol@eabuc.com", "Carol Chen") dave := s.user("dave@eabuc.com", "Dave Diaz") s.profile(alice, "Frontend engineer. Plants, pottery, PostgreSQL.", "Portland, OR", "https://alice.example.com") s.profile(bob, "I fix flaky tests.", "Chicago, IL", "") s.profile(carol, "Design + systems.", "Oakland, CA", "https://carol.example.com") // Shared tenants with a mix of owners and members. acme := s.tenant("Acme Inc", test) s.member(acme, alice, "owner") s.member(acme, bob, "member") s.member(acme, carol, "member") skunk := s.tenant("Skunkworks", alice) s.member(skunk, test, "member") s.member(skunk, dave, "member") // Todos in Acme: several lists with items in various states, // a mix of assignees, and a couple of unassigned/untriaged items. launch := s.list(acme, "Launch Checklist") s.item(acme, launch, "Write announcement post", todos.StatusInProgress, carol.ID, "Draft is in the shared folder, needs a second pass.") s.item(acme, launch, "Set up status page", todos.StatusDone, bob.ID, "") s.item(acme, launch, "Load test the API", todos.StatusOpen, bob.ID, "Target 500 rps sustained for 10 minutes.") s.item(acme, launch, "Finalize pricing page", todos.StatusOpen, core.ID{}, "") s.item(acme, launch, "Record demo video", todos.StatusOpen, carol.ID, "Keep it under 3 minutes.") s.item(acme, launch, "Send launch email to waitlist", todos.StatusDone, alice.ID, "") s.item(acme, launch, "Publish changelog", todos.StatusInProgress, alice.ID, "") bugs := s.list(acme, "Bugs") s.item(acme, bugs, "Login form flashes on reload", todos.StatusOpen, alice.ID, "") s.item(acme, bugs, "Audit log timestamps off by TZ", todos.StatusInProgress, test.ID, "Only when the server TZ isn't UTC.") s.item(acme, bugs, "Invite email lands in spam", todos.StatusDone, carol.ID, "") s.item(acme, bugs, "Avatar upload silently fails for PNGs over 2 MB", todos.StatusOpen, bob.ID, "") s.item(acme, bugs, "Search returns stale results after update", todos.StatusInProgress, alice.ID, "Cache invalidation is the suspect.") s.item(acme, bugs, "Webhook retries not honoring backoff", todos.StatusOpen, core.ID{}, "") s.item(acme, bugs, "Pagination breaks when filter is active", todos.StatusDone, bob.ID, "") roadmap := s.list(acme, "Q3 Roadmap") s.item(acme, roadmap, "Design API versioning strategy", todos.StatusDone, alice.ID, "") s.item(acme, roadmap, "Add filter expressions to list endpoints", todos.StatusDone, test.ID, "") s.item(acme, roadmap, "Implement cursor-based pagination", todos.StatusDone, bob.ID, "") s.item(acme, roadmap, "SSO integration with external IdP", todos.StatusInProgress, carol.ID, "") s.item(acme, roadmap, "Tenant-scoped audit log", todos.StatusInProgress, alice.ID, "") s.item(acme, roadmap, "Role-based access control overhaul", todos.StatusOpen, core.ID{}, "") s.item(acme, roadmap, "Bot bearer key authentication", todos.StatusDone, test.ID, "") s.item(acme, roadmap, "Secrets at rest: encrypt SSO credentials", todos.StatusOpen, bob.ID, "") s.item(acme, core.ID{}, "Untriaged: renew TLS certs", todos.StatusOpen, core.ID{}, "") s.item(acme, core.ID{}, "Untriaged: review dependency audit report", todos.StatusOpen, core.ID{}, "") // Todos in Skunkworks. proto := s.list(skunk, "Prototype") s.item(skunk, proto, "Sketch the CLI surface", todos.StatusDone, alice.ID, "") s.item(skunk, proto, "Spike sqlite vector search", todos.StatusInProgress, dave.ID, "") s.item(skunk, proto, "Demo for the team", todos.StatusOpen, core.ID{}, "Aim for Friday.") // A personal list in the test user's personal tenant. errands := s.list(test.ID, "Errands") s.item(test.ID, errands, "Rotate API keys", todos.StatusOpen, test.ID, "") s.item(test.ID, errands, "Clean up stale branches", todos.StatusDone, test.ID, "") if s.err != nil { return s.err } fmt.Printf("User ID %s\n", test.ID) fmt.Printf("Tenant %q %s\n", "Acme Inc", acme) fmt.Printf("Tenant %q %s\n", "Skunkworks", skunk) // The SSO details point at a real dev IdP, so they live in config // (see [seed.sso] in config.toml) rather than in source. With no // ClientID configured, skip SSO and domain seeding entirely. seedSSO := config.Seed.SSO if seedSSO.ClientID == "" { return nil } // Seal the client secret with the tenant's DEK before storing it. enc, err := srv.Encryptors.For(ctx, srv.Deks(tx), test.ID) if err != nil { return err } dekID, clientSecretEnc, err := enc.Seal([]byte(seedSSO.ClientSecret)) if err != nil { return err } ssoConfig := sso.Config{ ID: core.NewID("sso"), Tenant: test.ID, Name: seedSSO.Name, ClientID: seedSSO.ClientID, DekID: dekID, ClientSecretEnc: clientSecretEnc, // Scopes: []string{}, TODO? IssuerUrl: seedSSO.IssuerURL, JwksUrl: seedSSO.JwksURL, AuthUrl: seedSSO.AuthURL, DeviceAuthUrl: seedSSO.DeviceAuthURL, TokenUrl: seedSSO.TokenURL, } if err := srv.SsoConfigs(tx).Save(ctx, &ssoConfig); err != nil { return err } domain := domains.NewDomain(test.ID, seedSSO.Domain) now := time.Now() domain.VerifiedAt = &now if err := srv.Domains(tx).Create(ctx, &domain); err != nil { return err } if err := srv.SsoConfigs(tx).SetDomainOwner(ctx, test.ID, domain.ID, ssoConfig.ID); err != nil { return err } fmt.Printf("SSO CONFIG ID %s\n", ssoConfig.ID) return nil }) } // removeSqliteFiles deletes a SQLite database and its WAL/SHM/journal // sidecars, ignoring any that don't exist. Used by a forced reseed to start // from a clean schema. func removeSqliteFiles(path string) error { for _, p := range []string{path, path + "-wal", path + "-shm", path + "-journal"} { if err := os.Remove(p); err != nil && !os.IsNotExist(err) { return err } } return nil } // seeder threads ctx/tx through the seeding steps and holds the first // error; once set, every later step is a no-op so run can check once. type seeder struct { ctx context.Context tx dbi.DBI srv *boot.Server err error } // user creates a verified user with password "password" and provisions // their profile and personal tenant. func (s *seeder) user(email, name string) iam.User { u := iam.NewUser(email) u.Name = name u.Verified = true if s.err != nil { return u } if s.err = s.srv.Users(s.tx).Save(s.ctx, &u); s.err != nil { return u } if s.err = iam.SetPassword(s.ctx, s.srv.Passwords(s.tx), u.ID, "password"); s.err != nil { return u } s.err = s.srv.Provisioner.ProvisionUser(s.ctx, s.tx, &u) return u } func (s *seeder) profile(u iam.User, bio, location, website string) { if s.err != nil { return } s.err = s.srv.Profiles(s.tx).Update(s.ctx, &profiles.Profile{ UserID: u.ID, Bio: bio, Location: location, Website: website, }) } // tenant creates a shared tenant with the given user as owner. func (s *seeder) tenant(name string, owner iam.User) core.ID { t := iam.Tenant{ID: core.NewID("t"), Name: name} if s.err != nil { return t.ID } if s.err = s.srv.Provisioner.ProvisionTenant(s.ctx, s.tx, &t, owner.ID); s.err != nil { return t.ID } return t.ID } // systemGrant adds a system-scoped grant anchored to the user's personal tenant. func (s *seeder) systemGrant(u iam.User, role string) { if s.err != nil { return } s.err = s.srv.SystemGrants(s.tx).Add(s.ctx, iam.SystemGrant{ Principal: u.ID, Role: role, }) } // member adds a user to a tenant with the given role ("owner" or "member"). func (s *seeder) member(tenant core.ID, u iam.User, role string) { if s.err != nil { return } s.err = s.srv.Members(s.tx).Add(s.ctx, iam.TenantMember{ Tenant: tenant, User: u.ID, Owner: role == "owner", }) } func (s *seeder) list(tenant core.ID, name string) core.ID { l := todos.List{ID: core.NewID("list"), Tenant: tenant, Name: name} if s.err != nil { return l.ID } s.err = s.srv.Todos(s.tx).CreateList(s.ctx, &l) return l.ID } // item creates a todo item and, when list is non-empty, adds it to that list. func (s *seeder) item(tenant, list core.ID, title string, status todos.Status, assignee core.ID, notes string) { if s.err != nil { return } i := todos.Item{ ID: core.NewID("todo"), Tenant: tenant, Title: title, Notes: notes, Status: status, Assignee: assignee, } if s.err = s.srv.Todos(s.tx).CreateItem(s.ctx, &i); s.err != nil { return } if list.IsEmpty() { return } s.err = s.srv.Todos(s.tx).AddToList(s.ctx, tenant, list, i.ID) }