Skip to content

Fix #83: refuse the default-notebook/encrypt-by-default pair locally - #92

Merged
cloudmanic merged 3 commits into
mainfrom
issue-83-default-notebook-no-encrypt
Aug 7, 2026
Merged

Fix #83: refuse the default-notebook/encrypt-by-default pair locally#92
cloudmanic merged 3 commits into
mainfrom
issue-83-default-notebook-no-encrypt

Conversation

@cloudmanic

@cloudmanic cloudmanic commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Closes #83

A notebook can never be both the account default and encrypt-by-default — the default is where forwarded email, imports and notes created with no notebook land, and none of those writers can client-side encrypt. The server enforces it with 422 default_notebook_cannot_encrypt; the CLI sent the request anyway and relayed the failure.

What changed

guardDefaultNotebookEncrypt refuses both directions before the write — turning encryption on for the current default, and promoting a notebook that already encrypts.

It judges the RESULTING state, exactly as the server does. This is the part worth getting right: --make-default --default-encrypt=false legally promotes a notebook while switching encryption off. An implementation that asked "does this request mention both flags?" would block the single command that fixes an encrypting notebook someone wants as their default. Verified below.

It reads the notebook only when the answer depends on current state. A request stating both fields is decided with no round trip, and so is anything that turns encryption off, or a plain rename. An unreadable notebook fails open: the server enforces the same rule, so a transient GET failure costs a 422 at worst — turning it into a local refusal would block a legal update because an unrelated read failed.

default_notebook_cannot_encrypt is deliberately NOT added to mapNotebookError. Its message is user-facing copy shared with the web app, and the CLI's default renderer already prints an APIError's message verbatim — so paraphrasing it here is the one way to get it wrong. A test pins that it is never mapped, and a second keeps the CLI's own local refusal in step with the server's sentence.

sync push warns rather than refuses, because the server behaves differently there: PATCH /notebooks/:id 422s and writes nothing, but sync push coerces — the record lands with default_encrypt forced off and comes back corrected on the next pull. Refusing locally would reject a batch the server would have accepted; silence would let a flag the user set disappear without explanation.

The "sync engine cannot produce the pair" requirement holds by construction. The CLI keeps no offline queue, and there are exactly two SyncPush call sites — the push command, which forwards a JSON file the user wrote, and the crypto keystore. So no client-side state can hold a default notebook with default_encrypt on.

TestNoNotebookRecordsAreConstructedByTheCLI is a canary over that property, not a proof. It walks the whole module and greps for a notebook type tag, which catches the obvious regression — someone building a notebook envelope inline, including in a package that does not exist yet — but a determined one slips past: a struct with a json tag, a const indirection, or a value assembled at runtime. A failure is certain; a pass means "nothing obvious". The test says so in its own comment.

Verification

$ make lint && make test
go fmt ./...
go vet ./...
ok  	github.com/HarborMyNotes/harbor-cli/client	0.311s
ok  	github.com/HarborMyNotes/harbor-cli/cmd	0.971s
ok  	github.com/HarborMyNotes/harbor-cli/config	0.620s
ok  	github.com/HarborMyNotes/harbor-cli/crypto	0.855s

End-to-end against a real server, throwaway account, isolated HOME. All seven checks passed.

Both directions refused:

$ harbor notebooks update <default-nb> --default-encrypt
Error: the default notebook can't encrypt notes by default — forwarded email, imports, and notes with no notebook land there; encrypt a different notebook instead
EXIT CODE: 1

$ harbor notebooks get <default-nb> --json | jq '{usn, is_default, default_encrypt}'
{ "usn": 1, "is_default": true, "default_encrypt": false }   # identical before and after

$ harbor notebooks update <encrypting-nb> --make-default
Error: the default notebook can't encrypt notes by default — forwarded email, imports, and notes with no notebook land there; encrypt a different notebook instead — switch it off with '--default-encrypt=false' in the same command to promote this notebook
EXIT CODE: 1

The legal both-fields case still works — the check a naive implementation fails:

$ harbor notebooks update <encrypting-nb> --make-default --default-encrypt=false
│ Name              │ Work H83  │
│ Default           │ ★         │
│ Encrypt new notes │ ·         │
EXIT CODE: 0

The refusal is local, not a relayed 422. Pointing the CLI at a request-logging server shows exactly what goes on the wire:

=== BANNED  (notebooks update <default> --default-encrypt) ===
Error: the default notebook can't encrypt notes by default — …
EXIT: 1
--- HTTP requests sent by CLI:
GET /api/v1/notebooks/mock-nb-1          ← reads current state, then decides locally. No PATCH.

=== LEGAL  (--make-default --default-encrypt=false) ===
EXIT: 0
--- HTTP requests sent by CLI:
PATCH /api/v1/notebooks/mock-nb-1 {"default_encrypt":false,"is_default":true}   ← no GET; both fields explicit

To be precise: the banned command is not zero-traffic — it issues one read-only GET to learn whether the target is currently the default. No write is sent and no USN is spent.

The server really would have 422'd (the guard enforces a real rule, not an invented one):

