Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/schedule-no-stepup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw-webapp": patch
---

Changing the car's ready time no longer asks for Face ID. Login still uses a passkey. The box must accept the schedule write without a fresh step-up — pair with the Core change.
24 changes: 13 additions & 11 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,8 @@ about the method.** Ask what the code on the other side does:
1. **read** — answers a question, changes nothing, and hands back nothing that
could be replayed as authority. A shared viewer may ask for it.
2. **configure** — changes a setting. A late execution is the same
instruction, only later. Owner, with a step-up.
instruction, only later. Owner. Most of these also need a step-up;
a few household settings skip the ceremony.
3. **actuate** — moves energy, or takes control of what is moving it. A late
execution here is a *different* instruction.
4. **local** — served only on the box's own page, at home. Either the answer
Expand Down Expand Up @@ -317,16 +318,17 @@ has no such guarantee.
A `local` route is refused with `E_LOCAL_ONLY`, before the role and before the
ceremony, because neither of them changes it.

`configure` needs role `owner` and a `stepUp` flag. The app carries no list of
tiers — it asks, is refused with `E_NEEDS_STEP_UP`, runs the passkey ceremony
and replays the identical request once.

That cost is per write, not per session. The box refuses on the flag alone and
keeps nothing about a ceremony that already ran, so the second write of a
session is refused exactly as the first was: a round trip, a face or a
fingerprint, and a replay, every time. The app could avoid the round trip only
by sending `stepUp` before the ceremony, which would be the app claiming
something that had not happened.
`configure` needs role `owner`. Most configure routes also need a `stepUp`
flag. A few household settings — the charging schedule today — skip the
ceremony: the session already proved who is asking, and a second Face ID is
more friction than the table-phone risk is worth. The box names those routes
beside the handler. The app still carries no list of tiers — it asks without
the flag, and a route that still needs a ceremony answers `E_NEEDS_STEP_UP`.

For routes that still need the flag, the box remembers a genuine ceremony for
a few minutes on that session, so a settings screen that writes several things
in a row prompts once. The app does not earn that by sending `stepUp` before a
ceremony; that would be claiming something that had not happened.

