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..13df331e 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) @@ -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/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..35f1533f 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)) } @@ -259,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". @@ -278,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". @@ -496,6 +503,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..73eac381 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift @@ -144,7 +144,9 @@ public final class OfferFlow { } guard offer == shown else { return refuse(.malformed) } step = .approving(offer) - if let row = await access.approve(offer, grant: grant(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) } else { @@ -152,14 +154,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 +174,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..725cde88 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift @@ -54,6 +54,81 @@ 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 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. + @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..43f25578 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,65 @@ 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) + } + + /// 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() { + 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 {