Skip to main content

max / makenotwork

Give the mt browser axis a logged-in surface to drive The browser lens has only ever seen mt's read paths, because every write needs a session and mt logs in through MNW. The accounts and the OAuth client that makes that possible go into testnot's example seed rather than beside the instance: mnw-testnot-seed.sh drops every schema, so anything created by hand is gone at the next reset and the harness starts failing at login with nothing in the diff to explain it. server: a harness phase on --seed-examples, opt-in per box via MT_HARNESS_PASSWORD and MT_HARNESS_REDIRECT_URI, seeding three login-capable accounts at fixed ids (Fan+, creator, plain) and the PKCE client mt-astra-harness with the mt callback registered. Neither value is in the repo: one is a credential, the other names a tailnet host. multithreaded: the same three ids in the forum seed, carrying Owner and Moderator so a moderation action has somewhere to land, because login upserts identity and perks but never membership. deploy/reset-astra.sh wipes, re-migrates and reseeds the instance, then asserts those preconditions rather than trusting a 200 — an instance that is healthy but unseeded fails the first write with a 403, which reads like a permissions bug in mt. deploy.sh now reads its sibling-crate list out of the manifests. The hardcoded list had drifted both ways: it named docengine and livechat, which mt no longer takes by path, and omitted pom-contract, which it does. The instance is https://astra.tailc6b3e1.ts.net:3443, tailnet only, published by tailscale serve with mt bound to loopback. TLS is not decoration there: config.rs refuses to boot a non-loopback deployment over plain http.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-06 21:22 UTC
Signed with PGP, not checked
Commit: 9cc042b08cc0e2545d6d16ac0d7903d327accde5
Parent: 7dfb5d8
10 files changed, +831 insertions, -15 deletions
@@ -5010,13 +5010,9 @@
5010 5010 source = "registry+https://github.com/rust-lang/crates.io-index"
5011 5011 checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
5012 5012
5013 - [[patch.unused]]
5014 - name = "supernote-push"
5015 - version = "0.1.0"
5016 -
5017 5013 [[patch.unused]]
5018 5014 name = "synckit-client"
5019 - version = "0.6.0"
5015 + version = "0.7.0"
5020 5016
5021 5017 [[patch.unused]]
5022 5018 name = "synckit-config"
@@ -35,3 +35,10 @@
35 35 # SMTP_USER=
36 36 # SMTP_PASS=
37 37 # FROM_EMAIL=noreply@makenot.work
38 +
39 + # Optional: the multithreaded browser-harness phase of `--seed-examples`. Set
40 + # both on a testnot box to have the reseed recreate the login-capable harness
41 + # accounts and the mt OAuth client; leave unset anywhere else and the seed
42 + # builds the catalog alone. See multithreaded/deploy/README.md.
43 + # MT_HARNESS_PASSWORD=
44 + # MT_HARNESS_REDIRECT_URI=https://<mt-host>/auth/callback
@@ -100,3 +100,68 @@
100 100
101 101 Rollback is the MNW server's rollback: the previous bundle still holds the
102 102 previous mt binary.
103 +
104 + ## astra: the write-enabled harness instance
105 +
106 + `deploy/deploy.sh` installs mt on astra. That instance is not a second staging
107 + box in general; it exists so an audit run's browser lens can **write**.
108 +
109 + The public demo (testnot) is deliberately read-only and no-login, which leaves
110 + every mutation path browser-unverified, and mutation paths are where
111 + rendered-behaviour bugs live: the CSRF token round-tripping through a real form
112 + submission, HX swap targets, optimistic UI, the compiled islands once
113 + `static/dist` is loaded. Verifying those needs a session, and a session needs a
114 + real OAuth login. So the harness instance is reachable over the tailnet only,
115 + logs in against testnot's MNW, and is wiped between runs.
116 +
117 + Reached at `https://astra.tailc6b3e1.ts.net:3443`, published by `tailscale serve`
118 + (`tailscale serve --bg --https=3443 http://127.0.0.1:3400`) and reachable from
119 + the tailnet only. mt itself binds `127.0.0.1`, so the app port is on no other
120 + interface and the tailnet proxy is the only way in.
121 +
122 + TLS is not optional here, and finding that out is worth two minutes of someone
123 + else's time: `config.rs` refuses to boot a non-loopback deployment on plain
124 + `http`, because that would carry OAuth codes and session cookies in the clear.
125 + `tailscale serve` is what supplies the certificate — astra's own Caddy already
126 + holds `:443` for something else, hence the `:3443`. Set `COOKIE_SECURE=true`
127 + to match.
128 +
129 + ### What it depends on, on the MNW side
130 +
131 + Login is the whole point, so mt is only half the setup. The other half is
132 + testnot's example seed, whose harness phase creates the accounts and the OAuth
133 + client (`server/src/seed/harness.rs`):
134 +
135 + | Piece | Value |
136 + |-------|-------|
137 + | `OAUTH_CLIENT_ID` | `mt-astra-harness` (a `sync_apps` row; PKCE, no secret) |
138 + | Registered redirect | `https://astra.tailc6b3e1.ts.net:3443/auth/callback` |
139 + | Accounts | `harness_fan` (Fan+), `harness_creator` (creator), `harness_owner` |
140 + | Password | shared, from `MT_HARNESS_PASSWORD` on the testnot box |
141 +
142 + That phase runs only when `MT_HARNESS_PASSWORD` and `MT_HARNESS_REDIRECT_URI`
143 + are both set in testnot's `EnvironmentFile` (`/etc/mnw/makenotwork.env`), which
144 + `mnw-testnot-seed.sh` already passes through. Neither value is in the repo: one
145 + is a credential, the other names a tailnet host.
146 +
147 + The three accounts carry fixed MNW ids, and mt's own seed (`src/seed.rs`)
148 + assigns their community roles by `mnw_account_id` — Owner and Moderator for
149 + `harness_owner` — because login upserts identity and perks but never
150 + membership. The two id lists have to be edited together; the MNW side has a test
151 + pinning the literals.
152 +
153 + ### Resetting it
154 +
155 + ```
156 + deploy/reset-astra.sh
157 + ```
158 +
159 + Stops the service, drops every schema, re-migrates, re-seeds, and then asserts
160 + the harness preconditions (three accounts, an Owner membership, threads
161 + present). A run that logs in and finds no membership fails at the first write
162 + with a 403, which reads like a permissions bug rather than an unfinished seed,
163 + so the reset verifies rather than assuming.
164 +
165 + This resets mt only. The identities live on testnot and come back from its own
166 + reseed; the two are independent on purpose, because mt's state is disposable and
167 + the accounts behind it are not.
@@ -35,16 +35,27 @@
35 35 --exclude frontend/node_modules/ \
36 36 ./ $SERVER:~/$SRC_DIR/
37 37
38 - # Sync shared dependencies (Cargo.toml references ../shared/ from ~/src/multithreaded/)
39 - local shared_dir
40 - shared_dir="$(cd ../shared && pwd)"
41 - for dep in docengine tagtree s3-storage livechat; do
42 - local dep_dir="$shared_dir/$dep"
43 - if [ -d "$dep_dir" ]; then
44 - echo "[rsync] Syncing shared dep: $dep"
45 - ssh $SERVER "mkdir -p ~/src/shared/$dep"
38 + # Sync the sibling crates the manifests point at (`../shared/...` resolves
39 + # the same way from ~/src/multithreaded on the build host).
40 + #
41 + # Read the list out of the manifests rather than hardcoding it. The hardcoded
42 + # version listed docengine and livechat, which mt no longer takes by path,
43 + # and omitted pom-contract, which it does: the build failed on the build host
44 + # for a dependency the deploy script had never heard of. A list derived from
45 + # the manifests cannot drift from them.
46 + local dep_paths dep_path dep
47 + dep_paths="$(grep -ho 'path = "\.\./[^"]*"' Cargo.toml crates/*/Cargo.toml \
48 + | sed 's/path = "//; s/"$//' | sort -u)"
49 + for dep_path in $dep_paths; do
50 + dep="${dep_path#../}"
51 + if [ -d "$dep_path" ]; then
52 + echo "[rsync] Syncing sibling crate: $dep"
53 + ssh $SERVER "mkdir -p ~/src/$dep"
46 54 rsync -az --delete --exclude target --exclude .git \
47 - "$dep_dir/" "$SERVER:~/src/shared/$dep/"
55 + "$dep_path/" "$SERVER:~/src/$dep/"
56 + else
57 + echo "[rsync] ERROR: manifest points at $dep_path, which does not exist" >&2
58 + exit 1
48 59 fi
49 60 done
50 61 echo "[rsync] Done"
@@ -18,6 +18,7 @@
18 18 // --- users
19 19
20 20 let users = seed_users(pool).await;
21 + let harness = seed_harness_users(pool).await;
21 22
22 23 // --- communities
23 24
@@ -146,6 +147,17 @@
146 147 seed_membership_upsert(pool, users[1].id, rust_id, CommunityRole::Member).await;
147 148 seed_membership_upsert(pool, users[1].id, music_id, CommunityRole::Moderator).await;
148 149
150 + // The harness accounts get the roles the browser axis needs to write:
151 + // ordinary membership for thread/reply/flag, and Owner + Moderator on two
152 + // different communities so a moderation action has somewhere to land. The
153 + // roles are seeded rather than granted at login because login only ever
154 + // upserts identity and MNW perks, never membership.
155 + for account in &harness {
156 + seed_membership_upsert(pool, account.id, rust_id, account.rust_role).await;
157 + seed_membership_upsert(pool, account.id, music_id, account.music_role).await;
158 + seed_membership_upsert(pool, account.id, selfhosted_id, CommunityRole::Member).await;
159 + }
160 +
149 161 // --- Rust community: a few threads
150 162
151 163 let welcome_id = seed_thread(
@@ -225,6 +237,88 @@
225 237 id: Uuid,
226 238 }
227 239
240 + /// A harness account: the local row plus the roles it carries per community.
241 + struct HarnessUser {
242 + id: Uuid,
243 + rust_role: CommunityRole,
244 + music_role: CommunityRole,
245 + }
246 +
247 + /// The three accounts the browser harness logs in as.
248 + ///
249 + /// `mnw_account_id` is the join to MNW and these values are fixed on both
250 + /// sides: the MNW example seed creates the same three ids in
251 + /// `server/src/seed/harness.rs`, which is what lets roles be assigned here
252 + /// before anyone has logged in. The two lists have to be edited together, and
253 + /// the MNW side has a test pinning the literals to make that hard to forget.
254 + ///
255 + /// `is_fan_plus`/`is_creator` mirror the perks MNW will report at login. They
256 + /// are denormalised onto the row for post rendering, and login overwrites them
257 + /// from userinfo, so seeding them wrong is cosmetic rather than a privilege
258 + /// question. Seed them right anyway: a pre-login render of a seeded post is one
259 + /// of the things the harness looks at.
260 + async fn seed_harness_users(pool: &PgPool) -> Vec<HarnessUser> {
261 + let harness_data = [
262 + (
263 + "00000000-0000-0000-0000-00000000f001",
264 + "harness_fan",
265 + "Harness Fan",
266 + true,
267 + false,
268 + CommunityRole::Member,
269 + CommunityRole::Member,
270 + ),
271 + (
272 + "00000000-0000-0000-0000-00000000f002",
273 + "harness_creator",
274 + "Harness Creator",
275 + false,
276 + true,
277 + CommunityRole::Member,
278 + CommunityRole::Member,
279 + ),
280 + (
281 + "00000000-0000-0000-0000-00000000f003",
282 + "harness_owner",
283 + "Harness Owner",
284 + false,
285 + false,
286 + CommunityRole::Owner,
287 + CommunityRole::Moderator,
288 + ),
289 + ];
290 +
291 + let mut users = Vec::new();
292 + for (uuid_str, username, display_name, is_fan_plus, is_creator, rust_role, music_role) in
293 + harness_data
294 + {
295 + let id = Uuid::parse_str(uuid_str).unwrap();
296 + sqlx::query(
297 + "INSERT INTO users (mnw_account_id, username, display_name, is_fan_plus, is_creator)
298 + VALUES ($1, $2, $3, $4, $5)
299 + ON CONFLICT (mnw_account_id) DO UPDATE
300 + SET username = EXCLUDED.username,
301 + display_name = EXCLUDED.display_name,
302 + is_fan_plus = EXCLUDED.is_fan_plus,
303 + is_creator = EXCLUDED.is_creator",
304 + )
305 + .bind(id)
306 + .bind(username)
307 + .bind(display_name)
308 + .bind(is_fan_plus)
309 + .bind(is_creator)
310 + .execute(pool)
311 + .await
312 + .expect("failed to seed harness user");
313 + users.push(HarnessUser {
314 + id,
315 + rust_role,
316 + music_role,
317 + });
318 + }
319 + users
320 + }
321 +
228 322 async fn seed_users(pool: &PgPool) -> Vec<SeedUser> {
229 323 let user_data = [
230 324 ("00000000-0000-0000-0000-000000000001", "admin", "Admin"),
@@ -22,6 +22,13 @@
22 22 # configured in the env file (S3_*). Until MinIO is stood up on testnot, items
23 23 # seed hidden (scan_status stays pending) and the catalog shows creators,
24 24 # projects, blog posts, and follow counts without media.
25 + #
26 + # The seed's harness phase (login-capable accounts plus the OAuth client for the
27 + # write-enabled mt on astra) runs only when MT_HARNESS_PASSWORD and
28 + # MT_HARNESS_REDIRECT_URI are both set. They are read from $ENV_FILE, which the
29 + # systemd-run below already passes through, so nothing here needs to know them.
30 + # A box without them reseeds the catalog alone and the mt browser harness loses
31 + # its login — see multithreaded/deploy/README.md.
25 32 set -euo pipefail
26 33
27 34 SSH_TARGET="${TESTNOT_SSH:-root@testnot}"
@@ -32,6 +32,7 @@
32 32
33 33 pub mod blog;
34 34 pub mod creators;
35 + pub mod harness;
35 36 pub mod items;
36 37 pub mod media;
37 38 pub mod projects;
@@ -112,14 +113,24 @@
112 113 pub allow_example_seed: bool,
113 114 /// The configured `HOST_URL` (from [`crate::config::Config`]).
114 115 pub host_url: String,
116 + /// Credentials for the mt browser-harness phase, when the box carries them.
117 + /// `None` skips that phase and seeds the catalog alone.
118 + ///
119 + /// Carried here rather than read inside the phase so a test names what it
120 + /// wants explicitly: the harness accounts change the account counts every
121 + /// other seed test asserts, and a stray env var in a dev shell should not be
122 + /// able to fail them.
123 + pub harness: Option<harness::HarnessOptions>,
115 124 }
116 125
117 126 impl SeedOptions {
118 - /// Read the opt-in switch from the environment; take `host_url` from config.
127 + /// Read the opt-in switch and the harness credentials from the environment;
128 + /// take `host_url` from config.
119 129 pub fn from_env(host_url: &str) -> Self {
120 130 Self {
121 131 allow_example_seed: std::env::var("ALLOW_EXAMPLE_SEED").ok().as_deref() == Some("1"),
122 132 host_url: host_url.to_string(),
133 + harness: harness::HarnessOptions::from_env(),
123 134 }
124 135 }
125 136 }
@@ -167,6 +178,19 @@
167 178 social::seed_social(pool, &projects).await?;
168 179 tracing::warn!("example seed: forum (Phase 5) deferred; refresh-flow swap is Phase 6");
169 180
181 + // Harness phase: login-capable accounts and the mt OAuth client, for the
182 + // browser axis of an audit run. Opt-in per box (see `harness`), because the
183 + // password and the mt callback URL are environment, not repo content. A box
184 + // without them seeds exactly the catalog it seeded before.
185 + match opts.harness.as_ref() {
186 + Some(harness_opts) => harness::seed_harness(pool, harness_opts).await?,
187 + None => tracing::info!(
188 + "example seed: harness phase skipped ({} and {} must both be set)",
189 + harness::PASSWORD_ENV,
190 + harness::REDIRECT_URI_ENV,
191 + ),
192 + }
193 +
170 194 Ok(())
171 195 }
172 196
@@ -33,6 +33,7 @@
33 33 SeedOptions {
34 34 allow_example_seed: true,
35 35 host_url: "https://testnot.work".to_string(),
36 + harness: None,
36 37 }
37 38 }
38 39
@@ -289,6 +290,7 @@
289 290 &SeedOptions {
290 291 allow_example_seed: true,
291 292 host_url: "https://makenot.work".to_string(),
293 + harness: None,
292 294 },
293 295 &SeedMedia::none(),
294 296 )
@@ -449,3 +451,182 @@
449 451 SEEDED_ITEMS
450 452 );
451 453 }
454 +
455 + // ── Harness phase ──
456 + //
457 + // The accounts and OAuth client the mt browser axis logs in with. Their whole
458 + // value is that a testnot reseed reproduces them exactly: an audit run that
459 + // cannot log in stops at reads, which is the coverage gap the phase exists to
460 + // close. These tests pin the three things a login actually depends on, the
461 + // password verifying, the perks MNW will report, and the redirect URI being
462 + // registered, plus survival across a reseed.
463 +
464 + use makenotwork::seed::harness::{
465 + self, CREATOR_ACCOUNT_ID, FAN_ACCOUNT_ID, HarnessOptions, OWNER_ACCOUNT_ID,
466 + };
467 +
468 + const HARNESS_REDIRECT_URI: &str = "http://mt.example.test:3400/auth/callback";
469 +
470 + fn harness_opts() -> HarnessOptions {
471 + HarnessOptions {
472 + password: "harness-test-password".to_string(),
473 + redirect_uri: HARNESS_REDIRECT_URI.to_string(),
474 + }
475 + }
476 +
477 + /// Options that run the catalog *and* the harness phase, as a testnot box with
478 + /// both env vars set does.
479 + fn testnot_opts_with_harness() -> SeedOptions {
480 + SeedOptions {
481 + harness: Some(harness_opts()),
482 + ..testnot_opts()
483 + }
484 + }
485 +
486 + async fn password_hash_of(pool: &sqlx::PgPool, id: uuid::Uuid) -> String {
487 + sqlx::query_scalar("SELECT password_hash FROM users WHERE id = $1")
488 + .bind(id)
489 + .fetch_one(pool)
490 + .await
491 + .expect("harness account should exist")
492 + }
493 +
494 + #[tokio::test]
495 + async fn harness_accounts_can_log_in_and_carry_their_perks() {
496 + let db = TestDb::new().await;
497 + seed::run(&db.pool, &testnot_opts_with_harness(), &SeedMedia::none())
498 + .await
499 + .expect("seed with harness phase");
500 +
501 + // The password verifies: this is the login the browser run performs.
502 + for id in [FAN_ACCOUNT_ID, CREATOR_ACCOUNT_ID, OWNER_ACCOUNT_ID] {
503 + let hash = password_hash_of(&db.pool, id).await;
504 + assert!(
505 + makenotwork::auth::verify_password_async(harness_opts().password, hash)
506 + .await
507 + .expect("verify"),
508 + "seeded password should verify for {id}"
509 + );
510 + }
511 +
512 + // Perks, as `/oauth/userinfo` computes them: fan_plus from an active
513 + // subscription row, is_creator from a non-null creator_tier. The pair is
514 + // what mt's `UserPerks::effective_plus` gate reads, and the two accounts
515 + // exercise its two halves separately.
516 + let fan_plus_active: bool = sqlx::query_scalar(
517 + "SELECT EXISTS(SELECT 1 FROM fan_plus_subscriptions \
518 + WHERE user_id = $1 AND status = 'active')",
519 + )
520 + .bind(FAN_ACCOUNT_ID)
521 + .fetch_one(&db.pool)
522 + .await
523 + .expect("fan plus lookup");
524 + assert!(fan_plus_active, "fan account should hold active Fan+");
525 +
526 + let tiers: Vec<Option<String>> =
527 + sqlx::query_scalar("SELECT creator_tier FROM users WHERE id = ANY($1) ORDER BY username")
528 + .bind(vec![FAN_ACCOUNT_ID, CREATOR_ACCOUNT_ID, OWNER_ACCOUNT_ID])
529 + .fetch_all(&db.pool)
530 + .await
531 + .expect("creator tiers");
532 + // Ordered by username: harness_creator, harness_fan, harness_owner.
533 + assert_eq!(
534 + tiers,
535 + vec![Some("everything".to_string()), None, None],
536 + "only the creator account should report is_creator"
537 + );
538 +
539 + // Creator powers follow the tier. Three accounts that all hold them would be
540 + // one role tested three times.
541 + let can_create: Vec<bool> = sqlx::query_scalar(
542 + "SELECT can_create_projects FROM users WHERE id = ANY($1) ORDER BY username",
543 + )
544 + .bind(vec![FAN_ACCOUNT_ID, CREATOR_ACCOUNT_ID, OWNER_ACCOUNT_ID])
545 + .fetch_all(&db.pool)
546 + .await
547 + .expect("project rights");
548 + assert_eq!(can_create, vec![true, false, false]);
549 +
550 + // Not locked out, not suspended: a prior run's failed logins must not
551 + // survive the reset.
552 + let clean: i64 = sqlx::query_scalar(
553 + "SELECT COUNT(*) FROM users WHERE id = ANY($1) \
554 + AND failed_login_attempts = 0 AND locked_until IS NULL \
555 + AND suspended_at IS NULL AND deactivated_at IS NULL",
556 + )
557 + .bind(vec![FAN_ACCOUNT_ID, CREATOR_ACCOUNT_ID, OWNER_ACCOUNT_ID])
558 + .fetch_one(&db.pool)
559 + .await
560 + .expect("account state");
561 + assert_eq!(clean, 3);
562 + }
563 +
564 + #[tokio::test]
565 + async fn harness_client_registers_its_redirect_uri() {
566 + let db = TestDb::new().await;
567 + seed::run(&db.pool, &testnot_opts_with_harness(), &SeedMedia::none())
568 + .await
569 + .expect("seed with harness phase");
570 +
571 + // `validate_redirect_uri` waves through localhost only; the harness instance
572 + // is reached over the tailnet, so the URI has to be registered or the
573 + // authorize call fails with "redirect_uri is not allowed".
574 + let row: (uuid::Uuid, Vec<String>, bool) = sqlx::query_as(
575 + "SELECT creator_id, redirect_uris, is_active FROM sync_apps WHERE api_key_hash = $1",
576 + )
577 + .bind(makenotwork::db::synckit::hash_api_key(harness::CLIENT_ID))
578 + .fetch_one(&db.pool)
579 + .await
580 + .expect("harness OAuth client should be registered");
581 +
582 + assert_eq!(row.0, CREATOR_ACCOUNT_ID, "client is owned by the creator");
583 + assert_eq!(row.1, vec![HARNESS_REDIRECT_URI.to_string()]);
584 + assert!(row.2, "client must be active");
585 + }
586 +
587 + #[tokio::test]
588 + async fn harness_survives_a_reseed_with_stable_ids() {
589 + let db = TestDb::new().await;
590 + seed::run(&db.pool, &testnot_opts_with_harness(), &SeedMedia::none())
591 + .await
592 + .expect("first seed");
593 +
594 + // A reseed wipes every @example.test account first, harness accounts
595 + // included, then rebuilds them. Stable ids are the property mt's seed
596 + // depends on: it pre-assigns community roles by mnw_account_id, so an id
597 + // that moved would leave the harness a plain member with no way to moderate.
598 + seed::run(&db.pool, &testnot_opts_with_harness(), &SeedMedia::none())
599 + .await
600 + .expect("reseed");
601 +
602 + let ids: Vec<uuid::Uuid> = sqlx::query_scalar(
603 + "SELECT id FROM users WHERE username LIKE 'harness\\_%' ORDER BY username",
604 + )
605 + .fetch_all(&db.pool)
606 + .await
607 + .expect("harness ids");
608 + assert_eq!(
609 + ids,
610 + vec![CREATOR_ACCOUNT_ID, FAN_ACCOUNT_ID, OWNER_ACCOUNT_ID],
611 + "harness ids must be identical after a reseed"
612 + );
613 +
614 + // And the catalog is unchanged by the phase running twice.
615 + assert_eq!(count_example_creators(&db.pool).await, SEEDED_CREATORS + 3);
616 + }
617 +
618 + #[tokio::test]
619 + async fn without_harness_options_the_phase_does_not_run() {
620 + let db = TestDb::new().await;
621 + seed::run(&db.pool, &testnot_opts(), &SeedMedia::none())
622 + .await
623 + .expect("seed without harness");
624 +
625 + let harness_accounts: i64 =
626 + sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE username LIKE 'harness\\_%'")
627 + .fetch_one(&db.pool)
628 + .await
629 + .expect("count");
630 + assert_eq!(harness_accounts, 0, "harness phase must be opt-in");
631 + assert_eq!(count_example_creators(&db.pool).await, SEEDED_CREATORS);
632 + }
@@ -1,0 +1,111 @@
1 + #!/usr/bin/env bash
2 + # Reset the astra mt instance to its seeded state.
3 + #
4 + # astra runs the write-enabled mt: reachable over the tailnet only, logged into
5 + # with real MNW accounts, and driven by the browser axis of an audit run through
6 + # thread create, reply, flag and moderation. That is a surface that accumulates
7 + # junk by design, so it needs a way back to a known state, and this is it.
8 + #
9 + # Hard reset, not incremental: drop every schema, let the binary re-migrate and
10 + # re-seed. Nobody is looking at this instance, so there is nothing to preserve
11 + # and no reason to make the reset clever. Two audit runs a week apart should see
12 + # the same forum.
13 + #
14 + # What it does NOT reset is the MNW side. The accounts the harness logs in as
15 + # live on testnot and are recreated by testnot's own reseed
16 + # (`sando/deploy/mnw-testnot-seed.sh`, harness phase). The two are deliberately
17 + # independent: mt's state is disposable, the identities behind it are not.
18 + #
19 + # Usage:
20 + # deploy/reset-astra.sh # stop, wipe, migrate, seed, start, verify
21 + #
22 + # Env:
23 + # MT_ASTRA_SSH ssh target (default: astra)
24 + # MT_ASTRA_DB database name (default: multithreaded)
25 + set -euo pipefail
26 +
27 + SSH_TARGET="${MT_ASTRA_SSH:-astra}"
28 + DB="${MT_ASTRA_DB:-multithreaded}"
29 + SERVICE="multithreaded.service"
30 + APP_DIR="/opt/multithreaded"
31 + BIN="$APP_DIR/multithreaded"
32 + ENV_FILE="$APP_DIR/.env"
33 + APP_USER="multithreaded"
34 + PORT="${MT_ASTRA_PORT:-3400}"
35 +
36 + log() { echo "[$(date -u +%H:%M:%S)] $*"; }
37 + on_astra() { ssh "$SSH_TARGET" "$@"; }
38 +
39 + log "stopping $SERVICE on $SSH_TARGET"
40 + on_astra "sudo systemctl stop $SERVICE"
41 +
42 + # Drop every non-system schema rather than just `public`: tower-sessions creates
43 + # its own, and a surviving session table would carry logins across a reset that
44 + # is supposed to have removed the users they point at. Recreate `public` OWNED BY
45 + # the app role, because on PG15+ a postgres-owned public grants the app no
46 + # CREATE and the boot migrations fail with "no schema has been selected".
47 + log "resetting schema in $DB"
48 + on_astra "sudo -u postgres psql -v ON_ERROR_STOP=1 -d $DB" <<SQL
49 + DO \$\$
50 + DECLARE s text;
51 + BEGIN
52 + FOR s IN
53 + SELECT nspname FROM pg_namespace
54 + WHERE nspname NOT LIKE 'pg_%' AND nspname <> 'information_schema'
55 + LOOP
56 + EXECUTE format('DROP SCHEMA IF EXISTS %I CASCADE', s);
57 + END LOOP;
58 + EXECUTE 'CREATE SCHEMA public AUTHORIZATION $APP_USER';
59 + END \$\$;
60 + SQL
61 +
62 + # `--seed` migrates the empty schema, seeds, and exits before binding a port, so
63 + # it is safe with the service stopped. systemd-run with the service's own
64 + # EnvironmentFile rather than sourcing it in a shell: systemd parses KEY=value
65 + # literally, so a secret containing shell metacharacters survives.
66 + log "migrating + seeding"
67 + on_astra "sudo systemd-run --pipe --wait --collect --service-type=exec \
68 + -p EnvironmentFile=$ENV_FILE -p WorkingDirectory=$APP_DIR -p User=$APP_USER \
69 + $BIN --seed"
70 +
71 + log "starting $SERVICE"
72 + on_astra "sudo systemctl start $SERVICE"
73 +
74 + healthy=0
75 + for _ in $(seq 1 20); do
76 + code=$(on_astra "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:$PORT/api/health" || echo 000)
77 + [ "$code" = "200" ] && { log "health OK"; healthy=1; break; }
78 + sleep 3
79 + done
80 + if [ "$healthy" -ne 1 ]; then
81 + echo "mt did not return healthy after the reset" >&2
82 + exit 1
83 + fi
84 +
85 + # Healthy is not the same as usable. A browser run that logs in and finds no
86 + # membership fails at the first write with a 403, which reads like a permissions
87 + # bug in mt rather than a seed that did not finish. Assert the harness
88 + # preconditions here so the reset and its verification cannot drift apart:
89 + # the three accounts exist, and the owner account really is Owner somewhere.
90 + log "verifying harness preconditions"
91 + counts=$(on_astra "sudo -u postgres psql -v ON_ERROR_STOP=1 -At -d $DB" <<'SQL'
92 + SELECT count(*) FROM users
93 + WHERE mnw_account_id IN (
94 + '00000000-0000-0000-0000-00000000f001',
95 + '00000000-0000-0000-0000-00000000f002',
96 + '00000000-0000-0000-0000-00000000f003');
97 + SELECT count(*) FROM memberships
98 + WHERE user_id = '00000000-0000-0000-0000-00000000f003' AND role = 'owner';
99 + SELECT count(*) FROM threads;
100 + SQL
101 + )
102 + accounts=$(sed -n 1p <<<"$counts")
103 + owner_rows=$(sed -n 2p <<<"$counts")
104 + threads=$(sed -n 3p <<<"$counts")
105 +
106 + [ "$accounts" = "3" ] || { echo "expected 3 harness accounts, found ${accounts:-none}" >&2; exit 1; }
107 + [ "${owner_rows:-0}" -ge 1 ] || { echo "harness owner holds no owner membership" >&2; exit 1; }
108 + [ "${threads:-0}" -gt 0 ] || { echo "no threads seeded" >&2; exit 1; }
109 + log "harness OK: 3 accounts, owner membership present, $threads threads"
110 +
111 + log "done"
@@ -1,0 +1,320 @@
1 + //! Login-capable accounts and the OAuth client the Multithreaded browser
2 + //! harness needs, seeded into the example catalog on testnot.
3 + //!
4 + //! Everything else in [`crate::seed`] builds a catalog for a human to look at:
5 + //! the accounts it creates hash a random password nobody holds, because nothing
6 + //! is meant to log in as them. The browser axis of an audit run needs the
7 + //! opposite. It drives a real browser through mt's write paths (thread create,
8 + //! reply, flag, one moderation action), every one of which requires an mt
9 + //! session, and mt is an OAuth relying party against this server. So it needs
10 + //! accounts whose password is known and an OAuth client whose `redirect_uri`
11 + //! points at the mt instance doing the driving.
12 + //!
13 + //! # Why this lives in the seed rather than beside the instance
14 + //!
15 + //! `mnw-testnot-seed.sh` drops every schema before it reseeds. Anything created
16 + //! by hand — a `sync_apps` row, three accounts — is gone the next time testnot
17 + //! is reset, and the harness starts failing at login with nothing in the diff to
18 + //! explain it. Seeding them here makes the reset cycle reproduce them exactly,
19 + //! which is the property the harness actually depends on: the same accounts,
20 + //! with the same ids, after every reseed.
21 + //!
22 + //! The account ids are fixed constants rather than generated, because mt's own
23 + //! seed pre-assigns community roles by `mnw_account_id`. See
24 + //! `multithreaded/src/seed.rs`, which mirrors [`FAN_ACCOUNT_ID`],
25 + //! [`CREATOR_ACCOUNT_ID`] and [`OWNER_ACCOUNT_ID`] verbatim; the pair has to
26 + //! move together.
27 + //!
28 + //! # Why it is opt-in even inside the seed
29 + //!
30 + //! The phase runs only when both [`PASSWORD_ENV`] and [`REDIRECT_URI_ENV`] are
31 + //! set, and skips with a warning otherwise. Neither value is in this repo: the
32 + //! password is a credential, and the redirect URI names a tailnet host that a
33 + //! public repo has no reason to carry. A testnot box that has them set (in its
34 + //! `EnvironmentFile`, which the reseed passes through) gets the harness
35 + //! accounts; anywhere else the seed produces exactly what it produced before.
36 + //!
37 + //! The prod guards in [`crate::seed::run`] still apply, and they run first: this
38 + //! phase is unreachable without `ALLOW_EXAMPLE_SEED=1` on an approved host with
39 + //! no real accounts present.
40 +
41 + use uuid::Uuid;
42 +
43 + use super::{EXAMPLE_EMAIL_DOMAIN, SeedError};
44 + use crate::auth;
45 + use crate::db::{self, UserId};
46 +
47 + /// Env var holding the shared password for the three harness accounts.
48 + pub const PASSWORD_ENV: &str = "MT_HARNESS_PASSWORD";
49 +
50 + /// Env var holding the OAuth `redirect_uri` to register for the harness client,
51 + /// i.e. `{mt base url}/auth/callback` for the write-enabled mt instance.
52 + pub const REDIRECT_URI_ENV: &str = "MT_HARNESS_REDIRECT_URI";
53 +
54 + /// The harness client's `client_id` (= `sync_apps.api_key`, which is stored
55 + /// hashed). Fixed rather than generated so mt's `OAUTH_CLIENT_ID` is a constant
56 + /// its env file can carry across reseeds. Not a secret: this is a public PKCE
57 + /// client, holding the id alone grants nothing.
58 + pub const CLIENT_ID: &str = "mt-astra-harness";
59 +
60 + /// Display name of the registered app, shown on the OAuth consent screen.
61 + const CLIENT_NAME: &str = "Multithreaded (astra harness)";
62 +
63 + /// Fan+ subscriber, not a creator. Exercises the `fan_plus` half of
64 + /// `UserPerks::effective_plus` on its own.
65 + pub const FAN_ACCOUNT_ID: Uuid = Uuid::from_u128(0x0000_0000_0000_0000_0000_0000_0000_f001);
66 + /// Creator (top tier). Owns the harness OAuth client, and exercises the
67 + /// `is_creator` auto-grant half of `effective_plus`.
68 + pub const CREATOR_ACCOUNT_ID: Uuid = Uuid::from_u128(0x0000_0000_0000_0000_0000_0000_0000_f002);
69 + /// Plain account with no MNW perks at all. Its privileges are mt-side only
70 + /// (community Owner + Moderator), which is what makes it the moderation actor.
71 + pub const OWNER_ACCOUNT_ID: Uuid = Uuid::from_u128(0x0000_0000_0000_0000_0000_0000_0000_f003);
72 +
73 + /// One harness account: fixed id, handle, and what perks it carries on MNW.
74 + struct AccountSpec {
75 + id: Uuid,
76 + handle: &'static str,
77 + display_name: &'static str,
78 + /// `users.creator_tier`, which is what `/oauth/userinfo` reports as
79 + /// `perks.is_creator`. `None` leaves the account a plain fan.
80 + creator_tier: Option<&'static str>,
81 + /// Whether to seed an active `fan_plus_subscriptions` row.
82 + fan_plus: bool,
83 + }
84 +
85 + const ACCOUNTS: &[AccountSpec] = &[
86 + AccountSpec {
87 + id: FAN_ACCOUNT_ID,
88 + handle: "harness_fan",
89 + display_name: "Harness Fan",
90 + creator_tier: None,
91 + fan_plus: true,
92 + },
93 + AccountSpec {
94 + id: CREATOR_ACCOUNT_ID,
95 + handle: "harness_creator",
96 + display_name: "Harness Creator",
97 + creator_tier: Some("everything"),
98 + fan_plus: false,
99 + },
100 + AccountSpec {
101 + id: OWNER_ACCOUNT_ID,
102 + handle: "harness_owner",
103 + display_name: "Harness Owner",
104 + creator_tier: None,
105 + fan_plus: false,
106 + },
107 + ];
108 +
109 + /// The two values the phase needs from the environment. Absent either one, the
110 + /// phase does not run.
111 + #[derive(Clone)]
112 + pub struct HarnessOptions {
113 + /// Shared password for every harness account.
114 + pub password: String,
115 + /// The mt callback URL to register on the harness OAuth client.
116 + pub redirect_uri: String,
117 + }
118 +
119 + /// Hand-written so the password cannot reach a log through
120 + /// [`crate::seed::SeedOptions`]'s derived `Debug`.
121 + impl std::fmt::Debug for HarnessOptions {
122 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123 + f.debug_struct("HarnessOptions")
124 + .field("password", &"<redacted>")
125 + .field("redirect_uri", &self.redirect_uri)
126 + .finish()
127 + }
128 + }
129 +
130 + impl HarnessOptions {
131 + /// Read both values from the environment; `None` when either is unset or
132 + /// empty, which is the signal to skip the phase.
133 + pub fn from_env() -> Option<Self> {
134 + let password = std::env::var(PASSWORD_ENV).ok().filter(|s| !s.is_empty())?;
135 + let redirect_uri = std::env::var(REDIRECT_URI_ENV)
136 + .ok()
137 + .filter(|s| !s.is_empty())?;
138 + Some(Self {
139 + password,
140 + redirect_uri,
141 + })
142 + }
143 + }
144 +
145 + /// Seed the three harness accounts and the harness OAuth client.
146 + ///
147 + /// Called from [`super::run`] after the catalog phases, so the accounts land in
148 + /// a database the guards have already cleared. Re-runnable: every write is an
149 + /// upsert keyed on the fixed ids, so a reseed that did not drop the schema lands
150 + /// in the same state as one that did.
151 + pub async fn seed_harness(pool: &sqlx::PgPool, opts: &HarnessOptions) -> Result<(), SeedError> {
152 + // One hash for all three: the password is shared, and Argon2 is the
153 + // expensive part of this phase.
154 + let password_hash = auth::hash_password_async(opts.password.clone()).await?;
155 +
156 + for spec in ACCOUNTS {
157 + seed_account(pool, spec, &password_hash).await?;
158 + if spec.fan_plus {
159 + seed_fan_plus(pool, spec.id).await?;
160 + }
161 + }
162 +
163 + seed_client(pool, &opts.redirect_uri).await?;
164 +
165 + tracing::warn!(
166 + accounts = ACCOUNTS.len(),
167 + client_id = CLIENT_ID,
168 + redirect_uri = %opts.redirect_uri,
169 + "example seed: harness accounts and OAuth client seeded (login-capable)"
170 + );
171 + Ok(())
172 + }
173 +
174 + /// Insert (or reset) one harness account at its fixed id.
175 + ///
176 + /// `email_verified` mirrors [`db::users::create_example_creator`], and
177 + /// `is_sandbox` is left FALSE for the reason it is there: a sandbox account is
178 + /// refused by `SessionUser::check_not_sandbox` on routes the harness may well
179 + /// walk. `can_create_projects` follows the creator tier instead of being TRUE
180 + /// for everyone, so the fan account is actually a fan — three accounts that all
181 + /// hold creator powers would test one role three times.
182 + async fn seed_account(
183 + pool: &sqlx::PgPool,
184 + spec: &AccountSpec,
185 + password_hash: &str,
186 + ) -> Result<(), SeedError> {
187 + let email = format!("{}@{EXAMPLE_EMAIL_DOMAIN}", spec.handle.replace('_', "-"));
188 + sqlx::query(
189 + r"
190 + INSERT INTO users (
191 + id, username, email, password_hash, display_name,
192 + can_create_projects, email_verified, creator_tier
193 + )
194 + VALUES ($1, $2, $3, $4, $5, $6 IS NOT NULL, TRUE, $6)
195 + ON CONFLICT (id) DO UPDATE SET
196 + username = EXCLUDED.username,
197 + email = EXCLUDED.email,
198 + password_hash = EXCLUDED.password_hash,
199 + display_name = EXCLUDED.display_name,
200 + creator_tier = EXCLUDED.creator_tier,
201 + can_create_projects = EXCLUDED.can_create_projects,
202 + -- A harness run that tripped the lockout must not survive the
203 + -- reseed: the whole point of the reset is a known state.
204 + failed_login_attempts = 0,
205 + locked_until = NULL,
206 + suspended_at = NULL,
207 + deactivated_at = NULL
208 + ",
209 + )
210 + .bind(spec.id)
211 + .bind(spec.handle)
212 + .bind(&email)
213 + .bind(password_hash)
214 + .bind(spec.display_name)
215 + .bind(spec.creator_tier)
216 + .execute(pool)
217 + .await?;
218 +
219 + tracing::info!(
220 + handle = spec.handle,
221 + user_id = %spec.id,
222 + "example seed: harness account"
223 + );
224 + Ok(())
225 + }
226 +
227 + /// Give an account an active Fan+ subscription without touching Stripe.
228 + ///
229 + /// `is_fan_plus_active` reads status alone, so an `active` row is the whole
230 + /// requirement. The Stripe ids are fabricated and deliberately marked: nothing
231 + /// on testnot talks to live Stripe, and a `harness_` prefix makes a stray row
232 + /// obvious if one is ever found somewhere it should not be.
233 + async fn seed_fan_plus(pool: &sqlx::PgPool, user_id: Uuid) -> Result<(), SeedError> {
234 + sqlx::query(
235 + r"
236 + INSERT INTO fan_plus_subscriptions (
237 + user_id, stripe_subscription_id, stripe_customer_id, status
238 + )
239 + VALUES ($1, $2, $3, 'active')
240 + ON CONFLICT (user_id) DO UPDATE SET
241 + status = 'active',
242 + canceled_at = NULL
243 + ",
244 + )
245 + .bind(user_id)
246 + .bind(format!("sub_harness_{user_id}"))
247 + .bind(format!("cus_harness_{user_id}"))
248 + .execute(pool)
249 + .await?;
250 + Ok(())
251 + }
252 +
253 + /// Register the harness OAuth client, owned by the creator account.
254 + ///
255 + /// The `redirect_uri` must be registered explicitly: `validate_redirect_uri`
256 + /// waves through localhost only, and the harness instance is reached over the
257 + /// tailnet by name, which is not localhost.
258 + async fn seed_client(pool: &sqlx::PgPool, redirect_uri: &str) -> Result<(), SeedError> {
259 + let existing = db::synckit::get_sync_app_by_api_key(pool, CLIENT_ID).await?;
260 + let app = match existing {
261 + Some(app) => app,
262 + None => {
263 + db::synckit::create_sync_app(
264 + pool,
265 + UserId::from(CREATOR_ACCOUNT_ID),
266 + CLIENT_NAME,
267 + CLIENT_ID,
268 + None,
269 + None,
270 + )
271 + .await?
272 + }
273 + };
274 +
275 + sqlx::query("UPDATE sync_apps SET redirect_uris = $2, is_active = TRUE WHERE id = $1")
276 + .bind(app.id)
277 + .bind(vec![redirect_uri.to_string()])
278 + .execute(pool)
279 + .await?;
280 +
281 + Ok(())
282 + }
283 +
284 + #[cfg(test)]
285 + mod tests {
286 + use super::*;
287 +
288 + #[test]
289 + fn account_ids_are_the_ones_mt_seeds() {
290 + // These three constants are copied into multithreaded/src/seed.rs, which
291 + // pre-assigns community roles by mnw_account_id. A change here that is
292 + // not mirrored there silently drops the harness's moderator rights, and
293 + // the failure shows up as a 403 in a browser run rather than as a build
294 + // error. Pin the literals so the edit cannot be quiet.
295 + assert_eq!(
296 + FAN_ACCOUNT_ID.to_string(),
297 + "00000000-0000-0000-0000-00000000f001"
298 + );
299 + assert_eq!(
300 + CREATOR_ACCOUNT_ID.to_string(),
301 + "00000000-0000-0000-0000-00000000f002"
302 + );
303 + assert_eq!(
304 + OWNER_ACCOUNT_ID.to_string(),
305 + "00000000-0000-0000-0000-00000000f003"
306 + );
307 + }
308 +
309 + #[test]
310 + fn harness_emails_stay_inside_the_reserved_domain() {
311 + // The seed's reset step only deletes @example.test accounts, and its
312 + // third guard refuses to run at all when a non-example account exists.
313 + // A harness handle that produced an address outside the domain would
314 + // therefore both survive resets and block the next seed.
315 + for spec in ACCOUNTS {
316 + let email = format!("{}@{EXAMPLE_EMAIL_DOMAIN}", spec.handle.replace('_', "-"));
317 + assert!(email.ends_with("@example.test"), "{email}");
318 + }
319 + }
320 + }