Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 17 additions & 12 deletions App/Composition/AppEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/<any-key>/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:
///
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<AppSettingsDTO>
) 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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
8 changes: 4 additions & 4 deletions work-consolidation.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,19 +120,19 @@ Live and available to the test account: `GET /api/ai/status` returns `{"subscrib

<a id="g17-app-settings"></a>
**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/<invented-key>/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.

<a id="g18-notification-preferences"></a>
**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.

<a id="g19-sessions"></a>
**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.

<a id="g20-tags"></a>
**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.

<a id="g21-link-metadata"></a>
**G21 · Link metadata / previews — MEDIUM. Size S–M.**
Expand Down