Skip to main content

max / makenotwork

Stripe/McMaster-Carr quality remediations Tier 1 - Error fidelity: ResultExt trait for error context chains, structured error logging, user_id in request spans, context on payment/auth/S3 paths Tier 2 - Observability: Prometheus metrics (/metrics endpoint, request counters, duration histograms, error counters, DB pool gauges), Grafana+Prometheus on Hetzner, admin metrics dashboard, resource IDs in handler spans, rate limit response headers (X-RateLimit-*) Tier 3 - API discipline: API versioning (/api/v1/ for SyncKit, license keys, OTA, public), MNW-Version response header, idempotency keys (table + middleware), webhook retry queue (table + exponential backoff + scheduler worker) Tier 4 - Testability: EmailTransport trait + PostmarkTransport, PaymentProvider trait, MockEmailTransport + MockPaymentProvider, TestHarness::with_mocks(), 6 new integration tests (checkout flow, email assertions, failure modes) Tier 5 - Database resilience: Pool health (test_before_acquire, max_lifetime, idle_timeout, min_connections), slow query logging (100ms WARN threshold), index coverage audit (all hot paths verified) Tier 6 - Performance: Cache-Control middleware (CDN caching for public pages, no-cache for dashboard, no-store for APIs), page weight audit (31KB gzipped total) Type safety: Visibility, ProjectRole, SubscriptionStatus enums replacing strings, PriceCents newtype, impl_str_enum! macro enhanced with PartialEq<str>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-23 19:16 UTC
Commit: b3f80f618a6724bd6e816bd408990cecd348418b
Parent: fffc449
85 files changed, +2501 insertions, -372 deletions
@@ -29,6 +29,18 @@ dependencies = [
29 29 ]
30 30
31 31 [[package]]
32 + name = "ahash"
33 + version = "0.8.12"
34 + source = "registry+https://github.com/rust-lang/crates.io-index"
35 + checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
36 + dependencies = [
37 + "cfg-if",
38 + "once_cell",
39 + "version_check",
40 + "zerocopy",
41 + ]
42 +
43 + [[package]]
32 44 name = "aho-corasick"
33 45 version = "1.1.4"
34 46 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3373,7 +3385,7 @@ dependencies = [
3373 3385
3374 3386 [[package]]
3375 3387 name = "makenotwork"
3376 - version = "0.3.25"
3388 + version = "0.3.26"
3377 3389 dependencies = [
3378 3390 "anyhow",
3379 3391 "argon2",
@@ -3397,6 +3409,9 @@ dependencies = [
3397 3409 "http-body-util",
3398 3410 "infer 0.19.0",
3399 3411 "jsonwebtoken",
3412 + "log",
3413 + "metrics",
3414 + "metrics-exporter-prometheus",
3400 3415 "openssl",
3401 3416 "rand 0.8.5",
3402 3417 "regex",
@@ -3519,6 +3534,46 @@ dependencies = [
3519 3534 ]
3520 3535
3521 3536 [[package]]
3537 + name = "metrics"
3538 + version = "0.24.3"
3539 + source = "registry+https://github.com/rust-lang/crates.io-index"
3540 + checksum = "5d5312e9ba3771cfa961b585728215e3d972c950a3eed9252aa093d6301277e8"
3541 + dependencies = [
3542 + "ahash",
3543 + "portable-atomic",
3544 + ]
3545 +
3546 + [[package]]
3547 + name = "metrics-exporter-prometheus"
3548 + version = "0.18.1"
3549 + source = "registry+https://github.com/rust-lang/crates.io-index"
3550 + checksum = "3589659543c04c7dc5526ec858591015b87cd8746583b51b48ef4353f99dbcda"
3551 + dependencies = [
3552 + "base64 0.22.1",
3553 + "indexmap",
3554 + "metrics",
3555 + "metrics-util",
3556 + "quanta",
3557 + "thiserror 2.0.18",
3558 + ]
3559 +
3560 + [[package]]
3561 + name = "metrics-util"
3562 + version = "0.20.1"
3563 + source = "registry+https://github.com/rust-lang/crates.io-index"
3564 + checksum = "cdfb1365fea27e6dd9dc1dbc19f570198bc86914533ad639dae939635f096be4"
3565 + dependencies = [
3566 + "crossbeam-epoch",
3567 + "crossbeam-utils",
3568 + "hashbrown 0.16.1",
3569 + "metrics",
3570 + "quanta",
3571 + "rand 0.9.2",
3572 + "rand_xoshiro",
3573 + "sketches-ddsketch",
3574 + ]
3575 +
3576 + [[package]]
3522 3577 name = "mime"
3523 3578 version = "0.3.17"
3524 3579 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4443,6 +4498,15 @@ dependencies = [
4443 4498 ]
4444 4499
4445 4500 [[package]]
4501 + name = "rand_xoshiro"
4502 + version = "0.7.0"
4503 + source = "registry+https://github.com/rust-lang/crates.io-index"
4504 + checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41"
4505 + dependencies = [
4506 + "rand_core 0.9.5",
4507 + ]
4508 +
4509 + [[package]]
4446 4510 name = "raw-cpuid"
4447 4511 version = "11.6.0"
4448 4512 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5151,6 +5215,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
5151 5215 checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
5152 5216
5153 5217 [[package]]
5218 + name = "sketches-ddsketch"
5219 + version = "0.3.1"
5220 + source = "registry+https://github.com/rust-lang/crates.io-index"
5221 + checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b"
5222 +
5223 + [[package]]
5154 5224 name = "slab"
5155 5225 version = "0.4.12"
5156 5226 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -76,10 +76,17 @@ csv = "1.3"
76 76 # CLI
77 77 clap = { version = "4", features = ["derive"] }
78 78
79 + # Logging (used by sqlx slow query config)
80 + log = "0.4"
81 +
79 82 # Error handling
80 83 thiserror = "2.0.18"
81 84 anyhow = "1.0.101"
82 85
86 + # Metrics
87 + metrics = "0.24"
88 + metrics-exporter-prometheus = { version = "0.18.1", default-features = false }
89 +
83 90 # Markdown rendering + documentation engine
84 91 docengine = { path = "../shared/docengine", features = ["doc-loader", "directives", "frontmatter", "media-urls"] }
85 92
@@ -0,0 +1,73 @@
1 + # Documentation Flaws
2 +
3 + Audit of MNW public docs from the perspective of an anti-capitalist, AI-skeptical artist who needs to sell their work.
4 +
5 + Conducted 2026-04-22. Status updated as fixes were applied.
6 +
7 + ---
8 +
9 + ## Critical
10 +
11 + ### 1. AI content policy — RESOLVED
12 +
13 + Three-tier system (Handmade / Assisted / Generated) with mandatory classification at publish time. Assisted tier requires written disclosure. Fan-side filtering. Generative AI defined by training data ethics (unpaid copyright, undisclosed datasets). Full policy at `about/generative-ai.md`. FAQ, acceptable use, getting-started, items guide, and fan guide all updated to reference it.
14 +
15 + ### 2. No statement on AI in the platform itself — RESOLVED
16 +
17 + New section in `about/generative-ai.md`: no generative AI in the product, discovery is explicit (not ML), security/spam reserves the right to use best tools, platform development is LLM-assisted and disclosed in commit logs. Discovery algorithms linked directly to source files. Founder's personal note included as blockquote.
18 +
19 + ---
20 +
21 + ## High
22 +
23 + ### 3. "Creator" language everywhere — RESOLVED
24 +
25 + Landing page: "Creator tiers" → "Pricing tiers", "Every creator gets" → "Everyone gets". how-we-work.md "Who This Is For" now opens with "Artists, musicians, writers, developers, and makers." Targeted changes in audience-facing prose; kept "creator" in technical contexts (dashboard, API) where it's standard.
26 +
27 + ### 4. $10/month floor unaddressed — RESOLVED
28 +
29 + FAQ "What if I earn nothing" now acknowledges the tension honestly and links to earn-back. how-we-work.md unchanged (already had earn-back details).
30 +
31 + ### 5. "Platforms should be infrastructure, not landlords" buried — RESOLVED
32 +
33 + Added as subtagline on landing page hero, in og:description meta tag, and as closing line of story.md "What This Means in Practice" section. CSS added for `.subtagline`.
34 +
35 + ---
36 +
37 + ## Medium
38 +
39 + ### 6. Bus factor handwave — RESOLVED
40 +
41 + FAQ rewritten: acknowledges the risk directly, lists concrete mitigations (public source, export, shutdown protocol, separate funds), states hiring as top financial priority, calls the long-term goal a goal not a guarantee.
42 +
43 + ### 7. Source available vs open source blurring — RESOLVED
44 +
45 + `tech/open-source.md` now explicitly says "source-available, not open source" with explanation of why PolyForm Noncommercial was chosen. `legal/transparency.md` heading changed from "Open Source Transparency" to "Source-Available Transparency."
46 +
47 + ### 8. Platform economics not shown — RESOLVED
48 +
49 + New public page at `about/economics.md`: cost structure by category, per-creator costs and margins by tier, break-even number (36 creators), where surplus goes (wage, hiring via residency program, reserves, development), what surplus does not fund, the margin question addressed directly, "Why These Prices Won't Go Up" section (no hidden subsidy, margins widen with growth, missing cost centers, hosting trends down). Linked from how-we-work, story, and FAQ.
50 +
51 + ### 9. Cooperative ownership unexplored — RESOLVED (partially)
52 +
53 + FAQ and economics page now have honest framing: hard commitment that the company will never be sold to anyone other than its creator community, honest acknowledgment that the legal structure isn't figured out yet, commitment to figure it out with the community when the time comes. Deliberately not over-promising on co-op structure before legal advice.
54 +
55 + ---
56 +
57 + ## Low
58 +
59 + ### 10. Startup-flavored prose — RESOLVED
60 +
61 + Tonal pass across story.md, guarantees.md, how-we-work.md, faq.md, moderation.md. Pattern: replaced self-praise ("this isn't marketing, it's accountability") with action ("you can check"), replaced declarations of intent ("we believe", "our commitment") with descriptions of how things work, replaced sales language ("no catch") with incentive explanations. SLA intro shortened from three lines to one.
62 +
63 + ### 11. Missing artist stories / social proof — DEFERRED
64 +
65 + No testimonials possible during alpha. Will revisit once there are real creators to feature. Goal is honest stories, not marketing fluff.
66 +
67 + ### 12. Enforcement is one person — RESOLVED
68 +
69 + Moderation page now opens with "Current Limitations" section acknowledging one-person enforcement, linking to SLA planned guarantees for independent appeals, and stating hiring priority. "We'd rather be honest about this than pretend we have a trust and safety team."
70 +
71 + ### 13. Community features buried — RESOLVED
72 +
73 + Multithreaded referenced in: how-we-work.md (new "Community" section), roadmap.md (added to "What's Built"), getting-started.md (added to "Your First Week" checklist). Forums page already existed at `support/forums.md` but was orphaned from the main docs flow.
@@ -0,0 +1,17 @@
1 + -- Idempotency key storage for safe POST retries.
2 + -- Clients send an Idempotency-Key header; the server caches the response
3 + -- body and status code for 24 hours so duplicate requests return the same result.
4 +
5 + CREATE TABLE idempotency_keys (
6 + key TEXT NOT NULL,
7 + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
8 + method TEXT NOT NULL,
9 + path TEXT NOT NULL,
10 + status_code SMALLINT NOT NULL,
11 + response_body TEXT NOT NULL,
12 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
13 + PRIMARY KEY (key, user_id)
14 + );
15 +
16 + -- Index for cleanup job (expire keys older than 24h)
17 + CREATE INDEX idx_idempotency_keys_created_at ON idempotency_keys (created_at);
@@ -0,0 +1,17 @@
1 + -- Webhook event retry queue.
2 + -- Failed webhook events are persisted and retried with exponential backoff.
3 +
4 + CREATE TABLE webhook_events (
5 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
6 + source TEXT NOT NULL, -- 'stripe' or 'postmark'
7 + event_type TEXT NOT NULL, -- e.g. 'checkout.session.completed'
8 + payload TEXT NOT NULL, -- raw JSON body
9 + signature TEXT, -- original signature header (for re-verification)
10 + status TEXT NOT NULL DEFAULT 'failed' CHECK (status IN ('failed', 'retrying', 'dead')),
11 + attempts INT NOT NULL DEFAULT 0,
12 + last_error TEXT,
13 + next_retry_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '60 seconds',
14 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
15 + );
16 +
17 + CREATE INDEX idx_webhook_events_retry ON webhook_events (next_retry_at) WHERE status IN ('failed', 'retrying');
@@ -62,7 +62,8 @@ Items are individual pieces of content inside a project.
62 62 | **Digital** | Software, plugins, files, images |
63 63
64 64 4. Set a title and price (free, fixed, or pay-what-you-want)
65 - 5. Upload your content or write your text
65 + 5. Choose your [generative AI tier](../about/generative-ai.md): Handmade, Assisted (with disclosure), or Generated
66 + 6. Upload your content or write your text
66 67
67 68 ## Publish
68 69
@@ -76,7 +77,7 @@ Items are individual pieces of content inside a project.
76 77 - [ ] Waitlist application submitted (or creator access granted)
77 78 - [ ] Payment account connected
78 79 - [ ] First project created with title, slug, and category
79 - - [ ] First item created with content uploaded
80 + - [ ] First item created with content uploaded and AI tier declared
80 81 - [ ] Item and project published
81 82
82 83 ## What Kind of Creator Are You?
@@ -130,6 +131,7 @@ After your first publish, here's what to focus on:
130 131 3. **Share your link.** Post your profile URL or project URL wherever your audience is.
131 132 4. **Set up RSS cross-posting.** Connect your RSS feed to social media or newsletter tools. See [RSS](./rss.md).
132 133 5. **Fill in metadata.** Good titles, descriptions, tags, and cover art make your content discoverable and shareable. See [Metadata](./metadata.md).
134 + 6. **Join the forum.** Say hello at [forums.makenot.work](https://forums.makenot.work). It's where platform feedback, feature requests, and creator-to-creator discussion happen.
133 135
134 136 ## See Also
135 137
@@ -8,6 +8,8 @@ When you buy something here, the creator gets almost everything you paid. The pl
8 8
9 9 There's no algorithm deciding what you see. No ads. No tracking cookies following you around the internet. You find creators through search, links, or recommendations — the same way you find anything worth finding.
10 10
11 + Every item on the platform is classified as Handmade, Assisted, or Generated based on generative AI use. You can filter to see only Handmade content, only human-led work, or everything. See the [Generative AI Policy](../about/generative-ai.md) for what these tiers mean.
12 +
11 13 Your purchases are permanent and downloadable. No DRM, no streaming-only restrictions, no region locks.
12 14
13 15 ## How Payments Work
@@ -39,6 +39,7 @@ From the item settings, update:
39 39 - **Cover image**: Displayed on the item card and in social previews
40 40 - **Release date**: When it was or will be released
41 41 - **Credits**: Collaborators, producers, engineers
42 + - **AI tier**: Handmade, Assisted (with disclosure statement), or Generated — required before publishing. See [Generative AI Policy](../about/generative-ai.md)
42 43
43 44 ## Item URLs
44 45
@@ -44,7 +44,7 @@ Undisclosed paid advertisement is a serious moderation issue. Presenting sponsor
44 44 ### Content That Harms the Platform
45 45
46 46 - **Spam** - Automated posting, fake engagement, or promotional flooding
47 - - **Fraud** - Scams, deceptive schemes, or financial manipulation
47 + - **Fraud** - Scams, deceptive schemes, or financial manipulation — including misrepresenting your [generative AI tier](../about/generative-ai.md) (e.g., claiming Handmade when generative AI was used)
48 48 - **Impersonation** - Pretending to be someone else to deceive
49 49 - **Malware and harmful software** - Including but not limited to:
50 50 - Uploading software that contains malware, spyware, or backdoors
@@ -117,6 +117,7 @@ We review all reports. We don't disclose reporter identities to the reported use
117 117
118 118 ## See Also
119 119
120 + - [Generative AI Policy](../about/generative-ai.md) — AI tier definitions and disclosure requirements
120 121 - [Terms of Service](./terms-of-service.md) — Full legal terms
121 122 - [Privacy Policy](./privacy-policy.md) — Data collection and handling
122 123 - [FAQ](../support/faq.md) — Content policy questions
@@ -83,7 +83,7 @@ We'll begin publishing transparency reports once we have enough activity to make
83 83
84 84 ---
85 85
86 - ## Open Source Transparency
86 + ## Source-Available Transparency
87 87
88 88 Beyond reports, our moderation approach is transparent because:
89 89
@@ -24,7 +24,9 @@ Available for review:
24 24
25 25 ## License
26 26
27 - PolyForm Noncommercial 1.0.0. You can read, review, and use the code for any noncommercial purpose. Commercial use requires a separate license from Make Creative, LLC.
27 + PolyForm Noncommercial 1.0.0. This is **source-available, not open source** — an important distinction. You can read, review, learn from, and use the code for any noncommercial purpose. You cannot fork it and run a competing commercial service. Commercial use requires a separate license from Make Creative, LLC.
28 +
29 + We chose this license deliberately. Full open source (MIT, GPL, etc.) would let a well-funded company clone the platform, add a percentage cut, and outspend us on marketing. Source-available gives you the transparency and auditability that matter — verifying our claims, reading our code, holding us accountable — without giving venture capital a free blueprint.
28 30
29 31 ## See Also
30 32
@@ -147,6 +147,10 @@ impl FromRequestParts<crate::AppState> for AuthUser {
147 147 }
148 148 }
149 149
150 + // Record user_id in the current span so all downstream logs
151 + // (DB queries, error handlers, etc.) include it automatically.
152 + tracing::Span::current().record("user_id", tracing::field::display(&user.id));
153 +
150 154 Ok(AuthUser(user))
151 155 }
152 156 }
@@ -767,7 +767,7 @@ async fn exec_git_operation(
767 767 }
768 768 }
769 769 GitOperation::UploadPack | GitOperation::Archive => {
770 - if repo.visibility == "private" && user_id != owner_user.id {
770 + if repo.visibility == db::Visibility::Private && user_id != owner_user.id {
771 771 anyhow::bail!("repository not found");
772 772 }
773 773 }
@@ -835,7 +835,7 @@ enum ManagementCommand {
835 835 RepoList,
836 836 RepoInfo { name: String },
837 837 RepoDelete { name: String },
838 - RepoSetVisibility { name: String, visibility: String },
838 + RepoSetVisibility { name: String, visibility: db::Visibility },
839 839 RepoSetDescription { name: String, description: String },
840 840 KeyList,
841 841 KeyRemove { fingerprint: String },
@@ -882,13 +882,11 @@ fn parse_management_command(tokens: &[String]) -> anyhow::Result<ManagementComma
882 882 ["repo", "delete", name, "--confirm"] => Ok(ManagementCommand::RepoDelete { name: name.to_string() }),
883 883 ["repo", "delete", _, ..] => anyhow::bail!("repo delete requires --confirm flag"),
884 884 ["repo", "set-visibility", name, vis] => {
885 - match *vis {
886 - "public" | "private" | "unlisted" => {}
887 - _ => anyhow::bail!("visibility must be public, private, or unlisted"),
888 - }
885 + let visibility: db::Visibility = vis.parse()
886 + .map_err(|_| anyhow::anyhow!("visibility must be public, private, or unlisted"))?;
889 887 Ok(ManagementCommand::RepoSetVisibility {
890 888 name: name.to_string(),
891 - visibility: vis.to_string(),
889 + visibility,
892 890 })
893 891 }
894 892 ["repo", "set-description", name, desc] => Ok(ManagementCommand::RepoSetDescription {
@@ -915,7 +913,7 @@ async fn exec_management_command(
915 913 ManagementCommand::RepoInfo { name } => cmd_ssh_repo_info(pool, user_id, &name).await,
916 914 ManagementCommand::RepoDelete { name } => cmd_ssh_repo_delete(pool, user_id, username, &name).await,
917 915 ManagementCommand::RepoSetVisibility { name, visibility } => {
918 - cmd_ssh_repo_set_visibility(pool, user_id, &name, &visibility).await
916 + cmd_ssh_repo_set_visibility(pool, user_id, &name, visibility).await
919 917 }
920 918 ManagementCommand::RepoSetDescription { name, description } => {
921 919 cmd_ssh_repo_set_description(pool, user_id, &name, &description).await
@@ -999,7 +997,7 @@ async fn cmd_ssh_repo_set_visibility(
999 997 pool: &PgPool,
1000 998 user_id: db::UserId,
1001 999 name: &str,
1002 - visibility: &str,
1000 + visibility: db::Visibility,
1003 1001 ) -> anyhow::Result<()> {
1004 1002 let repo = db::git_repos::get_repo_by_user_and_name(pool, user_id, name)
1005 1003 .await?
@@ -1021,7 +1019,7 @@ async fn cmd_ssh_repo_set_description(
1021 1019 .await?
1022 1020 .ok_or_else(|| anyhow::anyhow!("repository '{}' not found", name))?;
1023 1021
1024 - db::git_repos::update_repo_settings(pool, repo.id, description, &repo.visibility).await?;
1022 + db::git_repos::update_repo_settings(pool, repo.id, description, repo.visibility).await?;
1025 1023
1026 1024 println!("Updated description of '{}'.", name);
1027 1025 Ok(())
@@ -1192,7 +1190,7 @@ mod tests {
1192 1190 let tokens: Vec<String> = vec!["repo".into(), "set-visibility".into(), "myrepo".into(), "private".into()];
1193 1191 assert_eq!(
1194 1192 parse_management_command(&tokens).unwrap(),
1195 - ManagementCommand::RepoSetVisibility { name: "myrepo".into(), visibility: "private".into() },
1193 + ManagementCommand::RepoSetVisibility { name: "myrepo".into(), visibility: db::Visibility::Private },
1196 1194 );
1197 1195 }
1198 1196
@@ -6,7 +6,12 @@
6 6
7 7 // -- Database --
8 8 pub const DB_POOL_MAX_CONNECTIONS: u32 = 25;
9 + pub const DB_POOL_MIN_CONNECTIONS: u32 = 2;
9 10 pub const DB_ACQUIRE_TIMEOUT_SECS: u64 = 3;
11 + /// Rotate connections after 30 minutes to prevent stale sessions.
12 + pub const DB_MAX_LIFETIME_SECS: u64 = 1800;
13 + /// Prune idle connections after 10 minutes.
14 + pub const DB_IDLE_TIMEOUT_SECS: u64 = 600;
10 15
11 16 // -- Sessions --
12 17 pub const SESSION_EXPIRY_DAYS: i64 = 7;
@@ -3,7 +3,7 @@
3 3 use chrono::{DateTime, Utc};
4 4 use sqlx::PgPool;
5 5
6 - use super::enums::CreatorTier;
6 + use super::enums::{CreatorTier, SubscriptionStatus};
7 7 use super::id_types::*;
8 8 use super::models::{DbCreatorSubscription, StorageBreakdown};
9 9 use crate::error::{AppError, Result};
@@ -93,7 +93,7 @@ pub async fn get_active_creator_tier(
93 93 pub async fn update_creator_sub_status<'e>(
94 94 executor: impl sqlx::PgExecutor<'e>,
95 95 stripe_subscription_id: &str,
96 - status: &str,
96 + status: SubscriptionStatus,
97 97 ) -> Result<Option<DbCreatorSubscription>> {
98 98 let sub = sqlx::query_as::<_, DbCreatorSubscription>(
99 99 r#"
@@ -61,6 +61,25 @@ macro_rules! impl_str_enum {
61 61 Ok(s.parse::<Self>()?)
62 62 }
63 63 }
64 +
65 + // Allow comparison with string slices (useful in Askama templates).
66 + impl PartialEq<&str> for $enum_name {
67 + fn eq(&self, other: &&str) -> bool {
68 + let s: &str = match self {
69 + $( Self::$variant => $str, )+
70 + };
71 + s == *other
72 + }
73 + }
74 +
75 + impl PartialEq<str> for $enum_name {
76 + fn eq(&self, other: &str) -> bool {
77 + let s: &str = match self {
78 + $( Self::$variant => $str, )+
79 + };
80 + s == other
81 + }
82 + }
64 83 };
65 84 }
66 85
@@ -179,6 +198,36 @@ impl_str_enum!(SubscriptionStatus {
179 198 Unpaid => "unpaid",
180 199 });
181 200
201 + // ── Git repository visibility ──
202 +
203 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
204 + #[serde(rename_all = "lowercase")]
205 + pub enum Visibility {
206 + Public,
207 + Unlisted,
208 + Private,
209 + }
210 +
211 + impl_str_enum!(Visibility {
212 + Public => "public",
213 + Unlisted => "unlisted",
214 + Private => "private",
215 + });
216 +
217 + // ── Project member roles ──
218 +
219 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
220 + #[serde(rename_all = "lowercase")]
221 + pub enum ProjectRole {
222 + Owner,
223 + Member,
224 + }
225 +
226 + impl_str_enum!(ProjectRole {
227 + Owner => "owner",
228 + Member => "member",
229 + });
230 +
182 231 // ── SyncKit ──
183 232
184 233 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -3,6 +3,7 @@
3 3 use chrono::{DateTime, Utc};
4 4 use sqlx::PgPool;
5 5
6 + use super::enums::SubscriptionStatus;
6 7 use super::id_types::*;
7 8 use super::models::DbFanPlusSubscription;
8 9 use crate::error::Result;
@@ -56,7 +57,7 @@ pub async fn get_fan_plus_by_stripe_id(
56 57 pub async fn update_fan_plus_status<'e>(
57 58 executor: impl sqlx::PgExecutor<'e>,
58 59 stripe_subscription_id: &str,
59 - status: &str,
60 + status: SubscriptionStatus,
60 61 ) -> Result<Option<DbFanPlusSubscription>> {
61 62 let sub = sqlx::query_as::<_, DbFanPlusSubscription>(
62 63 r#"
@@ -4,7 +4,7 @@ use chrono::{DateTime, Utc};
4 4 use sqlx::{FromRow, PgPool};
5 5
6 6 use super::models::DbGitRepo;
7 - use super::{GitRepoId, ProjectId, UserId};
7 + use super::{GitRepoId, ProjectId, UserId, Visibility};
8 8 use crate::error::Result;
9 9
10 10 /// A public repo joined with its owner's username, for the explore page.
@@ -40,7 +40,7 @@ pub async fn create_repo_with_visibility(
40 40 pool: &PgPool,
41 41 user_id: UserId,
42 42 name: &str,
43 - visibility: &str,
43 + visibility: Visibility,
44 44 ) -> Result<DbGitRepo> {
45 45 let repo = sqlx::query_as::<_, DbGitRepo>(
46 46 r#"
@@ -163,7 +163,7 @@ pub async fn unlink_repo_from_project(pool: &PgPool, repo_id: GitRepoId) -> Resu
163 163 pub async fn update_visibility(
164 164 pool: &PgPool,
165 165 repo_id: GitRepoId,
166 - visibility: &str,
166 + visibility: Visibility,
167 167 ) -> Result<()> {
168 168 sqlx::query("UPDATE git_repos SET visibility = $2 WHERE id = $1")
169 169 .bind(repo_id)
@@ -180,7 +180,7 @@ pub async fn update_repo_settings(
180 180 pool: &PgPool,
181 181 repo_id: GitRepoId,
182 182 description: &str,
183 - visibility: &str,
183 + visibility: Visibility,
184 184 ) -> Result<()> {
185 185 sqlx::query("UPDATE git_repos SET description = $2, visibility = $3 WHERE id = $1")
186 186 .bind(repo_id)
@@ -0,0 +1,71 @@
1 + //! Idempotency key storage for safe POST retries.
2 +
3 + use sqlx::PgPool;
4 + use crate::db::UserId;
5 + use crate::error::Result;
6 +
7 + /// A cached idempotency response.
8 + #[derive(sqlx::FromRow)]
9 + pub struct CachedResponse {
10 + pub status_code: i16,
11 + pub response_body: String,
12 + }
13 +
14 + /// Look up a cached response for an idempotency key.
15 + #[tracing::instrument(skip_all)]
16 + pub async fn get_cached_response(
17 + pool: &PgPool,
18 + key: &str,
19 + user_id: UserId,
20 + ) -> Result<Option<CachedResponse>> {
21 + let row = sqlx::query_as::<_, CachedResponse>(
22 + "SELECT status_code, response_body FROM idempotency_keys WHERE key = $1 AND user_id = $2",
23 + )
24 + .bind(key)
25 + .bind(user_id)
26 + .fetch_optional(pool)
27 + .await?;
28 +
29 + Ok(row)
30 + }
31 +
32 + /// Store a response for an idempotency key. Uses ON CONFLICT to handle
33 + /// race conditions (first writer wins).
34 + #[tracing::instrument(skip_all)]
35 + pub async fn store_response(
36 + pool: &PgPool,
37 + key: &str,
38 + user_id: UserId,
39 + method: &str,
40 + path: &str,
41 + status_code: u16,
42 + response_body: &str,
43 + ) -> Result<()> {
44 + sqlx::query(
45 + r#"INSERT INTO idempotency_keys (key, user_id, method, path, status_code, response_body)
46 + VALUES ($1, $2, $3, $4, $5, $6)
47 + ON CONFLICT (key, user_id) DO NOTHING"#,
48 + )
49 + .bind(key)
50 + .bind(user_id)
51 + .bind(method)
52 + .bind(path)
53 + .bind(status_code as i16)
54 + .bind(response_body)
55 + .execute(pool)
56 + .await?;
57 +
58 + Ok(())
59 + }
60 +
61 + /// Delete expired idempotency keys (older than 24 hours).
62 + #[tracing::instrument(skip_all)]
63 + pub async fn cleanup_expired(pool: &PgPool) -> Result<u64> {
64 + let result = sqlx::query(
65 + "DELETE FROM idempotency_keys WHERE created_at < NOW() - INTERVAL '24 hours'",
66 + )
67 + .execute(pool)
68 + .await?;
69 +
70 + Ok(result.rows_affected())
71 + }
@@ -58,6 +58,8 @@ pub(crate) mod imports;
58 58 pub(crate) mod media_files;
59 59 pub(crate) mod tips;
60 60 pub(crate) mod project_members;
61 + pub(crate) mod idempotency;
62 + pub(crate) mod webhook_events;
61 63
62 64 pub use id_types::*;
63 65 pub use validated_types::*;