diff --git a/ios/RELEASE_METADATA.md b/ios/RELEASE_METADATA.md index d6cab27..e7f253d 100644 --- a/ios/RELEASE_METADATA.md +++ b/ios/RELEASE_METADATA.md @@ -72,10 +72,10 @@ Confirm the rating produced by App Store Connect's current questionnaire. Setline does not generate or prescribe a training programme. The bundled sample demonstrates exact-order workout playback. All active workout actions work offline. No HealthKit or sensor data is requested. Device-only mode is available without creating an account. Optional cloud sync -currently authenticates with Google. Do not submit this build for App Review -until Sign in with Apple is implemented and configured as an equivalent login -option, or the Google login is removed; neither exception in App Review -Guideline 4.8 applies to this primary account flow. +offers Sign in with Apple beside Google. Existing Google users explicitly add +Apple while authenticated; matching email text does not silently merge +identities. The Apple token is nonce-bound and validated by the service for +`com.significanthobbies.setline`. ## Screenshots and release diff --git a/ios/Setline.xcodeproj/project.pbxproj b/ios/Setline.xcodeproj/project.pbxproj index 663a2e9..1cceeb3 100644 --- a/ios/Setline.xcodeproj/project.pbxproj +++ b/ios/Setline.xcodeproj/project.pbxproj @@ -83,6 +83,7 @@ 15268979096821BD9ABB22E0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 27A4007C0EBCEA2C015905F8 /* NativeAccountClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAccountClient.swift; sourceTree = ""; }; 3C2CB1821B5B5AE4C75873C6 /* Design.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Design.swift; sourceTree = ""; }; + 40CADB4F69054A14B366A16D /* Setline.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Setline.entitlements; sourceTree = ""; }; 594DC48A6CD7B68259549A12 /* Progression.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Progression.swift; sourceTree = ""; }; 608F81A574360691A58B8581 /* SetlineApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetlineApp.swift; sourceTree = ""; }; 65E8358BE39000A0F7E1BB90 /* Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence.swift; sourceTree = ""; }; @@ -138,6 +139,7 @@ 27A4007C0EBCEA2C015905F8 /* NativeAccountClient.swift */, 824EFFB1023C857CE4F1FE1C /* RootView.swift */, DFAE6EB21BC26F97140F4FEE /* SecondaryViews.swift */, + 40CADB4F69054A14B366A16D /* Setline.entitlements */, 608F81A574360691A58B8581 /* SetlineApp.swift */, 818A8EDA05B5D0B4E9DE47B3 /* WorkoutPlayerView.swift */, 1DB949AB54ED01CA9EBB90BB /* Resources */, @@ -463,6 +465,7 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = Sources/Setline/Setline.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; INFOPLIST_FILE = Sources/Setline/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -504,6 +507,7 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = Sources/Setline/Setline.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; INFOPLIST_FILE = Sources/Setline/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/ios/Sources/Setline/AppModel.swift b/ios/Sources/Setline/AppModel.swift index 03415cd..1626066 100644 --- a/ios/Sources/Setline/AppModel.swift +++ b/ios/Sources/Setline/AppModel.swift @@ -46,7 +46,9 @@ final class AppModel { defer { isLoading = false } do { if ProcessInfo.processInfo.arguments.contains("--fresh-demo") { - document = .sample + var sample = SetlineDocument.sample + sample.programme = nil + document = sample } else { document = try await store.load() } @@ -62,11 +64,11 @@ final class AppModel { isWorkoutPresented = true } if ProcessInfo.processInfo.arguments.contains("--account-demo") { - account = SetlineAccount(name: "Sarthak", email: "sarthak@example.com") + account = SetlineAccount(name: "Sarthak", email: "sarthak@example.com", providers: ["google"]) document.syncState = .synced document.lastSyncedAt = Date().addingTimeInterval(-240) } else if ProcessInfo.processInfo.arguments.contains("--account-conflict-demo") { - account = SetlineAccount(name: "Sarthak", email: "sarthak@example.com") + account = SetlineAccount(name: "Sarthak", email: "sarthak@example.com", providers: ["google"]) document.syncState = .conflict var accountDocument = document if let template = accountDocument.templates.first { @@ -84,7 +86,7 @@ final class AppModel { document: SetlineCloudDocument(document: accountDocument), revision: 3 ) - } else { + } else if !ProcessInfo.processInfo.arguments.contains("--fresh-demo") { await restoreAccount() } } catch { @@ -232,6 +234,23 @@ final class AppModel { } } + func completeAppleSignIn(_ payload: AppleIdentityPayload) async { + isAccountBusy = true + accountMessage = nil + defer { isAccountBusy = false } + do { + if let account, !account.hasApple { + self.account = try await accountClient.linkApple(payload) + accountMessage = "Apple sign-in added to this Setline account." + } else { + account = try await accountClient.signInWithApple(payload) + } + try await reconcileAccountCopy() + } catch { + accountMessage = friendlyMessage(for: error) + } + } + func syncNow() async { guard account != nil else { return } if let deferredConflict { diff --git a/ios/Sources/Setline/NativeAccountClient.swift b/ios/Sources/Setline/NativeAccountClient.swift index cda8c57..704ad96 100644 --- a/ios/Sources/Setline/NativeAccountClient.swift +++ b/ios/Sources/Setline/NativeAccountClient.swift @@ -1,12 +1,24 @@ import AuthenticationServices +import CryptoKit import Foundation import Security import SetlineCore import UIKit +struct AppleIdentityPayload: Sendable { + let identityToken: String + let nonce: String + let email: String? + let firstName: String? + let lastName: String? +} + struct SetlineAccount: Equatable, Sendable { let name: String let email: String + let providers: Set + + var hasApple: Bool { providers.contains("apple") } } enum NativeAccountError: LocalizedError { @@ -142,6 +154,20 @@ actor SetlineNativeAccountClient { return try await account() } + func signInWithApple(_ payload: AppleIdentityPayload) async throws -> SetlineAccount { + let response = try await appleRequest(path: "/api/auth/sign-in/social", payload: payload) + guard let token = response.response.value(forHTTPHeaderField: "set-auth-token") else { + throw NativeAccountError.missingSession + } + try await sessionStore.save(token) + return try await account() + } + + func linkApple(_ payload: AppleIdentityPayload) async throws -> SetlineAccount { + _ = try await appleRequest(path: "/api/auth/link-social", payload: payload, authenticated: true) + return try await account() + } + func fetchState() async throws -> SetlineCloudSnapshot? { let response = try await request(path: "/api/native/state", method: "GET") return try decoder.decode(StateResponse.self, from: response.data).state @@ -170,10 +196,51 @@ actor SetlineNativeAccountClient { try await sessionStore.delete() } + private func appleRequest( + path: String, + payload: AppleIdentityPayload, + authenticated: Bool = false + ) async throws -> NetworkResponse { + var idToken: [String: Any] = ["token": payload.identityToken, "nonce": payload.nonce] + if path.hasSuffix("sign-in/social") { + var user: [String: Any] = [:] + if let email = payload.email { user["email"] = email } + var name: [String: String] = [:] + if let firstName = payload.firstName { name["firstName"] = firstName } + if let lastName = payload.lastName { name["lastName"] = lastName } + if !name.isEmpty { user["name"] = name } + if !user.isEmpty { idToken["user"] = user } + } + return try await request( + path: path, + jsonBody: ["provider": "apple", "idToken": idToken], + authenticated: authenticated + ) + } + private func account() async throws -> SetlineAccount { let response = try await request(path: "/api/auth/get-session", method: "GET") let session = try decoder.decode(SessionResponse.self, from: response.data) - return SetlineAccount(name: session.user.name, email: session.user.email) + let accountsResponse = try await request(path: "/api/auth/list-accounts", method: "GET") + let accounts = try decoder.decode([ProviderAccount].self, from: accountsResponse.data) + return SetlineAccount( + name: session.user.name, + email: session.user.email, + providers: Set(accounts.map(\.providerId)) + ) + } + + private func request( + path: String, + jsonBody: [String: Any], + authenticated: Bool + ) async throws -> NetworkResponse { + try await request( + path: path, + method: "POST", + data: try JSONSerialization.data(withJSONObject: jsonBody), + authenticated: authenticated + ) } private func request( @@ -254,6 +321,17 @@ actor SetlineNativeAccountClient { } } +enum AppleNonce { + static func make() -> String { + let alphabet = Array("0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._") + return String((0..<32).map { _ in alphabet.randomElement()! }) + } + + static func digest(_ nonce: String) -> String { + SHA256.hash(data: Data(nonce.utf8)).map { String(format: "%02x", $0) }.joined() + } +} + @MainActor final class SetlineWebAuthenticator: NSObject, ASWebAuthenticationPresentationContextProviding { private var session: ASWebAuthenticationSession? @@ -301,6 +379,7 @@ final class SetlineWebAuthenticator: NSObject, ASWebAuthenticationPresentationCo private struct TokenResponse: Decodable { let token: String } private struct SessionResponse: Decodable { let user: SessionUser } private struct SessionUser: Decodable { let name: String; let email: String } +private struct ProviderAccount: Decodable { let providerId: String } private struct ErrorResponse: Decodable { let message: String } private struct StateResponse: Decodable { let state: SetlineCloudSnapshot? } private struct StateWrite: Encodable { diff --git a/ios/Sources/Setline/SecondaryViews.swift b/ios/Sources/Setline/SecondaryViews.swift index ccaa271..cc58ada 100644 --- a/ios/Sources/Setline/SecondaryViews.swift +++ b/ios/Sources/Setline/SecondaryViews.swift @@ -1,3 +1,4 @@ +import AuthenticationServices import SetlineCore import SwiftUI import UniformTypeIdentifiers @@ -363,9 +364,11 @@ private struct SessionDetailView: View { struct SettingsView: View { @Environment(AppModel.self) private var model + @Environment(\.colorScheme) private var colorScheme @State private var isImporterPresented = false @State private var showResetConfirmation = false @State private var showDeleteAccountConfirmation = false + @State private var appleNonce = AppleNonce.make() var body: some View { @Bindable var model = model @@ -403,6 +406,7 @@ struct SettingsView: View { } .buttonStyle(.borderedProminent) .tint(SetlinePalette.ink) + appleAccountButton } else { if let lastSync = model.document.lastSyncedAt { LabeledContent("Last synced") { @@ -416,6 +420,12 @@ struct SettingsView: View { } .buttonStyle(.borderedProminent) .tint(SetlinePalette.ink) + if model.account?.hasApple == false { + Text("Add Apple to this account so future Apple sign-ins open the same private workout copy.") + .font(.footnote) + .foregroundStyle(.secondary) + appleAccountButton + } Button { Task { await model.signOut() } } label: { Label("Sign out", systemImage: "rectangle.portrait.and.arrow.right") .frame(maxWidth: .infinity, alignment: .leading) @@ -535,6 +545,40 @@ struct SettingsView: View { } } + private var appleAccountButton: some View { + SignInWithAppleButton(.continue) { request in + appleNonce = AppleNonce.make() + request.requestedScopes = [.fullName, .email] + request.nonce = AppleNonce.digest(appleNonce) + } onCompletion: { result in + guard + case let .success(authorization) = result, + let credential = authorization.credential as? ASAuthorizationAppleIDCredential, + let tokenData = credential.identityToken, + let token = String(data: tokenData, encoding: .utf8) + else { + if case let .failure(error) = result, + (error as? ASAuthorizationError)?.code != .canceled { + model.accountMessage = error.localizedDescription + } + return + } + let payload = AppleIdentityPayload( + identityToken: token, + nonce: appleNonce, + email: credential.email, + firstName: credential.fullName?.givenName, + lastName: credential.fullName?.familyName + ) + Task { await model.completeAppleSignIn(payload) } + } + .signInWithAppleButtonStyle(colorScheme == .dark ? .white : .black) + .frame(maxWidth: .infinity, minHeight: 48) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .accessibilityIdentifier("apple-account-button") + .disabled(model.isAccountBusy) + } + private func settingsSection(_ title: String, @ViewBuilder content: () -> Content) -> some View { VStack(alignment: .leading, spacing: 10) { SectionLabel(text: title) diff --git a/ios/Sources/Setline/Setline.entitlements b/ios/Sources/Setline/Setline.entitlements new file mode 100644 index 0000000..a812db5 --- /dev/null +++ b/ios/Sources/Setline/Setline.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.applesignin + + Default + + + diff --git a/ios/Tests/SetlineUITests/SetlineUITests.swift b/ios/Tests/SetlineUITests/SetlineUITests.swift index 6627755..740d3b4 100644 --- a/ios/Tests/SetlineUITests/SetlineUITests.swift +++ b/ios/Tests/SetlineUITests/SetlineUITests.swift @@ -51,4 +51,14 @@ final class SetlineUITests: XCTestCase { XCTAssertTrue(app.textFields["Name"].exists) XCTAssertTrue(app.buttons["Add exercise"].exists) } + + func testAccountScreenOffersAppleAlongsideGoogle() { + let app = XCUIApplication() + app.launchArguments = ["--fresh-demo"] + app.launch() + + app.tabBars.buttons["You"].tap() + XCTAssertTrue(app.buttons["Connect Google account"].waitForExistence(timeout: 3)) + XCTAssertTrue(app.buttons["apple-account-button"].exists) + } } diff --git a/ios/project.yml b/ios/project.yml index ef372a1..1d89940 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -61,6 +61,7 @@ targets: base: PRODUCT_BUNDLE_IDENTIFIER: com.significanthobbies.setline PRODUCT_NAME: Setline + CODE_SIGN_ENTITLEMENTS: Sources/Setline/Setline.entitlements TARGETED_DEVICE_FAMILY: "1" ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon SUPPORTS_MACCATALYST: NO diff --git a/openspec/changes/add-native-ios-app/design.md b/openspec/changes/add-native-ios-app/design.md index 1e58682..29c4f65 100644 --- a/openspec/changes/add-native-ios-app/design.md +++ b/openspec/changes/add-native-ios-app/design.md @@ -35,6 +35,10 @@ Domain types and operations live in `SetlineCore`; the app target owns SwiftUI c Optional account connection uses `ASWebAuthenticationSession` and URLSession against existing Setline endpoints. Credentials are stored only in Keychain. The remote adapter exchanges versioned whole-state payloads and never performs per-set merging or silent template rewrites. No secrets or environment files are embedded. +### Apple identity is native and account linking is explicit + +The iPhone app obtains an Apple identity token with AuthenticationServices and a SHA-256 nonce, then sends it directly to Better Auth for signature, issuer, audience, expiry, and nonce validation against `com.significanthobbies.setline`. A new Apple identity may create an account. An existing Google account can add Apple only while already authenticated through an explicit link action. Matching email addresses never silently merge accounts, and a hidden Apple relay address is accepted when the owner deliberately links it. + ### Preserve-mode visual adaptation The native app inherits `DESIGN.md`: chalk/paper daylight surfaces, ink structure, lime execution signals, tabular numerals, Scoreboard Split hierarchy, sparse rounding, and one dominant action. Native navigation, sheets, focus, Dynamic Type, VoiceOver, and Reduce Motion behavior take precedence where platform conventions improve operation. diff --git a/openspec/changes/add-native-ios-app/proposal.md b/openspec/changes/add-native-ios-app/proposal.md index b78d6fc..2ca9231 100644 --- a/openspec/changes/add-native-ios-app/proposal.md +++ b/openspec/changes/add-native-ios-app/proposal.md @@ -8,6 +8,7 @@ Setline is designed around one-handed workout execution, but its only maintained - Match the current Setline product surface: schedule, workout playback, substitutions, rest timing, records, templates, history, analytics, settings, data transfer, and account controls. - Keep active-workout state available offline and recover interrupted sessions without reordering authored exercises. - Reuse existing Setline API and synchronization contracts where available; retain a useful local-only path when signed out. +- Offer Sign in with Apple beside Google, with explicit linking for an already connected account and no email-based implicit account merge. - Add native tests, privacy metadata, app metadata, icons, simulator verification, and a signed archive workflow that stops before upload. - Keep the web application intact and do not add a unified Fleet hub. @@ -23,4 +24,4 @@ None. ## Impact -Adds an `ios/` Swift/Xcode surface beside the existing web application. Existing web routes, API behavior, data formats, dependencies, and production deployment remain unchanged. The iOS app uses the personal Apple development team for local signing and produces no App Store Connect records or uploads. +Adds an `ios/` Swift/Xcode surface beside the existing web application and extends the existing auth service with native Apple identity-token validation. Existing workout routes and data formats remain unchanged. The iOS app uses the personal Apple development team for signing and is prepared for a separately authorized TestFlight upload. diff --git a/openspec/changes/add-native-ios-app/specs/native-ios-client/spec.md b/openspec/changes/add-native-ios-app/specs/native-ios-client/spec.md index 0f78cca..8c35810 100644 --- a/openspec/changes/add-native-ios-app/specs/native-ios-client/spec.md +++ b/openspec/changes/add-native-ios-app/specs/native-ios-client/spec.md @@ -46,6 +46,13 @@ The iOS client SHALL support versioned whole-state export, preview-before-replac - **WHEN** the user selects a compatible Setline export - **THEN** the client validates and summarizes the replacement before requiring explicit confirmation +### Requirement: Native account access includes Sign in with Apple +The iOS client SHALL offer Sign in with Apple beside Google account connection. Apple identity tokens SHALL be verified by the service for the native bundle identifier. The service SHALL disable implicit email-based linking; an existing signed-in account MAY add Apple only through an explicit authenticated linking action. + +#### Scenario: Existing Google user adds Apple +- **WHEN** an authenticated Google user chooses the Apple control and completes Apple's authorization +- **THEN** Apple is linked to that same account without replacing local workout data or inferring identity from matching email text + ### Requirement: The native experience is accessible and honest The iOS client SHALL support Dynamic Type, VoiceOver labels and values, Reduce Motion, sufficient contrast, 44-point targets, and status cues that do not rely on color alone. Unsupported sensor or health values SHALL be omitted or labelled unavailable rather than estimated. diff --git a/openspec/changes/add-native-ios-app/tasks.md b/openspec/changes/add-native-ios-app/tasks.md index 6e87332..ac3927d 100644 --- a/openspec/changes/add-native-ios-app/tasks.md +++ b/openspec/changes/add-native-ios-app/tasks.md @@ -25,3 +25,9 @@ - [x] 4.2 Complete Dynamic Type, VoiceOver, Reduce Motion, contrast, empty/error/loading states, and native polish review - [x] 4.3 Add release metadata, privacy/support copy, simulator screenshots, and documented device-only checks - [x] 4.4 Run strict OpenSpec validation, tests, Release simulator build, personal-team archive, and signature verification without upload + +## 5. Equivalent Native Account Access + +- [x] 5.1 Add the Apple entitlement, nonce-backed AuthenticationServices UI, native token exchange, provider visibility, and explicit Apple linking +- [x] 5.2 Configure Better Auth for native Apple token validation with disabled implicit linking and no browser-only Apple secret requirement +- [x] 5.3 Add account/auth tests, run the complete native and web checks, archive with the personal team, and update App Store metadata diff --git a/tests/native-account.test.mjs b/tests/native-account.test.mjs index 8af5bd8..69f3753 100644 --- a/tests/native-account.test.mjs +++ b/tests/native-account.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; import test from "node:test"; import { createServer } from "vite"; @@ -53,3 +54,17 @@ test("native state requires schema one and an explicit base revision", () => { null, ); }); + +test("native Apple auth validates the bundle audience and never links by email implicitly", async () => { + const [auth, client] = await Promise.all([ + readFile(new URL("../worker/auth.ts", import.meta.url), "utf8"), + readFile(new URL("../ios/Sources/Setline/NativeAccountClient.swift", import.meta.url), "utf8"), + ]); + + assert.match(auth, /appBundleIdentifier:\s*appleBundleIdentifier/); + assert.match(auth, /disableImplicitLinking:\s*true/); + assert.match(auth, /allowDifferentEmails:\s*true/); + assert.match(client, /\/api\/auth\/sign-in\/social/); + assert.match(client, /\/api\/auth\/link-social/); + assert.match(client, /set-auth-token/); +}); diff --git a/worker/auth.ts b/worker/auth.ts index 0d12806..452da19 100644 --- a/worker/auth.ts +++ b/worker/auth.ts @@ -8,6 +8,7 @@ export type SetlineBindings = CloudflareBindings & { BETTER_AUTH_SECRET?: string; GOOGLE_CLIENT_ID?: string; GOOGLE_CLIENT_SECRET?: string; + APPLE_APP_BUNDLE_IDENTIFIER?: string; }; const PRODUCTION_ORIGIN = "https://setline.significanthobbies.com"; @@ -31,6 +32,10 @@ export function isGoogleConfigured(env: SetlineBindings) { ); } +export function isAppleConfigured(env: SetlineBindings) { + return Boolean(env.APPLE_APP_BUNDLE_IDENTIFIER?.trim()); +} + export function createAuth(env: SetlineBindings, requestUrl: string) { const requestOrigin = new URL(requestUrl).origin; const baseURL = isLocalOrigin(requestOrigin) ? requestOrigin : PRODUCTION_ORIGIN; @@ -39,6 +44,7 @@ export function createAuth(env: SetlineBindings, requestUrl: string) { (isLocalOrigin(requestOrigin) ? "setline-local-development-secret-never-use-in-production" : undefined); + const appleBundleIdentifier = env.APPLE_APP_BUNDLE_IDENTIFIER?.trim() ?? ""; return betterAuth({ database: drizzleAdapter(drizzle(env.DB), { @@ -54,6 +60,23 @@ export function createAuth(env: SetlineBindings, requestUrl: string) { scope: ["openid", "email", "profile"], prompt: "select_account", }, + ...(isAppleConfigured(env) + ? { + apple: { + clientId: appleBundleIdentifier, + clientSecret: "", + appBundleIdentifier: appleBundleIdentifier, + }, + } + : {}), + }, + account: { + accountLinking: { + enabled: true, + disableImplicitLinking: true, + trustedProviders: ["google", "apple"], + allowDifferentEmails: true, + }, }, user: { deleteUser: { @@ -61,7 +84,9 @@ export function createAuth(env: SetlineBindings, requestUrl: string) { }, }, plugins: [bearer()], - trustedOrigins: [...new Set([baseURL, ...LOCAL_ORIGINS, "setline://auth"])], + trustedOrigins: [ + ...new Set([baseURL, ...LOCAL_ORIGINS, "https://appleid.apple.com", "setline://auth"]), + ], rateLimit: { enabled: false, }, diff --git a/worker/index.ts b/worker/index.ts index 1bc6e66..5815930 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -1,7 +1,12 @@ /** Cloudflare Worker entry point for Setline. */ import handler from "vinext/server/app-router-entry"; import { handleAgentEdge } from "./agent-edge.mjs"; -import { createAuth, isGoogleConfigured, type SetlineBindings } from "./auth"; +import { + createAuth, + isAppleConfigured, + isGoogleConfigured, + type SetlineBindings, +} from "./auth"; import { handleMcpRead, handleMcpTokenManagement } from "./mcp"; import { consumeNativeHandoff, @@ -50,13 +55,19 @@ const worker = { if (url.pathname === "/api/health" && request.method === "GET") { return json({ ok: true, - auth: { googleConfigured: isGoogleConfigured(env) }, + auth: { + googleConfigured: isGoogleConfigured(env), + appleConfigured: isAppleConfigured(env), + }, storage: "d1", }); } if (url.pathname === "/api/auth/config" && request.method === "GET") { - return json({ googleConfigured: isGoogleConfigured(env) }); + return json({ + googleConfigured: isGoogleConfigured(env), + appleConfigured: isAppleConfigured(env), + }); } if (url.pathname === "/api/native/auth/google/start" && request.method === "GET") { @@ -116,16 +127,29 @@ const worker = { if (url.pathname.startsWith("/api/auth/")) { if ( url.pathname.endsWith("/sign-in/social") && - request.method === "POST" && - !isGoogleConfigured(env) + request.method === "POST" ) { - return json( - { - code: "OAUTH_NOT_CONFIGURED", - message: "Google sign-in is not configured in this environment.", - }, - 503, - ); + const body = (await request.clone().json().catch(() => null)) as { + provider?: unknown; + } | null; + if (body?.provider === "google" && !isGoogleConfigured(env)) { + return json( + { + code: "OAUTH_NOT_CONFIGURED", + message: "Google sign-in is not configured in this environment.", + }, + 503, + ); + } + if (body?.provider === "apple" && !isAppleConfigured(env)) { + return json( + { + code: "OAUTH_NOT_CONFIGURED", + message: "Apple sign-in is not configured in this environment.", + }, + 503, + ); + } } const response = await createAuth(env, request.url).handler(request); return withApiHeaders(response); diff --git a/wrangler.jsonc b/wrangler.jsonc index 35d511e..42245da 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -4,6 +4,9 @@ "main": "worker/index.ts", "compatibility_date": "2026-05-22", "compatibility_flags": ["nodejs_compat"], + "vars": { + "APPLE_APP_BUNDLE_IDENTIFIER": "com.significanthobbies.setline" + }, "routes": [ { "pattern": "setline.significanthobbies.com",