The order the box refuses in is fixed, and the app depends on it: whole
document, then role, then ceremony. A viewer who posts a whole document hears
Expand Down
24 changes: 15 additions & 9 deletions src/lib/sim/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,9 @@ const RULE_TYPES = [
*
* read answers a question, changes nothing, and hands back nothing that
* could be replayed as authority. A shared viewer may ask for it.
* configure changes a setting. Owner, with a step-up. A late execution is the
* same instruction, only later.
* configure changes a setting. Owner, and usually a step-up. A late execution
* is the same instruction, only later. A route marked noStepUp skips
* the ceremony.
* actuate moves energy, or takes control of what is moving it. Refused
* through the passthrough for everybody: an HTTP request carries no
* expiry, and a request with no expiry must not move energy.
Expand All @@ -84,6 +85,11 @@ export interface RouteFacts {
* sender working from an older idea of it drops every field it never knew.
*/
replacesAll?: boolean
/**
* Owner is enough; no fresh passkey ceremony. The charging schedule is
* the case: login already proved who is asking.
*/
noStepUp?: boolean
}

/**
Expand Down Expand Up @@ -122,13 +128,13 @@ const ROUTES: Record<string, RouteFacts> = {
// The charger. Reads are reads; everything that starts, stops or redirects
// charging is actuation, priced exactly as the box prices it. The schedule
// alone is configuration — a standing instruction about future days, with
// its own route since srcfl/ftw#869 — while the target route it once rode
// stays actuation, because target also carries one-shot fields that move
// energy now.
// its own route since srcfl/ftw#869 — and NoStepUp, because login already
// proved who is asking. The target route it once rode stays actuation,
// because target also carries one-shot fields that move energy now.
'GET /api/loadpoints': { tier: 'read' },
'POST /api/loadpoints/{id}/vehicle': { tier: 'configure' },
'PUT /api/loadpoints/{id}/schedule': { tier: 'configure' },
'DELETE /api/loadpoints/{id}/schedule': { tier: 'configure' },
'PUT /api/loadpoints/{id}/schedule': { tier: 'configure', noStepUp: true },
'DELETE /api/loadpoints/{id}/schedule': { tier: 'configure', noStepUp: true },
'GET /api/mpc/plan': { tier: 'read' },
'GET /api/loadpoints/{id}/manual_hold': { tier: 'read' },
'GET /api/loadpoints/{id}/battery_boost': { tier: 'read' },
Expand Down Expand Up @@ -442,7 +448,7 @@ export class SimApi {
// The box's own file server, which this session does not carry.
if (!matched) return { code: 'E_UNKNOWN_OP', args: { t: 'api.req', field: 'path' } }

const { tier, cmdOp, replacesAll } = matched.facts
const { tier, cmdOp, replacesAll, noStepUp } = matched.facts

switch (tier) {
case 'read':
Expand All @@ -459,7 +465,7 @@ export class SimApi {
if (req.role !== ROLE_OWNER) {
return { code: 'E_SCOPE_DENIED', args: { needRole: ROLE_OWNER, role: req.role } }
}
if (this.#opts.requireStepUp !== false && !req.stepUp) {
if (this.#opts.requireStepUp !== false && !req.stepUp && !noStepUp) {
return { code: 'E_NEEDS_STEP_UP', args: { tier } }
}
break
Expand Down
45 changes: 21 additions & 24 deletions src/views/EvPanel.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@ import { FID } from '$lib/format/explanation'
import { wireBytes } from '$lib/protocol/frame'
import { localInputToUtcMinutes, localClock } from '$lib/format/ev'

// The ceremony, played by a hand. The sim's configure tier refuses without
// a step-up exactly as the box does; what is under test is that one save
// runs it once and the refusal prose reaches the screen when it fails.
// The ceremony, played by a hand. Most configure writes still refuse
// without a step-up; the charging schedule does not. Vehicle writes
// still need one. The mock is here so a schedule save can prove it
// never asked, and so a failed ceremony still has prose if a route
// that needs one is refused.
vi.mock('$lib/identity/stepup', () => ({
stepUp: vi.fn(async () => 'done'),
stepUpHelp: () => 'Your passkey did not answer. Nothing was changed.',
Expand Down Expand Up @@ -215,7 +217,7 @@ describe('the charger behind its bubble', () => {
).toBeGreaterThan(afterMount)
})

it('saves a schedule in one PUT and one ceremony, and repaints from the box', async () => {
it('saves a schedule in one PUT without a ceremony, and repaints from the box', async () => {
vi.useFakeTimers()
vi.setSystemTime(CHARGING_EVENING)

Expand Down Expand Up @@ -258,13 +260,12 @@ describe('the charger behind its bubble', () => {
document.querySelector('input[type="time"]')!.dispatchEvent(new Event('change', { bubbles: true }))
await vi.advanceTimersByTimeAsync(1_000)

// One ceremony for the whole draft, not one per field.
expect(vi.mocked(stepUp).mock.calls.length).toBe(1)
expect(vi.mocked(stepUp).mock.calls.length, 'a ready-time save asked for Face ID').toBe(0)

const saved = put.mock.calls.find(
(c) => c[0].method === 'PUT' && c[0].path.endsWith('/schedule') && c[0].stepUp
(c) => c[0].method === 'PUT' && c[0].path.endsWith('/schedule') && !c[0].stepUp
)
expect(saved, 'no stepped-up PUT reached the box').toBeDefined()
expect(saved, 'no schedule PUT reached the box').toBeDefined()
const bodyOnWire = JSON.parse(new TextDecoder().decode(saved![0].body!))
expect(bodyOnWire.time_of_day_min_utc).toBe(localInputToUtcMinutes('08:00'))
expect(bodyOnWire.days).toBe(0b0011111)
Expand All @@ -288,7 +289,7 @@ describe('the charger behind its bubble', () => {
const serve = box.api.serve.bind(box.api)
vi.spyOn(box.api, 'serve').mockImplementation(req => {
const answer = serve(req)
if (req.method === 'PUT' && req.path.endsWith('/schedule') && req.stepUp && 'status' in answer && answer.status === 200) {
if (req.method === 'PUT' && req.path.endsWith('/schedule') && 'status' in answer && answer.status === 200) {
pending = true
writes++
}
Expand Down Expand Up @@ -448,7 +449,7 @@ describe('the charger behind its bubble', () => {
)
})

it('says what happened when the ceremony fails, and changes nothing', async () => {
it('saves a schedule even if a passkey ceremony would fail', async () => {
vi.useFakeTimers()
vi.setSystemTime(CHARGING_EVENING)

Expand All @@ -460,28 +461,24 @@ describe('the charger behind its bubble', () => {
}

const stepup = await import('$lib/identity/stepup')
vi.mocked(stepup.stepUp).mockResolvedValueOnce('unavailable')
vi.mocked(stepup.stepUp).mockClear()

render(EvPanel, { props: { site, onclose: () => {} } })
await vi.advanceTimersByTimeAsync(500)
const before = document.body.textContent

;[...document.querySelectorAll('button')]
.find((b) => b.textContent?.trim() === 'Change goal')!
.click()
await vi.advanceTimersByTimeAsync(50)
document.querySelector('input[type="time"]')!.dispatchEvent(new Event('change', { bubbles: true }))
const time = document.querySelector('input[type="time"]') as HTMLInputElement
time.value = '09:00'
time.dispatchEvent(new Event('input', { bubbles: true }))
time.dispatchEvent(new Event('change', { bubbles: true }))
await vi.advanceTimersByTimeAsync(1_000)

expect(document.body.textContent).toContain('Nothing was changed')
// Cancel out and the schedule reads exactly as before the attempt.
;[...document.querySelectorAll('button')]
.find((b) => b.textContent?.trim() === 'Close goal settings')!
.click()
await vi.advanceTimersByTimeAsync(200)
expect(document.body.textContent).toContain(
before!.match(/Ready by [^·]+/)![0].trim()
)
expect(vi.mocked(stepup.stepUp).mock.calls.length).toBe(0)
expect(document.body.textContent).toContain('Schedule saved')
expect((document.querySelector('input[type="time"]') as HTMLInputElement).value).toBe('09:00')
})

it('removes a schedule and says the absence honestly', async () => {
Expand Down Expand Up @@ -519,7 +516,7 @@ describe('the charger behind its bubble', () => {
const put = vi.spyOn(box.api, 'serve')
use.click()
await vi.advanceTimersByTimeAsync(1000)
const writes = put.mock.calls.filter(c => c[0].method === 'PUT' && c[0].path.endsWith('/schedule') && c[0].stepUp)
const writes = put.mock.calls.filter(c => c[0].method === 'PUT' && c[0].path.endsWith('/schedule'))
expect(writes).toHaveLength(1)
const selected = JSON.parse(new TextDecoder().decode(writes[0]![0].body!))
expect(selected.soc).toBe(expectedSoc)
Expand Down Expand Up @@ -570,7 +567,7 @@ describe('the charger behind its bubble', () => {
expect(document.body.textContent).toContain('Goal removed.')
expect(document.body.textContent).not.toContain('Current charging status is unavailable.')
expect(document.body.textContent).not.toContain('Goal saved')
expect(asked.mock.calls.filter(([req]) => req.method === 'DELETE' && req.stepUp)).toHaveLength(1)
expect(asked.mock.calls.filter(([req]) => req.method === 'DELETE' && req.path.endsWith('/schedule'))).toHaveLength(1)
})

it('charges now through the door, and the whole household says so', async () => {
Expand Down
32 changes: 32 additions & 0 deletions tests/api-passthrough.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,38 @@ describe('configuration', () => {
expect((decode(res.body) as { role: string }).role).toBe(ROLE_VIEWER)
})

it('saves a charging schedule without a ceremony, and still refuses a viewer', async () => {
const body = new TextEncoder().encode(
JSON.stringify({ soc_pct: 80, time_of_day_min_utc: 360, recurring: true })
)

const box = new SimBox({ now: () => NOON, role: ROLE_OWNER })
const session = connect(box)
await settle()

const res = await session.api({
method: 'PUT',
path: '/api/loadpoints/carport/schedule',
body,
})
expect(res.status).toBe(200)

const cleared = await session.api({
method: 'DELETE',
path: '/api/loadpoints/carport/schedule',
})
expect(cleared.status).toBe(200)

const viewer = new SimBox({ now: () => NOON, role: ROLE_VIEWER })
const viewing = connect(viewer)
await settle()
await expect(
viewing.api({ method: 'PUT', path: '/api/loadpoints/carport/schedule', body })
).rejects.toMatchObject({
detail: { code: 'E_SCOPE_DENIED', args: { needRole: ROLE_OWNER } },
})
})

it('restarts the box as configuration, once a ceremony has happened', async () => {
const box = new SimBox({ now: () => NOON, role: ROLE_OWNER })
const session = connect(box)
Expand Down