steid

@jamesgill /

feat: the root is the owner's profile

Steid is hosted at jpgilldev.com, not git.jpgilldev.com — a `git.` subdomain
announces a Gitea clone, and the ROADMAP's framing is that the profile page is
the product and repositories are one kind of thing on it. Every reference is
repointed accordingly, including the release URL baked into install.sh, which
had to settle before anyone downloads a copy of that script.

Hosting at the apex exposed a gap the old comment had already predicted: an
anonymous visitor to the root got a sign-in prompt. For a personal site that is
the front door answering the wrong question — a stranger came for the profile
and will never sign in. The root now forwards them to the owner.

Knowing who "the owner" is needed a new port method, and the interesting part is
what it refuses to do. sole_user returns None both for an unclaimed instance and
for one with more than one user, because "who owns this instance" stops being
well defined the moment registration exists. Returning whichever row came back
first would send every stranger to a person chosen by storage order. SQLite asks
for `limit 2` rather than `limit 1` precisely so it can see the second user and
refuse. Milestone 7 has to define the concept properly; until then the root falls
back to the generic landing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZwc7URWKVhkAuRTWiDmjA
JamesPatrickGill authored 1 day agoparent0f19654Browse files8ed4e5a64ca5e285a9a9f8da91c598dd4cb0d528

12 files changed+144 −15

