diff --git a/App/Composition/AppEnvironment.swift b/App/Composition/AppEnvironment.swift index a3420db..46a5c00 100644 --- a/App/Composition/AppEnvironment.swift +++ b/App/Composition/AppEnvironment.swift @@ -192,9 +192,9 @@ final class AppEnvironment: ObservableObject { /// Synced app settings + the per-machine device registry /// (work-consolidation.md G17) — the platform's own mechanism for companion /// apps, and the sanctioned home for this app's preferences and the - /// Document Sync Agent's per-machine configuration. Optional because it is - /// only usable once an `appKey` is registered with the backend owner; the - /// Settings panes render an explicit unavailable state while it is nil. + /// Document Sync Agent's per-machine configuration. Optional so the feature + /// can be switched off via `appSettingsKey`; the panes render an explicit + /// unavailable state while it is nil. let appSettings: AppSettingsServicing? /// The server-driven notification-preferences catalogue @@ -315,16 +315,21 @@ final class AppEnvironment: ObservableObject { self.tags = tags } - /// The app-settings key this client registers under (work-consolidation.md - /// G17). + /// The app-settings namespace this client stores its settings under + /// (work-consolidation.md G17). /// - /// ⚠️ **Nil until an `appKey` is registered with the backend owner** — that - /// registration is a stated prerequisite of G17, and calling the routes with - /// an unregistered key 404s. While nil, `AppEnvironment.appSettings` is nil - /// and the Settings panes render an explicit "not configured" state instead - /// of failing opaquely. Set this to the agreed key to switch the feature on; - /// it is deliberately the single edit required. - static let appSettingsKey: String? = nil + /// **Verified live 2026-09-06: no registration is required.** The G17 gap + /// definition said to "pick and register an `appKey` with the backend + /// owner", but the live API treats the segment as a free-form namespace — + /// `GET /api/user/app-settings//devices` answers `200 {"devices":[]}` + /// for a key the server has never seen, and `OPTIONS` on the parent reports + /// `allow: DELETE, GET, HEAD, OPTIONS, PUT`. The key's only job is to keep + /// this app's settings separate from other companion apps on the same + /// account, so it must simply stay stable — changing it orphans whatever was + /// stored under the old one. + /// + /// Kept optional so the feature can still be switched off in one edit. + static let appSettingsKey: String? = "interlinedlist-macos" /// Builds the production service graph: /// diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/AppSettingsService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/AppSettingsService.swift index 413284f..b758895 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/AppSettingsService.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/AppSettingsService.swift @@ -48,14 +48,28 @@ public final class AppSettingsService: AppSettingsServicing { // MARK: - Bootstrap public func bootstrap(deviceID: String) async throws -> AppSettingsSnapshot { - let dto = try await api.send(AppSettings.bootstrap(appKey: appKey, deviceId: deviceID)) - return AppSettingsSnapshot(from: dto) + do { + let dto = try await api.send(AppSettings.bootstrap(appKey: appKey, deviceId: deviceID)) + return AppSettingsSnapshot(from: dto) + } catch let error as APIError { + // Verified live 2026-09-06: an unregistered device answers + // `404 {"source":"none"}`. That is the ordinary first-run state, not + // a failure — a brand-new Mac has nothing stored yet — so it maps to + // an empty snapshot flagged `isNewDevice`, and the caller registers. + guard case .notFound = error else { throw error } + return AppSettingsSnapshot(isNewDevice: true) + } } // MARK: - Shared settings public func sharedSettings() async throws -> AppSettingsBag { - AppSettingsBag(from: try await api.send(AppSettings.shared(appKey: appKey))) + // Verified live 2026-09-06: an app key with nothing stored yet answers + // 404, while `OPTIONS` on the same path reports + // `allow: DELETE, GET, HEAD, OPTIONS, PUT` — the route exists, the + // bucket is simply empty. Treating that as an error would make every + // fresh account show a failure instead of empty settings. + try await emptyOnNotFound { AppSettings.shared(appKey: self.appKey) } } public func writeSharedSettings(_ bag: AppSettingsBag) async throws -> AppSettingsBag { @@ -68,9 +82,23 @@ public final class AppSettingsService: AppSettingsServicing { // MARK: - Per-device settings public func deviceSettings(deviceID: String) async throws -> AppSettingsBag { - AppSettingsBag( - from: try await api.send(AppSettings.deviceSettings(appKey: appKey, deviceId: deviceID)) - ) + try await emptyOnNotFound { + AppSettings.deviceSettings(appKey: self.appKey, deviceId: deviceID) + } + } + + /// Sends a settings read, mapping the "nothing stored yet" 404 to an empty + /// bag. Every other error still propagates — a 401 or a 500 is a real + /// failure and must reach the UI. + private func emptyOnNotFound( + _ build: @Sendable () -> Request + ) async throws -> AppSettingsBag { + do { + return AppSettingsBag(from: try await api.send(build())) + } catch let error as APIError { + guard case .notFound = error else { throw error } + return AppSettingsBag() + } } public func writeDeviceSettings( diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/AppSettingsServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/AppSettingsServiceTests.swift index c619384..a994801 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/AppSettingsServiceTests.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/AppSettingsServiceTests.swift @@ -127,14 +127,70 @@ final class AppSettingsServiceTests: XCTestCase { // MARK: - Upstream failure - func test_givenUnregisteredAppKey_whenFetching_thenThrows() async { + func test_givenForbidden_whenFetching_thenThrows() async { + // Replaces an earlier test that asserted a 404 must throw. The live probe + // showed 404 is the empty-bucket state, not an unknown-key rejection, so + // the meaningful auth failure to cover here is 403. let api = StubAPIClient() - await api.enqueue(failure: .notFound(serverMessage: "unknown app key")) + await api.enqueue(failure: .forbidden(serverMessage: "nope")) let service = makeService(api) do { _ = try await service.sharedSettings() - XCTFail("An unregistered appKey must surface, not silently yield empty settings") + XCTFail("Expected the failure to propagate") + } catch { + // expected + } + } + + // MARK: - First-run 404s + // + // Verified live 2026-09-06: the server answers 404 for an app key with + // nothing stored yet, and 404 `{"source":"none"}` for a device it has not + // seen. Both are ordinary first-run states — treating them as errors made a + // fresh install show a failure instead of empty settings. + + func test_givenNothingStoredYet_whenReadingSharedSettings_thenReturnsEmptyBagNotAnError() async throws { + let api = StubAPIClient() + await api.enqueue(failure: .notFound(serverMessage: "Not found")) + let service = makeService(api) + + let bag = try await service.sharedSettings() + + XCTAssertTrue(bag.isEmpty) + } + + func test_givenUnregisteredDevice_whenBootstrapping_thenReturnsEmptySnapshotFlaggedNew() async throws { + let api = StubAPIClient() + await api.enqueue(failure: .notFound(serverMessage: "Not found")) + let service = makeService(api) + + let snapshot = try await service.bootstrap(deviceID: "dev-new") + + XCTAssertTrue(snapshot.shared.isEmpty) + XCTAssertTrue(snapshot.device.isEmpty) + XCTAssertTrue(snapshot.isNewDevice, "the caller registers off this flag") + } + + func test_givenNothingStoredYet_whenReadingDeviceSettings_thenReturnsEmptyBag() async throws { + let api = StubAPIClient() + await api.enqueue(failure: .notFound(serverMessage: "Not found")) + let service = makeService(api) + + let bag = try await service.deviceSettings(deviceID: "dev-1") + + XCTAssertTrue(bag.isEmpty) + } + + func test_givenRealFailure_whenReadingSettings_thenStillThrows() async { + // Only 404 is benign. A 500 or a 401 must still reach the UI. + let api = StubAPIClient() + await api.enqueue(failure: .httpStatus(code: 500, serverMessage: "boom")) + let service = makeService(api) + + do { + _ = try await service.sharedSettings() + XCTFail("a server error is not a first-run state") } catch { // expected } diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/TagDTO.swift b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/TagDTO.swift index 6b3c3d3..f07622c 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/TagDTO.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/TagDTO.swift @@ -38,10 +38,11 @@ public struct TrendingTagDTO: Decodable, Sendable, Equatable { /// `GET /api/tags/autocomplete` response — prefix matches over public messages. /// -/// The gap definition does not pin this shape, so the decoder accepts the three -/// plausible forms rather than guessing one: a bare string array -/// (`["swift","swiftui"]`), a bare object array (`[{"tag":"swift"}]`), or either -/// wrapped under `tags`. All collapse to `[String]`. +/// **Verified live 2026-09-06:** the server returns the wrapped form, +/// `{"tags":[...]}`. The decoder still accepts a bare string array +/// (`["swift","swiftui"]`) and a bare object array (`[{"tag":"swift"}]`) as +/// well, since those cost nothing and this API has been seen to wrap some +/// collections and not others. All collapse to `[String]`. public struct TagSuggestionsResponse: Decodable, Sendable, Equatable { public let tags: [String] diff --git a/work-consolidation.md b/work-consolidation.md index 19bb9e2..b27a757 100644 --- a/work-consolidation.md +++ b/work-consolidation.md @@ -120,19 +120,19 @@ Live and available to the test account: `GET /api/ai/status` returns `{"subscrib **G17 · Applications: synced settings + device registry — HIGH for a native client specifically. Size M.** -`GET/PUT/DELETE /api/user/app-settings/{appKey}`, `GET /api/user/app-settings/{appKey}/bootstrap?deviceId=…`, `GET/POST /api/user/app-settings/{appKey}/devices`, `GET/PUT /api/user/app-settings/{appKey}/devices/{deviceId}/settings`, `PATCH/DELETE /api/user/app-settings/{appKey}/devices/{deviceId}`. Per `/help/app-settings` this is the platform's *own* mechanism for companion apps: **shared settings** follow the account to every machine, **per-machine settings** stay pinned to one computer, one machine is the "main workstation" whose config seeds a brand-new device on first sign-in, and devices can be renamed or deregistered. This is the sanctioned home for the macOS app's preferences **and** the Document Sync Agent's per-machine configuration, replacing purely local `UserDefaults` state. Pick and register an `appKey` with the backend owner before building. +`GET/PUT/DELETE /api/user/app-settings/{appKey}`, `GET /api/user/app-settings/{appKey}/bootstrap?deviceId=…`, `GET/POST /api/user/app-settings/{appKey}/devices`, `GET/PUT /api/user/app-settings/{appKey}/devices/{deviceId}/settings`, `PATCH/DELETE /api/user/app-settings/{appKey}/devices/{deviceId}`. Per `/help/app-settings` this is the platform's *own* mechanism for companion apps: **shared settings** follow the account to every machine, **per-machine settings** stay pinned to one computer, one machine is the "main workstation" whose config seeds a brand-new device on first sign-in, and devices can be renamed or deregistered. This is the sanctioned home for the macOS app's preferences **and** the Document Sync Agent's per-machine configuration, replacing purely local `UserDefaults` state. **✅ SHIPPED 2026-09-06** (PR #25 + #30). ⚠️ **The "register an `appKey`" instruction above was wrong — no registration mechanism exists.** A read-only live probe found the segment is a free-form namespace: `GET /api/user/app-settings//devices` → `200 {"devices":[]}` for a key the server has never seen, and `OPTIONS` on the parent → `allow: DELETE, GET, HEAD, OPTIONS, PUT`. The client uses `interlinedlist-macos` (`AppEnvironment.appSettingsKey`); it must stay **stable**, since changing it orphans stored settings. **Also learned live:** 404 is the ordinary first-run state, not a failure — an app key with nothing stored 404s, and `bootstrap` returns `404 {"source":"none"}` for an unseen device; both now map to empty rather than throwing. **Still unverified:** the *populated* payload shapes, since nothing is stored yet — the DTOs stay tolerant. **G18 · Notification preferences — MEDIUM. Size S.** -`GET /api/user/notification-preferences` returns a typed event catalogue — `{"events":[{"key":"dig","label":"Digs on your messages","description":"…","channels":{"push":true,"inApp":true}}, …]}` — and `PATCH` writes it. Server-driven labels and descriptions mean the pane renders itself from the payload; drop it into Settings beside Preferences. Also the per-event `channels.push` flags are the switchboard [G9 push](#2b-spike-first-native-gaps) will need. +`GET /api/user/notification-preferences` returns a typed event catalogue — `{"events":[{"key":"dig","label":"Digs on your messages","description":"…","channels":{"push":true,"inApp":true}}, …]}` — and `PATCH` writes it. **✅ SHIPPED 2026-09-06** (PR #25) as Settings ▸ Notifications, and the shape is **live-verified**: 8 events, and their `channels` genuinely vary per event (`reply` offers only email; `follow` offers email+push but not inApp), so the pane renders only the channels the server sends — a fixed three-switch pane would show dead controls on six of the eight. Server-driven labels and descriptions mean the pane renders itself from the payload. Also the per-event `channels.push` flags are the switchboard [G9 push](#2b-spike-first-native-gaps) will need. **G19 · Active sessions & token revocation — MEDIUM. Size S. (Closes the client half of [P3-D](#p3-d-sessions-revocation).)** -`GET /api/user/sessions` is **Bearer-reachable** and returns `{"sessions":[{"id","deviceLabel","createdAt","lastUsedAt","isCurrent"}…]}`; `DELETE /api/user/sessions/{id}` revokes one. A Settings ▸ Security pane listing sessions with a Revoke action is a small, self-contained slice — and it is the honest complement to a never-expiring sync token. +`GET /api/user/sessions` is **Bearer-reachable** and returns `{"sessions":[{"id","deviceLabel","createdAt","lastUsedAt","isCurrent"}…]}`; `DELETE /api/user/sessions/{id}` revokes one. **✅ SHIPPED 2026-09-06** (PR #25) as Settings ▸ Security, shape **live-verified** (`{sessions:[{id, deviceLabel, createdAt, lastUsedAt, isCurrent}]}`). Revoking the *current* session signs this Mac out, so that row alone is gated behind a confirmation. It is the honest complement to a never-expiring sync token. **G20 · Tags: trending + autocomplete — MEDIUM. Size S.** -`GET /api/tags/trending` (verified: `{"tags":[{"tag","count","lastUsedAt"}…]}`) and `GET /api/tags/autocomplete` (prefix match on public messages). Feeds a composer tag-completion popover and a trending strip on the timeline. Pairs naturally with G15's "suggest tags" assistant. +`GET /api/tags/trending` (verified: `{"tags":[{"tag","count","lastUsedAt"}…]}`) and `GET /api/tags/autocomplete` (prefix match on public messages). **✅ SHIPPED 2026-09-06** (PR #25): a composer tag-completion popover and a trending strip on the timeline that reuses the existing tag filter. Both shapes **live-verified** — trending is `{tags:[{tag, count, lastUsedAt}]}` and autocomplete returns the wrapped `{tags:[…]}` form. Pairs naturally with G15's "suggest tags" assistant. **G21 · Link metadata / previews — MEDIUM. Size S–M.**