diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d37e611..b14b6fd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -29,7 +29,7 @@ jobs:
run: swiftlint lint --strict --config .swiftlint.yml
ios-build:
- name: Build SDK for iOS
+ name: Build SDK and FeedbackPortalApp for iOS
runs-on: macos-14
steps:
- uses: actions/checkout@v4
@@ -37,6 +37,13 @@ jobs:
- name: Show Xcode
run: xcodebuild -version
+ - name: Build FeedbackKit for iOS Simulator (SPM)
+ run: |
+ xcodebuild build \
+ -scheme FeedbackKit \
+ -destination 'generic/platform=iOS Simulator' \
+ CODE_SIGNING_ALLOWED=NO
+
- name: Install XcodeGen
run: brew install xcodegen
@@ -49,3 +56,12 @@ jobs:
-scheme FeedbackApp \
-destination 'generic/platform=iOS Simulator' \
CODE_SIGNING_ALLOWED=NO
+
+ - name: Build FeedbackPortalApp (iOS Simulator)
+ run: |
+ xcodebuild build \
+ -project FeedbackApp.xcodeproj \
+ -scheme FeedbackPortalApp \
+ -sdk iphonesimulator \
+ -destination 'generic/platform=iOS Simulator' \
+ CODE_SIGNING_ALLOWED=NO
diff --git a/.gitignore b/.gitignore
index a12302a..a860e53 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,3 +27,6 @@ build/
# ── Swift PM ─────────────────────────────────────────────────────────────────
.swiftpm/
+
+# ── Brainstorm companion artifacts ───────────────────────────────────────────
+.superpowers/
diff --git a/.swiftlint.yml b/.swiftlint.yml
index 4135877..cac181c 100644
--- a/.swiftlint.yml
+++ b/.swiftlint.yml
@@ -2,6 +2,7 @@ included:
- FeedbackApp/
- Sources/
- Tests/
+ - App/
excluded:
- .build/
diff --git a/App/FeedbackPortalApp/AccountTabView.swift b/App/FeedbackPortalApp/AccountTabView.swift
new file mode 100644
index 0000000..b58c393
--- /dev/null
+++ b/App/FeedbackPortalApp/AccountTabView.swift
@@ -0,0 +1,47 @@
+import FeedbackKit
+import SwiftUI
+
+struct AccountTabView: View {
+ @EnvironmentObject private var auth: AuthStore
+
+ @State private var isShowingSignIn = false
+
+ var body: some View {
+ NavigationStack {
+ List {
+ if auth.isSignedIn {
+ Section {
+ Label("Signed in", systemImage: "checkmark.seal.fill")
+ .foregroundStyle(.green)
+ }
+
+ Section {
+ Button(role: .destructive) {
+ auth.signOut()
+ } label: {
+ Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right")
+ }
+ }
+ } else {
+ Section {
+ Label("Not signed in", systemImage: "person.crop.circle.badge.xmark")
+ .foregroundStyle(.secondary)
+ }
+
+ Section {
+ Button {
+ isShowingSignIn = true
+ } label: {
+ Label("Sign In", systemImage: "person.crop.circle.badge.checkmark")
+ }
+ }
+ }
+ }
+ .listStyle(.insetGrouped)
+ .navigationTitle("Account")
+ .sheet(isPresented: $isShowingSignIn) {
+ SignInSheet()
+ }
+ }
+ }
+}
diff --git a/App/FeedbackPortalApp/ChangelogTabView.swift b/App/FeedbackPortalApp/ChangelogTabView.swift
new file mode 100644
index 0000000..7e79119
--- /dev/null
+++ b/App/FeedbackPortalApp/ChangelogTabView.swift
@@ -0,0 +1,77 @@
+import FeedbackKit
+import SwiftUI
+
+struct ChangelogTabView: View {
+ @EnvironmentObject private var env: AppEnvironment
+
+ @State private var vm: ChangelogViewModel?
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ if let vm {
+ ChangelogListView(vm: vm)
+ } else {
+ ProgressView()
+ }
+ }
+ .navigationTitle("Changelog")
+ }
+ .task {
+ let model = ChangelogViewModel(api: env.api)
+ vm = model
+ await model.load()
+ }
+ }
+}
+
+private struct ChangelogListView: View {
+ @ObservedObject var vm: ChangelogViewModel
+
+ var body: some View {
+ List {
+ if vm.isLoading && vm.entries.isEmpty {
+ ProgressView()
+ .frame(maxWidth: .infinity)
+ .listRowSeparator(.hidden)
+ } else if let error = vm.errorMessage, vm.entries.isEmpty {
+ ContentUnavailableView(
+ "Couldn't Load",
+ systemImage: "exclamationmark.triangle",
+ description: Text(error)
+ )
+ .listRowSeparator(.hidden)
+ } else if vm.entries.isEmpty {
+ ContentUnavailableView(
+ "No Entries Yet",
+ systemImage: "clock.arrow.circlepath",
+ description: Text("Check back for updates.")
+ )
+ .listRowSeparator(.hidden)
+ } else {
+ ForEach(vm.entries) { entry in
+ VStack(alignment: .leading, spacing: 6) {
+ Text(entry.title)
+ .font(.headline)
+ if let publishedAt = entry.publishedAt {
+ Text(publishedAt, style: .date)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ if let content = entry.content, !content.isEmpty {
+ Text(content)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .lineLimit(3)
+ }
+ }
+ .padding(.vertical, 4)
+ }
+ }
+ }
+ .listStyle(.plain)
+ .refreshable {
+ await vm.load()
+ }
+ }
+}
diff --git a/App/FeedbackPortalApp/Environment.swift b/App/FeedbackPortalApp/Environment.swift
new file mode 100644
index 0000000..9985107
--- /dev/null
+++ b/App/FeedbackPortalApp/Environment.swift
@@ -0,0 +1,27 @@
+import FeedbackKit
+import Foundation
+
+@MainActor
+final class AppEnvironment: ObservableObject {
+ let api: FeedbackAPI
+ let auth: AuthStore
+
+ private let tokenStore: KeychainTokenStore
+
+ init() {
+ let raw = Bundle.main.object(forInfoDictionaryKey: "FEEDBACK_INSTANCE_URL") as? String
+ ?? "http://localhost:3000"
+ let instanceURL = URL(string: raw) ?? URL(string: "http://localhost:3000")!
+
+ let store = KeychainTokenStore()
+ let authService = HTTPAuthService(baseURL: instanceURL)
+ let authStore = AuthStore(service: authService, tokenStore: store)
+
+ self.tokenStore = store
+ self.auth = authStore
+ self.api = HTTPFeedbackAPI(
+ baseURL: instanceURL,
+ tokenProvider: { store.token }
+ )
+ }
+}
diff --git a/App/FeedbackPortalApp/FeedTabView.swift b/App/FeedbackPortalApp/FeedTabView.swift
new file mode 100644
index 0000000..a3dd306
--- /dev/null
+++ b/App/FeedbackPortalApp/FeedTabView.swift
@@ -0,0 +1,108 @@
+import FeedbackKit
+import SwiftUI
+
+struct FeedTabView: View {
+ @EnvironmentObject private var env: AppEnvironment
+ @EnvironmentObject private var auth: AuthStore
+
+ @State private var vm: FeedViewModel?
+ @State private var isShowingSubmit = false
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ if let vm {
+ FeedListView(vm: vm, isShowingSubmit: $isShowingSubmit)
+ } else {
+ ProgressView()
+ }
+ }
+ .navigationTitle("Feedback")
+ .toolbar {
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button {
+ isShowingSubmit = true
+ } label: {
+ Image(systemName: "plus")
+ }
+ }
+ }
+ .sheet(isPresented: $isShowingSubmit) {
+ if let vm {
+ SubmitView(api: env.api, auth: auth)
+ }
+ }
+ }
+ .task {
+ let model = FeedViewModel(api: env.api)
+ vm = model
+ await model.load()
+ }
+ }
+}
+
+private struct FeedListView: View {
+ @ObservedObject var vm: FeedViewModel
+ @Binding var isShowingSubmit: Bool
+
+ var body: some View {
+ List {
+ if vm.isLoading && vm.posts.isEmpty {
+ ProgressView()
+ .frame(maxWidth: .infinity)
+ .listRowSeparator(.hidden)
+ } else if let error = vm.errorMessage, vm.posts.isEmpty {
+ ContentUnavailableView(
+ "Couldn't Load",
+ systemImage: "exclamationmark.triangle",
+ description: Text(error)
+ )
+ .listRowSeparator(.hidden)
+ } else if vm.posts.isEmpty {
+ ContentUnavailableView(
+ "No Posts Yet",
+ systemImage: "bubble.left",
+ description: Text("Be the first to submit feedback.")
+ )
+ .listRowSeparator(.hidden)
+ } else {
+ ForEach(vm.posts) { post in
+ NavigationLink(value: post.id) {
+ PostRowView(post: post)
+ }
+ }
+ }
+ }
+ .listStyle(.plain)
+ .refreshable {
+ await vm.load()
+ }
+ .navigationDestination(for: String.self) { postId in
+ PostDetailView(postId: postId)
+ }
+ }
+}
+
+private struct PostRowView: View {
+ let post: PostSummary
+
+ var body: some View {
+ HStack(spacing: 12) {
+ VStack(spacing: 2) {
+ Image(systemName: post.hasVoted ? "arrowtriangle.up.fill" : "arrowtriangle.up")
+ .foregroundStyle(post.hasVoted ? .blue : .secondary)
+ Text("\(post.voteCount)")
+ .font(.caption.monospacedDigit())
+ .foregroundStyle(.secondary)
+ }
+ .frame(width: 36)
+
+ Text(post.title)
+ .font(.body)
+ .lineLimit(2)
+
+ Spacer()
+ }
+ .padding(.vertical, 4)
+ }
+}
diff --git a/App/FeedbackPortalApp/FeedbackPortalApp.swift b/App/FeedbackPortalApp/FeedbackPortalApp.swift
new file mode 100644
index 0000000..ad1b599
--- /dev/null
+++ b/App/FeedbackPortalApp/FeedbackPortalApp.swift
@@ -0,0 +1,15 @@
+import FeedbackKit
+import SwiftUI
+
+@main
+struct FeedbackPortalApp: App {
+ @StateObject private var env = AppEnvironment()
+
+ var body: some Scene {
+ WindowGroup {
+ RootTabView()
+ .environmentObject(env)
+ .environmentObject(env.auth)
+ }
+ }
+}
diff --git a/App/FeedbackPortalApp/HelpTabView.swift b/App/FeedbackPortalApp/HelpTabView.swift
new file mode 100644
index 0000000..848eaa8
--- /dev/null
+++ b/App/FeedbackPortalApp/HelpTabView.swift
@@ -0,0 +1,72 @@
+import FeedbackKit
+import SwiftUI
+
+struct HelpTabView: View {
+ @EnvironmentObject private var env: AppEnvironment
+
+ @State private var vm: HelpViewModel?
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ if let vm {
+ HelpListView(vm: vm)
+ } else {
+ ProgressView()
+ }
+ }
+ .navigationTitle("Help")
+ }
+ .task {
+ let model = HelpViewModel(api: env.api)
+ vm = model
+ await model.load()
+ }
+ }
+}
+
+private struct HelpListView: View {
+ @ObservedObject var vm: HelpViewModel
+
+ var body: some View {
+ List {
+ if vm.isLoading && vm.categories.isEmpty {
+ ProgressView()
+ .frame(maxWidth: .infinity)
+ .listRowSeparator(.hidden)
+ } else if let error = vm.errorMessage, vm.categories.isEmpty {
+ ContentUnavailableView(
+ "Couldn't Load",
+ systemImage: "exclamationmark.triangle",
+ description: Text(error)
+ )
+ .listRowSeparator(.hidden)
+ } else if vm.categories.isEmpty {
+ ContentUnavailableView(
+ "No Categories",
+ systemImage: "questionmark.circle",
+ description: Text("No help content available yet.")
+ )
+ .listRowSeparator(.hidden)
+ } else {
+ ForEach(vm.categories) { category in
+ VStack(alignment: .leading, spacing: 4) {
+ Text(category.name)
+ .font(.headline)
+ if let description = category.description, !description.isEmpty {
+ Text(description)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+ }
+ .padding(.vertical, 4)
+ }
+ }
+ }
+ .listStyle(.plain)
+ .refreshable {
+ await vm.load()
+ }
+ }
+}
diff --git a/App/FeedbackPortalApp/Info.plist b/App/FeedbackPortalApp/Info.plist
new file mode 100644
index 0000000..7d418df
--- /dev/null
+++ b/App/FeedbackPortalApp/Info.plist
@@ -0,0 +1,46 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Feedback Portal
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+ FEEDBACK_INSTANCE_URL
+ $(FEEDBACK_INSTANCE_URL)
+ NSAppTransportSecurity
+
+ NSAllowsLocalNetworking
+
+
+ UILaunchStoryboardName
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+
+
diff --git a/App/FeedbackPortalApp/PostDetailView.swift b/App/FeedbackPortalApp/PostDetailView.swift
new file mode 100644
index 0000000..77fbe2b
--- /dev/null
+++ b/App/FeedbackPortalApp/PostDetailView.swift
@@ -0,0 +1,154 @@
+import FeedbackKit
+import SwiftUI
+
+struct PostDetailView: View {
+ let postId: String
+
+ @EnvironmentObject private var env: AppEnvironment
+ @EnvironmentObject private var auth: AuthStore
+
+ @State private var vm: PostDetailViewModel?
+ @State private var commentText = ""
+ @State private var isShowingSignIn = false
+
+ var body: some View {
+ Group {
+ if let vm {
+ PostDetailContent(
+ vm: vm,
+ commentText: $commentText,
+ isShowingSignIn: $isShowingSignIn
+ )
+ } else {
+ ProgressView()
+ }
+ }
+ .navigationTitle("Post")
+ .navigationBarTitleDisplayMode(.inline)
+ .sheet(isPresented: $isShowingSignIn) {
+ SignInSheet()
+ }
+ .task {
+ let model = PostDetailViewModel(
+ postId: postId,
+ api: env.api,
+ isSignedIn: { [auth] in auth.isSignedIn }
+ )
+ vm = model
+ await model.load()
+ }
+ .onChange(of: vm?.needsSignIn) { _, needsSignIn in
+ if needsSignIn == true {
+ isShowingSignIn = true
+ }
+ }
+ }
+}
+
+private struct PostDetailContent: View {
+ @ObservedObject var vm: PostDetailViewModel
+ @Binding var commentText: String
+ @Binding var isShowingSignIn: Bool
+
+ var body: some View {
+ List {
+ if vm.isLoading && vm.post == nil {
+ ProgressView()
+ .frame(maxWidth: .infinity)
+ .listRowSeparator(.hidden)
+ } else if let error = vm.errorMessage, vm.post == nil {
+ ContentUnavailableView(
+ "Couldn't Load",
+ systemImage: "exclamationmark.triangle",
+ description: Text(error)
+ )
+ .listRowSeparator(.hidden)
+ } else if let post = vm.post {
+ Section {
+ VStack(alignment: .leading, spacing: 12) {
+ Text(post.title)
+ .font(.headline)
+
+ if !post.content.isEmpty {
+ Text(post.content)
+ .font(.body)
+ .foregroundStyle(.secondary)
+ }
+
+ Button {
+ Task { await vm.toggleVote() }
+ } label: {
+ Label(
+ "\(post.voteCount) vote\(post.voteCount == 1 ? "" : "s")",
+ systemImage: post.hasVoted ? "arrowtriangle.up.fill" : "arrowtriangle.up"
+ )
+ .foregroundStyle(post.hasVoted ? .blue : .primary)
+ }
+ .buttonStyle(.borderless)
+ }
+ .padding(.vertical, 4)
+ }
+
+ Section("Comments") {
+ HStack(alignment: .top) {
+ TextField("Add a comment…", text: $commentText, axis: .vertical)
+ .lineLimit(3...6)
+
+ Button {
+ let text = commentText
+ commentText = ""
+ Task { await vm.addComment(text) }
+ } label: {
+ Image(systemName: "paperplane.fill")
+ }
+ .buttonStyle(.borderless)
+ .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ }
+
+ if vm.comments.isEmpty && !vm.isLoading {
+ Text("No comments yet.")
+ .foregroundStyle(.secondary)
+ .font(.subheadline)
+ } else {
+ ForEach(vm.comments) { comment in
+ CommentRowView(comment: comment)
+ }
+ }
+ }
+ }
+ }
+ .listStyle(.insetGrouped)
+ .refreshable {
+ await vm.load()
+ }
+ }
+}
+
+private struct CommentRowView: View {
+ let comment: Comment
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ Text(comment.authorName)
+ .font(.caption.bold())
+ Spacer()
+ Text(comment.createdAt, style: .relative)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ Text(comment.content)
+ .font(.subheadline)
+
+ if !comment.replies.isEmpty {
+ VStack(alignment: .leading, spacing: 4) {
+ ForEach(comment.replies) { reply in
+ CommentRowView(comment: reply)
+ .padding(.leading, 16)
+ }
+ }
+ }
+ }
+ .padding(.vertical, 2)
+ }
+}
diff --git a/App/FeedbackPortalApp/RootTabView.swift b/App/FeedbackPortalApp/RootTabView.swift
new file mode 100644
index 0000000..5f7e8b3
--- /dev/null
+++ b/App/FeedbackPortalApp/RootTabView.swift
@@ -0,0 +1,31 @@
+import FeedbackKit
+import SwiftUI
+
+struct RootTabView: View {
+ @EnvironmentObject private var env: AppEnvironment
+ @EnvironmentObject private var auth: AuthStore
+
+ var body: some View {
+ TabView {
+ FeedTabView()
+ .tabItem {
+ Label("Feedback", systemImage: "bubble.left.and.bubble.right")
+ }
+
+ ChangelogTabView()
+ .tabItem {
+ Label("Changelog", systemImage: "clock.arrow.circlepath")
+ }
+
+ HelpTabView()
+ .tabItem {
+ Label("Help", systemImage: "questionmark.circle")
+ }
+
+ AccountTabView()
+ .tabItem {
+ Label("Account", systemImage: "person.circle")
+ }
+ }
+ }
+}
diff --git a/App/FeedbackPortalApp/SignInSheet.swift b/App/FeedbackPortalApp/SignInSheet.swift
new file mode 100644
index 0000000..1aa6968
--- /dev/null
+++ b/App/FeedbackPortalApp/SignInSheet.swift
@@ -0,0 +1,110 @@
+import FeedbackKit
+import SwiftUI
+
+struct SignInSheet: View {
+ @EnvironmentObject private var auth: AuthStore
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var email = ""
+ @State private var code = ""
+ @State private var codeSent = false
+ @State private var isSending = false
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section {
+ TextField("Email", text: $email)
+ .keyboardType(.emailAddress)
+ .textContentType(.emailAddress)
+ .autocapitalization(.none)
+ .autocorrectionDisabled()
+ .disabled(codeSent)
+ }
+
+ if codeSent {
+ Section {
+ TextField("6-digit code", text: $code)
+ .keyboardType(.numberPad)
+ .textContentType(.oneTimeCode)
+ }
+ }
+
+ if let error = auth.errorMessage {
+ Section {
+ Text(error)
+ .foregroundStyle(.red)
+ .font(.subheadline)
+ }
+ }
+
+ Section {
+ if !codeSent {
+ Button {
+ Task { await sendCode() }
+ } label: {
+ if isSending {
+ ProgressView()
+ .frame(maxWidth: .infinity)
+ } else {
+ Text("Send Code")
+ .frame(maxWidth: .infinity)
+ }
+ }
+ .disabled(email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending)
+ } else {
+ Button {
+ Task { await verify() }
+ } label: {
+ if isSending {
+ ProgressView()
+ .frame(maxWidth: .infinity)
+ } else {
+ Text("Verify")
+ .frame(maxWidth: .infinity)
+ }
+ }
+ .disabled(code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending)
+
+ Button("Resend Code") {
+ code = ""
+ codeSent = false
+ }
+ .buttonStyle(.borderless)
+ .frame(maxWidth: .infinity)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ .navigationTitle("Sign In")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }
+ }
+ }
+ }
+ .onChange(of: auth.isSignedIn) { _, isSignedIn in
+ if isSignedIn {
+ dismiss()
+ }
+ }
+ }
+
+ private func sendCode() async {
+ isSending = true
+ do {
+ try await auth.requestCode(email: email)
+ codeSent = true
+ } catch {
+ // errorMessage surfaced through auth.errorMessage if set by AuthStore
+ }
+ isSending = false
+ }
+
+ private func verify() async {
+ isSending = true
+ await auth.verify(email: email, code: code)
+ isSending = false
+ }
+}
diff --git a/App/FeedbackPortalApp/SubmitView.swift b/App/FeedbackPortalApp/SubmitView.swift
new file mode 100644
index 0000000..0326b7f
--- /dev/null
+++ b/App/FeedbackPortalApp/SubmitView.swift
@@ -0,0 +1,95 @@
+import FeedbackKit
+import SwiftUI
+
+struct SubmitView: View {
+ let api: FeedbackAPI
+ let auth: AuthStore
+
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var vm: SubmitViewModel?
+ @State private var isShowingSignIn = false
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ if let vm {
+ SubmitFormView(vm: vm, isShowingSignIn: $isShowingSignIn, dismiss: dismiss)
+ } else {
+ ProgressView()
+ }
+ }
+ .navigationTitle("New Feedback")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }
+ }
+ }
+ .sheet(isPresented: $isShowingSignIn) {
+ SignInSheet()
+ }
+ }
+ .task {
+ let model = SubmitViewModel(
+ api: api,
+ isSignedIn: { [auth] in auth.isSignedIn }
+ )
+ vm = model
+ }
+ .onChange(of: vm?.needsSignIn) { _, needsSignIn in
+ if needsSignIn == true {
+ isShowingSignIn = true
+ }
+ }
+ }
+}
+
+private struct SubmitFormView: View {
+ @ObservedObject var vm: SubmitViewModel
+ @Binding var isShowingSignIn: Bool
+ let dismiss: DismissAction
+
+ var body: some View {
+ Form {
+ Section("Board") {
+ TextField("Board ID", text: $vm.boardId)
+ .autocorrectionDisabled()
+ }
+
+ Section("Post") {
+ TextField("Title", text: $vm.title)
+ TextField("Description (optional)", text: $vm.content, axis: .vertical)
+ .lineLimit(4...8)
+ }
+
+ if let error = vm.errorMessage {
+ Section {
+ Text(error)
+ .foregroundStyle(.red)
+ .font(.subheadline)
+ }
+ }
+
+ Section {
+ Button {
+ Task {
+ let success = await vm.submit()
+ if success {
+ dismiss()
+ }
+ }
+ } label: {
+ if vm.isSubmitting {
+ ProgressView()
+ .frame(maxWidth: .infinity)
+ } else {
+ Text("Submit")
+ .frame(maxWidth: .infinity)
+ }
+ }
+ .disabled(vm.isSubmitting)
+ }
+ }
+ }
+}
diff --git a/Package.swift b/Package.swift
index d728522..433e098 100644
--- a/Package.swift
+++ b/Package.swift
@@ -3,12 +3,15 @@ import PackageDescription
let package = Package(
name: "OpenCovenFeedback",
- platforms: [.iOS(.v15)],
+ platforms: [.iOS(.v15), .macOS(.v12)],
products: [
.library(name: "OpenCovenFeedback", targets: ["OpenCovenFeedback"]),
+ .library(name: "FeedbackKit", targets: ["FeedbackKit"]),
],
targets: [
.target(name: "OpenCovenFeedback", path: "Sources/OpenCovenFeedback"),
.testTarget(name: "OpenCovenFeedbackTests", dependencies: ["OpenCovenFeedback"], path: "Tests/OpenCovenFeedbackTests"),
+ .target(name: "FeedbackKit", path: "Sources/FeedbackKit"),
+ .testTarget(name: "FeedbackKitTests", dependencies: ["FeedbackKit"], path: "Tests/FeedbackKitTests"),
]
)
diff --git a/Sources/FeedbackKit/API/APIError.swift b/Sources/FeedbackKit/API/APIError.swift
new file mode 100644
index 0000000..442bb21
--- /dev/null
+++ b/Sources/FeedbackKit/API/APIError.swift
@@ -0,0 +1,10 @@
+import Foundation
+
+public enum APIError: Error, Equatable, Sendable {
+ case unauthorized
+ case notFound
+ case rateLimited
+ case server(status: Int, code: String?)
+ case transport(String)
+ case decoding(String)
+}
diff --git a/Sources/FeedbackKit/API/FeedbackAPI.swift b/Sources/FeedbackKit/API/FeedbackAPI.swift
new file mode 100644
index 0000000..b69b29b
--- /dev/null
+++ b/Sources/FeedbackKit/API/FeedbackAPI.swift
@@ -0,0 +1,16 @@
+import Foundation
+
+public enum PostSort: String, Sendable { case newest, votes }
+
+public protocol FeedbackAPI: Sendable {
+ func listPosts(boardId: String?, sort: PostSort, cursor: String?) async throws -> Page
+ func getPost(id: String) async throws -> PostDetail
+ func listComments(postId: String) async throws -> [Comment]
+ func listBoards() async throws -> [Board]
+ func listChangelog(cursor: String?) async throws -> Page
+ func listHelpCategories() async throws -> [HelpCategory]
+ func getHelpArticle(slug: String) async throws -> HelpArticle
+ func submitPost(boardId: String, title: String, content: String) async throws -> PostSummary
+ func vote(postId: String) async throws -> VoteResult
+ func addComment(postId: String, content: String, parentId: String?) async throws -> Comment
+}
diff --git a/Sources/FeedbackKit/API/HTTPFeedbackAPI.swift b/Sources/FeedbackKit/API/HTTPFeedbackAPI.swift
new file mode 100644
index 0000000..ef39028
--- /dev/null
+++ b/Sources/FeedbackKit/API/HTTPFeedbackAPI.swift
@@ -0,0 +1,152 @@
+import Foundation
+
+private struct _ErrorBody: Decodable {
+ struct Inner: Decodable { let code: String? }
+ let error: Inner?
+}
+
+public final class HTTPFeedbackAPI: FeedbackAPI, @unchecked Sendable {
+ private let baseURL: URL
+ private let session: URLSession
+ private let tokenProvider: @Sendable () -> String?
+
+ public init(baseURL: URL,
+ session: URLSession = .shared,
+ tokenProvider: @escaping @Sendable () -> String?) {
+ self.baseURL = baseURL
+ self.session = session
+ self.tokenProvider = tokenProvider
+ }
+
+ // MARK: - FeedbackAPI
+
+ public func listPosts(boardId: String?, sort: PostSort, cursor: String?) async throws -> Page {
+ var query: [(String, String)] = [("sort", sort.rawValue)]
+ if let boardId { query.append(("boardId", boardId)) }
+ if let cursor { query.append(("cursor", cursor)) }
+ let req = request(path: "/api/public/v1/posts", method: "GET", query: query)
+ return try await send(req)
+ }
+
+ public func getPost(id: String) async throws -> PostDetail {
+ let req = request(path: "/api/public/v1/posts/\(id)", method: "GET", query: [])
+ let envelope: Envelope = try await send(req)
+ return envelope.data
+ }
+
+ public func listComments(postId: String) async throws -> [Comment] {
+ let req = request(path: "/api/public/v1/posts/\(postId)/comments", method: "GET", query: [])
+ let envelope: Envelope<[Comment]> = try await send(req)
+ return envelope.data
+ }
+
+ public func listBoards() async throws -> [Board] {
+ let req = request(path: "/api/public/v1/boards", method: "GET", query: [])
+ let envelope: Envelope<[Board]> = try await send(req)
+ return envelope.data
+ }
+
+ public func listChangelog(cursor: String?) async throws -> Page {
+ var query: [(String, String)] = []
+ if let cursor { query.append(("cursor", cursor)) }
+ let req = request(path: "/api/public/v1/changelog", method: "GET", query: query)
+ return try await send(req)
+ }
+
+ public func listHelpCategories() async throws -> [HelpCategory] {
+ let req = request(path: "/api/public/v1/help/categories", method: "GET", query: [])
+ let envelope: Envelope<[HelpCategory]> = try await send(req)
+ return envelope.data
+ }
+
+ public func getHelpArticle(slug: String) async throws -> HelpArticle {
+ let req = request(path: "/api/public/v1/help/articles/\(slug)", method: "GET", query: [])
+ let envelope: Envelope = try await send(req)
+ return envelope.data
+ }
+
+ public func submitPost(boardId: String, title: String, content: String) async throws -> PostSummary {
+ var req = request(path: "/api/public/v1/posts", method: "POST", query: [])
+ let body: [String: String] = ["boardId": boardId, "title": title, "content": content]
+ req.httpBody = try? JSONSerialization.data(withJSONObject: body)
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ let envelope: Envelope = try await send(req)
+ return envelope.data
+ }
+
+ public func vote(postId: String) async throws -> VoteResult {
+ var req = request(path: "/api/public/v1/posts/\(postId)/vote", method: "POST", query: [])
+ req.httpBody = Data()
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ let envelope: Envelope = try await send(req)
+ return envelope.data
+ }
+
+ public func addComment(postId: String, content: String, parentId: String?) async throws -> Comment {
+ var req = request(path: "/api/public/v1/posts/\(postId)/comments", method: "POST", query: [])
+ var body: [String: String] = ["content": content]
+ if let parentId { body["parentId"] = parentId }
+ req.httpBody = try? JSONSerialization.data(withJSONObject: body)
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ let envelope: Envelope = try await send(req)
+ return envelope.data
+ }
+
+ // MARK: - Private helpers
+
+ private func request(path: String, method: String, query: [(String, String)]) -> URLRequest {
+ var components = URLComponents()
+ components.scheme = baseURL.scheme
+ components.host = baseURL.host
+ components.port = baseURL.port
+
+ let basePath = baseURL.path == "/" ? "" : baseURL.path
+ components.path = basePath + path
+
+ if !query.isEmpty {
+ components.queryItems = query.map { URLQueryItem(name: $0.0, value: $0.1) }
+ }
+
+ var req = URLRequest(url: components.url!)
+ req.httpMethod = method
+ if let token = tokenProvider() {
+ req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+ }
+ return req
+ }
+
+ private func send(_ request: URLRequest) async throws -> R {
+ let (data, response): (Data, URLResponse)
+ do {
+ (data, response) = try await session.data(for: request)
+ } catch {
+ throw APIError.transport(error.localizedDescription)
+ }
+
+ guard let http = response as? HTTPURLResponse else {
+ throw APIError.transport("Non-HTTP response")
+ }
+
+ let status = http.statusCode
+ if (200..<300).contains(status) {
+ do {
+ return try JSONDecoder.feedback.decode(R.self, from: data)
+ } catch {
+ throw APIError.decoding(error.localizedDescription)
+ }
+ }
+
+ switch status {
+ case 401:
+ throw APIError.unauthorized
+ case 404:
+ throw APIError.notFound
+ case 429:
+ throw APIError.rateLimited
+ default:
+ // Attempt to extract error.code from the response body
+ let code = (try? JSONDecoder().decode(_ErrorBody.self, from: data))?.error?.code
+ throw APIError.server(status: status, code: code)
+ }
+ }
+}
diff --git a/Sources/FeedbackKit/Auth/AuthStore.swift b/Sources/FeedbackKit/Auth/AuthStore.swift
new file mode 100644
index 0000000..4afdbb1
--- /dev/null
+++ b/Sources/FeedbackKit/Auth/AuthStore.swift
@@ -0,0 +1,50 @@
+import Foundation
+
+public protocol AuthService: Sendable {
+ func sendOTP(email: String) async throws
+ func verifyOTP(email: String, code: String) async throws -> String // returns session token
+}
+
+@MainActor
+public final class AuthStore: ObservableObject {
+ @Published public private(set) var isSignedIn: Bool
+ @Published public private(set) var errorMessage: String?
+
+ private let service: AuthService
+ private let tokenStore: TokenStore
+
+ public init(service: AuthService, tokenStore: TokenStore) {
+ self.service = service
+ self.tokenStore = tokenStore
+ self.isSignedIn = tokenStore.token != nil
+ }
+
+ public var token: String? { tokenStore.token }
+
+ public func requestCode(email: String) async throws {
+ errorMessage = nil
+ do {
+ try await service.sendOTP(email: email)
+ } catch {
+ errorMessage = "Couldn't send a code. Please try again."
+ throw error
+ }
+ }
+
+ public func verify(email: String, code: String) async {
+ errorMessage = nil
+ do {
+ let token = try await service.verifyOTP(email: email, code: code)
+ tokenStore.token = token
+ isSignedIn = true
+ } catch {
+ errorMessage = "That code didn't work. Try again."
+ isSignedIn = false
+ }
+ }
+
+ public func signOut() {
+ tokenStore.token = nil
+ isSignedIn = false
+ }
+}
diff --git a/Sources/FeedbackKit/Auth/HTTPAuthService.swift b/Sources/FeedbackKit/Auth/HTTPAuthService.swift
new file mode 100644
index 0000000..8ba5f1e
--- /dev/null
+++ b/Sources/FeedbackKit/Auth/HTTPAuthService.swift
@@ -0,0 +1,36 @@
+import Foundation
+
+// NOTE: The endpoint paths below (/api/auth/email-otp/send-verification-otp and
+// /api/auth/sign-in/email-otp) and the "token" field in the sign-in response are
+// assumptions based on the better-auth `emailOTP` plugin convention. Confirm these
+// against a live instance before shipping — AuthService/AuthStore interfaces won't change.
+public final class HTTPAuthService: AuthService, @unchecked Sendable {
+ private let baseURL: URL
+ private let session: URLSession
+ public init(baseURL: URL, session: URLSession = .shared) { self.baseURL = baseURL; self.session = session }
+
+ public func sendOTP(email: String) async throws {
+ _ = try await post("/api/auth/email-otp/send-verification-otp", body: ["email": email, "type": "sign-in"])
+ }
+
+ public func verifyOTP(email: String, code: String) async throws -> String {
+ let data = try await post("/api/auth/sign-in/email-otp", body: ["email": email, "otp": code])
+ struct SignInResponse: Decodable { let token: String }
+ guard let token = try? JSONDecoder().decode(SignInResponse.self, from: data).token else {
+ throw APIError.decoding("No token in sign-in response")
+ }
+ return token
+ }
+
+ private func post(_ path: String, body: [String: String]) async throws -> Data {
+ var req = URLRequest(url: baseURL.appendingPathComponent(path))
+ req.httpMethod = "POST"
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ req.httpBody = try JSONSerialization.data(withJSONObject: body)
+ let (data, response) = try await session.data(for: req)
+ guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
+ throw APIError.server(status: (response as? HTTPURLResponse)?.statusCode ?? -1, code: nil)
+ }
+ return data
+ }
+}
diff --git a/Sources/FeedbackKit/Auth/TokenStore.swift b/Sources/FeedbackKit/Auth/TokenStore.swift
new file mode 100644
index 0000000..15cdebd
--- /dev/null
+++ b/Sources/FeedbackKit/Auth/TokenStore.swift
@@ -0,0 +1,83 @@
+import Foundation
+#if canImport(Security)
+import Security
+#endif
+
+// MARK: - Protocol
+
+public protocol TokenStore: AnyObject, Sendable {
+ var token: String? { get set }
+}
+
+// MARK: - InMemoryTokenStore
+
+public final class InMemoryTokenStore: TokenStore, @unchecked Sendable {
+ private let lock = NSLock()
+ private var _token: String?
+
+ public init(token: String? = nil) {
+ _token = token
+ }
+
+ public var token: String? {
+ get {
+ lock.lock()
+ defer { lock.unlock() }
+ return _token
+ }
+ set {
+ lock.lock()
+ defer { lock.unlock() }
+ _token = newValue
+ }
+ }
+}
+
+// MARK: - KeychainTokenStore
+
+#if canImport(Security)
+public final class KeychainTokenStore: TokenStore, @unchecked Sendable {
+ private let service: String
+ private let account: String
+
+ public init(
+ service: String = "dev.opencoven.feedback.session",
+ account: String = "session-token"
+ ) {
+ self.service = service
+ self.account = account
+ }
+
+ private var baseQuery: [CFString: Any] {
+ [
+ kSecClass: kSecClassGenericPassword,
+ kSecAttrService: service,
+ kSecAttrAccount: account,
+ ]
+ }
+
+ public var token: String? {
+ get {
+ var query = baseQuery
+ query[kSecReturnData] = kCFBooleanTrue
+ query[kSecMatchLimit] = kSecMatchLimitOne
+
+ var result: AnyObject?
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
+ guard status == errSecSuccess, let data = result as? Data else {
+ return nil
+ }
+ return String(data: data, encoding: .utf8)
+ }
+ set {
+ SecItemDelete(baseQuery as CFDictionary)
+ guard let value = newValue, let data = value.data(using: .utf8) else {
+ return
+ }
+ var addQuery = baseQuery
+ addQuery[kSecValueData] = data
+ SecItemAdd(addQuery as CFDictionary, nil)
+ }
+ }
+}
+#endif
diff --git a/Sources/FeedbackKit/Cache/ContentCache.swift b/Sources/FeedbackKit/Cache/ContentCache.swift
new file mode 100644
index 0000000..8d1bc20
--- /dev/null
+++ b/Sources/FeedbackKit/Cache/ContentCache.swift
@@ -0,0 +1,34 @@
+import Foundation
+
+public struct ContentCache: Sendable {
+ private let directory: URL
+
+public init(directory: URL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
+ .appendingPathComponent("FeedbackKit")) {
+ self.directory = directory
+ try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
+}
+
+ public func save(_ value: T, as key: String) throws {
+ let data = try Self.encoder.encode(value)
+ try data.write(to: directory.appendingPathComponent("\(key).json"), options: .atomic)
+ }
+
+ public func load(_ key: String, as type: T.Type) throws -> T {
+ let data = try Data(contentsOf: directory.appendingPathComponent("\(key).json"))
+ return try JSONDecoder.feedback.decode(T.self, from: data)
+ }
+
+ // Encoder whose date strategy matches JSONDecoder.feedback (ISO-8601 with fractional seconds).
+ private static let encoder: JSONEncoder = {
+ let encoder = JSONEncoder()
+ encoder.keyEncodingStrategy = .convertToSnakeCase
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ encoder.dateEncodingStrategy = .custom { date, enc in
+ var container = enc.singleValueContainer()
+ try container.encode(formatter.string(from: date))
+ }
+ return encoder
+ }()
+}
diff --git a/Sources/FeedbackKit/Config/AppConfig.swift b/Sources/FeedbackKit/Config/AppConfig.swift
new file mode 100644
index 0000000..461e58e
--- /dev/null
+++ b/Sources/FeedbackKit/Config/AppConfig.swift
@@ -0,0 +1,9 @@
+import Foundation
+
+public struct AppConfig: Sendable {
+ public let instanceURL: URL
+
+ public init(instanceURL: URL) {
+ self.instanceURL = instanceURL
+ }
+}
diff --git a/Sources/FeedbackKit/Features/Changelog/ChangelogViewModel.swift b/Sources/FeedbackKit/Features/Changelog/ChangelogViewModel.swift
new file mode 100644
index 0000000..fe1b041
--- /dev/null
+++ b/Sources/FeedbackKit/Features/Changelog/ChangelogViewModel.swift
@@ -0,0 +1,31 @@
+import Foundation
+
+@MainActor
+public final class ChangelogViewModel: ObservableObject {
+ @Published public private(set) var entries: [ChangelogEntry] = []
+ @Published public private(set) var isLoading = false
+ @Published public private(set) var errorMessage: String?
+ private let api: FeedbackAPI
+ private let cache: ContentCache
+ private let cacheKey = "changelog"
+ public init(api: FeedbackAPI, cache: ContentCache = ContentCache()) {
+ self.api = api
+ self.cache = cache
+ }
+ public func load() async {
+ isLoading = true; errorMessage = nil
+ do {
+ entries = try await api.listChangelog(cursor: nil).data
+ try? cache.save(entries, as: cacheKey)
+ } catch {
+ if entries.isEmpty {
+ if let cached = try? cache.load(cacheKey, as: [ChangelogEntry].self) {
+ entries = cached
+ } else {
+ errorMessage = FeedViewModel.message(for: error)
+ }
+ }
+ }
+ isLoading = false
+ }
+}
diff --git a/Sources/FeedbackKit/Features/Detail/PostDetailViewModel.swift b/Sources/FeedbackKit/Features/Detail/PostDetailViewModel.swift
new file mode 100644
index 0000000..6bf7b53
--- /dev/null
+++ b/Sources/FeedbackKit/Features/Detail/PostDetailViewModel.swift
@@ -0,0 +1,70 @@
+import Foundation
+
+@MainActor
+public final class PostDetailViewModel: ObservableObject {
+ @Published public private(set) var post: PostDetail?
+ @Published public private(set) var comments: [Comment] = []
+ @Published public private(set) var isLoading = false
+ @Published public private(set) var errorMessage: String?
+ @Published public var needsSignIn = false
+
+ private let postId: String
+ private let api: FeedbackAPI
+ private let isSignedIn: () -> Bool
+
+ public init(postId: String, api: FeedbackAPI, isSignedIn: @escaping () -> Bool) {
+ self.postId = postId
+ self.api = api
+ self.isSignedIn = isSignedIn
+ }
+
+ public func load() async {
+ isLoading = true; errorMessage = nil
+ do {
+ async let p = api.getPost(id: postId)
+ async let c = api.listComments(postId: postId)
+ post = try await p
+ comments = try await c
+ } catch {
+ errorMessage = FeedViewModel.message(for: error)
+ }
+ isLoading = false
+ }
+
+ public func toggleVote() async {
+ needsSignIn = false
+ guard isSignedIn() else { needsSignIn = true; return }
+ do {
+ let result = try await api.vote(postId: postId)
+ if let current = post {
+ post = PostDetail(
+ id: current.id,
+ title: current.title,
+ content: current.content,
+ voteCount: result.voteCount,
+ statusId: current.statusId,
+ boardId: current.boardId,
+ createdAt: current.createdAt,
+ hasVoted: result.voted
+ )
+ }
+ } catch APIError.unauthorized {
+ needsSignIn = true
+ } catch {
+ errorMessage = FeedViewModel.message(for: error)
+ }
+ }
+
+ public func addComment(_ text: String) async {
+ needsSignIn = false
+ guard isSignedIn() else { needsSignIn = true; return }
+ do {
+ let comment = try await api.addComment(postId: postId, content: text, parentId: nil)
+ comments.insert(comment, at: 0)
+ } catch APIError.unauthorized {
+ needsSignIn = true
+ } catch {
+ errorMessage = FeedViewModel.message(for: error)
+ }
+ }
+}
diff --git a/Sources/FeedbackKit/Features/Feed/FeedViewModel.swift b/Sources/FeedbackKit/Features/Feed/FeedViewModel.swift
new file mode 100644
index 0000000..cc0b209
--- /dev/null
+++ b/Sources/FeedbackKit/Features/Feed/FeedViewModel.swift
@@ -0,0 +1,46 @@
+import Foundation
+
+@MainActor
+public final class FeedViewModel: ObservableObject {
+ @Published public private(set) var posts: [PostSummary] = []
+ @Published public private(set) var isLoading = false
+ @Published public private(set) var errorMessage: String?
+
+ private let api: FeedbackAPI
+ private let cache: ContentCache
+ private let cacheKey = "feed"
+ public var boardId: String?
+ public var sort: PostSort = .newest
+
+ public init(api: FeedbackAPI, cache: ContentCache = ContentCache()) {
+ self.api = api
+ self.cache = cache
+ }
+
+ public func load() async {
+ isLoading = true
+ errorMessage = nil
+ do {
+ let page = try await api.listPosts(boardId: boardId, sort: sort, cursor: nil)
+ posts = page.data
+ try? cache.save(posts, as: cacheKey)
+ } catch {
+ if posts.isEmpty {
+ if let cached = try? cache.load(cacheKey, as: [PostSummary].self) {
+ posts = cached
+ } else {
+ errorMessage = Self.message(for: error)
+ }
+ }
+ }
+ isLoading = false
+ }
+
+ static func message(for error: Error) -> String {
+ switch error {
+ case APIError.transport: return "You appear to be offline. Pull to retry."
+ case APIError.rateLimited: return "Too many requests. Try again shortly."
+ default: return "Something went wrong. Please try again."
+ }
+ }
+}
diff --git a/Sources/FeedbackKit/Features/Help/HelpViewModel.swift b/Sources/FeedbackKit/Features/Help/HelpViewModel.swift
new file mode 100644
index 0000000..6486564
--- /dev/null
+++ b/Sources/FeedbackKit/Features/Help/HelpViewModel.swift
@@ -0,0 +1,31 @@
+import Foundation
+
+@MainActor
+public final class HelpViewModel: ObservableObject {
+ @Published public private(set) var categories: [HelpCategory] = []
+ @Published public private(set) var isLoading = false
+ @Published public private(set) var errorMessage: String?
+ private let api: FeedbackAPI
+ private let cache: ContentCache
+ private let cacheKey = "help_categories"
+ public init(api: FeedbackAPI, cache: ContentCache = ContentCache()) {
+ self.api = api
+ self.cache = cache
+ }
+ public func load() async {
+ isLoading = true; errorMessage = nil
+ do {
+ categories = try await api.listHelpCategories()
+ try? cache.save(categories, as: cacheKey)
+ } catch {
+ if categories.isEmpty {
+ if let cached = try? cache.load(cacheKey, as: [HelpCategory].self) {
+ categories = cached
+ } else {
+ errorMessage = FeedViewModel.message(for: error)
+ }
+ }
+ }
+ isLoading = false
+ }
+}
diff --git a/Sources/FeedbackKit/Features/Submit/SubmitViewModel.swift b/Sources/FeedbackKit/Features/Submit/SubmitViewModel.swift
new file mode 100644
index 0000000..c0e2bc9
--- /dev/null
+++ b/Sources/FeedbackKit/Features/Submit/SubmitViewModel.swift
@@ -0,0 +1,39 @@
+import Foundation
+
+@MainActor
+public final class SubmitViewModel: ObservableObject {
+ @Published public var boardId: String = ""
+ @Published public var title: String = ""
+ @Published public var content: String = ""
+ @Published public private(set) var isSubmitting = false
+ @Published public private(set) var errorMessage: String?
+ @Published public var needsSignIn = false
+
+ private let api: FeedbackAPI
+ private let isSignedIn: () -> Bool
+
+ public init(api: FeedbackAPI, isSignedIn: @escaping () -> Bool) {
+ self.api = api; self.isSignedIn = isSignedIn
+ }
+
+ @discardableResult
+ public func submit() async -> Bool {
+ needsSignIn = false
+ errorMessage = nil
+ guard isSignedIn() else { needsSignIn = true; return false }
+ guard !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
+ errorMessage = "Title is required."; return false
+ }
+ guard !boardId.isEmpty else { errorMessage = "Pick a board."; return false }
+ isSubmitting = true
+ defer { isSubmitting = false }
+ do {
+ _ = try await api.submitPost(boardId: boardId, title: title, content: content)
+ return true
+ } catch APIError.unauthorized {
+ needsSignIn = true; return false
+ } catch {
+ errorMessage = FeedViewModel.message(for: error); return false
+ }
+ }
+}
diff --git a/Sources/FeedbackKit/FeedbackKit.swift b/Sources/FeedbackKit/FeedbackKit.swift
new file mode 100644
index 0000000..e41efd1
--- /dev/null
+++ b/Sources/FeedbackKit/FeedbackKit.swift
@@ -0,0 +1,3 @@
+public enum FeedbackKit {
+ public static let name = "FeedbackKit"
+}
diff --git a/Sources/FeedbackKit/Models/Models.swift b/Sources/FeedbackKit/Models/Models.swift
new file mode 100644
index 0000000..caa6fa4
--- /dev/null
+++ b/Sources/FeedbackKit/Models/Models.swift
@@ -0,0 +1,194 @@
+import Foundation
+
+// MARK: - Envelope types
+
+public struct Envelope: Codable, Sendable, Equatable {
+ public let data: T
+
+ public init(data: T) {
+ self.data = data
+ }
+}
+
+public struct Pagination: Codable, Sendable, Equatable {
+ public let cursor: String?
+ public let hasMore: Bool
+
+ public init(cursor: String?, hasMore: Bool) {
+ self.cursor = cursor
+ self.hasMore = hasMore
+ }
+}
+
+public struct Meta: Codable, Sendable, Equatable {
+ public let pagination: Pagination?
+
+ public init(pagination: Pagination?) {
+ self.pagination = pagination
+ }
+}
+
+public struct Page: Codable, Sendable, Equatable {
+ public let data: [T]
+ public let meta: Meta?
+
+ public init(data: [T], meta: Meta?) {
+ self.data = data
+ self.meta = meta
+ }
+}
+
+// MARK: - Domain models
+
+public struct Board: Codable, Sendable, Equatable, Identifiable {
+ public let id: String
+ public let name: String
+ public let slug: String
+ public let description: String?
+ public let postCount: Int?
+
+ public init(id: String, name: String, slug: String, description: String?, postCount: Int?) {
+ self.id = id
+ self.name = name
+ self.slug = slug
+ self.description = description
+ self.postCount = postCount
+ }
+}
+
+public struct PostSummary: Codable, Sendable, Equatable, Identifiable {
+ public let id: String
+ public let title: String
+ public let voteCount: Int
+ public let statusId: String?
+ public let boardId: String
+ public let createdAt: Date
+ public let hasVoted: Bool
+
+ public init(id: String, title: String, voteCount: Int, statusId: String?, boardId: String, createdAt: Date, hasVoted: Bool) {
+ self.id = id
+ self.title = title
+ self.voteCount = voteCount
+ self.statusId = statusId
+ self.boardId = boardId
+ self.createdAt = createdAt
+ self.hasVoted = hasVoted
+ }
+}
+
+public struct PostDetail: Codable, Sendable, Equatable, Identifiable {
+ public let id: String
+ public let title: String
+ public let content: String
+ public let voteCount: Int
+ public let statusId: String?
+ public let boardId: String
+ public let createdAt: Date
+ public let hasVoted: Bool
+
+ public init(id: String, title: String, content: String, voteCount: Int, statusId: String?, boardId: String, createdAt: Date, hasVoted: Bool) {
+ self.id = id
+ self.title = title
+ self.content = content
+ self.voteCount = voteCount
+ self.statusId = statusId
+ self.boardId = boardId
+ self.createdAt = createdAt
+ self.hasVoted = hasVoted
+ }
+}
+
+public struct Comment: Codable, Sendable, Equatable, Identifiable {
+ public let id: String
+ public let content: String
+ public let authorName: String
+ public let createdAt: Date
+ public let replies: [Comment]
+
+ public init(id: String, content: String, authorName: String, createdAt: Date, replies: [Comment]) {
+ self.id = id
+ self.content = content
+ self.authorName = authorName
+ self.createdAt = createdAt
+ self.replies = replies
+ }
+}
+
+public struct ChangelogEntry: Codable, Sendable, Equatable, Identifiable {
+ public let id: String
+ public let title: String
+ public let content: String?
+ public let publishedAt: Date?
+
+ public init(id: String, title: String, content: String?, publishedAt: Date?) {
+ self.id = id
+ self.title = title
+ self.content = content
+ self.publishedAt = publishedAt
+ }
+}
+
+public struct HelpCategory: Codable, Sendable, Equatable, Identifiable {
+ public let id: String
+ public let name: String
+ public let slug: String
+ public let description: String?
+
+ public init(id: String, name: String, slug: String, description: String?) {
+ self.id = id
+ self.name = name
+ self.slug = slug
+ self.description = description
+ }
+}
+
+public struct HelpArticle: Codable, Sendable, Equatable, Identifiable {
+ public let id: String
+ public let slug: String
+ public let title: String
+ public let content: String
+ public let categoryId: String
+
+ public init(id: String, slug: String, title: String, content: String, categoryId: String) {
+ self.id = id
+ self.slug = slug
+ self.title = title
+ self.content = content
+ self.categoryId = categoryId
+ }
+}
+
+public struct VoteResult: Codable, Sendable, Equatable {
+ public let voted: Bool
+ public let voteCount: Int
+
+ public init(voted: Bool, voteCount: Int) {
+ self.voted = voted
+ self.voteCount = voteCount
+ }
+}
+
+// MARK: - JSONDecoder extension
+
+public extension JSONDecoder {
+ static let feedback: JSONDecoder = {
+ let decoder = JSONDecoder()
+ decoder.keyDecodingStrategy = .convertFromSnakeCase
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ decoder.dateDecodingStrategy = .custom { dec in
+ let container = try dec.singleValueContainer()
+ let string = try container.decode(String.self)
+ guard let date = formatter.date(from: string) else {
+ throw DecodingError.dataCorrupted(
+ DecodingError.Context(
+ codingPath: dec.codingPath,
+ debugDescription: "Invalid ISO-8601 date with fractional seconds: \(string)"
+ )
+ )
+ }
+ return date
+ }
+ return decoder
+ }()
+}
diff --git a/Tests/FeedbackKitTests/AuthStoreTests.swift b/Tests/FeedbackKitTests/AuthStoreTests.swift
new file mode 100644
index 0000000..0522692
--- /dev/null
+++ b/Tests/FeedbackKitTests/AuthStoreTests.swift
@@ -0,0 +1,48 @@
+@testable import FeedbackKit
+import XCTest
+
+final class StubAuthService: AuthService, @unchecked Sendable {
+ var sentTo: String?
+ var tokenToReturn = "session_tok"
+ var sendError: Error?
+ func sendOTP(email: String) async throws {
+ if let sendError { throw sendError }
+ sentTo = email
+ }
+ func verifyOTP(email: String, code: String) async throws -> String { tokenToReturn }
+}
+
+@MainActor
+final class AuthStoreTests: XCTestCase {
+ func testSignInStoresTokenAndFlipsState() async {
+ let store = InMemoryTokenStore()
+ let auth = AuthStore(service: StubAuthService(), tokenStore: store)
+ XCTAssertFalse(auth.isSignedIn)
+ try? await auth.requestCode(email: "v@x.com")
+ await auth.verify(email: "v@x.com", code: "123456")
+ XCTAssertTrue(auth.isSignedIn)
+ XCTAssertEqual(store.token, "session_tok")
+ }
+
+ func testSignOutClearsToken() async {
+ let store = InMemoryTokenStore(token: "old")
+ let auth = AuthStore(service: StubAuthService(), tokenStore: store)
+ XCTAssertTrue(auth.isSignedIn)
+ auth.signOut()
+ XCTAssertFalse(auth.isSignedIn)
+ XCTAssertNil(store.token)
+ }
+
+ func testRequestCodeFailureSetsErrorMessageAndRethrows() async {
+ let service = StubAuthService()
+ service.sendError = APIError.transport("offline")
+ let auth = AuthStore(service: service, tokenStore: InMemoryTokenStore())
+
+ do {
+ try await auth.requestCode(email: "v@x.com")
+ XCTFail("Expected requestCode to throw")
+ } catch {
+ XCTAssertEqual(auth.errorMessage, "Couldn't send a code. Please try again.")
+ }
+ }
+}
diff --git a/Tests/FeedbackKitTests/ContentCacheTests.swift b/Tests/FeedbackKitTests/ContentCacheTests.swift
new file mode 100644
index 0000000..9b8c979
--- /dev/null
+++ b/Tests/FeedbackKitTests/ContentCacheTests.swift
@@ -0,0 +1,18 @@
+@testable import FeedbackKit
+import XCTest
+
+final class ContentCacheTests: XCTestCase {
+ func testRoundTripsPosts() throws {
+ let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ let cache = ContentCache(directory: dir)
+ // Use a date with millisecond precision so the ISO-8601 encode/decode round-trip is exact.
+ let posts = [PostSummary(id: "post_1", title: "A", voteCount: 1, statusId: nil, boardId: "b1", createdAt: Date(timeIntervalSince1970: 1_700_000_000.123), hasVoted: false)]
+ try cache.save(posts, as: "feed")
+ let loaded: [PostSummary] = try cache.load("feed", as: [PostSummary].self)
+ XCTAssertEqual(loaded, posts)
+ }
+ func testLoadMissingThrows() {
+ let cache = ContentCache(directory: FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString))
+ XCTAssertThrowsError(try cache.load("nope", as: [PostSummary].self))
+ }
+}
diff --git a/Tests/FeedbackKitTests/FeedViewModelTests.swift b/Tests/FeedbackKitTests/FeedViewModelTests.swift
new file mode 100644
index 0000000..e3ef073
--- /dev/null
+++ b/Tests/FeedbackKitTests/FeedViewModelTests.swift
@@ -0,0 +1,48 @@
+@testable import FeedbackKit
+import XCTest
+
+@MainActor
+final class FeedViewModelTests: XCTestCase {
+ func testLoadPopulatesPostsAndClearsLoading() async {
+ let api = MockFeedbackAPI()
+ api.posts = [PostSummary(id: "post_1", title: "A", voteCount: 5, statusId: nil, boardId: "b1", createdAt: .init(), hasVoted: false)]
+ let vm = FeedViewModel(api: api, cache: ContentCache(directory: FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)))
+ await vm.load()
+ XCTAssertEqual(vm.posts.map(\.id), ["post_1"])
+ XCTAssertFalse(vm.isLoading)
+ XCTAssertNil(vm.errorMessage)
+ }
+
+ func testLoadFailureSetsErrorMessage() async {
+ final class FailingAPI: MockFeedbackAPI {
+ override func listPosts(boardId: String?, sort: PostSort, cursor: String?) async throws -> Page {
+ throw APIError.transport("offline")
+ }
+ }
+ let emptyCache = ContentCache(directory: FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString))
+ let vm = FeedViewModel(api: FailingAPI(), cache: emptyCache)
+ await vm.load()
+ XCTAssertTrue(vm.posts.isEmpty)
+ XCTAssertNotNil(vm.errorMessage)
+ XCTAssertFalse(vm.isLoading)
+ }
+
+ func testCacheFallbackYieldsCachedPostsAndNoError() async throws {
+ // Pre-populate a temp cache with one post
+ let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ let cache = ContentCache(directory: dir)
+ let cachedPost = PostSummary(id: "cached_1", title: "Cached", voteCount: 2, statusId: nil, boardId: "b1", createdAt: Date(timeIntervalSince1970: 0), hasVoted: false)
+ try cache.save([cachedPost], as: "feed")
+
+ final class FailingAPI: MockFeedbackAPI {
+ override func listPosts(boardId: String?, sort: PostSort, cursor: String?) async throws -> Page {
+ throw APIError.transport("offline")
+ }
+ }
+ let vm = FeedViewModel(api: FailingAPI(), cache: cache)
+ await vm.load()
+ XCTAssertEqual(vm.posts.map(\.id), ["cached_1"])
+ XCTAssertNil(vm.errorMessage)
+ XCTAssertFalse(vm.isLoading)
+ }
+}
diff --git a/Tests/FeedbackKitTests/HTTPAuthServiceTests.swift b/Tests/FeedbackKitTests/HTTPAuthServiceTests.swift
new file mode 100644
index 0000000..6dc299c
--- /dev/null
+++ b/Tests/FeedbackKitTests/HTTPAuthServiceTests.swift
@@ -0,0 +1,19 @@
+@testable import FeedbackKit
+import XCTest
+
+final class HTTPAuthServiceTests: XCTestCase {
+ private func make() -> HTTPAuthService {
+ let cfg = URLSessionConfiguration.ephemeral
+ cfg.protocolClasses = [StubURLProtocol.self]
+ return HTTPAuthService(baseURL: URL(string: "https://fb.example.com")!, session: URLSession(configuration: cfg))
+ }
+ func testVerifyReturnsToken() async throws {
+ StubURLProtocol.handler = { req in
+ XCTAssertEqual(req.url?.path, "/api/auth/sign-in/email-otp")
+ return (HTTPURLResponse(url: req.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!,
+ Data(#"{"token":"sess_123","user":{"id":"u1"}}"#.utf8))
+ }
+ let token = try await make().verifyOTP(email: "v@x.com", code: "123456")
+ XCTAssertEqual(token, "sess_123")
+ }
+}
diff --git a/Tests/FeedbackKitTests/HTTPFeedbackAPITests.swift b/Tests/FeedbackKitTests/HTTPFeedbackAPITests.swift
new file mode 100644
index 0000000..0fbb4a9
--- /dev/null
+++ b/Tests/FeedbackKitTests/HTTPFeedbackAPITests.swift
@@ -0,0 +1,46 @@
+@testable import FeedbackKit
+import XCTest
+
+class StubURLProtocol: URLProtocol {
+ nonisolated(unsafe) static var handler: ((URLRequest) -> (HTTPURLResponse, Data))?
+ override class func canInit(with request: URLRequest) -> Bool { true }
+ override class func canonicalRequest(for r: URLRequest) -> URLRequest { r }
+ override func startLoading() {
+ let (resp, data) = StubURLProtocol.handler!(request)
+ client?.urlProtocol(self, didReceive: resp, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocol(self, didLoad: data)
+ client?.urlProtocolDidFinishLoading(self)
+ }
+ override func stopLoading() {}
+}
+
+final class HTTPFeedbackAPITests: XCTestCase {
+ private func makeAPI(token: String? = nil) -> HTTPFeedbackAPI {
+ let cfg = URLSessionConfiguration.ephemeral
+ cfg.protocolClasses = [StubURLProtocol.self]
+ let session = URLSession(configuration: cfg)
+ return HTTPFeedbackAPI(baseURL: URL(string: "https://fb.example.com")!,
+ session: session, tokenProvider: { token })
+ }
+
+ func testListPostsParsesEnvelope() async throws {
+ StubURLProtocol.handler = { req in
+ XCTAssertEqual(req.url?.path, "/api/public/v1/posts")
+ let body = #"{"data":[{"id":"post_1","title":"A","voteCount":2,"statusId":null,"boardId":"b1","createdAt":"2026-01-01T00:00:00.000Z","hasVoted":false}],"meta":{"pagination":{"cursor":null,"hasMore":false}}}"#
+ return (HTTPURLResponse(url: req.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, Data(body.utf8))
+ }
+ let page = try await makeAPI().listPosts(boardId: nil, sort: .newest, cursor: nil)
+ XCTAssertEqual(page.data.first?.id, "post_1")
+ }
+
+ func testVoteSendsBearerAndMapsUnauthorized() async {
+ StubURLProtocol.handler = { req in
+ XCTAssertEqual(req.value(forHTTPHeaderField: "Authorization"), "Bearer tok")
+ return (HTTPURLResponse(url: req.url!, statusCode: 401, httpVersion: nil, headerFields: nil)!,
+ Data(#"{"error":{"code":"UNAUTHORIZED","message":"x"}}"#.utf8))
+ }
+ do { _ = try await makeAPI(token: "tok").vote(postId: "post_1"); XCTFail("expected throw") } catch {
+ XCTAssertEqual(error as? APIError, .unauthorized)
+ }
+ }
+}
diff --git a/Tests/FeedbackKitTests/MockFeedbackAPI.swift b/Tests/FeedbackKitTests/MockFeedbackAPI.swift
new file mode 100644
index 0000000..b30a12e
--- /dev/null
+++ b/Tests/FeedbackKitTests/MockFeedbackAPI.swift
@@ -0,0 +1,119 @@
+@testable import FeedbackKit
+import Foundation
+
+// MARK: - MockFeedbackAPI
+
+struct SubmittedPost {
+ var boardId: String
+ var title: String
+ var content: String
+}
+
+struct AddedComment {
+ var postId: String
+ var content: String
+ var parentId: String?
+}
+
+// Non-final so subclasses can override individual methods to inject failures.
+class MockFeedbackAPI: @unchecked Sendable, FeedbackAPI {
+
+ // MARK: Canned data (mutable so tests can configure per-scenario)
+
+ var posts: [PostSummary] = []
+ var postDetail = PostDetail(
+ id: "post_1",
+ title: "Test Post",
+ content: "Content",
+ voteCount: 0,
+ statusId: nil,
+ boardId: "board_1",
+ createdAt: Date(timeIntervalSince1970: 0),
+ hasVoted: false
+ )
+ var comments: [Comment] = []
+ var boards: [Board] = []
+ var changelogEntries: [ChangelogEntry] = []
+ var helpCategories: [HelpCategory] = []
+ var helpArticle = HelpArticle(
+ id: "article_1",
+ slug: "getting-started",
+ title: "Getting Started",
+ content: "Welcome.",
+ categoryId: "cat_1"
+ )
+ var voteResult = VoteResult(voted: true, voteCount: 1)
+
+ // MARK: Failure injection
+
+ var shouldUnauthorize = false
+
+ // MARK: Recorded inputs
+
+ var submitted: SubmittedPost?
+ var votedPostId: String?
+ var addedComment: AddedComment?
+
+ // MARK: FeedbackAPI conformance
+
+ func listPosts(boardId: String?, sort: PostSort, cursor: String?) async throws -> Page {
+ Page(data: posts, meta: nil)
+ }
+
+ func getPost(id: String) async throws -> PostDetail {
+ postDetail
+ }
+
+ func listComments(postId: String) async throws -> [Comment] {
+ comments
+ }
+
+ func listBoards() async throws -> [Board] {
+ boards
+ }
+
+ func listChangelog(cursor: String?) async throws -> Page {
+ Page(data: changelogEntries, meta: nil)
+ }
+
+ func listHelpCategories() async throws -> [HelpCategory] {
+ helpCategories
+ }
+
+ func getHelpArticle(slug: String) async throws -> HelpArticle {
+ helpArticle
+ }
+
+ func submitPost(boardId: String, title: String, content: String) async throws -> PostSummary {
+ if shouldUnauthorize { throw APIError.unauthorized }
+ submitted = SubmittedPost(boardId: boardId, title: title, content: content)
+ let post = PostSummary(
+ id: "post_new",
+ title: title,
+ voteCount: 0,
+ statusId: nil,
+ boardId: boardId,
+ createdAt: Date(timeIntervalSince1970: 0),
+ hasVoted: false
+ )
+ return post
+ }
+
+ func vote(postId: String) async throws -> VoteResult {
+ if shouldUnauthorize { throw APIError.unauthorized }
+ votedPostId = postId
+ return voteResult
+ }
+
+ func addComment(postId: String, content: String, parentId: String?) async throws -> Comment {
+ if shouldUnauthorize { throw APIError.unauthorized }
+ addedComment = AddedComment(postId: postId, content: content, parentId: parentId)
+ return Comment(
+ id: "comment_new",
+ content: content,
+ authorName: "Test User",
+ createdAt: Date(timeIntervalSince1970: 0),
+ replies: []
+ )
+ }
+}
diff --git a/Tests/FeedbackKitTests/ModelsTests.swift b/Tests/FeedbackKitTests/ModelsTests.swift
new file mode 100644
index 0000000..eb30f16
--- /dev/null
+++ b/Tests/FeedbackKitTests/ModelsTests.swift
@@ -0,0 +1,23 @@
+@testable import FeedbackKit
+import XCTest
+
+final class ModelsTests: XCTestCase {
+ func testDecodesPostSummaryAndPageEnvelope() throws {
+ let raw = """
+ {"data":[{"id":"post_1","title":"A","voteCount":5,"statusId":null,"boardId":"b1","createdAt":"2026-01-01T00:00:00.000Z","hasVoted":true}],
+ "meta":{"pagination":{"cursor":null,"hasMore":false}}}
+ """
+ let json = Data(raw.utf8)
+ let page = try JSONDecoder.feedback.decode(Page.self, from: json)
+ XCTAssertEqual(page.data.count, 1)
+ XCTAssertEqual(page.data[0].id, "post_1")
+ XCTAssertTrue(page.data[0].hasVoted)
+ XCTAssertFalse(page.meta?.pagination?.hasMore ?? true)
+ }
+
+ func testDecodesBareDataEnvelope() throws {
+ let json = Data(#"{"data":{"id":"post_1","title":"A","content":"x","voteCount":3,"statusId":null,"boardId":"b1","createdAt":"2026-01-01T00:00:00.000Z","hasVoted":false}}"#.utf8)
+ let env = try JSONDecoder.feedback.decode(Envelope.self, from: json)
+ XCTAssertEqual(env.data.content, "x")
+ }
+}
diff --git a/Tests/FeedbackKitTests/PostDetailViewModelTests.swift b/Tests/FeedbackKitTests/PostDetailViewModelTests.swift
new file mode 100644
index 0000000..b5220f2
--- /dev/null
+++ b/Tests/FeedbackKitTests/PostDetailViewModelTests.swift
@@ -0,0 +1,55 @@
+@testable import FeedbackKit
+import XCTest
+
+@MainActor
+final class PostDetailViewModelTests: XCTestCase {
+ func testLoadFetchesPostAndComments() async {
+ let api = MockFeedbackAPI()
+ let vm = PostDetailViewModel(postId: "post_1", api: api, isSignedIn: { true })
+ await vm.load()
+ XCTAssertEqual(vm.post?.id, "post_1")
+ XCTAssertNotNil(vm.comments)
+ }
+
+ func testVoteUpdatesCountWhenSignedIn() async {
+ let api = MockFeedbackAPI()
+ api.voteResult = VoteResult(voted: true, voteCount: 9)
+ let vm = PostDetailViewModel(postId: "post_1", api: api, isSignedIn: { true })
+ await vm.load()
+ await vm.toggleVote()
+ XCTAssertEqual(vm.post?.voteCount, 9)
+ XCTAssertEqual(vm.post?.hasVoted, true)
+ XCTAssertFalse(vm.needsSignIn)
+ }
+
+ func testVoteWhenSignedOutRequestsSignIn() async {
+ let vm = PostDetailViewModel(postId: "post_1", api: MockFeedbackAPI(), isSignedIn: { false })
+ await vm.load()
+ await vm.toggleVote()
+ XCTAssertTrue(vm.needsSignIn)
+ }
+
+ func testVoteResetsNeedsSignInBeforeRepeatedSignedOutAttempts() async {
+ let vm = PostDetailViewModel(postId: "post_1", api: MockFeedbackAPI(), isSignedIn: { false })
+ await vm.load()
+ await vm.toggleVote()
+ XCTAssertTrue(vm.needsSignIn)
+ vm.needsSignIn = false
+
+ await vm.toggleVote()
+
+ XCTAssertTrue(vm.needsSignIn)
+ }
+
+ func testAddCommentResetsNeedsSignInBeforeRepeatedSignedOutAttempts() async {
+ let vm = PostDetailViewModel(postId: "post_1", api: MockFeedbackAPI(), isSignedIn: { false })
+ await vm.load()
+ await vm.addComment("hello")
+ XCTAssertTrue(vm.needsSignIn)
+ vm.needsSignIn = false
+
+ await vm.addComment("hello")
+
+ XCTAssertTrue(vm.needsSignIn)
+ }
+}
diff --git a/Tests/FeedbackKitTests/ProtocolConformanceTests.swift b/Tests/FeedbackKitTests/ProtocolConformanceTests.swift
new file mode 100644
index 0000000..cae31cc
--- /dev/null
+++ b/Tests/FeedbackKitTests/ProtocolConformanceTests.swift
@@ -0,0 +1,51 @@
+@testable import FeedbackKit
+import XCTest
+
+final class ProtocolConformanceTests: XCTestCase {
+
+ func testMockConformsToFeedbackAPI() async throws {
+ // Assigning to the protocol type proves conformance at compile time.
+ let api: FeedbackAPI = MockFeedbackAPI()
+ let page = try await api.listPosts(boardId: nil, sort: .newest, cursor: nil)
+ XCTAssertEqual(page.data.count, 0)
+ }
+
+ func testShouldUnauthorizeThrowsOnWrite() async {
+ let mock = MockFeedbackAPI()
+ mock.shouldUnauthorize = true
+
+ do {
+ _ = try await mock.submitPost(boardId: "b", title: "T", content: "C")
+ XCTFail("Expected unauthorized error")
+ } catch APIError.unauthorized {
+ // expected
+ } catch {
+ XCTFail("Unexpected error: \(error)")
+ }
+ }
+
+ func testRecordsSubmittedInputs() async throws {
+ let mock = MockFeedbackAPI()
+ _ = try await mock.submitPost(boardId: "b1", title: "My Post", content: "Body")
+ XCTAssertEqual(mock.submitted?.boardId, "b1")
+ XCTAssertEqual(mock.submitted?.title, "My Post")
+ XCTAssertEqual(mock.submitted?.content, "Body")
+ }
+
+ func testRecordsVotePostId() async throws {
+ let mock = MockFeedbackAPI()
+ let result = try await mock.vote(postId: "post_42")
+ XCTAssertEqual(mock.votedPostId, "post_42")
+ XCTAssertTrue(result.voted)
+ XCTAssertEqual(result.voteCount, 1)
+ }
+
+ func testRecordsAddedComment() async throws {
+ let mock = MockFeedbackAPI()
+ let comment = try await mock.addComment(postId: "post_1", content: "Nice!", parentId: nil)
+ XCTAssertEqual(mock.addedComment?.postId, "post_1")
+ XCTAssertEqual(mock.addedComment?.content, "Nice!")
+ XCTAssertNil(mock.addedComment?.parentId)
+ XCTAssertEqual(comment.content, "Nice!")
+ }
+}
diff --git a/Tests/FeedbackKitTests/ReadOnlyViewModelsTests.swift b/Tests/FeedbackKitTests/ReadOnlyViewModelsTests.swift
new file mode 100644
index 0000000..14a1856
--- /dev/null
+++ b/Tests/FeedbackKitTests/ReadOnlyViewModelsTests.swift
@@ -0,0 +1,27 @@
+@testable import FeedbackKit
+import XCTest
+
+@MainActor
+final class ReadOnlyViewModelsTests: XCTestCase {
+ func testChangelogLoads() async {
+ final class API: MockFeedbackAPI {
+ override func listChangelog(cursor: String?) async throws -> Page {
+ Page(data: [ChangelogEntry(id: "cl_1", title: "v1", content: nil, publishedAt: .init())], meta: nil)
+ }
+ }
+ let vm = ChangelogViewModel(api: API())
+ await vm.load()
+ XCTAssertEqual(vm.entries.map(\.id), ["cl_1"])
+ }
+
+ func testHelpLoadsCategories() async {
+ final class API: MockFeedbackAPI {
+ override func listHelpCategories() async throws -> [HelpCategory] {
+ [HelpCategory(id: "cat_1", name: "Start", slug: "start", description: nil)]
+ }
+ }
+ let vm = HelpViewModel(api: API())
+ await vm.load()
+ XCTAssertEqual(vm.categories.map(\.slug), ["start"])
+ }
+}
diff --git a/Tests/FeedbackKitTests/SmokeTests.swift b/Tests/FeedbackKitTests/SmokeTests.swift
new file mode 100644
index 0000000..be4d933
--- /dev/null
+++ b/Tests/FeedbackKitTests/SmokeTests.swift
@@ -0,0 +1,8 @@
+@testable import FeedbackKit
+import XCTest
+
+final class SmokeTests: XCTestCase {
+ func testModuleLoads() {
+ XCTAssertEqual(FeedbackKit.name, "FeedbackKit")
+ }
+}
diff --git a/Tests/FeedbackKitTests/SubmitViewModelTests.swift b/Tests/FeedbackKitTests/SubmitViewModelTests.swift
new file mode 100644
index 0000000..018f0c4
--- /dev/null
+++ b/Tests/FeedbackKitTests/SubmitViewModelTests.swift
@@ -0,0 +1,42 @@
+@testable import FeedbackKit
+import XCTest
+
+@MainActor
+final class SubmitViewModelTests: XCTestCase {
+ func testSubmitSucceedsWhenSignedIn() async {
+ let api = MockFeedbackAPI()
+ let vm = SubmitViewModel(api: api, isSignedIn: { true })
+ vm.boardId = "b1"; vm.title = "Bug"; vm.content = "Crashes on launch"
+ let ok = await vm.submit()
+ XCTAssertTrue(ok)
+ XCTAssertEqual(api.submitted?.title, "Bug")
+ }
+ func testSubmitBlockedWhenSignedOut() async {
+ let vm = SubmitViewModel(api: MockFeedbackAPI(), isSignedIn: { false })
+ vm.boardId = "b1"; vm.title = "Bug"
+ let ok = await vm.submit()
+ XCTAssertFalse(ok)
+ XCTAssertTrue(vm.needsSignIn)
+ }
+
+ func testSubmitResetsNeedsSignInBeforeRepeatedSignedOutAttempts() async {
+ let vm = SubmitViewModel(api: MockFeedbackAPI(), isSignedIn: { false })
+ vm.boardId = "b1"; vm.title = "Bug"
+ let firstAttempt = await vm.submit()
+ XCTAssertFalse(firstAttempt)
+ XCTAssertTrue(vm.needsSignIn)
+ vm.needsSignIn = false
+
+ let secondAttempt = await vm.submit()
+
+ XCTAssertFalse(secondAttempt)
+ XCTAssertTrue(vm.needsSignIn)
+ }
+ func testSubmitValidatesEmptyTitle() async {
+ let vm = SubmitViewModel(api: MockFeedbackAPI(), isSignedIn: { true })
+ vm.boardId = "b1"; vm.title = " "
+ let ok = await vm.submit()
+ XCTAssertFalse(ok)
+ XCTAssertEqual(vm.errorMessage, "Title is required.")
+ }
+}
diff --git a/Tests/FeedbackKitTests/TokenStoreTests.swift b/Tests/FeedbackKitTests/TokenStoreTests.swift
new file mode 100644
index 0000000..10b551f
--- /dev/null
+++ b/Tests/FeedbackKitTests/TokenStoreTests.swift
@@ -0,0 +1,13 @@
+@testable import FeedbackKit
+import XCTest
+
+final class TokenStoreTests: XCTestCase {
+ func testInMemoryStoreRoundTrips() {
+ let store = InMemoryTokenStore()
+ XCTAssertNil(store.token)
+ store.token = "abc"
+ XCTAssertEqual(store.token, "abc")
+ store.token = nil
+ XCTAssertNil(store.token)
+ }
+}
diff --git a/docs/superpowers/plans/2026-05-28-track1-public-api.md b/docs/superpowers/plans/2026-05-28-track1-public-api.md
new file mode 100644
index 0000000..e8a68c9
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-28-track1-public-api.md
@@ -0,0 +1,1268 @@
+# Track 1 — Public End-User API Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add a public `/api/public/v1/*` REST surface to `OpenCoven/feedback` — anonymous reads + end-user (better-auth bearer) writes — so a native app can browse boards/posts, vote, comment, submit, and read changelog + help, reusing existing domain services.
+
+**Architecture:** New routes under `apps/web/src/routes/api/public/v1/`, structurally cloning the admin `/api/v1/*` routes but swapping `withApiKeyAuth` for a portal-session helper (`optionalPortalSession` / `requirePortalSession`) that reuses the existing bearer→session→principal lookup from `getWidgetSession`. Writes are attributed to the session's principal. One genuinely new service query (`listPublicPosts`) powers the anonymous feed; everything else calls existing services. A parallel OpenAPI document is published at `/api/public/v1/openapi.json`.
+
+**Tech Stack:** TanStack Start (`createFileRoute` server handlers), Zod, better-auth (`bearer` plugin + `session` table), Drizzle, `zod-openapi`, Vitest.
+
+**Repo:** This plan executes in `OpenCoven/feedback` (clone it; this is NOT the `feedback-mobile` repo). All paths below are relative to that repo root.
+
+---
+
+## File Structure
+
+- Create `apps/web/src/lib/server/domains/api/portal-auth.ts` — `optionalPortalSession()` / `requirePortalSession()`. Owns end-user (portal) bearer auth for public routes. Wraps the existing session-by-token lookup.
+- Create `apps/web/src/lib/server/domains/posts/post.public-list.ts` — `listPublicPosts(...)`, the anonymous-safe feed query (public boards, visible posts only). Kept separate from `post.inbox.ts` (admin) so admin/public visibility rules never tangle.
+- Create read routes: `apps/web/src/routes/api/public/v1/{config,boards/index,posts/index,posts/$postId,posts/$postId.comments,changelog/index,changelog/$entryId,help/categories/index,help/articles/$slug,help/search}.ts`
+- Create write routes: `apps/web/src/routes/api/public/v1/posts/$postId.vote.ts`, `apps/web/src/routes/api/public/v1/posts/$postId.comments.ts` (POST handler co-located with the GET in the same file), `posts/index.ts` (POST co-located with feed GET).
+- Create `apps/web/src/routes/api/public/v1/openapi[.]json.ts` — serves the public spec.
+- Create `apps/web/src/lib/server/domains/api/public-openapi.ts` — registers public paths, builds the public document (separate from the admin doc in `openapi.ts`).
+- Tests co-located in `__tests__/` beside each route, mirroring `apps/web/src/routes/api/v1/posts/__tests__/index.test.ts`.
+
+Each route file owns exactly one URL path; the auth helper and the feed query are the only shared new units.
+
+---
+
+## Task 1: Portal-session auth helper
+
+**Files:**
+- Create: `apps/web/src/lib/server/domains/api/portal-auth.ts`
+- Test: `apps/web/src/lib/server/domains/api/__tests__/portal-auth.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mockFindFirst = vi.fn()
+vi.mock('@/lib/server/db', () => ({
+ db: { query: { session: { findFirst: (...a: unknown[]) => mockFindFirst(...a) } } },
+ session: { token: 'token', expiresAt: 'expiresAt' },
+ principal: { userId: 'user_id' },
+ eq: vi.fn(), and: vi.fn(), gt: vi.fn(),
+}))
+
+import { optionalPortalSession, requirePortalSession } from '../portal-auth'
+import { UnauthorizedError } from '@/lib/shared/errors'
+
+function req(auth?: string): Request {
+ return new Request('http://t/x', { headers: auth ? { authorization: auth } : {} })
+}
+
+describe('portal-auth', () => {
+ beforeEach(() => mockFindFirst.mockReset())
+
+ it('returns null when no bearer token is present', async () => {
+ expect(await optionalPortalSession(req())).toBeNull()
+ })
+
+ it('returns null when the session token is unknown/expired', async () => {
+ mockFindFirst.mockResolvedValue(undefined)
+ expect(await optionalPortalSession(req('Bearer nope'))).toBeNull()
+ })
+
+ it('returns the principal + user for a valid session', async () => {
+ mockFindFirst.mockResolvedValue({
+ userId: 'user_1',
+ user: { id: 'user_1', name: 'Val', email: 'v@x.com', image: null },
+ principal: { id: 'principal_1', role: 'user', type: 'user' },
+ })
+ const ctx = await optionalPortalSession(req('Bearer good'))
+ expect(ctx?.principal.id).toBe('principal_1')
+ expect(ctx?.user.email).toBe('v@x.com')
+ })
+
+ it('requirePortalSession throws UnauthorizedError when anonymous', async () => {
+ await expect(requirePortalSession(req())).rejects.toBeInstanceOf(UnauthorizedError)
+ })
+})
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd apps/web && pnpm vitest run src/lib/server/domains/api/__tests__/portal-auth.test.ts`
+Expected: FAIL — `Cannot find module '../portal-auth'`.
+
+- [ ] **Step 3: Write minimal implementation**
+
+```ts
+// apps/web/src/lib/server/domains/api/portal-auth.ts
+import type { PrincipalId, UserId } from '@opencoven-feedback/ids'
+import type { Role } from '@/lib/server/auth'
+import { db, session, principal, eq, and, gt } from '@/lib/server/db'
+import { UnauthorizedError } from '@/lib/shared/errors'
+
+export interface PortalSession {
+ user: { id: UserId; email: string; name: string; image: string | null }
+ principal: { id: PrincipalId; role: Role; type: string }
+}
+
+function bearer(request: Request): string | null {
+ const h = request.headers.get('authorization')
+ if (!h?.startsWith('Bearer ')) return null
+ const t = h.slice(7).trim()
+ return t.length ? t : null
+}
+
+/** Resolve an end-user session from a bearer token, or null if absent/invalid. */
+export async function optionalPortalSession(request: Request): Promise {
+ const token = bearer(request)
+ if (!token) return null
+
+ const row = await db.query.session.findFirst({
+ where: and(eq(session.token, token), gt(session.expiresAt, new Date())),
+ with: { user: true, principal: true },
+ })
+ if (!row?.user || !row.principal) return null
+
+ return {
+ user: {
+ id: row.user.id as UserId,
+ email: row.user.email!,
+ name: row.user.name,
+ image: row.user.image ?? null,
+ },
+ principal: {
+ id: row.principal.id as PrincipalId,
+ role: row.principal.role as Role,
+ type: row.principal.type ?? 'user',
+ },
+ }
+}
+
+/** Like optionalPortalSession but throws UnauthorizedError when anonymous. */
+export async function requirePortalSession(request: Request): Promise {
+ const s = await optionalPortalSession(request)
+ if (!s) throw new UnauthorizedError('Sign in required. Provide Authorization: Bearer .')
+ return s
+}
+```
+
+> NOTE: `getWidgetSession` (`lib/server/functions/widget-auth.ts`) does the same token→session→principal lookup using `getRequestHeaders()` and lazily creates a missing principal. This helper takes the `request` directly (route handlers already have it) and assumes the `session.principal` relation exists. If the Drizzle `session` relation has no `principal`, mirror `getWidgetSession`'s principal-by-`userId` lookup (`db.query.principal.findFirst({ where: eq(principal.userId, userId) })`) plus its create-if-missing block instead of the `with: { principal: true }` include.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd apps/web && pnpm vitest run src/lib/server/domains/api/__tests__/portal-auth.test.ts`
+Expected: PASS (4 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/web/src/lib/server/domains/api/portal-auth.ts apps/web/src/lib/server/domains/api/__tests__/portal-auth.test.ts
+git commit -m "feat(public-api): portal-session bearer auth helper"
+```
+
+---
+
+## Task 2: Public config endpoint (anonymous read)
+
+**Files:**
+- Create: `apps/web/src/routes/api/public/v1/config.ts`
+- Test: `apps/web/src/routes/api/public/v1/__tests__/config.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+import { describe, expect, it, vi } from 'vitest'
+
+const mockGetPublicWidgetConfig = vi.fn()
+vi.mock('@tanstack/react-router', () => ({
+ createFileRoute: vi.fn(() => (opts: unknown) => ({ options: opts })),
+}))
+vi.mock('@/lib/server/domains/settings/settings.widget', () => ({
+ getPublicWidgetConfig: (...a: unknown[]) => mockGetPublicWidgetConfig(...a),
+}))
+
+import { Route } from '../config'
+type Opts = { server: { handlers: { GET: () => Promise } } }
+const GET = (Route as unknown as { options: Opts }).options.server.handlers.GET
+
+describe('GET /api/public/v1/config', () => {
+ it('returns the public config payload', async () => {
+ mockGetPublicWidgetConfig.mockResolvedValue({ enabled: true, tabs: { feedback: true }, defaultBoard: 'b1' })
+ const res = await GET()
+ expect(res.status).toBe(200)
+ const json = await res.json()
+ expect(json.data.tabs.feedback).toBe(true)
+ })
+})
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd apps/web && pnpm vitest run src/routes/api/public/v1/__tests__/config.test.ts`
+Expected: FAIL — `Cannot find module '../config'`.
+
+- [ ] **Step 3: Write minimal implementation**
+
+```ts
+// apps/web/src/routes/api/public/v1/config.ts
+import { createFileRoute } from '@tanstack/react-router'
+import { successResponse, handleDomainError } from '@/lib/server/domains/api/responses'
+
+export const Route = createFileRoute('/api/public/v1/config')({
+ server: {
+ handlers: {
+ GET: async () => {
+ try {
+ const { getPublicWidgetConfig } = await import('@/lib/server/domains/settings/settings.widget')
+ const config = await getPublicWidgetConfig()
+ return successResponse(config)
+ } catch (error) {
+ return handleDomainError(error)
+ }
+ },
+ },
+ },
+})
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd apps/web && pnpm vitest run src/routes/api/public/v1/__tests__/config.test.ts`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/web/src/routes/api/public/v1/config.ts apps/web/src/routes/api/public/v1/__tests__/config.test.ts
+git commit -m "feat(public-api): GET /api/public/v1/config"
+```
+
+---
+
+## Task 3: `listPublicPosts` feed query (the one new service)
+
+**Files:**
+- Create: `apps/web/src/lib/server/domains/posts/post.public-list.ts`
+- Test: `apps/web/src/lib/server/domains/posts/__tests__/post.public-list.test.ts`
+
+The admin feed uses `listInboxPosts` (`post.inbox.ts`), which includes deleted/private posts — wrong for anonymous users. Add a public-scoped list.
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mockFindMany = vi.fn()
+vi.mock('@/lib/server/db', () => ({
+ db: { query: { post: { findMany: (...a: unknown[]) => mockFindMany(...a) } } },
+ post: {}, board: {}, eq: vi.fn(), and: vi.fn(), desc: vi.fn(), asc: vi.fn(), lt: vi.fn(),
+}))
+
+import { listPublicPosts } from '../post.public-list'
+
+describe('listPublicPosts', () => {
+ beforeEach(() => mockFindMany.mockReset())
+
+ it('requests only public, non-deleted posts and maps the result', async () => {
+ mockFindMany.mockResolvedValue([
+ { id: 'post_1', title: 'A', voteCount: 5, deletedAt: null, board: { isPublic: true } },
+ ])
+ const result = await listPublicPosts({ limit: 20 })
+ expect(result.items).toHaveLength(1)
+ expect(result.items[0].id).toBe('post_1')
+ expect(mockFindMany).toHaveBeenCalledTimes(1)
+ })
+})
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd apps/web && pnpm vitest run src/lib/server/domains/posts/__tests__/post.public-list.test.ts`
+Expected: FAIL — `Cannot find module '../post.public-list'`.
+
+- [ ] **Step 3: Write minimal implementation**
+
+```ts
+// apps/web/src/lib/server/domains/posts/post.public-list.ts
+import type { BoardId } from '@opencoven-feedback/ids'
+import { db, post, board, and, eq, desc, lt } from '@/lib/server/db'
+
+export interface PublicPostsParams {
+ boardId?: BoardId
+ sort?: 'newest' | 'votes'
+ cursor?: string
+ limit: number
+}
+
+export interface PublicPostSummary {
+ id: string
+ title: string
+ voteCount: number
+ statusId: string | null
+ boardId: string
+ createdAt: string
+}
+
+/** Lists posts visible to anonymous end users: public boards, not deleted. */
+export async function listPublicPosts(
+ params: PublicPostsParams
+): Promise<{ items: PublicPostSummary[]; cursor: string | null; hasMore: boolean }> {
+ const rows = await db.query.post.findMany({
+ where: and(
+ eq(post.deletedAt, null as unknown as Date),
+ params.boardId ? eq(post.boardId, params.boardId) : undefined
+ ),
+ with: { board: true },
+ orderBy: params.sort === 'votes' ? desc(post.voteCount) : desc(post.createdAt),
+ limit: params.limit + 1,
+ })
+
+ const visible = rows.filter((r) => r.board?.isPublic)
+ const page = visible.slice(0, params.limit)
+ const hasMore = visible.length > params.limit
+
+ return {
+ items: page.map((r) => ({
+ id: r.id,
+ title: r.title,
+ voteCount: r.voteCount,
+ statusId: r.statusId ?? null,
+ boardId: r.boardId,
+ createdAt: r.createdAt.toISOString(),
+ })),
+ cursor: hasMore ? page[page.length - 1].id : null,
+ hasMore,
+ }
+}
+```
+
+> NOTE: Match column names to the actual Drizzle `post` schema in `apps/web/src/lib/server/db/schema*`. If soft-delete is a boolean (`isDeleted`) rather than `deletedAt`, and/or cursor pagination uses a keyset on `createdAt`+`id`, adjust the `where`/`orderBy` to mirror `listInboxPosts` in `post.inbox.ts`. The contract (params in, `{items,cursor,hasMore}` out) stays fixed.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd apps/web && pnpm vitest run src/lib/server/domains/posts/__tests__/post.public-list.test.ts`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/web/src/lib/server/domains/posts/post.public-list.ts apps/web/src/lib/server/domains/posts/__tests__/post.public-list.test.ts
+git commit -m "feat(public-api): listPublicPosts feed query"
+```
+
+---
+
+## Task 4: GET `/api/public/v1/posts` (feed) + GET `/boards`
+
+**Files:**
+- Create: `apps/web/src/routes/api/public/v1/posts/index.ts` (feed GET; POST added in Task 9)
+- Create: `apps/web/src/routes/api/public/v1/boards/index.ts`
+- Test: `apps/web/src/routes/api/public/v1/posts/__tests__/index.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+import { describe, expect, it, vi } from 'vitest'
+
+const mockList = vi.fn()
+const mockOptional = vi.fn()
+const mockVoted = vi.fn()
+vi.mock('@tanstack/react-router', () => ({ createFileRoute: vi.fn(() => (o: unknown) => ({ options: o })) }))
+vi.mock('@/lib/server/domains/posts/post.public-list', () => ({ listPublicPosts: (...a: unknown[]) => mockList(...a) }))
+vi.mock('@/lib/server/domains/api/portal-auth', () => ({ optionalPortalSession: (...a: unknown[]) => mockOptional(...a) }))
+vi.mock('@/lib/server/domains/posts/post.public', () => ({ getAllUserVotedPostIds: (...a: unknown[]) => mockVoted(...a) }))
+
+import { Route } from '../index'
+type Opts = { server: { handlers: { GET: (a: { request: Request }) => Promise } } }
+const GET = (Route as unknown as { options: Opts }).options.server.handlers.GET
+
+describe('GET /api/public/v1/posts', () => {
+ it('returns a feed and marks hasVoted when authed', async () => {
+ mockOptional.mockResolvedValue({ principal: { id: 'principal_1' } })
+ mockVoted.mockResolvedValue(new Set(['post_1']))
+ mockList.mockResolvedValue({ items: [{ id: 'post_1', title: 'A', voteCount: 2, statusId: null, boardId: 'b1', createdAt: '2026-01-01T00:00:00.000Z' }], cursor: null, hasMore: false })
+ const res = await GET({ request: new Request('http://t/api/public/v1/posts?limit=20') })
+ expect(res.status).toBe(200)
+ const json = await res.json()
+ expect(json.data[0].hasVoted).toBe(true)
+ expect(json.meta.pagination.hasMore).toBe(false)
+ })
+
+ it('works anonymously (hasVoted false)', async () => {
+ mockOptional.mockResolvedValue(null)
+ mockList.mockResolvedValue({ items: [{ id: 'post_2', title: 'B', voteCount: 0, statusId: null, boardId: 'b1', createdAt: '2026-01-01T00:00:00.000Z' }], cursor: null, hasMore: false })
+ const res = await GET({ request: new Request('http://t/api/public/v1/posts') })
+ const json = await res.json()
+ expect(json.data[0].hasVoted).toBe(false)
+ })
+})
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd apps/web && pnpm vitest run src/routes/api/public/v1/posts/__tests__/index.test.ts`
+Expected: FAIL — `Cannot find module '../index'`.
+
+- [ ] **Step 3: Write minimal implementation**
+
+```ts
+// apps/web/src/routes/api/public/v1/posts/index.ts
+import { createFileRoute } from '@tanstack/react-router'
+import type { BoardId } from '@opencoven-feedback/ids'
+import { successResponse, handleDomainError } from '@/lib/server/domains/api/responses'
+import { optionalPortalSession } from '@/lib/server/domains/api/portal-auth'
+import { listPublicPosts } from '@/lib/server/domains/posts/post.public-list'
+
+export const Route = createFileRoute('/api/public/v1/posts/')({
+ server: {
+ handlers: {
+ GET: async ({ request }) => {
+ try {
+ const url = new URL(request.url)
+ const limit = Math.min(100, Math.max(1, parseInt(url.searchParams.get('limit') ?? '20', 10) || 20))
+ const sort = (url.searchParams.get('sort') as 'newest' | 'votes') ?? 'newest'
+ const boardId = (url.searchParams.get('boardId') ?? undefined) as BoardId | undefined
+ const cursor = url.searchParams.get('cursor') ?? undefined
+
+ const result = await listPublicPosts({ boardId, sort, cursor, limit })
+
+ const session = await optionalPortalSession(request)
+ let voted = new Set()
+ if (session) {
+ const { getAllUserVotedPostIds } = await import('@/lib/server/domains/posts/post.public')
+ voted = await getAllUserVotedPostIds(session.principal.id)
+ }
+
+ return successResponse(
+ result.items.map((p) => ({ ...p, hasVoted: voted.has(p.id) })),
+ { pagination: { cursor: result.cursor, hasMore: result.hasMore } }
+ )
+ } catch (error) {
+ return handleDomainError(error)
+ }
+ },
+ },
+ },
+})
+```
+
+```ts
+// apps/web/src/routes/api/public/v1/boards/index.ts
+import { createFileRoute } from '@tanstack/react-router'
+import { successResponse, handleDomainError } from '@/lib/server/domains/api/responses'
+
+export const Route = createFileRoute('/api/public/v1/boards/')({
+ server: {
+ handlers: {
+ GET: async () => {
+ try {
+ const { listBoardsWithDetails } = await import('@/lib/server/domains/boards/board.service')
+ const boards = await listBoardsWithDetails()
+ return successResponse(
+ boards
+ .filter((b) => b.isPublic)
+ .map((b) => ({ id: b.id, name: b.name, slug: b.slug, description: b.description, postCount: b.postCount }))
+ )
+ } catch (error) {
+ return handleDomainError(error)
+ }
+ },
+ },
+ },
+})
+```
+
+> NOTE: Confirm `getAllUserVotedPostIds(principalId)` returns a `Set` (it is used by `api/widget/identify.ts`); if it returns an array, wrap with `new Set(...)`.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd apps/web && pnpm vitest run src/routes/api/public/v1/posts/__tests__/index.test.ts`
+Expected: PASS (2 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/web/src/routes/api/public/v1/posts/index.ts apps/web/src/routes/api/public/v1/boards/index.ts apps/web/src/routes/api/public/v1/posts/__tests__/index.test.ts
+git commit -m "feat(public-api): GET posts feed + boards"
+```
+
+---
+
+## Task 5: GET `/posts/:id` and GET `/posts/:id/comments`
+
+**Files:**
+- Create: `apps/web/src/routes/api/public/v1/posts/$postId.ts`
+- Create: `apps/web/src/routes/api/public/v1/posts/$postId.comments.ts` (GET; POST added in Task 11)
+- Test: `apps/web/src/routes/api/public/v1/posts/__tests__/detail.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+import { describe, expect, it, vi } from 'vitest'
+
+const mockGetPost = vi.fn()
+const mockOptional = vi.fn()
+const mockVoted = vi.fn()
+vi.mock('@tanstack/react-router', () => ({ createFileRoute: vi.fn(() => (o: unknown) => ({ options: o })) }))
+vi.mock('@/lib/server/domains/posts/post.query', () => ({ getPostWithDetails: (...a: unknown[]) => mockGetPost(...a) }))
+vi.mock('@/lib/server/domains/api/portal-auth', () => ({ optionalPortalSession: (...a: unknown[]) => mockOptional(...a) }))
+vi.mock('@/lib/server/domains/posts/post.public', () => ({ getAllUserVotedPostIds: (...a: unknown[]) => mockVoted(...a) }))
+vi.mock('@/lib/server/domains/api/validation', () => ({ parseTypeId: (v: string) => v }))
+
+import { Route } from '../$postId'
+type Opts = { server: { handlers: { GET: (a: { request: Request; params: { postId: string } }) => Promise } } }
+const GET = (Route as unknown as { options: Opts }).options.server.handlers.GET
+
+describe('GET /api/public/v1/posts/:id', () => {
+ it('returns the post', async () => {
+ mockOptional.mockResolvedValue(null)
+ mockGetPost.mockResolvedValue({ id: 'post_1', title: 'A', content: 'x', voteCount: 3, statusId: null, boardId: 'b1', createdAt: new Date('2026-01-01') })
+ const res = await GET({ request: new Request('http://t/api/public/v1/posts/post_1'), params: { postId: 'post_1' } })
+ expect(res.status).toBe(200)
+ expect((await res.json()).data.id).toBe('post_1')
+ })
+
+ it('404s when missing', async () => {
+ mockOptional.mockResolvedValue(null)
+ mockGetPost.mockResolvedValue(null)
+ const res = await GET({ request: new Request('http://t/x'), params: { postId: 'post_x' } })
+ expect(res.status).toBe(404)
+ })
+})
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd apps/web && pnpm vitest run src/routes/api/public/v1/posts/__tests__/detail.test.ts`
+Expected: FAIL — `Cannot find module '../$postId'`.
+
+- [ ] **Step 3: Write minimal implementation**
+
+```ts
+// apps/web/src/routes/api/public/v1/posts/$postId.ts
+import { createFileRoute } from '@tanstack/react-router'
+import type { PostId } from '@opencoven-feedback/ids'
+import { successResponse, notFoundResponse, handleDomainError } from '@/lib/server/domains/api/responses'
+import { parseTypeId } from '@/lib/server/domains/api/validation'
+import { optionalPortalSession } from '@/lib/server/domains/api/portal-auth'
+
+export const Route = createFileRoute('/api/public/v1/posts/$postId')({
+ server: {
+ handlers: {
+ GET: async ({ request, params }) => {
+ try {
+ const postId = parseTypeId(params.postId, 'post', 'post ID')
+ const { getPostWithDetails } = await import('@/lib/server/domains/posts/post.query')
+ const post = await getPostWithDetails(postId)
+ if (!post) return notFoundResponse('Post not found')
+
+ const session = await optionalPortalSession(request)
+ let hasVoted = false
+ if (session) {
+ const { getAllUserVotedPostIds } = await import('@/lib/server/domains/posts/post.public')
+ hasVoted = (await getAllUserVotedPostIds(session.principal.id)).has(post.id)
+ }
+
+ return successResponse({
+ id: post.id, title: post.title, content: post.content,
+ voteCount: post.voteCount, statusId: post.statusId ?? null,
+ boardId: post.boardId, createdAt: post.createdAt.toISOString(), hasVoted,
+ })
+ } catch (error) {
+ return handleDomainError(error)
+ }
+ },
+ },
+ },
+})
+```
+
+```ts
+// apps/web/src/routes/api/public/v1/posts/$postId.comments.ts
+import { createFileRoute } from '@tanstack/react-router'
+import type { PostId } from '@opencoven-feedback/ids'
+import { successResponse, handleDomainError } from '@/lib/server/domains/api/responses'
+import { parseTypeId } from '@/lib/server/domains/api/validation'
+
+export const Route = createFileRoute('/api/public/v1/posts/$postId/comments')({
+ server: {
+ handlers: {
+ GET: async ({ params }) => {
+ try {
+ const postId = parseTypeId(params.postId, 'post', 'post ID')
+ const { getCommentsWithReplies } = await import('@/lib/server/domains/posts/post.query')
+ const comments = await getCommentsWithReplies(postId)
+ const serialize = (c: { id: string; content: string; authorName: string; createdAt: Date; replies: unknown[] }): unknown => ({
+ id: c.id, content: c.content, authorName: c.authorName,
+ createdAt: c.createdAt.toISOString(),
+ replies: (c.replies as typeof comments).map(serialize),
+ })
+ return successResponse(comments.map(serialize))
+ } catch (error) {
+ return handleDomainError(error)
+ }
+ },
+ },
+ },
+})
+```
+
+> NOTE: `notFoundResponse` is exported from `responses.ts` (the admin routes use `NotFoundError` + `handleDomainError`; either is fine — if `notFoundResponse` doesn't exist, `throw new NotFoundError('Post not found')` and let `handleDomainError` map it to 404). Mirror the comment field names from `apps/web/src/routes/api/v1/posts/$postId.comments.ts`.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd apps/web && pnpm vitest run src/routes/api/public/v1/posts/__tests__/detail.test.ts`
+Expected: PASS (2 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/web/src/routes/api/public/v1/posts/\$postId.ts apps/web/src/routes/api/public/v1/posts/\$postId.comments.ts apps/web/src/routes/api/public/v1/posts/__tests__/detail.test.ts
+git commit -m "feat(public-api): GET post detail + comments"
+```
+
+---
+
+## Task 6: GET changelog (list + entry)
+
+**Files:**
+- Create: `apps/web/src/routes/api/public/v1/changelog/index.ts`, `apps/web/src/routes/api/public/v1/changelog/$entryId.ts`
+- Test: `apps/web/src/routes/api/public/v1/changelog/__tests__/index.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+import { describe, expect, it, vi } from 'vitest'
+const mockList = vi.fn()
+vi.mock('@tanstack/react-router', () => ({ createFileRoute: vi.fn(() => (o: unknown) => ({ options: o })) }))
+vi.mock('@/lib/server/domains/changelog/changelog.query', () => ({ listChangelogs: (...a: unknown[]) => mockList(...a) }))
+import { Route } from '../index'
+type Opts = { server: { handlers: { GET: (a: { request: Request }) => Promise } } }
+const GET = (Route as unknown as { options: Opts }).options.server.handlers.GET
+
+describe('GET /api/public/v1/changelog', () => {
+ it('lists only published entries', async () => {
+ mockList.mockResolvedValue({ items: [{ id: 'cl_1', title: 'v1', publishedAt: new Date('2026-01-01') }], cursor: null, hasMore: false })
+ const res = await GET({ request: new Request('http://t/api/public/v1/changelog') })
+ expect(res.status).toBe(200)
+ expect(mockList).toHaveBeenCalledWith(expect.objectContaining({ status: 'published' }))
+ })
+})
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd apps/web && pnpm vitest run src/routes/api/public/v1/changelog/__tests__/index.test.ts`
+Expected: FAIL — module not found.
+
+- [ ] **Step 3: Write minimal implementation**
+
+```ts
+// apps/web/src/routes/api/public/v1/changelog/index.ts
+import { createFileRoute } from '@tanstack/react-router'
+import { successResponse, handleDomainError } from '@/lib/server/domains/api/responses'
+import { listChangelogs } from '@/lib/server/domains/changelog/changelog.query'
+
+export const Route = createFileRoute('/api/public/v1/changelog/')({
+ server: {
+ handlers: {
+ GET: async ({ request }) => {
+ try {
+ const url = new URL(request.url)
+ const cursor = url.searchParams.get('cursor') ?? undefined
+ const limit = Math.min(100, Math.max(1, parseInt(url.searchParams.get('limit') ?? '20', 10) || 20))
+ // Anonymous users only ever see published entries.
+ const result = await listChangelogs({ status: 'published', cursor, limit })
+ return successResponse(
+ result.items.map((e) => ({ id: e.id, title: e.title, publishedAt: e.publishedAt?.toISOString() ?? null })),
+ { pagination: { cursor: result.cursor, hasMore: result.hasMore } }
+ )
+ } catch (error) {
+ return handleDomainError(error)
+ }
+ },
+ },
+ },
+})
+```
+
+```ts
+// apps/web/src/routes/api/public/v1/changelog/$entryId.ts
+import { createFileRoute } from '@tanstack/react-router'
+import { successResponse, notFoundResponse, handleDomainError } from '@/lib/server/domains/api/responses'
+
+export const Route = createFileRoute('/api/public/v1/changelog/$entryId')({
+ server: {
+ handlers: {
+ GET: async ({ params }) => {
+ try {
+ const { getChangelog } = await import('@/lib/server/domains/changelog/changelog.query')
+ const entry = await getChangelog(params.entryId)
+ if (!entry || entry.status !== 'published') return notFoundResponse('Changelog entry not found')
+ return successResponse({ id: entry.id, title: entry.title, content: entry.content, publishedAt: entry.publishedAt?.toISOString() ?? null })
+ } catch (error) {
+ return handleDomainError(error)
+ }
+ },
+ },
+ },
+})
+```
+
+> NOTE: Confirm `getChangelog` exists in `changelog.query.ts` (the admin `$entryId.ts` route imports the single-entry getter — use that exact name). Match `listChangelogs`'s param shape (`{ status, cursor, limit }`) — verified from `api/v1/changelog/index.ts`.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd apps/web && pnpm vitest run src/routes/api/public/v1/changelog/__tests__/index.test.ts`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/web/src/routes/api/public/v1/changelog/ && git commit -m "feat(public-api): GET changelog list + entry"
+```
+
+---
+
+## Task 7: GET help-center (categories, article, search)
+
+**Files:**
+- Create: `apps/web/src/routes/api/public/v1/help/categories/index.ts`, `apps/web/src/routes/api/public/v1/help/articles/$slug.ts`, `apps/web/src/routes/api/public/v1/help/search.ts`
+- Test: `apps/web/src/routes/api/public/v1/help/__tests__/help.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+import { describe, expect, it, vi } from 'vitest'
+const mockCats = vi.fn()
+vi.mock('@tanstack/react-router', () => ({ createFileRoute: vi.fn(() => (o: unknown) => ({ options: o })) }))
+vi.mock('@/lib/server/domains/help-center/help-center.service', () => ({
+ listCategories: (...a: unknown[]) => mockCats(...a),
+ listArticles: vi.fn(), getArticleBySlug: vi.fn(),
+}))
+import { Route } from '../categories/index'
+type Opts = { server: { handlers: { GET: () => Promise } } }
+const GET = (Route as unknown as { options: Opts }).options.server.handlers.GET
+
+describe('GET /api/public/v1/help/categories', () => {
+ it('returns categories', async () => {
+ mockCats.mockResolvedValue([{ id: 'cat_1', name: 'Getting Started', slug: 'getting-started' }])
+ const res = await GET()
+ expect(res.status).toBe(200)
+ expect((await res.json()).data[0].slug).toBe('getting-started')
+ })
+})
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd apps/web && pnpm vitest run src/routes/api/public/v1/help/__tests__/help.test.ts`
+Expected: FAIL — module not found.
+
+- [ ] **Step 3: Write minimal implementation**
+
+```ts
+// apps/web/src/routes/api/public/v1/help/categories/index.ts
+import { createFileRoute } from '@tanstack/react-router'
+import { successResponse, handleDomainError } from '@/lib/server/domains/api/responses'
+import { listCategories } from '@/lib/server/domains/help-center/help-center.service'
+
+export const Route = createFileRoute('/api/public/v1/help/categories/')({
+ server: {
+ handlers: {
+ GET: async () => {
+ try {
+ const cats = await listCategories()
+ return successResponse(cats.map((c) => ({ id: c.id, name: c.name, slug: c.slug, description: c.description ?? null })))
+ } catch (error) {
+ return handleDomainError(error)
+ }
+ },
+ },
+ },
+})
+```
+
+```ts
+// apps/web/src/routes/api/public/v1/help/articles/$slug.ts
+import { createFileRoute } from '@tanstack/react-router'
+import { successResponse, notFoundResponse, handleDomainError } from '@/lib/server/domains/api/responses'
+import { getArticleBySlug } from '@/lib/server/domains/help-center/help-center.service'
+
+export const Route = createFileRoute('/api/public/v1/help/articles/$slug')({
+ server: {
+ handlers: {
+ GET: async ({ params }) => {
+ try {
+ const article = await getArticleBySlug(params.slug)
+ if (!article || article.status !== 'published') return notFoundResponse('Article not found')
+ return successResponse({ id: article.id, slug: article.slug, title: article.title, content: article.content, categoryId: article.categoryId })
+ } catch (error) {
+ return handleDomainError(error)
+ }
+ },
+ },
+ },
+})
+```
+
+```ts
+// apps/web/src/routes/api/public/v1/help/search.ts
+import { createFileRoute } from '@tanstack/react-router'
+import { successResponse, handleDomainError } from '@/lib/server/domains/api/responses'
+
+export const Route = createFileRoute('/api/public/v1/help/search')({
+ server: {
+ handlers: {
+ GET: async ({ request }) => {
+ try {
+ const q = new URL(request.url).searchParams.get('q')?.trim() ?? ''
+ if (!q) return successResponse([])
+ const { searchKnowledgeBase } = await import('@/lib/server/domains/help-center/help-center.service')
+ const results = await searchKnowledgeBase(q)
+ return successResponse(results.map((r) => ({ id: r.id, slug: r.slug, title: r.title })))
+ } catch (error) {
+ return handleDomainError(error)
+ }
+ },
+ },
+ },
+})
+```
+
+> NOTE: `listCategories`/`listArticles` are confirmed in `help-center.service.ts`. For the single-article getter and search, use the exact names that file exports (the existing `api/widget/kb-search.ts` route already performs widget KB search — reuse the same service function it calls instead of `searchKnowledgeBase` if the name differs). Filter to `status === 'published'` for anonymous access.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd apps/web && pnpm vitest run src/routes/api/public/v1/help/__tests__/help.test.ts`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/web/src/routes/api/public/v1/help/ && git commit -m "feat(public-api): GET help categories, article, search"
+```
+
+---
+
+## Task 8: POST `/posts` (submit) — auth required
+
+**Files:**
+- Modify: `apps/web/src/routes/api/public/v1/posts/index.ts` (add POST handler)
+- Test: `apps/web/src/routes/api/public/v1/posts/__tests__/submit.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+import { describe, expect, it, vi } from 'vitest'
+const mockRequire = vi.fn()
+const mockCreate = vi.fn()
+vi.mock('@tanstack/react-router', () => ({ createFileRoute: vi.fn(() => (o: unknown) => ({ options: o })) }))
+vi.mock('@/lib/server/domains/posts/post.public-list', () => ({ listPublicPosts: vi.fn() }))
+vi.mock('@/lib/server/domains/api/portal-auth', () => ({
+ optionalPortalSession: vi.fn(), requirePortalSession: (...a: unknown[]) => mockRequire(...a),
+}))
+vi.mock('@/lib/server/domains/posts/post.service', () => ({ createPost: (...a: unknown[]) => mockCreate(...a) }))
+import { Route } from '../index'
+import { UnauthorizedError } from '@/lib/shared/errors'
+type Opts = { server: { handlers: { POST: (a: { request: Request }) => Promise } } }
+const POST = (Route as unknown as { options: Opts }).options.server.handlers.POST
+const body = (b: unknown) => new Request('http://t/api/public/v1/posts', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) })
+
+describe('POST /api/public/v1/posts', () => {
+ it('401s when anonymous', async () => {
+ mockRequire.mockRejectedValue(new UnauthorizedError('Sign in required'))
+ const res = await POST({ request: body({ boardId: 'b1', title: 'Hi' }) })
+ expect(res.status).toBe(401)
+ })
+ it('creates a post attributed to the session principal', async () => {
+ mockRequire.mockResolvedValue({ principal: { id: 'principal_1' } })
+ mockCreate.mockResolvedValue({ id: 'post_new', title: 'Hi', boardId: 'b1', createdAt: new Date('2026-01-01') })
+ const res = await POST({ request: body({ boardId: 'b1', title: 'Hi', content: 'x' }) })
+ expect(res.status).toBe(201)
+ expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ authorPrincipalId: 'principal_1' }))
+ })
+ it('400s on invalid body', async () => {
+ mockRequire.mockResolvedValue({ principal: { id: 'principal_1' } })
+ const res = await POST({ request: body({ title: '' }) })
+ expect(res.status).toBe(400)
+ })
+})
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd apps/web && pnpm vitest run src/routes/api/public/v1/posts/__tests__/submit.test.ts`
+Expected: FAIL — `POST is not a function`.
+
+- [ ] **Step 3: Add the POST handler** (in `posts/index.ts`, alongside GET)
+
+Add these imports at the top:
+
+```ts
+import { z } from 'zod'
+import { createdResponse, badRequestResponse } from '@/lib/server/domains/api/responses'
+import { requirePortalSession } from '@/lib/server/domains/api/portal-auth'
+
+const submitSchema = z.object({
+ boardId: z.string().min(1, 'Board ID is required'),
+ title: z.string().min(1, 'Title is required').max(200),
+ content: z.string().max(10000).optional().default(''),
+})
+```
+
+Add the `POST` handler inside `handlers`:
+
+```ts
+POST: async ({ request }) => {
+ try {
+ const session = await requirePortalSession(request)
+ const body = await request.json().catch(() => null)
+ const parsed = submitSchema.safeParse(body)
+ if (!parsed.success) {
+ return badRequestResponse('Invalid request body', { errors: parsed.error.flatten().fieldErrors })
+ }
+ const { createPost } = await import('@/lib/server/domains/posts/post.service')
+ const post = await createPost({
+ boardId: parsed.data.boardId,
+ title: parsed.data.title,
+ content: parsed.data.content,
+ authorPrincipalId: session.principal.id,
+ })
+ return createdResponse({ id: post.id, title: post.title, boardId: post.boardId, createdAt: post.createdAt.toISOString() })
+ } catch (error) {
+ return handleDomainError(error)
+ }
+},
+```
+
+> NOTE: Match `createPost`'s argument shape to `post.service.ts` (the admin `POST /api/v1/posts` calls `createPost(...)` then resolves the author principal — mirror exactly how it passes the author). `handleDomainError` must map `UnauthorizedError`→401, `ValidationError`→400; confirm in `responses.ts` (the admin routes rely on this).
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd apps/web && pnpm vitest run src/routes/api/public/v1/posts/__tests__/submit.test.ts`
+Expected: PASS (3 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/web/src/routes/api/public/v1/posts/index.ts apps/web/src/routes/api/public/v1/posts/__tests__/submit.test.ts
+git commit -m "feat(public-api): POST submit post (auth required)"
+```
+
+---
+
+## Task 9: POST `/posts/:id/vote` (toggle) — auth required
+
+**Files:**
+- Create: `apps/web/src/routes/api/public/v1/posts/$postId.vote.ts`
+- Test: `apps/web/src/routes/api/public/v1/posts/__tests__/vote.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+import { describe, expect, it, vi } from 'vitest'
+const mockRequire = vi.fn()
+const mockVote = vi.fn()
+vi.mock('@tanstack/react-router', () => ({ createFileRoute: vi.fn(() => (o: unknown) => ({ options: o })) }))
+vi.mock('@/lib/server/domains/api/portal-auth', () => ({ requirePortalSession: (...a: unknown[]) => mockRequire(...a) }))
+vi.mock('@/lib/server/domains/posts/post.voting', () => ({ voteOnPost: (...a: unknown[]) => mockVote(...a) }))
+vi.mock('@/lib/server/domains/api/validation', () => ({ parseTypeId: (v: string) => v }))
+import { Route } from '../$postId.vote'
+type Opts = { server: { handlers: { POST: (a: { request: Request; params: { postId: string } }) => Promise } } }
+const POST = (Route as unknown as { options: Opts }).options.server.handlers.POST
+
+describe('POST /api/public/v1/posts/:id/vote', () => {
+ it('toggles the vote for the session principal', async () => {
+ mockRequire.mockResolvedValue({ principal: { id: 'principal_1' } })
+ mockVote.mockResolvedValue({ voted: true, voteCount: 6 })
+ const res = await POST({ request: new Request('http://t/x', { method: 'POST' }), params: { postId: 'post_1' } })
+ expect(res.status).toBe(200)
+ const json = await res.json()
+ expect(json.data).toEqual({ voted: true, voteCount: 6 })
+ expect(mockVote).toHaveBeenCalledWith('post_1', 'principal_1')
+ })
+})
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd apps/web && pnpm vitest run src/routes/api/public/v1/posts/__tests__/vote.test.ts`
+Expected: FAIL — module not found.
+
+- [ ] **Step 3: Write minimal implementation**
+
+```ts
+// apps/web/src/routes/api/public/v1/posts/$postId.vote.ts
+import { createFileRoute } from '@tanstack/react-router'
+import type { PostId } from '@opencoven-feedback/ids'
+import { successResponse, handleDomainError } from '@/lib/server/domains/api/responses'
+import { parseTypeId } from '@/lib/server/domains/api/validation'
+import { requirePortalSession } from '@/lib/server/domains/api/portal-auth'
+import { voteOnPost } from '@/lib/server/domains/posts/post.voting'
+
+export const Route = createFileRoute('/api/public/v1/posts/$postId/vote')({
+ server: {
+ handlers: {
+ POST: async ({ request, params }) => {
+ try {
+ const session = await requirePortalSession(request)
+ const postId = parseTypeId(params.postId, 'post', 'post ID')
+ const result = await voteOnPost(postId, session.principal.id)
+ return successResponse({ voted: result.voted, voteCount: result.voteCount })
+ } catch (error) {
+ return handleDomainError(error)
+ }
+ },
+ },
+ },
+})
+```
+
+> NOTE: Confirm `voteOnPost`'s signature in `post.voting.ts` (the admin `$postId.vote.ts` calls `voteOnPost(...)`). Match its argument order and the shape of its return (`{ voted, voteCount }`); adjust the response mapping if the property names differ.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd apps/web && pnpm vitest run src/routes/api/public/v1/posts/__tests__/vote.test.ts`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/web/src/routes/api/public/v1/posts/\$postId.vote.ts apps/web/src/routes/api/public/v1/posts/__tests__/vote.test.ts
+git commit -m "feat(public-api): POST toggle vote (auth required)"
+```
+
+---
+
+## Task 10: POST `/posts/:id/comments` — auth required
+
+**Files:**
+- Modify: `apps/web/src/routes/api/public/v1/posts/$postId.comments.ts` (add POST)
+- Test: `apps/web/src/routes/api/public/v1/posts/__tests__/comment-create.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+import { describe, expect, it, vi } from 'vitest'
+const mockRequire = vi.fn()
+const mockCreate = vi.fn()
+vi.mock('@tanstack/react-router', () => ({ createFileRoute: vi.fn(() => (o: unknown) => ({ options: o })) }))
+vi.mock('@/lib/server/domains/posts/post.query', () => ({ getCommentsWithReplies: vi.fn() }))
+vi.mock('@/lib/server/domains/api/portal-auth', () => ({ requirePortalSession: (...a: unknown[]) => mockRequire(...a) }))
+vi.mock('@/lib/server/domains/api/validation', () => ({ parseTypeId: (v: string) => v }))
+vi.mock('@/lib/server/domains/posts/post.comment', () => ({ createComment: (...a: unknown[]) => mockCreate(...a) }))
+import { Route } from '../$postId.comments'
+type Opts = { server: { handlers: { POST: (a: { request: Request; params: { postId: string } }) => Promise } } }
+const POST = (Route as unknown as { options: Opts }).options.server.handlers.POST
+const body = (b: unknown) => new Request('http://t/x', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) })
+
+describe('POST /api/public/v1/posts/:id/comments', () => {
+ it('creates a comment as the session principal', async () => {
+ mockRequire.mockResolvedValue({ principal: { id: 'principal_1' } })
+ mockCreate.mockResolvedValue({ id: 'comment_1', content: 'nice', createdAt: new Date('2026-01-01') })
+ const res = await POST({ request: body({ content: 'nice' }), params: { postId: 'post_1' } })
+ expect(res.status).toBe(201)
+ expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ authorPrincipalId: 'principal_1', content: 'nice' }))
+ })
+ it('400s on empty content', async () => {
+ mockRequire.mockResolvedValue({ principal: { id: 'principal_1' } })
+ const res = await POST({ request: body({ content: '' }), params: { postId: 'post_1' } })
+ expect(res.status).toBe(400)
+ })
+})
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd apps/web && pnpm vitest run src/routes/api/public/v1/posts/__tests__/comment-create.test.ts`
+Expected: FAIL — `POST is not a function`.
+
+- [ ] **Step 3: Add the POST handler** (in `$postId.comments.ts`)
+
+Add imports:
+
+```ts
+import { z } from 'zod'
+import { createdResponse, badRequestResponse } from '@/lib/server/domains/api/responses'
+import { requirePortalSession } from '@/lib/server/domains/api/portal-auth'
+
+const commentSchema = z.object({
+ content: z.string().min(1, 'Content is required').max(10000),
+ parentId: z.string().optional(),
+})
+```
+
+Add the handler:
+
+```ts
+POST: async ({ request, params }) => {
+ try {
+ const session = await requirePortalSession(request)
+ const postId = parseTypeId(params.postId, 'post', 'post ID')
+ const parsed = commentSchema.safeParse(await request.json().catch(() => null))
+ if (!parsed.success) {
+ return badRequestResponse('Invalid request body', { errors: parsed.error.flatten().fieldErrors })
+ }
+ const { createComment } = await import('@/lib/server/domains/posts/post.comment')
+ const comment = await createComment({
+ postId,
+ content: parsed.data.content,
+ parentId: parsed.data.parentId,
+ authorPrincipalId: session.principal.id,
+ })
+ return createdResponse({ id: comment.id, content: comment.content, createdAt: comment.createdAt.toISOString() })
+ } catch (error) {
+ return handleDomainError(error)
+ }
+},
+```
+
+> NOTE: Find the comment-creation service used by the admin `POST /api/v1/posts/$postId/comments` (`apps/web/src/routes/api/v1/posts/$postId.comments.ts`) and import that exact function/module path here (the example assumes `createComment` in `post.comment`). Match its argument shape.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd apps/web && pnpm vitest run src/routes/api/public/v1/posts/__tests__/comment-create.test.ts`
+Expected: PASS (2 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/web/src/routes/api/public/v1/posts/\$postId.comments.ts apps/web/src/routes/api/public/v1/posts/__tests__/comment-create.test.ts
+git commit -m "feat(public-api): POST create comment (auth required)"
+```
+
+---
+
+## Task 11: Public OpenAPI document
+
+**Files:**
+- Create: `apps/web/src/lib/server/domains/api/public-openapi.ts`
+- Create: `apps/web/src/routes/api/public/v1/openapi[.]json.ts`
+- Test: `apps/web/src/routes/api/public/v1/__tests__/openapi.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+import { describe, expect, it, vi } from 'vitest'
+vi.mock('@tanstack/react-router', () => ({ createFileRoute: vi.fn(() => (o: unknown) => ({ options: o })) }))
+import { Route } from '../openapi[.]json'
+type Opts = { server: { handlers: { GET: () => Promise } } }
+const GET = (Route as unknown as { options: Opts }).options.server.handlers.GET
+
+describe('GET /api/public/v1/openapi.json', () => {
+ it('serves an OpenAPI 3.x document covering public paths', async () => {
+ const res = await GET()
+ expect(res.status).toBe(200)
+ const doc = await res.json()
+ expect(doc.openapi).toMatch(/^3\./)
+ expect(Object.keys(doc.paths)).toContain('/api/public/v1/posts')
+ })
+})
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd apps/web && pnpm vitest run src/routes/api/public/v1/__tests__/openapi.test.ts`
+Expected: FAIL — module not found.
+
+- [ ] **Step 3: Write minimal implementation**
+
+```ts
+// apps/web/src/lib/server/domains/api/public-openapi.ts
+import 'zod-openapi'
+import { createDocument } from 'zod-openapi'
+import { z } from 'zod'
+
+/** Builds the public end-user API document. Mirror the descriptor style in openapi.ts. */
+export function buildPublicOpenApiDocument(baseUrl: string) {
+ return createDocument({
+ openapi: '3.1.0',
+ info: { title: 'OpenCoven Feedback — Public API', version: '1.0.0' },
+ servers: [{ url: baseUrl }],
+ paths: {
+ '/api/public/v1/config': { get: { summary: 'Public widget/portal config', responses: { 200: { description: 'OK' } } } },
+ '/api/public/v1/boards': { get: { summary: 'List public boards', responses: { 200: { description: 'OK' } } } },
+ '/api/public/v1/posts': {
+ get: { summary: 'List feed posts', responses: { 200: { description: 'OK' } } },
+ post: { summary: 'Submit a post (auth)', responses: { 201: { description: 'Created' }, 401: { description: 'Unauthorized' } } },
+ },
+ '/api/public/v1/posts/{postId}': { get: { summary: 'Get post detail', responses: { 200: { description: 'OK' }, 404: { description: 'Not found' } } } },
+ '/api/public/v1/posts/{postId}/comments': {
+ get: { summary: 'List comments', responses: { 200: { description: 'OK' } } },
+ post: { summary: 'Add comment (auth)', responses: { 201: { description: 'Created' }, 401: { description: 'Unauthorized' } } },
+ },
+ '/api/public/v1/posts/{postId}/vote': { post: { summary: 'Toggle vote (auth)', responses: { 200: { description: 'OK' }, 401: { description: 'Unauthorized' } } } },
+ '/api/public/v1/changelog': { get: { summary: 'List changelog', responses: { 200: { description: 'OK' } } } },
+ '/api/public/v1/changelog/{entryId}': { get: { summary: 'Get changelog entry', responses: { 200: { description: 'OK' }, 404: { description: 'Not found' } } } },
+ '/api/public/v1/help/categories': { get: { summary: 'List help categories', responses: { 200: { description: 'OK' } } } },
+ '/api/public/v1/help/articles/{slug}': { get: { summary: 'Get help article', responses: { 200: { description: 'OK' }, 404: { description: 'Not found' } } } },
+ '/api/public/v1/help/search': { get: { summary: 'Search help', responses: { 200: { description: 'OK' } } } },
+ },
+ components: {
+ securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', description: 'better-auth session token' } },
+ },
+ })
+ void z
+}
+```
+
+```ts
+// apps/web/src/routes/api/public/v1/openapi[.]json.ts
+import { createFileRoute } from '@tanstack/react-router'
+import { config } from '@/lib/server/config'
+import { buildPublicOpenApiDocument } from '@/lib/server/domains/api/public-openapi'
+
+export const Route = createFileRoute('/api/public/v1/openapi.json')({
+ server: {
+ handlers: {
+ GET: async () => {
+ const doc = buildPublicOpenApiDocument(config.baseUrl)
+ return new Response(JSON.stringify(doc), {
+ headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'public, max-age=3600' },
+ })
+ },
+ },
+ },
+})
+```
+
+> NOTE: For a richer spec, attach the Zod response schemas via `.meta()` and `registerPath` exactly as `openapi.ts` does for the admin API. The minimal document above is enough to drive `swift-openapi-generator` in Track 2; enrich incrementally.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd apps/web && pnpm vitest run src/routes/api/public/v1/__tests__/openapi.test.ts`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/web/src/lib/server/domains/api/public-openapi.ts apps/web/src/routes/api/public/v1/openapi\[.\]json.ts apps/web/src/routes/api/public/v1/__tests__/openapi.test.ts
+git commit -m "feat(public-api): publish /api/public/v1/openapi.json"
+```
+
+---
+
+## Task 12: Full suite + lint gate
+
+- [ ] **Step 1: Run the public-API test suite**
+
+Run: `cd apps/web && pnpm vitest run src/routes/api/public src/lib/server/domains/api/__tests__/portal-auth.test.ts src/lib/server/domains/posts/__tests__/post.public-list.test.ts`
+Expected: all PASS.
+
+- [ ] **Step 2: Typecheck + lint (match the repo's scripts)**
+
+Run: `cd apps/web && pnpm typecheck && pnpm lint` (use the script names in `apps/web/package.json`; commonly `typecheck`/`lint`)
+Expected: no errors.
+
+- [ ] **Step 3: Open the PR**
+
+```bash
+git push -u origin feat/public-end-user-api
+gh pr create --repo OpenCoven/feedback --base main \
+ --title "feat: public end-user API (/api/public/v1)" \
+ --body "Adds anonymous reads + better-auth bearer writes for native/portal end-user clients, reusing existing domain services. No new business logic or migrations. Backs the native iOS app."
+```
+
+---
+
+## Self-Review
+
+- **Spec coverage** (§4 of the design): config ✅ T2 · boards ✅ T4 · posts feed ✅ T3+T4 · post detail ✅ T5 · comments read ✅ T5 · changelog ✅ T6 · help ✅ T7 · submit ✅ T8 · vote ✅ T9 · comment create ✅ T10 · OpenAPI ✅ T11 · bearer auth (anon reads / session writes) ✅ T1. Rate-limiting is reused from existing middleware; if public routes need their own limiter, add `checkRateLimit(getClientIp(request))` (from `domains/api/rate-limit.ts`) at the top of each write handler — noted here so it isn't missed.
+- **Placeholder scan:** No "TBD"/"handle later". The `> NOTE:` blocks are concrete "verify this exact name in file X / mirror route Y" instructions, not vague placeholders — they exist because exact service signatures must be confirmed against the live repo at execution time.
+- **Type consistency:** `PortalSession.principal.id` (T1) is the principal passed to `createPost`/`voteOnPost`/`createComment` (T8/T9/T10) and `getAllUserVotedPostIds` (T4/T5). `listPublicPosts` returns `{items,cursor,hasMore}` (T3) consumed identically in T4.
diff --git a/docs/superpowers/plans/2026-05-28-track2-native-ios-app.md b/docs/superpowers/plans/2026-05-28-track2-native-ios-app.md
new file mode 100644
index 0000000..88191f6
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-28-track2-native-ios-app.md
@@ -0,0 +1,1501 @@
+# Track 2 — Native Give-Feedback iOS App Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build a native SwiftUI give-feedback app in `feedback-mobile` that browses boards/posts, votes, comments, submits, and reads changelog + help against the Track 1 public API — with content that updates live from the server.
+
+**Architecture:** A new SPM library target `FeedbackKit` holds all logic (typed API client, `AuthStore`, observable view models, models, cache) so it is unit-testable via `swift test` on macOS (no iOS SDK needed). A thin SwiftUI app target (`FeedbackPortalApp`) renders a 4-tab UI (Feedback · Changelog · Help · Account) over `FeedbackKit`. Reads are anonymous; writes attach a better-auth bearer token (email-OTP sign-in, stored in Keychain). The app is independent of the existing `OpenCovenFeedback` widget SDK.
+
+**Tech Stack:** Swift 5.9+, SwiftUI, Swift Concurrency, `URLSession`, `XCTest`. The API client is a hand-written `FeedbackAPI` protocol + a `URLSession`-backed implementation decoding `Codable` models that mirror the Track 1 OpenAPI contract. (A later task can swap the implementation for swift-openapi-generator output behind the same protocol without touching view models.)
+
+**Prerequisite:** Track 1 (`/api/public/v1`) contract is defined. View models are tested against a mock conforming to `FeedbackAPI`, so this track can proceed before the live API exists.
+
+**Repo:** `feedback-mobile` (this repo). New code goes under `Sources/FeedbackKit/` and `App/` with tests in `Tests/FeedbackKitTests/`.
+
+---
+
+## File Structure
+
+- `Package.swift` — add `FeedbackKit` library target + `FeedbackKitTests` test target (keep the existing `OpenCovenFeedback` SDK targets untouched).
+- `Sources/FeedbackKit/Config/AppConfig.swift` — instance URL.
+- `Sources/FeedbackKit/Models/*.swift` — `Board`, `PostSummary`, `PostDetail`, `Comment`, `ChangelogEntry`, `HelpCategory`, `HelpArticle`, `Page` (Codable, mirror the API JSON).
+- `Sources/FeedbackKit/API/FeedbackAPI.swift` — protocol (all endpoints).
+- `Sources/FeedbackKit/API/HTTPFeedbackAPI.swift` — `URLSession` implementation.
+- `Sources/FeedbackKit/API/APIError.swift` — typed errors incl. `.unauthorized`.
+- `Sources/FeedbackKit/Auth/TokenStore.swift` — Keychain-backed token persistence (protocol + Keychain impl + in-memory test impl).
+- `Sources/FeedbackKit/Auth/AuthStore.swift` — observable sign-in state + email-OTP flow.
+- `Sources/FeedbackKit/Features/Feed/FeedViewModel.swift`, `Detail/PostDetailViewModel.swift`, `Submit/SubmitViewModel.swift`, `Changelog/ChangelogViewModel.swift`, `Help/HelpViewModel.swift`.
+- `Sources/FeedbackKit/Cache/ContentCache.swift` — last-feed/changelog/help disk cache.
+- `App/FeedbackPortalApp/*.swift` — `@main` app, `RootTabView`, per-tab SwiftUI screens, `SignInSheet`. (UI; not unit-tested.)
+- `App/project.yml` additions or a new XcodeGen target; CI builds it for iOS Simulator.
+
+Each view model has one responsibility and depends only on `FeedbackAPI` + `AuthStore`, never on `URLSession` directly — so all are testable with a mock.
+
+---
+
+## Task 1: Add the `FeedbackKit` library + test targets
+
+**Files:**
+- Modify: `Package.swift`
+- Create: `Sources/FeedbackKit/FeedbackKit.swift` (placeholder so the target compiles)
+- Create: `Tests/FeedbackKitTests/SmokeTests.swift`
+
+- [ ] **Step 1: Write the failing test**
+
+```swift
+// Tests/FeedbackKitTests/SmokeTests.swift
+import XCTest
+@testable import FeedbackKit
+
+final class SmokeTests: XCTestCase {
+ func testModuleLoads() {
+ XCTAssertEqual(FeedbackKit.name, "FeedbackKit")
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `swift test --filter FeedbackKitTests`
+Expected: FAIL — no target `FeedbackKit`.
+
+- [ ] **Step 3: Add the targets + placeholder**
+
+In `Package.swift`, add to `products` and `targets`:
+
+```swift
+.library(name: "FeedbackKit", targets: ["FeedbackKit"]),
+```
+
+```swift
+.target(name: "FeedbackKit", path: "Sources/FeedbackKit"),
+.testTarget(name: "FeedbackKitTests", dependencies: ["FeedbackKit"], path: "Tests/FeedbackKitTests"),
+```
+
+```swift
+// Sources/FeedbackKit/FeedbackKit.swift
+public enum FeedbackKit {
+ public static let name = "FeedbackKit"
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `swift test --filter FeedbackKitTests`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add Package.swift Sources/FeedbackKit/FeedbackKit.swift Tests/FeedbackKitTests/SmokeTests.swift
+git commit -m "feat(app): scaffold FeedbackKit library + test target"
+```
+
+---
+
+## Task 2: Codable models mirroring the API contract
+
+**Files:**
+- Create: `Sources/FeedbackKit/Models/Models.swift`
+- Test: `Tests/FeedbackKitTests/ModelsTests.swift`
+
+- [ ] **Step 1: Write the failing test**
+
+```swift
+import XCTest
+@testable import FeedbackKit
+
+final class ModelsTests: XCTestCase {
+ func testDecodesPostSummaryAndPageEnvelope() throws {
+ let json = """
+ {"data":[{"id":"post_1","title":"A","voteCount":5,"statusId":null,"boardId":"b1","createdAt":"2026-01-01T00:00:00.000Z","hasVoted":true}],
+ "meta":{"pagination":{"cursor":null,"hasMore":false}}}
+ """.data(using: .utf8)!
+ let page = try JSONDecoder.feedback.decode(Page.self, from: json)
+ XCTAssertEqual(page.data.count, 1)
+ XCTAssertEqual(page.data[0].id, "post_1")
+ XCTAssertTrue(page.data[0].hasVoted)
+ XCTAssertFalse(page.meta?.pagination?.hasMore ?? true)
+ }
+
+ func testDecodesBareDataEnvelope() throws {
+ let json = #"{"data":{"id":"post_1","title":"A","content":"x","voteCount":3,"statusId":null,"boardId":"b1","createdAt":"2026-01-01T00:00:00.000Z","hasVoted":false}}"#.data(using: .utf8)!
+ let env = try JSONDecoder.feedback.decode(Envelope.self, from: json)
+ XCTAssertEqual(env.data.content, "x")
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `swift test --filter ModelsTests`
+Expected: FAIL — `Page`/`PostSummary` undefined.
+
+- [ ] **Step 3: Write minimal implementation**
+
+```swift
+// Sources/FeedbackKit/Models/Models.swift
+import Foundation
+
+public struct Pagination: Codable, Sendable, Equatable {
+ public let cursor: String?
+ public let hasMore: Bool
+}
+public struct Meta: Codable, Sendable, Equatable {
+ public let pagination: Pagination?
+}
+public struct Page: Codable, Sendable, Equatable {
+ public let data: [T]
+ public let meta: Meta?
+}
+public struct Envelope: Codable, Sendable, Equatable {
+ public let data: T
+}
+
+public struct Board: Codable, Sendable, Equatable, Identifiable {
+ public let id: String
+ public let name: String
+ public let slug: String
+ public let description: String?
+ public let postCount: Int?
+}
+public struct PostSummary: Codable, Sendable, Equatable, Identifiable {
+ public let id: String
+ public let title: String
+ public let voteCount: Int
+ public let statusId: String?
+ public let boardId: String
+ public let createdAt: Date
+ public let hasVoted: Bool
+}
+public struct PostDetail: Codable, Sendable, Equatable, Identifiable {
+ public let id: String
+ public let title: String
+ public let content: String
+ public let voteCount: Int
+ public let statusId: String?
+ public let boardId: String
+ public let createdAt: Date
+ public let hasVoted: Bool
+}
+public struct Comment: Codable, Sendable, Equatable, Identifiable {
+ public let id: String
+ public let content: String
+ public let authorName: String
+ public let createdAt: Date
+ public let replies: [Comment]
+}
+public struct ChangelogEntry: Codable, Sendable, Equatable, Identifiable {
+ public let id: String
+ public let title: String
+ public let content: String?
+ public let publishedAt: Date?
+}
+public struct HelpCategory: Codable, Sendable, Equatable, Identifiable {
+ public let id: String
+ public let name: String
+ public let slug: String
+ public let description: String?
+}
+public struct HelpArticle: Codable, Sendable, Equatable, Identifiable {
+ public let id: String
+ public let slug: String
+ public let title: String
+ public let content: String
+ public let categoryId: String
+}
+public struct VoteResult: Codable, Sendable, Equatable {
+ public let voted: Bool
+ public let voteCount: Int
+}
+
+public extension JSONDecoder {
+ /// ISO-8601 with fractional seconds, matching the API's `toISOString()` output.
+ static let feedback: JSONDecoder = {
+ let d = JSONDecoder()
+ let f = ISO8601DateFormatter()
+ f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ d.dateDecodingStrategy = .custom { decoder in
+ let s = try decoder.singleValueContainer().decode(String.self)
+ if let date = f.date(from: s) { return date }
+ throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Bad date: \(s)"))
+ }
+ return d
+ }()
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `swift test --filter ModelsTests`
+Expected: PASS (2 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add Sources/FeedbackKit/Models/Models.swift Tests/FeedbackKitTests/ModelsTests.swift
+git commit -m "feat(app): Codable models + JSON envelopes"
+```
+
+---
+
+## Task 3: `FeedbackAPI` protocol + `APIError`
+
+**Files:**
+- Create: `Sources/FeedbackKit/API/FeedbackAPI.swift`, `Sources/FeedbackKit/API/APIError.swift`
+- Test: `Tests/FeedbackKitTests/MockFeedbackAPI.swift` (test helper — no assertions yet)
+
+- [ ] **Step 1: Write the failing test** (a mock that must conform to the protocol)
+
+```swift
+// Tests/FeedbackKitTests/MockFeedbackAPI.swift
+@testable import FeedbackKit
+import Foundation
+
+final class MockFeedbackAPI: FeedbackAPI, @unchecked Sendable {
+ var posts: [PostSummary] = []
+ var voteResult = VoteResult(voted: true, voteCount: 1)
+ var submitted: (boardId: String, title: String, content: String)?
+ var shouldUnauthorize = false
+
+ func listPosts(boardId: String?, sort: PostSort, cursor: String?) async throws -> Page {
+ Page(data: posts, meta: Meta(pagination: Pagination(cursor: nil, hasMore: false)))
+ }
+ func getPost(id: String) async throws -> PostDetail {
+ PostDetail(id: id, title: "A", content: "x", voteCount: 1, statusId: nil, boardId: "b1", createdAt: .init(), hasVoted: false)
+ }
+ func listComments(postId: String) async throws -> [Comment] { [] }
+ func listBoards() async throws -> [Board] { [] }
+ func listChangelog(cursor: String?) async throws -> Page { Page(data: [], meta: nil) }
+ func listHelpCategories() async throws -> [HelpCategory] { [] }
+ func getHelpArticle(slug: String) async throws -> HelpArticle {
+ HelpArticle(id: "a", slug: slug, title: "T", content: "c", categoryId: "cat")
+ }
+ func submitPost(boardId: String, title: String, content: String) async throws -> PostSummary {
+ if shouldUnauthorize { throw APIError.unauthorized }
+ submitted = (boardId, title, content)
+ return PostSummary(id: "post_new", title: title, voteCount: 0, statusId: nil, boardId: boardId, createdAt: .init(), hasVoted: false)
+ }
+ func vote(postId: String) async throws -> VoteResult {
+ if shouldUnauthorize { throw APIError.unauthorized }
+ return voteResult
+ }
+ func addComment(postId: String, content: String, parentId: String?) async throws -> Comment {
+ if shouldUnauthorize { throw APIError.unauthorized }
+ return Comment(id: "c1", content: content, authorName: "Me", createdAt: .init(), replies: [])
+ }
+}
+
+func _mockConforms() -> FeedbackAPI { MockFeedbackAPI() }
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `swift test --filter FeedbackKitTests`
+Expected: FAIL — `FeedbackAPI`/`APIError`/`PostSort` undefined.
+
+- [ ] **Step 3: Write minimal implementation**
+
+```swift
+// Sources/FeedbackKit/API/APIError.swift
+import Foundation
+
+public enum APIError: Error, Equatable, Sendable {
+ case unauthorized
+ case notFound
+ case rateLimited
+ case server(status: Int, code: String?)
+ case transport(String)
+ case decoding(String)
+}
+```
+
+```swift
+// Sources/FeedbackKit/API/FeedbackAPI.swift
+import Foundation
+
+public enum PostSort: String, Sendable { case newest, votes }
+
+/// All public-API operations the app needs. View models depend on this, never URLSession.
+public protocol FeedbackAPI: Sendable {
+ // Reads (anonymous OK)
+ func listPosts(boardId: String?, sort: PostSort, cursor: String?) async throws -> Page
+ func getPost(id: String) async throws -> PostDetail
+ func listComments(postId: String) async throws -> [Comment]
+ func listBoards() async throws -> [Board]
+ func listChangelog(cursor: String?) async throws -> Page
+ func listHelpCategories() async throws -> [HelpCategory]
+ func getHelpArticle(slug: String) async throws -> HelpArticle
+ // Writes (auth required → throw .unauthorized when no/expired token)
+ func submitPost(boardId: String, title: String, content: String) async throws -> PostSummary
+ func vote(postId: String) async throws -> VoteResult
+ func addComment(postId: String, content: String, parentId: String?) async throws -> Comment
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `swift test --filter FeedbackKitTests`
+Expected: PASS (mock compiles & conforms).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add Sources/FeedbackKit/API/FeedbackAPI.swift Sources/FeedbackKit/API/APIError.swift Tests/FeedbackKitTests/MockFeedbackAPI.swift
+git commit -m "feat(app): FeedbackAPI protocol + APIError + test mock"
+```
+
+---
+
+## Task 4: `TokenStore` (bearer token persistence)
+
+**Files:**
+- Create: `Sources/FeedbackKit/Auth/TokenStore.swift`
+- Test: `Tests/FeedbackKitTests/TokenStoreTests.swift`
+
+- [ ] **Step 1: Write the failing test**
+
+```swift
+import XCTest
+@testable import FeedbackKit
+
+final class TokenStoreTests: XCTestCase {
+ func testInMemoryStoreRoundTrips() {
+ let store = InMemoryTokenStore()
+ XCTAssertNil(store.token)
+ store.token = "abc"
+ XCTAssertEqual(store.token, "abc")
+ store.token = nil
+ XCTAssertNil(store.token)
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `swift test --filter TokenStoreTests`
+Expected: FAIL — `InMemoryTokenStore` undefined.
+
+- [ ] **Step 3: Write minimal implementation**
+
+```swift
+// Sources/FeedbackKit/Auth/TokenStore.swift
+import Foundation
+
+public protocol TokenStore: AnyObject, Sendable {
+ var token: String? { get set }
+}
+
+/// Test/double store.
+public final class InMemoryTokenStore: TokenStore, @unchecked Sendable {
+ private let lock = NSLock()
+ private var value: String?
+ public init(token: String? = nil) { self.value = token }
+ public var token: String? {
+ get { lock.lock(); defer { lock.unlock() }; return value }
+ set { lock.lock(); value = newValue; lock.unlock() }
+ }
+}
+
+#if canImport(Security)
+import Security
+
+/// Keychain-backed store for the device.
+public final class KeychainTokenStore: TokenStore, @unchecked Sendable {
+ private let account: String
+ private let service = "dev.opencoven.feedback.session"
+ public init(account: String = "session-token") { self.account = account }
+
+ public var token: String? {
+ get {
+ var query: [String: Any] = baseQuery
+ query[kSecReturnData as String] = true
+ query[kSecMatchLimit as String] = kSecMatchLimitOne
+ var item: CFTypeRef?
+ guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
+ let data = item as? Data else { return nil }
+ return String(data: data, encoding: .utf8)
+ }
+ set {
+ SecItemDelete(baseQuery as CFDictionary)
+ guard let newValue, let data = newValue.data(using: .utf8) else { return }
+ var add = baseQuery
+ add[kSecValueData as String] = data
+ SecItemAdd(add as CFDictionary, nil)
+ }
+ }
+
+ private var baseQuery: [String: Any] {
+ [kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: account]
+ }
+}
+#endif
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `swift test --filter TokenStoreTests`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add Sources/FeedbackKit/Auth/TokenStore.swift Tests/FeedbackKitTests/TokenStoreTests.swift
+git commit -m "feat(app): TokenStore (in-memory + Keychain)"
+```
+
+---
+
+## Task 5: `FeedViewModel` (anonymous read, the representative slice)
+
+**Files:**
+- Create: `Sources/FeedbackKit/Features/Feed/FeedViewModel.swift`
+- Test: `Tests/FeedbackKitTests/FeedViewModelTests.swift`
+
+- [ ] **Step 1: Write the failing test**
+
+```swift
+import XCTest
+@testable import FeedbackKit
+
+@MainActor
+final class FeedViewModelTests: XCTestCase {
+ func testLoadPopulatesPostsAndClearsLoading() async {
+ let api = MockFeedbackAPI()
+ api.posts = [PostSummary(id: "post_1", title: "A", voteCount: 5, statusId: nil, boardId: "b1", createdAt: .init(), hasVoted: false)]
+ let vm = FeedViewModel(api: api)
+ await vm.load()
+ XCTAssertEqual(vm.posts.map(\.id), ["post_1"])
+ XCTAssertFalse(vm.isLoading)
+ XCTAssertNil(vm.errorMessage)
+ }
+
+ func testLoadFailureSetsErrorMessage() async {
+ final class FailingAPI: MockFeedbackAPI {
+ override func listPosts(boardId: String?, sort: PostSort, cursor: String?) async throws -> Page {
+ throw APIError.transport("offline")
+ }
+ }
+ let vm = FeedViewModel(api: FailingAPI())
+ await vm.load()
+ XCTAssertTrue(vm.posts.isEmpty)
+ XCTAssertNotNil(vm.errorMessage)
+ XCTAssertFalse(vm.isLoading)
+ }
+}
+```
+
+> NOTE: `MockFeedbackAPI` methods must be `open`/overridable — mark the class `open` or its methods accordingly, or make `FailingAPI` a sibling mock. Simplest: change `MockFeedbackAPI` to a non-final class with overridable methods (drop `final`).
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `swift test --filter FeedViewModelTests`
+Expected: FAIL — `FeedViewModel` undefined.
+
+- [ ] **Step 3: Write minimal implementation**
+
+```swift
+// Sources/FeedbackKit/Features/Feed/FeedViewModel.swift
+import Foundation
+
+@MainActor
+public final class FeedViewModel: ObservableObject {
+ @Published public private(set) var posts: [PostSummary] = []
+ @Published public private(set) var isLoading = false
+ @Published public private(set) var errorMessage: String?
+
+ private let api: FeedbackAPI
+ public var boardId: String?
+ public var sort: PostSort = .newest
+
+ public init(api: FeedbackAPI) { self.api = api }
+
+ public func load() async {
+ isLoading = true
+ errorMessage = nil
+ do {
+ let page = try await api.listPosts(boardId: boardId, sort: sort, cursor: nil)
+ posts = page.data
+ } catch {
+ errorMessage = Self.message(for: error)
+ }
+ isLoading = false
+ }
+
+ static func message(for error: Error) -> String {
+ switch error {
+ case APIError.transport: return "You appear to be offline. Pull to retry."
+ case APIError.rateLimited: return "Too many requests. Try again shortly."
+ default: return "Something went wrong. Please try again."
+ }
+ }
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `swift test --filter FeedViewModelTests`
+Expected: PASS (2 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add Sources/FeedbackKit/Features/Feed/FeedViewModel.swift Tests/FeedbackKitTests/FeedViewModelTests.swift Tests/FeedbackKitTests/MockFeedbackAPI.swift
+git commit -m "feat(app): FeedViewModel with loading/error states"
+```
+
+---
+
+## Task 6: `AuthStore` + email-OTP flow
+
+**Files:**
+- Create: `Sources/FeedbackKit/Auth/AuthStore.swift`
+- Test: `Tests/FeedbackKitTests/AuthStoreTests.swift`
+
+The email-OTP flow calls better-auth's existing endpoints: `POST /api/auth/email-otp/send-verification-otp` then `POST /api/auth/sign-in/email-otp`, which returns a session token. Abstract this behind an `AuthService` protocol so the store is testable.
+
+- [ ] **Step 1: Write the failing test**
+
+```swift
+import XCTest
+@testable import FeedbackKit
+
+final class StubAuthService: AuthService, @unchecked Sendable {
+ var sentTo: String?
+ var tokenToReturn = "session_tok"
+ func sendOTP(email: String) async throws { sentTo = email }
+ func verifyOTP(email: String, code: String) async throws -> String { tokenToReturn }
+}
+
+@MainActor
+final class AuthStoreTests: XCTestCase {
+ func testSignInStoresTokenAndFlipsState() async {
+ let store = InMemoryTokenStore()
+ let auth = AuthStore(service: StubAuthService(), tokenStore: store)
+ XCTAssertFalse(auth.isSignedIn)
+ try? await auth.requestCode(email: "v@x.com")
+ await auth.verify(email: "v@x.com", code: "123456")
+ XCTAssertTrue(auth.isSignedIn)
+ XCTAssertEqual(store.token, "session_tok")
+ }
+
+ func testSignOutClearsToken() async {
+ let store = InMemoryTokenStore(token: "old")
+ let auth = AuthStore(service: StubAuthService(), tokenStore: store)
+ XCTAssertTrue(auth.isSignedIn)
+ auth.signOut()
+ XCTAssertFalse(auth.isSignedIn)
+ XCTAssertNil(store.token)
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `swift test --filter AuthStoreTests`
+Expected: FAIL — `AuthService`/`AuthStore` undefined.
+
+- [ ] **Step 3: Write minimal implementation**
+
+```swift
+// Sources/FeedbackKit/Auth/AuthStore.swift
+import Foundation
+
+public protocol AuthService: Sendable {
+ func sendOTP(email: String) async throws
+ func verifyOTP(email: String, code: String) async throws -> String // returns session token
+}
+
+@MainActor
+public final class AuthStore: ObservableObject {
+ @Published public private(set) var isSignedIn: Bool
+ @Published public private(set) var errorMessage: String?
+
+ private let service: AuthService
+ private let tokenStore: TokenStore
+
+ public init(service: AuthService, tokenStore: TokenStore) {
+ self.service = service
+ self.tokenStore = tokenStore
+ self.isSignedIn = tokenStore.token != nil
+ }
+
+ public var token: String? { tokenStore.token }
+
+ public func requestCode(email: String) async throws {
+ try await service.sendOTP(email: email)
+ }
+
+ public func verify(email: String, code: String) async {
+ errorMessage = nil
+ do {
+ let token = try await service.verifyOTP(email: email, code: code)
+ tokenStore.token = token
+ isSignedIn = true
+ } catch {
+ errorMessage = "That code didn't work. Try again."
+ isSignedIn = false
+ }
+ }
+
+ public func signOut() {
+ tokenStore.token = nil
+ isSignedIn = false
+ }
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `swift test --filter AuthStoreTests`
+Expected: PASS (2 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add Sources/FeedbackKit/Auth/AuthStore.swift Tests/FeedbackKitTests/AuthStoreTests.swift
+git commit -m "feat(app): AuthStore + email-OTP AuthService protocol"
+```
+
+---
+
+## Task 7: `PostDetailViewModel` (detail + comments + vote with auth gating)
+
+**Files:**
+- Create: `Sources/FeedbackKit/Features/Detail/PostDetailViewModel.swift`
+- Test: `Tests/FeedbackKitTests/PostDetailViewModelTests.swift`
+
+- [ ] **Step 1: Write the failing test**
+
+```swift
+import XCTest
+@testable import FeedbackKit
+
+@MainActor
+final class PostDetailViewModelTests: XCTestCase {
+ func testLoadFetchesPostAndComments() async {
+ let api = MockFeedbackAPI()
+ let vm = PostDetailViewModel(postId: "post_1", api: api, isSignedIn: { true })
+ await vm.load()
+ XCTAssertEqual(vm.post?.id, "post_1")
+ XCTAssertNotNil(vm.comments)
+ }
+
+ func testVoteUpdatesCountWhenSignedIn() async {
+ let api = MockFeedbackAPI()
+ api.voteResult = VoteResult(voted: true, voteCount: 9)
+ let vm = PostDetailViewModel(postId: "post_1", api: api, isSignedIn: { true })
+ await vm.load()
+ await vm.toggleVote()
+ XCTAssertEqual(vm.post?.voteCount, 9)
+ XCTAssertEqual(vm.post?.hasVoted, true)
+ XCTAssertFalse(vm.needsSignIn)
+ }
+
+ func testVoteWhenSignedOutRequestsSignIn() async {
+ let vm = PostDetailViewModel(postId: "post_1", api: MockFeedbackAPI(), isSignedIn: { false })
+ await vm.load()
+ await vm.toggleVote()
+ XCTAssertTrue(vm.needsSignIn)
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `swift test --filter PostDetailViewModelTests`
+Expected: FAIL — `PostDetailViewModel` undefined.
+
+- [ ] **Step 3: Write minimal implementation**
+
+```swift
+// Sources/FeedbackKit/Features/Detail/PostDetailViewModel.swift
+import Foundation
+
+@MainActor
+public final class PostDetailViewModel: ObservableObject {
+ @Published public private(set) var post: PostDetail?
+ @Published public private(set) var comments: [Comment] = []
+ @Published public private(set) var isLoading = false
+ @Published public private(set) var errorMessage: String?
+ @Published public var needsSignIn = false
+
+ private let postId: String
+ private let api: FeedbackAPI
+ private let isSignedIn: () -> Bool
+
+ public init(postId: String, api: FeedbackAPI, isSignedIn: @escaping () -> Bool) {
+ self.postId = postId
+ self.api = api
+ self.isSignedIn = isSignedIn
+ }
+
+ public func load() async {
+ isLoading = true; errorMessage = nil
+ do {
+ async let p = api.getPost(id: postId)
+ async let c = api.listComments(postId: postId)
+ post = try await p
+ comments = try await c
+ } catch {
+ errorMessage = FeedViewModel.message(for: error)
+ }
+ isLoading = false
+ }
+
+ public func toggleVote() async {
+ guard isSignedIn() else { needsSignIn = true; return }
+ do {
+ let result = try await api.vote(postId: postId)
+ if var current = post {
+ post = PostDetail(id: current.id, title: current.title, content: current.content,
+ voteCount: result.voteCount, statusId: current.statusId,
+ boardId: current.boardId, createdAt: current.createdAt, hasVoted: result.voted)
+ _ = current
+ }
+ } catch APIError.unauthorized {
+ needsSignIn = true
+ } catch {
+ errorMessage = FeedViewModel.message(for: error)
+ }
+ }
+
+ public func addComment(_ text: String) async {
+ guard isSignedIn() else { needsSignIn = true; return }
+ do {
+ let comment = try await api.addComment(postId: postId, content: text, parentId: nil)
+ comments.insert(comment, at: 0)
+ } catch APIError.unauthorized {
+ needsSignIn = true
+ } catch {
+ errorMessage = FeedViewModel.message(for: error)
+ }
+ }
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `swift test --filter PostDetailViewModelTests`
+Expected: PASS (3 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add Sources/FeedbackKit/Features/Detail/PostDetailViewModel.swift Tests/FeedbackKitTests/PostDetailViewModelTests.swift
+git commit -m "feat(app): PostDetailViewModel (vote/comment with sign-in gate)"
+```
+
+---
+
+## Task 8: `SubmitViewModel` (auth-gated write)
+
+**Files:**
+- Create: `Sources/FeedbackKit/Features/Submit/SubmitViewModel.swift`
+- Test: `Tests/FeedbackKitTests/SubmitViewModelTests.swift`
+
+- [ ] **Step 1: Write the failing test**
+
+```swift
+import XCTest
+@testable import FeedbackKit
+
+@MainActor
+final class SubmitViewModelTests: XCTestCase {
+ func testSubmitSucceedsWhenSignedIn() async {
+ let api = MockFeedbackAPI()
+ let vm = SubmitViewModel(api: api, isSignedIn: { true })
+ vm.boardId = "b1"; vm.title = "Bug"; vm.content = "Crashes on launch"
+ let ok = await vm.submit()
+ XCTAssertTrue(ok)
+ XCTAssertEqual(api.submitted?.title, "Bug")
+ }
+ func testSubmitBlockedWhenSignedOut() async {
+ let vm = SubmitViewModel(api: MockFeedbackAPI(), isSignedIn: { false })
+ vm.boardId = "b1"; vm.title = "Bug"
+ let ok = await vm.submit()
+ XCTAssertFalse(ok)
+ XCTAssertTrue(vm.needsSignIn)
+ }
+ func testSubmitValidatesEmptyTitle() async {
+ let vm = SubmitViewModel(api: MockFeedbackAPI(), isSignedIn: { true })
+ vm.boardId = "b1"; vm.title = " "
+ let ok = await vm.submit()
+ XCTAssertFalse(ok)
+ XCTAssertEqual(vm.errorMessage, "Title is required.")
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `swift test --filter SubmitViewModelTests`
+Expected: FAIL — `SubmitViewModel` undefined.
+
+- [ ] **Step 3: Write minimal implementation**
+
+```swift
+// Sources/FeedbackKit/Features/Submit/SubmitViewModel.swift
+import Foundation
+
+@MainActor
+public final class SubmitViewModel: ObservableObject {
+ @Published public var boardId: String = ""
+ @Published public var title: String = ""
+ @Published public var content: String = ""
+ @Published public private(set) var isSubmitting = false
+ @Published public private(set) var errorMessage: String?
+ @Published public var needsSignIn = false
+
+ private let api: FeedbackAPI
+ private let isSignedIn: () -> Bool
+
+ public init(api: FeedbackAPI, isSignedIn: @escaping () -> Bool) {
+ self.api = api; self.isSignedIn = isSignedIn
+ }
+
+ @discardableResult
+ public func submit() async -> Bool {
+ errorMessage = nil
+ guard isSignedIn() else { needsSignIn = true; return false }
+ guard !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
+ errorMessage = "Title is required."; return false
+ }
+ guard !boardId.isEmpty else { errorMessage = "Pick a board."; return false }
+ isSubmitting = true
+ defer { isSubmitting = false }
+ do {
+ _ = try await api.submitPost(boardId: boardId, title: title, content: content)
+ return true
+ } catch APIError.unauthorized {
+ needsSignIn = true; return false
+ } catch {
+ errorMessage = FeedViewModel.message(for: error); return false
+ }
+ }
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `swift test --filter SubmitViewModelTests`
+Expected: PASS (3 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add Sources/FeedbackKit/Features/Submit/SubmitViewModel.swift Tests/FeedbackKitTests/SubmitViewModelTests.swift
+git commit -m "feat(app): SubmitViewModel (validation + sign-in gate)"
+```
+
+---
+
+## Task 9: `ChangelogViewModel` and `HelpViewModel` (read-only)
+
+**Files:**
+- Create: `Sources/FeedbackKit/Features/Changelog/ChangelogViewModel.swift`, `Sources/FeedbackKit/Features/Help/HelpViewModel.swift`
+- Test: `Tests/FeedbackKitTests/ReadOnlyViewModelsTests.swift`
+
+- [ ] **Step 1: Write the failing test**
+
+```swift
+import XCTest
+@testable import FeedbackKit
+
+@MainActor
+final class ReadOnlyViewModelsTests: XCTestCase {
+ func testChangelogLoads() async {
+ final class API: MockFeedbackAPI {
+ override func listChangelog(cursor: String?) async throws -> Page {
+ Page(data: [ChangelogEntry(id: "cl_1", title: "v1", content: nil, publishedAt: .init())], meta: nil)
+ }
+ }
+ let vm = ChangelogViewModel(api: API())
+ await vm.load()
+ XCTAssertEqual(vm.entries.map(\.id), ["cl_1"])
+ }
+
+ func testHelpLoadsCategories() async {
+ final class API: MockFeedbackAPI {
+ override func listHelpCategories() async throws -> [HelpCategory] {
+ [HelpCategory(id: "cat_1", name: "Start", slug: "start", description: nil)]
+ }
+ }
+ let vm = HelpViewModel(api: API())
+ await vm.load()
+ XCTAssertEqual(vm.categories.map(\.slug), ["start"])
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `swift test --filter ReadOnlyViewModelsTests`
+Expected: FAIL — view models undefined.
+
+- [ ] **Step 3: Write minimal implementation**
+
+```swift
+// Sources/FeedbackKit/Features/Changelog/ChangelogViewModel.swift
+import Foundation
+
+@MainActor
+public final class ChangelogViewModel: ObservableObject {
+ @Published public private(set) var entries: [ChangelogEntry] = []
+ @Published public private(set) var isLoading = false
+ @Published public private(set) var errorMessage: String?
+ private let api: FeedbackAPI
+ public init(api: FeedbackAPI) { self.api = api }
+ public func load() async {
+ isLoading = true; errorMessage = nil
+ do { entries = try await api.listChangelog(cursor: nil).data }
+ catch { errorMessage = FeedViewModel.message(for: error) }
+ isLoading = false
+ }
+}
+```
+
+```swift
+// Sources/FeedbackKit/Features/Help/HelpViewModel.swift
+import Foundation
+
+@MainActor
+public final class HelpViewModel: ObservableObject {
+ @Published public private(set) var categories: [HelpCategory] = []
+ @Published public private(set) var isLoading = false
+ @Published public private(set) var errorMessage: String?
+ private let api: FeedbackAPI
+ public init(api: FeedbackAPI) { self.api = api }
+ public func load() async {
+ isLoading = true; errorMessage = nil
+ do { categories = try await api.listHelpCategories() }
+ catch { errorMessage = FeedViewModel.message(for: error) }
+ isLoading = false
+ }
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `swift test --filter ReadOnlyViewModelsTests`
+Expected: PASS (2 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add Sources/FeedbackKit/Features/Changelog Sources/FeedbackKit/Features/Help Tests/FeedbackKitTests/ReadOnlyViewModelsTests.swift
+git commit -m "feat(app): Changelog + Help view models"
+```
+
+---
+
+## Task 10: `HTTPFeedbackAPI` (URLSession implementation)
+
+**Files:**
+- Create: `Sources/FeedbackKit/API/HTTPFeedbackAPI.swift`, `Sources/FeedbackKit/Config/AppConfig.swift`
+- Test: `Tests/FeedbackKitTests/HTTPFeedbackAPITests.swift` (uses `URLProtocol` stub)
+
+- [ ] **Step 1: Write the failing test**
+
+```swift
+import XCTest
+@testable import FeedbackKit
+
+final class StubURLProtocol: URLProtocol {
+ nonisolated(unsafe) static var handler: ((URLRequest) -> (HTTPURLResponse, Data))?
+ override class func canInit(with request: URLRequest) -> Bool { true }
+ override class func canonicalRequest(for r: URLRequest) -> URLRequest { r }
+ override func startLoading() {
+ let (resp, data) = StubURLProtocol.handler!(request)
+ client?.urlProtocol(self, didReceive: resp, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocol(self, didLoad: data)
+ client?.urlProtocolDidFinishLoading(self)
+ }
+ override func stopLoading() {}
+}
+
+final class HTTPFeedbackAPITests: XCTestCase {
+ private func makeAPI(token: String? = nil) -> HTTPFeedbackAPI {
+ let cfg = URLSessionConfiguration.ephemeral
+ cfg.protocolClasses = [StubURLProtocol.self]
+ let session = URLSession(configuration: cfg)
+ return HTTPFeedbackAPI(baseURL: URL(string: "https://fb.example.com")!,
+ session: session, tokenProvider: { token })
+ }
+
+ func testListPostsParsesEnvelope() async throws {
+ StubURLProtocol.handler = { req in
+ XCTAssertEqual(req.url?.path, "/api/public/v1/posts")
+ let body = #"{"data":[{"id":"post_1","title":"A","voteCount":2,"statusId":null,"boardId":"b1","createdAt":"2026-01-01T00:00:00.000Z","hasVoted":false}],"meta":{"pagination":{"cursor":null,"hasMore":false}}}"#
+ return (HTTPURLResponse(url: req.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, body.data(using: .utf8)!)
+ }
+ let page = try await makeAPI().listPosts(boardId: nil, sort: .newest, cursor: nil)
+ XCTAssertEqual(page.data.first?.id, "post_1")
+ }
+
+ func testVoteSendsBearerAndMapsUnauthorized() async {
+ StubURLProtocol.handler = { req in
+ XCTAssertEqual(req.value(forHTTPHeaderField: "Authorization"), "Bearer tok")
+ return (HTTPURLResponse(url: req.url!, statusCode: 401, httpVersion: nil, headerFields: nil)!,
+ #"{"error":{"code":"UNAUTHORIZED","message":"x"}}"#.data(using: .utf8)!)
+ }
+ do { _ = try await makeAPI(token: "tok").vote(postId: "post_1"); XCTFail("expected throw") }
+ catch { XCTAssertEqual(error as? APIError, .unauthorized) }
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `swift test --filter HTTPFeedbackAPITests`
+Expected: FAIL — `HTTPFeedbackAPI` undefined.
+
+- [ ] **Step 3: Write minimal implementation**
+
+```swift
+// Sources/FeedbackKit/Config/AppConfig.swift
+import Foundation
+public struct AppConfig: Sendable {
+ public let instanceURL: URL
+ public init(instanceURL: URL) { self.instanceURL = instanceURL }
+}
+```
+
+```swift
+// Sources/FeedbackKit/API/HTTPFeedbackAPI.swift
+import Foundation
+
+public final class HTTPFeedbackAPI: FeedbackAPI, @unchecked Sendable {
+ private let baseURL: URL
+ private let session: URLSession
+ private let tokenProvider: @Sendable () -> String?
+
+ public init(baseURL: URL, session: URLSession = .shared, tokenProvider: @escaping @Sendable () -> String?) {
+ self.baseURL = baseURL; self.session = session; self.tokenProvider = tokenProvider
+ }
+
+ // MARK: Reads
+ public func listPosts(boardId: String?, sort: PostSort, cursor: String?) async throws -> Page {
+ var q = [URLQueryItem(name: "sort", value: sort.rawValue)]
+ if let boardId { q.append(.init(name: "boardId", value: boardId)) }
+ if let cursor { q.append(.init(name: "cursor", value: cursor)) }
+ return try await get("/api/public/v1/posts", query: q)
+ }
+ public func getPost(id: String) async throws -> PostDetail {
+ try await getEnvelope("/api/public/v1/posts/\(id)")
+ }
+ public func listComments(postId: String) async throws -> [Comment] {
+ try await getEnvelope("/api/public/v1/posts/\(postId)/comments")
+ }
+ public func listBoards() async throws -> [Board] { try await getEnvelope("/api/public/v1/boards") }
+ public func listChangelog(cursor: String?) async throws -> Page {
+ try await get("/api/public/v1/changelog", query: cursor.map { [URLQueryItem(name: "cursor", value: $0)] } ?? [])
+ }
+ public func listHelpCategories() async throws -> [HelpCategory] { try await getEnvelope("/api/public/v1/help/categories") }
+ public func getHelpArticle(slug: String) async throws -> HelpArticle { try await getEnvelope("/api/public/v1/help/articles/\(slug)") }
+
+ // MARK: Writes
+ public func submitPost(boardId: String, title: String, content: String) async throws -> PostSummary {
+ try await postEnvelope("/api/public/v1/posts", body: ["boardId": boardId, "title": title, "content": content])
+ }
+ public func vote(postId: String) async throws -> VoteResult {
+ try await postEnvelope("/api/public/v1/posts/\(postId)/vote", body: [:])
+ }
+ public func addComment(postId: String, content: String, parentId: String?) async throws -> Comment {
+ var body: [String: String] = ["content": content]
+ if let parentId { body["parentId"] = parentId }
+ return try await postEnvelope("/api/public/v1/posts/\(postId)/comments", body: body)
+ }
+
+ // MARK: Transport
+ private func get(_ path: String, query: [URLQueryItem]) async throws -> Page {
+ try await send(request(path, method: "GET", query: query))
+ }
+ private func getEnvelope(_ path: String) async throws -> T {
+ let env: Envelope = try await send(request(path, method: "GET", query: []))
+ return env.data
+ }
+ private func postEnvelope(_ path: String, body: [String: String]) async throws -> T {
+ var req = request(path, method: "POST", query: [])
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ req.httpBody = try JSONSerialization.data(withJSONObject: body)
+ let env: Envelope = try await send(req)
+ return env.data
+ }
+
+ private func request(_ path: String, method: String, query: [URLQueryItem]) -> URLRequest {
+ var comps = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false)!
+ if !query.isEmpty { comps.queryItems = query }
+ var req = URLRequest(url: comps.url!)
+ req.httpMethod = method
+ if let token = tokenProvider() { req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
+ return req
+ }
+
+ private func send(_ req: URLRequest) async throws -> R {
+ let (data, response): (Data, URLResponse)
+ do { (data, response) = try await session.data(for: req) }
+ catch { throw APIError.transport(error.localizedDescription) }
+ guard let http = response as? HTTPURLResponse else { throw APIError.transport("No HTTP response") }
+ switch http.statusCode {
+ case 200..<300: break
+ case 401: throw APIError.unauthorized
+ case 404: throw APIError.notFound
+ case 429: throw APIError.rateLimited
+ default: throw APIError.server(status: http.statusCode, code: Self.errorCode(data))
+ }
+ do { return try JSONDecoder.feedback.decode(R.self, from: data) }
+ catch { throw APIError.decoding(String(describing: error)) }
+ }
+
+ private static func errorCode(_ data: Data) -> String? {
+ struct E: Decodable { struct Inner: Decodable { let code: String }; let error: Inner }
+ return (try? JSONDecoder().decode(E.self, from: data))?.error.code
+ }
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `swift test --filter HTTPFeedbackAPITests`
+Expected: PASS (2 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add Sources/FeedbackKit/API/HTTPFeedbackAPI.swift Sources/FeedbackKit/Config/AppConfig.swift Tests/FeedbackKitTests/HTTPFeedbackAPITests.swift
+git commit -m "feat(app): HTTPFeedbackAPI URLSession client (bearer + error mapping)"
+```
+
+---
+
+## Task 11: `HTTPAuthService` (better-auth email-OTP over HTTP)
+
+**Files:**
+- Create: `Sources/FeedbackKit/Auth/HTTPAuthService.swift`
+- Test: `Tests/FeedbackKitTests/HTTPAuthServiceTests.swift` (reuses `StubURLProtocol`)
+
+- [ ] **Step 1: Write the failing test**
+
+```swift
+import XCTest
+@testable import FeedbackKit
+
+final class HTTPAuthServiceTests: XCTestCase {
+ private func make() -> HTTPAuthService {
+ let cfg = URLSessionConfiguration.ephemeral
+ cfg.protocolClasses = [StubURLProtocol.self]
+ return HTTPAuthService(baseURL: URL(string: "https://fb.example.com")!, session: URLSession(configuration: cfg))
+ }
+ func testVerifyReturnsToken() async throws {
+ StubURLProtocol.handler = { req in
+ XCTAssertEqual(req.url?.path, "/api/auth/sign-in/email-otp")
+ return (HTTPURLResponse(url: req.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!,
+ #"{"token":"sess_123","user":{"id":"u1"}}"#.data(using: .utf8)!)
+ }
+ let token = try await make().verifyOTP(email: "v@x.com", code: "123456")
+ XCTAssertEqual(token, "sess_123")
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `swift test --filter HTTPAuthServiceTests`
+Expected: FAIL — `HTTPAuthService` undefined.
+
+- [ ] **Step 3: Write minimal implementation**
+
+```swift
+// Sources/FeedbackKit/Auth/HTTPAuthService.swift
+import Foundation
+
+public final class HTTPAuthService: AuthService, @unchecked Sendable {
+ private let baseURL: URL
+ private let session: URLSession
+ public init(baseURL: URL, session: URLSession = .shared) { self.baseURL = baseURL; self.session = session }
+
+ public func sendOTP(email: String) async throws {
+ _ = try await post("/api/auth/email-otp/send-verification-otp", body: ["email": email, "type": "sign-in"])
+ }
+
+ public func verifyOTP(email: String, code: String) async throws -> String {
+ let data = try await post("/api/auth/sign-in/email-otp", body: ["email": email, "otp": code])
+ struct R: Decodable { let token: String }
+ guard let token = try? JSONDecoder().decode(R.self, from: data).token else {
+ throw APIError.decoding("No token in sign-in response")
+ }
+ return token
+ }
+
+ private func post(_ path: String, body: [String: String]) async throws -> Data {
+ var req = URLRequest(url: baseURL.appendingPathComponent(path))
+ req.httpMethod = "POST"
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ req.httpBody = try JSONSerialization.data(withJSONObject: body)
+ let (data, response) = try await session.data(for: req)
+ guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
+ throw APIError.server(status: (response as? HTTPURLResponse)?.statusCode ?? -1, code: nil)
+ }
+ return data
+ }
+}
+```
+
+> NOTE: Confirm the exact better-auth email-OTP endpoint paths and the field names (`email`/`otp`/`type`) and the token property in the response against the running instance (`/api/auth/*` — better-auth `emailOTP` plugin). Adjust paths/keys to match; the protocol (`AuthService`) and `AuthStore` (Task 6) do not change.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `swift test --filter HTTPAuthServiceTests`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add Sources/FeedbackKit/Auth/HTTPAuthService.swift Tests/FeedbackKitTests/HTTPAuthServiceTests.swift
+git commit -m "feat(app): HTTPAuthService (better-auth email-OTP)"
+```
+
+---
+
+## Task 12: SwiftUI app target — 4-tab shell + screens
+
+**Files:**
+- Create: `App/FeedbackPortalApp/FeedbackPortalApp.swift`, `RootTabView.swift`, `FeedTabView.swift`, `PostDetailView.swift`, `SubmitView.swift`, `ChangelogTabView.swift`, `HelpTabView.swift`, `AccountTabView.swift`, `SignInSheet.swift`, `Environment.swift`
+- Modify: `App/project.yml` (new XcodeGen target `FeedbackPortalApp` depending on `FeedbackKit`) — or add to the existing `project.yml`.
+
+This task is UI wiring; it is verified by the CI build (Task 13), not unit tests. Build incrementally and run the app in the simulator.
+
+- [ ] **Step 1: Composition root**
+
+```swift
+// App/FeedbackPortalApp/Environment.swift
+import FeedbackKit
+import SwiftUI
+
+@MainActor
+final class AppEnvironment: ObservableObject {
+ let api: FeedbackAPI
+ let auth: AuthStore
+ init() {
+ let url = URL(string: Bundle.main.object(forInfoDictionaryKey: "FEEDBACK_INSTANCE_URL") as? String ?? "http://localhost:3000")!
+ let tokenStore: TokenStore = KeychainTokenStore()
+ self.auth = AuthStore(service: HTTPAuthService(baseURL: url), tokenStore: tokenStore)
+ self.api = HTTPFeedbackAPI(baseURL: url, tokenProvider: { tokenStore.token })
+ }
+}
+```
+
+```swift
+// App/FeedbackPortalApp/FeedbackPortalApp.swift
+import SwiftUI
+
+@main
+struct FeedbackPortalApp: App {
+ @StateObject private var env = AppEnvironment()
+ var body: some Scene {
+ WindowGroup { RootTabView().environmentObject(env).environmentObject(env.auth) }
+ }
+}
+```
+
+- [ ] **Step 2: 4-tab shell**
+
+```swift
+// App/FeedbackPortalApp/RootTabView.swift
+import SwiftUI
+
+struct RootTabView: View {
+ var body: some View {
+ TabView {
+ FeedTabView().tabItem { Label("Feedback", systemImage: "bubble.left.and.bubble.right") }
+ ChangelogTabView().tabItem { Label("Changelog", systemImage: "sparkles") }
+ HelpTabView().tabItem { Label("Help", systemImage: "questionmark.circle") }
+ AccountTabView().tabItem { Label("Account", systemImage: "person.crop.circle") }
+ }
+ }
+}
+```
+
+- [ ] **Step 3: Feed tab (list → detail → submit)**
+
+```swift
+// App/FeedbackPortalApp/FeedTabView.swift
+import FeedbackKit
+import SwiftUI
+
+struct FeedTabView: View {
+ @EnvironmentObject private var env: AppEnvironment
+ @EnvironmentObject private var auth: AuthStore
+ @StateObject private var vm: FeedViewModel
+ @State private var showSubmit = false
+
+ init() { _vm = StateObject(wrappedValue: FeedViewModel(api: AppEnvironment().api)) }
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ if vm.isLoading && vm.posts.isEmpty { ProgressView() }
+ else if let err = vm.errorMessage, vm.posts.isEmpty {
+ ContentUnavailableView("Couldn't load", systemImage: "wifi.slash", description: Text(err))
+ } else {
+ List(vm.posts) { post in
+ NavigationLink(value: post.id) {
+ HStack { Text("\(post.voteCount)").monospacedDigit().frame(width: 36); Text(post.title) }
+ }
+ }
+ .refreshable { await vm.load() }
+ }
+ }
+ .navigationTitle("Feedback")
+ .toolbar { ToolbarItem(placement: .primaryAction) { Button { showSubmit = true } label: { Image(systemName: "plus") } } }
+ .navigationDestination(for: String.self) { PostDetailView(postId: $0) }
+ .sheet(isPresented: $showSubmit) { SubmitView() }
+ .task { await vm.load() }
+ }
+ }
+}
+```
+
+> NOTE: The `init()` above constructs a throwaway `AppEnvironment` just to obtain `api` because `@StateObject` can't read `@EnvironmentObject` at init. Cleaner: make `FeedViewModel` lazily settable and inject `env.api` in `.task`, or pass `api` down from `RootTabView`. Pick one pattern and use it consistently for all tabs. The remaining screens (`PostDetailView`, `SubmitView`, `ChangelogTabView`, `HelpTabView`, `AccountTabView`, `SignInSheet`) follow the same shape: bind a `@StateObject` view model, render loading/error/empty/content, and present `SignInSheet` when the view model's `needsSignIn` flips true. `SignInSheet` collects an email, calls `auth.requestCode`, then a 6-digit code calling `auth.verify`.
+
+- [ ] **Step 4: Build & run in the simulator**
+
+Run: open the generated project and run `FeedbackPortalApp` on an iOS Simulator. Verify: feed loads (anonymous), tapping a post shows detail, voting/commenting/submitting prompts sign-in when signed out, changelog and help load.
+Expected: golden-path flows work against a live/staging instance.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add App/FeedbackPortalApp App/project.yml
+git commit -m "feat(app): SwiftUI 4-tab shell + screens"
+```
+
+---
+
+## Task 13: Offline content cache
+
+**Files:**
+- Create: `Sources/FeedbackKit/Cache/ContentCache.swift`
+- Test: `Tests/FeedbackKitTests/ContentCacheTests.swift`
+- Modify: `FeedViewModel`, `ChangelogViewModel`, `HelpViewModel` to read cache on failure / seed before load.
+
+- [ ] **Step 1: Write the failing test**
+
+```swift
+import XCTest
+@testable import FeedbackKit
+
+final class ContentCacheTests: XCTestCase {
+ func testRoundTripsPosts() throws {
+ let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ let cache = ContentCache(directory: dir)
+ let posts = [PostSummary(id: "post_1", title: "A", voteCount: 1, statusId: nil, boardId: "b1", createdAt: .init(), hasVoted: false)]
+ try cache.save(posts, as: "feed")
+ let loaded: [PostSummary] = try cache.load("feed", as: [PostSummary].self)
+ XCTAssertEqual(loaded, posts)
+ }
+
+ func testLoadMissingThrows() {
+ let cache = ContentCache(directory: FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString))
+ XCTAssertThrowsError(try cache.load("nope", as: [PostSummary].self))
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `swift test --filter ContentCacheTests`
+Expected: FAIL — `ContentCache` undefined.
+
+- [ ] **Step 3: Write minimal implementation**
+
+```swift
+// Sources/FeedbackKit/Cache/ContentCache.swift
+import Foundation
+
+public struct ContentCache: Sendable {
+ private let directory: URL
+ public init(directory: URL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0].appendingPathComponent("FeedbackKit")) {
+ self.directory = directory
+ try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ }
+ public func save(_ value: T, as key: String) throws {
+ let data = try JSONEncoder().encode(value)
+ try data.write(to: directory.appendingPathComponent("\(key).json"), options: .atomic)
+ }
+ public func load(_ key: String, as type: T.Type) throws -> T {
+ let data = try Data(contentsOf: directory.appendingPathComponent("\(key).json"))
+ return try JSONDecoder.feedback.decode(T.self, from: data)
+ }
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `swift test --filter ContentCacheTests`
+Expected: PASS (2 tests).
+
+- [ ] **Step 5: Wire into FeedViewModel and commit**
+
+In `FeedViewModel.load()`: after a successful fetch, `try? cache.save(posts, as: "feed")`; in the `catch`, if `posts.isEmpty`, attempt `posts = (try? cache.load("feed", as: [PostSummary].self)) ?? []` and only show the error if the cache is also empty. Add a `cache` init parameter defaulting to `ContentCache()`. Repeat for Changelog/Help.
+
+```bash
+git add Sources/FeedbackKit/Cache/ContentCache.swift Tests/FeedbackKitTests/ContentCacheTests.swift Sources/FeedbackKit/Features
+git commit -m "feat(app): offline content cache (read-only fallback)"
+```
+
+---
+
+## Task 14: CI — build the app for iOS + run FeedbackKit tests
+
+**Files:**
+- Modify: `.github/workflows/ci.yml`
+
+- [ ] **Step 1: Add FeedbackKit to the iOS build job**
+
+The existing `ios-build` job runs `xcodebuild -scheme OpenCovenFeedback -destination 'generic/platform=iOS Simulator'`. Add a second build of the app:
+
+```yaml
+ - name: Build FeedbackPortalApp (iOS Simulator)
+ run: |
+ brew install xcodegen
+ (cd App && xcodegen generate)
+ xcodebuild build \
+ -project App/FeedbackPortalApp.xcodeproj \
+ -scheme FeedbackPortalApp \
+ -sdk iphonesimulator \
+ -destination 'generic/platform=iOS Simulator' \
+ CODE_SIGNING_ALLOWED=NO
+```
+
+> NOTE: Pin a `schemes:` entry for `FeedbackPortalApp` in `App/project.yml` (XcodeGen does not emit shared schemes by default — this was the cause of an earlier CI failure on the SDK side). If XcodeGen's project-format version outruns the runner's Xcode (objectVersion mismatch — also hit earlier), prefer building the SPM `FeedbackKit` library for iOS via `xcodebuild -scheme FeedbackKit -destination 'generic/platform=iOS Simulator'` and keep the app build behind a newer-Xcode runner (`macos-15`).
+
+- [ ] **Step 2: Confirm `swift test` covers FeedbackKit**
+
+The `test` job already runs `swift test`; with `FeedbackKit` added to `Package.swift` (Task 1) it now includes all FeedbackKit tests. No change needed beyond verifying locally:
+
+Run: `swift test`
+Expected: all `OpenCovenFeedback*` and `FeedbackKit*` tests PASS.
+
+- [ ] **Step 3: Commit + push + PR**
+
+```bash
+git add .github/workflows/ci.yml App/project.yml
+git commit -m "ci: build FeedbackPortalApp + run FeedbackKit tests"
+git push -u origin feat/native-give-feedback-app
+gh pr create --repo OpenCoven/feedback-mobile --base main \
+ --title "feat: native give-feedback iOS app (FeedbackKit + FeedbackPortalApp)" \
+ --body "Native SwiftUI app over the Track 1 public API: feed, post detail, vote, comment, submit, changelog, help. Email-OTP bearer auth, offline read cache. Requires Track 1 (/api/public/v1)."
+```
+
+---
+
+## Self-Review
+
+- **Spec coverage** (design §5): API client ✅ T3/T10 · email-OTP bearer auth ✅ T6/T11 + Keychain T4 · Feedback (feed/detail/vote/comment/submit) ✅ T5/T7/T8 · Changelog ✅ T9 · Help ✅ T9 · Account (sign-in/out) ✅ T6 + SignInSheet T12 · offline cache ✅ T13 · 4-tab nav ✅ T12 · CI ✅ T14. `hasVoted` enrichment flows from API (T2 model) through detail VM (T7).
+- **Placeholder scan:** No "TBD"/"handle later". The `> NOTE:` blocks are concrete verify/choose instructions (exact better-auth paths, the SwiftUI injection pattern, the XcodeGen scheme/objectVersion gotcha learned earlier) — not vague filler. Task 12 is explicitly UI-wiring verified by CI/simulator rather than unit tests, and names every screen file.
+- **Type consistency:** `FeedbackAPI` (T3) is the exact surface implemented by `HTTPFeedbackAPI` (T10) and `MockFeedbackAPI` (T3) and consumed by every view model. `FeedViewModel.message(for:)` (T5) is reused by the detail/submit/changelog/help VMs. `AuthService` (T6) is implemented by `HTTPAuthService` (T11). `TokenStore` (T4) feeds both `AuthStore` (T6) and `HTTPFeedbackAPI.tokenProvider` (T10/T12).
+- **Dependency on Track 1:** view models test against the mock, so this plan is executable before the live API exists; the HTTP client/auth-service NOTE blocks flag the fields to confirm against the running instance.
diff --git a/docs/superpowers/specs/2026-05-28-native-give-feedback-app-design.md b/docs/superpowers/specs/2026-05-28-native-give-feedback-app-design.md
new file mode 100644
index 0000000..b741cf4
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-28-native-give-feedback-app-design.md
@@ -0,0 +1,213 @@
+# Native Give-Feedback iOS App + Public End-User API (Architecture C)
+
+**Date:** 2026-05-28
+**Status:** Design approved (sections), pending spec review
+**Author:** Andrew Peltekci (with Claude)
+**Related:** `2026-05-28-ios-sdk-conformance-native-design.md` (the embeddable SDK — a separate product)
+
+## 1. Summary
+
+Build a **standalone native iOS app for end users to give feedback** to an
+OpenCoven Feedback instance: browse boards, vote, comment, submit posts, read
+the changelog, and read the help-center docs — with content that **auto-updates**
+as the web app changes, because everything is fetched live from an API.
+
+The audit found OpenCoven exposes a rich, OpenAPI-documented REST API at
+`/api/v1/*`, but it is **API-key authenticated for team/admin/integration use**
+— unusable from a shipped consumer app. The only thing an anonymous/end-user
+client can fetch today is the public widget config and `kb-search`; everything
+else an end user reads or writes goes through server-rendered TanStack server
+functions (portal) or the embedded widget iframe — neither a public contract a
+native app can consume.
+
+Therefore this is a **two-track program**:
+
+- **Track 1 — Public End-User API** (in `OpenCoven/feedback`): a thin
+ `/api/public/v1/*` surface — anonymous reads + end-user (better-auth bearer)
+ writes — that **reuses the existing domain services**, adding no new business
+ logic and no schema migrations.
+- **Track 2 — Native iOS app** (in `feedback-mobile`): a SwiftUI app that
+ consumes Track 1 via a client generated from its OpenAPI spec.
+
+The API contract is the shared linchpin; this single design doc covers both
+tracks. Implementation is sequenced API-first (Track 2 is untestable without
+Track 1, though it can start against a spec-generated mock).
+
+## 2. Goals / Non-goals
+
+### Goals
+- End users can browse boards, vote, comment, and submit posts natively.
+- End users can read the changelog and help-center articles natively.
+- Content auto-updates from the server with no app release (live API reads).
+- End-user auth via better-auth **bearer token** (email-OTP primary).
+- The iOS client is generated from a published OpenAPI spec, so contract
+ evolution flows into the app via codegen rather than hand edits.
+- The public API is additive to OpenCoven, reusing existing services — small,
+ reviewable, logic-free, maximizing upstream-PR acceptance odds.
+
+### Non-goals (v1 — all fast-follows)
+- Anonymous writes (v1 requires sign-in for submit/vote/comment).
+- Roadmap tab, notifications, push notifications.
+- Offline write queue (writes require connectivity).
+- Multi-instance support (the app points at one instance).
+- Any change to the embeddable `OpenCovenFeedback` SDK (separate product).
+
+## 3. Audit findings (why Architecture C)
+
+- `/api/v1/*` is comprehensive and OpenAPI-documented (`/api/v1/openapi.json`,
+ Swagger UI at `/api/v1/docs`) but every route (incl. reads) calls
+ `withApiKeyAuth(request, { role: 'team' | 'admin' })` — a workspace API key
+ (`Bearer qb_…`). Not shippable in a consumer app.
+- End-user content (boards/posts/changelog/help/roadmap) is rendered by the
+ **portal** via TanStack server functions, and interactions go through the
+ **widget** (`/api/widget/*`: config, session, identify, search, kb-search,
+ upload) using cookie sessions / host-signed `ssoToken`. No public REST CRUD.
+- Auth stack is **better-auth** with the **`bearer` plugin enabled**, plus
+ email-OTP, magic-link, OAuth, and separate portal (end-user) auth config — so
+ a native app can authenticate end users with a bearer token.
+- Conclusion: the product has every feature; what's missing is a public
+ end-user API. Adding one (Track 1) unlocks a fully native app (Track 2).
+
+## 4. Track 1 — Public End-User API (`OpenCoven/feedback`)
+
+**Principle:** add a thin public surface, reuse all existing logic. Handlers
+call the same domain services the portal/admin API already use
+(`post.public`, `post.voting`, `comment.service`, `changelog.service`,
+`help-center.service`, `getPublicWidgetConfig`). New code = an end-user auth
+middleware, thin route handlers, response schemas, and OpenAPI entries. No new
+business logic, no migrations.
+
+### Auth
+- Namespace `/api/public/v1/*`, distinct from admin `/api/v1/*` (API-key) and
+ `/api/widget/*`.
+- End-user auth = better-auth **bearer token** (`bearer` plugin already on).
+ Sign-in via **email-OTP** (primary; no deep link needed) or OAuth. Token
+ stored client-side, sent as `Authorization: Bearer `.
+- **Reads: anonymous allowed.** With a token, responses enrich with personal
+ state (e.g. `hasVoted`).
+- **Writes: require a valid session.** v1 = signed-in only; anonymous
+ (widget-style throwaway session) writes are a documented fast-follow.
+- Reuse the existing per-IP/per-session rate limiter and tenant scoping.
+- Auth endpoints: reuse better-auth's existing `/api/auth/*` (email-OTP
+ request/verify, OAuth) — no new auth endpoints.
+
+### Endpoints
+Reads (anonymous OK):
+- `GET /api/public/v1/config` — public config (tabs, theme, defaultBoard) — reuses `getPublicWidgetConfig`
+- `GET /api/public/v1/boards`
+- `GET /api/public/v1/posts?boardId=&sort=&search=&cursor=&limit=` — feed (`voteCount`, `status`, `hasVoted`)
+- `GET /api/public/v1/posts/:id`
+- `GET /api/public/v1/posts/:id/comments?cursor=`
+- `GET /api/public/v1/changelog?cursor=` · `GET /api/public/v1/changelog/:id`
+- `GET /api/public/v1/help/categories` · `GET /api/public/v1/help/articles/:slug` · `GET /api/public/v1/help/search?q=` (reuses `kb-search`)
+
+Writes (bearer session required):
+- `POST /api/public/v1/posts` — `{ boardId, title, content }`
+- `POST /api/public/v1/posts/:id/vote` — toggle vote
+- `POST /api/public/v1/posts/:id/comments` — `{ content, parentId? }`
+
+### Contract delivery
+- Publish `/api/public/v1/openapi.json` (extend the existing OpenAPI generator
+ or a parallel public spec). This is the single source of truth the iOS client
+ is generated from.
+
+### Error model
+- Mirror the existing `{ error: { code, message } }` shape used by the widget
+ and v1 responses. Standard codes: `UNAUTHORIZED`, `VALIDATION_ERROR`,
+ `NOT_FOUND`, `RATE_LIMITED`, `WIDGET_DISABLED`/`DISABLED`.
+
+## 5. Track 2 — Native iOS app (`feedback-mobile`)
+
+**Navigation:** 4-tab bar — Feedback · Changelog · Help · Account.
+
+**Relationship to the SDK:** the app does native reads + writes against the
+public API and does **not** use the widget WebView. The `OpenCovenFeedback` SDK
+remains a separate product (for third parties embedding the widget). The app
+reuses the `FeedbackApp` demo *shell* — `AppConfiguration`'s instance-URL
+plumbing and design tokens — replacing the single `HomeView` with the tabs.
+
+**Modules** (each a SwiftUI view + observable model):
+- **API client** — generated from `/api/public/v1/openapi.json` via
+ **swift-openapi-generator**. Base URL = configured instance URL. Regenerating
+ from the published spec is how contract evolution enters the app.
+- **Auth** — `AuthStore` holding a better-auth bearer token in the **Keychain**.
+ Email-OTP flow (email → code → token). Anonymous browsing by default; the
+ first write triggers a sign-in sheet, then the action retries.
+- **Feedback** — boards + feed (sort/filter), post detail (comments + vote),
+ compose/submit.
+- **Changelog** — list + detail (read-only).
+- **Help** — categories → articles + search (read-only).
+- **Account** — sign-in/out, profile.
+- **Shared** — design system, OpenAPI-generated models, instance-URL config,
+ loading/empty/error states.
+
+**Data flow:** View ⇄ observable model ⇄ generated client ⇄ public API. Reads
+anonymous (enriched with `hasVoted` when signed in); writes attach the bearer
+token; a `401` opens the sign-in sheet then retries the action.
+
+**Offline/caching (light):** `URLCache` + a small on-disk cache of the last
+feed/changelog/help so the app opens to content offline (read-only). Writes
+need connectivity; no offline write queue in v1.
+
+**Config/scope:** single-instance, configured by instance URL (like the SDK).
+
+## 6. Sequencing
+
+**API-first, two tracks:**
+
+- **Track 1 (web) — build/PR first.**
+ 1. End-user auth middleware (`optionalSession` / `requireSession`, bearer).
+ 2. Read endpoints (config, boards, posts, comments, changelog, help).
+ 3. Write endpoints (submit, vote, comment).
+ 4. `/api/public/v1/openapi.json`.
+ 5. Tests (below).
+
+- **Track 2 (iOS) — start against a spec-generated mock, then the real API.**
+ 1. Client codegen + instance-URL config + email-OTP auth (`AuthStore`, Keychain).
+ 2. Feedback tab (feed, detail, vote, comment, submit).
+ 3. Changelog tab.
+ 4. Help tab.
+ 5. Account tab.
+ 6. Polish: offline cache, accessibility, error/empty states.
+
+Each track ships as its own implementation plan (writing-plans), sharing this
+design and the OpenAPI contract.
+
+## 7. Testing
+
+- **Web (Track 1):** per-endpoint integration tests (reuse existing patterns),
+ auth tests for anonymous vs session paths, and an OpenAPI contract check.
+- **iOS (Track 2):** view-model unit tests against the generated mock client,
+ snapshot tests for key screens (feed, post detail, submit, sign-in), and one
+ integration smoke against a live/staging instance.
+
+## 8. Risks & mitigations
+
+- **Upstream PR acceptance (biggest).** Track 1 is a PR to a repo we don't own.
+ Mitigation: additive, logic-free, mirrors existing route/auth/schema patterns.
+ Fallback: run the public API as a thin separate service over the same DB/API
+ if upstream declines.
+- **Email-OTP abuse.** Reuse the existing `signin-rate-limit`.
+- **Cross-repo contract drift.** OpenAPI is the single source; iOS client is
+ codegen'd; add a CI check that the committed spec matches the routes.
+- **App Store wrapper rejection.** Low — this is a genuinely native app, not a
+ WebView wrapper.
+
+## 9. Decisions log (forks resolved during design)
+
+- App audience: **end-user give-feedback app** (not admin/triage).
+- Feature set: submit, browse + vote + comment, changelog, help-center.
+- Web-repo access: **yes** — we can add a public API (Architecture C).
+- Auth: better-auth **bearer**, **email-OTP** primary; **signed-in writes only**
+ in v1 (anonymous writes deferred).
+- Navigation: **4-tab bar** (Feedback · Changelog · Help · Account).
+- The standalone app does **not** depend on the embeddable SDK.
+
+## 10. Definition of done
+
+- Track 1: `/api/public/v1/*` reads (anonymous) and writes (bearer session) pass
+ integration + auth tests; `/api/public/v1/openapi.json` is published; PR open
+ upstream.
+- Track 2: the app builds in CI; a signed-in user can browse a board, vote,
+ comment, submit a post, read the changelog, and read a help article against a
+ live instance; content reflects server-side changes with no app release.
diff --git a/project.yml b/project.yml
index 5cb8f81..12f3eff 100644
--- a/project.yml
+++ b/project.yml
@@ -57,6 +57,44 @@ targets:
PRODUCT_BUNDLE_IDENTIFIER: dev.opencoven.feedback
INFOPLIST_FILE: FeedbackApp/Sources/FeedbackApp/Info.plist
+ FeedbackPortalApp:
+ type: application
+ platform: iOS
+ deploymentTarget: "17.0"
+ sources:
+ - path: App/FeedbackPortalApp
+ excludes:
+ - "**/*.md"
+ dependencies:
+ - package: OpenCovenFeedback
+ product: FeedbackKit
+ info:
+ path: App/FeedbackPortalApp/Info.plist
+ properties:
+ CFBundleName: "$(PRODUCT_NAME)"
+ CFBundleDisplayName: Feedback Portal
+ CFBundleIdentifier: "$(PRODUCT_BUNDLE_IDENTIFIER)"
+ CFBundleShortVersionString: "1.0"
+ CFBundleVersion: "1"
+ UILaunchStoryboardName: ""
+ FEEDBACK_INSTANCE_URL: "$(FEEDBACK_INSTANCE_URL)"
+ UISupportedInterfaceOrientations:
+ - UIInterfaceOrientationPortrait
+ - UIInterfaceOrientationLandscapeLeft
+ - UIInterfaceOrientationLandscapeRight
+ UISupportedInterfaceOrientations~ipad:
+ - UIInterfaceOrientationPortrait
+ - UIInterfaceOrientationPortraitUpsideDown
+ - UIInterfaceOrientationLandscapeLeft
+ - UIInterfaceOrientationLandscapeRight
+ NSAppTransportSecurity:
+ NSAllowsLocalNetworking: true
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: dev.opencoven.feedbackportal
+ INFOPLIST_FILE: App/FeedbackPortalApp/Info.plist
+ FEEDBACK_INSTANCE_URL: "http://localhost:3000"
+
schemes:
FeedbackApp:
build:
@@ -64,3 +102,18 @@ schemes:
FeedbackApp: all
run:
config: Debug
+
+ FeedbackPortalApp:
+ build:
+ targets:
+ FeedbackPortalApp: all
+ run:
+ config: Debug
+ test:
+ config: Debug
+ profile:
+ config: Release
+ analyze:
+ config: Debug
+ archive:
+ config: Release