# The newsroom's reader API: a map, and a review

*Generated by `admin/build/api_map.py` at v0.10.2, 11 October 2026. The page is <https://newsroom.sgit.ai/newsroom-api/>, the data is
<https://newsroom.sgit.ai/newsroom-api/map.json>. Edit the tables in the script, not this file.*

Phase 0 of [Go slow to go fast](https://newsroom.sgit.ai/articles/explorer-to-villager-newsroom.html): map everything a reader does
on this site before it moves to a repository of its own, and review it line by line. "API" means client-side: components,
formats and contracts the browser runs.

## In numbers

- Reader code: 6,441 lines that run in a browser; 5,697 more in 20 frozen files of earlier meter versions.
- 21 pages written for it; 12 views; 56 kinds of control; 11 inputs; 9 outputs; 14 contracts.
- Tests: 201 check calls in three files (more at run time, from loops); CI runs them since this release.
- Pages with a CSP: 0. Upstreams: two (sgit.ai v0.7.61 and this site).

## Findings

| # | Severity | Area | Finding | Evidence | Pinned by | Proposed change |
|---|---|---|---|---|---|---|
| R1 | medium | the meter's logic | Declining a page as “not relevant” leaves it in the persona's reading list. open() auto-keeps an article on first open; decline() refunds the charge but does not undo the auto-keep, so the article stays under “Its reading list” and profile() adds +5 for it while the decline subtracts 2: a page the reader rejected pulls its topics up. | `sg-meter-core.js open() [autoKeep], decline(); seen in a browser on 11 October` | test_review.mjs R1 (passes while the article stays in the list) | decline() removes an autoKept entry from the active persona's on[]; a reader who added it by hand keeps it |
| R2 | low | restore from a file | restore() checks that the file has a numeric balance and a log array and nothing else. A persona without on[] and off[] makes the personas and newsroom views throw and show nothing; a log entry's path is rendered as the history link's href as it is; a persona's theme is written into a style attribute as it is. The file is the reader's own, so the exposure is a file someone else sent them to restore. | `sg-meter-core.js restore(), _read(); sg-meter.js _account() history rows, _chip() style` | test_review.mjs R2 (the blank personas page); the href and theme cases are described, not pinned | normalise on read: personas get arrays, paths must be same-origin paths, themes must be a hex colour; unknown fields dropped |
| R3 | low | the return page | A malformed percent sequence in ?session_id= (for example %E0%A4%A) makes decodeURIComponent throw inside onReady, so the element fails with “did not load: URI malformed” instead of saying the reference is missing. | `sg-meter.js _toppedUp()` | test_review.mjs R3 | try/catch around the decode; treat an undecodable id as no id |
| R4 | medium | data correctness | A reading share or a subscription sent from this site is labelled sgit.ai: the imported config has share.site = "sgit.ai" (so the text begins “My reading on sgit.ai”), subscribe.js writes From: site@sgit.ai, and the contact file says site: sgit.ai. The agent that drains the lane can tell the two sites apart only by the Sent from line. | `index.html sg-meter-config share.site; assets/subscribe.js eml(); .well-known/sgit-subscribe.json` | test_review.mjs R4 | a recorded meter rule for share.site (admin/build/newsroom-meter.json); subscribe.js and the contact file are verbatim imports and need this site's own copies, or a parameter, after the move |
| R5 | note | operations | On the live site the lane for shares and subscriptions is on dev.send.sgraph.ai and the study page's vault frames come from dev.vault.sgraph.ai. Both hosts are named “dev”. Production reader data goes to them. | `.well-known/sgit-subscribe.json inbox.endpoint; assets/vault-ui-embed.js ORIGIN` | test_review.mjs R5 (records the hosts) | a decision, not a code change: either these are the production hosts and say so, or the files point at production ones |
| R6 | low | display | Money is kept to a tenth of a penny and shown to a penny. Balance and spent are each formatted on their own, so a half-penny state shows, from £5.00, a balance of £4.95 and £0.06 spent, which do not add up; the history shows 5.5p. A reader who checks the arithmetic finds a penny missing. | `sg-meter-core.js money(), r1(); seen with balance 494.5` | test_review.mjs R6 | one rounding rule for the pair (format spent as start − balance, or keep both to 0.1p on the account page) |
| R7 | question | picks | picks() excludes every page in the log, whichever persona read it, while profile() is per persona. An article read as Morgan is never picked for Rowan. | `sg-meter-core.js picks()` | test_review.mjs R7 (records the behaviour) | decide: per-persona exclusion, or keep and say so on the newsroom page |
| R8 | low | robustness | feed() caches a failed fetch as null for the life of the page, so one transient failure leaves “The list of articles could not be loaded” on every view until a reload. | `sg-meter-core.js feed()` | test_review.mjs R8 | do not cache a failure; retry on the next call |
| R9 | medium | the number contract | If sgit.ai renames an article (a new slug), ids.py marks the old number withdrawn and gives the article a new number. A reading list that named the old number then says the article is no longer published, although it is. | `admin/build/ids.py assign()` | described; a rename has not happened yet | a renamed_to field on the old entry, followed by the list page; the gate requires it when a slug disappears and a new one appears in the same import |
| R10 | high for the move | Content-Security-Policy | Every imported page loads assets/site.js, subscribe.js and shots.js by fetching the text and running it with (0, eval)(text) (the loader's bridge path), and the loader and the meter config are inline. A script-src policy without 'unsafe-eval' and without hashes breaks every page. frame-ancestors cannot be set in a meta tag at all, and GitHub Pages sets no headers, so framing (the Keep, Remove and Top up buttons under another site) is not prevented today. | `the loader IIFE on every imported page; subscribe/index.html` | test_review.mjs R10 (counts the pages that eval) | load scripts with <script src> or import(); then a meta CSP for script-src and connect-src; frame-ancestors needs a host that sets headers |
| R11 | high per the brief | the pipeline | The one workflow ran validate.js only; the browser tests (46 + 65 checks) never ran in CI. | `.github/workflows/deploy-pages.yml` | fixed in this release: a reader-tests job runs all three test files before the tag | done; the move's pipeline adds WebKit and Firefox and a pre-production copy |
| R12 | note for the move | two upstreams | The account pages, subscribe, the contact file and the component base are imported from sgit.ai on every sync and cannot be changed here except by a recorded rule, and verbatim files (scripts, JSON) cannot be changed by rule at all. R4 is the first consequence. The newsroom's meter is v1.4.0 while sgit.ai's is v1.3.1. | `sync_newsroom.py VERBATIM, MOVED_DIRS` | the brief's phase 1 removes it | the move: both sites pin released files |
| R13 | note | what leaves | The export includes the payment references (sessions, topups[].ref) that the share deliberately leaves out. A reader who sends their export instead of their share sends them. | `sg-meter.js _export(); shareData()` | described | say so beside the button, or export without sessions |
| R14 | low | bounds | sessions[] and topups[] grow without bound, and a payment reference is not checked for shape before it is stored. | `sg-meter-core.js credit()` | described | cap both; keep references that look like cs_… only |
| R15 | low | personas | persona(id) falls back to personas[0], which is “me” only when “me” is first; after a restore that puts another persona first, an unknown id resolves to that persona. | `sg-meter-core.js persona(), _read()` | described | fall back to the persona with id me |
| R16 | process | caching | Imported pages load the meter as sg-meter.js?v=<sgit.ai's release>, so the cache-buster never changes for a release of this site. That is fine while a published version is never edited in place, which is the rule; it is written here because v1.4.0 was edited in place while it was being built. | `the import's own_meter() keeps sgit.ai's ?v=` | described | a change is a new version path, always |
| R17 | note | reading lists | A list's persona is keyed by a 32-bit hash of the names and numbers, so two different lists can share one persona with probability about one in four billion per pair. | `for/reading-list.js listKey()` | described | a longer hash, or the fragment itself as the key |
| R18 | note | listeners | The landing view adds a window hashchange listener per element and never removes it, and re-renders on every meter change (the picker's “Copied” label resets). | `sg-meter.js _landing()` | described | one listener, removed on disconnect |

## Accepted, and said so

| Gap | Why | Where |
|---|---|---|
| The return page credits any session id | a static page cannot verify a payment; building the address by hand credits the reader's own screen | meter/security.html |
| The reader can edit their own balance | the state is in their storage; nothing is behind a wall | meter/security.html, No server by design |
| The names in a reading list are whatever the link says | there is no signature yet; a signed list is a later step on RFC 0001's keys | for/index.html |
| A page is charged by the deepest point reached, and the reader card is at the foot | reaching the card to rate a page is reading to the end | going-live-with-the-reading-meter.html |

## What the move takes from here

The before-hashes (file and page tables on the page), the controls and inputs tables as the test checklist, the contracts
table as the new repository's README, and the findings as its first issues.
