diff --git a/appleApp/FTW/Access/GrantView.swift b/appleApp/FTW/Access/GrantView.swift index e9badf9..8c0f3d2 100644 --- a/appleApp/FTW/Access/GrantView.swift +++ b/appleApp/FTW/Access/GrantView.swift @@ -10,6 +10,7 @@ struct GrantView: View { let id: String @State private var scopes: Set = [] @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 @@ -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 { @@ -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 { @@ -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 { @@ -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)) @@ -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 { @@ -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 } } diff --git a/appleApp/FTW/Access/OfferFlowView.swift b/appleApp/FTW/Access/OfferFlowView.swift index 13df331..e2b6808 100644 --- a/appleApp/FTW/Access/OfferFlowView.swift +++ b/appleApp/FTW/Access/OfferFlowView.swift @@ -145,11 +145,21 @@ 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) @@ -157,7 +167,7 @@ struct OfferReview: View { .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")) { diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift index a932059..407651f 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift @@ -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 { @@ -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"] diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift b/appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift index 598d5bb..bcd2985 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift @@ -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) { diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/AccessWire.swift b/appleApp/FTWKit/Sources/FTWKit/Access/AccessWire.swift index 680008c..e5da14b 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Access/AccessWire.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Access/AccessWire.swift @@ -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 = [scopeHold, scopeRequest] diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift b/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift index 35f1533..701f096 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift @@ -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", ] @@ -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 @@ -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", diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift b/appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift index 73eac38..ce1b305 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift @@ -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 @@ -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. @@ -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) } diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift index 384d6d1..2a3b5f9 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift @@ -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", @@ -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 @@ -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", diff --git a/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift index dd76073..9d879dc 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift @@ -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. @@ -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)") diff --git a/appleApp/FTWKit/Tests/FTWKitTests/OfferTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/OfferTests.swift index 43f2557..faba4c3 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/OfferTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/OfferTests.swift @@ -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))) } } diff --git a/protocol/registry.yaml b/protocol/registry.yaml index 0ae5357..b8c70b4 100644 --- a/protocol/registry.yaml +++ b/protocol/registry.yaml @@ -142,6 +142,7 @@ scopes: - { name: ftw.logs.read, desc: Log excerpts the box redacts } - { name: ftw.config.read, desc: Configuration the box redacts } - { name: ftw.diag.run, desc: Full technical diagnostics and read API } + - { name: ftw.setup.write, desc: Save confirmed system setup and restart to apply it after user confirmation } - { name: ftw.dispatch.hold, desc: Hold energy assets for a capped time } - { name: ftw.goals.request, desc: Ask an owner to change a durable goal } # Reserved: no op asks for an update yet, and no role but owner carries it. @@ -173,10 +174,10 @@ roles: # is the release check, which reads a health summary and nothing else. agent: label: Assistant - scopes: [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] + scopes: [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.setup.write, ftw.dispatch.hold, ftw.goals.request] support: label: Sourceful support - scopes: [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] + scopes: [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.setup.write, ftw.dispatch.hold, ftw.goals.request] release: label: Release check scopes: [ftw.health.read] @@ -510,3 +511,14 @@ charger_car_fields: - { name: carCorrected, kind: bool, unit: null, desc: A better rule replaced the car the box first named } - { name: carCorrectedFrom, kind: text, unit: null, desc: The car the box named before that } - { name: choose, kind: text_list, unit: null, desc: While asking the cars a tap may pick. At most four } + +# Agent setup calls use the box's typed draft service. They do not carry an +# HTTP path or grant general configuration or membership authority. +agent_calls: + - { msg: setup.schema, const: MsgSetupSchema, scope: ftw.diag.run, desc: Read this build's complete setup schema } + - { msg: setup.status, const: MsgSetupStatus, scope: ftw.diag.run, desc: Resume the masked setup draft } + - { msg: setup.edit, const: MsgSetupEdit, scope: ftw.setup.write, desc: Patch the exact current setup draft } + - { msg: setup.reset, const: MsgSetupReset, scope: ftw.setup.write, desc: Start a new draft from current settings } + - { msg: setup.validate, const: MsgSetupValidate, scope: ftw.setup.write, desc: Check the exact setup draft without applying it } + - { msg: setup.commit, const: MsgSetupCommit, scope: ftw.setup.write, desc: Save the exact checked and confirmed draft } + - { msg: setup.restart, const: MsgSetupRestart, scope: ftw.setup.write, desc: Restart to apply the exact saved setup after user confirmation }