From b2b01ca644ee997286adda3d8b7236d74e22cc0d Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 08:28:03 +0200 Subject: [PATCH 1/3] feat(access): let an assistant in for up to a year where the box takes it The length choices are now 1, 7, 30 and 90 days and a year, and an offer starts at 30 days (a week for Sourceful), no longer at the longest asked. The app offers no more than the box's agent.list says it takes (longestMs, srcfl/ftw). A box from before that says nothing and takes 30 days for an assistant, so a newer app offers it 30 days at most, though FTW Cloud's offer asks for a year. Renewing follows the same rule. The demo box can play either kind of box; tests cover both. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/Access/GrantView.swift | 2 +- appleApp/FTW/Access/OfferFlowView.swift | 2 +- .../Sources/FTWDemo/SimulatedAccess.swift | 24 +++++++++-- .../Sources/FTWKit/Access/AccessModel.swift | 28 +++++++++--- .../Sources/FTWKit/Access/AccessWire.swift | 22 +++++++++- .../Sources/FTWKit/Access/AccessWords.swift | 4 +- .../Sources/FTWKit/Access/OfferFlow.swift | 17 ++++---- .../Tests/FTWKitTests/AccessFlowTests.swift | 38 ++++++++++++++++ .../FTWKit/Tests/FTWKitTests/OfferTests.swift | 43 ++++++++++++++++++- 9 files changed, 156 insertions(+), 24 deletions(-) diff --git a/appleApp/FTW/Access/GrantView.swift b/appleApp/FTW/Access/GrantView.swift index f2c0e050..03029464 100644 --- a/appleApp/FTW/Access/GrantView.swift +++ b/appleApp/FTW/Access/GrantView.swift @@ -35,7 +35,7 @@ struct GrantView: View { private func form(_ row: AgentRow) -> some View { let now = Date().timeIntervalSince1970 * 1000 let reads = (Contract.agentRoleScopes[row.role] ?? []).filter { !AccessWire.controlScopes.contains($0) } - let lengths = Grant.lengths(role: row.role) + let lengths = Grant.lengths(role: row.role, boxMs: access.boxLongestMs(row.role)) return List { Section { VStack(alignment: .leading, spacing: 6) { diff --git a/appleApp/FTW/Access/OfferFlowView.swift b/appleApp/FTW/Access/OfferFlowView.swift index a155aed9..d409c30a 100644 --- a/appleApp/FTW/Access/OfferFlowView.swift +++ b/appleApp/FTW/Access/OfferFlowView.swift @@ -171,7 +171,7 @@ struct OfferReview: View { Section { Picker(AccessWords.text("For"), selection: $flow.lengthMs) { - ForEach(flow.lengths, id: \.self) { Text(AccessWords.length($0)).tag($0) } + ForEach(flow.lengths(boxMs: access.boxLongestMs(offer.role)), id: \.self) { Text(AccessWords.length($0)).tag($0) } } .accessibilityIdentifier("offer.length") HomePick(app: app, flow: flow, offer: offer) diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift index f3901fee..a4ea37e3 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift @@ -17,6 +17,11 @@ public final class SimulatedAccess { /// deviceId → registered approval key. public private(set) var keys: [String: Bytes] = [:] public private(set) var audit: [AuditRow] = [] + /// The longest grant this box takes for each role, as its `agent.list` + /// says. Nil is a box from before `longestMs`: it says nothing and takes + /// 30 days for an assistant. + public var longestMs: [String: Int64]? = Dictionary(uniqueKeysWithValues: + [Contract.roleAgent, Contract.roleSupport, Contract.roleRelease].map { ($0, AccessWire.longestMs($0)) }) private var seen = Set() private var seq: UInt64 = 0 let nowMs: @MainActor () -> Double @@ -26,6 +31,10 @@ public final class SimulatedAccess { self.nowMs = nowMs } + func longest(_ role: String) -> Int64 { + longestMs?[role] ?? AccessWire.unstatedLongestMs(role) + } + /// A lived-in home: Claude for a week, Sourceful on a ticket, and Claude /// asking for a change of mode. public func seed() { @@ -78,11 +87,15 @@ public final class SimulatedAccess { let now = Int64(nowMs()) switch env.t { case AccessWire.msgList: - ok(.map([ + var list: [(String, CBOR)] = [ ("agents", .array(rows.map(Self.cbor))), ("requests", .array(requests.map(Self.cbor))), ("keys", .array(keys.keys.sorted().map { .map([("deviceId", .text($0)), ("setAtMs", .int(now))]) })), - ])) + ] + if let longestMs { + list.append(("longestMs", .map(longestMs.keys.sorted().map { CBOR.Entry(.text($0), .int(longestMs[$0] ?? 0)) }))) + } + ok(.map(list)) case AccessWire.msgAudit: let before = body["beforeSeq"]?.int64.map(UInt64.init) ?? .max let n = min(body["n"]?.int ?? 200, 200) @@ -113,8 +126,8 @@ public final class SimulatedAccess { add.sig = sig guard let msg = try? Approval.addBytes(signedFor, add) else { return refuse("E_UNKNOWN_OP", [("field", .text("order"))]) } if let code = check(pub, msg, sig, atMs: atMs, now: now) { return refuse(code.0, code.1) } - guard add.expiresAtMs > now, add.expiresAtMs <= now + AccessWire.longestMs(add.role) else { - return refuse("E_CAP_EXCEEDED", [("cap", .text("expiresAtMs")), ("max", .int(now + AccessWire.longestMs(add.role)))]) + guard add.expiresAtMs > now, add.expiresAtMs <= now + longest(add.role) else { + return refuse("E_CAP_EXCEEDED", [("cap", .text("expiresAtMs")), ("max", .int(now + longest(add.role)))]) } let id = String(Base64url.encode(key).prefix(8)) guard !rows.contains(where: { $0.id == id }) else { return refuse("E_UNKNOWN_OP", [("field", .text("key"))]) } @@ -138,6 +151,9 @@ public final class SimulatedAccess { expiresAtMs: body["expiresAtMs"]?.int64 ?? 0, atMs: atMs) guard let msg = try? Approval.widenBytes(signedFor, m, key: rows[i].key) else { return refuse("E_UNKNOWN_OP", [("field", .text("order"))]) } if let code = check(pub, msg, sig, atMs: atMs, now: now) { return refuse(code.0, code.1) } + guard m.expiresAtMs > now, m.expiresAtMs <= now + longest(rows[i].role) else { + return refuse("E_CAP_EXCEEDED", [("cap", .text("expiresAtMs")), ("max", .int(now + longest(rows[i].role)))]) + } rows[i].scopes = m.scopes rows[i].ops = m.ops rows[i].caps = m.caps diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift b/appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift index 363a7c7d..fca89d5c 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift @@ -85,20 +85,30 @@ public struct Grant: Equatable, Sendable { self.expiresAtMs = expiresAtMs } - /// The lengths an owner may pick, longest last, within the role's. - public static func lengths(role: String, offeredS: UInt64? = nil) -> [Int64] { + /// The lengths an owner may pick, longest last: within the role's, the + /// offer's and the box's. `boxMs` is what the box's `agent.list` says + /// for the role; nil is a box that says nothing, which takes 30 days + /// for an assistant. + public static func lengths(role: String, offeredS: UInt64? = nil, boxMs: Int64? = nil) -> [Int64] { let day: Int64 = 86_400_000 - var most = AccessWire.longestMs(role) + var most = min(AccessWire.longestMs(role), boxMs ?? AccessWire.unstatedLongestMs(role)) if let offeredS, offeredS > 0 { most = min(most, Int64(offeredS) * 1000) } - let picks = role == AccessWire.roleSupport ? [day, 3 * day, 7 * day, 14 * day] : [day, 7 * day, 30 * day] + let picks = role == AccessWire.roleSupport ? [day, 3 * day, 7 * day, 14 * day] : [day, 7 * day, 30 * day, 90 * day, 365 * day] let fit = picks.filter { $0 <= most } return fit.isEmpty ? [most] : fit } + /// The length an offer starts at: 30 days for an assistant and a week + /// for Sourceful, or the longest pick below that. + public static func startLength(role: String, in picks: [Int64]) -> Int64 { + let start: Int64 = (role == AccessWire.roleSupport ? 7 : 30) * 86_400_000 + return picks.last(where: { $0 <= start }) ?? picks.first ?? start + } + /// What the owner grants from an offer: what it reads, and control only /// when ticked. Never more than it asks, and never past the box's own /// limits. - public static func from(_ offer: GrantOffer, control: Bool, lengthMs: Int64) -> Grant { + public static func from(_ offer: GrantOffer, control: Bool, lengthMs: Int64, boxMs: Int64? = nil) -> Grant { let allowed = Set(Contract.agentRoleScopes[offer.role] ?? []) let asked = Set(offer.scopes).intersection(allowed) let scopes = control ? asked : asked.subtracting(AccessWire.controlScopes) @@ -108,7 +118,7 @@ public struct Grant: Equatable, Sendable { caps.holdS = min(offer.caps.holdS, AccessWire.holdSRange.upperBound) caps.maxW = min(offer.caps.maxW, AccessWire.maxWRange.upperBound) } - let longest = lengths(role: offer.role, offeredS: offer.ttlS).last ?? lengthMs + let longest = lengths(role: offer.role, offeredS: offer.ttlS, boxMs: boxMs).last ?? lengthMs return Grant(scopes: Array(scopes), ops: ops, caps: caps, lengthMs: min(lengthMs, longest)) } @@ -243,6 +253,12 @@ public final class AccessModel { /// id of its own. var keyHome: String? { site.siteId ?? (site.isDemo ? "demo" : nil) } + /// The longest grant the box says it takes for a role, once it has + /// answered `agent.list` this visit. Nil means it said nothing. + public func boxLongestMs(_ role: String) -> Int64? { + keysKnown ? list?.longestMs[role] : nil + } + // MARK: Reading public func refresh() async { diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/AccessWire.swift b/appleApp/FTWKit/Sources/FTWKit/Access/AccessWire.swift index ef0dba6d..680008c8 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Access/AccessWire.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Access/AccessWire.swift @@ -26,6 +26,17 @@ public enum AccessWire { switch role { case roleSupport: return 14 * 86_400_000 case roleRelease: return 90 * 86_400_000 + default: return 365 * 86_400_000 + } + } + + /// The longest grant a box takes when its `agent.list` names none. + /// Boxes before `longestMs` took 30 days for an assistant and refuse + /// more, so the app offers them no more (docs/agents.md in srcfl/ftw, + /// Mixed versions). + public static func unstatedLongestMs(_ role: String) -> Int64 { + switch role { + case roleSupport, roleRelease: return longestMs(role) default: return 30 * 86_400_000 } } @@ -253,17 +264,26 @@ public struct AgentList: Equatable, Sendable { public var agents: [AgentRow] public var requests: [AgentRequest] public var keys: [ApprovalKeyInfo] + /// The longest grant the box takes for each role, in ms. Empty from a + /// box before `longestMs`. + public var longestMs: [String: Int64] - public init(agents: [AgentRow] = [], requests: [AgentRequest] = [], keys: [ApprovalKeyInfo] = []) { + public init(agents: [AgentRow] = [], requests: [AgentRequest] = [], keys: [ApprovalKeyInfo] = [], longestMs: [String: Int64] = [:]) { self.agents = agents self.requests = requests self.keys = keys + self.longestMs = longestMs } init(_ c: CBOR) { agents = (c["agents"]?.array ?? []).compactMap(AgentRow.init) requests = (c["requests"]?.array ?? []).compactMap(AgentRequest.init) keys = (c["keys"]?.array ?? []).compactMap(ApprovalKeyInfo.init) + var longest: [String: Int64] = [:] + for e in c["longestMs"]?.entries ?? [] { + if let role = e.key.string, let ms = e.value.int64, ms > 0 { longest[role] = ms } + } + longestMs = longest } } diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift b/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift index d8053b3c..4c4aec05 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift @@ -249,9 +249,10 @@ public enum AccessWords { // MARK: Time - /// "1 day", "7 days", "30 days". + /// "1 day", "7 days", "30 days", "1 year". public static func length(_ ms: Int64) -> String { let days = Int((Double(ms) / 86_400_000).rounded()) + if days == 365 { return text("1 year") } return days == 1 ? text("1 day") : format("%@ days", String(days)) } @@ -496,6 +497,7 @@ public enum AccessWords { "Off: it reads and changes nothing.": "Av: den läser och ändrar ingenting.", "For": "Hur länge", "1 day": "1 dag", + "1 year": "1 år", "%@ days": "%@ dagar", "Until %@": "Till %@", "Ends %@": "Slutar %@", diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift b/appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift index c69d74a9..858910be 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift @@ -144,7 +144,7 @@ public final class OfferFlow { } guard offer == shown else { return refuse(.malformed) } step = .approving(offer) - if let row = await access.approve(offer, grant: grant(offer)) { + if let row = await access.approve(offer, grant: grant(offer, boxMs: access.boxLongestMs(offer.role))) { step = .done(row) onDone?(row) } else { @@ -152,14 +152,15 @@ public final class OfferFlow { } } - /// What the owner has chosen so far. - public func grant(_ offer: GrantOffer) -> Grant { - Grant.from(offer, control: control, lengthMs: lengthMs) + /// What the owner has chosen so far, within what the box takes. + public func grant(_ offer: GrantOffer, boxMs: Int64? = nil) -> Grant { + Grant.from(offer, control: control, lengthMs: lengthMs, boxMs: boxMs) } - public var lengths: [Int64] { + /// The lengths to pick from, within the offer and what the box takes. + public func lengths(boxMs: Int64?) -> [Int64] { guard let offer else { return [] } - return Grant.lengths(role: offer.role, offeredS: offer.ttlS) + return Grant.lengths(role: offer.role, offeredS: offer.ttlS, boxMs: boxMs) } func check(_ signed: SignedOffer, home: String?) throws -> GrantOffer { @@ -171,9 +172,7 @@ public final class OfferFlow { } private func review(_ offer: GrantOffer) { - let picks = Grant.lengths(role: offer.role, offeredS: offer.ttlS) - lengthMs = picks.last ?? lengthMs - if offer.isSupport, let week = picks.last(where: { $0 <= 7 * 86_400_000 }) { lengthMs = week } + lengthMs = Grant.startLength(role: offer.role, in: Grant.lengths(role: offer.role, offeredS: offer.ttlS)) control = false trustsUnknown = false step = .review(offer) diff --git a/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift index 0e775fd3..1938185c 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift @@ -54,6 +54,44 @@ import FTWDemo return w } + /// A box that takes a year says so, and the owner may grant it. + @Test func aBoxThatTakesAYearIsOfferedAYear() async throws { + let w = await world() + let year: Int64 = 365 * 86_400_000 + await w.during { await w.access.refresh() } + #expect(w.access.boxLongestMs(Contract.roleAgent) == year) + let offer = try w.offer() + let boxMs = w.access.boxLongestMs(offer.role) + #expect(Grant.lengths(role: offer.role, offeredS: offer.ttlS, boxMs: boxMs).last == year) + let row = await w.during { await w.access.approve(offer, grant: Grant.from(offer, control: false, lengthMs: year, boxMs: boxMs)) } + let added = try #require(row) + #expect(added.expiresAtMs > Int64(w.scheduler.nowMs) + year - 86_400_000) + } + + /// A box from before longestMs says nothing and takes 30 days. The app + /// offers it no more, though the offer asks for a year, so the grant goes + /// through; a year would have been refused. + @Test func anOlderBoxIsOfferedNoMoreThan30Days() async throws { + let w = await world() + w.box.access.longestMs = nil + let day: Int64 = 86_400_000 + await w.during { await w.access.refresh() } + #expect(w.access.boxLongestMs(Contract.roleAgent) == nil) + let offer = try w.offer() + #expect(offer.ttlS == 365 * 86_400) + let boxMs = w.access.boxLongestMs(offer.role) + #expect(Grant.lengths(role: offer.role, offeredS: offer.ttlS, boxMs: boxMs).last == 30 * day) + let grant = Grant.from(offer, control: false, lengthMs: 365 * day, boxMs: boxMs) + #expect(grant.lengthMs == 30 * day) + #expect(await w.during { await w.access.approve(offer, grant: grant) } != nil) + + var tooLong = grant + tooLong.lengthMs = 365 * day + let second = try w.offer() + #expect(await w.during { await w.access.approve(second, grant: tooLong) } == nil) + #expect(w.access.help != nil) + } + @Test func theBoxSaysWhoHasAccess() async { let w = await world() #expect(w.access.available) diff --git a/appleApp/FTWKit/Tests/FTWKitTests/OfferTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/OfferTests.swift index b2c1cd27..eacff035 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/OfferTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/OfferTests.swift @@ -171,7 +171,7 @@ import Testing #expect(o.client == "Claude") #expect(flow.typed.isEmpty) #expect(flow.pageCode == "K7QF-2MXD") - // Control is off until ticked, and the length is the longest asked. + // Control is off until ticked, and the length starts at 30 days. #expect(!flow.control) #expect(flow.lengthMs == 30 * 86_400_000) // A code typed into a link's flow changes nothing. @@ -368,6 +368,47 @@ import Testing #expect(capped.caps == Caps(holdS: 1_800, maxW: 100_000, perMin: 60)) } + /// An assistant may have up to a year, where the offer and the box allow + /// it. A box that says nothing takes 30 days, so it is offered no more, + /// whatever the offer asks. + @Test func aYearOnlyWhereTheBoxSaysItTakesOne() { + let day: Int64 = 86_400_000 + let year = 365 * day + var o = offer() + o.ttlS = 365 * 86_400 + #expect(Grant.lengths(role: Contract.roleAgent, offeredS: o.ttlS, boxMs: year) == [day, 7 * day, 30 * day, 90 * day, year]) + #expect(Grant.lengths(role: Contract.roleAgent, offeredS: o.ttlS, boxMs: nil) == [day, 7 * day, 30 * day]) + #expect(Grant.lengths(role: Contract.roleAgent, offeredS: 30 * 86_400, boxMs: year).last == 30 * day) + #expect(Grant.from(o, control: false, lengthMs: year, boxMs: year).lengthMs == year) + #expect(Grant.from(o, control: false, lengthMs: year, boxMs: nil).lengthMs == 30 * day) + // Never past the app's own year, whatever a box says. + #expect(Grant.lengths(role: Contract.roleAgent, offeredS: 0, boxMs: 2 * year).last == year) + // Support stays at 14 days; the box saying nothing changes nothing. + #expect(Grant.lengths(role: Contract.roleSupport, boxMs: nil).last == 14 * day) + #expect(AccessWords.length(year) == AccessWords.text("1 year")) + } + + /// The offer asks for a year; the owner starts at 30 days, Sourceful at a + /// week. + @Test func anOfferStartsAt30Days() { + let day: Int64 = 86_400_000 + #expect(Grant.startLength(role: Contract.roleAgent, in: [day, 7 * day, 30 * day, 90 * day, 365 * day]) == 30 * day) + #expect(Grant.startLength(role: Contract.roleAgent, in: [day, 7 * day]) == 7 * day) + #expect(Grant.startLength(role: Contract.roleSupport, in: [day, 3 * day, 7 * day, 14 * day]) == 7 * day) + } + + /// agent.list's longestMs, read as the box sent it; a box from before it + /// sends none. + @Test func theBoxSaysHowLongItTakes() { + let year: Int64 = 365 * 86_400_000 + let newer = AgentList(CBOR.map([("agents", .array([])), ("longestMs", .map([ + CBOR.Entry(.text(Contract.roleAgent), .int(year)), CBOR.Entry(.text(Contract.roleSupport), .int(14 * 86_400_000)), + ]))])) + #expect(newer.longestMs == [Contract.roleAgent: year, Contract.roleSupport: 14 * 86_400_000]) + let older = AgentList(CBOR.map([("agents", .array([])), ("requests", .array([])), ("keys", .array([]))])) + #expect(older.longestMs.isEmpty) + } + // MARK: Helpers func offer() -> GrantOffer { From 05df5d1fc2e42637f8df589e186da90f991a464e Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 10:37:21 +0200 Subject: [PATCH 2/3] fix(access): a far end shows its year Review of srcfl/ftw-app#82: a year granted on 17 Oct read "Until 17 Oct", which looks like today. "Until" and "Ends" now add the year when the end is in another calendar year or more than about five months off. Co-Authored-By: Claude Opus 5.5 --- .../Sources/FTWKit/Access/AccessWords.swift | 16 +++++++++++----- .../FTWKit/Tests/FTWKitTests/OfferTests.swift | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift b/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift index 4c4aec05..35f1533f 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift @@ -260,14 +260,20 @@ public enum AccessWords { public static func until(_ ms: Int64, nowMs: Double, zone: TimeZone = .current) -> String { if Double(ms) <= nowMs { return text("Ended") } if Double(ms) - nowMs < 86_400_000 { return format("Ends %@", AppWords.day(Double(ms), nowMs: nowMs, zone: zone)) } - return format("Until %@", dayOnly(ms, zone: zone)) + return format("Until %@", dayOnly(ms, nowMs: nowMs, zone: zone)) } - static func dayOnly(_ ms: Int64, zone: TimeZone) -> String { + /// "15 Nov", or "17 Oct 2027" when the end is in another year or more + /// than about five months off, so a year-long grant never reads as today. + static func dayOnly(_ ms: Int64, nowMs: Double, zone: TimeZone) -> String { var cal = Calendar(identifier: .gregorian) cal.timeZone = zone - let c = cal.dateComponents([.day, .month], from: Date(timeIntervalSince1970: Double(ms) / 1000)) - return "\(c.day ?? 1) \(AppWords.text(AppWords.monthLabels[((c.month ?? 1) + 11) % 12]))" + let c = cal.dateComponents([.day, .month, .year], from: Date(timeIntervalSince1970: Double(ms) / 1000)) + let day = "\(c.day ?? 1) \(AppWords.text(AppWords.monthLabels[((c.month ?? 1) + 11) % 12]))" + let thisYear = cal.component(.year, from: Date(timeIntervalSince1970: nowMs / 1000)) + let far = Double(ms) - nowMs > 150 * 86_400_000 + guard let year = c.year, far || year != thisYear else { return day } + return "\(day) \(year)" } /// "Never used" or "Last used today 14:03". @@ -279,7 +285,7 @@ public enum AccessWords { public static func ends(_ ms: Int64, nowMs: Double, zone: TimeZone = .current) -> String { if Double(ms) <= nowMs { return text("Ended") } if Double(ms) - nowMs < 86_400_000 { return AppWords.day(Double(ms), nowMs: nowMs, zone: zone) } - return dayOnly(ms, zone: zone) + return dayOnly(ms, nowMs: nowMs, zone: zone) } /// The last call as a value under "Last used": "today 08:12" or "Never". diff --git a/appleApp/FTWKit/Tests/FTWKitTests/OfferTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/OfferTests.swift index eacff035..43f25578 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/OfferTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/OfferTests.swift @@ -397,6 +397,24 @@ import Testing #expect(Grant.startLength(role: Contract.roleSupport, in: [day, 3 * day, 7 * day, 14 * day]) == 7 * day) } + /// A year-long grant shows its year, so it never reads as ending today; + /// a near end in the same year needs none. + @Test func aFarEndShowsItsYear() { + let zone = TimeZone(identifier: "Europe/Stockholm")! + let now = 1_792_224_000_000.0 // 17 Oct 2026, 10:00 in Stockholm + let day: Int64 = 86_400_000 + let oct17 = AccessWords.dayOnly(Int64(now), nowMs: now, zone: zone) + #expect(AccessWords.until(Int64(now) + 365 * day, nowMs: now, zone: zone) == AccessWords.format("Until %@", oct17 + " 2027")) + #expect(AccessWords.ends(Int64(now) + 365 * day, nowMs: now, zone: zone) == oct17 + " 2027") + #expect(!AccessWords.ends(Int64(now) + 30 * day, nowMs: now, zone: zone).contains("2026")) + // Another calendar year, though near. + #expect(AccessWords.ends(Int64(now) + 80 * day, nowMs: now, zone: zone).hasSuffix(" 2027")) + // More than five months off in the same year. + let jan2 = 1_767_348_000_000.0 // 2 Jan 2026 + #expect(AccessWords.ends(Int64(jan2) + 200 * day, nowMs: jan2, zone: zone).hasSuffix(" 2026")) + #expect(!AccessWords.ends(Int64(jan2) + 100 * day, nowMs: jan2, zone: zone).contains("2026")) + } + /// agent.list's longestMs, read as the box sent it; a box from before it /// sends none. @Test func theBoxSaysHowLongItTakes() { From 52e77f96225f5030fc6f2131b64b67ee54f95da7 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 10:55:51 +0200 Subject: [PATCH 3/3] fix(access): the offer page reads the box, so a link can grant a year Review of srcfl/ftw-app#82: boxLongestMs is nil until agent.list has been read this visit, and a link opens the offer page with nothing else having read it. On that main path the picker and the grant stayed at 30 days. The offer page now reads agent.list when it appears, which also makes the "already has access" check in AccessModel.approve see the box's rows. OfferFlow.approve reads it first if the page has not, so a length chosen before the list arrived is not cut. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/Access/OfferFlowView.swift | 4 ++ .../Sources/FTWKit/Access/OfferFlow.swift | 2 + .../Tests/FTWKitTests/AccessFlowTests.swift | 37 +++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/appleApp/FTW/Access/OfferFlowView.swift b/appleApp/FTW/Access/OfferFlowView.swift index d409c30a..13df331e 100644 --- a/appleApp/FTW/Access/OfferFlowView.swift +++ b/appleApp/FTW/Access/OfferFlowView.swift @@ -193,6 +193,10 @@ struct OfferReview: View { .background(Theme.surface) .accessibilityIdentifier("offer.approve") } + // The box's own list, read this visit: how long it lets an + // assistant stay, and whether this key already has access. A link + // opens this page with nothing else having asked. + .task { await access.refresh() } } } diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift b/appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift index 858910be..73eac381 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift @@ -144,6 +144,8 @@ public final class OfferFlow { } guard offer == shown else { return refuse(.malformed) } step = .approving(offer) + // The box's word on how long it takes, if the page has not read it. + if !access.keysKnown { await access.refresh() } if let row = await access.approve(offer, grant: grant(offer, boxMs: access.boxLongestMs(offer.role))) { step = .done(row) onDone?(row) diff --git a/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift index 1938185c..725cde88 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift @@ -68,6 +68,43 @@ import FTWDemo #expect(added.expiresAtMs > Int64(w.scheduler.nowMs) + year - 86_400_000) } + /// A consent page's link opens the offer with nothing else having read + /// the box. The page reads it, so a box that takes a year is offered a + /// year, and the grant goes through for a year. + @Test func aLinkToABoxThatTakesAYearOffersAYear() async throws { + let w = await world(seed: false) + let day: Int64 = 86_400_000 + let link = OfferLink(request: "r1", offer: SimulatedAccess.offer(nowMs: w.scheduler.nowMs).base64url) + let flow = OfferFlow(link: link, lookup: OfferLookup(base: URL(string: "https://cloud.test")!) { _ in (Data(), 500) }, + keys: SimulatedAccess.offerKeys, wallMs: { [s = w.scheduler] in s.nowMs }) + await flow.start(home: nil) + guard case .review(let offer) = flow.step else { Issue.record("no review: \(flow.step)"); return } + #expect(flow.lengths(boxMs: w.access.boxLongestMs(offer.role)).last == 30 * day) + // What the offer page does when it appears. + await w.during { await w.access.refresh() } + #expect(flow.lengths(boxMs: w.access.boxLongestMs(offer.role)).last == 365 * day) + flow.lengthMs = 365 * day + await w.during { await flow.approve(w.access, home: nil) } + guard case .done(let row) = flow.step else { Issue.record("not granted: \(flow.step) \(w.access.help ?? "")"); return } + #expect(row.expiresAtMs > Int64(w.scheduler.nowMs) + 364 * day) + } + + /// Approving reads the box first when the page has not: a year chosen + /// before the list arrived is kept, not cut to 30 days. + @Test func approvingReadsTheBoxFirst() async throws { + let w = await world(seed: false) + let day: Int64 = 86_400_000 + let link = OfferLink(request: "r1", offer: SimulatedAccess.offer(nowMs: w.scheduler.nowMs).base64url) + let flow = OfferFlow(link: link, lookup: OfferLookup(base: URL(string: "https://cloud.test")!) { _ in (Data(), 500) }, + keys: SimulatedAccess.offerKeys, wallMs: { [s = w.scheduler] in s.nowMs }) + await flow.start(home: nil) + #expect(!w.access.keysKnown) + flow.lengthMs = 365 * day + await w.during { await flow.approve(w.access, home: nil) } + guard case .done(let row) = flow.step else { Issue.record("not granted: \(flow.step) \(w.access.help ?? "")"); return } + #expect(row.expiresAtMs > Int64(w.scheduler.nowMs) + 364 * day) + } + /// A box from before longestMs says nothing and takes 30 days. The app /// offers it no more, though the offer asks for a year, so the grant goes /// through; a year would have been refused.