diff --git a/.changeset/registry-agents.md b/.changeset/registry-agents.md new file mode 100644 index 0000000..02c2ac7 --- /dev/null +++ b/.changeset/registry-agents.md @@ -0,0 +1,5 @@ +--- +"ftw-webapp": patch +--- + +The registry names agents: assistants, Sourceful support and the release check, with their scopes, the scope that opens each command to an agent, their refusals and two push kinds. A box that sends the new kinds gets their notification toggles; nothing else in the app changes yet. diff --git a/contract/push-catalogue.yaml b/contract/push-catalogue.yaml index 8473df4..4c7f3b9 100644 --- a/contract/push-catalogue.yaml +++ b/contract/push-catalogue.yaml @@ -60,3 +60,29 @@ events: # To every owner, when FTW Cloud is turned off at either door. title: FTW Cloud is off body: "Your box stopped sharing with FTW Cloud and asked it to delete its copy." + - kind: agent.added + # To every owner, when an owner gives an assistant, Sourceful support or + # the release check access to the box. Access from outside the house is + # news every owner should hear, as with cloud.granted. + title: New access to your box + body: "You gave an assistant or Sourceful access. See who has access in the app." + - kind: agent.request + # To every owner, when an agent asks for a durable change. It waits for + # one of them to confirm it, and runs out after twelve hours. + title: Waiting for your answer + body: "An assistant asks to change a setting. Open FTW to confirm or decline." + - kind: agent.expiring + # To every owner, a day before an agent's grant runs out, so access that + # should go on is renewed and access that should end can be left to end. + title: Access ends tomorrow + body: "An assistant's or Sourceful's access to your box runs out in a day. Renew it in the app if it should go on." + - kind: approval.key.set + # To every owner, when a phone registers or replaces the key that + # approves access. A new key is a new way to let others in. + title: A phone can now approve access + body: "A phone set up the key it uses to approve access to your box. If that was not you, remove the phone in the app." + - kind: owner.added + # To every owner, when a phone pairs as an owner. An owner can do + # anything, including give others access. + title: A new owner + body: "A phone was paired as an owner of your box. If that was not you, remove it in the app." diff --git a/contract/registry.yaml b/contract/registry.yaml index 63b4de5..dfa8a85 100644 --- a/contract/registry.yaml +++ b/contract/registry.yaml @@ -111,9 +111,16 @@ capabilities: # watch codes. Absent means the phone offers no Add watch. The wire is # docs/watch-exchange.md in srcfl/ftw-webapp. - cmd.exchange + # The box takes agents: assistants, Sourceful support and the release + # check, each an owner's grant with scopes, ops, caps and an expiry, and it + # answers the agent.* messages and approval.key.set (docs/agents.md in + # srcfl/ftw). Absent means the app offers no Access list. + - access.agents # --------------------------------------------------------------------------- -# Scopes. One object axis, two verb axes: .. +# Scopes. One object axis and a verb: .. +# read and write are a phone's. run changes nothing, hold acts for a capped +# time and ends unless renewed, and request waits for an owner to confirm. # Roles are a UI projection over these, never a replacement for them. # --------------------------------------------------------------------------- scopes: @@ -127,6 +134,13 @@ scopes: - { name: ftw.mode.write, desc: Change site operating mode } - { name: ftw.members.read, desc: See who has access } - { name: ftw.members.write, desc: Invite and revoke } + - { name: ftw.logs.read, desc: Log excerpts the box redacts } + - { name: ftw.config.read, desc: Configuration the box redacts } + - { name: ftw.diag.run, desc: Checks that change nothing } + - { 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. + - { name: ftw.update.request, desc: Ask an owner to install a version } roles: owner: @@ -142,6 +156,23 @@ roles: watch: label: Watch scopes: [ftw.live.read, ftw.plan.read, ftw.events.read, ftw.dispatch.write] + # Agents: grants an owner gives to something other than a paired device. + # A role here is the most a grant of that kind may carry. Each grant names + # a subset, and each check keeps only what the approving owner holds at + # that moment, as for a watch. An agent reaches the box only through FTW + # Cloud's tunnel. See docs/agents.md in srcfl/ftw. + # + # agent is a personal assistant. support is Sourceful for one ticket. release + # 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] + 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] + release: + label: Release check + scopes: [ftw.health.read] # --------------------------------------------------------------------------- # FTW Cloud. What an owner can let the box share with FTW Cloud, and why. @@ -170,15 +201,23 @@ cloud_purposes: [serve, stats] # does not implement is answered with E_UNKNOWN_OP like any other stranger — # battery.hold is the app's vocabulary today and no shipping box acts on it # yet. +# +# agent is the scope that opens the op to an agent, and its verb says how. +# ftw.dispatch.hold runs now as a cmd, within the grant's caps, and must end +# by itself. ftw.goals.request never runs as a cmd: it waits as an +# agent.request until an owner confirms it. An op without agent is never +# open to an agent. battery.hold and loadpoint.boost have none until the box +# can bound them: battery.hold has no handler yet, and a boost has no power +# of its own to hold to maxW. agent_args below says what an agent may send. # --------------------------------------------------------------------------- ops: - - { name: site.mode.set, scope: ftw.mode.write, desc: Change the site operating mode } - - { name: planner.prefs.set, scope: ftw.mode.write, desc: Set household planner preferences } + - { name: site.mode.set, scope: ftw.mode.write, agent: ftw.goals.request, desc: Change the site operating mode } + - { name: planner.prefs.set, scope: ftw.mode.write, agent: ftw.goals.request, desc: Set household planner preferences } - { name: battery.hold, scope: ftw.dispatch.write, desc: Hold the battery at a fixed setpoint } - - { name: loadpoint.hold, scope: ftw.dispatch.write, desc: Charge the car now at a fixed current } + - { name: loadpoint.hold, scope: ftw.dispatch.write, agent: ftw.dispatch.hold, desc: Charge the car now at a fixed current } - { name: loadpoint.boost, scope: ftw.dispatch.write, desc: Boost the car from the house battery } - - { name: loadpoint.soc.set, scope: ftw.dispatch.write, desc: Correct the car's current charge level } - - { name: loadpoint.surplus_only.set, scope: ftw.dispatch.write, desc: Charge the car from surplus PV only } + - { name: loadpoint.soc.set, scope: ftw.dispatch.write, agent: ftw.goals.request, desc: Correct the car's current charge level } + - { name: loadpoint.surplus_only.set, scope: ftw.dispatch.write, agent: ftw.goals.request, desc: Charge the car from surplus PV only } # --------------------------------------------------------------------------- # Dispatch modes. @@ -240,6 +279,25 @@ errors: # or doing it needs somebody standing at the box. Not a permission the owner # is missing, so neither a role nor a ceremony changes the answer. - { code: E_LOCAL_ONLY, retryable: false, desc: Route is served only on the box's own page } + # Agents' refusals (docs/agents.md in srcfl/ftw). Each is a different + # sentence to an owner reading the audit log or an assistant reading its + # answer, so none reuses a code above. + # + # E_RATE_LIMITED is retryable because the same command goes through once + # the grant's minute has room. E_CAP_EXCEEDED is not: the box refuses an + # argument past a cap rather than clipping it, and the same argument gets + # the same answer. + - { code: E_GRANT_EXPIRED, retryable: false, desc: Access ran out at its expiry } + - { code: E_OP_NOT_GRANTED, retryable: false, desc: The grant does not list this operation } + - { code: E_CAP_EXCEEDED, retryable: false, desc: An argument or a count is past its cap } + - { code: E_RATE_LIMITED, retryable: true, desc: Too many commands for the grant's rate } + - { code: E_NEEDS_OWNER, retryable: false, desc: A durable change waits for an owner as agent.request } + - { code: E_BAD_SIGNATURE, retryable: false, desc: The approval signature is missing or does not verify } + - { code: E_NO_APPROVAL_KEY, retryable: false, desc: This phone has no approval key on the box } + - { code: E_NOT_FOUND, retryable: false, desc: Nothing by that id or name } + # An approval whose atMs is too far from the box's wall clock. args.boxMs + # is the box's clock, so the phone can sign again with the right time. + - { code: E_CLOCK_SKEW, retryable: true, desc: The approval's time is too far from the box's } # --------------------------------------------------------------------------- # Error codes the client raises for itself. @@ -288,3 +346,56 @@ carrier_states: [webrtc, relay, tunnel, cloud, cache, none] resolutions: '5m': { tile_span_ms: 43200000, retention_days: 30, source: energy_ledger } '1h': { tile_span_ms: 604800000, retention_days: 730, source: hourly_rollup } + +# --------------------------------------------------------------------------- +# What an agent may put in each op it can name (docs/agents.md in srcfl/ftw, +# Operations). The box refuses any other key, a value of another kind and one +# outside its range, in a cmd and in an agent.request, as it arrives. label is +# the words the app shows an owner for the value. cap names the grant's cap +# that bounds it as well. kind says how to read the value: +# loadpoint the opaque charger id the agent read from loadpoints.get +# mode a key in modes +# enum one of values +# number, power, duration a number in unit +# ratio a fraction from 0 to 1 +# bool true or false +# --------------------------------------------------------------------------- +agent_args: + site.mode.set: + - { name: mode, kind: mode, required: true, label: Operating mode } + planner.prefs.set: + - { name: safety_k, kind: number, min: 0, max: 2, label: How cautious the plan is } + - { name: battery_export, kind: enum, values: [unknown, not_allowed, allowed], label: Whether the battery may sell to the grid } + loadpoint.hold: + - { name: id, kind: loadpoint, required: true, label: Charger } + - { name: power_w, kind: power, unit: W, min: 0, cap: maxW, label: Charging power } + - { name: hold_s, kind: duration, unit: s, min: 1, cap: holdS, label: For how long } + - { name: phase_mode, kind: enum, values: [auto, 1p, 3p], label: Phases } + - { name: clear, kind: bool, label: Stop the agent's own hold } + loadpoint.soc.set: + - { name: id, kind: loadpoint, required: true, label: Charger } + - { name: soc, kind: ratio, required: true, min: 0, max: 1, label: The car's charge level } + loadpoint.surplus_only.set: + - { name: id, kind: loadpoint, required: true, label: Charger } + - { name: surplus_only, kind: bool, required: true, label: Charge from solar only } + +# --------------------------------------------------------------------------- +# Reads an agent may send, and the scope each needs (docs/agents.md in +# srcfl/ftw, Reads). Their answers name nothing: sources, chargers and cars +# go by opaque ids the box cuts with a key of its own. An agent has no sub; +# live.get is its view of now. A phone may send these too, under the same +# scope. +# --------------------------------------------------------------------------- +reads: + - { msg: live.get, scope: ftw.live.read, desc: Readings and freshness now } + - { msg: loadpoints.get, scope: ftw.live.read, desc: Each charger and the car on it } + - { msg: cars.get, scope: ftw.live.read, desc: Each car's level and goal } + - { msg: plan.get, scope: ftw.plan.read, desc: The plan and its reasons } + - { msg: price.get, scope: ftw.plan.read, desc: Electricity prices } + - { msg: hist.query, scope: ftw.history.read, desc: Stored history } + - { msg: health.get, scope: ftw.health.read, desc: The health summary } + - { msg: drivers.get, scope: ftw.health.read, desc: Each driver's state } + - { msg: events.get, scope: ftw.events.read, desc: Recent events } + - { msg: logs.get, scope: ftw.logs.read, desc: A log excerpt the box redacts } + - { msg: config.get, scope: ftw.config.read, desc: A configuration section the box redacts } + - { msg: diag.run, scope: ftw.diag.run, desc: A check that changes nothing } diff --git a/src/lib/notify/kinds.ts b/src/lib/notify/kinds.ts index 0534e26..24e1b31 100644 --- a/src/lib/notify/kinds.ts +++ b/src/lib/notify/kinds.ts @@ -19,6 +19,11 @@ export const KINDS = [ 'box.unreachable', 'cloud.granted', 'cloud.revoked', + 'agent.added', + 'agent.request', + 'agent.expiring', + 'approval.key.set', + 'owner.added', ] as const export type PushKind = (typeof KINDS)[number] @@ -46,4 +51,9 @@ export const KIND_LABELS: Record = { 'box.unreachable': 'If your box goes out of reach', 'cloud.granted': 'When FTW Cloud is turned on', 'cloud.revoked': 'When FTW Cloud is turned off', + 'agent.added': 'When an assistant or Sourceful gets access', + 'agent.request': 'When an assistant asks to change a setting', + 'agent.expiring': "When an assistant's or Sourceful's access is about to end", + 'approval.key.set': 'When a phone sets up the key that approves access', + 'owner.added': 'When a phone is paired as an owner', } diff --git a/src/lib/protocol/contract.ts b/src/lib/protocol/contract.ts index 455a2ce..d27c4d5 100644 --- a/src/lib/protocol/contract.ts +++ b/src/lib/protocol/contract.ts @@ -12,7 +12,11 @@ * Add nothing here that is not in the registry. */ -/** One object axis, two verb axes. `.`. */ +/** + * An object and a verb: `.`. read and + * write are a phone's; run, hold and request are an agent's (docs/agents.md + * in srcfl/ftw). + */ export const SCOPES = [ 'ftw.live.read', 'ftw.history.read', @@ -24,10 +28,30 @@ export const SCOPES = [ 'ftw.mode.write', 'ftw.members.read', 'ftw.members.write', + 'ftw.logs.read', + 'ftw.config.read', + 'ftw.diag.run', + 'ftw.dispatch.hold', + 'ftw.goals.request', + 'ftw.update.request', ] as const export type Scope = (typeof SCOPES)[number] +/** What an assistant or Sourceful support may carry at most. */ +const AGENT_SCOPES: readonly string[] = [ + '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', +] + /** * What each role carries, with the registry's `'*'` expanded. * @@ -39,6 +63,12 @@ export const ROLE_SCOPES: Record = { owner: SCOPES, viewer: ['ftw.live.read'], watch: ['ftw.live.read', 'ftw.plan.read', 'ftw.events.read', 'ftw.dispatch.write'], + // Agents: grants an owner gives to an assistant, to Sourceful support or to + // the release check. The most a grant of each kind may carry; each grant + // names a subset, and the box enforces it. + agent: AGENT_SCOPES, + support: AGENT_SCOPES, + release: ['ftw.health.read'], } /** The label the registry gives each role. The app owns no other wording. */ @@ -46,6 +76,9 @@ export const ROLE_LABELS: Record = { owner: 'Owner', viewer: 'Viewer', watch: 'Watch', + agent: 'Assistant', + support: 'Sourceful support', + release: 'Release check', } /** Whether a role carries a scope. An unknown role carries nothing. */ diff --git a/src/lib/protocol/messages.ts b/src/lib/protocol/messages.ts index f9a66b5..7ada730 100644 --- a/src/lib/protocol/messages.ts +++ b/src/lib/protocol/messages.ts @@ -574,6 +574,19 @@ const RETRYABLE: Record = { // no role and no ceremony changes it. E_WHOLE_DOCUMENT: false, E_LOCAL_ONLY: false, + // Agents' refusals (docs/agents.md in srcfl/ftw). A rate limit clears once + // the grant's minute has room; every other one gets the same answer again. + E_GRANT_EXPIRED: false, + E_OP_NOT_GRANTED: false, + E_CAP_EXCEEDED: false, + E_RATE_LIMITED: true, + E_NEEDS_OWNER: false, + E_BAD_SIGNATURE: false, + E_NO_APPROVAL_KEY: false, + E_NOT_FOUND: false, + // The approval's atMs is too far from the box's clock; the phone signs + // again with args.boxMs, so asking again can succeed. + E_CLOCK_SKEW: true, // Not from the box. The session layer raises this one itself out of // api.end{truncated: true} — see contract/registry.yaml, client_errors. E_RESPONSE_TOO_LARGE: false, diff --git a/tests/registry-contract.test.ts b/tests/registry-contract.test.ts index 5284125..a59f4c4 100644 --- a/tests/registry-contract.test.ts +++ b/tests/registry-contract.test.ts @@ -373,7 +373,7 @@ describe('hand-written shared names', () => { // The scan can only ban a shape it recognises, so the shape is pinned to // the registry first: a declared scope it cannot match is a scope the // scan has gone blind to. - const shape = /^ftw\.[a-z_]+\.(?:read|write)$/ + const shape = /^ftw\.[a-z_]+\.(?:read|write|run|hold|request)$/ const declared = new Set(registryScopes()) for (const scope of declared) { expect(scope, 'a registry scope the bare-name scan cannot recognise').toMatch(shape)