From be39821e6f370f5fa0a4b7577c3a7ec927c52403 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 9 Sep 2026 09:57:00 -0700 Subject: [PATCH] fix(domain,app): gate writes on account status, email verification and tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #42, #40, #41 — built as one model rather than three patches, because all three answer the same question: will the server refuse this, and why? `CapabilityGate` composes the three mechanisms and evaluates them hardest-first (status → verification → tier), so the user is told the reason they can act on. A restricted subscriber is told they are under review, not to upgrade; a new, unverified, free account is told to verify, which is both the cheaper fix and the documented fastest way off probation. #42 — accountStatus was entirely unmodelled. Now decoded off GET /api/user (verified live), narrowed to the five documented cases with an `.unknown` escape hatch, and surfaced as a timeline banner for new/restricted/suspended. The OpenAPI schema types the field as a bare string with no enum, so unknown values fail *open*: a server-side rename cannot brick a paying user's app. #40 — `Feature` grows from 3 modelled features to the documented 12, and `canManageLists` loses its M3 permissive default. That default was masking a worse bug: all 21 ListsService write methods routed through it, including pure reads (`myLists`, `rows`, `watchers`) and row edits. Flipping the default alone would have stopped free users reading their own lists. The gate now sits on `create` only, and the case names (`.listCreation`, not `.lists`) encode the create-only rule so no call site can gate an edit, a row insert, or a revoke by accident. Documents and organizations gain the same create-only gate. #41 — the composer, media picker, and Settings now consult the gate up front instead of failing at publish. `POST /api/auth/send-verification-email` is `x-auth-type: session` and rejects this Bearer client, so the resend action deep-links to web Settings rather than shipping a button that 401s silently. The 10-minute cooldown is modelled as local advisory state; the server owns it. Verification (full E2E gate, all observed): - xcodebuild build ** BUILD SUCCEEDED ** - xcodebuild test (App) 762 tests, 0 failures — ** TEST SUCCEEDED ** - swift test InterlinedKit 403 tests, 0 failures - swift test InterlinedDomain 823 tests, 0 failures - swift test InterlinedPersistence 135 tests, 0 failures - Decision 0003 grep zero real imports - Contract tests (live, read-only) 5 tests, 0 failures Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YSyDpagUre475rMMxtXgLE --- App/Composition/AppEnvironment.swift | 18 +- .../Account/AccountStatusBanner.swift | 124 +++++++ App/Features/Compose/ComposerViewModel.swift | 64 +++- App/Features/Compose/ComposerWindowView.swift | 43 +++ .../Settings/AccountSettingsView.swift | 40 ++ App/Features/Timeline/TimelineRootView.swift | 9 + AppTests/ComposerCapabilityGateTests.swift | 174 +++++++++ .../Models/AccountStatus.swift | 97 +++++ .../InterlinedDomain/Models/CurrentUser.swift | 7 + .../Models/EmailVerification.swift | 117 ++++++ .../InterlinedDomain/Models/Mappers.swift | 3 + .../Services/CapabilityGate.swift | 341 ++++++++++++++++++ .../Services/DocumentsService.swift | 25 +- .../Services/EntitlementsService.swift | 168 +++++++-- .../Services/ListsService.swift | 66 ++-- .../Services/MessagesService.swift | 10 +- .../Services/OrgService.swift | 40 +- .../Services/SharingService.swift | 12 +- .../AccountStatusTests.swift | 84 +++++ .../CapabilityGateTests.swift | 282 +++++++++++++++ .../EmailVerificationTests.swift | 117 ++++++ .../EntitlementsServiceTests.swift | 64 +++- .../OwnedListsServiceTests.swift | 47 +-- .../SubscriberCreateGateTests.swift | 185 ++++++++++ .../Sources/InterlinedKit/DTOs/UserDTO.swift | 14 +- .../InterlinedKitTests/ContractTests.swift | 30 ++ .../UserEndpointTests.swift | 64 ++++ 27 files changed, 2114 insertions(+), 131 deletions(-) create mode 100644 App/Features/Account/AccountStatusBanner.swift create mode 100644 AppTests/ComposerCapabilityGateTests.swift create mode 100644 Packages/InterlinedDomain/Sources/InterlinedDomain/Models/AccountStatus.swift create mode 100644 Packages/InterlinedDomain/Sources/InterlinedDomain/Models/EmailVerification.swift create mode 100644 Packages/InterlinedDomain/Sources/InterlinedDomain/Services/CapabilityGate.swift create mode 100644 Packages/InterlinedDomain/Tests/InterlinedDomainTests/AccountStatusTests.swift create mode 100644 Packages/InterlinedDomain/Tests/InterlinedDomainTests/CapabilityGateTests.swift create mode 100644 Packages/InterlinedDomain/Tests/InterlinedDomainTests/EmailVerificationTests.swift create mode 100644 Packages/InterlinedDomain/Tests/InterlinedDomainTests/SubscriberCreateGateTests.swift diff --git a/App/Composition/AppEnvironment.swift b/App/Composition/AppEnvironment.swift index 4780be6..3fc780a 100644 --- a/App/Composition/AppEnvironment.swift +++ b/App/Composition/AppEnvironment.swift @@ -58,6 +58,17 @@ final class AppEnvironment: ObservableObject { EntitlementsService(user: currentUserStore.currentUser) } + /// The composed capability gate — account status, email verification, and + /// subscription tier answered as one question (GitHub #40 / #41 / #42). + /// + /// Features should prefer this over `liveEntitlements`: a subscriber who is + /// `restricted`, or who has not verified their email, is entitled but still + /// cannot post, and only the composed gate knows that. Derived live from + /// `currentUserStore.currentUser`, exactly like `liveEntitlements`. + var liveCapabilities: CapabilityGate { + CapabilityGate(user: currentUserStore.currentUser) + } + /// The visibility a new-message composer draft opens on — the signed-in /// account's "new posts are public by default" preference. Derived live from /// `currentUserStore.currentUser`, exactly like `liveEntitlements` above, so @@ -487,7 +498,9 @@ final class AppEnvironment: ObservableObject { // deltas stay consistent (stale-while-revalidate paint). store: documentStore, // Live image ceilings for `uploadImage` prep (G14 tail). - contentLimits: contentLimits + contentLimits: contentLimits, + // Subscriber gate for document *creation* only (GitHub #40). + entitlements: { liveEntitlements.current() } ) // Server document templates (work-consolidation.md G12). Reuses the same // kit-layer `APIClient` like the other services do — the @@ -517,7 +530,8 @@ final class AppEnvironment: ObservableObject { // decision-0001 session allowlist, both already routed by the shared // `authTransport`. `UserService` takes the default production base URL // for the browser-handoff OAuth link flow. - let orgService = OrgService(api: api) + // Subscriber gate for organization *creation* only (GitHub #40). + let orgService = OrgService(api: api, entitlements: { liveEntitlements.current() }) // Org-memberships cache (work-consolidation.md) — the Organizations switcher's // initial-view data. On-disk in Application Support with disposable / // auto-rebuild semantics; falls back to `NullOrgStore` if the container diff --git a/App/Features/Account/AccountStatusBanner.swift b/App/Features/Account/AccountStatusBanner.swift new file mode 100644 index 0000000..d6ad6d1 --- /dev/null +++ b/App/Features/Account/AccountStatusBanner.swift @@ -0,0 +1,124 @@ +// AccountStatusBanner +// +// The home-timeline banner that explains a limited account (GitHub #42). +// +// `/help/account`: "When your account is new or locked, a banner at the top of +// your home page explains the current status and, where relevant, links you to +// verify your email or to contact support." +// +// The banner renders nothing for an `active` account, and nothing for an +// unrecognised status — an unknown value must never scare a user with a +// warning the client cannot explain. `banned` never reaches here either: a +// banned account cannot sign in, so it is a sign-in failure path. +// +// Decision 0003: consumes `InterlinedDomain` only. + +import SwiftUI +import InterlinedDomain + +struct AccountStatusBanner: View { + + let status: AccountStatus + let isEmailVerified: Bool + + @Environment(\.openURL) private var openURL + + var body: some View { + if status.warrantsBanner { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Image(systemName: iconName) + .foregroundStyle(tint) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.callout.weight(.semibold)) + Text(explanation) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 8) + + if let action { + Button(action.title) { + openURL(action.destination.url()) + } + .buttonStyle(.link) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(tint.opacity(0.10)) + .overlay(alignment: .bottom) { Divider() } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(title). \(explanation)") + } + } + + // MARK: - Copy + + private var title: String { + switch status { + case .new: return "Your account is new" + case .restricted: return "Your account is temporarily read-only" + case .suspended: return "Your account is suspended" + case .active, .banned, .unknown: return "" + } + } + + private var explanation: String { + switch status { + case .new: + // Posting works but is rate-limited, so the copy must not imply the + // user cannot post at all. + return isEmailVerified + ? "Posting is limited while your account is reviewed. Direct messages, media, " + + "cross-posting, scheduling, and creating lists, documents, and organizations " + + "unlock once it is active." + : "Posting is limited while your account is new. Verifying your email is the " + + "fastest way to unlock direct messages, media, cross-posting, scheduling, " + + "and creating lists, documents, and organizations." + case .restricted: + return "You can read and browse as usual. Posting, replying, reacting, following, " + + "messaging, and creating content are paused while your account is reviewed." + case .suspended: + return "You can read and browse as usual. If you think this is a mistake, you can appeal." + case .active, .banned, .unknown: + return "" + } + } + + /// The next step, mirroring the web: verify email for a new account, + /// contact support for a locked one. + private var action: (title: String, destination: AccountWebDestination)? { + switch status { + case .new: + // Nothing useful to offer once the email is already verified — the + // remaining wait is the platform's own review. + return isEmailVerified ? nil : ("Verify email", .settings) + case .restricted, .suspended: + return ("Contact support", .support) + case .active, .banned, .unknown: + return nil + } + } + + private var iconName: String { + switch status { + case .new: return "clock.badge.checkmark" + case .restricted, .suspended: return "exclamationmark.triangle.fill" + case .active, .banned, .unknown: return "info.circle" + } + } + + private var tint: Color { + switch status { + case .new: return .accentColor + case .restricted, .suspended: return .orange + case .active, .banned, .unknown: return .secondary + } + } +} diff --git a/App/Features/Compose/ComposerViewModel.swift b/App/Features/Compose/ComposerViewModel.swift index 8bf25a6..7f770ed 100644 --- a/App/Features/Compose/ComposerViewModel.swift +++ b/App/Features/Compose/ComposerViewModel.swift @@ -45,6 +45,15 @@ final class ComposerViewModel { /// domain `MessagesService` enforces the same status as a backstop. private(set) var entitlements: EntitlementsService + /// The composed capability gate — account status, email verification, and + /// subscription tier as one answer (GitHub #40 / #41 / #42). + /// + /// `entitlements` alone cannot tell the composer why a post will fail: a + /// subscriber who is `restricted`, or who has not verified their email, is + /// entitled but still cannot post. This is the gate the Post button and the + /// media affordance consult. + private(set) var capabilities: CapabilityGate + /// Reads a local file's bytes at send time. Injected so tests can supply /// bytes without touching the filesystem; production reads the file URL. private let readData: @Sendable (URL) async throws -> Data @@ -163,6 +172,31 @@ final class ComposerViewModel { entitlements.isSubscriber } + /// Why this draft cannot be posted right now, or `nil` if it can. + /// + /// An edit republishes an existing message rather than posting a new one, + /// but the platform gates both the same way, so the same action is asked + /// about in either mode. + var postDenial: CapabilityDenial? { + capabilities.denial(for: .postMessage) + } + + /// Why media cannot be attached right now, or `nil` if it can. + var attachmentDenial: CapabilityDenial? { + capabilities.denial(for: .mediaAttachments) + } + + /// The inline, non-modal explanation shown under the composer when posting + /// is blocked. Never blocks typing — the draft must survive (GitHub #41). + var postBlockedMessage: String? { + postDenial?.message + } + + /// The next step to offer beside ``postBlockedMessage``, if any. + var postBlockedRemedy: CapabilityRemedy? { + postDenial?.remedy + } + /// Whether the M6 controls should appear at all. Edits don't expose media / /// schedule / cross-post — those apply to a fresh message only. var showsSubscriberControls: Bool { @@ -213,6 +247,11 @@ final class ComposerViewModel { if showsSubscriberControls, isScheduled, scheduledAt <= Date() { return false } + // Account status / email verification / tier. Asked before the user + // clicks Post rather than discovered from a server error afterwards. + if postDenial != nil { + return false + } return true } @@ -223,6 +262,8 @@ final class ComposerViewModel { eventBus: ComposerEventBus, mode: ComposerMode = .newPost, entitlements: EntitlementsService = EntitlementsService(customerStatus: .free), + accountStatus: AccountStatus = .active, + isEmailVerified: Bool = true, readData: @escaping @Sendable (URL) async throws -> Data = { try Data(contentsOf: $0) }, onSubscriberLapse: (@MainActor () async -> Void)? = nil, userService: UserServicing? = nil, @@ -234,6 +275,11 @@ final class ComposerViewModel { self.eventBus = eventBus self.mode = mode self.entitlements = entitlements + self.capabilities = CapabilityGate( + accountStatus: accountStatus, + entitlements: entitlements, + isEmailVerified: isEmailVerified + ) self.readData = readData self.onSubscriberLapse = onSubscriberLapse self.userService = userService @@ -274,8 +320,16 @@ final class ComposerViewModel { /// non-subscribers (the affordance is disabled in the view, but this is /// defence-in-depth so a programmatic add can't bypass the gate's intent). func addAttachments(urls: [URL]) { - guard canUseSubscriberFeatures else { - error = MessagesError.subscriberRequired(.mediaAttachments) + if let denial = attachmentDenial { + // A tier denial keeps surfacing as `MessagesError.subscriberRequired` + // — the type the rest of the app already treats as "subscription + // lapse". Status and verification denials are a different problem + // with a different remedy, so they carry the denial itself. + if case .subscriberRequired(let feature) = denial { + error = MessagesError.subscriberRequired(feature) + } else { + error = ComposerError.blocked(denial) + } return } var rejected = false @@ -522,10 +576,16 @@ enum ComposerError: Error, LocalizedError, Equatable { /// A picked / dropped file isn't a supported image or video type. case unsupportedAttachment + /// The account may not perform this action — because of its status or an + /// unverified email, rather than its subscription tier (GitHub #41 / #42). + case blocked(CapabilityDenial) + var errorDescription: String? { switch self { case .unsupportedAttachment: return "That file isn't a supported image or video." + case .blocked(let denial): + return denial.message } } } diff --git a/App/Features/Compose/ComposerWindowView.swift b/App/Features/Compose/ComposerWindowView.swift index fda629b..3076668 100644 --- a/App/Features/Compose/ComposerWindowView.swift +++ b/App/Features/Compose/ComposerWindowView.swift @@ -70,6 +70,11 @@ struct ComposerWindowView: View { // B): a subscriber sees the M6 controls enabled, a free / // signed-out account sees them disabled with an upsell. entitlements: environment.liveEntitlements, + // GitHub #42 / #41 — the other two reasons a post can be + // refused. Passed alongside the tier so the composer can + // say *which* one applies before the user writes anything. + accountStatus: environment.currentUserStore.currentUser?.accountStatus ?? .active, + isEmailVerified: environment.currentUserStore.currentUser?.isEmailVerified ?? true, // PLAN.md §8 — a gated 403 mid-flow re-fetches the // customerStatus so the composer re-gates. onSubscriberLapse: { await environment.refreshEntitlements() }, @@ -604,6 +609,44 @@ struct ComposerWindowView: View { @ViewBuilder private func footer(viewModel: ComposerViewModel) -> some View { + VStack(alignment: .leading, spacing: 8) { + // Why Post is disabled, said before the user clicks it (GitHub + // #41 / #42). Inline and non-modal on purpose: the draft keeps its + // text, and the user can still type, copy, and save it elsewhere. + if let message = viewModel.postBlockedMessage { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Image(systemName: "exclamationmark.circle") + .foregroundStyle(.secondary) + .accessibilityHidden(true) + Text(message) + .font(.ilSubtitle()) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + if let destination = viewModel.postBlockedRemedy?.webDestination { + Link(remedyLabel(for: viewModel.postBlockedRemedy), destination: destination.url()) + .font(.ilSubtitle()) + } + Spacer(minLength: 0) + } + .accessibilityElement(children: .combine) + } + + footerControls(viewModel: viewModel) + } + } + + /// The label for the remedy link beside a blocked-post explanation. + private func remedyLabel(for remedy: CapabilityRemedy?) -> String { + switch remedy { + case .verifyEmail: return "Verify email" + case .contactSupport: return "Contact support" + case .upgrade: return "Manage subscription" + case .noneAvailable, nil: return "" + } + } + + @ViewBuilder + private func footerControls(viewModel: ComposerViewModel) -> some View { HStack { Button("Cancel", role: .cancel) { dismiss() diff --git a/App/Features/Settings/AccountSettingsView.swift b/App/Features/Settings/AccountSettingsView.swift index 5b2afdf..85d248e 100644 --- a/App/Features/Settings/AccountSettingsView.swift +++ b/App/Features/Settings/AccountSettingsView.swift @@ -118,6 +118,46 @@ struct AccountSettingsView: View { } } + // Resend verification (GitHub #41). `POST /api/auth/ + // send-verification-email` is `x-auth-type: session` and rejects + // this client's Bearer token, so the action opens the web page + // that can actually do it rather than shipping a button that + // silently 401s. The server owns the 10-minute rate limit; the + // copy states it so the user is not left guessing. + if vm.currentUser?.isEmailVerified == false { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text("Verify your email before posting messages or attaching media.") + .font(.ilBody()) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 8) + Link( + "Resend verification email", + destination: AccountWebDestination.settings.url() + ) + .font(.ilBody()) + } + + Text("You can resend once every \(Int(EmailVerificationResend.cooldown / 60)) minutes.") + .font(.ilMono(9)) + .foregroundStyle(.secondary) + } + + // Account status (GitHub #42) — the third reason an action can + // be refused, shown here as well as in the timeline banner so + // Settings is a complete picture of the account. + if let status = vm.currentUser?.accountStatus, status.warrantsBanner { + HStack { + Text("Account status") + .font(.ilBody()) + .foregroundStyle(.secondary) + Spacer() + Text(status.rawValue.capitalized) + .font(.ilMono(9)) + .foregroundStyle(status.isWriteRestricted ? ILColor.amber : ILColor.primary) + } + } + TextField("New email address", text: $vm.newEmail) .font(.ilBody()) .textFieldStyle(.roundedBorder) diff --git a/App/Features/Timeline/TimelineRootView.swift b/App/Features/Timeline/TimelineRootView.swift index 83978c3..3355e47 100644 --- a/App/Features/Timeline/TimelineRootView.swift +++ b/App/Features/Timeline/TimelineRootView.swift @@ -219,6 +219,15 @@ struct TimelineRootView: View { @ViewBuilder private func timelineBody(viewModel: TimelineViewModel) -> some View { VStack(spacing: 0) { + // Account-status banner (GitHub #42). Renders nothing for an + // `active` — or unrecognised — status, so it costs no space in the + // common case. + if let user = environment?.currentUserStore.currentUser { + AccountStatusBanner( + status: user.accountStatus, + isEmailVerified: user.isEmailVerified + ) + } toolbar(viewModel: viewModel) // Trending tags (work-consolidation.md G20). Hides itself when the // list is empty or unavailable, so it costs no space by default. diff --git a/AppTests/ComposerCapabilityGateTests.swift b/AppTests/ComposerCapabilityGateTests.swift new file mode 100644 index 0000000..7cb09f9 --- /dev/null +++ b/AppTests/ComposerCapabilityGateTests.swift @@ -0,0 +1,174 @@ +// ComposerCapabilityGateTests +// +// The composer half of the "know why the server will say no" cluster +// (GitHub #40 / #41 / #42): the Post button and the media affordance must +// reflect account status, email verification, and subscription tier *before* +// the user does the work, not after a server error. +// +// View-model only — no SwiftUI rendering. Kept in its own file so the large +// existing `ComposerViewModelTests` suite stays untouched. + +import XCTest +import InterlinedDomain +@testable import InterlinedList + +@MainActor +final class ComposerCapabilityGateTests: XCTestCase { + + // MARK: - Happy path + + func test_givenActiveVerifiedSubscriber_whenDrafting_thenPublishableWithNoExplanation() { + // Given + let viewModel = makeComposer(status: .active, tier: .subscriber, verified: true) + viewModel.body = "hello" + + // Then + XCTAssertTrue(viewModel.isPublishable) + XCTAssertNil(viewModel.postDenial) + XCTAssertNil(viewModel.postBlockedMessage) + } + + func test_givenActiveVerifiedFreeAccount_whenDraftingPlainText_thenStillPublishable() { + // Posting is free on every tier — the gate must not paywall a plain post. + let viewModel = makeComposer(status: .active, tier: .free, verified: true) + viewModel.body = "hello" + + XCTAssertTrue(viewModel.isPublishable) + XCTAssertNil(viewModel.postBlockedMessage) + } + + // MARK: - Invalid state: unverified email + + func test_givenUnverifiedEmail_whenDrafting_thenPostBlockedAndDraftPreserved() async { + // Given — the exact scenario in #41: the user writes a whole message + // before finding out. + let stub = StubMessagesService() + let viewModel = makeComposer(status: .active, tier: .subscriber, verified: false, messages: stub) + viewModel.body = "a draft worth keeping" + + // Then — Post is refused up front, with an actionable reason … + XCTAssertFalse(viewModel.isPublishable) + XCTAssertEqual(viewModel.postDenial, .emailUnverified) + XCTAssertEqual(viewModel.postBlockedRemedy, .verifyEmail) + XCTAssertNotNil(viewModel.postBlockedMessage) + + // … and the draft survives: typing is never blocked. + XCTAssertEqual(viewModel.body, "a draft worth keeping") + + // And nothing was sent. + let recorded = await stub.recorded + XCTAssertTrue(recorded.isEmpty) + } + + func test_givenUnverifiedEmail_whenAddingAttachment_thenBlockedWithVerificationReason() async { + // Given + let stub = StubMessagesService() + let viewModel = makeComposer(status: .active, tier: .subscriber, verified: false, messages: stub) + + // When + viewModel.addAttachments(urls: [URL(fileURLWithPath: "/tmp/test.png")]) + + // Then — the reason is verification, not a subscription upsell: this + // user is already paying, so an Upgrade prompt would be wrong. + XCTAssertTrue(viewModel.attachments.isEmpty) + XCTAssertEqual(viewModel.error as? ComposerError, .blocked(.emailUnverified)) + let recorded = await stub.recorded + XCTAssertTrue(recorded.isEmpty) + } + + // MARK: - Upstream / account-state failure: restricted account + + func test_givenRestrictedSubscriber_whenDrafting_thenBlockedForReviewNotForPayment() async { + // Given — a paying account under review. Status outranks tier. + let stub = StubMessagesService() + let viewModel = makeComposer(status: .restricted, tier: .subscriber, verified: true, messages: stub) + viewModel.body = "hello" + + // Then + XCTAssertFalse(viewModel.isPublishable) + XCTAssertEqual(viewModel.postDenial, .accountReadOnly(.restricted)) + XCTAssertEqual(viewModel.postBlockedRemedy, .contactSupport) + + let recorded = await stub.recorded + XCTAssertTrue(recorded.isEmpty) + } + + func test_givenSuspendedAccount_whenAddingAttachment_thenBlockedWithReadOnlyReason() { + let viewModel = makeComposer(status: .suspended, tier: .subscriber, verified: true) + + viewModel.addAttachments(urls: [URL(fileURLWithPath: "/tmp/test.png")]) + + XCTAssertTrue(viewModel.attachments.isEmpty) + XCTAssertEqual(viewModel.error as? ComposerError, .blocked(.accountReadOnly(.suspended))) + } + + /// Fail-open, end to end through the view model: an unrecognised status + /// must leave the composer exactly as permissive as an active one. + func test_givenUnknownAccountStatus_whenDrafting_thenNothingIsBlocked() { + let viewModel = makeComposer(status: .unknown("quarantined"), tier: .subscriber, verified: true) + viewModel.body = "hello" + + XCTAssertTrue(viewModel.isPublishable) + XCTAssertNil(viewModel.postBlockedMessage) + } + + // MARK: - Boundary: the `new` account + + /// The nuance that makes #42 worth modelling: a new account *can* post + /// (rate-limited) but *cannot* attach media. Disabling Post here would be + /// as wrong as leaving media enabled. + func test_givenNewVerifiedAccount_whenDrafting_thenCanPostButCannotAttachMedia() { + // Given + let viewModel = makeComposer(status: .new, tier: .subscriber, verified: true) + viewModel.body = "my first post" + + // Then — posting stays available … + XCTAssertTrue(viewModel.isPublishable) + XCTAssertNil(viewModel.postBlockedMessage) + + // … while the documented locked set is refused. + viewModel.addAttachments(urls: [URL(fileURLWithPath: "/tmp/test.png")]) + XCTAssertTrue(viewModel.attachments.isEmpty) + XCTAssertEqual(viewModel.error as? ComposerError, .blocked(.newAccountLocked)) + } + + /// A free account is still told about the tier, in the type the rest of the + /// app already treats as a subscription lapse. + func test_givenFreeAccount_whenAddingAttachment_thenSurfacesSubscriberRequired() { + let viewModel = makeComposer(status: .active, tier: .free, verified: true) + + viewModel.addAttachments(urls: [URL(fileURLWithPath: "/tmp/test.png")]) + + XCTAssertTrue(viewModel.attachments.isEmpty) + XCTAssertEqual(viewModel.error as? MessagesError, .subscriberRequired(.mediaAttachments)) + } + + /// Empty boundary: a blocked account with an empty body is unpublishable + /// for both reasons, and must not crash or report a confusing one. + func test_givenBlockedAccountAndEmptyBody_whenAskingPublishable_thenFalse() { + let viewModel = makeComposer(status: .restricted, tier: .free, verified: false) + viewModel.body = " " + + XCTAssertFalse(viewModel.isPublishable) + // Status is the hardest gate, so it is the reason reported. + XCTAssertEqual(viewModel.postDenial, .accountReadOnly(.restricted)) + } + + // MARK: - Helpers + + private func makeComposer( + status: AccountStatus, + tier: CustomerStatus, + verified: Bool, + messages: StubMessagesService = StubMessagesService() + ) -> ComposerViewModel { + ComposerViewModel( + messages: messages, + eventBus: ComposerEventBus(), + mode: .newPost, + entitlements: EntitlementsService(customerStatus: tier), + accountStatus: status, + isEmailVerified: verified + ) + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/AccountStatus.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/AccountStatus.swift new file mode 100644 index 0000000..0e35d72 --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/AccountStatus.swift @@ -0,0 +1,97 @@ +import Foundation + +/// A typed reading of the API's `accountStatus` string (GitHub #42). +/// +/// Every InterlinedList account carries a status that decides what it may do, +/// **independently of subscription tier and of email verification**. It arrives +/// on `GET /api/user` (verified live 2026-09-09: `"accountStatus":"active"`). +/// +/// The wire field is an open `String` — `GET /api/openapi.json` declares the +/// `User.accountStatus` property as a bare `{"type":"string"}` with **no enum**, +/// so the server is free to introduce values this client has never seen. The +/// closed set below is documented at `/help/account`, not by the schema: +/// +/// | case | what it means | +/// |---|---| +/// | `.new` | On probation. Read/browse/follow/block/mute/report work. Plain posting is *rate-limited*, not blocked. DMs, media upload, cross-posting, scheduled posts, and creating lists/documents/organizations are locked. Verifying email is the fastest way off it. | +/// | `.active` | Normal. Everything works, subject only to subscription tier. | +/// | `.restricted` | Temporarily read-only while under review. No posting, replying, reacting, following, messaging, or creating. Appealable. | +/// | `.suspended` | As `.restricted`, applied by the team. Appealable. | +/// | `.banned` | Closed; cannot sign in — so it is a sign-in failure path, never a banner state. | +/// +/// **Unknown values fail open.** `.unknown` is deliberately treated as `.active` +/// by ``AccountStatus/isWriteRestricted`` and friends: a server-side rename of a +/// status string must never brick a paying user's app by silently disabling +/// every write. The raw value is preserved for display and telemetry. +public enum AccountStatus: Sendable, Equatable, Hashable { + + /// A brand-new account on probation — the state every account starts in. + case new + /// The normal state. + case active + /// Temporarily read-only while under review. + case restricted + /// Read-only, applied by the team. + case suspended + /// Closed; cannot sign in. + case banned + /// A status string this client does not recognise. **Behaves as `.active`.** + case unknown(String) + + /// Maps the raw wire string to a case, case-insensitively. + /// + /// A `nil` or blank field — an older server, or a payload that simply omits + /// it — maps to `.active`, matching the fail-open rule: absence of evidence + /// that the account is limited is not evidence that it is. + public init(raw: String?) { + guard let raw, !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + self = .active + return + } + switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "new": self = .new + case "active": self = .active + case "restricted": self = .restricted + case "suspended": self = .suspended + case "banned": self = .banned + default: self = .unknown(raw) + } + } + + /// The original wire value, for display or round-tripping. + public var rawValue: String { + switch self { + case .new: return "new" + case .active: return "active" + case .restricted: return "restricted" + case .suspended: return "suspended" + case .banned: return "banned" + case .unknown(let raw): return raw + } + } + + /// Whether the account is fully read-only: it may browse but may not post, + /// reply, react, follow, message, or create anything. + /// + /// `.unknown` is **not** read-only (fail open). + public var isWriteRestricted: Bool { + switch self { + case .restricted, .suspended: return true + case .new, .active, .banned, .unknown: return false + } + } + + /// Whether the account is on new-account probation, where a *subset* of + /// actions is locked but plain posting still works (rate-limited). + public var isOnProbation: Bool { self == .new } + + /// Whether this status warrants the home-timeline status banner. `.active` + /// and `.unknown` are silent; `.banned` cannot sign in at all, so it is a + /// sign-in failure path rather than a banner state. + public var warrantsBanner: Bool { + switch self { + case .new, .restricted, .suspended: return true + case .active, .banned, .unknown: return false + } + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/CurrentUser.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/CurrentUser.swift index f01a55b..a938b1c 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/CurrentUser.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/CurrentUser.swift @@ -54,6 +54,11 @@ public struct CurrentUser: Sendable, Equatable, Identifiable { public let summary: UserSummary public let email: String public let customerStatus: CustomerStatus + /// The account's lifecycle status, which gates behaviour independently of + /// `customerStatus` and of `isEmailVerified` — a subscriber who is + /// `.restricted` still cannot post. Ask ``CapabilityGate`` rather than + /// reading this directly at a call site. + public let accountStatus: AccountStatus public let isEmailVerified: Bool public let isPrivateAccount: Bool /// The account's "new posts are public by default" preference. Carried here @@ -79,6 +84,7 @@ public struct CurrentUser: Sendable, Equatable, Identifiable { summary: UserSummary, email: String, customerStatus: CustomerStatus, + accountStatus: AccountStatus = .active, isEmailVerified: Bool, isPrivateAccount: Bool, defaultPubliclyVisible: Bool = true, @@ -87,6 +93,7 @@ public struct CurrentUser: Sendable, Equatable, Identifiable { self.summary = summary self.email = email self.customerStatus = customerStatus + self.accountStatus = accountStatus self.isEmailVerified = isEmailVerified self.isPrivateAccount = isPrivateAccount self.defaultPubliclyVisible = defaultPubliclyVisible diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/EmailVerification.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/EmailVerification.swift new file mode 100644 index 0000000..a4ccafa --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/EmailVerification.swift @@ -0,0 +1,117 @@ +import Foundation + +/// The resend-verification-email affordance (GitHub #41 / work-consolidation.md G38). +/// +/// ## Why this deep-links instead of calling the API +/// +/// `POST /api/auth/send-verification-email` is declared `x-auth-type: session` +/// in the live OpenAPI document (confirmed 2026-09-09; it is one of 45 +/// session-only operations, against 187 `sync-token` ones). This client +/// authenticates with a Bearer sync-token and therefore **cannot** call it — +/// shipping a button that silently 401s is exactly what issue #41 rules out. +/// +/// So the resend action opens the web Settings ▸ Security page, where the user +/// already has a session. If the route is ever opened to Bearer, replace +/// ``resendURL(baseURL:)`` with a real kit request builder; the cooldown model +/// below is unaffected either way. +public struct EmailVerificationResend: Sendable, Equatable { + + /// The documented rate limit: *"You can resend once every 10 minutes."* + public static let cooldown: TimeInterval = 600 + + /// The default web origin for the deep link. + public static let defaultWebBaseURL = URL(string: "https://interlinedlist.com")! + + /// When the user last triggered a resend from this client, or `nil` if they + /// have not during this install. + /// + /// **Advisory only.** The server owns the real limit and counts resends the + /// user triggered on the web too, so a local `nil` does not prove a resend + /// will be accepted. The value exists so the UI can avoid *obviously* + /// wasted trips and tell the user when to come back. + public let lastResendAt: Date? + + public init(lastResendAt: Date? = nil) { + self.lastResendAt = lastResendAt + } + + /// Seconds still to wait before another resend is worth attempting. + /// + /// Clamped at zero, so a `lastResendAt` in the future — a clock change, or + /// a value restored from a device whose clock has since moved back — yields + /// a finite wait rather than a negative one or a crash. + public func remainingCooldown(now: Date = Date()) -> TimeInterval { + guard let lastResendAt else { return 0 } + let elapsed = now.timeIntervalSince(lastResendAt) + guard elapsed.isFinite else { return 0 } + return max(0, min(Self.cooldown, Self.cooldown - elapsed)) + } + + /// Whether a resend is worth attempting now. + public func isAvailable(now: Date = Date()) -> Bool { + remainingCooldown(now: now) == 0 + } + + /// When the next resend becomes available, or `nil` if it already is. + public func availableAt(now: Date = Date()) -> Date? { + let remaining = remainingCooldown(now: now) + return remaining == 0 ? nil : now.addingTimeInterval(remaining) + } + + /// The web page that can actually perform the resend. + public func resendURL(baseURL: URL = defaultWebBaseURL) -> URL { + baseURL.appendingPathComponent("settings") + } + + /// Returns a copy stamped with a resend at `now`. + public func recordingResend(at now: Date = Date()) -> EmailVerificationResend { + EmailVerificationResend(lastResendAt: now) + } +} + +/// The web pages this client hands the user off to when a remedy cannot be +/// performed natively (GitHub #40 / #41 / #42). +/// +/// Verified live 2026-09-09: `/support` and `/help` answer 200; `/settings` +/// answers 200 for a signed-in browser and redirects anonymous visitors to +/// `/login`. `/contact` does **not** exist (404) — do not link to it. +public enum AccountWebDestination: Sendable, Equatable, Hashable, CaseIterable { + + /// Settings, where "Resend verification email" and the subscription + /// controls live. The resend route is session-only, so this is the only + /// way a Bearer client can offer it. + case settings + + /// The appeal path for a restricted or suspended account. + case support + + /// The help centre. + case help + + public var path: String { + switch self { + case .settings: return "settings" + case .support: return "support" + case .help: return "help" + } + } + + public func url(baseURL: URL = EmailVerificationResend.defaultWebBaseURL) -> URL { + baseURL.appendingPathComponent(path) + } +} + +public extension CapabilityRemedy { + /// The page that resolves this remedy, or `nil` when there is nothing the + /// user can do (a closed account). + /// + /// Subscription is managed on the web — there is no native purchase path. + var webDestination: AccountWebDestination? { + switch self { + case .verifyEmail: return .settings + case .contactSupport: return .support + case .upgrade: return .settings + case .noneAvailable: return nil + } + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Mappers.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Mappers.swift index 09279e4..c5f229d 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Mappers.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Mappers.swift @@ -36,6 +36,9 @@ extension CurrentUser { ), email: dto.email, customerStatus: CustomerStatus(raw: dto.customerStatus), + // Absent or unrecognised status maps to `.active` — see the + // fail-open rule on `AccountStatus`. + accountStatus: AccountStatus(raw: dto.accountStatus), isEmailVerified: dto.emailVerified, isPrivateAccount: dto.isPrivateAccount ?? false, // Absent field falls back to `true`, matching `UserSettings.default` diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/CapabilityGate.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/CapabilityGate.swift new file mode 100644 index 0000000..6e3bc8b --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/CapabilityGate.swift @@ -0,0 +1,341 @@ +import Foundation + +// MARK: - GatedAction + +/// Something the account might not be allowed to do right now (GitHub #40, #41, +/// #42). +/// +/// This is the single currency the UI and the domain services ask about, so a +/// call site poses **one** question — "may I do this, and if not why?" — instead +/// of separately interrogating account status, email verification, and +/// subscription tier and then trying to reconcile three answers. +/// +/// Ask it through ``CapabilityGate``. +public enum GatedAction: Sendable, Equatable, Hashable, CaseIterable { + + // MARK: Posting and social + + /// Publishing a message. + case postMessage + /// Replying to a message. A reply is a message, so it carries the same + /// verification requirement as ``postMessage``. + case replyToMessage + /// Digging / pushing a message. + case reactToMessage + /// Following another account. + case followUser + + // MARK: Direct messages + + /// Sending a direct message. + case directMessages + /// Attaching a photo to a direct message. + case directMessageImages + + // MARK: Composer extras + + /// Attaching images or video to a message. + case mediaAttachments + /// Cross-posting to Mastodon / Bluesky / LinkedIn / X. + case crossPosting + /// Scheduling a message for later. + case scheduledPosts + + // MARK: Creation + + case listCreation + case listFolderCreation + case documentCreation + case documentTemplateCreation + case organizationCreation + + // MARK: Sharing + + case sharingWithPeople + case emailInvites + case shareLinkCreation + + // MARK: AI + + case aiFeatures + + /// The subscriber dimension of this action, if it has one. `nil` means the + /// action is free on every tier (posting, replying, reacting, following, + /// and sending a plain DM all are). + public var requiredFeature: Feature? { + switch self { + case .postMessage, .replyToMessage, .reactToMessage, .followUser, + .directMessages, .directMessageImages: + return nil + case .mediaAttachments: return .mediaAttachments + case .crossPosting: return .crossPosting + case .scheduledPosts: return .scheduledPosts + case .listCreation: return .listCreation + case .listFolderCreation: return .listFolderCreation + case .documentCreation: return .documentCreation + case .documentTemplateCreation: return .documentTemplateCreation + case .organizationCreation: return .organizationCreation + case .sharingWithPeople: return .sharingWithPeople + case .emailInvites: return .emailInvites + case .shareLinkCreation: return .shareLinkCreation + case .aiFeatures: return .aiFeatures + } + } + + /// Whether a read-only account (`.restricted` / `.suspended`) is blocked + /// from this action. + /// + /// `/help/account`: a read-only account *"can't post, reply, react, follow, + /// send messages, or create content"*. Sharing an item that already exists, + /// and using AI, are not in that list, so they stay available — the gate + /// blocks exactly what the documentation blocks and no more. + public var isBlockedWhileReadOnly: Bool { + switch self { + case .postMessage, .replyToMessage, .reactToMessage, .followUser, + .directMessages, .directMessageImages, + .mediaAttachments, .crossPosting, .scheduledPosts, + .listCreation, .listFolderCreation, + .documentCreation, .documentTemplateCreation, + .organizationCreation: + return true + case .sharingWithPeople, .emailInvites, .shareLinkCreation, .aiFeatures: + return false + } + } + + /// Whether a `.new` account on probation is locked out of this action. + /// + /// `/help/account` names the locked set exactly: *"direct messages, image + /// and video uploads, cross-posting, scheduled posts, and creating lists, + /// documents, and organizations"*. Plain posting is deliberately **not** in + /// it — it is rate-limited, not blocked, and the client must not disable it. + /// + /// List *folders* and document *templates* are read as sub-kinds of + /// "creating lists" and "creating documents"; the help text does not call + /// them out separately. + public var isLockedOnProbation: Bool { + switch self { + case .directMessages, .directMessageImages, + .mediaAttachments, .crossPosting, .scheduledPosts, + .listCreation, .listFolderCreation, + .documentCreation, .documentTemplateCreation, + .organizationCreation: + return true + case .postMessage, .replyToMessage, .reactToMessage, .followUser, + .sharingWithPeople, .emailInvites, .shareLinkCreation, .aiFeatures: + return false + } + } + + /// Whether this action requires a verified email address. + /// + /// `/help/settings`: *"You must verify your email before posting messages or + /// attaching media."* `/help/direct-messages`: *"You'll need a verified + /// email address to send images."* Sending a text-only DM is not listed. + public var requiresVerifiedEmail: Bool { + switch self { + case .postMessage, .replyToMessage, .mediaAttachments, .directMessageImages: + return true + case .reactToMessage, .followUser, .directMessages, + .crossPosting, .scheduledPosts, + .listCreation, .listFolderCreation, + .documentCreation, .documentTemplateCreation, + .organizationCreation, + .sharingWithPeople, .emailInvites, .shareLinkCreation, .aiFeatures: + return false + } + } +} + +// MARK: - CapabilityDenial + +/// Why an action is unavailable, in the precedence order the gate applies. +/// +/// The order matters: an account can fail more than one check at once, and the +/// UI must show the reason the user can actually act on. A `.restricted` +/// subscriber is told they are under review, not that they need to upgrade. +public enum CapabilityDenial: Sendable, Equatable, Hashable { + + /// The account is closed. Reachable only in theory — a banned account + /// cannot sign in — but modelled so the switch is total. + case accountBanned + + /// The account is temporarily read-only. Carries the status so the UI can + /// distinguish "under review" (`.restricted`) from "actioned by the team" + /// (`.suspended`); both are appealable via support. + case accountReadOnly(AccountStatus) + + /// The account is new and this action is locked until it is reviewed or the + /// email is verified. + case newAccountLocked + + /// The account's email address is not verified yet. + case emailUnverified + + /// The action needs an active subscription. Carries the feature so the + /// upgrade prompt can name it. + case subscriberRequired(Feature) +} + +/// What the user can actually do about a denial. +/// +/// The gate names the remedy; the App layer decides how to present it (a +/// deep link, a sheet, a disabled control with a tooltip). Keeping the choice +/// here means the banner, the composer, and Settings cannot drift into offering +/// three different next steps for the same underlying block. +public enum CapabilityRemedy: Sendable, Equatable, Hashable { + /// Verify the email address. Also the documented fastest way off `.new`. + case verifyEmail + /// Appeal to support — the documented path for `.restricted` / `.suspended`. + case contactSupport + /// Subscribe, to unlock a named feature. + case upgrade(Feature) + /// Nothing the user can do (a banned account, or simply waiting out + /// probation review). + /// + /// Deliberately *not* spelled `none`: an enum case by that name collides + /// with `Optional.none` at every `CapabilityRemedy?` switch site. + case noneAvailable +} + +extension CapabilityDenial { + + /// The sentence shown to the user. Explains *why*, in the platform's own + /// vocabulary, without blaming them. + public var message: String { + switch self { + case .accountBanned: + return "This account is closed." + case .accountReadOnly(.suspended): + return "Your account is suspended and is read-only while it is reviewed. " + + "You can still read and browse, and you can appeal." + case .accountReadOnly: + return "Your account is temporarily read-only while it is reviewed. " + + "You can still read and browse, and you can appeal." + case .newAccountLocked: + return "This is locked while your account is new. " + + "Verifying your email is the fastest way to unlock it." + case .emailUnverified: + return "Verify your email address before posting or attaching media." + case .subscriberRequired(let feature): + return feature.upgradeMessage + } + } + + /// The next step to offer alongside ``message``. + public var remedy: CapabilityRemedy { + switch self { + case .accountBanned: return .noneAvailable + case .accountReadOnly: return .contactSupport + case .newAccountLocked: return .verifyEmail + case .emailUnverified: return .verifyEmail + case .subscriberRequired(let feature): return .upgrade(feature) + } + } +} + +// MARK: - CapabilityDecision + +/// The gate's answer: allowed, or denied with a single actionable reason. +public enum CapabilityDecision: Sendable, Equatable, Hashable { + case allowed + case denied(CapabilityDenial) + + public var isAllowed: Bool { self == .allowed } + + /// The reason, or `nil` when allowed. + public var denial: CapabilityDenial? { + guard case .denied(let reason) = self else { return nil } + return reason + } +} + +// MARK: - CapabilityGate + +/// The one place that answers "may this account do X right now, and if not +/// why?" (GitHub #40 / #41 / #42). +/// +/// Three independent mechanisms can each say no: +/// +/// 1. **Account status** — `/help/account`. The hardest gate: a paying +/// subscriber who is `.restricted` still cannot post. +/// 2. **Email verification** — `/help/settings`. Independent of both others. +/// 3. **Subscription tier** — `/help/settings`. The softest, and the only one +/// the user can fix by paying. +/// +/// Evaluating them in that order is what makes the message actionable. A new, +/// unverified, free account that tries to attach media fails all three; telling +/// it to *upgrade* would be useless advice, because verifying the email is both +/// the cheaper fix and the documented way off `.new`. +/// +/// Pure value type — no I/O, no async. Rebuild it whenever `CurrentUser` +/// changes. +public struct CapabilityGate: Sendable, Equatable { + + public let accountStatus: AccountStatus + public let entitlements: EntitlementsService + public let isEmailVerified: Bool + + /// Builds the gate from the signed-in account. + /// + /// A `nil` user is signed-out: `.free`, unverified, but `.active` in status + /// so the gate never invents a restriction for someone it knows nothing + /// about. + public init(user: CurrentUser?) { + self.accountStatus = user?.accountStatus ?? .active + self.entitlements = EntitlementsService(user: user) + self.isEmailVerified = user?.isEmailVerified ?? false + } + + /// Direct construction, for tests and for composition-root wiring that + /// holds the three inputs separately. + public init( + accountStatus: AccountStatus, + entitlements: EntitlementsService, + isEmailVerified: Bool + ) { + self.accountStatus = accountStatus + self.entitlements = entitlements + self.isEmailVerified = isEmailVerified + } + + /// Evaluates `action` against all three gates, hardest first. + public func evaluate(_ action: GatedAction) -> CapabilityDecision { + // 1. Account status — the hardest gate. `.unknown` fails open here + // because `AccountStatus` reports it as neither restricted nor on + // probation, so an unrecognised server value disables nothing. + if accountStatus == .banned { + return .denied(.accountBanned) + } + if accountStatus.isWriteRestricted, action.isBlockedWhileReadOnly { + return .denied(.accountReadOnly(accountStatus)) + } + if accountStatus.isOnProbation, action.isLockedOnProbation { + return .denied(.newAccountLocked) + } + + // 2. Email verification — independent of tier, and the fix that also + // moves a `.new` account off probation fastest. + if action.requiresVerifiedEmail, !isEmailVerified { + return .denied(.emailUnverified) + } + + // 3. Subscription tier — the softest gate, and the only one an upgrade + // prompt can resolve. + if let feature = action.requiredFeature, !entitlements.isEnabled(feature) { + return .denied(.subscriberRequired(feature)) + } + + return .allowed + } + + /// Convenience boolean for call sites that only need yes/no. + public func allows(_ action: GatedAction) -> Bool { + evaluate(action).isAllowed + } + + /// The reason `action` is unavailable, or `nil` when it is available. + public func denial(for action: GatedAction) -> CapabilityDenial? { + evaluate(action).denial + } +} diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/DocumentsService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/DocumentsService.swift index 307c22a..bcbaf5f 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/DocumentsService.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/DocumentsService.swift @@ -8,6 +8,15 @@ import InterlinedKit /// domain-layer error cases the kit cannot express. public enum DocumentsError: Error, Sendable, Equatable { + /// Creating a document requires an active subscription. Raised before any + /// HTTP call so a free account sees an upgrade prompt instead of an opaque + /// server 403 (GitHub #40). + /// + /// **Creation only** — reading, editing, and deleting an existing document + /// stay free, because a lapsed subscription keeps existing documents "fully + /// usable". + case subscriberRequired(Feature) + /// The requested document id was not found. case notFound @@ -34,6 +43,8 @@ extension DocumentsError: LocalizedError, CustomStringConvertible { public var description: String { switch self { + case .subscriberRequired(let feature): + return feature.upgradeMessage case .notFound: return "Document not found." case .conflict(let localId, let serverVersion): @@ -147,6 +158,13 @@ public final class DocumentsService: DocumentsServicing { /// G14 tail). When present, `uploadImage` enforces the live `GET /api/limits` /// image ceilings; when `nil` the built-in `ImagePrep` constants apply. private let contentLimits: ContentLimitsProviding? + /// The subscriber gate consulted by `create`, and only by `create`. + /// + /// A provider rather than a stored value: this service is built once at + /// launch, but the account's tier can change mid-session (sign-in, an + /// upgrade, a 403-triggered re-fetch). Evaluating at call time keeps the + /// gate live; a snapshot would freeze a signed-out user's tier forever. + private let entitlements: @Sendable () -> EntitlementsService /// - Parameters: /// - api: networking seam (a stub in tests). @@ -167,13 +185,15 @@ public final class DocumentsService: DocumentsServicing { sync: DocumentSyncCoordinating? = nil, store: DocumentStore? = nil, decoder: JSONDecoder = JSONCoders.makeDecoder(), - contentLimits: ContentLimitsProviding? = nil + contentLimits: ContentLimitsProviding? = nil, + entitlements: @escaping @Sendable () -> EntitlementsService = { EntitlementsService(customerStatus: .subscriber) } ) { self.api = api self.sync = sync self.store = store self.decoder = decoder self.contentLimits = contentLimits + self.entitlements = entitlements } // MARK: - Documents @@ -241,6 +261,9 @@ public final class DocumentsService: DocumentsServicing { folderId: String?, isPublic: Bool ) async throws -> Document { + guard entitlements().isEnabled(.documentCreation) else { + throw DocumentsError.subscriberRequired(.documentCreation) + } let req = CreateDocumentRequest( title: title, content: body, diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/EntitlementsService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/EntitlementsService.swift index 9653da2..82610f4 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/EntitlementsService.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/EntitlementsService.swift @@ -1,35 +1,128 @@ import Foundation -/// A subscriber-gated capability (PLAN.md §1, §6 M6 — "Subscriber & orgs"). +/// A subscriber-gated capability (GitHub #40 / work-consolidation.md G37). /// -/// Seeded with the three features the plan names as subscriber-only. Free -/// features are simply absent from this enum; if every `Feature` is -/// subscriber-gated, the gating switch is exhaustive by construction. +/// The published Free/Subscriber matrix (`/help/settings` and `/help/account`, +/// re-read live 2026-09-09) gates the cases below and nothing else. Free +/// features — posting, following, browsing and reading public lists and +/// documents, joining organizations, tags, reading templates, exporting data, +/// and **accepting** any share or email invite — are simply absent from this +/// enum, so the gating switch stays exhaustive by construction: every `Feature` +/// is subscriber-only. +/// +/// ## The create-only rule — read this before adding a case +/// +/// Subscription gates **creation**, never reading, editing, or undoing: +/// +/// - *"If your subscription lapses, your existing lists, documents, and +/// organizations stay fully usable: you can still read and edit them. Only +/// creating new … is subscriber-only."* +/// - *"Adding rows to an existing list is free, even without a subscription."* +/// - Revoking a share link and removing a collaborator are free, so a +/// downgraded owner can always take access back. +/// +/// The case names encode that rule deliberately — `.listCreation`, not +/// `.lists` — so no call site can gate an edit, a row insert, a revoke, or a +/// read on one of these by accident. If you find yourself reaching for a +/// `Feature` to guard a non-create path, the gate is wrong, not the name. public enum Feature: Sendable, Equatable, Hashable, CaseIterable { - /// Media attachments on a post (PLAN.md §1 "Media attachments"). + + // MARK: Composer + + /// Attaching images or video to a message. case mediaAttachments - /// Scheduling a post for future publication (PLAN.md §1 "Scheduled posts"). + /// Scheduling a message for future publication. case scheduledPosts - /// Cross-posting to Mastodon / Bluesky / LinkedIn (PLAN.md §1 "Cross-posting"). + /// Cross-posting to Mastodon / Bluesky / LinkedIn / X. case crossPosting + + // MARK: Creation + + /// Creating a new list. Editing an existing list, and adding rows to it, + /// are free. + case listCreation + /// Creating a new list folder. + case listFolderCreation + /// Creating a new document. Editing an existing document is free. + case documentCreation + /// Creating a new document template. Reading templates is free. + case documentTemplateCreation + /// Creating a new organization. Joining one is free. + case organizationCreation + + // MARK: Sharing + + /// Sharing a list or document with a named person (adding a collaborator + /// or changing their role). Removing a collaborator is free. + case sharingWithPeople + /// Inviting someone by email who does not yet have an account. Revoking an + /// invite, and accepting one, are free. + case emailInvites + /// Creating a tokenized share link. Revoking one is free. + case shareLinkCreation + + // MARK: AI + + /// The AI surface. AI is included in the subscription — there is no + /// user-supplied API key. + case aiFeatures + + /// The user-facing sentence explaining why this feature is unavailable. + /// + /// One source of truth, so `MessagesError`, `DocumentsError`, `OrgError`, + /// and the App's upgrade prompts all say the same thing about the same + /// feature. Phrased as the *action* the user was attempting, because that + /// is what they just clicked. + public var upgradeMessage: String { + switch self { + case .mediaAttachments: + return "Attaching media requires an active subscription." + case .scheduledPosts: + return "Scheduling messages requires an active subscription." + case .crossPosting: + return "Cross-posting requires an active subscription." + case .listCreation: + return "Creating lists requires an active subscription." + case .listFolderCreation: + return "Creating list folders requires an active subscription." + case .documentCreation: + return "Creating documents requires an active subscription." + case .documentTemplateCreation: + return "Creating document templates requires an active subscription." + case .organizationCreation: + return "Creating organizations requires an active subscription." + case .sharingWithPeople: + return "Sharing with specific people requires an active subscription." + case .emailInvites: + return "Inviting people by email requires an active subscription." + case .shareLinkCreation: + return "Creating share links requires an active subscription." + case .aiFeatures: + return "AI features require an active subscription." + } + } } -/// Maps the current account's `customerStatus` to feature flags so gating is -/// "one switch, not scattered ifs" (PLAN.md §3). Pure value type — give it a +/// Maps the current account's `customerStatus` to feature flags so subscriber +/// gating is "one switch, not scattered ifs". Pure value type — give it a /// `CurrentUser` and ask; no I/O, no async. /// +/// This answers exactly one of the three questions the client must ask before a +/// write ("is this tier entitled?"). Account status and email verification are +/// the other two; ``CapabilityGate`` composes all three in the documented +/// precedence order and is what UI and services should normally consult. +/// /// When the signed-in user's subscription state changes, the App layer rebuilds /// the service from the refreshed `CurrentUser` (e.g. after a 403 triggers a -/// `customerStatus` re-fetch, per PLAN.md §8). +/// `customerStatus` re-fetch). public struct EntitlementsService: Sendable, Equatable { - /// The account these entitlements are computed for. `nil` represents a - /// signed-out / unknown user, which is treated as a non-subscriber. + /// The account these entitlements are computed for. A signed-out / unknown + /// user is treated as `.free`. private let customerStatus: CustomerStatus - /// Stored override for `canManageLists`. `nil` defers to the default - /// (permissive in M3 — see doc on the public property). Settable only - /// at construction so the value stays a true pure value type. + /// Test-only override for ``canManageLists``. `nil` defers to the real + /// subscriber-driven logic. private let listManagementOverride: Bool? public init(user: CurrentUser?) { @@ -44,11 +137,8 @@ public struct EntitlementsService: Sendable, Equatable { self.listManagementOverride = nil } - /// Construct with an explicit list-management gate. Used by tests today - /// (to exercise the `ListsError.subscriberRequired` path against the M3 - /// permissive default) and by the M6 wave when the real gate source - /// becomes known. The default factories above keep the permissive M3 - /// behaviour for everyone else. + /// Construct with an explicit list-creation gate, so a test can exercise + /// the allowed and blocked paths without standing up a whole account. public init(customerStatus: CustomerStatus, canManageLists: Bool) { self.customerStatus = customerStatus self.listManagementOverride = canManageLists @@ -60,33 +150,31 @@ public struct EntitlementsService: Sendable, Equatable { } /// Whether `feature` is available to the current account. The single switch - /// every gated call site routes through. + /// every subscriber-gated call site routes through. + /// + /// Every `Feature` is subscriber-only by construction, so this is one + /// branch today; it stays a `switch` so that adding a case with different + /// logic is a compile-time prompt rather than a silent inheritance of + /// `isSubscriber`. public func isEnabled(_ feature: Feature) -> Bool { switch feature { - case .mediaAttachments, .scheduledPosts, .crossPosting: + case .mediaAttachments, .scheduledPosts, .crossPosting, + .listCreation, .listFolderCreation, + .documentCreation, .documentTemplateCreation, + .organizationCreation, + .sharingWithPeople, .emailInvites, .shareLinkCreation, + .aiFeatures: return isSubscriber } } - /// Whether the current account may manage lists (create, edit, share, - /// delete, mutate rows, edit schemas, manage connections). - /// - /// **M3 defensive gate.** PLAN.md §6 M6 ships the real subscriber-driven - /// gating logic. Until then the M3 lists service must call through a - /// single entitlement seam so the call sites are correct on day one and - /// the M6 wave only has to flip this property's default. The default - /// behaviour is permissive — every signed-in account "may manage lists" - /// — but the call shape (and the `ListsError.subscriberRequired` error - /// path) is the production shape from M3 onward. + /// Whether the current account may **create** a list. /// - /// When M6 lands, change the default in this property's body to - /// `isSubscriber` (or to the per-feature check the upstream entitlement - /// model dictates) without touching any call site in `ListsService`. - /// Tests that need to exercise the blocked-path use - /// `init(customerStatus:canManageLists:)` to override the default. + /// Despite the historical name this is a create-only gate, equivalent to + /// `isEnabled(.listCreation)`. Reading a list, editing one, adding or + /// editing rows, managing watchers, and managing connections are all free + /// and must never be guarded by it — see the create-only rule on ``Feature``. public var canManageLists: Bool { - // Defensive default: permissive in M3. Override-aware so tests can - // exercise the blocked path without waiting for M6. - listManagementOverride ?? true + listManagementOverride ?? isEnabled(.listCreation) } } diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/ListsService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/ListsService.swift index 351ae37..4890b46 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/ListsService.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/ListsService.swift @@ -8,10 +8,15 @@ import InterlinedKit /// domain-layer error cases the kit cannot express. public enum ListsError: Error, Sendable, Equatable { - /// The current account is not entitled to manage lists. Raised when - /// `EntitlementsService.canManageLists == false`, before any HTTP call - /// is made. M3 ships this gate defensively per the M3 brief; M6 wires - /// the real subscriber check. + /// Creating a list requires an active subscription. Raised when + /// `EntitlementsService.canManageLists == false`, before any HTTP call is + /// made. + /// + /// **Creation only.** Reading a list, editing one, adding or editing rows, + /// managing watchers, and managing connections stay free — the published + /// matrix says a lapsed subscriber keeps existing lists "fully usable" and + /// that "adding rows to an existing list is free". Do not reintroduce this + /// gate on those paths (GitHub #40). case subscriberRequired /// The schema DSL returned by the API failed to parse. The raw string @@ -42,11 +47,14 @@ extension ListsError: LocalizedError, CustomStringConvertible { /// `publicRows` against `/api/users/[username]/lists*`. No auth, no /// subscriber gating. /// -/// **M3 surface (authenticated owned-list management).** `myLists` / `detail` -/// / `create` / `update` / `delete`, the schema reads/writes, row CRUD, -/// watcher management, and the connections graph. Every M3 write method -/// consults `EntitlementsService.canManageLists` before making the HTTP -/// call; on `false` it throws `ListsError.subscriberRequired`. +/// **Authenticated owned-list management.** `myLists` / `detail` / `create` / +/// `update` / `delete`, the schema reads/writes, row CRUD, watcher management, +/// and the connections graph. +/// +/// Only `create` is subscriber-gated: it consults +/// `EntitlementsService.canManageLists` before making the HTTP call and throws +/// `ListsError.subscriberRequired` on `false`. Everything else is free, because +/// the subscription gates creation and nothing else (GitHub #40). /// /// Follows the same DI shape as `MessagesServicing`: takes its /// `APIClientProtocol` and `EntitlementsService` as parameters so unit @@ -201,10 +209,11 @@ public final class ListsService: ListsServicing { /// - Parameters: /// - api: the networking seam (a stub in tests). - /// - entitlements: subscriber gate. M3 ships this defensively; M6 - /// wires the real source. Defaults to a permissive (`free`-status) - /// instance because `canManageLists` is currently permissive by - /// decision (see `EntitlementsService.canManageLists`). + /// - entitlements: the subscriber gate consulted by `create`. The + /// default is deliberately permissive: an un-injected gate is a + /// composition-root wiring defect, and the server remains the real + /// authority, so failing open here beats locking a paying user out. + /// `AppEnvironment` injects the signed-in account's entitlements. /// - store: optional lists cache port. When `nil`, the service fetches /// live with no caching (the default keeps existing `ListsService(api:)` /// call sites source-compatible). @@ -212,7 +221,7 @@ public final class ListsService: ListsServicing { /// `JSONCoders` decoder so dates parse identically to the client. public init( api: APIClientProtocol, - entitlements: EntitlementsService = EntitlementsService(customerStatus: .free), + entitlements: EntitlementsService = EntitlementsService(customerStatus: .subscriber), store: ListsStore? = nil, decoder: JSONDecoder = JSONCoders.makeDecoder() ) { @@ -279,7 +288,6 @@ public final class ListsService: ListsServicing { // MARK: - M3 owned list CRUD public func myLists(limit: Int, offset: Int) async throws -> OwnedListsPage { - try requireListManagement() do { let request = Lists.list(limit: limit, offset: offset) let (data, _) = try await api.sendRaw(request) @@ -317,7 +325,6 @@ public final class ListsService: ListsServicing { } public func detail(listId: String) async throws -> OwnedList { - try requireListManagement() let dto = try await api.send(Lists.get(id: listId)) return OwnedList(from: dto) } @@ -348,7 +355,6 @@ public final class ListsService: ListsServicing { isPublic: Bool?, parentId: String? ) async throws -> OwnedList { - try requireListManagement() let request = UpdateListRequest( title: title, description: description, @@ -360,20 +366,17 @@ public final class ListsService: ListsServicing { } public func delete(listId: String) async throws { - try requireListManagement() try await api.sendVoid(Lists.delete(id: listId)) } // MARK: - M3 schema public func schema(of listId: String) async throws -> ListSchema { - try requireListManagement() let dto = try await api.send(Lists.schema(id: listId)) return try parseSchema(dto.schema) } public func updateSchema(of listId: String, schema: ListSchema) async throws -> ListSchema { - try requireListManagement() let dsl = SchemaDSL.serialize(schema) let request = UpdateListSchemaRequest(schema: dsl) let dto = try await api.send(Lists.updateSchema(id: listId, request)) @@ -383,7 +386,6 @@ public final class ListsService: ListsServicing { // MARK: - M3 refresh public func refresh(listId: String) async throws -> OwnedList { - try requireListManagement() let dto = try await api.send(Lists.refresh(id: listId)) return OwnedList(from: dto) } @@ -391,7 +393,6 @@ public final class ListsService: ListsServicing { // MARK: - M3 row CRUD public func rows(of listId: String, limit: Int, offset: Int) async throws -> RowsPage { - try requireListManagement() let request = Lists.rows(listId: listId, limit: limit, offset: offset) let (data, _) = try await api.sendRaw(request) let key = request.paginationKey ?? "data" @@ -405,14 +406,12 @@ public final class ListsService: ListsServicing { } public func row(listId: String, rowId: String) async throws -> ListRow { - try requireListManagement() // The live read answers `{ data }`; unwrap it. let dto = try await api.send(Lists.row(listId: listId, rowId: rowId)).data return ListRow(from: dto) } public func createRow(listId: String, data: [String: ListCellValue]) async throws -> ListRow { - try requireListManagement() let wire = data.mapValues(ListJSONValue.init(from:)) let request = CreateListRowRequest(rowData: wire) // The live create answers `{ message, data }`; unwrap it. @@ -425,7 +424,6 @@ public final class ListsService: ListsServicing { rowId: String, data: [String: ListCellValue] ) async throws -> ListRow { - try requireListManagement() let wire = data.mapValues(ListJSONValue.init(from:)) let request = UpdateListRowRequest(rowData: wire) // The live update answers `{ message, data }`; unwrap it. @@ -434,26 +432,22 @@ public final class ListsService: ListsServicing { } public func deleteRow(listId: String, rowId: String) async throws { - try requireListManagement() try await api.sendVoid(Lists.deleteRow(listId: listId, rowId: rowId)) } // MARK: - M3 watchers public func watchers(of listId: String) async throws -> [ListWatcher] { - try requireListManagement() let dtos = try await api.send(Lists.watchers(listId: listId)) return dtos.map(ListWatcher.init(from:)) } public func myWatcherStatus(of listId: String) async throws -> WatcherStatus { - try requireListManagement() let dto = try await api.send(Lists.myWatcherStatus(listId: listId)) return WatcherStatus(from: dto) } public func watcherUsers(of listId: String) async throws -> [ListWatcher] { - try requireListManagement() let dtos = try await api.send(Lists.watcherUsers(listId: listId)) return dtos.map(ListWatcher.init(from:)) } @@ -463,21 +457,18 @@ public final class ListsService: ListsServicing { userId: String, role: WatcherRole ) async throws -> ListWatcher { - try requireListManagement() let request = UpdateListWatcherRequest(role: role.wireToken) let dto = try await api.send(Lists.setWatcher(listId: listId, userId: userId, request)) return ListWatcher(from: dto) } public func removeWatcher(listId: String, userId: String) async throws { - try requireListManagement() try await api.sendVoid(Lists.removeWatcher(listId: listId, userId: userId)) } // MARK: - M3 connections public func connections(of listId: String?) async throws -> [ListConnection] { - try requireListManagement() let response = try await api.send(Lists.connections()) let all = response.connections.map(ListConnection.init(from:)) guard let listId else { return all } @@ -489,7 +480,6 @@ public final class ListsService: ListsServicing { toListId: String, label: String? ) async throws -> ListConnection { - try requireListManagement() let request = CreateListConnectionRequest( fromListId: fromListId, toListId: toListId, @@ -500,16 +490,16 @@ public final class ListsService: ListsServicing { } public func removeConnection(connectionId: String) async throws { - try requireListManagement() try await api.sendVoid(Lists.deleteConnection(id: connectionId)) } // MARK: - Internals - /// The single entitlement gate every M3 write method routes through. - /// Throws `ListsError.subscriberRequired` when the account is not - /// entitled to manage lists. M3 ships this defensively; the actual - /// `canManageLists` body becomes restrictive in M6. + /// The subscriber gate for list **creation**, and only creation. + /// + /// Throws `ListsError.subscriberRequired` before any HTTP call when the + /// account may not create lists. Reads, edits, row CRUD, watchers, and + /// connections deliberately do not call this (GitHub #40). private func requireListManagement() throws { guard entitlements.canManageLists else { throw ListsError.subscriberRequired diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/MessagesService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/MessagesService.swift index 99c1928..a0a3c3b 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/MessagesService.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/MessagesService.swift @@ -50,14 +50,8 @@ extension MessagesError: LocalizedError, CustomStringConvertible { public var description: String { switch self { case .subscriberRequired(let feature): - switch feature { - case .mediaAttachments: - return "Attaching media requires an active subscription." - case .scheduledPosts: - return "Scheduling messages requires an active subscription." - case .crossPosting: - return "Cross-posting requires an active subscription." - } + // Shared copy — see `Feature.upgradeMessage`. + return feature.upgradeMessage case .mediaTooLarge(let byteCount, let limit): return "This file is \(byteCount) bytes, over the \(limit)-byte limit." case .editingNotSupported: diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/OrgService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/OrgService.swift index 51da730..228db6e 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/OrgService.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/OrgService.swift @@ -1,6 +1,31 @@ import Foundation import InterlinedKit +// MARK: - OrgError + +/// Domain-level errors surfaced by `OrgService`. +public enum OrgError: Error, Sendable, Equatable { + + /// Creating an organization requires an active subscription. Raised before + /// any HTTP call so a free account sees an upgrade prompt rather than an + /// opaque server 403 (GitHub #40). + /// + /// **Creation only.** Joining an organization is free on every tier, and a + /// lapsed subscriber keeps their existing organizations fully usable. + case subscriberRequired(Feature) +} + +extension OrgError: LocalizedError, CustomStringConvertible { + public var errorDescription: String? { description } + + public var description: String { + switch self { + case .subscriberRequired(let feature): + return feature.upgradeMessage + } + } +} + // MARK: - OrgServicing /// The organizations surface the App layer codes against (PLAN.md §1 @@ -78,18 +103,28 @@ public final class OrgService: OrgServicing { private let api: APIClientProtocol private let decoder: JSONDecoder + /// The subscriber gate consulted by `create`, and only by `create`. + /// A provider, not a snapshot — see `DocumentsService` for why. + private let entitlements: @Sendable () -> EntitlementsService /// - Parameters: /// - api: the networking seam (a stub in tests). /// - decoder: shared kit JSON configuration, used to split the paginated /// envelope. Defaults to the kit's `JSONCoders` decoder so dates parse /// identically to the client. + /// - entitlements: the subscriber gate for `create`. The default is + /// deliberately permissive: an un-injected gate is a composition-root + /// wiring defect, and the server remains the real authority, so failing + /// open here beats locking a paying user out. `AppEnvironment` injects + /// the signed-in account's entitlements in production. public init( api: APIClientProtocol, - decoder: JSONDecoder = JSONCoders.makeDecoder() + decoder: JSONDecoder = JSONCoders.makeDecoder(), + entitlements: @escaping @Sendable () -> EntitlementsService = { EntitlementsService(customerStatus: .subscriber) } ) { self.api = api self.decoder = decoder + self.entitlements = entitlements } // MARK: Org CRUD @@ -127,6 +162,9 @@ public final class OrgService: OrgServicing { description: String, isPublic: Bool ) async throws -> Organization { + guard entitlements().isEnabled(.organizationCreation) else { + throw OrgError.subscriberRequired(.organizationCreation) + } let body = CreateOrganizationRequest(name: name, description: description, isPublic: isPublic) // The live create answers `{ message, organization }`; unwrap it. let dto = try await api.send(Organizations.create(body)).organization diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/SharingService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/SharingService.swift index fc10052..c87aac6 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/SharingService.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/SharingService.swift @@ -90,7 +90,7 @@ public final class SharingService: SharingServicing { } public func createListShareLink(listId: String, role: ShareRole, expiresAt: Date?) async throws -> ShareLink { - guard entitlements.isSubscriber else { throw SharingError.subscriberRequired } + guard entitlements.isEnabled(.shareLinkCreation) else { throw SharingError.subscriberRequired } let dto = try await api.send( Sharing.createListShareLink(listId: listId, CreateShareLinkRequest(role: role.rawValue, expiresAt: expiresAt)) ) @@ -119,7 +119,7 @@ public final class SharingService: SharingServicing { } public func createDocumentShareLink(documentId: String, role: ShareRole, expiresAt: Date?) async throws -> ShareLink { - guard entitlements.isSubscriber else { throw SharingError.subscriberRequired } + guard entitlements.isEnabled(.shareLinkCreation) else { throw SharingError.subscriberRequired } let dto = try await api.send( Sharing.createDocumentShareLink(documentId: documentId, CreateShareLinkRequest(role: role.rawValue, expiresAt: expiresAt)) ) @@ -153,14 +153,14 @@ public final class SharingService: SharingServicing { } public func addDocumentCollaborator(documentId: String, userId: String, role: ShareRole, notify: Bool) async throws { - guard entitlements.isSubscriber else { throw SharingError.subscriberRequired } + guard entitlements.isEnabled(.sharingWithPeople) else { throw SharingError.subscriberRequired } _ = try await api.send( Sharing.addDocumentCollaborator(documentId: documentId, AddCollaboratorRequest(userId: userId, role: role.rawValue, notify: notify)) ) } public func setDocumentCollaboratorRole(documentId: String, userId: String, role: ShareRole, notify: Bool) async throws { - guard entitlements.isSubscriber else { throw SharingError.subscriberRequired } + guard entitlements.isEnabled(.sharingWithPeople) else { throw SharingError.subscriberRequired } _ = try await api.send( Sharing.setDocumentCollaboratorRole(documentId: documentId, userId: userId, SetCollaboratorRoleRequest(role: role.rawValue, notify: notify)) ) @@ -179,7 +179,7 @@ public final class SharingService: SharingServicing { } public func createDocumentInvite(documentId: String, email: String, role: ShareRole, expiresAt: Date?) async throws -> SentInvite { - guard entitlements.isSubscriber else { throw SharingError.subscriberRequired } + guard entitlements.isEnabled(.emailInvites) else { throw SharingError.subscriberRequired } let dto = try await api.send( Sharing.createDocumentInvite(documentId: documentId, CreateInviteRequest(email: email, role: role.rawValue, expiresAt: expiresAt)) ) @@ -197,7 +197,7 @@ public final class SharingService: SharingServicing { } public func createListInvite(listId: String, email: String, role: ShareRole, expiresAt: Date?) async throws -> SentInvite { - guard entitlements.isSubscriber else { throw SharingError.subscriberRequired } + guard entitlements.isEnabled(.emailInvites) else { throw SharingError.subscriberRequired } let dto = try await api.send( Sharing.createListInvite(listId: listId, CreateInviteRequest(email: email, role: role.rawValue, expiresAt: expiresAt)) ) diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/AccountStatusTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/AccountStatusTests.swift new file mode 100644 index 0000000..62f8bfa --- /dev/null +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/AccountStatusTests.swift @@ -0,0 +1,84 @@ +import XCTest +@testable import InterlinedDomain + +/// Coverage for the `accountStatus` model (GitHub #42). +/// +/// The wire field is an open string — `GET /api/openapi.json` declares it as a +/// bare `{"type":"string"}` with no enum — so the fail-open behaviour on +/// unrecognised values is the load-bearing property here, not a nicety. +final class AccountStatusTests: XCTestCase { + + // MARK: - Happy path + + func test_givenDocumentedStatusStrings_whenMapped_thenClassifiedExactly() { + XCTAssertEqual(AccountStatus(raw: "new"), .new) + XCTAssertEqual(AccountStatus(raw: "active"), .active) + XCTAssertEqual(AccountStatus(raw: "restricted"), .restricted) + XCTAssertEqual(AccountStatus(raw: "suspended"), .suspended) + XCTAssertEqual(AccountStatus(raw: "banned"), .banned) + } + + func test_givenMixedCaseAndPaddedStatus_whenMapped_thenStillClassified() { + // The live payload is lowercase, but a case or whitespace change on the + // server must not silently degrade every account to `.unknown`. + XCTAssertEqual(AccountStatus(raw: "ACTIVE"), .active) + XCTAssertEqual(AccountStatus(raw: " Restricted "), .restricted) + } + + // MARK: - Invalid / unrecognised input + + func test_givenUnrecognisedStatus_whenMapped_thenPreservedAsUnknown() { + // Given a value this client has never seen. + let status = AccountStatus(raw: "shadowbanned") + + // Then — preserved verbatim for display and telemetry. + XCTAssertEqual(status, .unknown("shadowbanned")) + XCTAssertEqual(status.rawValue, "shadowbanned") + } + + /// The rule that keeps a server-side rename from bricking a paying user: + /// an unknown status must disable nothing and show nothing. + func test_givenUnrecognisedStatus_whenAskingCapabilities_thenBehavesAsActive() { + let status = AccountStatus(raw: "some-future-state") + + XCTAssertFalse(status.isWriteRestricted) + XCTAssertFalse(status.isOnProbation) + XCTAssertFalse(status.warrantsBanner) + } + + // MARK: - Upstream failure / absent field + + func test_givenAbsentOrBlankStatus_whenMapped_thenTreatedAsActive() { + // An older server, or a payload that simply omits the field. + XCTAssertEqual(AccountStatus(raw: nil), .active) + XCTAssertEqual(AccountStatus(raw: ""), .active) + XCTAssertEqual(AccountStatus(raw: " "), .active) + } + + // MARK: - Boundary + + func test_givenEachStatus_whenAskingForBanner_thenOnlyLimitedStatusesShowOne() { + // `/help/account`: the banner explains a new or locked account. `.active` + // is silent, and `.banned` cannot sign in so it never reaches a banner. + XCTAssertTrue(AccountStatus.new.warrantsBanner) + XCTAssertTrue(AccountStatus.restricted.warrantsBanner) + XCTAssertTrue(AccountStatus.suspended.warrantsBanner) + XCTAssertFalse(AccountStatus.active.warrantsBanner) + XCTAssertFalse(AccountStatus.banned.warrantsBanner) + } + + func test_givenReadOnlyStatuses_whenAskingWriteRestriction_thenOnlyRestrictedAndSuspended() { + XCTAssertTrue(AccountStatus.restricted.isWriteRestricted) + XCTAssertTrue(AccountStatus.suspended.isWriteRestricted) + // `.new` is rate-limited, not read-only — it must stay able to post. + XCTAssertFalse(AccountStatus.new.isWriteRestricted) + XCTAssertFalse(AccountStatus.active.isWriteRestricted) + } + + func test_givenEveryStatus_whenRoundTripped_thenRawValueMapsBack() { + let statuses: [AccountStatus] = [.new, .active, .restricted, .suspended, .banned] + for status in statuses { + XCTAssertEqual(AccountStatus(raw: status.rawValue), status) + } + } +} diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/CapabilityGateTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/CapabilityGateTests.swift new file mode 100644 index 0000000..b952687 --- /dev/null +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/CapabilityGateTests.swift @@ -0,0 +1,282 @@ +import XCTest +@testable import InterlinedDomain + +/// Coverage for the composed capability gate (GitHub #40 / #41 / #42). +/// +/// The three mechanisms — account status, email verification, subscription +/// tier — are independent, and an account can fail several at once. These cases +/// pin the precedence, because the precedence is what decides whether the user +/// is told something they can act on. +final class CapabilityGateTests: XCTestCase { + + // MARK: - Happy path + + func test_givenActiveVerifiedSubscriber_whenEvaluatingEveryAction_thenAllAllowed() { + // Given — the fully unencumbered account. + let gate = makeGate(status: .active, tier: .subscriber, verified: true) + + // Then — nothing is gated, for any action. + for action in GatedAction.allCases { + XCTAssertEqual(gate.evaluate(action), .allowed, "\(action) should be allowed") + } + } + + func test_givenActiveVerifiedFreeAccount_whenPosting_thenAllowed() { + // Posting is free on every tier — the gate must not invent a paywall. + let gate = makeGate(status: .active, tier: .free, verified: true) + + XCTAssertTrue(gate.allows(.postMessage)) + XCTAssertTrue(gate.allows(.reactToMessage)) + XCTAssertTrue(gate.allows(.followUser)) + XCTAssertTrue(gate.allows(.directMessages)) + } + + // MARK: - Invalid state: restricted account + + func test_givenRestrictedAccount_whenEvaluatingWrites_thenDeniedAsReadOnlyAndReadsUnaffected() { + // Given — a *subscriber* under review, to prove status outranks tier. + let gate = makeGate(status: .restricted, tier: .subscriber, verified: true) + + // Then — the documented blocked set is denied with the read-only reason. + XCTAssertEqual(gate.evaluate(.postMessage), .denied(.accountReadOnly(.restricted))) + XCTAssertEqual(gate.evaluate(.replyToMessage), .denied(.accountReadOnly(.restricted))) + XCTAssertEqual(gate.evaluate(.reactToMessage), .denied(.accountReadOnly(.restricted))) + XCTAssertEqual(gate.evaluate(.followUser), .denied(.accountReadOnly(.restricted))) + XCTAssertEqual(gate.evaluate(.directMessages), .denied(.accountReadOnly(.restricted))) + XCTAssertEqual(gate.evaluate(.listCreation), .denied(.accountReadOnly(.restricted))) + } + + func test_givenSuspendedAccount_whenEvaluatingWrites_thenDenialCarriesTheSuspendedStatus() { + // The UI distinguishes "under review" from "actioned by the team", so + // the denial must carry which one it is. + let gate = makeGate(status: .suspended, tier: .free, verified: true) + + XCTAssertEqual(gate.evaluate(.postMessage), .denied(.accountReadOnly(.suspended))) + } + + func test_givenRestrictedSubscriber_whenPosting_thenStatusOutranksSubscription() { + // The precedence rule stated in #42: "a subscriber who is restricted + // still cannot post". Paying must not buy past a review. + let gate = makeGate(status: .restricted, tier: .subscriber, verified: true) + + XCTAssertEqual(gate.denial(for: .mediaAttachments), .accountReadOnly(.restricted)) + } + + // MARK: - Upstream failure: unknown / absent status + + /// The fail-open guarantee, end to end: a status string the client has + /// never seen must disable nothing. + func test_givenUnknownAccountStatus_whenEvaluatingEveryAction_thenNothingIsDisabledByStatus() { + let gate = makeGate(status: .unknown("quarantined"), tier: .subscriber, verified: true) + + for action in GatedAction.allCases { + XCTAssertEqual(gate.evaluate(action), .allowed, "\(action) must not be gated by an unknown status") + } + } + + func test_givenAbsentStatusFromServer_whenBuildingGate_thenTreatedAsActive() { + // A payload with no `accountStatus` at all. + let user = makeUser(accountStatus: AccountStatus(raw: nil), tier: .subscriber, verified: true) + let gate = CapabilityGate(user: user) + + XCTAssertEqual(gate.accountStatus, .active) + XCTAssertTrue(gate.allows(.postMessage)) + } + + func test_givenSignedOutUser_whenBuildingGate_thenFreeAndUnverifiedButNotRestricted() { + // Given — nobody signed in. + let gate = CapabilityGate(user: nil) + + // Then — the gate must not invent a restriction for an unknown account, + // but it also must not hand out subscriber features. + XCTAssertEqual(gate.accountStatus, .active) + XCTAssertFalse(gate.isEmailVerified) + XCTAssertEqual(gate.denial(for: .listCreation), .subscriberRequired(.listCreation)) + } + + // MARK: - Boundary: the `new` account + + /// The most nuanced case in #42: plain posting stays enabled (it is + /// rate-limited, not blocked) while a specific documented set is locked. + func test_givenNewAccount_whenEvaluatingActions_thenOnlyTheDocumentedSetIsLocked() { + // Given — verified so the email gate cannot confound the result, and a + // subscriber so the tier gate cannot either. + let gate = makeGate(status: .new, tier: .subscriber, verified: true) + + // Then — plain posting and social reads/writes stay available. + XCTAssertTrue(gate.allows(.postMessage), "Posting is rate-limited on a new account, not blocked") + XCTAssertTrue(gate.allows(.replyToMessage)) + XCTAssertTrue(gate.allows(.reactToMessage)) + XCTAssertTrue(gate.allows(.followUser)) + + // And the documented locked set is denied, with the probation reason. + let locked: [GatedAction] = [ + .directMessages, .directMessageImages, .mediaAttachments, + .crossPosting, .scheduledPosts, + .listCreation, .listFolderCreation, + .documentCreation, .documentTemplateCreation, .organizationCreation, + ] + for action in locked { + XCTAssertEqual(gate.evaluate(action), .denied(.newAccountLocked), "\(action) is locked on probation") + } + } + + func test_givenBannedAccount_whenEvaluatingAnyAction_thenDeniedAsBanned() { + // Reachable only in theory — a banned account cannot sign in — but the + // model stays total so no call site has to handle a missing case. + let gate = makeGate(status: .banned, tier: .subscriber, verified: true) + + XCTAssertEqual(gate.evaluate(.postMessage), .denied(.accountBanned)) + XCTAssertEqual(gate.evaluate(.aiFeatures), .denied(.accountBanned)) + } + + // MARK: - Email verification (GitHub #41) + + func test_givenUnverifiedEmail_whenPosting_thenDeniedForVerification() { + let gate = makeGate(status: .active, tier: .subscriber, verified: false) + + XCTAssertEqual(gate.evaluate(.postMessage), .denied(.emailUnverified)) + XCTAssertEqual(gate.evaluate(.replyToMessage), .denied(.emailUnverified)) + XCTAssertEqual(gate.evaluate(.mediaAttachments), .denied(.emailUnverified)) + XCTAssertEqual(gate.evaluate(.directMessageImages), .denied(.emailUnverified)) + } + + func test_givenUnverifiedEmail_whenDoingActionsThatDoNotRequireIt_thenAllowed() { + // Only posting and media are documented as verification-gated; the gate + // must not over-reach and lock an unverified user out of the whole app. + let gate = makeGate(status: .active, tier: .subscriber, verified: false) + + XCTAssertTrue(gate.allows(.reactToMessage)) + XCTAssertTrue(gate.allows(.followUser)) + XCTAssertTrue(gate.allows(.directMessages), "A text-only DM is not verification-gated") + XCTAssertTrue(gate.allows(.listCreation)) + } + + /// Precedence between the two "you cannot post" reasons. A new, unverified + /// account is told to verify — which is both the cheaper fix and the + /// documented fastest way off probation — not that it is on probation. + func test_givenNewUnverifiedAccount_whenPosting_thenVerificationIsTheReasonGiven() { + let gate = makeGate(status: .new, tier: .free, verified: false) + + XCTAssertEqual(gate.evaluate(.postMessage), .denied(.emailUnverified)) + } + + // MARK: - Subscription tier (GitHub #40) + + func test_givenFreeAccount_whenCreatingContent_thenDeniedWithNamedFeature() { + let gate = makeGate(status: .active, tier: .free, verified: true) + + // The denial names the feature so the upgrade prompt can be specific. + XCTAssertEqual(gate.evaluate(.listCreation), .denied(.subscriberRequired(.listCreation))) + XCTAssertEqual(gate.evaluate(.documentCreation), .denied(.subscriberRequired(.documentCreation))) + XCTAssertEqual(gate.evaluate(.organizationCreation), .denied(.subscriberRequired(.organizationCreation))) + XCTAssertEqual(gate.evaluate(.shareLinkCreation), .denied(.subscriberRequired(.shareLinkCreation))) + XCTAssertEqual(gate.evaluate(.aiFeatures), .denied(.subscriberRequired(.aiFeatures))) + } + + /// The lapsed-subscriber boundary from #40: the tier gate covers creation + /// only, so nothing a lapsed user does to *existing* content is modelled as + /// a gated action in the first place. + func test_givenLapsedSubscriber_whenActingOnExistingContent_thenNoGatedActionCoversIt() { + let gate = makeGate(status: .active, tier: .free, verified: true) + + // Creation is denied … + XCTAssertFalse(gate.allows(.listCreation)) + // … while every free action stays available. + XCTAssertTrue(gate.allows(.postMessage)) + XCTAssertTrue(gate.allows(.followUser)) + + // And no `GatedAction` exists for editing, row-adding, revoking, or + // removing access — the vocabulary itself prevents gating them. + let creationOnly = GatedAction.allCases.filter { $0.requiredFeature != nil } + for action in creationOnly { + XCTAssertFalse( + "\(action)".contains("edit") || "\(action)".contains("revoke") || "\(action)".contains("remove"), + "\(action) names a non-create operation, which must never be tier-gated" + ) + } + } + + func test_givenUnknownCustomerStatus_whenCreating_thenFailsClosedForCreation() { + // #40: an unrecognised tier must not hand out paid features. + let gate = makeGate(status: .active, tier: .other("trialing"), verified: true) + + XCTAssertEqual(gate.evaluate(.documentCreation), .denied(.subscriberRequired(.documentCreation))) + // But it must still leave the free surface alone. + XCTAssertTrue(gate.allows(.postMessage)) + } + + // MARK: - Action metadata invariants + + /// Actions with no subscriber dimension are exactly the ones the published + /// Free tier names. A drift here silently paywalls something free. + func test_givenActionsWithoutFeature_whenListed_thenTheyAreTheDocumentedFreeSet() { + let free = Set(GatedAction.allCases.filter { $0.requiredFeature == nil }) + XCTAssertEqual( + free, + [.postMessage, .replyToMessage, .reactToMessage, .followUser, .directMessages, .directMessageImages] + ) + } + + // MARK: - Denial copy and remedy + + func test_givenEveryDenial_whenAskingForCopy_thenMessageIsPresent() { + let denials: [CapabilityDenial] = [ + .accountBanned, .accountReadOnly(.restricted), .accountReadOnly(.suspended), + .newAccountLocked, .emailUnverified, .subscriberRequired(.listCreation), + ] + for denial in denials { + XCTAssertFalse(denial.message.isEmpty, "\(denial) needs user-facing copy") + } + } + + /// Each denial must point at the step that actually resolves it, so the UI + /// cannot offer "Upgrade" to someone whose real problem is a review. + func test_givenEachDenial_whenAskingRemedy_thenPointsAtTheResolvingStep() { + XCTAssertEqual(CapabilityDenial.emailUnverified.remedy, .verifyEmail) + XCTAssertEqual(CapabilityDenial.newAccountLocked.remedy, .verifyEmail) + XCTAssertEqual(CapabilityDenial.accountReadOnly(.restricted).remedy, .contactSupport) + XCTAssertEqual(CapabilityDenial.accountReadOnly(.suspended).remedy, .contactSupport) + XCTAssertEqual(CapabilityDenial.subscriberRequired(.aiFeatures).remedy, .upgrade(.aiFeatures)) + XCTAssertEqual(CapabilityDenial.accountBanned.remedy, .noneAvailable) + } + + func test_givenSuspendedVersusRestricted_whenAskingCopy_thenWordingDiffers() { + // The user is told which one applies to them; both are appealable. + XCTAssertNotEqual( + CapabilityDenial.accountReadOnly(.suspended).message, + CapabilityDenial.accountReadOnly(.restricted).message + ) + } + + /// The subscriber denial reuses the shared per-feature copy, so an upgrade + /// prompt always names the thing the user just tried to do. + func test_givenSubscriberDenial_whenAskingCopy_thenNamesTheFeature() { + XCTAssertEqual( + CapabilityDenial.subscriberRequired(.documentCreation).message, + Feature.documentCreation.upgradeMessage + ) + } + + // MARK: - Helpers + + private func makeGate(status: AccountStatus, tier: CustomerStatus, verified: Bool) -> CapabilityGate { + CapabilityGate( + accountStatus: status, + entitlements: EntitlementsService(customerStatus: tier), + isEmailVerified: verified + ) + } + + private func makeUser(accountStatus: AccountStatus, tier: CustomerStatus, verified: Bool) -> CurrentUser { + CurrentUser( + summary: UserSummary(id: "1", username: "ada", displayName: "Ada"), + email: "ada@example.com", + customerStatus: tier, + accountStatus: accountStatus, + isEmailVerified: verified, + isPrivateAccount: false, + createdAt: Date(timeIntervalSince1970: 1_700_000_000) + ) + } +} diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/EmailVerificationTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/EmailVerificationTests.swift new file mode 100644 index 0000000..e02ca5d --- /dev/null +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/EmailVerificationTests.swift @@ -0,0 +1,117 @@ +import XCTest +@testable import InterlinedDomain + +/// Coverage for the resend-verification-email affordance (GitHub #41). +/// +/// The route itself is `x-auth-type: session` and unreachable from this Bearer +/// client, so what is modelled here is the cooldown that decides whether the +/// deep-link action is offered or disabled. +final class EmailVerificationTests: XCTestCase { + + private let now = Date(timeIntervalSince1970: 1_700_000_000) + + // MARK: - Happy path + + func test_givenNoPriorResend_whenAskingAvailability_thenImmediatelyAvailable() { + // Given — a fresh install, nothing sent yet. + let resend = EmailVerificationResend() + + // Then + XCTAssertTrue(resend.isAvailable(now: now)) + XCTAssertEqual(resend.remainingCooldown(now: now), 0) + XCTAssertNil(resend.availableAt(now: now)) + } + + func test_givenResendRecorded_whenAskingImmediatelyAfter_thenFullCooldownRemains() { + // Given + let resend = EmailVerificationResend().recordingResend(at: now) + + // Then — the documented ten minutes. + XCTAssertFalse(resend.isAvailable(now: now)) + XCTAssertEqual(resend.remainingCooldown(now: now), 600) + XCTAssertEqual(resend.availableAt(now: now), now.addingTimeInterval(600)) + } + + // MARK: - Invalid input + + func test_givenResendTimestampInTheFuture_whenAskingCooldown_thenClampedNotNegative() { + // Given — a clock change, or state restored from a device whose clock + // has since moved backwards. + let resend = EmailVerificationResend(lastResendAt: now.addingTimeInterval(3_600)) + + // Then — a finite, sane wait rather than a negative or runaway value. + let remaining = resend.remainingCooldown(now: now) + XCTAssertGreaterThanOrEqual(remaining, 0) + XCTAssertLessThanOrEqual(remaining, EmailVerificationResend.cooldown) + XCTAssertFalse(resend.isAvailable(now: now)) + } + + // MARK: - Upstream failure + + /// The route is session-only, so the client must send the user to the web + /// page that has a session rather than calling an endpoint that would 401. + func test_givenResendRequested_whenBuildingAction_thenDeepLinksToWebSettings() { + let resend = EmailVerificationResend() + + let url = resend.resendURL() + + XCTAssertEqual(url.absoluteString, "https://interlinedlist.com/settings") + } + + func test_givenCustomBaseURL_whenBuildingResendURL_thenHonoursIt() { + // Staging / self-hosted origins must not be hardcoded past. + let resend = EmailVerificationResend() + + let url = resend.resendURL(baseURL: URL(string: "https://staging.example.com")!) + + XCTAssertEqual(url.absoluteString, "https://staging.example.com/settings") + } + + // MARK: - Boundary: the ten-minute edge + + func test_givenCooldownJustUnderTenMinutes_whenAskingAvailability_thenStillBlocked() { + // Given — one second short of the limit. + let resend = EmailVerificationResend(lastResendAt: now.addingTimeInterval(-599)) + + // Then + XCTAssertFalse(resend.isAvailable(now: now)) + XCTAssertEqual(resend.remainingCooldown(now: now), 1, accuracy: 0.001) + } + + func test_givenCooldownExactlyTenMinutes_whenAskingAvailability_thenAvailable() { + // Given — exactly at the boundary, which must resolve in the user's favour. + let resend = EmailVerificationResend(lastResendAt: now.addingTimeInterval(-600)) + + // Then + XCTAssertTrue(resend.isAvailable(now: now)) + XCTAssertEqual(resend.remainingCooldown(now: now), 0) + } + + func test_givenCooldownJustOverTenMinutes_whenAskingAvailability_thenAvailable() { + let resend = EmailVerificationResend(lastResendAt: now.addingTimeInterval(-601)) + + XCTAssertTrue(resend.isAvailable(now: now)) + XCTAssertNil(resend.availableAt(now: now)) + } + + // MARK: - Web destinations + + func test_givenEachDestination_whenBuildingURL_thenMatchesTheLivePaths() { + // Verified live 2026-09-09; `/contact` is deliberately absent (404). + XCTAssertEqual(AccountWebDestination.settings.url().absoluteString, "https://interlinedlist.com/settings") + XCTAssertEqual(AccountWebDestination.support.url().absoluteString, "https://interlinedlist.com/support") + XCTAssertEqual(AccountWebDestination.help.url().absoluteString, "https://interlinedlist.com/help") + } + + func test_givenEachRemedy_whenAskingDestination_thenRoutesToTheRightPage() { + XCTAssertEqual(CapabilityRemedy.verifyEmail.webDestination, .settings) + XCTAssertEqual(CapabilityRemedy.contactSupport.webDestination, .support) + XCTAssertEqual(CapabilityRemedy.upgrade(.listCreation).webDestination, .settings) + } + + /// A closed account has no next step, so the UI must not render a button + /// that goes nowhere. + func test_givenNoRemedy_whenAskingDestination_thenNil() { + XCTAssertNil(CapabilityRemedy.noneAvailable.webDestination) + } +} diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/EntitlementsServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/EntitlementsServiceTests.swift index 3467ee0..af1d27b 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/EntitlementsServiceTests.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/EntitlementsServiceTests.swift @@ -68,35 +68,79 @@ final class EntitlementsServiceTests: XCTestCase { XCTAssertEqual(CustomerStatus(raw: "mystery"), .other("mystery")) } - // MARK: - canManageLists (M3 defensive gate) + // MARK: - canManageLists (real subscriber gate — GitHub #40) - func test_givenDefaultConstruction_whenAskingCanManageLists_thenPermissiveByDefault() { - // Given — every default factory keeps the M3 permissive default. - XCTAssertTrue(EntitlementsService(customerStatus: .free).canManageLists) + /// The M3 permissive default is gone: `canManageLists` now tracks + /// `customerStatus` like every other create gate. + func test_givenDefaultConstruction_whenAskingCanManageLists_thenTracksSubscriberStatus() { + XCTAssertFalse(EntitlementsService(customerStatus: .free).canManageLists) XCTAssertTrue(EntitlementsService(customerStatus: .subscriber).canManageLists) - XCTAssertTrue(EntitlementsService(user: nil).canManageLists) + XCTAssertFalse(EntitlementsService(user: nil).canManageLists) + } + + /// `canManageLists` is a create-only gate, so it must agree exactly with + /// `isEnabled(.listCreation)` — the two must never drift apart. + func test_givenAnyStatus_whenAskingCanManageLists_thenAgreesWithListCreationFeature() { + for status: CustomerStatus in [.subscriber, .free, .other("trialing")] { + let service = EntitlementsService(customerStatus: status) + XCTAssertEqual( + service.canManageLists, + service.isEnabled(.listCreation), + "canManageLists drifted from .listCreation for \(status)" + ) + } } func test_givenOverrideToFalse_whenAskingCanManageLists_thenBlocks() { - // Given — the test seam used by M3 services to exercise gating - // before M6 wires the real source. + // Given — the explicit test seam, retained so a suite can exercise the + // blocked path without standing up a whole account. let service = EntitlementsService(customerStatus: .subscriber, canManageLists: false) // Then XCTAssertFalse(service.canManageLists) - // Subscriber-only features remain gated by their own switch. + // Other subscriber features remain governed by their own switch. XCTAssertTrue(service.isEnabled(.mediaAttachments)) } func test_givenOverrideToTrueOnFreeAccount_whenAskingCanManageLists_thenAllows() { - // Given — override is authoritative; the default permissive M3 gate - // is preserved for callers who do not pass the seam. + // Given — the override stays authoritative over the status-driven default. let service = EntitlementsService(customerStatus: .free, canManageLists: true) // Then XCTAssertTrue(service.canManageLists) } + // MARK: - The documented matrix (GitHub #40) + + /// Every feature named subscriber-only by `/help/settings` must be modelled. + /// This is the regression guard for "the app models 3, the platform gates ~10". + func test_givenSubscriber_whenCheckingEveryDocumentedFeature_thenAllAreModelledAndEnabled() { + let service = EntitlementsService(customerStatus: .subscriber) + let documented: [Feature] = [ + .mediaAttachments, .scheduledPosts, .crossPosting, + .listCreation, .listFolderCreation, + .documentCreation, .documentTemplateCreation, + .organizationCreation, + .sharingWithPeople, .emailInvites, .shareLinkCreation, + .aiFeatures, + ] + XCTAssertEqual( + Set(documented), Set(Feature.allCases), + "Feature drifted from the documented Free/Subscriber matrix." + ) + for feature in documented { + XCTAssertTrue(service.isEnabled(feature), "\(feature) should be enabled for a subscriber") + } + } + + /// Every feature must carry distinct, non-empty upgrade copy, so an + /// upgrade prompt can always name what the user was trying to do. + func test_givenEveryFeature_whenAskingUpgradeMessage_thenCopyIsPresentAndDistinct() { + let messages = Feature.allCases.map(\.upgradeMessage) + XCTAssertFalse(messages.contains { $0.isEmpty }) + XCTAssertEqual(Set(messages).count, Feature.allCases.count, "Upgrade copy must be per-feature") + } + // MARK: - Helpers private func makeUser(status: CustomerStatus) -> CurrentUser { diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OwnedListsServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OwnedListsServiceTests.swift index d83175e..972f5db 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OwnedListsServiceTests.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OwnedListsServiceTests.swift @@ -11,22 +11,24 @@ import InterlinedKit /// floor for every public method. final class OwnedListsServiceTests: XCTestCase { - // MARK: - Subscriber gating (defensive M3 gate) + // MARK: - Subscriber gating (create-only — GitHub #40) - func test_givenEntitlementsBlockManagement_whenCallingMyLists_thenThrowsSubscriberRequiredWithoutHittingAPI() async throws { - // Given — a manage-blocking entitlements stub. + /// Reading your own lists is free on every tier. The published matrix says + /// a lapsed subscriber keeps existing lists "fully usable", so the gate + /// must not stand between a free account and its own data. + func test_givenEntitlementsBlockCreation_whenCallingMyLists_thenReadIsUngatedAndHitsAPI() async throws { + // Given — an entitlement that blocks list *creation*. let api = StubAPIClient() + await api.enqueue(json: Fixtures.paginatedLists(ids: ["l-1"])) let service = ListsService(api: api, entitlements: BlockingEntitlements.shared) - // When / Then - do { - _ = try await service.myLists(limit: 20, offset: 0) - XCTFail("Expected ListsError.subscriberRequired") - } catch let error as ListsError { - XCTAssertEqual(error, .subscriberRequired) - } + // When + let page = try await service.myLists(limit: 20, offset: 0) + + // Then — the read went through untouched by the gate. + XCTAssertEqual(page.lists.map(\.id), ["l-1"]) let recorded = await api.recorded - XCTAssertTrue(recorded.isEmpty, "Gated calls must not hit the API.") + XCTAssertEqual(recorded.first?.path, "/api/lists") } func test_givenEntitlementsBlockManagement_whenCreatingList_thenThrowsSubscriberRequiredWithoutHittingAPI() async throws { @@ -51,21 +53,22 @@ final class OwnedListsServiceTests: XCTestCase { XCTAssertTrue(recorded.isEmpty) } - func test_givenEntitlementsBlockManagement_whenDeletingRow_thenThrowsSubscriberRequiredWithoutHittingAPI() async throws { - // Given — covers a void-returning write to confirm the gate fires - // on every M3 method, not just the value-returning ones. + /// Row writes are explicitly free: *"Adding rows to an existing list is + /// free, even without a subscription."* Deleting one is the same class of + /// edit, so the creation gate must not fire here either. + func test_givenEntitlementsBlockCreation_whenDeletingRow_thenRowWriteIsUngatedAndHitsAPI() async throws { + // Given let api = StubAPIClient() + await api.enqueue(json: "{}") let service = ListsService(api: api, entitlements: BlockingEntitlements.shared) - // When / Then - do { - try await service.deleteRow(listId: "list-1", rowId: "row-1") - XCTFail("Expected ListsError.subscriberRequired") - } catch let error as ListsError { - XCTAssertEqual(error, .subscriberRequired) - } + // When + try await service.deleteRow(listId: "list-1", rowId: "row-1") + + // Then let recorded = await api.recorded - XCTAssertTrue(recorded.isEmpty) + XCTAssertEqual(recorded.first?.method, "DELETE") + XCTAssertEqual(recorded.first?.path, "/api/lists/list-1/data/row-1") } func test_givenPermissiveEntitlements_whenCallingPublicBrowse_thenSubscriberGateDoesNotApply() async throws { diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SubscriberCreateGateTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SubscriberCreateGateTests.swift new file mode 100644 index 0000000..70f4f0f --- /dev/null +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SubscriberCreateGateTests.swift @@ -0,0 +1,185 @@ +import XCTest +import InterlinedKit +@testable import InterlinedDomain + +/// The service-level half of the subscriber matrix (GitHub #40): document and +/// organization **creation** are gated before any HTTP call, while every +/// non-create path on the same services stays free. +/// +/// The create-only rule is the thing worth protecting here. A gate that also +/// caught reads or edits would lock a lapsed subscriber out of content they +/// already own, which the published matrix explicitly promises it will not do. +final class SubscriberCreateGateTests: XCTestCase { + + private func free() -> @Sendable () -> EntitlementsService { + { EntitlementsService(customerStatus: .free) } + } + + private func subscriber() -> @Sendable () -> EntitlementsService { + { EntitlementsService(customerStatus: .subscriber) } + } + + // MARK: - Documents: happy path + + func test_givenSubscriber_whenCreatingDocument_thenProceedsToAPI() async throws { + // Given + let api = StubAPIClient() + await api.enqueue(json: Fixtures.documentEnvelope(id: "d-new", title: "New", content: "Body")) + let service = DocumentsService(api: api, entitlements: subscriber()) + + // When + let doc = try await service.create(title: "New", body: "Body", folderId: nil, isPublic: false) + + // Then + XCTAssertEqual(doc.id, "d-new") + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/documents") + } + + // MARK: - Documents: invalid state (free account) + + func test_givenFreeAccount_whenCreatingDocument_thenThrowsBeforeAnyAPICall() async throws { + // Given + let api = StubAPIClient() + let service = DocumentsService(api: api, entitlements: free()) + + // When / Then — the client predicts the server's 403. + do { + _ = try await service.create(title: "New", body: "Body", folderId: nil, isPublic: false) + XCTFail("Expected DocumentsError.subscriberRequired") + } catch let error as DocumentsError { + XCTAssertEqual(error, .subscriberRequired(.documentCreation)) + } + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty, "A predicted denial must not hit the network.") + } + + // MARK: - Documents: boundary — the lapsed subscriber + + /// The promise in `/help/settings`: "your existing lists, documents, and + /// organizations stay fully usable: you can still read and edit them." + func test_givenFreeAccount_whenReadingAndEditingExistingDocument_thenUngated() async throws { + // Given — a lapsed account acting on content it already owns. + let api = StubAPIClient() + await api.enqueue(json: Fixtures.documentEnvelope(id: "d-1", title: "Existing", content: "Body")) + await api.enqueue(json: Fixtures.documentEnvelope(id: "d-1", title: "Edited", content: "Body")) + let service = DocumentsService(api: api, entitlements: free()) + + // When — a read, then an edit. + let read = try await service.document(id: "d-1") + let edited = try await service.update( + id: "d-1", title: "Edited", body: nil, folderId: nil, isPublic: nil + ) + + // Then — both went through. + XCTAssertEqual(read.id, "d-1") + XCTAssertEqual(edited.title, "Edited") + let recorded = await api.recorded + XCTAssertEqual(recorded.count, 2) + } + + // MARK: - Documents: upstream failure still surfaces + + func test_givenSubscriberAndServerRejection_whenCreatingDocument_thenAPIErrorSurfacesUnchanged() async throws { + // Given — the gate is open, so a genuine server failure must reach the + // caller unchanged rather than being masked as an entitlement problem. + let api = StubAPIClient() + await api.enqueue(failure: .badRequest(serverMessage: "title required")) + let service = DocumentsService(api: api, entitlements: subscriber()) + + // When / Then + do { + _ = try await service.create(title: "", body: "", folderId: nil, isPublic: false) + XCTFail("Expected an APIError") + } catch let error as APIError { + XCTAssertEqual(error, .badRequest(serverMessage: "title required")) + } + } + + // MARK: - Organizations: happy path + + func test_givenSubscriber_whenCreatingOrganization_thenProceedsToAPI() async throws { + // Given + let api = StubAPIClient() + await api.enqueue(json: Fixtures.organizationEnvelope(id: "o-new", name: "Acme", isPublic: false)) + let service = OrgService(api: api, entitlements: subscriber()) + + // When + let org = try await service.create(name: "Acme", description: "We make things", isPublic: false) + + // Then + XCTAssertEqual(org.id, "o-new") + let recorded = await api.recorded + XCTAssertEqual(recorded.first?.path, "/api/organizations") + } + + // MARK: - Organizations: invalid state (free account) + + func test_givenFreeAccount_whenCreatingOrganization_thenThrowsBeforeAnyAPICall() async throws { + // Given + let api = StubAPIClient() + let service = OrgService(api: api, entitlements: free()) + + // When / Then + do { + _ = try await service.create(name: "Acme", description: "d", isPublic: true) + XCTFail("Expected OrgError.subscriberRequired") + } catch let error as OrgError { + XCTAssertEqual(error, .subscriberRequired(.organizationCreation)) + } + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty) + } + + // MARK: - Organizations: boundary — joining stays free + + /// `/help/settings` puts "join organizations" in the **Free** column, so + /// reading and joining must not be caught by the creation gate. + func test_givenFreeAccount_whenListingOrganizations_thenUngated() async throws { + // Given + let api = StubAPIClient() + await api.enqueue(json: Fixtures.paginatedOrganizations(ids: ["o-1"])) + let service = OrgService(api: api, entitlements: free()) + + // When + let page = try await service.organizations(isPublic: nil, userId: nil, limit: 20, offset: 0) + + // Then + XCTAssertEqual(page.organizations.map(\.id), ["o-1"]) + } + + // MARK: - The live provider + + /// The gate is evaluated per call, not captured at construction, so a + /// mid-session sign-in or upgrade re-gates without rebuilding the service. + func test_givenTierChangesMidSession_whenCreatingAgain_thenGateFollowsTheNewTier() async throws { + // Given — a provider whose answer changes between calls. + let api = StubAPIClient() + let isSubscriber = MutableFlag() + let service = DocumentsService(api: api, entitlements: { + EntitlementsService(customerStatus: isSubscriber.value ? .subscriber : .free) + }) + + // When — first attempt while free. + do { + _ = try await service.create(title: "N", body: "B", folderId: nil, isPublic: false) + XCTFail("Expected DocumentsError.subscriberRequired") + } catch let error as DocumentsError { + XCTAssertEqual(error, .subscriberRequired(.documentCreation)) + } + + // And then the account subscribes. + isSubscriber.value = true + await api.enqueue(json: Fixtures.documentEnvelope(id: "d-new", title: "N", content: "B")) + let doc = try await service.create(title: "N", body: "B", folderId: nil, isPublic: false) + + // Then — the second attempt goes through, with no rebuild in between. + XCTAssertEqual(doc.id, "d-new") + } +} + +/// A trivially mutable, `Sendable` box so the provider closure above can change +/// its answer between calls. +private final class MutableFlag: @unchecked Sendable { + var value = false +} diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/UserDTO.swift b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/UserDTO.swift index b8c2918..512e42d 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/UserDTO.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/UserDTO.swift @@ -97,6 +97,15 @@ public struct UserDTO: Decodable, Sendable, Equatable { public let openaiApiKey: String? public let anthropicApiKey: String? public let customerStatus: String + /// The account's lifecycle status (`new` / `active` / `restricted` / + /// `suspended` / `banned`), verified live 2026-09-09 on `GET /api/user`. + /// + /// Optional and kept as a raw `String` on purpose. The OpenAPI schema + /// declares this as a bare `{"type":"string"}` with **no** enum, so the + /// server may introduce a value this client has never seen — decoding it + /// loosely means an unknown status can never fail the whole account decode. + /// `InterlinedDomain.AccountStatus` narrows it and fails open. + public let accountStatus: String? public let stripeCustomerId: String? public let notificationTrayLimit: Int? public let createdAt: Date @@ -126,6 +135,7 @@ public struct UserDTO: Decodable, Sendable, Equatable { openaiApiKey: String? = nil, anthropicApiKey: String? = nil, customerStatus: String, + accountStatus: String? = nil, stripeCustomerId: String? = nil, notificationTrayLimit: Int? = nil, createdAt: Date, @@ -154,6 +164,7 @@ public struct UserDTO: Decodable, Sendable, Equatable { self.openaiApiKey = openaiApiKey self.anthropicApiKey = anthropicApiKey self.customerStatus = customerStatus + self.accountStatus = accountStatus self.stripeCustomerId = stripeCustomerId self.notificationTrayLimit = notificationTrayLimit self.createdAt = createdAt @@ -316,7 +327,8 @@ public struct LinkedIdentityDTO: Decodable, Sendable, Equatable { // MARK: - Organizations (user membership view) -/// Envelope for `GET /api/user/organizations` (session-only): +/// Envelope for `GET /api/user/organizations` (`x-auth-type: sync-token` — +/// Bearer-reachable; probed 2026-09-09, correcting an earlier "session-only" note): /// `{ "organizations": [...] }`. Each entry carries the caller's membership /// `role` and `joinedAt` alongside the organization fields. public struct UserOrganizationsResponse: Decodable, Sendable, Equatable { diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/ContractTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/ContractTests.swift index b357024..22dea46 100644 --- a/Packages/InterlinedKit/Tests/InterlinedKitTests/ContractTests.swift +++ b/Packages/InterlinedKit/Tests/InterlinedKitTests/ContractTests.swift @@ -77,6 +77,36 @@ final class ContractTests: XCTestCase { XCTAssertNotNil(try store.read()) } + /// Live-shape contract for `accountStatus` (GitHub #42). + /// + /// The OpenAPI schema types this as a bare string with no enum, so the only + /// way to know what the server actually sends is to ask it. Read-only. + func test_givenLiveCredentials_whenFetchingUser_thenAccountStatusIsPresentAndKnown() async throws { + guard let credentials = credentialsFromEnvironment() else { + throw XCTSkip("Live credentials not set — skipping contract test.") + } + + let store = InMemoryTokenStore() + let (client, service) = makeLiveStack(tokenStore: store) + _ = try await service.signIn(email: credentials.email, password: credentials.password) + + let response = try await client.send(User.current()) + + // The field the client used to drop entirely. + XCTAssertNotNil(response.user.accountStatus, "GET /api/user should carry accountStatus") + + // If the server ever introduces a value outside the documented set, + // this fails loudly here rather than silently mis-gating the whole app. + // The client still fails *open* at runtime — this is the early warning. + let documented = ["new", "active", "restricted", "suspended", "banned"] + if let status = response.user.accountStatus { + XCTAssertTrue( + documented.contains(status.lowercased()), + "Undocumented accountStatus \"\(status)\" — the gating matrix may need revisiting" + ) + } + } + func test_givenLiveCredentials_whenFetchingTimeline_thenReturns200AndDecodes() async throws { guard let credentials = credentialsFromEnvironment() else { throw XCTSkip("Live credentials not set — skipping contract test.") diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/UserEndpointTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/UserEndpointTests.swift index 9e5d701..9dd73fd 100644 --- a/Packages/InterlinedKit/Tests/InterlinedKitTests/UserEndpointTests.swift +++ b/Packages/InterlinedKit/Tests/InterlinedKitTests/UserEndpointTests.swift @@ -82,6 +82,70 @@ final class UserEndpointTests: XCTestCase { XCTAssertFalse(response.user.emailVerified) } + // MARK: - accountStatus (GitHub #42) + + /// Happy path: the field the client previously dropped on the floor. + /// Verified against the live payload 2026-09-09 (`"accountStatus":"active"`). + func test_givenUserEnvelopeWithAccountStatus_whenDecoded_thenCarriesRawStatus() throws { + let json = #""" + { "user": { "id": "u1", "email": "a@b.c", "username": "ada", + "emailVerified": true, "customerStatus": "subscriber", + "accountStatus": "active", + "createdAt": "2026-01-01T00:00:00Z" } } + """# + + let response = try JSONCoders.makeDecoder().decode(UserResponse.self, from: Data(json.utf8)) + + XCTAssertEqual(response.user.accountStatus, "active") + } + + /// Boundary: the field is absent, as it is on any older server. It must + /// decode to nil rather than failing — the domain layer supplies the + /// fail-open default. + func test_givenUserEnvelopeWithoutAccountStatus_whenDecoded_thenNilNotAThrow() throws { + let json = #""" + { "user": { "id": "u1", "email": "a@b.c", "username": "ada", + "emailVerified": false, "customerStatus": "free", + "createdAt": "2026-01-01T00:00:00Z" } } + """# + + let response = try JSONCoders.makeDecoder().decode(UserResponse.self, from: Data(json.utf8)) + + XCTAssertNil(response.user.accountStatus) + } + + /// Upstream drift: the OpenAPI schema declares `accountStatus` as a bare + /// string with no enum, so the server may send a value this client has + /// never seen. Decoding it loosely means one unknown status can never fail + /// the whole account decode and sign the user out. + func test_givenUnrecognisedAccountStatus_whenDecoded_thenPreservedNotRejected() throws { + let json = #""" + { "user": { "id": "u1", "email": "a@b.c", "username": "ada", + "emailVerified": true, "customerStatus": "free", + "accountStatus": "shadow-realm", + "createdAt": "2026-01-01T00:00:00Z" } } + """# + + let response = try JSONCoders.makeDecoder().decode(UserResponse.self, from: Data(json.utf8)) + + XCTAssertEqual(response.user.accountStatus, "shadow-realm") + } + + /// Invalid input: a null literal is distinct from an absent key on the + /// wire, and must land on the same nil rather than throwing. + func test_givenNullAccountStatus_whenDecoded_thenNil() throws { + let json = #""" + { "user": { "id": "u1", "email": "a@b.c", "username": "ada", + "emailVerified": true, "customerStatus": "free", + "accountStatus": null, + "createdAt": "2026-01-01T00:00:00Z" } } + """# + + let response = try JSONCoders.makeDecoder().decode(UserResponse.self, from: Data(json.utf8)) + + XCTAssertNil(response.user.accountStatus) + } + func test_givenUnauthorized_whenCurrentSent_thenThrowsUnauthorized() async throws { // Upstream API failure. With no token the request still sends; the 401 // safety net retries through the (empty) session transport, whose