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
2 changes: 1 addition & 1 deletion appleApp/FTW/Access/GrantView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
6 changes: 5 additions & 1 deletion appleApp/FTW/Access/OfferFlowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Refresh box limits before capping the offer

When a consent link opens directly from RootView before Settings/Access has been visited, keysKnown is still false because OfferFlowView starts the offer without calling access.refresh(). Consequently boxLongestMs returns nil here, which is interpreted as an old box's 30-day limit, so the picker hides the 90-day and one-year choices even for a capable box. Approval also constructs the capped grant before AccessModel.signed performs its refresh, so the direct-link flow cannot grant more than 30 days; load agent.list before rendering or approving the offer.

Useful? React with 👍 / 👎.

}
.accessibilityIdentifier("offer.length")
HomePick(app: app, flow: flow, offer: offer)
Expand All @@ -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() }
}
}

Expand Down
24 changes: 20 additions & 4 deletions appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Bytes>()
private var seq: UInt64 = 0
let nowMs: @MainActor () -> Double
Expand All @@ -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() {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"))]) }
Expand All @@ -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
Expand Down
28 changes: 22 additions & 6 deletions appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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))
}

Expand Down Expand Up @@ -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 {
Expand Down
22 changes: 21 additions & 1 deletion appleApp/FTWKit/Sources/FTWKit/Access/AccessWire.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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
}
}

Expand Down
20 changes: 14 additions & 6 deletions appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift
Original file line number Diff line number Diff line change
Expand Up @@ -249,24 +249,31 @@ 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))
}

/// "Until 15 Nov", or "Ends today 18:00" when it ends within a day.
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".
Expand All @@ -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".
Expand Down Expand Up @@ -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 %@",
Expand Down
19 changes: 10 additions & 9 deletions appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -144,22 +144,25 @@ 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 {
step = .review(offer)
}
}

/// 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 {
Expand All @@ -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)
Expand Down
75 changes: 75 additions & 0 deletions appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading