Name each capture's posting by an opaque key on the API (#711) #1077

Merged
tiagoagueda merged 1 commit from 711-captures-posting-key into main 2026-10-29 09:37:28 +00:00
Owner

The browser extension counts the postings waiting for review once each, merging
Postulo's list with its own unsent captures, and to do it carried a port of
same_url() with cases of its own. Two copies of one rule drift, and the day they
did the badge and the review list would disagree with Postulo about what is one
posting.

Every capture the API answers with (GET /captures, POST /captures, the rendering
call) now carries posting_key: an HMAC of the stored Capture.url_key and the
owner's id, under a key derived from the instance's key with a domain prefix of
its own (secrets.posting_key, beside backup_mac). Two captures of one posting
share it, two postings do not, and it is stable while the instance's key is.

Not the normalised address: a token holding only captures is not told the
address (#761), and a normalised one per row, or a bare SHA of it, would hand it
back. The keyed hash says nothing without the key, so it is told to that token
too. The owner's id is in it so two accounts' keys cannot be matched against each
other. An address with no host has an empty key, as same_url() matches nothing.

POST /captures/known answers each asked address's key as well, so a client can
put what it has not sent yet beside the list without reducing an address itself;
it tells a captures-only token nothing /known did not already say. The field is
described in the OpenAPI schema; the threat model says what a captures token sees.

Closes #711

Notes for review

  • The key: each capture carries posting_key, an HMAC-SHA256 (keyed from POSTULO_FIELD_KEY, else SECRET_KEY) of the owner's id and the stored url_key.
    • It is the same for two captures of one posting.
    • It can't be turned back into the address. Capture-only tokens get it while url stays blank, as #761 requires.
  • Decisions the issue left open:
    • The owner is part of the key, so keys don't match across accounts.
    • An address with no host gets an empty key rather than a hash of "", which would have counted every such capture as one posting.
    • POST /captures/known also returns the key for each address asked about, so the extension can key its unsent captures too. A capture-only token learns nothing new, since /known already says whether a capture exists.
    • The key changes if the instance's key does, and the wiki says so.
  • Follow-up: the extension drops its own same_url() copy, filed on postulo-chromium.

🤖 Generated with Claude Code

The browser extension counts the postings waiting for review once each, merging Postulo's list with its own unsent captures, and to do it carried a port of same_url() with cases of its own. Two copies of one rule drift, and the day they did the badge and the review list would disagree with Postulo about what is one posting. Every capture the API answers with (GET /captures, POST /captures, the rendering call) now carries `posting_key`: an HMAC of the stored `Capture.url_key` and the owner's id, under a key derived from the instance's key with a domain prefix of its own (secrets.posting_key, beside backup_mac). Two captures of one posting share it, two postings do not, and it is stable while the instance's key is. Not the normalised address: a token holding only `captures` is not told the address (#761), and a normalised one per row, or a bare SHA of it, would hand it back. The keyed hash says nothing without the key, so it is told to that token too. The owner's id is in it so two accounts' keys cannot be matched against each other. An address with no host has an empty key, as same_url() matches nothing. POST /captures/known answers each asked address's key as well, so a client can put what it has not sent yet beside the list without reducing an address itself; it tells a captures-only token nothing /known did not already say. The field is described in the OpenAPI schema; the threat model says what a captures token sees. Closes #711 ## Notes for review - **The key:** each capture carries `posting_key`, an HMAC-SHA256 (keyed from `POSTULO_FIELD_KEY`, else `SECRET_KEY`) of the owner's id and the stored `url_key`. - It is the same for two captures of one posting. - It can't be turned back into the address. Capture-only tokens get it while `url` stays blank, as #761 requires. - **Decisions the issue left open:** - The owner is part of the key, so keys don't match across accounts. - An address with no host gets an empty key rather than a hash of "", which would have counted every such capture as one posting. - `POST /captures/known` also returns the key for each address asked about, so the extension can key its unsent captures too. A capture-only token learns nothing new, since `/known` already says whether a capture exists. - The key changes if the instance's key does, and the wiki says so. - **Follow-up:** the extension drops its own `same_url()` copy, filed on postulo-chromium. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Name each capture's posting by an opaque key on the API (#711)
All checks were successful
CI / Unit tests and coverage (Python 3.14) (push) Successful in 12m40s
CI / Checks: lint, migrations, catalogues, settings, built files (push) Successful in 2m57s
CI / Database tests on PostgreSQL (push) Successful in 4m4s
CI / Security audit (push) Successful in 1m1s
CI / Browser tests (Chromium) (push) Successful in 31m37s
Every Python / Unit tests (Python 3.12) (push) Successful in 13m44s
Every Python / Unit tests (Python 3.13) (push) Successful in 13m54s
CI / Unit tests and coverage (Python 3.14) (pull_request) Successful in 13m12s
CI / Checks: lint, migrations, catalogues, settings, built files (pull_request) Successful in 2m55s
CI / Database tests on PostgreSQL (pull_request) Successful in 4m6s
CI / Security audit (pull_request) Successful in 1m3s
CI / Browser tests (Chromium) (pull_request) Successful in 31m48s
72ce330b50
The browser extension counts the postings waiting for review once each, merging
Postulo's list with its own unsent captures, and to do it carried a port of
same_url() with cases of its own. Two copies of one rule drift, and the day they
did the badge and the review list would disagree with Postulo about what is one
posting.

Every capture the API answers with (GET /captures, POST /captures, the rendering
call) now carries `posting_key`: an HMAC of the stored `Capture.url_key` and the
owner's id, under a key derived from the instance's key with a domain prefix of
its own (secrets.posting_key, beside backup_mac). Two captures of one posting
share it, two postings do not, and it is stable while the instance's key is.

Not the normalised address: a token holding only `captures` is not told the
address (#761), and a normalised one per row, or a bare SHA of it, would hand it
back. The keyed hash says nothing without the key, so it is told to that token
too. The owner's id is in it so two accounts' keys cannot be matched against each
other. An address with no host has an empty key, as same_url() matches nothing.

POST /captures/known answers each asked address's key as well, so a client can
put what it has not sent yet beside the list without reducing an address itself;
it tells a captures-only token nothing /known did not already say. The field is
described in the OpenAPI schema; the threat model says what a captures token sees.

Closes #711

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tiagoagueda force-pushed 711-captures-posting-key from 72ce330b50
All checks were successful
CI / Unit tests and coverage (Python 3.14) (push) Successful in 12m40s
CI / Checks: lint, migrations, catalogues, settings, built files (push) Successful in 2m57s
CI / Database tests on PostgreSQL (push) Successful in 4m4s
CI / Security audit (push) Successful in 1m1s
CI / Browser tests (Chromium) (push) Successful in 31m37s
Every Python / Unit tests (Python 3.12) (push) Successful in 13m44s
Every Python / Unit tests (Python 3.13) (push) Successful in 13m54s
CI / Unit tests and coverage (Python 3.14) (pull_request) Successful in 13m12s
CI / Checks: lint, migrations, catalogues, settings, built files (pull_request) Successful in 2m55s
CI / Database tests on PostgreSQL (pull_request) Successful in 4m6s
CI / Security audit (pull_request) Successful in 1m3s
CI / Browser tests (Chromium) (pull_request) Successful in 31m48s
to 2e01143c75
Some checks failed
CI / Unit tests and coverage (Python 3.14) (pull_request) Successful in 15m9s
CI / Checks: lint, migrations, catalogues, settings, built files (pull_request) Successful in 3m8s
CI / Database tests on PostgreSQL (pull_request) Successful in 4m25s
CI / Security audit (pull_request) Successful in 1m13s
CI / Browser tests (Chromium) (pull_request) Failing after 46m24s
2026-10-29 09:36:10 +00:00
Compare
tiagoagueda deleted branch 711-captures-posting-key 2026-10-29 09:37:28 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo!1077
No description provided.