chore: plans setup
JamesPatrickGill authored 1 month agoparent1111df1Browse files
ab7fea973ad099de93aca2ff25c2ca7703ed25a7ab7fea973ad099de93aca2ff25c2ca7703ed25a77 files changed+555 −0
| @@ -0,0 +1,69 @@ | ||
| 1 | +# Steid | |
| 2 | + | |
| 3 | +The source of truth for everything in a developer's or organization's portfolio. | |
| 4 | + | |
| 5 | +## Vision | |
| 6 | + | |
| 7 | +Steid is a developer/org identity and portfolio platform. It's where you host your | |
| 8 | +code, your writing, your projects — everything you do, in one place, under your | |
| 9 | +control. | |
| 10 | + | |
| 11 | +Git repos are a core feature, not the whole product. Blogs, documentation, project | |
| 12 | +showcases, and anything else a dev or org wants to present lives here too. | |
| 13 | + | |
| 14 | +Personal-first, but organizations can collaborate on it. | |
| 15 | + | |
| 16 | +**What makes it different from Gitea/Forgejo:** those are GitHub clones scaled down. | |
| 17 | +Steid is portfolio-first — the profile page is the product, and repos are one kind of | |
| 18 | +thing that appears on it. That framing should win any tie-break. | |
| 19 | + | |
| 20 | +## Core Principles | |
| 21 | + | |
| 22 | +- **Fast** — Rust, minimal overhead | |
| 23 | +- **Reliable** — correct first, then optimize | |
| 24 | +- **Personal-first** — great for a single dev, scales to orgs | |
| 25 | +- **Identity-centric** — everything is built around who you are (user/org); auth is | |
| 26 | + designed in from day one, not bolted on | |
| 27 | + | |
| 28 | +## Stack | |
| 29 | + | |
| 30 | +Starting intent, not settled decisions. Each one gets a record in | |
| 31 | +[decisions/](decisions/) when it's actually made and confirmed in this build. | |
| 32 | + | |
| 33 | +| Concern | Intent | | |
| 34 | +|---|---| | |
| 35 | +| Web framework | Topcoat (tokio-rs) | | |
| 36 | +| Git protocol | shell out to the `git` binary | | |
| 37 | +| SSH transport | embedded russh server | | |
| 38 | +| Crate layout | single crate | | |
| 39 | +| Ownership | personal org owns repos | | |
| 40 | +| Database | SQLite via sqlx | | |
| 41 | + | |
| 42 | +Architecture and conventions: [architecture.md](architecture.md). | |
| 43 | + | |
| 44 | +## Milestone Ladder | |
| 45 | + | |
| 46 | +This repo is a from-scratch rebuild on Topcoat. Milestones 1–4 were built and | |
| 47 | +verified end-to-end in a previous attempt — see [progress.md](progress.md) for what | |
| 48 | +that proved. Treat that code as reference, not as a baseline. | |
| 49 | + | |
| 50 | +| # | Milestone | Status | | |
| 51 | +|---|---|---| | |
| 52 | +| 0 | **Skeleton** — Topcoat app boots, config, one page, SQLite wired | active | | |
| 53 | +| 1 | **Identity** — users, orgs, memberships, sessions, registration policy | not started | | |
| 54 | +| 2 | **Repo model** — repo records + bare repos on disk | not started | | |
| 55 | +| 3 | **Git over SSH** — clone and push via embedded russh | not started | | |
| 56 | +| 4 | **SSH key auth + authz** — pubkey → user, membership-gated push/clone | not started | | |
| 57 | +| 5 | **Repo browsing** — tree, blob, commit log in the web UI | not started | | |
| 58 | +| 6 | **Portfolio** — blogs, pages, project showcases | not started | | |
| 59 | +| 7 | **Collaboration** — issues, PRs, review | not started | | |
| 60 | +| 8 | **API / federation** | not started | | |
| 61 | + | |
| 62 | +Milestones 5–8 have never been built. Everything below 5 has a working reference | |
| 63 | +implementation to crib from. | |
| 64 | + | |
| 65 | +**One numbering scheme.** The previous attempt kept two (a roadmap ladder and a | |
| 66 | +separate build log with conflicting numbers) and they drifted apart within three | |
| 67 | +milestones. If the plan changes, edit this table — don't start a second list. | |
| 68 | + | |
| 69 | +Milestone 0 is broken into steps in [current.md](current.md). |
| @@ -0,0 +1,117 @@ | ||
| 1 | +# Architecture | |
| 2 | + | |
| 3 | +## Layers | |
| 4 | + | |
| 5 | +DDD + clean architecture. Dependencies point inward. The domain knows nothing about | |
| 6 | +HTTP, SQL, git, or Topcoat. | |
| 7 | + | |
| 8 | +``` | |
| 9 | +web / ssh (interface) → application (use cases) → domain (entities, ports) | |
| 10 | + ↑ | |
| 11 | + infrastructure (sqlite, git binary, argon2) | |
| 12 | + implements the ports the domain/application declare | |
| 13 | +``` | |
| 14 | + | |
| 15 | +| Layer | Holds | Must not import | | |
| 16 | +|---|---|---| | |
| 17 | +| `domain` | entities, value objects, typed IDs, repository traits, `DomainError` | anything infrastructural | | |
| 18 | +| `application` | use cases, ports (`GitStorage`, `GitProtocolServer`, `PasswordHasher`), `AppConfig` | concrete adapters | | |
| 19 | +| `infrastructure` | SQLite repos, `git` subprocess wrappers, Argon2, russh, web handlers | — | | |
| 20 | + | |
| 21 | +**Every operation takes an `Actor`.** Authorization is checked in the use case, before | |
| 22 | +any side effect. Not in the handler, not in the adapter. The previous attempt got this | |
| 23 | +right and it paid off — `serve_push` could enforce `Role::Owner` in one place | |
| 24 | +regardless of transport. | |
| 25 | + | |
| 26 | +## Conventions | |
| 27 | + | |
| 28 | +These come from two prior attempts. They earned their place. | |
| 29 | + | |
| 30 | +### Typed IDs — never raw strings for entity references | |
| 31 | + | |
| 32 | +```rust | |
| 33 | +// CORRECT — the compiler catches a swapped argument | |
| 34 | +fn get_member(&self, org_id: &OrgId, user_id: &UserId) -> ... | |
| 35 | + | |
| 36 | +// WRONG — silently compiles, fails at runtime | |
| 37 | +fn get_member(&self, org_id: &str, user_id: &str) -> ... | |
| 38 | +``` | |
| 39 | + | |
| 40 | +`UserId`, `OrgId`, `MembershipId`, `RepoId`, `InviteCodeId`, `SshKeyId`. | |
| 41 | + | |
| 42 | +### `new()` validates, `from_trusted()` doesn't | |
| 43 | + | |
| 44 | +`new()` is for user input and returns `Result`. `from_trusted()` is for rows loaded | |
| 45 | +out of the database, which were validated on the way in. Infrastructure repos should | |
| 46 | +always use `from_trusted()` — re-validating DB rows means a validation-rule change | |
| 47 | +turns old rows unreadable. | |
| 48 | + | |
| 49 | +### `from_str` returns `Result`, never `Option` | |
| 50 | + | |
| 51 | +A silently-defaulted enum parse is a bug that surfaces days later as wrong | |
| 52 | +permissions. Applies to `Role`, `Visibility`, `RegistrationPolicy`. | |
| 53 | + | |
| 54 | +### Every repository port gets two implementations | |
| 55 | + | |
| 56 | +An in-memory one and a SQLite one. The in-memory one is what makes use cases testable | |
| 57 | +without a database — attempt #2 reached 60 tests this way and they ran fast enough to | |
| 58 | +stay in the loop. | |
| 59 | + | |
| 60 | +### Ports live where they're consumed | |
| 61 | + | |
| 62 | +Repository traits in `domain/repository/`. Service ports the application needs | |
| 63 | +(`GitStorage`, `GitProtocolServer`, `PasswordHasher`) in `application/port.rs`. Use | |
| 64 | +cases take `&impl Port`, not a boxed trait object. | |
| 65 | + | |
| 66 | +## DB-plus-filesystem writes | |
| 67 | + | |
| 68 | +Creating a repo writes to two places that can't share a transaction: the | |
| 69 | +`repositories` row and the bare repo on disk. Neither prior attempt fully solved this. | |
| 70 | + | |
| 71 | +Attempt #1 used a **compensating transaction** — create on disk first, and if the DB | |
| 72 | +insert fails, delete the directory: | |
| 73 | + | |
| 74 | +```rust | |
| 75 | +let repo = self.repository.create(&id).await?; | |
| 76 | +if let Err(e) = self.repo_record_repo.create(&record).await { | |
| 77 | + let _ = self.repository.delete(&id).await; // compensate | |
| 78 | + return Err(e.into()); | |
| 79 | +} | |
| 80 | +``` | |
| 81 | + | |
| 82 | +That leaks an orphaned directory if the process dies between the two calls. Good | |
| 83 | +enough to ship; write down that it's a known hole rather than rediscovering it. The | |
| 84 | +durable fix is a reconciliation sweep on boot, or marking rows pending and committing | |
| 85 | +after the filesystem write lands. | |
| 86 | + | |
| 87 | +## Open question: Topcoat's data access vs clean architecture | |
| 88 | + | |
| 89 | +**This is the one genuinely unresolved design question, and it blocks Milestone 1.** | |
| 90 | + | |
| 91 | +Topcoat's model is async components that query the database directly and check | |
| 92 | +permissions inline, with `#[memoize]` deduplicating calls per request. State comes | |
| 93 | +from `app_context::<T>(cx)`, and the framework's own guidance is to "prefer composable | |
| 94 | +`cx: &Cx` functions over middleware/extractors for auth and request-scoped data." | |
| 95 | + | |
| 96 | +That is a view layer reaching straight for the data layer — the exact thing the layer | |
| 97 | +rules above forbid. The two models are in real tension and the resolution has to be | |
| 98 | +deliberate: | |
| 99 | + | |
| 100 | +- **Hold the line.** Components call use cases; use cases hold the ports. Costs some | |
| 101 | + of Topcoat's ergonomics and may fight the framework's grain. | |
| 102 | +- **Let components read, force writes through use cases.** Reads go direct (they're | |
| 103 | + the ones that benefit from memoization and per-component fetching); every mutation | |
| 104 | + and every authorization decision stays in a use case. Pragmatic middle. | |
| 105 | +- **Adopt Topcoat's model fully.** Fastest to build, and abandons the layering that | |
| 106 | + made the previous attempt's authorization work uniformly across web *and* SSH. | |
| 107 | + | |
| 108 | +The SSH transport is the thing that makes this non-obvious: `serve_push` has no `Cx` | |
| 109 | +and no request. Whatever authorization lives in a Topcoat component is unavailable to | |
| 110 | +it. Anything enforcing a permission needs to sit somewhere both transports can reach. | |
| 111 | + | |
| 112 | +Decide before Milestone 1 and write it up as decision 0001 — see | |
| 113 | +[decisions/TEMPLATE.md](decisions/TEMPLATE.md). | |
| 114 | + | |
| 115 | +## Safety | |
| 116 | + | |
| 117 | +Safe Rust only. No `unsafe`. |
| @@ -0,0 +1,57 @@ | ||
| 1 | +# Current | |
| 2 | + | |
| 3 | +> Keep this file short. One active step, one ordered backlog. Completed work moves to | |
| 4 | +> [progress.md](progress.md). If this file starts reading like a changelog, it has | |
| 5 | +> drifted — that's exactly what went wrong last time. | |
| 6 | + | |
| 7 | +## Active: Milestone 0 — Skeleton | |
| 8 | + | |
| 9 | +**Goal:** a Topcoat app that boots, serves one page, reads config from env, and opens | |
| 10 | +a SQLite pool. No domain logic yet. The point is to learn Topcoat's shape before | |
| 11 | +committing the architecture to it. | |
| 12 | + | |
| 13 | +### Steps | |
| 14 | + | |
| 15 | +- [ ] Add `topcoat` 0.5 + `tokio` to `Cargo.toml`; install `topcoat-cli` | |
| 16 | +- [ ] Get the getting-started hello-world page rendering | |
| 17 | +- [ ] Work out how `#[page]` / `module_router!` discovery wants the source tree laid | |
| 18 | + out — this constrains everything after it | |
| 19 | +- [ ] Config from env via `envy` (`STEID_*`), mirroring `.env.dev` from the previous | |
| 20 | + attempt (see [runbook.md](runbook.md)) | |
| 21 | +- [ ] SQLite pool registered as app context; confirm a page can read it via | |
| 22 | + `app_context::<T>(cx)` | |
| 23 | +- [ ] Decide the layering question in [architecture.md](architecture.md#open-question-topcoats-data-access-vs-clean-architecture) | |
| 24 | + — this is the one genuinely open design question and it blocks Milestone 1 | |
| 25 | + | |
| 26 | +### Done when | |
| 27 | + | |
| 28 | +`cargo run` (or `topcoat` CLI) serves a page that renders a value read from SQLite, | |
| 29 | +with config supplied by env. | |
| 30 | + | |
| 31 | +## Backlog | |
| 32 | + | |
| 33 | +Ordered. Pull from the top. | |
| 34 | + | |
| 35 | +1. **Milestone 1 — Identity.** Domain model (User, Org, Membership, Actor, Role), | |
| 36 | + value objects (Email, PasswordHash), typed IDs, repository ports, Argon2 hashing, | |
| 37 | + registration policy, session cookies. Reference implementation exists and was | |
| 38 | + solid — port the domain layer, rewrite the web layer. | |
| 39 | +2. **Milestone 2 — Repo model.** `Repository` entity, `Visibility`, `create_repo` use | |
| 40 | + case, bare repo on disk at `{data_dir}/{org}/{repo}.git`. Watch the | |
| 41 | + DB-plus-filesystem atomicity problem — see | |
| 42 | + [architecture.md](architecture.md#db-plus-filesystem-writes). | |
| 43 | +3. **Milestone 3 — Git over SSH.** `GitProtocolServer` port, `GitBinary` adapter, | |
| 44 | + embedded russh. Channel-splitting is fiddly; the notes in | |
| 45 | + [progress.md](progress.md#ssh-channel-bridging) are hard-won. | |
| 46 | +4. **Milestone 4 — SSH key auth + authz.** | |
| 47 | + | |
| 48 | +## Open questions | |
| 49 | + | |
| 50 | +- **Topcoat is nine days old** (v0.5.0, first release 2026-07-22, breaking changes | |
| 51 | + expected). Pin the exact version and expect to chase it. Budget time for churn that | |
| 52 | + isn't feature work. | |
| 53 | +- Does Topcoat's asset/CSS pipeline coexist with an embedded russh server in one | |
| 54 | + binary, or does the CLI's watch-and-rebuild model push SSH into a separate process? | |
| 55 | + Unresolved — affects the embedded-SSH approach and single-binary deploy. | |
| 56 | +- Topcoat ships Tailwind without Node. That reopens the design system from attempt #1 | |
| 57 | + that was dropped purely to avoid an npm build step — see [ui.md](ui.md). |
| @@ -0,0 +1,50 @@ | ||
| 1 | +# NNNN — Short imperative title | |
| 2 | + | |
| 3 | +**Status:** proposed | accepted | superseded by [NNNN](NNNN-slug.md) · **Date:** YYYY-MM-DD | |
| 4 | + | |
| 5 | +## Context | |
| 6 | + | |
| 7 | +The situation that forces a choice. What's true right now, what pressure it creates, | |
| 8 | +and what breaks if nothing changes. Write enough that someone who wasn't there can | |
| 9 | +tell whether this still applies — including the constraints that turn out to matter | |
| 10 | +later (team size, scale, dependencies, deadlines). | |
| 11 | + | |
| 12 | +No decision here. Just the facts. | |
| 13 | + | |
| 14 | +## Decision | |
| 15 | + | |
| 16 | +What was chosen, stated plainly and in the active voice. One or two sentences is | |
| 17 | +usually right. | |
| 18 | + | |
| 19 | +## Alternatives considered | |
| 20 | + | |
| 21 | +What else was on the table and why it lost. This is the part future-you actually | |
| 22 | +needs — a rejected option with no recorded reason gets re-litigated every six months. | |
| 23 | + | |
| 24 | +- **Option** — why not. | |
| 25 | + | |
| 26 | +## Consequences | |
| 27 | + | |
| 28 | +What follows, good and bad. Be honest about the costs; a record listing only benefits | |
| 29 | +is marketing, not a decision. | |
| 30 | + | |
| 31 | +- What this makes easy | |
| 32 | +- What this makes hard | |
| 33 | +- New dependencies, obligations, or known holes it opens | |
| 34 | +- How reversible it is, and what reversing would cost | |
| 35 | + | |
| 36 | +--- | |
| 37 | + | |
| 38 | +## How to use this directory | |
| 39 | + | |
| 40 | +- One file per decision, numbered sequentially: `0001-slug.md`. Numbers are never | |
| 41 | + reused, and files are never deleted — a decision that stops applying gets its status | |
| 42 | + changed to **superseded**, with a link to the one that replaced it. The trail of | |
| 43 | + wrong turns is the point. | |
| 44 | +- Write one when a choice would be expensive to reverse, when it constrains future | |
| 45 | + work, or when the reasoning wouldn't be obvious from reading the code. Skip it for | |
| 46 | + anything the code makes self-evident. | |
| 47 | +- Write it **when the decision is made**, not afterwards. Reconstructed rationale is | |
| 48 | + mostly fiction. | |
| 49 | +- Link to the record from wherever the choice shows up — `ROADMAP.md`, | |
| 50 | + `architecture.md` — rather than restating the reasoning in both places. |
| @@ -0,0 +1,102 @@ | ||
| 1 | +# Progress | |
| 2 | + | |
| 3 | +## This attempt (#3, Topcoat) | |
| 4 | + | |
| 5 | +Nothing shipped yet. Milestone 0 in progress — see [current.md](current.md). | |
| 6 | + | |
| 7 | +--- | |
| 8 | + | |
| 9 | +## Reference: what attempt #2 proved | |
| 10 | + | |
| 11 | +Not this repo's progress. This is a catalogue of what was built and **verified working** | |
| 12 | +in `steid-backup-2026-07-31`, so the rebuild can crib rather than rediscover. | |
| 13 | + | |
| 14 | +Final state: single crate, ~4,200 LOC, 60 passing tests, five milestones. | |
| 15 | + | |
| 16 | +### Identity | |
| 17 | + | |
| 18 | +Domain model (User, Organization, Membership, Actor, Role), `Email` and | |
| 19 | +`PasswordHash` value objects, typed IDs, `DomainError`, four repository ports with | |
| 20 | +in-memory and SQLite implementations each. `RegistrationPolicy` (Personal / Invite / | |
| 21 | +Open) driving which routes exist. Argon2 hashing behind a `PasswordHasher` port with a | |
| 22 | +stub for tests. Use cases: `bootstrap_owner`, `register_user`, `login`, `create_invite`. | |
| 23 | +Signed-cookie sessions via an `AuthUser` extractor. | |
| 24 | + | |
| 25 | +**Gotcha:** organizations must be saved before users — the FK runs that direction. | |
| 26 | +Both `bootstrap_owner` and `register_user` had to be fixed for this. | |
| 27 | + | |
| 28 | +### Repo model | |
| 29 | + | |
| 30 | +`Repository` entity, `RepoId`, `Visibility` (Public/Private), `RepoRepository` port, | |
| 31 | +migration `006_create_repositories.sql`. `create_repo` use case validates the name, | |
| 32 | +rejects duplicates, and initialises the bare repo on disk in the same call. Bare repos | |
| 33 | +live at `{data_dir}/{org}/{repo}.git`, `data_dir` defaulting to `./data`. Repos are | |
| 34 | +created empty, no initial commit, like GitHub. | |
| 35 | + | |
| 36 | +### Git over SSH | |
| 37 | + | |
| 38 | +`GitStorage` port (`init_bare`, `repo_path`) with `DiskGitStorage` shelling out to | |
| 39 | +`git init --bare`. `GitProtocolServer` port (`upload_pack`, `receive_pack`) with | |
| 40 | +`GitBinary` spawning `git upload-pack` / `git receive-pack` via | |
| 41 | +`tokio::process::Command` and pumping stdio with `tokio::io::copy`. | |
| 42 | + | |
| 43 | +`serve_clone` and `serve_push` use cases enforce visibility and actor checks **before | |
| 44 | +any protocol byte flows** — that ordering is the whole point of putting them in the | |
| 45 | +application layer. | |
| 46 | + | |
| 47 | +#### SSH channel bridging | |
| 48 | + | |
| 49 | +The fiddly part, and worth re-reading before Milestone 3: | |
| 50 | + | |
| 51 | +- Store `Channel<Msg>` per `ChannelId` in the handler's map on `channel_open_session` | |
| 52 | +- On `exec_request`, take the channel, split it with `into_stream()` + | |
| 53 | + `tokio::io::split` | |
| 54 | +- Take stderr via `make_writer_ext(Some(1))` **before** `into_stream()` — that call | |
| 55 | + consumes the channel, so the order is not optional | |
| 56 | +- No `data()` or `channel_eof()` handlers needed once the streams are split | |
| 57 | + | |
| 58 | +An earlier iteration used mpsc channels, custom `ChannelReader`/`ChannelWriter`, and a | |
| 59 | +`spawn_blocking` thread. All of it was deleted and the result was simpler. | |
| 60 | + | |
| 61 | +### SSH key auth and authorization | |
| 62 | + | |
| 63 | +`SshKey { id, user_id, name, fingerprint, openssh }` + port, migration | |
| 64 | +`007_create_ssh_keys.sql` (`fingerprint` UNIQUE). Fingerprints are SHA256 via | |
| 65 | +`russh::keys::ssh_key::PublicKey::fingerprint(HashAlg::Sha256)`, stored as `SHA256:…`. | |
| 66 | +`add_ssh_key` parses the openssh blob, dedupes on fingerprint, and re-encodes to a | |
| 67 | +canonical form before storing. | |
| 68 | + | |
| 69 | +`auth_none` rejects. `auth_publickey` fingerprints the offered key, looks it up, and | |
| 70 | +on a match stores `user_id` on the handler; `exec_request` builds the real `Actor` | |
| 71 | +from it. | |
| 72 | + | |
| 73 | +Authorization rules as shipped: | |
| 74 | + | |
| 75 | +| Operation | Requirement | | |
| 76 | +|---|---| | |
| 77 | +| Clone, public repo | open | | |
| 78 | +| Clone, private repo | any membership in the repo's org | | |
| 79 | +| Push | `Role::Owner` membership in the repo's org | | |
| 80 | + | |
| 81 | +Web UI at `/{owner}/keys` — owner-only, lists fingerprints, accepts openssh via | |
| 82 | +textarea, revokes per-row. | |
| 83 | + | |
| 84 | +**Verified end-to-end:** clone with an unregistered key → `Permission denied` (exit | |
| 85 | +128); register via web UI → clone and push both succeed; second unregistered key → | |
| 86 | +rejected at auth; revoke via web UI → subsequent clone rejected at auth. | |
| 87 | + | |
| 88 | +### Security notes | |
| 89 | + | |
| 90 | +Attempt #2 ran with a **named, deliberate backdoor** between milestones: SSH accepted | |
| 91 | +any connection and passed a placeholder `Actor` (`UserId("ssh-anonymous")`), leaving | |
| 92 | +push open to anyone who could reach the port. It was recorded with an explicit | |
| 93 | +tightening point (`ssh.rs::exec_request`) and a closing milestone, and it did close. | |
| 94 | + | |
| 95 | +That practice is worth keeping. When this attempt opens a hole to make progress, name | |
| 96 | +it, name the line that closes it, and name the milestone. | |
| 97 | + | |
| 98 | +### Never built | |
| 99 | + | |
| 100 | +Repo browsing (tree/blob/log), HTTP smart protocol, personal access tokens, flash | |
| 101 | +messages, issues, PRs, blogs, pages, project showcases. Milestones 5–8 in | |
| 102 | +[ROADMAP.md](ROADMAP.md) are all greenfield. |
| @@ -0,0 +1,93 @@ | ||
| 1 | +# Runbook | |
| 2 | + | |
| 3 | +> Attempt #2's only setup instructions lived in a plan file describing an architecture | |
| 4 | +> that had already been deleted, so they were actively wrong. Keep this file honest: | |
| 5 | +> if a command here doesn't work, fix it or delete it. | |
| 6 | + | |
| 7 | +## Status | |
| 8 | + | |
| 9 | +Milestone 0 is in progress and the app doesn't boot yet. Everything below marked | |
| 10 | +**(#2)** is carried from the previous attempt and needs re-verifying against Topcoat | |
| 11 | +before it can be trusted. | |
| 12 | + | |
| 13 | +## Dev setup | |
| 14 | + | |
| 15 | +```bash | |
| 16 | +cargo install topcoat-cli # dev server, asset bundling, watch mode | |
| 17 | +cargo run # or the topcoat CLI once routing is wired | |
| 18 | +``` | |
| 19 | + | |
| 20 | +Topcoat's CLI builds the app, bundles assets, and watches source directories for | |
| 21 | +rebuilds. How that interacts with the embedded SSH server is unresolved — see | |
| 22 | +[current.md](current.md#open-questions). | |
| 23 | + | |
| 24 | +## Configuration (#2) | |
| 25 | + | |
| 26 | +Env vars, `STEID_` prefixed, loaded with `dotenvy` + `envy`. From attempt #2's | |
| 27 | +`.env.dev`: | |
| 28 | + | |
| 29 | +``` | |
| 30 | +STEID_REGISTRATION=personal # personal | invite | open | |
| 31 | +STEID_OWNER_EMAIL=admin@localhost.dev | |
| 32 | +STEID_OWNER_PASSWORD=changeme | |
| 33 | +STEID_OWNER_USERNAME=admin | |
| 34 | +STEID_DATABASE_URL=sqlite:steid.db?mode=rwc | |
| 35 | +STEID_LISTEN_ADDR=127.0.0.1:3000 | |
| 36 | +STEID_DATA_DIR=./data # bare repos live here | |
| 37 | +``` | |
| 38 | + | |
| 39 | +In `personal` mode the owner account is bootstrapped from `STEID_OWNER_*` on first | |
| 40 | +boot. Keep `.env.prod` out of git. | |
| 41 | + | |
| 42 | +## Repo layout on disk (#2) | |
| 43 | + | |
| 44 | +Bare repos at `{STEID_DATA_DIR}/{org}/{repo}.git`. Created empty — no initial commit. | |
| 45 | + | |
| 46 | +## SSH (#2) | |
| 47 | + | |
| 48 | +The SSH server is embedded (russh), not OpenSSH — there is no `authorized_keys` | |
| 49 | +configuration and no forced command. Users register public keys through the web UI at | |
| 50 | +`/{owner}/keys`, and the server matches incoming keys by SHA256 fingerprint. | |
| 51 | + | |
| 52 | +Host key generation and persistence was never written down. Sort it out during | |
| 53 | +Milestone 3 and document it here — a host key regenerated on each boot means every | |
| 54 | +client gets a changed-host-key warning. | |
| 55 | + | |
| 56 | +```bash | |
| 57 | +git clone git@host:owner/repo | |
| 58 | +``` | |
| 59 | + | |
| 60 | +## Manual verification checklist (#2) | |
| 61 | + | |
| 62 | +Attempt #2 verified these by hand each milestone but never wrote down the steps. They | |
| 63 | +are the smoke test for Milestones 2–4: | |
| 64 | + | |
| 65 | +- [ ] Create a repo via the web UI → bare repo appears at | |
| 66 | + `{data_dir}/{org}/{repo}.git` | |
| 67 | +- [ ] `git clone` an empty repo → succeeds | |
| 68 | +- [ ] `git clone` a repo with history → succeeds | |
| 69 | +- [ ] `git clone` a non-existent repo → clean error, not a hang or panic | |
| 70 | +- [ ] First push to an empty repo → succeeds | |
| 71 | +- [ ] Push to a repo with history → succeeds | |
| 72 | +- [ ] Clone with an unregistered key → `Permission denied`, exit 128 | |
| 73 | +- [ ] Register key via `/{owner}/keys` → clone and push both succeed | |
| 74 | +- [ ] Revoke key via web UI → subsequent clone rejected at auth | |
| 75 | +- [ ] Clone a private repo as a non-member → rejected | |
| 76 | +- [ ] Push as a non-owner member → rejected | |
| 77 | + | |
| 78 | +Worth automating as an integration test rather than re-running by hand a fourth time. | |
| 79 | + | |
| 80 | +## Suggested `.gitignore` additions | |
| 81 | + | |
| 82 | +Not applied yet — no code to ignore. When the app lands: | |
| 83 | + | |
| 84 | +``` | |
| 85 | +/data | |
| 86 | +*.db | |
| 87 | +*.db-shm | |
| 88 | +*.db-wal | |
| 89 | +.env.prod | |
| 90 | +``` | |
| 91 | + | |
| 92 | +**Do not add `/plans`.** Attempt #2 did, and that is why these docs had to be | |
| 93 | +hand-carried between repos. |
| @@ -0,0 +1,67 @@ | ||
| 1 | +# UI | |
| 2 | + | |
| 3 | +## Philosophy | |
| 4 | + | |
| 5 | +A developer tool people use daily to browse code and manage repos. Optimise for: | |
| 6 | + | |
| 7 | +1. **Speed-first** — minimise clicks, maximise information density | |
| 8 | +2. **Scannable** — find what you need in under a second | |
| 9 | +3. **Quiet confidence** — premium without flashy; the UI should disappear | |
| 10 | +4. **Code-centric** — code is the hero, everything else supports it | |
| 11 | + | |
| 12 | +| Principle | Meaning | | |
| 13 | +|---|---| | |
| 14 | +| Readable density | Compact without cramped. Maximise info per viewport. | | |
| 15 | +| Clear hierarchy | Strong contrast between labels, content, and muted elements | | |
| 16 | +| Functional spacing | 8px on items, 12–16px on sections. No wasted space. | | |
| 17 | +| Keyboard-first | Every action reachable without a mouse | | |
| 18 | +| Dark-mode primary | Developers live in dark mode. Light mode is supported, secondary. | | |
| 19 | + | |
| 20 | +**Do:** small type (`text-xs` / `text-sm` for most UI), small buttons, monospace for | |
| 21 | +paths, SHAs, and branch names, opacity modifiers for text hierarchy, tight spacing. | |
| 22 | + | |
| 23 | +**Don't:** large type outside page titles, shadows in dark mode (use borders), bright | |
| 24 | +backgrounds, hover animations that move or resize things. | |
| 25 | + | |
| 26 | +## Where the portfolio framing bites | |
| 27 | + | |
| 28 | +Steid is portfolio-first, not a Gitea clone. The profile page is the product — repos | |
| 29 | +are one kind of thing on it, alongside writing and projects. Any layout inherited from | |
| 30 | +a GitHub-shaped forge needs checking against that before it's copied. | |
| 31 | + | |
| 32 | +## Stack | |
| 33 | + | |
| 34 | +Topcoat bundles assets and ships Tailwind **without Node**, plus htmx and Alpine | |
| 35 | +integrations, Fontsource, and Iconify. | |
| 36 | + | |
| 37 | +That's worth noting: the previous attempt dropped Tailwind purely to avoid an npm | |
| 38 | +build step and hand-rolled CSS instead. Topcoat removes that objection, so Tailwind is | |
| 39 | +back on the table at no tooling cost. | |
| 40 | + | |
| 41 | +Suggested, none of it settled until Milestone 5 needs real pages: | |
| 42 | + | |
| 43 | +| Concern | Candidate | | |
| 44 | +|---|---| | |
| 45 | +| Styling | Tailwind via Topcoat's bundler | | |
| 46 | +| UI font | Inter (Fontsource) | | |
| 47 | +| Mono font | IBM Plex Mono | | |
| 48 | +| Icons | Iconify | | |
| 49 | +| Interactivity | Topcoat signals; htmx where signals fall short | | |
| 50 | + | |
| 51 | +Topcoat's own client reactivity is early and acknowledged as limited, which is why the | |
| 52 | +htmx integration exists. Reach for signals first, fall back without ceremony. | |
| 53 | + | |
| 54 | +## Prior art | |
| 55 | + | |
| 56 | +Attempt #1 (`steid-backup/AGENTS/UI.md`) has a complete 643-line design system — | |
| 57 | +OKLCH light/dark palettes with concrete token values, a type scale, spacing scale, and | |
| 58 | +component markup for sidebar, file tree, commit bar, breadcrumbs, badges, empty | |
| 59 | +states. It was written for Tailwind + DaisyUI. | |
| 60 | + | |
| 61 | +It is worth mining when Milestone 5 arrives, with two caveats: it specifies DaisyUI, | |
| 62 | +which is a separate choice from Tailwind and not bundled by Topcoat; and it was | |
| 63 | +written for a GitHub-shaped forge rather than a portfolio-first one. | |
| 64 | + | |
| 65 | +It is also, on its own, longer than every other doc in this directory combined — for | |
| 66 | +an app that had about nine pages. Take the palette and the principles. Don't | |
| 67 | +re-specify components before there are components. |