Skip to main content

max / makenotwork

Add SyncKit tier management, fix dashboard key UX, tag taxonomy fixes SyncKit: add app tiers endpoint (POST /api/v1/sync/app/tiers), tier change endpoint (POST /api/v1/sync/subscription/change) with Stripe proration, update_app_sync_sub_tier DB function, synckit-client SDK methods (get_available_tiers, change_subscription_tier, TierInfo type). Dashboard: fix broken API key copy button (keys are hashed, can't be recovered), show key on create/regenerate with copy button and warning, reload page after feature toggle so tabs update immediately. Tag taxonomy: fixes for discover filters, bulk tag operations, import pipeline, validation. Test updates for new tag hierarchy. v0.5.15 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-11 16:30 UTC
Commit: ea0e960abb685f4f149a47fcb27fcff07525fea0
Parent: d661899
26 files changed, +741 insertions, -245 deletions
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.5.14"
3 + version = "0.5.15"
4 4 edition = "2024"
5 5 license-file = "LICENSE"
6 6
@@ -1,7 +1,7 @@
1 1 # Makenotwork TODO
2 2
3 3 ## Status
4 - v0.5.12 deployed 2026-05-10. Audit grade A (Run 24). ~88K LOC, 1,933 tests, 0 warnings. Migration 110. Sprints 1-9 complete (see `todo_done.md`). Content seeded: AF 0.4.0 + GO 0.3.1 on discover page.
4 + v0.5.14 deployed 2026-05-11. Audit grade A (Run 24). ~88K LOC, 1,935 tests, 0 warnings. Migration 111. Sprints 1-9 complete (see `todo_done.md`). Content seeded: AF 0.4.0 + GO 0.3.1 on discover page.
5 5
6 6 Human tasks in `human_todo.md`. Completed items in `todo_done.md`.
7 7
@@ -11,7 +11,7 @@ Human tasks in `human_todo.md`. Completed items in `todo_done.md`.
11 11
12 12 Priority order. See `human_todo.md` for the full manual testing feature map.
13 13
14 - 1. ~~**Deploy**~~ — Done (v0.5.11, 2026-05-10). Run 24 fixes + scheduler SQL fixes + robots.txt + Prometheus auth + ALERT_EMAIL.
14 + 1. ~~**Deploy**~~ — Done (v0.5.14, 2026-05-11). Run 24 fixes + scheduler SQL fixes + robots.txt + Prometheus auth + ALERT_EMAIL + tag taxonomy overhaul (migration 111).
15 15 2. **Manual testing** — walk through `human_todo.md` sign-off table on live server (Stripe checkout, license keys, promo codes, cart, SyncKit sync)
16 16 3. **Content seeding** — at least one real creator with published content on discover page
17 17 4. **Invite testers** — generate invite codes, send hand-written emails per `docs/internal/outreach/tiers.md`
@@ -195,7 +195,7 @@ MNW/server/src/
195 195 import/ (CSV converter, pipeline, intermediate format)
196 196 MNW/server/tests/
197 197 integration.rs, harness/, workflows/*.rs
198 - MNW/server/migrations/ (001-107)
198 + MNW/server/migrations/ (001-111)
199 199 MNW/server/templates/
200 200 MNW/server/deploy/
201 201 MNW/server/site-docs/public/, MNW/server/site-docs/unpublished/
@@ -166,6 +166,31 @@ pub async fn update_app_sync_sub_period<'e>(
166 166 Ok(())
167 167 }
168 168
169 + /// Update the tier and storage limit of an app sync subscription.
170 + #[tracing::instrument(skip_all)]
171 + pub async fn update_app_sync_sub_tier<'e>(
172 + executor: impl sqlx::PgExecutor<'e>,
173 + stripe_subscription_id: &str,
174 + tier: AppSyncTier,
175 + storage_limit_bytes: Option<i64>,
176 + ) -> Result<Option<DbAppSyncSubscription>> {
177 + let sub = sqlx::query_as::<_, DbAppSyncSubscription>(
178 + r#"
179 + UPDATE app_sync_subscriptions
180 + SET tier = $2, storage_limit_bytes = $3
181 + WHERE stripe_subscription_id = $1
182 + RETURNING *
183 + "#,
184 + )
185 + .bind(stripe_subscription_id)
186 + .bind(tier)
187 + .bind(storage_limit_bytes)
188 + .fetch_optional(executor)
189 + .await?;
190 +
191 + Ok(sub)
192 + }
193 +
169 194 /// Cancel an app sync subscription (set status + canceled_at).
170 195 #[tracing::instrument(skip_all)]
171 196 pub async fn cancel_app_sync_sub(
@@ -103,12 +103,13 @@ fn append_item_discover_filters(
103 103 query.push_str(" AND i.price_cents <= $4");
104 104 }
105 105 if filters.tag.is_some() {
106 + // Match exact slug or any descendant (e.g. "audio.genre" matches "audio.genre.electronic")
106 107 query.push_str(
107 108 r#" AND EXISTS (
108 109 SELECT 1 FROM item_tags it2
109 110 JOIN tags t2 ON t2.id = it2.tag_id
110 111 WHERE it2.item_id = i.id
111 - AND (t2.slug = $5 OR t2.parent_id = (SELECT id FROM tags WHERE slug = $5))
112 + AND (t2.slug = $5 OR t2.path LIKE $5 || '.%')
112 113 )"#,
113 114 );
114 115 }
@@ -30,12 +30,15 @@ pub struct DbCategoryCount {
30 30 // ── Tag models ──
31 31
32 32 /// A tag in the hierarchical taxonomy.
33 + ///
34 + /// Tag slugs use dot-notation (`audio.genre.electronic`) and are validated
35 + /// by tagtree, not the general `Slug` type.
33 36 #[derive(Debug, Clone, FromRow)]
34 37 #[allow(dead_code)] // Fields populated by sqlx query, read during type conversion
35 38 pub struct DbTag {
36 39 pub id: TagId,
37 40 pub name: String,
38 - pub slug: Slug,
41 + pub slug: String,
39 42 pub parent_id: Option<TagId>,
40 43 pub sort_order: i32,
41 44 pub created_at: DateTime<Utc>,
@@ -50,7 +53,7 @@ pub struct DbItemTag {
50 53 pub tag_id: TagId,
51 54 pub is_primary: bool,
52 55 pub tag_name: String,
53 - pub tag_slug: Slug,
56 + pub tag_slug: String,
54 57 }
55 58
56 59 /// Tag with item count, used for discover sidebar facets.
@@ -59,6 +62,6 @@ pub struct DbItemTag {
59 62 pub struct DbTagCount {
60 63 pub tag_id: TagId,
61 64 pub tag_name: String,
62 - pub tag_slug: Slug,
65 + pub tag_slug: String,
63 66 pub count: i64,
64 67 }
@@ -3,7 +3,6 @@
3 3 use sqlx::PgPool;
4 4
5 5 use super::models::*;
6 - use super::validated_types::Slug;
7 6 use super::{ItemId, TagId};
8 7 use crate::error::Result;
9 8
@@ -73,33 +72,45 @@ pub async fn get_tags_for_items(
73 72 Ok(map)
74 73 }
75 74
76 - /// Find an existing tag by name (case-insensitive) or create a new one.
75 + /// Find an existing tag by slug or create a new one.
76 + ///
77 + /// Slugs use dot-notation (e.g. `audio.genre.electronic`). The `path` column
78 + /// is set to the slug, and `parent_id` is resolved from the parent slug
79 + /// (everything before the last dot).
77 80 #[tracing::instrument(skip_all)]
78 - pub async fn find_or_create_tag(pool: &PgPool, name: &str) -> Result<DbTag> {
79 - // Try to find existing tag first
81 + pub async fn find_or_create_tag(pool: &PgPool, name: &str, slug: &str) -> Result<DbTag> {
82 + // Try to find existing tag by slug first
80 83 if let Some(tag) = sqlx::query_as::<_, DbTag>(
81 - "SELECT id, name, slug, parent_id, sort_order, created_at, path FROM tags WHERE lower(name) = lower($1)",
84 + "SELECT id, name, slug, parent_id, sort_order, created_at, path FROM tags WHERE slug = $1",
82 85 )
83 - .bind(name)
86 + .bind(slug)
84 87 .fetch_optional(pool)
85 88 .await?
86 89 {
87 90 return Ok(tag);
88 91 }
89 92
90 - // Create new tag with slug derived from name
91 - let slug = Slug::new(&name.to_lowercase().replace(' ', "-"))
92 - .unwrap_or_else(|_| Slug::new("tag").expect("hardcoded slug"));
93 + // Resolve parent from slug hierarchy (e.g. "audio.genre.electronic" → parent "audio.genre")
94 + let parent_id: Option<TagId> = if let Some(parent_slug) = tagtree::parent(slug) {
95 + sqlx::query_scalar("SELECT id FROM tags WHERE slug = $1")
96 + .bind(parent_slug)
97 + .fetch_optional(pool)
98 + .await?
99 + } else {
100 + None
101 + };
102 +
93 103 let tag = sqlx::query_as::<_, DbTag>(
94 104 r#"
95 - INSERT INTO tags (name, slug, path)
96 - VALUES ($1, $2, $1)
105 + INSERT INTO tags (name, slug, parent_id, path)
106 + VALUES ($1, $2, $3, $2)
97 107 ON CONFLICT (slug) DO UPDATE SET name = tags.name
98 108 RETURNING id, name, slug, parent_id, sort_order, created_at, path
99 109 "#,
100 110 )
101 111 .bind(name)
102 - .bind(&slug)
112 + .bind(slug)
113 + .bind(parent_id)
103 114 .fetch_one(pool)
104 115 .await?;
105 116
@@ -222,9 +233,12 @@ pub async fn get_tag_counts(
222 233 Ok(counts)
223 234 }
224 235
225 - /// Get a tag by its URL slug.
236 + /// Get a tag by its URL slug (dot-notation, e.g. `audio.genre.electronic`).
237 + ///
238 + /// Takes `&str` rather than `&Slug` because tag slugs use dot-notation
239 + /// (validated by tagtree), not the general slug format.
226 240 #[tracing::instrument(skip_all)]
227 - pub async fn get_tag_by_slug(pool: &PgPool, slug: &Slug) -> Result<Option<DbTag>> {
241 + pub async fn get_tag_by_slug(pool: &PgPool, slug: &str) -> Result<Option<DbTag>> {
228 242 let tag = sqlx::query_as::<_, DbTag>(
229 243 "SELECT id, name, slug, parent_id, sort_order, created_at, path FROM tags WHERE slug = $1",
230 244 )
@@ -311,10 +325,23 @@ pub async fn get_all_tag_counts(pool: &PgPool) -> Result<std::collections::HashM
311 325 Ok(rows.into_iter().collect())
312 326 }
313 327
314 - /// Suggest up to 5 tags for an item based on its metadata.
328 + /// Map item type to tag type prefix for suggestions.
329 + fn item_type_to_tag_prefix(item_type: &str) -> &str {
330 + match item_type {
331 + "audio" | "sample" | "preset" => "audio",
332 + "plugin" | "template" | "digital" => "software",
333 + "text" => "writing",
334 + "video" | "course" => "video",
335 + "image" => "visual",
336 + _ => "",
337 + }
338 + }
339 +
340 + /// Suggest up to 5 tags for an item based on its type and metadata.
315 341 ///
316 - /// Matching priority: exact type match, parent-type match, substring in title/description,
317 - /// trigram similarity. Excludes tags already on the item.
342 + /// Matching priority: tags under the item's type prefix, then substring/trigram
343 + /// similarity against title and description. Only suggests leaf tags (depth >= 3).
344 + /// Excludes tags already on the item.
318 345 #[tracing::instrument(skip_all)]
319 346 pub async fn suggest_tags_for_item(
320 347 pool: &PgPool,
@@ -324,28 +351,28 @@ pub async fn suggest_tags_for_item(
324 351 item_type: &str,
325 352 ) -> Result<Vec<DbTag>> {
326 353 let text = format!("{} {}", title, description.unwrap_or(""));
354 + let type_prefix = item_type_to_tag_prefix(item_type);
355 + let prefix_pattern = format!("{type_prefix}.%");
327 356
328 357 let tags = sqlx::query_as::<_, DbTag>(
329 358 r#"
330 359 SELECT t.id, t.name, t.slug, t.parent_id, t.sort_order, t.created_at, t.path
331 360 FROM tags t
332 361 WHERE t.id NOT IN (SELECT tag_id FROM item_tags WHERE item_id = $1)
362 + AND length(t.path) - length(replace(t.path, '.', '')) >= 2
333 363 AND (
334 - t.slug = $2
335 - OR EXISTS(SELECT 1 FROM tags p WHERE p.id = t.parent_id AND p.slug = $2)
364 + t.path LIKE $2
336 365 OR $3 ILIKE '%' || replace(replace(replace(t.name, '\', '\\'), '%', '\%'), '_', '\_') || '%'
337 366 OR similarity(t.name, $3) > 0.3
338 367 )
339 368 ORDER BY
340 - (t.slug = $2) DESC,
341 - EXISTS(SELECT 1 FROM tags p WHERE p.id = t.parent_id AND p.slug = $2) DESC,
342 - ($3 ILIKE '%' || replace(replace(replace(t.name, '\', '\\'), '%', '\%'), '_', '\_') || '%') DESC,
369 + (t.path LIKE $2) DESC,
343 370 similarity(t.name, $3) DESC
344 371 LIMIT 5
345 372 "#,
346 373 )
347 374 .bind(item_id)
348 - .bind(item_type)
375 + .bind(&prefix_pattern)
349 376 .bind(&text)
350 377 .fetch_all(pool)
351 378 .await?;
@@ -197,10 +197,9 @@ async fn import_item(
197 197 .await?;
198 198 }
199 199
200 - // Attach tags by name lookup
201 - for tag_name in &item.tags {
202 - let slug = crate::helpers::slugify(tag_name);
203 - if let Ok(Some(tag)) = db::tags::get_tag_by_slug(pool, &slug).await {
200 + // Attach tags by slug lookup (dot-notation, e.g. "audio.genre.electronic")
201 + for tag_slug in &item.tags {
202 + if let Ok(Some(tag)) = db::tags::get_tag_by_slug(pool, tag_slug).await {
204 203 let _ = db::tags::add_tag_to_item(pool, db_item.id, tag.id, false).await;
205 204 }
206 205 }
@@ -253,6 +253,81 @@ impl StripeClient {
253 253 Ok(())
254 254 }
255 255
256 + /// Change the tier of a platform-level app sync subscription.
257 + ///
258 + /// Retrieves the subscription to find the existing item, then updates it
259 + /// with new inline price_data for the target tier. Stripe prorates automatically.
260 + /// Returns the updated subscription's metadata for webhook reconciliation.
261 + #[tracing::instrument(skip_all, name = "payments::update_app_sync_subscription_tier")]
262 + pub async fn update_app_sync_subscription_tier(
263 + &self,
264 + stripe_sub_id: &str,
265 + product_name: &str,
266 + price_cents: i64,
267 + interval: &str,
268 + ) -> Result<()> {
269 + let http = reqwest::Client::new();
270 + let auth = format!("Bearer {}", self.config.secret_key);
271 +
272 + // 1. Retrieve subscription to get the current item ID
273 + let resp = http
274 + .get(&format!("https://api.stripe.com/v1/subscriptions/{stripe_sub_id}"))
275 + .header("Authorization", &auth)
276 + .timeout(std::time::Duration::from_secs(30))
277 + .send()
278 + .await
279 + .map_err(|e| {
280 + tracing::error!(stripe_sub_id = %stripe_sub_id, error = ?e, "failed to retrieve subscription");
281 + AppError::Internal(anyhow::anyhow!("Failed to retrieve subscription"))
282 + })?;
283 +
284 + if !resp.status().is_success() {
285 + let body = resp.text().await.unwrap_or_default();
286 + tracing::error!(stripe_sub_id = %stripe_sub_id, body = %body, "Stripe retrieve subscription returned error");
287 + return Err(AppError::Internal(anyhow::anyhow!("Failed to retrieve subscription")));
288 + }
289 +
290 + let sub_json: serde_json::Value = resp.json().await.map_err(|e| {
291 + tracing::error!(error = ?e, "failed to parse subscription JSON");
292 + AppError::Internal(anyhow::anyhow!("Failed to parse subscription"))
293 + })?;
294 +
295 + let item_id = sub_json["items"]["data"][0]["id"]
296 + .as_str()
297 + .ok_or_else(|| {
298 + tracing::error!(stripe_sub_id = %stripe_sub_id, "subscription has no items");
299 + AppError::Internal(anyhow::anyhow!("Subscription has no items"))
300 + })?;
301 +
302 + // 2. Update subscription: replace the item's price with new inline price_data
303 + let resp = http
304 + .post(&format!("https://api.stripe.com/v1/subscriptions/{stripe_sub_id}"))
305 + .header("Authorization", &auth)
306 + .form(&[
307 + ("items[0][id]", item_id),
308 + ("items[0][price_data][currency]", "usd"),
309 + ("items[0][price_data][product_data][name]", product_name),
310 + ("items[0][price_data][unit_amount]", &price_cents.to_string()),
311 + ("items[0][price_data][recurring][interval]", interval),
312 + ("proration_behavior", "create_prorations"),
313 + ])
314 + .timeout(std::time::Duration::from_secs(30))
315 + .send()
316 + .await
317 + .map_err(|e| {
318 + tracing::error!(stripe_sub_id = %stripe_sub_id, error = ?e, "failed to update subscription tier");
319 + AppError::Internal(anyhow::anyhow!("Failed to update subscription tier"))
320 + })?;
321 +
322 + if !resp.status().is_success() {
323 + let body = resp.text().await.unwrap_or_default();
324 + tracing::error!(stripe_sub_id = %stripe_sub_id, body = %body, "Stripe update subscription returned error");
325 + return Err(AppError::Internal(anyhow::anyhow!("Failed to update subscription tier")));
326 + }
327 +
328 + Ok(())
329 + }
330 +
256 331 /// Cancel a platform-level subscription (creator tier, Fan+). Not on a connected account.
257 332 #[tracing::instrument(skip_all, name = "payments::cancel_platform_subscription")]
258 333 pub async fn cancel_platform_subscription(
@@ -80,6 +80,8 @@ pub trait PaymentProvider: Send + Sync {
80 80 async fn cancel_subscription(&self, stripe_sub_id: &str, connected_account_id: &str) -> crate::error::Result<()>;
81 81 /// Set or clear `cancel_at_period_end` on a fan subscription (for creator pause/resume).
82 82 async fn set_cancel_at_period_end(&self, stripe_sub_id: &str, connected_account_id: &str, cancel: bool) -> crate::error::Result<()>;
83 + /// Update the tier of an app sync subscription (swap price with proration).
84 + async fn update_app_sync_subscription_tier(&self, stripe_sub_id: &str, product_name: &str, price_cents: i64, interval: &str) -> crate::error::Result<()>;
83 85 /// Cancel a platform-level subscription (creator tier, Fan+). Not on a connected account.
84 86 async fn cancel_platform_subscription(&self, stripe_sub_id: &str) -> crate::error::Result<()>;
85 87
@@ -182,6 +184,10 @@ impl PaymentProvider for StripeClient {
182 184 StripeClient::set_cancel_at_period_end(self, stripe_sub_id, connected_account_id, cancel).await
183 185 }
184 186
187 + async fn update_app_sync_subscription_tier(&self, stripe_sub_id: &str, product_name: &str, price_cents: i64, interval: &str) -> crate::error::Result<()> {
188 + StripeClient::update_app_sync_subscription_tier(self, stripe_sub_id, product_name, price_cents, interval).await
189 + }
190 +
185 191 async fn cancel_platform_subscription(&self, stripe_sub_id: &str) -> crate::error::Result<()> {
186 192 StripeClient::cancel_platform_subscription(self, stripe_sub_id).await
187 193 }
@@ -151,10 +151,11 @@ pub(in crate::routes::api) async fn bulk_price(
151 151 pub struct BulkTagRequest {
152 152 #[serde(default)]
153 153 pub item_ids: Vec<ItemId>,
154 - pub tag_name: String,
154 + /// Dot-notation tag slug, e.g. "audio.genre.electronic".
155 + pub tag_slug: String,
155 156 }
156 157
157 - /// Bulk-add a tag to selected items (creates tag if needed).
158 + /// Bulk-add a tag to selected items by slug lookup.
158 159 #[tracing::instrument(skip_all, name = "items::bulk_tag")]
159 160 pub(in crate::routes::api) async fn bulk_tag(
160 161 State(state): State<AppState>,
@@ -163,18 +164,17 @@ pub(in crate::routes::api) async fn bulk_tag(
163 164 ) -> Result<impl IntoResponse> {
164 165 user.check_not_suspended()?;
165 166
166 - let tag_name = req.tag_name.trim().to_lowercase();
167 - if tag_name.is_empty() || tag_name.len() > 50 {
168 - return Err(AppError::BadRequest("Tag must be 1-50 characters".into()));
169 - }
167 + let slug = req.tag_slug.trim();
168 + crate::validation::validate_tag_slug(slug)?;
170 169
171 170 let project_id = verify_bulk_ownership(&state, &req.item_ids, user.id).await?;
172 171
173 - // Find or create the tag
174 - let tag = db::tags::find_or_create_tag(&state.db, &tag_name).await?;
172 + let tag = db::tags::get_tag_by_slug(&state.db, slug)
173 + .await?
174 + .ok_or(AppError::NotFound)?;
175 175
176 176 let count = db::items::bulk_add_tag(&state.db, &req.item_ids, project_id, user.id, tag.id).await?;
177 177 db::projects::bump_cache_generation(&state.db, project_id).await?;
178 178
179 - Ok(htmx_toast_response(&format!("Tag \"{tag_name}\" added to {count} item(s)"), "success"))
179 + Ok(htmx_toast_response(&format!("Tag \"{}\" added to {count} item(s)", tag.name), "success"))
180 180 }
@@ -24,7 +24,13 @@ pub struct AddTagForm {
24 24 pub tag_id: TagId,
25 25 }
26 26
27 + /// Maximum number of tags per item.
28 + const MAX_TAGS_PER_ITEM: usize = 5;
29 +
27 30 /// Add a tag to an owned item, returning the rendered HTMX tag element.
31 + ///
32 + /// Only leaf tags (depth >= 3, i.e. `type.category.value`) may be assigned.
33 + /// Items are limited to [`MAX_TAGS_PER_ITEM`] tags.
28 34 #[tracing::instrument(skip_all, name = "items::add_tag")]
29 35 pub(in crate::routes::api) async fn add_tag(
30 36 State(state): State<AppState>,
@@ -39,6 +45,21 @@ pub(in crate::routes::api) async fn add_tag(
39 45 .await?
40 46 .ok_or(AppError::NotFound)?;
41 47
48 + // Only leaf tags (depth >= 3) are assignable to items
49 + if tagtree::depth(&tag.slug) < 3 {
50 + return Err(AppError::Validation(
51 + "Only leaf tags (type.category.value) can be assigned to items".into(),
52 + ));
53 + }
54 +
55 + // Enforce per-item tag limit
56 + let existing = db::tags::get_tags_for_item(&state.db, id).await?;
57 + if existing.len() >= MAX_TAGS_PER_ITEM && !existing.iter().any(|t| t.tag_id == form.tag_id) {
58 + return Err(AppError::Validation(
59 + format!("Items may have at most {MAX_TAGS_PER_ITEM} tags"),
60 + ));
61 + }
62 +
42 63 db::tags::add_tag_to_item(&state.db, id, form.tag_id, false).await?;
43 64 db::projects::bump_cache_generation(&state.db, item.project_id).await?;
44 65
@@ -10,8 +10,8 @@ use tower_sessions::Session;
10 10 use crate::{
11 11 auth::MaybeUser,
12 12 constants,
13 - db::{self, discover::DiscoverFilters, DiscoverSort, ItemType, Slug},
14 - error::{AppError, Result},
13 + db::{self, discover::DiscoverFilters, DiscoverSort, ItemType},
14 + error::Result,
15 15 helpers::get_csrf_token,
16 16 templates::*,
17 17 types::*,
@@ -226,10 +226,9 @@ pub(super) async fn tag_tree(
226 226 ) -> Result<impl IntoResponse> {
227 227 let csrf_token = get_csrf_token(&session).await;
228 228
229 - // Resolve parent tag from ?parent=slug
229 + // Resolve parent tag from ?parent=slug (dot-notation, e.g. "audio.genre")
230 230 let parent_tag = if let Some(ref slug) = query.parent {
231 - let slug = Slug::new(slug).map_err(|_| AppError::NotFound)?;
232 - db::tags::get_tag_by_slug(&state.db, &slug).await?
231 + db::tags::get_tag_by_slug(&state.db, slug).await?
233 232 } else {
234 233 None
235 234 };
@@ -55,7 +55,7 @@ pub(crate) struct SyncAuthResponse {
55 55
56 56 #[derive(Deserialize, utoipa::ToSchema)]
57 57 pub(crate) struct ValidateAppQuery {
58 - api_key: String,
58 + pub(crate) api_key: String,
59 59 }
60 60
61 61 #[derive(Serialize, utoipa::ToSchema)]
@@ -301,6 +301,32 @@ pub(super) struct SubscriptionCheckoutRequest {
301 301 pub interval: String,
302 302 }
303 303
304 + /// Individual tier info returned by the tiers endpoint.
305 + #[derive(Serialize)]
306 + pub(super) struct TierInfo {
307 + pub id: String,
308 + pub label: String,
309 + pub description: String,
310 + pub storage_bytes: Option<i64>,
311 + pub monthly_price_cents: i64,
312 + pub annual_price_cents: i64,
313 + }
314 +
315 + /// Response from the app tiers endpoint.
316 + #[derive(Serialize)]
317 + pub(super) struct AppTiersResponse {
318 + pub app_name: String,
319 + pub tiers: Vec<TierInfo>,
320 + }
321 +
322 + #[derive(Deserialize)]
323 + pub(super) struct SubscriptionChangeTierRequest {
324 + /// New tier: "light", "standard", or "large"
325 + pub tier: String,
326 + /// Billing interval: "monthly" or "annual"
327 + pub interval: String,
328 + }
329 +
304 330 #[derive(Serialize)]
305 331 pub(super) struct SubscriptionCheckoutResponse {
306 332 /// Stripe Checkout URL to redirect the user to
@@ -355,6 +381,8 @@ pub fn synckit_routes() -> Router<AppState> {
355 381 .route("/api/v1/sync/auth", post(auth::sync_auth))
356 382 .route("/api/sync/validate-app", post(auth::validate_app))
357 383 .route("/api/v1/sync/validate-app", post(auth::validate_app))
384 + .route("/api/sync/app/tiers", post(subscription::get_app_tiers))
385 + .route("/api/v1/sync/app/tiers", post(subscription::get_app_tiers))
358 386 .route_layer(GovernorLayer {
359 387 config: auth_rate_limit,
360 388 });
@@ -401,6 +429,8 @@ pub fn synckit_routes() -> Router<AppState> {
401 429 .route("/api/v1/sync/subscription", get(subscription::get_subscription_status))
402 430 .route("/api/sync/subscription/checkout", post(subscription::create_checkout))
403 431 .route("/api/v1/sync/subscription/checkout", post(subscription::create_checkout))
432 + .route("/api/sync/subscription/change", post(subscription::change_tier))
433 + .route("/api/v1/sync/subscription/change", post(subscription::change_tier))
404 434 // Per-app rate limit (inner layer runs first): prevents one developer's
405 435 // app from starving other apps. Extracts app ID from JWT payload.
406 436 .route_layer(GovernorLayer {
@@ -14,7 +14,7 @@ use crate::{
14 14 AppState,
15 15 };
16 16
17 - use super::{SubscriptionCheckoutRequest, SubscriptionCheckoutResponse, SubscriptionStatusResponse};
17 + use super::{AppTiersResponse, SubscriptionChangeTierRequest, SubscriptionCheckoutRequest, SubscriptionCheckoutResponse, SubscriptionStatusResponse, TierInfo, ValidateAppQuery};
18 18
19 19 /// Billing interval for inline price_data.
20 20 #[derive(Debug, Clone, Copy, PartialEq)]
@@ -23,6 +23,45 @@ enum BillingInterval {
23 23 Annual,
24 24 }
25 25
26 + /// POST /api/v1/sync/app/tiers — return available pricing tiers for an app.
27 + ///
28 + /// Authenticated by API key only (no JWT required). Allows clients to display
29 + /// pricing before the user has logged in.
30 + #[tracing::instrument(skip_all, name = "synckit::get_app_tiers")]
31 + pub(super) async fn get_app_tiers(
32 + State(state): State<AppState>,
33 + Json(params): Json<ValidateAppQuery>,
34 + ) -> Result<impl IntoResponse> {
35 + let app = db::synckit::get_sync_app_by_api_key(&state.db, &params.api_key)
36 + .await?
37 + .ok_or(AppError::Unauthorized)?;
38 +
39 + let all_tiers = [AppSyncTier::Light, AppSyncTier::Standard, AppSyncTier::Large];
40 + let tiers: Vec<TierInfo> = all_tiers
41 + .iter()
42 + .filter_map(|tier| {
43 + let monthly = tier.monthly_price_cents(&app.name)?;
44 + let annual = tier.annual_price_cents(&app.name)?;
45 + Some(TierInfo {
46 + id: tier.to_string(),
47 + label: tier.label().to_string(),
48 + description: match tier.blob_storage_bytes() {
49 + Some(bytes) => format!("{} GB storage", bytes / (1024 * 1024 * 1024)),
50 + None => "Cloud sync".to_string(),
51 + },
52 + storage_bytes: tier.blob_storage_bytes(),
53 + monthly_price_cents: monthly,
54 + annual_price_cents: annual,
55 + })
56 + })
57 + .collect();
58 +
59 + Ok(Json(AppTiersResponse {
60 + app_name: app.name,
61 + tiers,
62 + }))
63 + }
64 +
26 65 /// GET /api/v1/sync/subscription — check subscription status for this user+app.
27 66 #[tracing::instrument(skip_all, name = "synckit::get_subscription_status")]
28 67 pub(super) async fn get_subscription_status(
@@ -128,3 +167,85 @@ pub(super) async fn create_checkout(
128 167
129 168 Ok(Json(SubscriptionCheckoutResponse { checkout_url }))
130 169 }
170 +
171 + /// POST /api/v1/sync/subscription/change — change tier on an existing subscription.
172 + ///
173 + /// Validates the new tier, calls Stripe to update the subscription item with
174 + /// new inline pricing (prorated), and updates the local DB record.
175 + #[tracing::instrument(skip_all, name = "synckit::change_subscription_tier")]
176 + pub(super) async fn change_tier(
177 + State(state): State<AppState>,
178 + sync_user: SyncUser,
179 + Json(req): Json<SubscriptionChangeTierRequest>,
180 + ) -> Result<impl IntoResponse> {
181 + let new_tier: AppSyncTier = req.tier.parse()
182 + .map_err(|_| AppError::BadRequest("Invalid tier. Expected: standard, light, or large".to_string()))?;
183 +
184 + let interval = match req.interval.as_str() {
185 + "monthly" => "month",
186 + "annual" => "year",
187 + _ => return Err(AppError::BadRequest("Invalid interval. Expected: monthly or annual".to_string())),
188 + };
189 +
190 + // Must have an active subscription to change tier
191 + let sub = db::app_sync::get_app_sync_sub(&state.db, sync_user.user_id, sync_user.app_id)
192 + .await?
193 + .filter(|s| s.status == db::SubscriptionStatus::Active)
194 + .ok_or_else(|| AppError::BadRequest("No active subscription to change".to_string()))?;
195 +
196 + if sub.tier == new_tier {
197 + return Err(AppError::BadRequest("Already on this tier".to_string()));
198 + }
199 +
200 + let app = db::synckit::get_sync_app_by_id(&state.db, sync_user.app_id)
201 + .await?
202 + .ok_or(AppError::Unauthorized)?;
203 +
204 + let price_cents = match interval {
205 + "year" => new_tier.annual_price_cents(&app.name),
206 + _ => new_tier.monthly_price_cents(&app.name),
207 + }
208 + .ok_or_else(|| AppError::BadRequest(format!("Tier '{}' is not available for {}", new_tier, app.name)))?;
209 +
210 + let product_name = new_tier.product_name(&app.name);
211 +
212 + let stripe = state.stripe.as_ref()
213 + .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
214 +
215 + // Update the subscription on Stripe (prorated)
216 + stripe.update_app_sync_subscription_tier(
217 + &sub.stripe_subscription_id,
218 + &product_name,
219 + price_cents,
220 + interval,
221 + ).await?;
222 +
223 + // Update local DB immediately (webhook will also fire, but this gives instant feedback)
224 + let storage_limit_bytes = new_tier.blob_storage_bytes();
225 + db::app_sync::update_app_sync_sub_tier(
226 + &state.db,
227 + &sub.stripe_subscription_id,
228 + new_tier,
229 + storage_limit_bytes,
230 + ).await?;
231 +
232 + tracing::info!(
233 + user_id = %sync_user.user_id, app_id = %sync_user.app_id,
234 + old_tier = %sub.tier, new_tier = %new_tier,
235 + "app sync subscription tier changed"
236 + );
237 +
238 + // Return updated status
239 + let storage_used = db::synckit::get_blob_storage_used(
240 + &state.db, sync_user.app_id, sync_user.user_id,
241 + ).await.unwrap_or(0);
242 +
243 + Ok(Json(SubscriptionStatusResponse {
244 + active: true,
245 + tier: Some(new_tier.to_string()),
246 + status: Some("active".to_string()),
247 + storage_limit_bytes,
248 + storage_used_bytes: Some(storage_used),
249 + current_period_end: sub.current_period_end,
250 + }))
251 + }
@@ -79,12 +79,13 @@ pub fn validate_tag_name(name: &str) -> Result<(), AppError> {
79 79
80 80 /// Validate a tag slug using the tagtree standard.
81 81 ///
82 - /// Tag slugs support dot-separated hierarchy (e.g. "software.plugin") with a
83 - /// maximum depth of 5 and maximum length of 100 characters.
84 - const MNW_TAG_CONFIG: tagtree::TagConfig = tagtree::TagConfig {
82 + /// Tag slugs follow a 3-level hierarchy: `type.category.value`
83 + /// (e.g. `audio.genre.electronic`, `software.language.rust`).
84 + /// `semantic_depth: 2` enforces at least 3 segments.
85 + pub const MNW_TAG_CONFIG: tagtree::TagConfig = tagtree::TagConfig {
85 86 max_depth: 5,
86 87 max_length: 100,
87 - semantic_depth: 0,
88 + semantic_depth: 2,
88 89 };
89 90
90 91 pub fn validate_tag_slug(slug: &str) -> Result<(), AppError> {
@@ -48,13 +48,13 @@
48 48 <div id="bulk-tag-form" style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; background: var(--surface-muted); border: 1px solid var(--border); border-radius: 4px;">
49 49 <div style="display: flex; gap: 0.75rem; align-items: end;">
50 50 <div>
51 - <label style="font-size: 0.85rem; display: block; margin-bottom: 0.25rem;">Tag name</label>
52 - <input type="text" id="bulk-tag-input" placeholder="e.g. ambient" style="width: 200px; padding: 0.3rem;">
51 + <label style="font-size: 0.85rem; display: block; margin-bottom: 0.25rem;">Tag slug</label>
52 + <input type="text" id="bulk-tag-input" placeholder="e.g. audio.genre.ambient" style="width: 260px; padding: 0.3rem;">
53 53 </div>
54 54 <button class="primary small" onclick="submitBulkTag()">Apply</button>
55 55 <button class="secondary small" onclick="document.getElementById('bulk-tag-form').style.display='none'">Cancel</button>
56 56 </div>
57 - <p class="form-hint" style="margin-top: 0.5rem;">Creates the tag if it doesn't exist.</p>
57 + <p class="form-hint" style="margin-top: 0.5rem;">Use dot-notation: type.category.value</p>
58 58 </div>
59 59 <table class="data-table">
60 60 <thead>
@@ -311,11 +311,11 @@ function submitBulkTag() {
311 311 var checked = document.querySelectorAll('.bulk-check:checked');
312 312 if (checked.length === 0) return;
313 313 var tag = document.getElementById('bulk-tag-input').value.trim();
314 - if (!tag) { showToast('Enter a tag name'); return; }
314 + if (!tag) { showToast('Enter a tag slug'); return; }
315 315
316 316 var params = new URLSearchParams();
317 317 for (var i = 0; i < checked.length; i++) params.append('item_ids', checked[i].value);
318 - params.append('tag_name', tag);
318 + params.append('tag_slug', tag);
319 319
320 320 fetch('/api/items/bulk/tag', {
321 321 method: 'POST',
@@ -238,7 +238,8 @@ function updateFeatures(projectId) {
238 238 }).then(function(r) {
239 239 if (r.ok) {
240 240 if (status) { status.textContent = 'Saved'; status.style.color = 'var(--success-color)'; }
241 - setTimeout(function() { if (status) status.textContent = ''; }, 2000);
241 + // Reload the page so the tab bar reflects new features
242 + setTimeout(function() { window.location.reload(); }, 300);
242 243 } else {
243 244 if (status) { status.textContent = 'Save failed'; status.style.color = 'var(--error-color)'; }
244 245 }
@@ -58,9 +58,8 @@
58 58 style="margin-left: 0.5rem; padding: 0.15rem 0.4rem; font-size: 0.75rem;">Set</button>
59 59 </td>
60 60 <td>
61 - <code style="font-size: 0.8rem;">{{ app.api_key_masked }}</code>
62 - <button class="btn-small secondary" onclick="syncKitCopyKey(event, '{{ app.api_key_full }}')"
63 - style="margin-left: 0.5rem; padding: 0.15rem 0.4rem; font-size: 0.75rem;">Copy</button>
61 + <code id="synckit-key-display-{{ app.id }}" style="font-size: 0.8rem;">{{ app.api_key_masked }}</code>
62 + <span class="form-hint" style="font-size: 0.7rem; margin-left: 0.25rem;">(use Regenerate to get a new key)</span>
64 63 </td>
65 64 <td>
66 65 {% if app.is_active %}
@@ -89,15 +88,6 @@
89 88 </div>
90 89
91 90 <script>
92 - function syncKitCopyKey(event, key) {
93 - navigator.clipboard.writeText(key).then(function() {
94 - var btn = event.target;
95 - var orig = btn.textContent;
96 - btn.textContent = 'Copied';
97 - setTimeout(function() { btn.textContent = orig; }, 1500);
98 - });
99 - }
100 -
101 91 function syncKitRegenKey(appId) {
102 92 if (!confirm('Regenerate API key? All existing clients using the current key will stop working.')) return;
103 93 fetch('/api/sync/apps/' + appId + '/regenerate-key', {
@@ -106,7 +96,18 @@ function syncKitRegenKey(appId) {
106 96 headers: csrfHeaders()
107 97 }).then(function(res) {
108 98 if (res.ok) {
109 - document.getElementById('tab-synckit').click();
99 + return res.json().then(function(data) {
100 + // Show the new key with a copy button
101 + var display = document.getElementById('synckit-key-display-' + appId);
102 + if (display && data.api_key) {
103 + display.parentElement.innerHTML =
104 + '<code style="font-size: 0.8rem; word-break: break-all;">' + data.api_key + '</code>' +
105 + ' <button class="btn-small secondary" style="padding: 0.15rem 0.4rem; font-size: 0.75rem;" ' +
106 + 'onclick="navigator.clipboard.writeText(\'' + data.api_key + '\').then(function(){this.textContent=\'Copied\';var b=this;setTimeout(function(){b.textContent=\'Copy\'},1500)}.bind(this))">Copy</button>' +
107 + ' <span class="form-hint" style="font-size: 0.7rem; color: var(--error-color);">Save this key now — it cannot be shown again.</span>';
108 + }
109 + showToast('Key regenerated. Copy it now — it will not be shown again.');
110 + });
110 111 } else {
111 112 showToast('Failed to regenerate key.');
112 113 }
@@ -192,7 +193,22 @@ function syncKitShowSlugForm(appId, currentSlug) {
192 193 body: JSON.stringify({ name: name, project_id: '{{ project_id }}' })
193 194 }).then(function(res) {
194 195 if (res.ok) {
195 - document.getElementById('tab-synckit').click();
196 + return res.json().then(function(data) {
197 + var statusEl = document.getElementById('synckit-create-status');
198 + if (statusEl && data.api_key) {
199 + statusEl.style.color = '';
200 + statusEl.style.marginTop = '0.5rem';
201 + statusEl.innerHTML =
202 + '<div style="padding: 0.75rem; background: var(--light-background); border: 1px solid var(--border-color); margin-top: 0.5rem;">' +
203 + '<p style="margin: 0 0 0.5rem 0; font-weight: 500; color: var(--error-color);">Save this API key now — it cannot be shown again.</p>' +
204 + '<code style="font-size: 0.85rem; word-break: break-all; user-select: all;">' + data.api_key + '</code>' +
205 + ' <button class="btn-small secondary" style="padding: 0.15rem 0.4rem; font-size: 0.75rem;" ' +
206 + 'onclick="navigator.clipboard.writeText(\'' + data.api_key + '\').then(function(){this.textContent=\'Copied\';var b=this;setTimeout(function(){b.textContent=\'Copy\'},1500)}.bind(this))">Copy</button>' +
207 + '<button class="btn-small" style="margin-left: 0.5rem; padding: 0.15rem 0.4rem; font-size: 0.75rem;" ' +
208 + 'onclick="document.getElementById(\'tab-synckit\').click()">Done</button>' +
209 + '</div>';
210 + }
211 + });
196 212 } else {
197 213 return res.text().then(function(t) {
198 214 var statusEl = document.getElementById('synckit-create-status');
@@ -81,9 +81,8 @@
81 81 style="margin-left: 0.5rem; padding: 0.15rem 0.4rem; font-size: 0.75rem;">Link</button>
82 82 </td>
83 83 <td>
84 - <code style="font-size: 0.8rem;">{{ app.api_key_masked }}</code>
85 - <button class="btn-small secondary" onclick="syncKitCopyKey(event, '{{ app.api_key_full }}')"
86 - style="margin-left: 0.5rem; padding: 0.15rem 0.4rem; font-size: 0.75rem;">Copy</button>
84 + <code id="synckit-key-display-{{ app.id }}" style="font-size: 0.8rem;">{{ app.api_key_masked }}</code>
85 + <span class="form-hint" style="font-size: 0.7rem; margin-left: 0.25rem;">(use Regenerate to get a new key)</span>
87 86 </td>
88 87 <td>
89 88 {% if app.is_active %}
@@ -10,7 +10,7 @@ async fn add_custom_domain() {
10 10
11 11 let resp = h
12 12 .client
13 - .post_json("/api/domains", r#"{"domain": "mysite.example.com"}"#)
13 + .post_form("/api/domains", "domain=mysite.example.com")
14 14 .await;
15 15 assert!(
16 16 resp.status.is_success(),
@@ -19,11 +19,12 @@ async fn add_custom_domain() {
19 19 resp.text
20 20 );
21 21
22 - let body: Value = resp.json();
23 - assert_eq!(body["domain"].as_str().unwrap(), "mysite.example.com");
24 - assert!(!body["verified"].as_bool().unwrap());
25 - assert!(body["verification_token"].as_str().unwrap().starts_with("mnw-verify-"));
26 - assert!(body["instructions"].as_str().unwrap().contains("_mnw-verify.mysite.example.com"));
22 + // Handler returns HTML with DNS verification instructions
23 + assert!(
24 + resp.text.contains("_mnw-verify.mysite.example.com"),
25 + "Response should contain DNS verification instructions: {}",
26 + resp.text
27 + );
27 28 }
28 29
29 30 #[tokio::test]
@@ -33,7 +34,7 @@ async fn add_domain_rejects_duplicate() {
33 34
34 35 let resp = h
35 36 .client
36 - .post_json("/api/domains", r#"{"domain": "dup.example.com"}"#)
37 + .post_form("/api/domains", "domain=dup.example.com")
37 38 .await;
38 39 assert!(resp.status.is_success());
39 40
@@ -43,7 +44,7 @@ async fn add_domain_rejects_duplicate() {
43 44
44 45 let resp = h
45 46 .client
46 - .post_json("/api/domains", r#"{"domain": "dup.example.com"}"#)
47 + .post_form("/api/domains", "domain=dup.example.com")
47 48 .await;
48 49 assert!(
49 50 !resp.status.is_success(),
@@ -60,14 +61,14 @@ async fn add_domain_one_per_user_limit() {
60 61
61 62 let resp = h
62 63 .client
63 - .post_json("/api/domains", r#"{"domain": "first.example.com"}"#)
64 + .post_form("/api/domains", "domain=first.example.com")
64 65 .await;
65 66 assert!(resp.status.is_success());
66 67
67 68 // Second domain should fail
68 69 let resp = h
69 70 .client
70 - .post_json("/api/domains", r#"{"domain": "second.example.com"}"#)
71 + .post_form("/api/domains", "domain=second.example.com")
71 72 .await;
72 73 assert!(
73 74 !resp.status.is_success(),
@@ -94,7 +95,7 @@ async fn get_domain_returns_domain() {
94 95 let _uid = h.create_creator("domgetok").await;
95 96
96 97 h.client
97 - .post_json("/api/domains", r#"{"domain": "getme.example.com"}"#)
98 + .post_form("/api/domains", "domain=getme.example.com")
98 99 .await;
99 100
100 101 let resp = h.client.get("/api/domains").await;
@@ -110,9 +111,12 @@ async fn remove_domain() {
110 111
111 112 let resp = h
112 113 .client
113 - .post_json("/api/domains", r#"{"domain": "remove.example.com"}"#)
114 + .post_form("/api/domains", "domain=remove.example.com")
114 115 .await;
115 116 assert!(resp.status.is_success());
117 +
118 + // GET the domain to retrieve its id (GET returns JSON)
119 + let resp = h.client.get("/api/domains").await;
116 120 let body: Value = resp.json();
117 121 let id = body["id"].as_str().unwrap();
118 122
@@ -335,7 +339,7 @@ async fn add_domain_rejects_mnw_domains() {
335 339
336 340 let resp = h
337 341 .client
338 - .post_json("/api/domains", r#"{"domain": "makenot.work"}"#)
342 + .post_form("/api/domains", "domain=makenot.work")
339 343 .await;
340 344 assert!(
341 345 !resp.status.is_success(),
@@ -344,7 +348,7 @@ async fn add_domain_rejects_mnw_domains() {
344 348
345 349 let resp = h
346 350 .client
347 - .post_json("/api/domains", r#"{"domain": "sub.makenot.work"}"#)
351 + .post_form("/api/domains", "domain=sub.makenot.work")
348 352 .await;
349 353 assert!(
350 354 !resp.status.is_success(),