diff --git a/.changeset/schedule-no-stepup.md b/.changeset/schedule-no-stepup.md new file mode 100644 index 0000000..05b7b71 --- /dev/null +++ b/.changeset/schedule-no-stepup.md @@ -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. diff --git a/docs/protocol.md b/docs/protocol.md index fc50fed..2930da9 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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 @@ -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 diff --git a/src/lib/sim/api.ts b/src/lib/sim/api.ts index da7416f..f4fb8e1 100644 --- a/src/lib/sim/api.ts +++ b/src/lib/sim/api.ts @@ -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. @@ -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 } /** @@ -122,13 +128,13 @@ const ROUTES: Record = { // 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' }, @@ -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': @@ -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 diff --git a/src/views/EvPanel.svelte.test.ts b/src/views/EvPanel.svelte.test.ts index ba498e1..53ebd6d 100644 --- a/src/views/EvPanel.svelte.test.ts +++ b/src/views/EvPanel.svelte.test.ts @@ -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.', @@ -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) @@ -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) @@ -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++ } @@ -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) @@ -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 () => { @@ -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) @@ -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 () => { diff --git a/tests/api-passthrough.test.ts b/tests/api-passthrough.test.ts index e9510c7..bd4d8ec 100644 --- a/tests/api-passthrough.test.ts +++ b/tests/api-passthrough.test.ts @@ -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)