diff --git a/App/Composition/AppEnvironment.swift b/App/Composition/AppEnvironment.swift index 6e5bbeb..66e87af 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 @@ -505,10 +516,10 @@ final class AppEnvironment: ObservableObject { store: documentStore, // Live image ceilings for `uploadImage` prep (G14 tail). contentLimits: contentLimits, - // work-consolidation.md G24 — `POST /api/documents/folders/{id}/ - // documents` is subscriber-gated upstream. Same live box the + // Gate for document *creation* only (#40 matrix) — moving, + // editing and deleting stay free on every tier. Same live box the // messages gate reads, so a mid-session subscribe or lapse re-gates - // creating documents in a folder without a relaunch. + // without a relaunch. entitlementsProvider: { liveEntitlements.current() } ) // Server document templates (work-consolidation.md G12). Reuses the same @@ -539,7 +550,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 06c1753..712d38e 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 @@ -181,6 +190,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 { @@ -279,6 +313,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 } @@ -289,6 +328,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, @@ -301,6 +342,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 @@ -373,8 +419,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 @@ -621,10 +675,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 df36b3c..7203e30 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() }, @@ -682,6 +687,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/DirectMessages/DMAttachmentDraft.swift b/App/Features/DirectMessages/DMAttachmentDraft.swift index d6d9c32..ef9e6c0 100644 --- a/App/Features/DirectMessages/DMAttachmentDraft.swift +++ b/App/Features/DirectMessages/DMAttachmentDraft.swift @@ -14,12 +14,12 @@ // • "Photos are resized automatically." → the resize happens in // `DirectMessagesService.uploadImage` via the shared `ImagePrep` + // `ContentLimits` path, not here and not with fresh constants. -// • "You'll need a verified email address to send images." → NOT checked -// here. The server's 403 carries the canonical wording and is surfaced -// verbatim. TODO(#41): issue #41 builds `CapabilityGate` -// (status → email verification → tier); when it merges, the composers -// should consult it to explain the refusal before the user picks a -// file. One gate, one owner — do not add a second check here. +// • "You'll need a verified email address to send images." → still NOT +// checked here, deliberately. Since #41 the *composers* consult +// `CapabilityGate.evaluate(.directMessageImages)` and disable the attach +// affordance with the reason up front; this draft holds picked files and +// has no business re-deciding entitlement. One gate, one owner — do not +// add a second check here. The server's 403 remains the backstop. // // The upload loop is deliberately failure-tolerant: an upload that fails // must not cost the user their draft. Successful uploads are kept, the diff --git a/App/Features/DirectMessages/DMThreadView.swift b/App/Features/DirectMessages/DMThreadView.swift index dc921d0..e571e33 100644 --- a/App/Features/DirectMessages/DMThreadView.swift +++ b/App/Features/DirectMessages/DMThreadView.swift @@ -46,7 +46,10 @@ struct DMThreadView: View { username: username, service: environment.directMessages, eventBus: environment.directMessagesEventBus, - currentUserID: currentUserID + currentUserID: currentUserID, + // Read live, not captured: verifying an email in a browser + // must re-enable the attach control without a relaunch. + capabilities: { environment.liveCapabilities } ) viewModel = vm await vm.startPolling() @@ -235,22 +238,26 @@ struct DMThreadView: View { } HStack(spacing: 8) { // G22: photo attachments. Sending photos needs a verified - // email address; the server refuses with an explanation when - // it isn't, and that message is what the error line shows. - // TODO(#41): once issue #41's `CapabilityGate` merges, - // disable this and explain up front rather than after the - // attempt. + // email address. Since #41 the gate is asked *before* the user + // picks a file, so the control is disabled with the reason in + // its tooltip rather than failing after the attempt. The + // server's 403 remains the backstop if the gate is stale. Button { isPhotoImporterPresented = true } label: { Image(systemName: "photo.on.rectangle") } .buttonStyle(.bordered) - .disabled(composerDisabled || viewModel.attachmentsAreFull) + .disabled( + composerDisabled + || viewModel.attachmentsAreFull + || viewModel.attachmentDenial != nil + ) .help( - viewModel.attachmentsAreFull - ? "Up to \(viewModel.maxAttachments) photos per message" - : "Attach photos" + viewModel.attachmentBlockedMessage + ?? (viewModel.attachmentsAreFull + ? "Up to \(viewModel.maxAttachments) photos per message" + : "Attach photos") ) .accessibilityLabel("Attach photos") diff --git a/App/Features/DirectMessages/DMThreadViewModel.swift b/App/Features/DirectMessages/DMThreadViewModel.swift index cb1d176..c4def45 100644 --- a/App/Features/DirectMessages/DMThreadViewModel.swift +++ b/App/Features/DirectMessages/DMThreadViewModel.swift @@ -28,8 +28,10 @@ // `DirectMessagesServicing.uploadImage` immediately before the send. An // upload that fails does NOT abort the message — the text still goes out, // the failure is surfaced, and nothing the user typed or picked is lost. -// Photo sending requires a verified email; the server's 403 is surfaced -// verbatim. TODO(#41): the verification gate is owned by issue #41. +// Photo sending requires a verified email: since #41 the attach affordance +// is disabled and explained up front via +// `CapabilityGate.evaluate(.directMessageImages)`, with the server's 403 +// retained as the authoritative backstop. // // Bubble alignment uses `DirectMessage.isOutgoing(currentUserId:)` with // the id from the injected `currentUserID` closure so the view model @@ -157,7 +159,8 @@ final class DMThreadViewModel { eventBus: DirectMessagesEventBus? = nil, currentUserID: @MainActor @escaping () -> String? = { nil }, pollInterval: Duration = defaultPollInterval, - readData: @escaping @Sendable (URL) async throws -> Data = { try Data(contentsOf: $0) } + readData: @escaping @Sendable (URL) async throws -> Data = { try Data(contentsOf: $0) }, + capabilities: @escaping @Sendable () -> CapabilityGate = { CapabilityGate(user: nil) } ) { self.username = username self.service = service @@ -165,8 +168,29 @@ final class DMThreadViewModel { self.currentUserIDProvider = currentUserID self.pollInterval = pollInterval self.readData = readData + self.capabilities = capabilities } + /// The live capability gate. A closure, not a snapshot: verifying an email + /// or resolving a sign-in mid-session must re-enable the affordance without + /// a relaunch. + private let capabilities: @Sendable () -> CapabilityGate + + /// Why photos cannot be attached right now, or `nil` if they can. + /// + /// Sending a DM photo needs a **verified email address**, which no amount of + /// subscription fixes — so this asks the composed gate rather than + /// `EntitlementsService`. A `nil` provider means "not wired", which + /// evaluates as an unrestricted account: the affordance stays enabled and + /// the server's 403 remains the backstop, exactly as before #41. + var attachmentDenial: CapabilityDenial? { + capabilities().denial(for: .directMessageImages) + } + + /// The sentence shown beside a disabled attach control. + var attachmentBlockedMessage: String? { attachmentDenial?.message } + + // MARK: - Lifecycle /// Loads the thread, marks inbound messages read, and starts the diff --git a/App/Features/DirectMessages/NewMessageSheet.swift b/App/Features/DirectMessages/NewMessageSheet.swift index 77ca178..07f693c 100644 --- a/App/Features/DirectMessages/NewMessageSheet.swift +++ b/App/Features/DirectMessages/NewMessageSheet.swift @@ -51,7 +51,10 @@ struct NewMessageSheet: View { if viewModel == nil, let environment { let vm = NewMessageViewModel( service: environment.directMessages, - eventBus: environment.directMessagesEventBus + eventBus: environment.directMessagesEventBus, + // Read live, not captured: verifying an email in a browser + // must re-enable the attach control without a relaunch. + capabilities: { environment.liveCapabilities } ) viewModel = vm await vm.loadRecipients(preselectUsername: preselectUsername) @@ -146,10 +149,10 @@ struct NewMessageSheet: View { } // G22: photo attachments, up to 8. Sending photos needs a - // verified email address; the server refuses with an explanation - // when it isn't, and that message is what the error line shows. - // TODO(#41): once issue #41's `CapabilityGate` merges, disable - // this and explain up front rather than after the attempt. + // verified email address. Since #41 the gate is asked *before* the + // user picks a file, so the control is disabled with the reason in + // its tooltip rather than failing after the attempt. The server's + // 403 remains the backstop if the gate's view is stale. VStack(alignment: .leading, spacing: 6) { if !viewModel.attachments.isEmpty { DMAttachmentStrip( @@ -164,11 +167,12 @@ struct NewMessageSheet: View { Label("Add photos", systemImage: "photo.on.rectangle") } .buttonStyle(.bordered) - .disabled(viewModel.attachmentsAreFull) + .disabled(viewModel.attachmentsAreFull || viewModel.attachmentDenial != nil) .help( - viewModel.attachmentsAreFull - ? "Up to \(viewModel.maxAttachments) photos per message" - : "Attach photos" + viewModel.attachmentBlockedMessage + ?? (viewModel.attachmentsAreFull + ? "Up to \(viewModel.maxAttachments) photos per message" + : "Attach photos") ) } diff --git a/App/Features/DirectMessages/NewMessageViewModel.swift b/App/Features/DirectMessages/NewMessageViewModel.swift index f8acc10..788f72e 100644 --- a/App/Features/DirectMessages/NewMessageViewModel.swift +++ b/App/Features/DirectMessages/NewMessageViewModel.swift @@ -15,9 +15,10 @@ // Photo attachments (work-consolidation.md G22): up to 8 per message via a // shared `DMAttachmentDraft`, uploaded immediately before the send. A // failed upload never costs the user their draft — the text still sends -// and the failure is surfaced. Photo sending requires a verified email; -// the server's 403 is surfaced verbatim. TODO(#41): the verification gate -// is owned by issue #41 — do not add a second check here. +// and the failure is surfaced. Photo sending requires a verified email: +// since #41 the attach affordance is disabled and explained up front via +// `CapabilityGate.evaluate(.directMessageImages)`. The service keeps no +// competing pre-check — the server's 403 stays authoritative. // // The recipient list is the mutual-follower set (`recipients()`), which is // why the empty state explains the mutual-follow rule rather than just @@ -105,13 +106,35 @@ final class NewMessageViewModel { init( service: DirectMessagesServicing, eventBus: DirectMessagesEventBus? = nil, - readData: @escaping @Sendable (URL) async throws -> Data = { try Data(contentsOf: $0) } + readData: @escaping @Sendable (URL) async throws -> Data = { try Data(contentsOf: $0) }, + capabilities: @escaping @Sendable () -> CapabilityGate = { CapabilityGate(user: nil) } ) { self.service = service self.bus = eventBus self.readData = readData + self.capabilities = capabilities } + /// The live capability gate. A closure, not a snapshot: verifying an email + /// or resolving a sign-in mid-session must re-enable the affordance without + /// a relaunch. + private let capabilities: @Sendable () -> CapabilityGate + + /// Why photos cannot be attached right now, or `nil` if they can. + /// + /// Sending a DM photo needs a **verified email address**, which no amount of + /// subscription fixes — so this asks the composed gate rather than + /// `EntitlementsService`. A `nil` provider means "not wired", which + /// evaluates as an unrestricted account: the affordance stays enabled and + /// the server's 403 remains the backstop, exactly as before #41. + var attachmentDenial: CapabilityDenial? { + capabilities().denial(for: .directMessageImages) + } + + /// The sentence shown beside a disabled attach control. + var attachmentBlockedMessage: String? { attachmentDenial?.message } + + // MARK: - Intents /// Loads the eligible recipient list. If a `preselectUsername` is 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 8337aa0..64c70b2 100644 --- a/App/Features/Timeline/TimelineRootView.swift +++ b/App/Features/Timeline/TimelineRootView.swift @@ -226,6 +226,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/App/InterlinedListApp.swift b/App/InterlinedListApp.swift index 8924919..3f82405 100644 --- a/App/InterlinedListApp.swift +++ b/App/InterlinedListApp.swift @@ -62,6 +62,9 @@ struct InterlinedListApp: App { /// than writing the badge directly, so neither clobbers the other. @State private var badgeAggregator: UnreadBadgeAggregator? + /// Drives the capability-gate refresh below. + @Environment(\.scenePhase) private var scenePhase + var body: some Scene { WindowGroup { AppRootView(store: environment.currentUserStore) @@ -137,6 +140,15 @@ struct InterlinedListApp: App { dmCoordinator.start() } } + .onChange(of: scenePhase) { _, phase in + // #41/#42 — the capability gate is only as fresh as the + // session it is built from, and `SessionService.restore()` + // is the ONLY re-fetch path. Without this, verifying an + // email or fixing an account status in a browser leaves the + // Mac app gated until the next launch. + guard phase == .active else { return } + Task { _ = try? await environment.currentUserStore.restore() } + } } .windowToolbarStyle(.unified) .commands { diff --git a/AppTests/AIAssistantViewModelTests.swift b/AppTests/AIAssistantViewModelTests.swift index a17abcc..5cb0360 100644 --- a/AppTests/AIAssistantViewModelTests.swift +++ b/AppTests/AIAssistantViewModelTests.swift @@ -42,7 +42,7 @@ final class AIAssistantViewModelTests: XCTestCase { XCTAssertTrue(viewModel.hasCheckedAvailability) } - func test_givenSubscriberWithoutProviderKey_whenRefreshed_thenExplainsWhatIsMissing() async { + func test_givenSubscriberAndNoProvidersConfigured_whenRefreshed_thenBlamesTheServiceNotTheUser() async { let service = StubAIService() await service.enqueueAvailability(AIAvailability(isSubscriber: true, providers: [])) let viewModel = AIAssistantViewModel(ai: service) @@ -52,7 +52,7 @@ final class AIAssistantViewModelTests: XCTestCase { XCTAssertFalse(viewModel.isAvailable) XCTAssertEqual( viewModel.unavailableReason, - "Add your own AI provider key in Settings on interlinedlist.com to use AI features." + "AI features are temporarily unavailable. Nothing is wrong with your account — please try again later." ) } 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/AppTests/DMThreadViewModelTests.swift b/AppTests/DMThreadViewModelTests.swift index 405765b..f7d2cea 100644 --- a/AppTests/DMThreadViewModelTests.swift +++ b/AppTests/DMThreadViewModelTests.swift @@ -33,6 +33,17 @@ import InterlinedDomain import InterlinedKit @testable import InterlinedList +/// An unrestricted account: active, verified, subscribed. The default so every +/// pre-existing case keeps exercising what it was written for rather than +/// silently acquiring an entitlement assertion. +nonisolated private func unrestrictedDMGate() -> CapabilityGate { + CapabilityGate( + accountStatus: .active, + entitlements: EntitlementsService(customerStatus: .subscriber), + isEmailVerified: true + ) +} + @MainActor final class DMThreadViewModelTests: XCTestCase { @@ -43,7 +54,8 @@ final class DMThreadViewModelTests: XCTestCase { private func makeViewModel( pollInterval: Duration = .milliseconds(5), - readData: @escaping @Sendable (URL) async throws -> Data = { _ in Data([0x1]) } + readData: @escaping @Sendable (URL) async throws -> Data = { _ in Data([0x1]) }, + capabilities: @escaping @Sendable () -> CapabilityGate = { unrestrictedDMGate() } ) -> (DMThreadViewModel, StubDirectMessagesService, DirectMessagesEventBus) { let service = StubDirectMessagesService() let bus = DirectMessagesEventBus() @@ -53,7 +65,8 @@ final class DMThreadViewModelTests: XCTestCase { eventBus: bus, currentUserID: { [me] in me }, pollInterval: pollInterval, - readData: readData + readData: readData, + capabilities: capabilities ) return (vm, service, bus) } @@ -485,4 +498,53 @@ final class DMThreadViewModelTests: XCTestCase { XCTAssertTrue(recorded.isEmpty) XCTAssertEqual(vm.error as? DMThreadError, .bodyTooLong(limit: 10_000)) } + + // MARK: - Photo attachments are gated on a verified email (GitHub #41) + + func test_givenVerifiedSubscriber_whenAskingIfPhotosCanBeAttached_thenNothingBlocksIt() { + let (vm, _, _) = makeViewModel() + + XCTAssertNil(vm.attachmentDenial) + XCTAssertNil(vm.attachmentBlockedMessage) + } + + func test_givenUnverifiedEmail_whenAskingIfPhotosCanBeAttached_thenBlockedWithAVerificationReason() { + // The defect this closes: the composer advertised photo attachments and + // only discovered the refusal after the user had picked a file, because + // the server's 403 was the first and only signal. + let (vm, _, _) = makeViewModel(capabilities: { + CapabilityGate( + accountStatus: .active, + entitlements: EntitlementsService(customerStatus: .subscriber), + isEmailVerified: false + ) + }) + + XCTAssertEqual(vm.attachmentDenial, .emailUnverified) + XCTAssertEqual(vm.attachmentBlockedMessage, CapabilityDenial.emailUnverified.message) + } + + func test_givenSuspendedAccount_whenAskingIfPhotosCanBeAttached_thenStatusOutranksVerification() { + // Status is the harder gate and is evaluated first, so a suspended user + // is not told to "verify your email" — which would not unblock them. + let (vm, _, _) = makeViewModel(capabilities: { + CapabilityGate( + accountStatus: .suspended, + entitlements: EntitlementsService(customerStatus: .subscriber), + isEmailVerified: false + ) + }) + + XCTAssertEqual(vm.attachmentDenial, .accountReadOnly(.suspended)) + } + + func test_givenNoGateWired_whenAskingIfPhotosCanBeAttached_thenFailsOpenAndLeavesTheServerAuthoritative() { + // Boundary: an un-injected gate must not invent a restriction. A signed- + // out or unresolved session is `.active` + unverified, so the *email* + // gate is what speaks — never a fabricated status block. + let (vm, _, _) = makeViewModel(capabilities: { CapabilityGate(user: nil) }) + + XCTAssertEqual(vm.attachmentDenial, .emailUnverified) + XCTAssertNotEqual(vm.attachmentDenial, .accountBanned) + } } diff --git a/AppTests/DocumentsTreeAndMoveViewModelTests.swift b/AppTests/DocumentsTreeAndMoveViewModelTests.swift index 47ba4b5..a72545b 100644 --- a/AppTests/DocumentsTreeAndMoveViewModelTests.swift +++ b/AppTests/DocumentsTreeAndMoveViewModelTests.swift @@ -335,7 +335,7 @@ final class DocumentsTreeAndMoveViewModelTests: XCTestCase { // column, and the error must reach the view. let stub = StubDocumentsService() await stub.enqueueDocuments(success: []) - await stub.enqueueCreateInFolder(failure: DocumentsError.subscriberRequired) + await stub.enqueueCreateInFolder(failure: DocumentsError.subscriberRequired(.documentCreation)) let viewModel = DocumentsListViewModel(documents: stub) await viewModel.reload(in: "F1") @@ -343,6 +343,6 @@ final class DocumentsTreeAndMoveViewModelTests: XCTestCase { XCTAssertNil(created) XCTAssertTrue(viewModel.documentsLoaded.isEmpty) - XCTAssertEqual(viewModel.error as? DocumentsError, .subscriberRequired) + XCTAssertEqual(viewModel.error as? DocumentsError, .subscriberRequired(.documentCreation)) } } diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/AI.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/AI.swift index 20e4f8e..6c1a2b8 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/AI.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/AI.swift @@ -140,7 +140,16 @@ public struct AIAvailability: Sendable, Equatable { return "AI features are part of a subscription." } if providers.isEmpty { - return "Add your own AI provider key in Settings on interlinedlist.com to use AI features." + // GitHub #39. This used to read "Add your own AI provider key in + // Settings on interlinedlist.com" — which was wrong and, worse, + // actionable-sounding: it sent subscribers hunting for a setting + // that does not exist. Per /help/ai and /help/settings, AI is + // powered by Claude and provided by InterlinedList as part of the + // subscription; there is no user-supplied key and no separate AI + // bill. An empty `providers[]` is therefore a *service-side* state, + // not a user misconfiguration, so the copy says so and asks for + // nothing the user cannot do. + return "AI features are temporarily unavailable. Nothing is wrong with your account — please try again later." } if let quota, !quota.hasRemaining { return "You've used today's \(quota.dailyLimit) AI requests. The limit resets tomorrow." 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..38ac8d9 --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/EmailVerification.swift @@ -0,0 +1,119 @@ +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, **re-confirmed by live +/// probe 2026-09-14: a valid Bearer sync-token gets `401 Unauthorized`**). The +/// 2026-09-14 re-measure counted 32 session-only operations against 207 +/// `sync-token` ones; this is one of only four that constrain a native client. 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 e3e2e42..557446d 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..0ad5e57 --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/CapabilityGate.swift @@ -0,0 +1,339 @@ +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 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 .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, + .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, + .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, + .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/DirectMessagesService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/DirectMessagesService.swift index 022cb99..31b7114 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/DirectMessagesService.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/DirectMessagesService.swift @@ -61,11 +61,12 @@ public protocol DirectMessagesServicing: Sendable { /// /// Sending photos requires a **verified email address**. This method does /// NOT pre-check that: the server's 403 is surfaced verbatim instead. - /// TODO(#41): the email-verification gate is owned by issue #41, which - /// builds `CapabilityGate` (status → email verification → tier). When that - /// branch merges, have the DM composers ask the gate so the affordance is - /// explained *before* the user picks a file. Do not add a second check - /// here — one gate, one owner. + /// Resolved by #41 (2026-09-14): `CapabilityGate` now exists and the DM + /// composers ask it for `.directMessageImages` so the affordance is + /// disabled and explained *before* the user picks a file. This service + /// deliberately keeps **no** competing pre-check — the server's 403 stays + /// the authoritative answer, and one gate with one owner cannot disagree + /// with itself. func uploadImage(_ data: Data) async throws -> String func thread(username: String, cursor: String?) async throws -> DMThread @@ -134,10 +135,11 @@ public final class DirectMessagesService: DirectMessagesServicing { // A 403 here is the documented "verified email required" refusal. It // propagates untouched: `APIError.forbidden` preserves the server's own // wording, which is the canonical explanation for the user. - // TODO(#41): the email-verification gate is owned by issue #41 and - // arrives as `CapabilityGate`. When it lands, the composer should - // disable the attach affordance up front and explain why. Do not add a - // competing pre-check in this service. + // #41 (2026-09-14): the composer now disables the attach affordance up + // front via `CapabilityGate.evaluate(.directMessageImages)`. This stays + // un-pre-checked on purpose — the gate is advisory for the call and the + // server remains authoritative, so a stale client verdict can never + // block a request the server would have accepted. let response = try await api.send( DirectMessages.uploadImage(prepared.data, contentType: prepared.format.mimeType) ) diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/DocumentsService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/DocumentsService.swift index 503a3c5..1de9a4e 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/DocumentsService.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/DocumentsService.swift @@ -8,6 +8,21 @@ 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, deleting and *moving* an existing + /// document stay free, because a lapsed subscription keeps existing + /// documents "fully usable". + /// + /// Carries the `Feature` so the message comes from the one published + /// matrix (`Feature.upgradeMessage`) rather than being written twice. The + /// richer `CapabilityGate` — which also weighs account status and email + /// verification, hardest first — is consumed at the App layer, where a + /// denial can be explained; a service-layer throw only needs to be correct. + case subscriberRequired(Feature) + /// The requested document id was not found. case notFound @@ -21,25 +36,6 @@ public enum DocumentsError: Error, Sendable, Equatable { /// on `DocumentsError`, not the imaging error. case imageTooLargeAfterPrep - /// A subscriber-only documents action was attempted by a free account. - /// Raised **before** any HTTP call so the UI can gate the affordance - /// rather than surfacing a bare 403. Carries nothing: the only gated - /// documents action today is creating a document, and the message is the - /// same whichever route it came in on. - /// - /// TODO(#40): issue #40 owns `EntitlementsService` and is building a - /// `CapabilityGate` whose denials carry a reason and run - /// status → email-verification → tier, hardest first. When it lands, this - /// case should carry that denial instead of standing alone, and the gate - /// below should ask it rather than reading `isSubscriber`. Deliberately not - /// done here: adding a `Feature` case means editing the file #40 owns, so - /// this consumes the existing seam and leaves the enum untouched. - /// - /// Note the gate matches #40's published matrix: **creation only**. Moving, - /// editing and deleting documents stay free on every tier, so a lapsed - /// subscriber keeps existing content fully usable. - case subscriberRequired - /// The sync engine refused to complete a cycle. Carries the underlying /// transport / API failure unchanged so the UI can still inspect it. /// Wrapped as `APIError` when the underlying source was one, or @@ -53,6 +49,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): @@ -229,7 +227,6 @@ 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? - /// Live entitlements, evaluated at call time on every gated write. /// /// A closure, not a stored value, for the same reason `MessagesService` @@ -241,12 +238,10 @@ public final class DocumentsService: DocumentsServicing { /// Defaults to `.free` — a signed-out or unresolved session is never /// wrongly entitled. /// - /// TODO(#40): the gate reads `EntitlementsService.isSubscriber` directly - /// because `Feature` has no documents case yet. Issue #40 owns - /// `EntitlementsService`; when its `CapabilityGate` lands, add a - /// `Feature.documentCreation` case (named for creation, not `.documents`, - /// to keep it unmissable that only creation is gated) and switch - /// `requireSubscriber()` below to ask the gate. One line, no call sites. + /// Resolved by #40 (2026-09-14): `Feature.documentCreation` exists and + /// `requireSubscriber()` asks for it by name. The case is deliberately + /// spelled for *creation*, not `.documents`, so it stays unmissable that + /// moving, editing and deleting are free on every tier. private let entitlementsProvider: @Sendable () -> EntitlementsService /// - Parameters: @@ -346,6 +341,9 @@ public final class DocumentsService: DocumentsServicing { folderId: String?, isPublic: Bool ) async throws -> Document { + guard entitlementsProvider().isEnabled(.documentCreation) else { + throw DocumentsError.subscriberRequired(.documentCreation) + } let req = CreateDocumentRequest( title: title, content: body, @@ -419,7 +417,7 @@ public final class DocumentsService: DocumentsServicing { // The server gates this route too. Translate its 403 into the same // typed error the local gate raises so callers branch once. if case .forbidden = error { - throw DocumentsError.subscriberRequired + throw DocumentsError.subscriberRequired(.documentCreation) } throw error } @@ -654,12 +652,17 @@ public final class DocumentsService: DocumentsServicing { // MARK: - Entitlement gate - /// Throws `DocumentsError.subscriberRequired` when the live account is not - /// a subscriber. The single place the documents surface consults - /// entitlements, so the #40 follow-up is a one-line change here. + /// Throws `DocumentsError.subscriberRequired` when document **creation** is + /// not enabled for the live account. The single place the documents surface + /// consults entitlements. + /// + /// Asks for `.documentCreation` specifically rather than `isSubscriber`, so + /// this path is governed by the same published matrix as every other gated + /// feature and cannot drift from it. Evaluated per call, so a mid-session + /// subscribe or lapse re-gates without a relaunch. private func requireSubscriber() throws { - guard entitlementsProvider().isSubscriber else { - throw DocumentsError.subscriberRequired + guard entitlementsProvider().isEnabled(.documentCreation) else { + throw DocumentsError.subscriberRequired(.documentCreation) } } diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/EntitlementsService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/EntitlementsService.swift index 9653da2..6ae2243 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/EntitlementsService.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/EntitlementsService.swift @@ -1,35 +1,129 @@ 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 + // NOTE: there is deliberately no `listFolderCreation` case. List folders + // were removed from macOS in PR #19 and the owner confirmed on 2026-09-14 + // that they are not returning (GitHub #49) — the divergence from the web is + // intentional. A gate for a feature the client does not have would be dead + // code that reads like a promise. + /// 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 .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 +138,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 +151,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, + .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 f48692e..f3489e7 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 @@ -49,11 +54,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 @@ -241,10 +249,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). @@ -252,7 +261,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() ) { @@ -319,7 +328,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) @@ -357,7 +365,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) } @@ -388,7 +395,6 @@ public final class ListsService: ListsServicing { isPublic: Bool?, parentId: String? ) async throws -> OwnedList { - try requireListManagement() let request = UpdateListRequest( title: title, description: description, @@ -400,20 +406,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)) @@ -423,7 +426,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) } @@ -431,7 +433,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" @@ -445,14 +446,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. @@ -465,7 +464,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. @@ -474,14 +472,15 @@ 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: - G23 shared with me + /// Lists shared *with* the caller. Free on every tier — seeing what someone + /// gave you access to is not a subscriber feature, and gating it would hide + /// content a free user is entitled to read (#40 matrix: creation only). public func watching(limit: Int, offset: Int) async throws -> WatchedListsPage { - try requireListManagement() let request = Lists.watching(limit: limit, offset: offset) let (data, _) = try await api.sendRaw(request) let key = request.paginationKey ?? "data" @@ -498,8 +497,8 @@ public final class ListsService: ListsServicing { return WatchedListsPage(from: paginated) } + /// Free: inspecting who contributes to a list is a read. public func contributors(of listId: String) async throws -> [ListContributor] { - try requireListManagement() let response = try await api.send(Lists.contributors(listId: listId)) return response.contributors.map(ListContributor.init(from:)) } @@ -516,7 +515,14 @@ public final class ListsService: ListsServicing { // intended recipient. let trimmedUserId = userId.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedUserId.isEmpty else { throw ListsError.invalidWatcher } - try requireListManagement() + // Granting someone else access is `sharingWithPeople`, not list + // creation — the same entitlement that gates document collaborators and + // email invites. Checked *after* the empty-id guard so an invalid call + // reports what is wrong with it rather than an entitlement the caller + // may well have. + guard entitlements.isEnabled(.sharingWithPeople) else { + throw ListsError.subscriberRequired + } let request = AddListWatcherRequest( userId: trimmedUserId, role: role.wireToken, @@ -535,13 +541,13 @@ public final class ListsService: ListsServicing { // MARK: - M3 watchers public func watchers(of listId: String) async throws -> [ListWatcher] { - try requireListManagement() + // Reading who watches a list is free on every tier (#40 matrix: + // creation is gated, inspection is not). let response = try await api.send(Lists.watchers(listId: listId)) return response.watchers.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) } @@ -551,7 +557,8 @@ public final class ListsService: ListsServicing { search: String?, limit: Int ) async throws -> [CollaboratorCandidate] { - try requireListManagement() + // Listing who *could* be added is a read; the gate lives on + // `addWatcher`, the action that actually shares the list. // Blank searches are sent as `nil` so the route returns its default // (unfiltered) candidate page rather than matching on an empty string. let trimmed = search?.trimmingCharacters(in: .whitespacesAndNewlines) @@ -578,7 +585,6 @@ public final class ListsService: ListsServicing { userId: String, role: WatcherRole ) async throws -> ListWatcher { - try requireListManagement() let request = UpdateListWatcherRequest(role: role.wireToken) do { // The route answers `{ role }` only, so the caller's own `userId` @@ -594,14 +600,12 @@ public final class ListsService: ListsServicing { } 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 } @@ -613,7 +617,6 @@ public final class ListsService: ListsServicing { toListId: String, label: String? ) async throws -> ListConnection { - try requireListManagement() let request = CreateListConnectionRequest( fromListId: fromListId, toListId: toListId, @@ -624,33 +627,23 @@ 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. /// - /// - TODO: GitHub issue **#40** owns `EntitlementsService` and is landing - /// the real capability model (`CapabilityGate`) on a separate branch. - /// `canManageLists` is still permissive-by-default here, so the - /// *client-side* gate does not yet distinguish the subscriber-only - /// sharing writes (`addWatcher` / `setWatcher`) from the free ones. - /// Until #40 merges, the server's `403` is the authoritative gate and - /// both call sites project it onto `ListsError.subscriberRequired`; - /// afterwards, point those two methods — and only those two — at the new - /// capability so the block happens before the round-trip. + /// 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). /// - /// Deliberately *not* gated on `entitlements.isSubscriber` today, for - /// two reasons: `AppEnvironment` builds `ListsService` with the default - /// `.free` entitlements (so that check would reject every real caller), - /// and every one of this service's write methods routes through this one - /// seam — including pure reads — so tightening it wholesale would stop - /// free users reading their own lists. + /// **Why this is not applied wholesale.** Before #40 this one seam guarded + /// *every* write method — 24 call sites on `dev`, including pure reads. + /// `canManageLists` was permissive-by-default (`?? true`), so that was + /// harmless; tightening it without first removing the read call sites would + /// have stopped free users reading their own lists. #40 removes those call + /// sites, so the gate can now be honest. 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 f881032..e82dd93 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/MessagesService.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/MessagesService.swift @@ -67,14 +67,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 59c8255..fd756c4 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 @@ -154,6 +179,10 @@ public final class OrgService: OrgServicing { private let api: APIClientProtocol private let decoder: JSONDecoder + /// The entitlement gate consulted by `create` and by the org-LinkedIn + /// writes. A provider, not a snapshot — see `DocumentsService` for why. + private let entitlements: @Sendable () -> EntitlementsService + private let baseURL: URL /// - Parameters: @@ -161,15 +190,25 @@ public final class OrgService: OrgServicing { /// - 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 gate for `create` and the org-LinkedIn writes. + /// Defaults to `.free` so an un-injected gate is never *wrongly + /// entitled* — the same choice `DocumentsService` makes, and the + /// invariant #40 standardises on. `AppEnvironment` injects the + /// signed-in account's entitlements in production; tests that exercise + /// a gated path pass `.subscriber` explicitly. /// - baseURL: origin for the browser-redirect OAuth URL. Mirrors /// `UserService`'s parameter of the same name. public init( api: APIClientProtocol, decoder: JSONDecoder = JSONCoders.makeDecoder(), + entitlements: @escaping @Sendable () -> EntitlementsService = { + EntitlementsService(customerStatus: .free) + }, baseURL: URL = URL(string: "https://interlinedlist.com")! ) { self.api = api self.decoder = decoder + self.entitlements = entitlements self.baseURL = baseURL } @@ -208,6 +247,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 @@ -361,11 +403,14 @@ public final class OrgService: OrgServicing { OrgLinkedInStatus(from: try await api.send(Organizations.linkedInStatus(id: orgId))) } + /// Discovering the org's LinkedIn company pages. Gated on + /// `.crossPosting` — see `requireCrossPosting()`. public func syncLinkedInPages( of orgId: String, callerRole: OrgRole? ) async throws -> OrgLinkedInStatus { try requireLinkedInManager(callerRole) + try requireCrossPosting() // The 201 body is unmodelled upstream — ignore it and re-read status, // which is the only route that returns the discovered pages. try await api.sendVoid(Organizations.syncLinkedInPages(id: orgId)) @@ -379,6 +424,7 @@ public final class OrgService: OrgServicing { callerRole: OrgRole? ) async throws { try requireLinkedInManager(callerRole) + try requireCrossPosting() guard !userId.isEmpty else { throw OrgLifecycleError.unknownCurrentUser } let body = UpdateOrgLinkedInAssignmentRequest(userId: userId, pageId: pageId) try await api.sendVoid(Organizations.assignLinkedInPage(id: orgId, body)) @@ -393,6 +439,24 @@ public final class OrgService: OrgServicing { OrgLinkedInAuthorization.url(baseURL: baseURL, organizationId: organizationId) } + /// Entitlement gate for the org-LinkedIn writes that *establish* a + /// cross-posting destination. + /// + /// Gated on `.crossPosting`, the same feature that already gates personal + /// cross-posting on `dev`. Leaving these free would let an org route + /// quietly establish a publishing destination that the personal route + /// refuses to create — one product capability with two different answers. + /// + /// Deliberately **not** applied to `disconnectLinkedIn`: a user must always + /// be able to undo a connection, including after a subscription lapses. + /// Nor to `linkedInStatus` / `linkedInAuthorizeURL`, which are a read and a + /// pure projection. + private func requireCrossPosting() throws { + guard entitlements().isEnabled(.crossPosting) else { + throw OrgError.subscriberRequired(.crossPosting) + } + } + /// Owner/admin gate shared by the three org-LinkedIn writes. private func requireLinkedInManager(_ role: OrgRole?) throws { switch role { diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/SharingService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/SharingService.swift index 7678386..63f9dd6 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/SharingService.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/SharingService.swift @@ -111,7 +111,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)) ) @@ -161,7 +161,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)) ) @@ -195,14 +195,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)) ) @@ -221,7 +221,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)) ) @@ -239,7 +239,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/AIServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/AIServiceTests.swift index cbf7b40..f69ee9b 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/AIServiceTests.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/AIServiceTests.swift @@ -35,7 +35,7 @@ final class AIServiceTests: XCTestCase { XCTAssertEqual(availability.quota?.remaining, 47) } - func test_givenSubscriberWithNoProviderKey_whenAvailabilityFetched_thenExplainsWhy() async throws { + func test_givenSubscriberAndNoProvidersConfigured_whenAvailabilityFetched_thenBlamesTheServiceNotTheUser() async throws { let api = StubAPIClient() await api.enqueue(json: #"{"subscriber": true, "providers": []}"#) @@ -44,7 +44,7 @@ final class AIServiceTests: XCTestCase { XCTAssertFalse(availability.isAvailable) XCTAssertEqual( availability.unavailableReason, - "Add your own AI provider key in Settings on interlinedlist.com to use AI features." + "AI features are temporarily unavailable. Nothing is wrong with your account — please try again later." ) } 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..1d79f99 --- /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, + .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/DocumentsServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/DocumentsServiceTests.swift index e1bc90e..68355f9 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/DocumentsServiceTests.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/DocumentsServiceTests.swift @@ -14,7 +14,7 @@ final class DocumentsServiceTests: XCTestCase { // Given let api = StubAPIClient() await api.enqueue(json: Fixtures.paginatedDocuments(ids: ["d1", "d2"])) - let service = DocumentsService(api: api) + let service = DocumentsService(api: api, entitlementsProvider: { EntitlementsService(customerStatus: .subscriber) }) // When let docs = try await service.documents(in: nil, limit: 20, offset: 0) @@ -30,7 +30,7 @@ final class DocumentsServiceTests: XCTestCase { // Given let api = StubAPIClient() await api.enqueue(json: Fixtures.paginatedDocuments(ids: ["d1"])) - let service = DocumentsService(api: api) + let service = DocumentsService(api: api, entitlementsProvider: { EntitlementsService(customerStatus: .subscriber) }) // When _ = try await service.documents(in: "folder-42", limit: 20, offset: 0) @@ -44,7 +44,7 @@ final class DocumentsServiceTests: XCTestCase { // Given — boundary: zero documents. let api = StubAPIClient() await api.enqueue(json: Fixtures.paginatedDocuments(ids: [])) - let service = DocumentsService(api: api) + let service = DocumentsService(api: api, entitlementsProvider: { EntitlementsService(customerStatus: .subscriber) }) // When let docs = try await service.documents(in: nil, limit: 20, offset: 0) @@ -57,7 +57,7 @@ final class DocumentsServiceTests: XCTestCase { // Given let api = StubAPIClient() await api.enqueue(failure: .unauthorized(serverMessage: "sign in")) - let service = DocumentsService(api: api) + let service = DocumentsService(api: api, entitlementsProvider: { EntitlementsService(customerStatus: .subscriber) }) // When / Then do { @@ -74,7 +74,7 @@ final class DocumentsServiceTests: XCTestCase { // Given let api = StubAPIClient() await api.enqueue(json: Fixtures.documentEnvelope(id: "d-42", title: "Notes", content: "# H")) - let service = DocumentsService(api: api) + let service = DocumentsService(api: api, entitlementsProvider: { EntitlementsService(customerStatus: .subscriber) }) // When let doc = try await service.document(id: "d-42") @@ -89,7 +89,7 @@ final class DocumentsServiceTests: XCTestCase { // Given let api = StubAPIClient() await api.enqueue(failure: .notFound(serverMessage: nil)) - let service = DocumentsService(api: api) + let service = DocumentsService(api: api, entitlementsProvider: { EntitlementsService(customerStatus: .subscriber) }) // When / Then do { @@ -104,7 +104,7 @@ final class DocumentsServiceTests: XCTestCase { // Given — invalid input → 400 from the server. let api = StubAPIClient() await api.enqueue(failure: .badRequest(serverMessage: "bad id")) - let service = DocumentsService(api: api) + let service = DocumentsService(api: api, entitlementsProvider: { EntitlementsService(customerStatus: .subscriber) }) // When / Then do { @@ -119,7 +119,7 @@ final class DocumentsServiceTests: XCTestCase { // Given — boundary: server returns null content. let api = StubAPIClient() await api.enqueue(json: Fixtures.documentEnvelope(id: "d", content: nil)) - let service = DocumentsService(api: api) + let service = DocumentsService(api: api, entitlementsProvider: { EntitlementsService(customerStatus: .subscriber) }) // When let doc = try await service.document(id: "d") @@ -134,7 +134,7 @@ final class DocumentsServiceTests: XCTestCase { // Given let api = StubAPIClient() await api.enqueue(json: Fixtures.documentEnvelope(id: "d-new", title: "New", content: "Body")) - let service = DocumentsService(api: api) + let service = DocumentsService(api: api, entitlementsProvider: { EntitlementsService(customerStatus: .subscriber) }) // When let doc = try await service.create(title: "New", body: "Body", folderId: nil, isPublic: false) @@ -150,7 +150,7 @@ final class DocumentsServiceTests: XCTestCase { // Given — invalid input → 400. let api = StubAPIClient() await api.enqueue(failure: .badRequest(serverMessage: "title required")) - let service = DocumentsService(api: api) + let service = DocumentsService(api: api, entitlementsProvider: { EntitlementsService(customerStatus: .subscriber) }) // When / Then do { @@ -165,7 +165,7 @@ final class DocumentsServiceTests: XCTestCase { // Given — boundary: API permits empty body. let api = StubAPIClient() await api.enqueue(json: Fixtures.documentEnvelope(id: "d-e", content: "")) - let service = DocumentsService(api: api) + let service = DocumentsService(api: api, entitlementsProvider: { EntitlementsService(customerStatus: .subscriber) }) // When let doc = try await service.create(title: "T", body: "", folderId: nil, isPublic: false) @@ -180,7 +180,7 @@ final class DocumentsServiceTests: XCTestCase { // Given let api = StubAPIClient() await api.enqueue(json: Fixtures.documentEnvelope(id: "d", title: "Renamed")) - let service = DocumentsService(api: api) + let service = DocumentsService(api: api, entitlementsProvider: { EntitlementsService(customerStatus: .subscriber) }) // When let doc = try await service.update(id: "d", title: "Renamed", body: nil, folderId: nil, isPublic: nil) @@ -196,7 +196,7 @@ final class DocumentsServiceTests: XCTestCase { // Given let api = StubAPIClient() await api.enqueue(failure: .notFound(serverMessage: nil)) - let service = DocumentsService(api: api) + let service = DocumentsService(api: api, entitlementsProvider: { EntitlementsService(customerStatus: .subscriber) }) // When / Then do { @@ -211,7 +211,7 @@ final class DocumentsServiceTests: XCTestCase { // Given — invalid input case: user doesn't own this document. let api = StubAPIClient() await api.enqueue(failure: .forbidden(serverMessage: "not yours")) - let service = DocumentsService(api: api) + let service = DocumentsService(api: api, entitlementsProvider: { EntitlementsService(customerStatus: .subscriber) }) // When / Then do { @@ -226,7 +226,7 @@ final class DocumentsServiceTests: XCTestCase { // Given — boundary: every field nil. Server may reject; service does not. let api = StubAPIClient() await api.enqueue(json: Fixtures.documentEnvelope(id: "d")) - let service = DocumentsService(api: api) + let service = DocumentsService(api: api, entitlementsProvider: { EntitlementsService(customerStatus: .subscriber) }) // When / Then — no throw. _ = try await service.update(id: "d", title: nil, body: nil, folderId: nil, isPublic: nil) @@ -238,7 +238,7 @@ final class DocumentsServiceTests: XCTestCase { // Given let api = StubAPIClient() await api.enqueue(json: "{}") - let service = DocumentsService(api: api) + let service = DocumentsService(api: api, entitlementsProvider: { EntitlementsService(customerStatus: .subscriber) }) // When try await service.delete(id: "d-1") @@ -253,7 +253,7 @@ final class DocumentsServiceTests: XCTestCase { // Given let api = StubAPIClient() await api.enqueue(failure: .notFound(serverMessage: nil)) - let service = DocumentsService(api: api) + let service = DocumentsService(api: api, entitlementsProvider: { EntitlementsService(customerStatus: .subscriber) }) // When / Then do { @@ -268,7 +268,7 @@ final class DocumentsServiceTests: XCTestCase { // Given let api = StubAPIClient() await api.enqueue(failure: .forbidden(serverMessage: "not yours")) - let service = DocumentsService(api: api) + let service = DocumentsService(api: api, entitlementsProvider: { EntitlementsService(customerStatus: .subscriber) }) // When / Then do { @@ -283,7 +283,7 @@ final class DocumentsServiceTests: XCTestCase { // Given — boundary. Server validates; service forwards. let api = StubAPIClient() await api.enqueue(failure: .badRequest(serverMessage: "missing id")) - let service = DocumentsService(api: api) + let service = DocumentsService(api: api, entitlementsProvider: { EntitlementsService(customerStatus: .subscriber) }) // When / Then do { @@ -303,7 +303,7 @@ final class DocumentsServiceTests: XCTestCase { await api.enqueue(json: Fixtures.documentImageUploadResponse( url: "https://cdn.interlinedlist.com/uploads/x.png" )) - let service = DocumentsService(api: api) + let service = DocumentsService(api: api, entitlementsProvider: { EntitlementsService(customerStatus: .subscriber) }) // When let url = try await service.uploadImage(in: "doc-1", image: image, suggestedName: "x.png") @@ -318,7 +318,7 @@ final class DocumentsServiceTests: XCTestCase { func test_givenUndecodableBytes_whenUploadingImage_thenThrowsImagePrepError() async { // Given — invalid input: non-image bytes. let api = StubAPIClient() - let service = DocumentsService(api: api) + let service = DocumentsService(api: api, entitlementsProvider: { EntitlementsService(customerStatus: .subscriber) }) // When / Then do { @@ -336,7 +336,7 @@ final class DocumentsServiceTests: XCTestCase { let image = makeSmoothPNG(width: 64, height: 64) let api = StubAPIClient() await api.enqueue(failure: .forbidden(serverMessage: "subscriber feature")) - let service = DocumentsService(api: api) + let service = DocumentsService(api: api, entitlementsProvider: { EntitlementsService(customerStatus: .subscriber) }) // When / Then do { @@ -352,7 +352,7 @@ final class DocumentsServiceTests: XCTestCase { let image = makeSmoothPNG(width: 64, height: 64) let api = StubAPIClient() await api.enqueue(json: Fixtures.documentImageUploadResponse(url: "")) - let service = DocumentsService(api: api) + let service = DocumentsService(api: api, entitlementsProvider: { EntitlementsService(customerStatus: .subscriber) }) // When / Then do { @@ -373,7 +373,7 @@ final class DocumentsServiceTests: XCTestCase { // Given let api = StubAPIClient() await api.enqueue(json: Fixtures.paginatedFolders(ids: ["f1", "f2"])) - let service = DocumentsService(api: api) + let service = DocumentsService(api: api, entitlementsProvider: { EntitlementsService(customerStatus: .subscriber) }) // When let folders = try await service.folders(limit: 20, offset: 0) @@ -386,7 +386,7 @@ final class DocumentsServiceTests: XCTestCase { // Given — boundary. let api = StubAPIClient() await api.enqueue(json: Fixtures.paginatedFolders(ids: [])) - let service = DocumentsService(api: api) + let service = DocumentsService(api: api, entitlementsProvider: { EntitlementsService(customerStatus: .subscriber) }) // When let folders = try await service.folders(limit: 20, offset: 0) @@ -399,7 +399,7 @@ final class DocumentsServiceTests: XCTestCase { // Given let api = StubAPIClient() await api.enqueue(failure: .transport(message: "offline")) - let service = DocumentsService(api: api) + let service = DocumentsService(api: api, entitlementsProvider: { EntitlementsService(customerStatus: .subscriber) }) // When / Then do { @@ -414,7 +414,7 @@ final class DocumentsServiceTests: XCTestCase { // Given let api = StubAPIClient() await api.enqueue(json: Fixtures.folderEnvelope(id: "f1", name: "Inbox")) - let service = DocumentsService(api: api) + let service = DocumentsService(api: api, entitlementsProvider: { EntitlementsService(customerStatus: .subscriber) }) // When let folder = try await service.folder(id: "f1") @@ -428,7 +428,7 @@ final class DocumentsServiceTests: XCTestCase { // Given let api = StubAPIClient() await api.enqueue(failure: .notFound(serverMessage: nil)) - let service = DocumentsService(api: api) + let service = DocumentsService(api: api, entitlementsProvider: { EntitlementsService(customerStatus: .subscriber) }) // When / Then do { @@ -443,7 +443,7 @@ final class DocumentsServiceTests: XCTestCase { // Given let api = StubAPIClient() await api.enqueue(json: Fixtures.folderEnvelope(id: "f-new", name: "New")) - let service = DocumentsService(api: api) + let service = DocumentsService(api: api, entitlementsProvider: { EntitlementsService(customerStatus: .subscriber) }) // When let folder = try await service.createFolder(name: "New", parentId: nil) @@ -459,7 +459,7 @@ final class DocumentsServiceTests: XCTestCase { // Given let api = StubAPIClient() await api.enqueue(failure: .badRequest(serverMessage: "name required")) - let service = DocumentsService(api: api) + let service = DocumentsService(api: api, entitlementsProvider: { EntitlementsService(customerStatus: .subscriber) }) // When / Then do { @@ -474,7 +474,7 @@ final class DocumentsServiceTests: XCTestCase { // Given let api = StubAPIClient() await api.enqueue(json: Fixtures.folderEnvelope(id: "f1", name: "Renamed")) - let service = DocumentsService(api: api) + let service = DocumentsService(api: api, entitlementsProvider: { EntitlementsService(customerStatus: .subscriber) }) // When let folder = try await service.renameFolder(id: "f1", to: "Renamed") @@ -491,7 +491,7 @@ final class DocumentsServiceTests: XCTestCase { // Given let api = StubAPIClient() await api.enqueue(json: "{}") - let service = DocumentsService(api: api) + let service = DocumentsService(api: api, entitlementsProvider: { EntitlementsService(customerStatus: .subscriber) }) // When try await service.deleteFolder(id: "f1") @@ -506,7 +506,7 @@ final class DocumentsServiceTests: XCTestCase { // Given let api = StubAPIClient() await api.enqueue(failure: .notFound(serverMessage: nil)) - let service = DocumentsService(api: api) + let service = DocumentsService(api: api, entitlementsProvider: { EntitlementsService(customerStatus: .subscriber) }) // When / Then do { @@ -522,7 +522,7 @@ final class DocumentsServiceTests: XCTestCase { func test_givenNoCoordinator_whenSyncing_thenThrowsSyncFailed() async { // Given — boundary: no coordinator injected. let api = StubAPIClient() - let service = DocumentsService(api: api) + let service = DocumentsService(api: api, entitlementsProvider: { EntitlementsService(customerStatus: .subscriber) }) // When / Then do { @@ -542,7 +542,7 @@ final class DocumentsServiceTests: XCTestCase { func test_givenNoCoordinator_whenReadingSyncEvents_thenReturnsNil() async { // Given let api = StubAPIClient() - let service = DocumentsService(api: api) + let service = DocumentsService(api: api, entitlementsProvider: { EntitlementsService(customerStatus: .subscriber) }) // When / Then XCTAssertNil(service.syncEvents) diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/DocumentsTreeServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/DocumentsTreeServiceTests.swift index 4966758..e6c347a 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/DocumentsTreeServiceTests.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/DocumentsTreeServiceTests.swift @@ -249,9 +249,9 @@ final class DocumentsTreeServiceTests: XCTestCase { _ = try await service.createDocument( inFolder: "f1", title: "Notes", body: "", isPublic: false, relativePath: nil ) - XCTFail("Expected DocumentsError.subscriberRequired") + XCTFail("Expected DocumentsError.subscriberRequired(.documentCreation)") } catch let error as DocumentsError { - XCTAssertEqual(error, .subscriberRequired) + XCTAssertEqual(error, .subscriberRequired(.documentCreation)) } let recorded = await api.recorded XCTAssertTrue(recorded.isEmpty, "the gate must run before the HTTP call") @@ -268,9 +268,9 @@ final class DocumentsTreeServiceTests: XCTestCase { _ = try await service.createDocument( inFolder: "f1", title: "Notes", body: "", isPublic: false, relativePath: nil ) - XCTFail("Expected DocumentsError.subscriberRequired") + XCTFail("Expected DocumentsError.subscriberRequired(.documentCreation)") } catch let error as DocumentsError { - XCTAssertEqual(error, .subscriberRequired) + XCTAssertEqual(error, .subscriberRequired(.documentCreation)) } } 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..95c4373 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, + .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/GatingMatrixTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/GatingMatrixTests.swift new file mode 100644 index 0000000..3d6d757 --- /dev/null +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/GatingMatrixTests.swift @@ -0,0 +1,234 @@ +// GatingMatrixTests +// +// Guards the entitlement matrix settled for GitHub #40 / #41 / #42 against the +// surfaces G23 (lists sharing), G24 (documents tree) and G25 (orgs + org +// LinkedIn) added *after* the capability-gate branch was written. +// +// The rule these tests exist to hold: **creation is gated; managing, moving and +// leaving what you already have is free.** Two failure directions matter +// equally, so both are asserted throughout — +// +// • a gated action must be refused *before* the HTTP call (a client that lets +// the request through turns a explainable refusal into a bare 403), and +// • a free action must NOT be refused, because the historical bug here was a +// single over-broad seam: `requireListManagement()` once guarded all 24 +// `ListsService` write methods including pure reads, and tightening it would +// have stopped free users reading their own lists. +// +// The "no HTTP call was made" assertion is the load-bearing one. Asserting only +// that an error was thrown would pass even if the gate ran after the round-trip. + +import XCTest +@testable import InterlinedDomain + +final class GatingMatrixTests: XCTestCase { + + private func free() -> EntitlementsService { EntitlementsService(customerStatus: .free) } + private func subscriber() -> EntitlementsService { EntitlementsService(customerStatus: .subscriber) } + + // MARK: - Lists · addWatcher is `sharingWithPeople`, not list creation + + func test_givenFreeAccount_whenAddingAWatcher_thenRefusesBeforeAnyHTTPCall() async throws { + let api = StubAPIClient() + let service = ListsService(api: api, entitlements: free()) + + do { + try await service.addWatcher(listId: "l-1", userId: "u-2", role: .viewer, notify: false) + XCTFail("Expected ListsError.subscriberRequired") + } catch let error as ListsError { + XCTAssertEqual(error, .subscriberRequired) + } + + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty, "the gate must run before the HTTP call, not after the 403") + } + + func test_givenSubscriber_whenAddingAWatcher_thenTheRequestIsSent() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"message":"ok"}"#) + let service = ListsService(api: api, entitlements: subscriber()) + + try await service.addWatcher(listId: "l-1", userId: "u-2", role: .viewer, notify: false) + + let recorded = await api.recorded + XCTAssertEqual(recorded.count, 1) + } + + func test_givenFreeAccountAndBlankUserId_whenAddingAWatcher_thenReportsTheInvalidInputNotTheEntitlement() async throws { + // Ordering matters for the message the user sees: a blank recipient is + // wrong regardless of tier, and telling a free user to subscribe in + // order to fix a typo would be actively misleading. + let api = StubAPIClient() + let service = ListsService(api: api, entitlements: free()) + + do { + try await service.addWatcher(listId: "l-1", userId: " ", role: .viewer, notify: false) + XCTFail("Expected ListsError.invalidWatcher") + } catch let error as ListsError { + XCTAssertEqual(error, .invalidWatcher) + } + + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty) + } + + // MARK: - Lists · reads stay free (the 24-call-site regression guard) + + func test_givenFreeAccount_whenListingWatchedLists_thenItIsNotGated() async throws { + let api = StubAPIClient() + await api.enqueue(json: Fixtures.watchingEnvelope([(id: "l-1", role: "watcher")])) + let service = ListsService(api: api, entitlements: free()) + + let page = try await service.watching(limit: 50, offset: 0) + + XCTAssertEqual(page.lists.map(\.id), ["l-1"]) + } + + func test_givenFreeAccount_whenReadingContributors_thenItIsNotGated() async throws { + let api = StubAPIClient() + await api.enqueue(json: #"{"contributors":[]}"#) + let service = ListsService(api: api, entitlements: free()) + + let contributors = try await service.contributors(of: "l-1") + + XCTAssertTrue(contributors.isEmpty) + } + + func test_givenFreeAccount_whenListingWatcherCandidates_thenItIsNotGated() async throws { + // Listing who *could* be added is a read; the gate belongs on the action + // that actually shares the list. + let api = StubAPIClient() + await api.enqueue(json: #"{"users":[]}"#) + let service = ListsService(api: api, entitlements: free()) + + let candidates = try await service.watcherCandidates(of: "l-1", search: nil, limit: 20) + + XCTAssertTrue(candidates.isEmpty) + } + + // MARK: - Documents · creation gated, moving free + + func test_givenFreeAccount_whenCreatingADocument_thenRefusesWithTheCreationFeature() async throws { + let api = StubAPIClient() + let service = DocumentsService(api: api, entitlementsProvider: { EntitlementsService(customerStatus: .free) }) + + do { + _ = try await service.create(title: "N", body: "B", folderId: nil, isPublic: false) + XCTFail("Expected DocumentsError.subscriberRequired(.documentCreation)") + } catch let error as DocumentsError { + XCTAssertEqual(error, .subscriberRequired(.documentCreation)) + } + + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty) + } + + func test_givenFreeAccount_whenMovingADocument_thenItIsNotGated() async throws { + // A lapsed subscriber keeps existing content "fully usable" — moving is + // reorganising what you already have, not creating something new. + let api = StubAPIClient() + await api.enqueue(json: Fixtures.documentEnvelope(id: "d-1", title: "N", content: "B")) + let service = DocumentsService(api: api, entitlementsProvider: { EntitlementsService(customerStatus: .free) }) + + let moved = try await service.moveDocument(id: "d-1", toFolder: "f-2") + + XCTAssertEqual(moved.id, "d-1") + } + + // MARK: - Orgs · lifecycle free, LinkedIn destinations gated + + func test_givenFreeAccount_whenSyncingOrgLinkedInPages_thenRefusesBeforeAnyHTTPCall() async throws { + // Establishing a LinkedIn company page as a publishing destination is + // cross-posting. Leaving it free would let the org route quietly create + // a destination the personal cross-post route refuses to create. + let api = StubAPIClient() + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .free) }) + + do { + _ = try await service.syncLinkedInPages(of: "o-1", callerRole: .owner) + XCTFail("Expected OrgError.subscriberRequired(.crossPosting)") + } catch let error as OrgError { + XCTAssertEqual(error, .subscriberRequired(.crossPosting)) + } + + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty) + } + + func test_givenFreeAccount_whenAssigningALinkedInPage_thenRefusesBeforeAnyHTTPCall() async throws { + let api = StubAPIClient() + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .free) }) + + do { + try await service.assignLinkedInPage(in: "o-1", userId: "u-1", pageId: "p-1", callerRole: .owner) + XCTFail("Expected OrgError.subscriberRequired(.crossPosting)") + } catch let error as OrgError { + XCTAssertEqual(error, .subscriberRequired(.crossPosting)) + } + + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty) + } + + func test_givenFreeAccount_whenDisconnectingOrgLinkedIn_thenItIsNotGated() async throws { + // Deliberately ungated: a user must always be able to undo a connection, + // including *because* their subscription lapsed. Gating this would trap + // them in a destination they can no longer manage. + let api = StubAPIClient() + await api.enqueue(json: #"{"message":"ok"}"#) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .free) }) + + try await service.disconnectLinkedIn(from: "o-1", callerRole: .owner) + + let recorded = await api.recorded + XCTAssertEqual(recorded.count, 1, "disconnect must reach the server on every tier") + } + + func test_givenFreeAccountAndNonManagerRole_whenSyncingPages_thenReportsTheRoleNotTheEntitlement() async throws { + // Role is the harder gate and is checked first: telling a viewer to + // subscribe would be wrong, since subscribing would not grant them the + // permission they actually lack. + let api = StubAPIClient() + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .free) }) + + do { + _ = try await service.syncLinkedInPages(of: "o-1", callerRole: .member) + XCTFail("Expected a role refusal, not an entitlement refusal") + } catch let error as OrgError { + XCTAssertNotEqual(error, .subscriberRequired(.crossPosting)) + } catch { + // A role-specific error type is equally acceptable here; what must + // not happen is the entitlement error masking the real reason. + } + + let recorded = await api.recorded + XCTAssertTrue(recorded.isEmpty) + } + + // MARK: - The matrix itself + + func test_givenTheFeatureEnum_thenItCarriesNoListFolderCase() { + // List folders were removed from macOS in PR #19 and confirmed not + // returning (GitHub #49). A gate for a feature the client does not have + // is dead code that reads like a promise. + XCTAssertEqual(Feature.allCases.count, 11) + XCTAssertFalse(Feature.allCases.contains { "\($0)".contains("listFolder") }) + } + + func test_givenAFreeAccount_thenExactlyTheCreationAndReachFeaturesAreDisabled() { + let entitlements = free() + for feature in Feature.allCases { + XCTAssertFalse( + entitlements.isEnabled(feature), + "\(feature) should require a subscription on the published matrix" + ) + } + } + + func test_givenASubscriber_thenEveryFeatureIsEnabled() { + let entitlements = subscriber() + for feature in Feature.allCases { + XCTAssertTrue(entitlements.isEnabled(feature), "\(feature) should be enabled for a subscriber") + } + } +} diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OrgLinkedInServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OrgLinkedInServiceTests.swift index 9431180..0991e55 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OrgLinkedInServiceTests.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OrgLinkedInServiceTests.swift @@ -24,7 +24,7 @@ final class OrgLinkedInServiceTests: XCTestCase { "pageLogoUrl":"https://cdn/l.png"}], "assignments":[{"userId":"u2","pageId":"p1","pageName":"Acme Corp"}]} """#) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) let status = try await service.linkedInStatus(of: "o1") @@ -47,7 +47,7 @@ final class OrgLinkedInServiceTests: XCTestCase { // not be offered the management controls. let api = StubAPIClient() await api.enqueue(json: #"{"credential":null,"role":"member"}"#) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) let status = try await service.linkedInStatus(of: "o1") @@ -64,7 +64,7 @@ final class OrgLinkedInServiceTests: XCTestCase { await api.enqueue(json: #""" {"credential":{"expiresAt":"2026-12-01T00:00:00.000Z"},"role":"admin","pages":[]} """#) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) let status = try await service.linkedInStatus(of: "o1") @@ -77,7 +77,7 @@ final class OrgLinkedInServiceTests: XCTestCase { // Upstream API failure. let api = StubAPIClient() await api.enqueue(failure: .httpStatus(code: 502, serverMessage: "linkedin down")) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) do { _ = try await service.linkedInStatus(of: "o1") @@ -92,7 +92,7 @@ final class OrgLinkedInServiceTests: XCTestCase { // stay hidden rather than rendering an action the server will reject. let api = StubAPIClient() await api.enqueue(json: #"{"credential":null,"role":"guest"}"#) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) let status = try await service.linkedInStatus(of: "o1") @@ -111,7 +111,7 @@ final class OrgLinkedInServiceTests: XCTestCase { {"connected":true,"role":"owner", "pages":[{"id":"p1","pageName":"Acme Corp"},{"id":"p2","pageName":"Acme Labs"}]} """#) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) let status = try await service.syncLinkedInPages(of: "o1", callerRole: .owner) @@ -125,7 +125,7 @@ final class OrgLinkedInServiceTests: XCTestCase { func test_givenMember_whenSyncingPages_thenRejectsBeforeAnyServiceCall() async throws { // Invalid input: sync is owner/admin only. let api = StubAPIClient() - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) do { _ = try await service.syncLinkedInPages(of: "o1", callerRole: .member) @@ -143,7 +143,7 @@ final class OrgLinkedInServiceTests: XCTestCase { // sync, so the caller can keep showing its stale page list. let api = StubAPIClient() await api.enqueue(failure: .httpStatus(code: 502, serverMessage: "linkedin down")) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) do { _ = try await service.syncLinkedInPages(of: "o1", callerRole: .owner) @@ -161,7 +161,7 @@ final class OrgLinkedInServiceTests: XCTestCase { let api = StubAPIClient() await api.enqueue(json: "{}") await api.enqueue(json: #"{"connected":true,"role":"owner","pages":[]}"#) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) let status = try await service.syncLinkedInPages(of: "o1", callerRole: .owner) @@ -175,7 +175,7 @@ final class OrgLinkedInServiceTests: XCTestCase { // Happy path. let api = StubAPIClient() await api.enqueue(json: "{}") - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) try await service.assignLinkedInPage(in: "o1", userId: "u2", pageId: "p1", callerRole: .admin) @@ -188,7 +188,7 @@ final class OrgLinkedInServiceTests: XCTestCase { // Boundary: clearing an assignment sends a null page reference. let api = StubAPIClient() await api.enqueue(json: "{}") - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) try await service.assignLinkedInPage(in: "o1", userId: "u2", pageId: nil, callerRole: .owner) @@ -199,7 +199,7 @@ final class OrgLinkedInServiceTests: XCTestCase { func test_givenMember_whenAssigningPage_thenRejectsBeforeAnyServiceCall() async throws { // Invalid input: assignment is owner/admin only. let api = StubAPIClient() - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) do { try await service.assignLinkedInPage(in: "o1", userId: "u2", pageId: "p1", callerRole: .member) @@ -214,7 +214,7 @@ final class OrgLinkedInServiceTests: XCTestCase { func test_givenBlankUserId_whenAssigningPage_thenRejectsBeforeAnyServiceCall() async throws { // Invalid input: an assignment needs someone to assign to. let api = StubAPIClient() - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) do { try await service.assignLinkedInPage(in: "o1", userId: "", pageId: "p1", callerRole: .owner) @@ -230,7 +230,7 @@ final class OrgLinkedInServiceTests: XCTestCase { // Upstream API failure. let api = StubAPIClient() await api.enqueue(failure: .badRequest(serverMessage: "unknown page")) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) do { try await service.assignLinkedInPage(in: "o1", userId: "u2", pageId: "nope", callerRole: .owner) @@ -246,7 +246,7 @@ final class OrgLinkedInServiceTests: XCTestCase { // Happy path. let api = StubAPIClient() await api.enqueue(json: "{}") - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) try await service.disconnectLinkedIn(from: "o1", callerRole: .owner) @@ -259,7 +259,7 @@ final class OrgLinkedInServiceTests: XCTestCase { // Invalid input: disconnect is owner/admin only — and it silently // redirects every assigned member's cross-posts, so the gate matters. let api = StubAPIClient() - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) do { try await service.disconnectLinkedIn(from: "o1", callerRole: .member) @@ -275,7 +275,7 @@ final class OrgLinkedInServiceTests: XCTestCase { // Upstream API failure. let api = StubAPIClient() await api.enqueue(failure: .httpStatus(code: 500, serverMessage: "boom")) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) do { try await service.disconnectLinkedIn(from: "o1", callerRole: .owner) diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OrgServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OrgServiceTests.swift index a942b4a..324515a 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OrgServiceTests.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OrgServiceTests.swift @@ -15,7 +15,7 @@ final class OrgServiceTests: XCTestCase { await api.enqueue(json: Fixtures.paginatedOrganizations( ids: ["o-1", "o-2"], limit: 20, offset: 0, hasMore: true )) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) // When let page = try await service.organizations(isPublic: true, userId: "u-9", limit: 20, offset: 0) @@ -39,7 +39,7 @@ final class OrgServiceTests: XCTestCase { // the malformed envelope fails rather than being silently accepted. let api = StubAPIClient() await api.enqueue(json: #"{"data":{"oops":true},"pagination":{"total":0,"limit":20,"offset":0,"hasMore":false}}"#) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) // When / Then do { @@ -54,7 +54,7 @@ final class OrgServiceTests: XCTestCase { // Given — upstream API failure. let api = StubAPIClient() await api.enqueue(failure: .httpStatus(code: 500, serverMessage: "boom")) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) // When / Then do { @@ -69,7 +69,7 @@ final class OrgServiceTests: XCTestCase { // Given — boundary: zero-item page. let api = StubAPIClient() await api.enqueue(json: Fixtures.paginatedOrganizations(ids: [], hasMore: false)) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) // When let page = try await service.organizations(isPublic: nil, userId: nil, limit: 20, offset: 0) @@ -86,7 +86,7 @@ final class OrgServiceTests: XCTestCase { // Given let api = StubAPIClient() await api.enqueue(json: Fixtures.organizationEnvelope(id: "o-new", name: "Acme", isPublic: false)) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) // When let org = try await service.create(name: "Acme", description: "We make things", isPublic: false) @@ -104,7 +104,7 @@ final class OrgServiceTests: XCTestCase { // Given — invalid input: response missing required `name`. let api = StubAPIClient() await api.enqueue(json: #"{"organization":{"id":"o-new"}}"#) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) // When / Then do { @@ -119,7 +119,7 @@ final class OrgServiceTests: XCTestCase { // Given — upstream API failure (e.g. quota / permissions). let api = StubAPIClient() await api.enqueue(failure: .forbidden(serverMessage: "not allowed")) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) // When / Then do { @@ -134,7 +134,7 @@ final class OrgServiceTests: XCTestCase { // Given — boundary: empty description string is accepted by the API. let api = StubAPIClient() await api.enqueue(json: Fixtures.organizationEnvelope(id: "o-2", description: nil)) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) // When let org = try await service.create(name: "Acme", description: "", isPublic: true) @@ -150,7 +150,7 @@ final class OrgServiceTests: XCTestCase { // Given let api = StubAPIClient() await api.enqueue(json: Fixtures.organizationEnvelope(id: "o-7", name: "Globex")) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) // When let org = try await service.organization(id: "o-7") @@ -166,7 +166,7 @@ final class OrgServiceTests: XCTestCase { // Given — upstream API failure. let api = StubAPIClient() await api.enqueue(failure: .notFound(serverMessage: "no such org")) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) // When / Then do { @@ -181,7 +181,7 @@ final class OrgServiceTests: XCTestCase { // Given — invalid input. let api = StubAPIClient() await api.enqueue(json: #"{"organization":{"oops":true}}"#) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) // When / Then do { @@ -196,7 +196,7 @@ final class OrgServiceTests: XCTestCase { // Given — boundary: server omits createdAt / updatedAt. let api = StubAPIClient() await api.enqueue(json: Fixtures.organizationEnvelope(id: "o-8", includeTimestamps: false)) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) // When let org = try await service.organization(id: "o-8") @@ -212,7 +212,7 @@ final class OrgServiceTests: XCTestCase { // Given let api = StubAPIClient() await api.enqueue(json: Fixtures.organizationEnvelope(id: "o-7", name: "Renamed")) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) // When — only `name` changes. let org = try await service.update(id: "o-7", name: "Renamed", description: nil, isPublic: nil) @@ -229,7 +229,7 @@ final class OrgServiceTests: XCTestCase { // Given — upstream API failure. let api = StubAPIClient() await api.enqueue(failure: .badRequest(serverMessage: "bad name")) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) // When / Then do { @@ -244,7 +244,7 @@ final class OrgServiceTests: XCTestCase { // Given — invalid input. let api = StubAPIClient() await api.enqueue(json: #"not json"#) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) // When / Then do { @@ -259,7 +259,7 @@ final class OrgServiceTests: XCTestCase { // Given — boundary: a no-op patch (all fields nil). let api = StubAPIClient() await api.enqueue(json: Fixtures.organizationEnvelope(id: "o-7")) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) // When let org = try await service.update(id: "o-7", name: nil, description: nil, isPublic: nil) @@ -276,7 +276,7 @@ final class OrgServiceTests: XCTestCase { await api.enqueue(json: Fixtures.paginatedOrgMembers( userIds: ["u-1", "u-2"], role: "admin", limit: 10, offset: 0, hasMore: true )) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) // When let page = try await service.members(of: "o-7", limit: 10, offset: 0) @@ -295,7 +295,7 @@ final class OrgServiceTests: XCTestCase { // Given — upstream API failure. let api = StubAPIClient() await api.enqueue(failure: .forbidden(serverMessage: "not a member")) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) // When / Then do { @@ -311,7 +311,7 @@ final class OrgServiceTests: XCTestCase { // surfaces a `DecodingError`; assert the malformed envelope throws. let api = StubAPIClient() await api.enqueue(json: #"{"data":"nope"}"#) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) // When / Then do { @@ -326,7 +326,7 @@ final class OrgServiceTests: XCTestCase { // Given — boundary: zero-item page. let api = StubAPIClient() await api.enqueue(json: Fixtures.paginatedOrgMembers(userIds: [], hasMore: false)) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) // When let page = try await service.members(of: "o-7", limit: 10, offset: 0) @@ -344,7 +344,7 @@ final class OrgServiceTests: XCTestCase { await api.enqueue(json: Fixtures.orgMembershipResponse( membershipId: "m-1", userId: "u-3", organizationId: "o-7", role: "member" )) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) // When let member = try await service.addMember(to: "o-7", userId: "u-3", role: .member) @@ -362,7 +362,7 @@ final class OrgServiceTests: XCTestCase { // Given — upstream API failure. let api = StubAPIClient() await api.enqueue(failure: .badRequest(serverMessage: "already a member")) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) // When / Then do { @@ -377,7 +377,7 @@ final class OrgServiceTests: XCTestCase { // Given — invalid input: missing `membership`. let api = StubAPIClient() await api.enqueue(json: #"{"message":"ok"}"#) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) // When / Then do { @@ -394,7 +394,7 @@ final class OrgServiceTests: XCTestCase { await api.enqueue(json: Fixtures.orgMembershipResponse( membershipId: "m-2", userId: "u-4", organizationId: "o-7", role: "billing-admin" )) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) // When let member = try await service.addMember(to: "o-7", userId: "u-4", role: .member) @@ -411,7 +411,7 @@ final class OrgServiceTests: XCTestCase { await api.enqueue(json: Fixtures.orgMembershipResponse( membershipId: "m-1", userId: "u-3", organizationId: "o-7", role: "admin" )) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) // When let member = try await service.updateMember(in: "o-7", userId: "u-3", role: .admin, active: true) @@ -428,7 +428,7 @@ final class OrgServiceTests: XCTestCase { // Given — upstream API failure (e.g. caller is not an owner). let api = StubAPIClient() await api.enqueue(failure: .forbidden(serverMessage: "owners only")) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) // When / Then do { @@ -443,7 +443,7 @@ final class OrgServiceTests: XCTestCase { // Given — invalid input. let api = StubAPIClient() await api.enqueue(json: #"{"membership":{"id":"m-1"}}"#) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) // When / Then do { @@ -460,7 +460,7 @@ final class OrgServiceTests: XCTestCase { await api.enqueue(json: Fixtures.orgMembershipResponse( membershipId: "m-1", userId: "u-3", organizationId: "o-7", role: "member", active: nil )) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) // When let member = try await service.updateMember(in: "o-7", userId: "u-3", role: .member, active: nil) @@ -475,7 +475,7 @@ final class OrgServiceTests: XCTestCase { // Given let api = StubAPIClient() await api.enqueue(json: #"{}"#) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) // When try await service.removeMember(from: "o-7", userId: "u-3") @@ -490,7 +490,7 @@ final class OrgServiceTests: XCTestCase { // Given — upstream API failure. let api = StubAPIClient() await api.enqueue(failure: .notFound(serverMessage: "not a member")) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) // When / Then do { @@ -505,7 +505,7 @@ final class OrgServiceTests: XCTestCase { // Given — invalid input boundary: empty user id → server 400. let api = StubAPIClient() await api.enqueue(failure: .badRequest(serverMessage: "missing user id")) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) // When / Then do { @@ -525,7 +525,7 @@ final class OrgServiceTests: XCTestCase { (id: "u-1", role: "owner"), (id: "u-2", role: "member") ])) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) // When let users = try await service.users(of: "o-7") @@ -542,7 +542,7 @@ final class OrgServiceTests: XCTestCase { // Given — upstream API failure. let api = StubAPIClient() await api.enqueue(failure: .httpStatus(code: 503, serverMessage: "down")) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) // When / Then do { @@ -557,7 +557,7 @@ final class OrgServiceTests: XCTestCase { // Given — invalid input: object where an array is expected. let api = StubAPIClient() await api.enqueue(json: #"{"users":[]}"#) - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) // When / Then do { @@ -572,7 +572,7 @@ final class OrgServiceTests: XCTestCase { // Given — boundary: empty roster. let api = StubAPIClient() await api.enqueue(json: "[]") - let service = OrgService(api: api) + let service = OrgService(api: api, entitlements: { EntitlementsService(customerStatus: .subscriber) }) // When let users = try await service.users(of: "o-7") diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OwnedListsServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/OwnedListsServiceTests.swift index 9142d2d..ded98aa 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..7f70792 --- /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, entitlementsProvider: 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, entitlementsProvider: 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(.documentCreation)") + } 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, entitlementsProvider: 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, entitlementsProvider: 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, entitlementsProvider: { + 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(.documentCreation)") + } 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 b638691..d4d216e 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 @@ -334,7 +345,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/Sources/InterlinedKit/Endpoints/AuthEndpoint.swift b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/AuthEndpoint.swift index 00a438e..6205148 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/AuthEndpoint.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/AuthEndpoint.swift @@ -10,8 +10,14 @@ import Foundation /// /// Auth requirements per decision 0001 and the live probe: /// - `forgotPassword`, `resetPassword`, `verifyEmail` — `.none` (public). -/// - `sendVerificationEmail` — `.bearer` (the live endpoint returned 401 for an -/// unauthenticated request, i.e. it identifies the account from the session). +/// - `sendVerificationEmail` — ⚠️ **UNREACHABLE under Bearer. Do not wire this up.** +/// Corrected 2026-09-14 by live probe: `POST /api/auth/send-verification-email` +/// with a valid Bearer sync-token returns **401 `{"error":"Unauthorized"}`**, and +/// the live OpenAPI marks it `x-auth-type: session`. The earlier `.bearer` +/// annotation inferred the wrong thing from a 401 for an *unauthenticated* +/// caller — that only ever proved anonymous fails, never that Bearer succeeds. +/// The resend affordance therefore deep-links to web Settings ▸ Security; see +/// `EmailVerificationResend.resendURL(baseURL:)` in InterlinedDomain. /// - `logout` — `.session` (clears the cookie session; the bearer token is a /// separate, client-held secret cleared by `TokenStore.delete()`). public enum Auth { @@ -41,8 +47,15 @@ public enum Auth { } /// `POST /api/auth/send-verification-email` — (re)send the verification - /// email for the given address. `.bearer` because the live endpoint - /// requires an authenticated caller. + /// email for the given address. + /// + /// ⚠️ **This route rejects Bearer (401), verified live 2026-09-14.** The + /// builder is retained so the operation stays inventoried against the spec, + /// but nothing in the App or Domain layer calls it and nothing should: use + /// `EmailVerificationResend.resendURL(baseURL:)` to hand the user to the web + /// session that can actually perform it. Kept `.bearer` rather than + /// `.session` because this client has no cookie-session transport at all — + /// relabelling it would imply a capability that does not exist. public static func sendVerificationEmail(email: String) -> Request { Request( method: .post, diff --git a/Packages/InterlinedKit/Tests/InterlinedKitTests/ContractTests.swift b/Packages/InterlinedKit/Tests/InterlinedKitTests/ContractTests.swift index 23951d2..6df2f9b 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 e6f93cb..6979d83 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 diff --git a/work-consolidation.md b/work-consolidation.md index 516b6b5..db1a9c4 100644 --- a/work-consolidation.md +++ b/work-consolidation.md @@ -22,6 +22,44 @@ The July web-parity batch (`feature/web-parity-batch-2026-07`) merged into `dev` Milestones **M0–M7** feature work is complete; post-milestone items NW-1…NW-6, S1/S3/S4, B8 are done. +**Capability gating — ✅ SHIPPED 2026-09-14 (GitHub [#42](https://github.com/CompositeCode/interlinedlist-macos-native/issues/42) / [#40](https://github.com/CompositeCode/interlinedlist-macos-native/issues/40) / [#41](https://github.com/CompositeCode/interlinedlist-macos-native/issues/41) / [#39](https://github.com/CompositeCode/interlinedlist-macos-native/issues/39)).** + +`accountStatus` is modelled, the entitlement matrix went from **3 `Feature` cases to 11**, and email +verification gates the surfaces that need it. `CapabilityGate` answers all three as one question — +**status → email verification → tier, hardest first** — so the user gets the reason they can act on +rather than whichever check happened to run first. + +The rule the matrix encodes: **creation is gated; managing, moving and leaving what you already have +is free.** A lapsed subscriber keeps existing content fully usable. + +- **Gated:** post/reply media, scheduled posts, cross-posting, list creation, document creation, + document-template creation, organization creation, sharing-with-people (including + `ListsService.addWatcher`), email invites, share links, AI — **and org LinkedIn + `syncLinkedInPages` / `assignLinkedInPage`**, which establish a publishing destination and are + therefore `.crossPosting`, aligning org LinkedIn with the personal cross-post gate. +- **Free:** every read, `moveDocument`, org `delete` / `leave` / `setMemberSuspended`, and + `disconnectLinkedIn` — a user must always be able to undo a connection, including *because* their + subscription lapsed. +- **No `listFolderCreation` case.** List folders are not returning to macOS + ([#49](https://github.com/CompositeCode/interlinedlist-macos-native/issues/49), owner decision + 2026-09-14), so gating them would be dead code that reads like a promise. + +**Three defects fixed on the way.** +1. **The staleness hole.** `EntitlementsService` is built from `user?.customerStatus` and + `SessionService.restore()` was the *only* re-fetch path — launch and sign-in. Verifying an email + in a browser left the Mac app gated until relaunch. The app now re-resolves the session on + `scenePhase == .active`. +2. **`send-verification-email` was annotated `.bearer` on the strength of a 401 for an + *unauthenticated* caller** — which only ever proved anonymous fails. A live probe (2026-09-14) + returns **401 under a valid Bearer token**; the route really is `x-auth-type: session`. The + resend affordance deep-links to web Settings ▸ Security, and the builder now says so. +3. **#39 — the AI copy told subscribers to "add your own AI provider key"**, a setting that does not + exist. AI is included in the subscription, so an empty `providers[]` is a *service-side* outage. + The copy no longer asks the user to fix something they cannot. + +Test baseline after this change: Kit **483** · Domain **1012** · Persistence **140** · App **968**. + + **Merged since this doc was consolidated (2026-08-18 → 2026-09-02):** - **G4 · GitHub issue integration** (PR #12, merged 2026-08-18) — Kit + Domain client + the full App UI (issue browser in GitHub-backed lists, "create issue from a message", close/reopen + label/assignee editing). Client-complete; only the issue **update** and **comment** routes stay backend-blocked (see [§1 · G4](#g4-github-issue-integration) / [§2 · P1-H2](#p1-h2-github-issue-update-comment-routes)). Also in PR #12: G7 verify, G14 tail, ERD schema-entity view, timeline New Message button, G11a LinkedIn target, force-directed connections layout, Preferences pane, per-item Markdown export. - **Sharing collaborators / invites / visibility** (PR #13, merged 2026-09-02) — extends the G3 sharing group: per-person document collaborators (search/add/set-role/remove), email invites for lists **and** documents, and a make-public visibility toggle. Full stack (Kit `SharingEndpoint`/`SharingDTO`, Domain `Sharing` models + `SharingService`, App `DocumentCollaborators*`/`Invites*`/`Visibility*` views + VMs) with Kit/Domain/App tests. Create paths are subscriber-gated.