$ curl -s -w '\nHTTP %{http_code}\n' -X PATCH … -d '{"default_encrypt":true}' …/notebooks/<default-nb>
{"error":{"code":"default_notebook_cannot_encrypt","message":"The default notebook can't encrypt notes by default — forwarded email, imports, and notes with no notebook land there; encrypt a different notebook instead.","request_id":"req_9f0fd44a…"}}
HTTP 422

The CLI's sentence is character-for-character the server's, em dash and semicolon included, with the leading The lowercased and the trailing period dropped per Go error convention.

sync push coerces, and the CLI says so:

--- STDERR ---
Note: a pushed notebook is both the default and encrypt-by-default. The server
cannot store that pair — the default is where forwarded email, imports and notes
with no notebook land, and none of those can encrypt — so it will force
default_encrypt off and send the corrected record back on your next pull.
--- STDOUT ---
│ h83-c2 │ notebook │ 70dfb946… │ applied │ 17 │
EXIT: 0

$ harbor sync pull --after-usn 0 --all --json
{ "id": "70dfb946-…", "is_default": true, "default_encrypt": false, "usn": 17 }   ← coerced, as documented

Edge cases also checked: --make-default --default-encrypt (both explicitly true) is refused; --default-encrypt=false on the default notebook succeeds as a harmless no-op.

Found while testing — a server bug, filed separately

When a pushed sync envelope would move the default (promote notebook B while A still holds it), the server rejects with a raw SQLite string rather than demoting the prior default the way PATCH /notebooks/:id does:

Error: the server refused 1 of the 1 change in this push
  code: sync_push_rejected
  • h83-c1: constraint failed: UNIQUE constraint failed: notebooks.is_default (2067)

The CLI handled it correctly — warned, surfaced the rejection, exit 1. Unrelated to this change; filed as HarborMyNotes/app.harbor.my#1388.

A notebook can never be both the account default and encrypt-by-default:
the default is where forwarded email, imports and notes with no notebook
land, and none of those writers can client-side encrypt. The server
enforces it (422 default_notebook_cannot_encrypt); the CLI sent the
request anyway and relayed the failure.

- guardDefaultNotebookEncrypt refuses both directions before the write:
  turning encryption on for the current default, and promoting a notebook
  that already encrypts. Judged on the RESULTING state, like the server —
  '--make-default --default-encrypt=false' legally promotes while
  switching encryption off, and blocking that would break the one command
  that fixes the situation.
- It reads the notebook only when the answer depends on current state. A
  request stating both fields needs no round trip, nor does anything that
  turns encryption off. An unreadable notebook fails OPEN: the server
  enforces the same rule, so a transient GET failure must not become a
  refusal to write.
- default_notebook_cannot_encrypt is deliberately left out of
  mapNotebookError. Its message is user-facing copy shared with the web
  app and the default renderer prints it verbatim; a test pins that it is
  never paraphrased, and another keeps the local refusal's wording in step
  with the server's.
- sync push warns rather than refuses, because the server COERCES there
  instead of rejecting. The CLI keeps no offline queue and builds no
  notebook records, so it cannot produce the pair by construction — a test
  pins that structurally rather than by assertion.
- Help text on both flags and in the update long-form names the rule and
  shows the escape hatch.
- TestNoNotebookRecordsAreConstructedByTheCLI matched two exact spacings
  in one package, so it passed on the most natural envelope shape gofmt
  produces. Use a regex across cmd/, client/, crypto/ and config/, with a
  floor on files scanned. Verified it now catches what it missed.
- The bundled agent skill documented --make-default and --default-encrypt
  without the ban between them; that file is what 'harbor skill install'
  ships to Claude/Codex/Cursor, so an agent read the incomplete rule.
- Move the 'deliberately not mapped' comment above the switch — inside the
  last case it read as belonging to cannot_unset_default.
- Drop the '(never allowed on the default notebook)' note from the CREATE
  flag: a notebook being created is never the default, so it was a
  non-sequitur there. It stays on update, where it applies.
…overclaiming it

The reviewer wrote three regressions the test waved through — a struct
with a json tag, a const indirection, and a notebook record in a NEW
package, which the hardcoded root list could not see at all. The last one
is now caught: walk the module from its root instead of naming four
directories, with a floor on files scanned.

The other two still slip past, and a grep never will catch them, so the
test and the PR now say plainly that it is a canary over a property that
holds by construction — not a proof of it. A failure is certain; a pass
means 'nothing obvious'.

- The doc comment claimed the refusal 'reads the same offline as on'.
  Only true for a request stating both fields; the single-flag cases read
  the notebook first, so offline they surface the read error instead.
- The wording test lowercased the whole server sentence, which would
  demand the CLI mangle a proper noun if the copy ever gains one. Lower
  the first character only.
- Bump skillVersion, whose own comment says to do so when the bundled
  skill files change.
- Move warnDefaultNotebookEncrypt into the file's Helpers section.
@cloudmanic
cloudmanic merged commit 2a6bb58 into main Aug 7, 2026
1 check passed
@cloudmanic
cloudmanic deleted the issue-83-default-notebook-no-encrypt branch August 7, 2026 04:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Parity: the default notebook can never be encrypt-by-default (app.harbor.my#1338) — CLI

1 participant