deploy/steid.service+1 −1View file
@@ -12,7 +12,7 @@
1212
1313 [Unit]
1414 Description=Steid
15Documentation=https://git.jpgilldev.com/jamesgill/repos/steid
15+Documentation=https://jpgilldev.com/jamesgill/repos/steid
1616 After=network-online.target
1717 Wants=network-online.target
1818
install.sh+1 −1View file
@@ -45,7 +45,7 @@ set -eu
4545 # profiles at /{handle}, so a root path would squat its own namespace. This is
4646 # also exactly where a real release feature would put these files, so published
4747 # links survive that feature landing.
48RELEASE_BASE_URL="${STEID_RELEASE_BASE_URL:-https://git.jpgilldev.com/jamesgill/repos/steid/releases}"
48+RELEASE_BASE_URL="${STEID_RELEASE_BASE_URL:-https://jpgilldev.com/jamesgill/repos/steid/releases}"
4949
5050 # The version to install. Pinned rather than "latest" because there is no
5151 # redirect to resolve "latest" against, and a pinned default makes the upgrade
plans/current.md+1 −1View file
@@ -86,7 +86,7 @@ instance, and Steid's own source is pushed to it and browsable there.
8686 deliberately says nothing about licensing rather than guessing.
8787 - ~~Blocked on a domain transfer~~ — **transferred 2026-08-29**, so Phases 1–6 of the
8888 deployment runbook are unblocked.
89- **(historic) Blocked on a domain transfer** (noted 2026-08-29). `git.jpgilldev.com` is the
89+- **(historic) Blocked on a domain transfer** (noted 2026-08-29). `jpgilldev.com` is the
9090 intended host for both the instance and the release downloads; the transfer is in
9191 flight. Phases 1–6 of the deployment runbook cannot start until DNS resolves, because
9292 Caddy requests a certificate on startup. Nothing else is blocked by it — the artifact
plans/runbook.md+4 −4View file
@@ -252,14 +252,14 @@ hand-carried between repos.
252252 ## Deploying this instance
253253
254254 The operator's path, as opposed to `README.md`, which is written for a stranger
255installing their own. `git.jpgilldev.com` serves two roles from one box: this instance,
255+installing their own. `jpgilldev.com` serves two roles from one box: this instance,
256256 and the place everyone else downloads Steid from.
257257
258258 ### Order matters
259259
260260 1. **Provision** — Debian 12, x86_64. Open 22, 80 and 443. Port 80 is not optional:
261261 Let's Encrypt validates over it.
2622. **DNS first, then install.** `dig +short git.jpgilldev.com` must return the box's IP
262+2. **DNS first, then install.** `dig +short jpgilldev.com` must return the box's IP
263263 *before* `install.sh` runs. Caddy requests a certificate on startup; if DNS has not
264264 propagated it fails and backs off, and the resulting error points nowhere useful.
265265 3. **First install uses `--tarball`.** There is a bootstrap: this instance is what will
@@ -267,11 +267,11 @@ and the place everyone else downloads Steid from.
267267
268268 ```sh
269269 scp dist/steid-0.1.0-x86_64-unknown-linux-gnu.tar.gz install.sh root@<ip>:/root/
270ssh root@<ip> './install.sh --domain git.jpgilldev.com \
270+ssh root@<ip> './install.sh --domain jpgilldev.com \
271271 --tarball ./steid-0.1.0-x86_64-unknown-linux-gnu.tar.gz'
272272 ```
273273
274Then `curl https://git.jpgilldev.com/healthz` — over https, with a real certificate.
274+Then `curl https://jpgilldev.com/healthz` — over https, with a real certificate.
275275
276276 ### Becoming the distribution host
277277
plans/ui.md+14 −0View file
@@ -105,6 +105,20 @@ blander default.
105105 The general rule this sets: **hierarchy comes from weight, colour and space. Reach for
106106 size last, and outside a page title, probably not at all.**
107107
108+### The front door
109+
110+Steid is meant to be someone's site, reached at their own domain — `jpgilldev.com`, not
111+`git.jpgilldev.com`, because a `git.` subdomain announces a Gitea clone and this is
112+portfolio-first. That makes the root a signpost rather than a page: unclaimed it sends
113+you to setup, and otherwise it forwards to a profile — your own when signed in, **the
114+owner's when not**.
115+
116+The last case is the one that matters. A stranger arriving at the apex came for the
117+profile and will never sign in, so a sign-in prompt there is the front door answering the
118+wrong question. It only applies when the owner is unambiguous: with more than one user
119+"the owner" has no answer, so the root falls back to a generic landing rather than
120+electing someone by storage order.
121+
108122 ### What it needs that does not exist yet
109123
110124 - **`updated_at` on a repository.** The design promises recency and nothing stores it.
src/application/claim.rs+25 −2View file
@@ -1,6 +1,6 @@
11 use crate::domain::{
2 Actor, DomainError, Email, Membership, MembershipId, OrgId, Organization, Role, SetupToken,
3 User, UserId,
2+ Actor, DomainError, Email, Membership, MembershipId, OrgId, OrgName, Organization, Role,
3+ SetupToken, User, UserId,
44 repository::{MembershipRepository, OrgRepository, UserRepository},
55 };
66
@@ -21,6 +21,29 @@ pub async fn is_claimed(users: &impl UserRepository) -> Result<bool> {
2121 Ok(users.any_exist().await?)
2222 }
2323
24+/// The handle of the sole user, when this instance has exactly one.
25+///
26+/// What makes a personal instance's front door work: `jpgilldev.com` should be the
27+/// owner's profile, not a sign-in prompt, and the root needs to know whose profile that
28+/// is without anyone being signed in.
29+///
30+/// `None` for an unclaimed instance and — deliberately — for one with more than one
31+/// user, because "the owner" stops being well defined the moment registration exists.
32+/// The root then falls back to a generic landing rather than picking someone.
33+pub async fn sole_owner_handle(
34+ users: &impl UserRepository,
35+ orgs: &impl OrgRepository,
36+) -> Result<Option<OrgName>> {
37+ let Some(user) = users.sole_user().await? else {
38+ return Ok(None);
39+ };
40+
41+ Ok(orgs
42+ .find_by_id(&user.personal_org_id)
43+ .await?
44+ .map(|org| org.name))
45+}
46+
2447 /// Creates the owner of an unclaimed installation and returns them as an actor.
2548 ///
2649 /// Gated on the one-time setup token printed at boot — see `plans/decisions/0002`.
src/application/mod.rs+1 −1View file
@@ -21,7 +21,7 @@ pub use browse::{
2121 Browsed, FileView, LOG_LIMIT, MAX_BLOB_BYTES, MAX_RAW_BYTES, RawFile, RefList, browse_repo,
2222 list_refs, read_raw_file, repo_log,
2323 };
24pub use claim::{OwnerSpec, claim_instance, is_claimed};
24+pub use claim::{OwnerSpec, claim_instance, is_claimed, sole_owner_handle};
2525 pub use config::{AppConfig, Secret};
2626 pub use error::{Error, Result};
2727 pub use git::{GitClientHeaders, GitEndpoint, GitOperation, GitService, serve_git};
src/domain/repository/user_repo.rs+10 −0View file
@@ -23,4 +23,14 @@ pub trait UserRepository: Send + Sync {
2323
2424 /// Whether any user exists. Drives first-boot bootstrap.
2525 fn any_exist(&self) -> impl Future<Output = RepositoryResult<bool>> + Send;
26+
27+ /// The only user, when there is exactly one.
28+ ///
29+ /// `None` when the instance is unclaimed **and** when more than one user exists —
30+ /// the second case is deliberate rather than a shortcut. "Who owns this instance"
31+ /// has no answer once registration exists, so this refuses to guess instead of
32+ /// returning whichever row happened to come back first. Milestone 7 has to define
33+ /// the concept properly; until then a personal instance has one user and this is
34+ /// unambiguous.
35+ fn sole_user(&self) -> impl Future<Output = RepositoryResult<Option<User>>> + Send;
2636 }
src/infrastructure/repository/in_memory.rs+9 −0View file
@@ -48,6 +48,15 @@ impl UserRepository for InMemoryUserRepo {
4848 Ok(())
4949 }
5050
51+ async fn sole_user(&self) -> RepositoryResult<Option<User>> {
52+ let users = self.users.lock().expect("lock poisoned");
53+
54+ match users.len() {
55+ 1 => Ok(users.values().next().cloned()),
56+ _ => Ok(None),
57+ }
58+ }
59+
5160 async fn any_exist(&self) -> RepositoryResult<bool> {
5261 let users = self.users.lock().expect("lock poisoned");
5362 Ok(!users.is_empty())
src/infrastructure/repository/sqlite.rs+53 −0View file
@@ -82,6 +82,20 @@ impl UserRepository for SqliteUserRepo {
8282 Ok(())
8383 }
8484
85+ async fn sole_user(&self) -> RepositoryResult<Option<User>> {
86+ // `limit 2`, not `limit 1`: the query has to be able to see a second user in
87+ // order to refuse, and `limit 1` would silently pick one.
88+ let rows = sqlx::query("select * from users limit 2")
89+ .fetch_all(&self.pool)
90+ .await
91+ .map_err(backend)?;
92+
93+ match rows.as_slice() {
94+ [row] => Ok(Some(Self::map(row))),
95+ _ => Ok(None),
96+ }
97+ }
98+
8599 async fn any_exist(&self) -> RepositoryResult<bool> {
86100 let count: i64 = sqlx::query_scalar("select exists (select 1 from users)")
87101 .fetch_one(&self.pool)
@@ -1302,4 +1316,43 @@ mod tests {
13021316
13031317 assert!(tokens.save(&orphan).await.is_err());
13041318 }
1319+
1320+ // --- sole_user ----------------------------------------------------------------
1321+
1322+ #[tokio::test]
1323+ async fn there_is_no_sole_user_before_the_instance_is_claimed() {
1324+ let repos = repos().await;
1325+
1326+ assert!(repos.users.sole_user().await.expect("lookup").is_none());
1327+ }
1328+
1329+ #[tokio::test]
1330+ async fn the_only_user_is_the_sole_user() {
1331+ let repos = repos().await;
1332+ let org = saved_org(&repos, "acme").await;
1333+ let user = saved_user(&repos, "owner@example.com", &org).await;
1334+
1335+ let found = repos
1336+ .users
1337+ .sole_user()
1338+ .await
1339+ .expect("lookup")
1340+ .expect("one user");
1341+
1342+ assert_eq!(found.id, user.id);
1343+ }
1344+
1345+ #[tokio::test]
1346+ async fn a_second_user_makes_the_owner_undefined_rather_than_arbitrary() {
1347+ // The root redirects an anonymous visitor to "the owner". With two users that
1348+ // question has no answer, and picking whichever row came back first would send
1349+ // strangers to a person chosen by storage order.
1350+ let repos = repos().await;
1351+ let first = saved_org(&repos, "acme").await;
1352+ let second = saved_org(&repos, "other").await;
1353+ saved_user(&repos, "one@example.com", &first).await;
1354+ saved_user(&repos, "two@example.com", &second).await;
1355+
1356+ assert!(repos.users.sole_user().await.expect("lookup").is_none());
1357+ }
13051358 }
src/infrastructure/web/context.rs+4 −0View file
@@ -68,6 +68,10 @@ pub fn storage(cx: &Cx) -> DiskGitStorage {
6868 DiskGitStorage::new(app_context::<AppConfig>(cx).data_dir.clone())
6969 }
7070
71+pub fn users(cx: &Cx) -> SqliteUserRepo {
72+ SqliteUserRepo::new(pool(cx).clone())
73+}
74+
7175 pub fn tokens(cx: &Cx) -> SqliteTokenRepo {
7276 SqliteTokenRepo::new(pool(cx).clone())
7377 }
src/infrastructure/web/pages.rs+21 −5View file
@@ -5,14 +5,23 @@ use topcoat::{
55 view::view,
66 };
77
8use super::context::{claimed, identity};
8+use crate::application::sole_owner_handle;
9+
10+use super::context::{claimed, identity, orgs, server_error, users};
911
1012 /// The root.
1113 ///
12/// Unclaimed, it sends you to `/auth/setup`. Claimed and signed in, it forwards to your
13/// own profile — the profile is the product, so the root is a signpost rather than a
14/// page. Claimed and signed out, it offers a way in; a multi-user instance will want
15/// something better here, but there is nothing to index yet.
14+/// The profile is the product, so the root is a signpost rather than a page of its own.
15+/// Unclaimed, it sends you to `/auth/setup`. Otherwise it forwards to a profile: your
16+/// own if you are signed in, and **the owner's if you are not**.
17+///
18+/// That last case is what makes a personal instance's front door work. Steid is meant to
19+/// be someone's site, reached at their own domain, so a stranger arriving at the root
20+/// must land on the owner rather than on a sign-in prompt — the thing they came for is
21+/// the profile, and they will never sign in.
22+///
23+/// Only when the owner is unambiguous. A multi-user instance has no single profile to
24+/// show, so it falls back to the generic landing rather than electing someone.
1625 #[page("/")]
1726 async fn home(cx: &Cx) -> Result {
1827 if !claimed(cx).await? {
@@ -23,6 +32,13 @@ async fn home(cx: &Cx) -> Result {
2332 return Err(redirect(&format!("/{}", identity.handle)).into());
2433 }
2534
35+ if let Some(owner) = sole_owner_handle(&users(cx), &orgs(cx))
36+ .await
37+ .map_err(server_error)?
38+ {
39+ return Err(redirect(&format!("/{owner}")).into());
40+ }
41+
2642 view! {
2743 <h1>"steid"</h1>
2844 <p><a href="/auth/login">"Sign in"</a></p>