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
23 changes: 18 additions & 5 deletions appleApp/FTW/Access/GrantView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ struct GrantView: View {
let id: String
@State private var scopes: Set<String> = []
@State private var control = false
@State private var setup = false
@State private var lengthMs: Int64 = 0
@State private var loaded = false
@Environment(\.dismiss) private var dismiss
Expand All @@ -26,15 +27,16 @@ struct GrantView: View {
}

private func load(_ row: AgentRow) {
scopes = Set(row.scopes).subtracting(AccessWire.controlScopes)
scopes = Set(row.scopes).subtracting(AccessWire.controlScopes).subtracting([AccessWire.scopeSetup])
control = row.hasControl
setup = row.scopes.contains(AccessWire.scopeSetup)
lengthMs = 0
loaded = true
}

private func form(_ row: AgentRow) -> some View {
let now = Date().timeIntervalSince1970 * 1000
let reads = (Contract.agentRoleScopes[row.role] ?? []).filter { !AccessWire.controlScopes.contains($0) }
let reads = (Contract.agentRoleScopes[row.role] ?? []).filter { !AccessWire.controlScopes.contains($0) && $0 != AccessWire.scopeSetup }
let lengths = Grant.lengths(role: row.role, boxMs: access.boxLongestMs(row.role))
return List {
Section {
Expand Down Expand Up @@ -73,6 +75,16 @@ struct GrantView: View {
}
}

if row.role != Contract.roleRelease {
Section {
Toggle(AccessWords.text("Allow setup"), isOn: $setup)
.tint(Theme.live)
.accessibilityIdentifier("grant.setup")
} footer: {
Text(AccessWords.text("Off until you choose it. This includes equipment names and connection settings. Review and confirm the exact draft before it saves, and confirm any restart separately. Equipment settings can affect operation; the box keeps its safety checks."))
}
}

if row.role != Contract.roleRelease {
let canTurnOn = row.hasControl || !(access.asked(row)?.askedOps.isEmpty ?? true)
Section {
Expand All @@ -84,7 +96,7 @@ struct GrantView: View {
if !canTurnOn {
Text(AccessWords.text("Add it again from its page to let it control."))
} else {
Text(control ? AccessWords.control(g.caps, scopes: g.scopes) : AccessWords.text("Off: it reads and changes nothing."))
Text(control ? AccessWords.control(g.caps, scopes: g.scopes) : AccessWords.text(setup ? "Short control actions are off." : "Off: it reads and changes nothing."))
}
}
if control {
Expand Down Expand Up @@ -132,7 +144,7 @@ struct GrantView: View {
}
}
.buttonStyle(FTWButtonStyle(kind: .primary))
.disabled(scopes.isEmpty || access.working != nil)
.disabled(grant(row).scopes.isEmpty || access.working != nil)
.padding(16)
.background(Theme.surface)
.transition(.move(edge: .bottom).combined(with: .opacity))
Expand All @@ -147,6 +159,7 @@ struct GrantView: View {
/// phone approved it, never more; control off drops both.
private func grant(_ row: AgentRow) -> Grant {
var all = scopes
if setup { all.insert(AccessWire.scopeSetup) }
var ops = row.ops
var caps = row.caps
if control, row.hasControl {
Expand All @@ -169,6 +182,6 @@ struct GrantView: View {
}

private func changed(_ row: AgentRow) -> Bool {
scopes != Set(row.scopes).subtracting(AccessWire.controlScopes) || control != row.hasControl || lengthMs != 0
scopes != Set(row.scopes).subtracting(AccessWire.controlScopes).subtracting([AccessWire.scopeSetup]) || setup != row.scopes.contains(AccessWire.scopeSetup) || control != row.hasControl || lengthMs != 0
}
}
14 changes: 12 additions & 2 deletions appleApp/FTW/Access/OfferFlowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -145,19 +145,29 @@ struct OfferReview: View {
}

Section(AccessWords.text("It may")) {
ForEach(AccessWords.ordered(grant.scopes.filter { !AccessWire.controlScopes.contains($0) }), id: \.self) { scope in
ForEach(AccessWords.ordered(grant.scopes.filter { !AccessWire.controlScopes.contains($0) && $0 != AccessWire.scopeSetup }), id: \.self) { scope in
Label(AccessWords.scope(scope), systemImage: "eye")
}
}

if offer.scopes.contains(AccessWire.scopeSetup) {
Section {
Toggle(AccessWords.text("Allow setup"), isOn: $flow.setup)
.tint(Theme.live)
.accessibilityIdentifier("offer.setup")
} footer: {
Text(AccessWords.text("Off until you choose it. This includes equipment names and connection settings. Review and confirm the exact draft before it saves, and confirm any restart separately. Equipment settings can affect operation; the box keeps its safety checks."))
}
}

if offer.asksControl {
Section {
Toggle(AccessWords.text("Also let it control"), isOn: $flow.control)
.tint(Theme.live)
.accessibilityIdentifier("offer.control")
} footer: {
let asked = Grant.from(offer, control: true, lengthMs: flow.lengthMs)
Text(flow.control ? AccessWords.control(asked.caps, scopes: asked.scopes) : AccessWords.text("Off: it reads and changes nothing."))
Text(flow.control ? AccessWords.control(asked.caps, scopes: asked.scopes) : AccessWords.text(flow.setup ? "Short control actions are off." : "Off: it reads and changes nothing."))
}
if flow.control {
Section(AccessWords.text("With control it may")) {
Expand Down
4 changes: 2 additions & 2 deletions appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ public final class SimulatedAccess {
o.clientId = AccessWire.supportClientID
o.clientHost = "cloud.ftw.energy"
o.verified = true
o.scopes = ["ftw.config.read", "ftw.diag.run", "ftw.dispatch.hold", "ftw.events.read", "ftw.goals.request", "ftw.health.read", "ftw.history.read", "ftw.live.read", "ftw.logs.read", "ftw.plan.read"]
o.scopes = [AccessWire.scopeSetup, "ftw.config.read", "ftw.diag.run", "ftw.dispatch.hold", "ftw.events.read", "ftw.goals.request", "ftw.health.read", "ftw.history.read", "ftw.live.read", "ftw.logs.read", "ftw.plan.read"]
o.ticket = ticket
o.ttlS = UInt64(AccessWire.longestMs(Contract.roleSupport) / 1000)
} else {
Expand All @@ -256,7 +256,7 @@ public final class SimulatedAccess {
o.clientId = "https://claude.ai/oauth/mcp-oauth-client-metadata"
o.clientHost = "claude.ai"
o.verified = true
o.scopes = ["ftw.dispatch.hold", "ftw.events.read", "ftw.goals.request", "ftw.health.read", "ftw.history.read", "ftw.live.read", "ftw.plan.read"]
o.scopes = [AccessWire.scopeSetup, "ftw.config.read", "ftw.diag.run", "ftw.dispatch.hold", "ftw.events.read", "ftw.goals.request", "ftw.health.read", "ftw.history.read", "ftw.live.read", "ftw.plan.read"]
o.ttlS = UInt64(AccessWire.longestMs(Contract.roleAgent) / 1000)
}
o.ops = ["car.goal.set", "loadpoint.hold", "planner.prefs.set", "site.mode.set"]
Expand Down
5 changes: 3 additions & 2 deletions appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,11 @@ public struct Grant: Equatable, Sendable {
/// 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, boxMs: Int64? = nil) -> Grant {
public static func from(_ offer: GrantOffer, control: Bool, lengthMs: Int64, boxMs: Int64? = nil, setup: Bool = false) -> Grant {
let allowed = Set(Contract.agentRoleScopes[offer.role] ?? [])
let asked = Set(offer.scopes).intersection(allowed)
let scopes = control ? asked : asked.subtracting(AccessWire.controlScopes)
var scopes = (control ? asked : asked.subtracting(AccessWire.controlScopes)).subtracting([AccessWire.scopeSetup])
if setup, asked.contains(AccessWire.scopeSetup) { scopes.insert(AccessWire.scopeSetup) }
let ops = offer.ops.filter { op in Contract.agentOps[op].map(scopes.contains) ?? false }
var caps = Caps(perMin: min(offer.caps.perMin, 60))
if scopes.contains(AccessWire.scopeHold) {
Expand Down
1 change: 1 addition & 0 deletions appleApp/FTWKit/Sources/FTWKit/Access/AccessWire.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public enum AccessWire {
}

/// The scopes an owner ticks "control" to add.
public static let scopeSetup = "ftw.setup.write"
public static let scopeHold = "ftw.dispatch.hold"
public static let scopeRequest = "ftw.goals.request"
public static let controlScopes: Set<String> = [scopeHold, scopeRequest]
Expand Down
31 changes: 28 additions & 3 deletions appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@ public enum AccessWords {
"ftw.events.read": "See events",
"ftw.logs.read": "Read the box's log, with names taken out",
"ftw.config.read": "Read the settings, with names and secrets taken out",
"ftw.diag.run": "Run checks that change nothing",
"ftw.diag.run": "Read full diagnostics, source, logs and backups, which can contain saved credentials",
"ftw.setup.write": "Save confirmed setup and restart to apply it when you confirm",
"ftw.dispatch.hold": "Charge the car for a short while",
"ftw.goals.request": "Ask you for lasting changes",
]
Expand All @@ -105,9 +106,21 @@ public enum AccessWords {
return scopes.sorted { (order.firstIndex(of: $0) ?? 99) < (order.firstIndex(of: $1) ?? 99) }
}

/// One line for a grant: "Reads" or "Reads and controls".
/// One line for the rights this grant actually holds.
public static func summary(_ scopes: [String]) -> String {
AccessWire.controlScopes.isDisjoint(with: scopes) ? text("Reads") : text("Reads and controls")
let setup = scopes.contains(AccessWire.scopeSetup)
let control = !AccessWire.controlScopes.isDisjoint(with: scopes)
let reads = scopes.contains { $0 != AccessWire.scopeSetup && !AccessWire.controlScopes.contains($0) }
switch (reads, setup, control) {
case (true, true, true): return text("Reads, setup and control")
case (true, true, false): return text("Reads and setup")
case (true, false, true): return text("Reads and controls")
case (true, false, false): return text("Reads")
case (false, true, true): return text("Configures and controls")
case (false, true, false): return text("Configures")
case (false, false, true): return text("Controls")
case (false, false, false): return text("No access")
}
}

/// What control means for these caps: how long and how hard it may
Expand Down Expand Up @@ -486,6 +499,18 @@ public enum AccessWords {
"May": "Får",
"Reads": "Läser",
"Reads and controls": "Läser och styr",
"Configures and controls": "Konfigurerar och styr",
"Configures": "Konfigurerar",
"Controls": "Styr",
"No access": "Ingen åtkomst",
"Reads and setup": "Läser och konfigurerar",
"Reads, setup and control": "Läser, konfigurerar och styr",
"Read full diagnostics, source, logs and backups, which can contain saved credentials": "Läsa full diagnostik, källkod, loggar och säkerhetskopior som kan innehålla sparade inloggningsuppgifter",
"Save confirmed setup and restart to apply it when you confirm": "Spara bekräftad konfiguration och starta om när du godkänner det",
"Allow setup": "Tillåt konfigurering",
"Off until you choose it. This includes equipment names and connection settings. Review and confirm the exact draft before it saves, and confirm any restart separately. Equipment settings can affect operation; the box keeps its safety checks.": "Av tills du väljer det. Det omfattar utrustningens namn och anslutningsinställningar. Granska och bekräfta det exakta utkastet före sparning och godkänn varje omstart för sig. Utrustningens inställningar kan påverka driften; boxens säkerhetskontroller gäller.",
"Short control actions are off.": "Tillfälliga styråtgärder är avstängda.",

"Control": "Styra",
"Also let it control": "Låt den också styra",
"See live readings": "Se mätvärden just nu",
Expand Down
5 changes: 4 additions & 1 deletion appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ public final class OfferFlow {
public var typed = ""
/// Control is a separate switch, off until ticked.
public var control = false
/// Setup needs its own explicit choice, even when control is on.
public var setup = false
/// FTW Cloud does not know this client: the owner says they started
/// this and trust its address before Face ID.
public var trustsUnknown = false
Expand Down Expand Up @@ -156,7 +158,7 @@ public final class OfferFlow {

/// 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)
Grant.from(offer, control: control, lengthMs: lengthMs, boxMs: boxMs, setup: setup)
}

/// The lengths to pick from, within the offer and what the box takes.
Expand All @@ -176,6 +178,7 @@ public final class OfferFlow {
private func review(_ offer: GrantOffer) {
lengthMs = Grant.startLength(role: offer.role, in: Grant.lengths(role: offer.role, offeredS: offer.ttlS))
control = false
setup = false
trustsUnknown = false
step = .review(offer)
}
Expand Down
4 changes: 3 additions & 1 deletion appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public enum Contract {
"ftw.logs.read",
"ftw.config.read",
"ftw.diag.run",
"ftw.setup.write",
"ftw.dispatch.hold",
"ftw.goals.request",
"ftw.update.request",
Expand Down Expand Up @@ -65,7 +66,7 @@ public enum Contract {
]
static let agentScopes = [
"ftw.live.read", "ftw.history.read", "ftw.plan.read", "ftw.health.read", "ftw.events.read",
"ftw.logs.read", "ftw.config.read", "ftw.diag.run", "ftw.dispatch.hold", "ftw.goals.request",
"ftw.logs.read", "ftw.config.read", "ftw.diag.run", "ftw.setup.write", "ftw.dispatch.hold", "ftw.goals.request",
]

/// Each scope's description, from the registry. Words for an owner come
Expand All @@ -84,6 +85,7 @@ public enum Contract {
"ftw.logs.read": "Log excerpts the box redacts",
"ftw.config.read": "Configuration the box redacts",
"ftw.diag.run": "Full technical diagnostics and read API",
"ftw.setup.write": "Save confirmed system setup and restart to apply it after user confirmation",
"ftw.dispatch.hold": "Hold energy assets for a capped time",
"ftw.goals.request": "Ask an owner to change a durable goal",
"ftw.update.request": "Ask an owner to install a version",
Expand Down
20 changes: 19 additions & 1 deletion appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,8 @@ import FTWDemo
#expect(w.keyring.unlocks == 1)
#expect(w.box.access.keys[AccessModel.demoFor.deviceID] != nil)
#expect(w.access.boxHasKey)
#expect(added.scopes == ["ftw.events.read", "ftw.health.read", "ftw.history.read", "ftw.live.read", "ftw.plan.read"])
#expect(added.scopes == ["ftw.config.read", "ftw.diag.run", "ftw.events.read", "ftw.health.read", "ftw.history.read", "ftw.live.read", "ftw.plan.read"])
#expect(!added.scopes.contains(AccessWire.scopeSetup))
#expect(added.ops.isEmpty)
#expect(added.principal.clientId == "https://claude.ai/oauth/mcp-oauth-client-metadata")
// Within the role's longest, as the box holds it.
Expand Down Expand Up @@ -604,6 +605,23 @@ import FTWDemo
}

/// Every word an owner reads has its Swedish.
@Test func setupNeedsItsOwnChoiceEvenWithControl() {
var offer = GrantOffer()
offer.role = Contract.roleAgent
offer.scopes = ["ftw.config.read", AccessWire.scopeHold, AccessWire.scopeSetup]
offer.ttlS = 30 * 86_400
offer.caps = Caps(holdS: 300, maxW: 3000, perMin: 6)
let reading = Grant.from(offer, control: false, lengthMs: 86_400_000)
let controlling = Grant.from(offer, control: true, lengthMs: 86_400_000)
#expect(!reading.scopes.contains(AccessWire.scopeSetup))
#expect(!controlling.scopes.contains(AccessWire.scopeSetup))
let setup = Grant.from(offer, control: false, lengthMs: 86_400_000, setup: true)
#expect(setup.scopes.contains(AccessWire.scopeSetup))
#expect(!setup.scopes.contains(AccessWire.scopeHold))
offer.scopes.removeAll { $0 == AccessWire.scopeSetup }
#expect(!Grant.from(offer, control: false, lengthMs: 86_400_000, setup: true).scopes.contains(AccessWire.scopeSetup))
}

@Test func everyWordHasItsSwedish() {
for scope in Contract.agentRoleScopes[Contract.roleAgent]! {
#expect(AccessWords.sv[AccessWords.scopeWords[scope] ?? ""] != nil, "\(scope)")
Expand Down
19 changes: 19 additions & 0 deletions appleApp/FTWKit/Tests/FTWKitTests/OfferTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,25 @@ import Testing
v = try ApprovalVectors.load().offer
}

@Test func grantSummaryNamesOnlyItsRights() {
let read = "ftw.live.read"
let setup = AccessWire.scopeSetup
let control = AccessWire.scopeHold
let cases: [([String], String)] = [
([], "No access"), ([setup], "Configures"), ([control], "Controls"),
([setup, control], "Configures and controls"), ([read], "Reads"),
([read, setup], "Reads and setup"), ([read, control], "Reads and controls"),
([read, setup, control], "Reads, setup and control"),
]
for (scopes, expected) in cases {
#expect(AccessWords.summary(scopes) == AccessWords.text(expected))
}
#expect(AccessWords.sv["Configures"] == "Konfigurerar")
#expect(AccessWords.sv["Configures and controls"] == "Konfigurerar och styr")
#expect(AccessWords.sv["Controls"] == "Styr")
#expect(AccessWords.sv["No access"] == "Ingen åtkomst")
}

var pinned: OfferKeys { OfferKeys(pins: ["\(v.kid):\(Base64url.encode(Bytes(hex: v.pubHex)))"]) }
var signed: SignedOffer { get throws { try SignedOffer(base64url: Base64url.encode(Bytes(hex: v.signedOfferHex))) } }

Expand Down
Loading
Loading