diff --git "a/api/routes_publish.py" "b/api/routes_publish.py" --- "a/api/routes_publish.py" +++ "b/api/routes_publish.py" @@ -1,917 +1,917 @@ -"""routes_publish.py — PUBLISH AN INTERFACE VIEW AS A LINK (wave 33, owner item 8b, ruling R5). - -The owner, verbatim: *"Have the ability to publish interface, when a user's view is an interface -(e.g. Map or Catalog), we should have the ability to publish a link that can be either accessed -publicly or with a password that the user that share it can toggle."* - -R5, in five clauses, and every one of them is load-bearing: - * publish = a per-view secret token in a **server-only bucket**; - * a `public | password` toggle the SHARER owns, with a **hashed** passphrase beside it; - * an **unauthenticated** read-only route plus a `#/v/` client route; - * the publish surface **projects only the columns the view shows** — never the whole table; - * **creator-or-admin** may publish, and **revoke ROTATES** the token. - -⛔ THIS FILE COPIES `routes_forms.py`'S CONSTRUCTION ON PURPOSE, AND THE TICKET SAID TO. That door -has been public since wave 23 and carries scar tissue no fresh design would reproduce: ONE refusal -for every resolution failure (so the route is not an oracle for which tokens are real), a -constant-time compare, a STREAMING body bound rather than `Body(...)` (FastAPI reads and parses the -whole body before the handler's first line, and `content-length` is caller-supplied), a sliding -rate window rather than a fixed bucket, and an index that is a POINTER, never a permission. - -⚠ WHAT IS DELIBERATELY *NOT* SHARED WITH `routes_forms.py`: the bucket. A form token buys a WRITE -door into a table; a publish token buys a READ projection of one view. Folding them into one index -would make a single leaked string ambiguous about which of the two it opens, and would make -`_resolve` return an object whose capability depends on a field rather than on which door was -knocked. Two buckets, two resolvers, one shape. - -⚠ AND NOT SHARED WITH SHARING. **Three systems now answer some version of "who can see this"** and -they are not the same question (audit S-4): the grant registry drives *Shared with me*, -`table_store.is_shared` GATES opening a view inside the app, and a published link is a THIRD thing -— an unauthenticated read of a projection, bound to a secret rather than to an account. A published -link is NOT a grant: it creates no registry row, appears in nobody's *Shared with me*, and cannot -be revoked by removing a person, because there is no person. - - python verify_forms.py # this file's gate; section 6 onward -""" -import hashlib -import hmac -import os -import secrets -import time - -from fastapi import APIRouter, Depends, Request - -from deps import Session, err, require_session - -router = APIRouter(prefix="/api/v1") - -#: The SERVER-ONLY index: `{token: {table, view, access, pw?, salt?, iter?, createdBy, createdAt}}`. -#: ⛔ IT IS NEVER `display.*`. Contract C1 puts two PRESENTATIONAL flags on the view spec -#: (`published`, `publishAccess`) precisely so the client has something to render, and the browser -#: writes that spec on every autosave — so anything secret living there would be echoed back to the -#: browser by construction. `aios_grid._clean_display`'s allowlist enforces the other half. -TOKENS_KEY = "publish_tokens" -TOKEN_BYTES = 24 - -#: Passphrase storage. PBKDF2-HMAC-SHA256 with a per-link salt. -#: ⚠ D-130 IS THE SCAR THIS AVOIDS: a form's "invited addresses" list is IDENTIFICATION — it says -#: who you claim to be and anyone may claim it. A passphrase is AUTHENTICATION. The difference is -#: not a stronger string, it is that the secret is never stored, never logged and never echoed. -PW_ITERATIONS = 240_000 -PW_SALT_BYTES = 16 -MIN_PASSPHRASE = 6 -MAX_PASSPHRASE = 128 - -#: v1 request protections, the same shape and the same numbers as the form door (contract C9), so -#: the two public routes cannot drift into different postures. ⚠ In-process, therefore PER WORKER. -RATE_WINDOW_S = 60 -RATE_PER_WINDOW = 30 -MAX_BODY_BYTES = 16 * 1024 - -_HITS: dict = {} - -#: The view kinds that may be published, i.e. R5's "interface". -#: ⚠ THE CLIENT'S SOURCE OF TRUTH IS `customer-grid/iconShapes.ts::MODE_GROUP` (wave 33 item 9 -#: moved `swipe` and `timeseries` into this group). This constant is the SERVER's copy and the two -#: are held in step by `verify_forms.py`, which parses `MODE_GROUP` and compares — because a -#: server list that silently drifts from the picker means a mode a user can create and cannot -#: publish, with nothing anywhere going red. A `grid` or `kanban` view is a re-shaping of a row -#: set; publishing one would be publishing the table, which is exactly what R5's projection clause -#: exists to prevent. -#: ⛔⛔ W33-T68 — `form` IS DELIBERATELY ABSENT, AND ITS ABSENCE IS THE FIX. -#: A `form` view's rows ARE the submissions people have sent it. Every other mode here re-shapes a -#: row set the publisher already curated; a form's row set is other people's answers, gathered under -#: an implicit promise that they go to the owner. Publishing one turned "share this interface" into -#: "serve the responses to anyone holding the link" — on the one unauthenticated door in the -#: product, with no wall between the link and the data. -#: ⚠ AND A FORM ALREADY HAS ITS OWN PUBLIC DOOR: `#/form/` through `routes_forms.py`, which -#: serves the BLANK form for submitting and never the stored rows. So this is not a capability -#: removed, it is a second door onto the same object that should never have existed beside the -#: first. Publishing a form to be filled in still works, at the URL that was always for it. -#: ⚠ `verify_forms.py` holds this list in step with the client's `MODE_GROUP`; the client must not -#: offer Publish on a form view, or the picker promises what this refuses. -PUBLISHABLE_MODES = ("map", "catalog", "swipe", "timeseries") - - -def _refuse(): - """THE ONE REFUSAL, for every way a published link can fail to resolve. - - Wrong token, revoked link, deleted view, deleted table, a view that stopped being an - interface, a wrong passphrase. ⛔ Callers must not branch a more specific message out of it: - the difference between "no such link" and "that link was revoked" tells an enumerator which - tokens are real, and the difference between "no such link" and "wrong passphrase" tells them - which links are worth guessing at. - """ - return err(403, "bad_publish_token", "that link is not valid") - - -def _same(a: str, b: str) -> bool: - """Constant-time. A `==` leaks a secret's prefix through timing, one character at a time.""" - return hmac.compare_digest(str(a or ""), str(b or "")) - - -def _client_ip(request: Request) -> str: - """⛔⛔ W33-T70 — `x-forwarded-for` IS GONE FROM THIS FUNCTION, AND THAT WAS THE WHOLE HOLE. - - The header is chosen by the caller. Keying a rate limit on it means an enumerator writes a new - value per request and every request lands in a fresh bucket: MEASURED at **100 of 100 admitted - through a 30-per-window ceiling**. A limiter with a caller-chosen key is not a limiter, and its - old docstring said the header was "a rate-limit key and NOTHING else" — which was exactly the - use it could not support. It is safe as a LOG field and as nothing else. - - The socket peer is the only thing here the caller cannot choose, so it is the key. - ⚠ AND BEHIND HF'S PROXY EVERY CALLER SHARES ONE PEER, which is why `_rate_ok` counts FAILURES - ONLY (see there). A per-peer ceiling over ALL traffic would be one global bucket, i.e. an alarm - that fires for everyone [[alarm-that-fires-for-everyone]] — the honest reading of a shared peer - is that we cannot separate callers, not that we should throttle them together. - """ - return request.client.host if request.client else "?" - - -def _rate_ok(key: str, now: float) -> bool: - """Is this caller UNDER the failure ceiling? Pure check — call `_note_failure` to count. - - A SLIDING window. A fixed bucket lets a caller spend a whole allowance at 11:59:59 and the - whole next one at 12:00:00 — i.e. double the limit, back to back, against a door whose entire - protection is that guessing a 24-byte token is slow. - - ⛔ W33-T70 — IT COUNTS FAILURES, NOT REQUESTS, and the split is what makes a shared proxy peer - survivable. A legitimate reader opens a link that RESOLVES, so they never touch the counter at - all; an enumerator produces nothing but misses. Counting every request under one shared peer - would have denied service to everybody the moment one guesser showed up — trading a token - oracle for an outage is not a fix. - """ - seen = [t for t in _HITS.get(key, ()) if now - t < RATE_WINDOW_S] - _HITS[key] = seen - return len(seen) < RATE_PER_WINDOW - - -def _note_failure(key: str, now: float) -> None: - """Record one failed resolution against this peer, and keep the table bounded.""" - seen = [t for t in _HITS.get(key, ()) if now - t < RATE_WINDOW_S] - seen.append(now) - _HITS[key] = seen - if len(_HITS) > 4096: - for k in [k for k, v in _HITS.items() if not v or now - v[-1] > RATE_WINDOW_S]: - _HITS.pop(k, None) - - -#: A salt used ONLY to burn the same PBKDF2 a real verification would, when there is no stored hash -#: to check against. Its value is irrelevant; its COST is the point. -_DUMMY_SALT = b"\x00" * 16 - - -def _equalise_pw_cost(entry) -> None: - """⛔⛔ W33-T70 — SPEND THE PBKDF2 EVEN WHEN THERE IS NOTHING TO VERIFY. - - MEASURED before this existed: a wrong TOKEN answered in ~0.4 ms and a wrong PASSPHRASE in - ~82 ms — a **206x gap with zero overlap across 24 samples**. So the refusal's careful wording - ("that link is not valid", identical for both) was undone by the clock: anyone could sort real - tokens from fake ones by timing alone, then spend their guesses only on the real ones. The - docstring on `_refuse` describes exactly the leak the code then handed over for free. - - Called on every path that fails BEFORE a passphrase check would have happened, so the cheap - branch costs what the expensive one costs. `iter` is taken from the entry when there is one, so - a link minted under a different iteration count stays indistinguishable too. - """ - iterations = PW_ITERATIONS - if isinstance(entry, dict): - try: - iterations = int(entry.get("iter") or PW_ITERATIONS) - except (TypeError, ValueError): - iterations = PW_ITERATIONS - _hash_pw("", _DUMMY_SALT, iterations) - - -def _public_base() -> str: - return (os.environ.get("AIOS_PUBLIC_BASE") or os.environ.get("APP_BASE_URL") or "").rstrip("/") - - -def _link_of(token: str) -> str: - return f"{_public_base()}/#/v/{token}" - - -def _tokens(rt) -> dict: - """This tenant's publish index, always a dict.""" - try: - found = rt.get(TOKENS_KEY) or {} - except Exception: # noqa: BLE001 - return {} - return found if isinstance(found, dict) else {} - - -def _hash_pw(passphrase: str, salt: bytes, iterations: int = PW_ITERATIONS) -> str: - return hashlib.pbkdf2_hmac("sha256", str(passphrase).encode("utf-8"), - salt, int(iterations)).hex() - - -def _pw_ok(entry: dict, passphrase: str) -> bool: - """Constant-time verify against the stored digest. - - ⛔ RETURNS FALSE, NEVER RAISES, and never distinguishes "this link has no passphrase stored" - from "the passphrase is wrong" — a `password` link whose hash went missing must refuse, not - fall open. [[default-must-pass-its-own-guard]] - """ - stored, salt_hex = str(entry.get("pw") or ""), str(entry.get("salt") or "") - if not stored or not salt_hex: - return False - try: - salt = bytes.fromhex(salt_hex) - except ValueError: - return False - got = _hash_pw(passphrase, salt, int(entry.get("iter") or PW_ITERATIONS)) - return _same(got, stored) - - -def _find_view(rt, table_key: str, view_id: str): - """`(view, owner_username)` from the table's workspace bucket, or `(None, "")`. - - The bucket shape is `{username: {views: {id: view}}}` — the same walk `routes_forms._find_view` - does, and the reason a view is addressable by id ALONE here: the id is unique within the table, - while the owner is what we are trying to discover. - """ - if not table_key or not view_id: - return None, "" - try: - bucket = rt.get(f"{table_key}_table_workspace") or {} - except Exception: # noqa: BLE001 - return None, "" - if not isinstance(bucket, dict): - return None, "" - for owner, blob in bucket.items(): - views = (blob or {}).get("views") if isinstance(blob, dict) else None - if isinstance(views, dict) and isinstance(views.get(view_id), dict): - return views[view_id], str(owner) - return None, "" - - -def _mode_of(view: dict) -> str: - """The view's display mode. Absent means `grid` — the stored default is "say nothing".""" - cfg = (view or {}).get("config") if isinstance(view, dict) else None - disp = (cfg or {}).get("display") if isinstance(cfg, dict) else None - return str((disp or {}).get("mode") or "grid") if isinstance(disp, dict) else "grid" - - -def _may_administer_view(session: Session, table_key: str, view_id: str): - """`(view, mode)` when this caller may publish THIS view, else raises. R5's creator-or-admin. - - ⛔ `session.uname` / `session.admin`, NOT `username` / `is_admin`. `Session` has no such - attributes, and reading one raises ABOVE this route's own `try:` — which is how D-107 arrived - as a bare plain-text 500 rather than as our JSON envelope. - """ - import core.table_store as table_store - - if not table_key.startswith("ut_"): - # The Odoo/registry grids are read-through mirrors with their own permission wall; a - # published projection of one would be a second, secret-gated door onto tenant data whose - # visibility the module gate is supposed to decide. - raise err(400, "not_a_database", "you can publish views on your own databases only") - # ⛔ A READ-THROUGH DATABASE CANNOT BE PUBLISHED, AND THE REFUSAL SAYS SO RATHER THAN MINTING - # A LINK THAT WILL NOT OPEN. `startswith("ut_")` admits `ut_odoo_*` and `ut_meta_*`, whose rows - # do not live in the tenant document at all — they are windowed out of the DuckDB mirror - # through a Session-bound path (`ut_assembly`, which 409s `window_required` on the big ones, - # D-174's lineage). An unauthenticated route has no session and therefore no such path, so a - # token minted here would resolve to a page that could never render. - # ⚠ THIS IS R6's SECOND SENTENCE, WHICH IS THE HALF THAT GETS DROPPED: a limit that genuinely - # cannot be removed must be REPORTED, with its cause, never silently enforced. Refusing at the - # MINT — where a person is standing in front of the answer — is the only place that reads as a - # sentence rather than as an empty page. - import core.user_tables as user_tables - - if user_tables.is_connected(table_key, st=session.runtime): - raise err(400, "connected_source", - "a database that reads through a connected source (Odoo, Meta Ads) cannot be " - "published as a public link: its rows are served from the tenant's mirror by a " - "signed-in request, and a public link has no session to serve them with") - view, owner = _find_view(session.runtime, table_key, view_id) - if not isinstance(view, dict): - raise err(404, "no_such_view", "that view no longer exists") - if not (owner == session.uname - or table_store._may_administer(view, session.uname, session.admin)): - raise err(403, "not_yours", "only this view's creator or an admin can publish it") - mode = _mode_of(view) - if mode not in PUBLISHABLE_MODES: - raise err(400, "not_an_interface", - "only an interface view (Map, Catalog, Swipe, Time-series, Form) can be " - "published as a link") - cfg = view.get("config") or {} - if cfg.get("cohortLock"): - raise err(400, "reader_scoped", - "this view is locked to a cohort, and a cohort's membership is resolved for " - "the person reading it — a public link has no reader, so the page would show " - "no rows at all. Publish a copy without the cohort lock.") - if _needs_a_reader(cfg.get("filters")): - raise err(400, "reader_scoped", - "this view filters on a cohort, a measure rule or a top-N slice, and each of " - "those is resolved for the person reading it — a public link has no reader, so " - "the page would show no rows at all. Publish a copy filtered on columns.") - return view, mode - - -#: How many rows a published page will serve. ⚠ R6 (no cap on connected-source data) does not -#: reach here twice over: a publishable database is `records_mutable`, i.e. the EDITABLE substrate -#: that keeps its bound (`user_tables.MAX_ROWS`), and a connected one is refused at the mint. What -#: R6's SECOND sentence does reach here is the reporting duty — a page that serves fewer rows than -#: the view has must SAY SO on the wire, with the cause, never just stop. -PUBLIC_ROW_CAP = 5000 - - -def _needs_a_reader(tree) -> bool: - """Does this filter tree contain a leaf that only a SIGNED-IN reader could resolve? - - ⛔ COHORTS, MEASURE RULES AND RANK SLICES ARE NOT PROPERTIES OF THE VIEW. Each is a SET the - host computes for the person asking — `filter_eval.EvalCtx`'s own docstring: *"each is an - answer a single ROW cannot compute … absent, the condition matches NOTHING rather than - everything"*. That default is right (it fails closed) and it is unusable here: an anonymous - page whose every row was silently filtered out is indistinguishable from a broken link, and - the reader has nobody to ask. Worse, `rank_sets` has NO server-side resolver anywhere in this - repo — `routes_alerts._evaluate` passes cohort/measure/today and nothing else — so a `topN` - view would serve zero rows on the server while showing twenty in the browser. - - ⭐ So such a view is refused AT THE MINT, where a person is standing in front of the answer. - That is R6's second sentence: a limit that genuinely cannot be removed is REPORTED with its - cause, never silently enforced. - - ⚠ THE PREDICATES ARE `filter_eval`'S OWN. Re-implementing "is this a cohort leaf?" here would - be a second evaluator that agrees today and drifts the day the leaf shape changes — and it - would drift SILENTLY, because the two answers only differ on views nobody has published yet. - [[one-evaluator-per-question]] - """ - from harness import filter_eval - - def walk(node) -> bool: - if isinstance(node, list): - return any(walk(n) for n in node) - if not isinstance(node, dict): - return False - if node.get("kind") == "group" or isinstance(node.get("children"), list): - return any(walk(n) for n in (node.get("children") or [])) - if filter_eval._is_cohort(node) or filter_eval._is_measure(node): - return True - return node.get("op") in filter_eval.RANK_OPS - - return walk(tree) - - -def _mirror_display(rt, table_key: str, view_id: str, published: bool, access: str = "password"): - """Keep contract C1's two PRESENTATIONAL flags on the stored view in step with this bucket. - - ⛔ WHY THIS EXISTS AT ALL, and it was found by a reviewer rather than by a gate: revoking a - link dropped the token and left `config.display.published: true` on the view, so the grid's - own UI would go on saying "published" about a link that no longer resolves. Two records of one - fact, and only one of them moving, is the [[flag-shipped-without-its-writer]] shape — here with - the writer present and the OTHER half forgotten. - - ⚠ THE BUCKET REMAINS THE TRUTH. These flags exist so the client has something to render - without asking; they are a MIRROR, never a source, and nothing in this module reads them back - to decide anything. `read_view_link` deliberately reports both so a drift is visible rather - than assumed away. - - ⚠ ONLY THE TWO LEGAL KEYS ARE WRITTEN, with E's fail-closed coercion reproduced exactly - (`aios_grid._clean_display`: `published: True` always carries a `publishAccess`, and anything - that is not the literal `public` stores as `password`) — so a value written here and a value - written by the browser cannot disagree. - """ - def _set(cur): - cur = dict(cur or {}) - for owner, blob in list(cur.items()): - views = (blob or {}).get("views") if isinstance(blob, dict) else None - view = views.get(view_id) if isinstance(views, dict) else None - if not isinstance(view, dict): - continue - cfg = dict(view.get("config") or {}) - disp = dict(cfg.get("display") or {}) - if not disp.get("mode"): - # No display block means no interface view; nothing here should invent one. - continue - if published: - disp["published"] = True - disp["publishAccess"] = "public" if access == "public" else "password" - else: - disp.pop("published", None) - disp.pop("publishAccess", None) - cfg["display"] = disp - cur[owner] = {**blob, "views": {**views, view_id: {**view, "config": cfg}}} - return cur - - try: - rt.update(f"{table_key}_table_workspace", _set, flush="sync") - except Exception: # noqa: BLE001 - # ⚠ SWALLOWED, and deliberately: the token bucket is the truth and it has already been - # written. A mirror that failed to update leaves the UI one refresh out of date, which is - # strictly better than a 500 on a publish that actually succeeded. - pass - - -def _entry_for(rt, table_key: str, view_id: str): - """`(token, entry)` for a view's existing link, or `(None, None)`.""" - for token, where in _tokens(rt).items(): - if (isinstance(where, dict) and where.get("table") == table_key - and where.get("view") == view_id): - return str(token), where - return None, None - - -def _state_of(token, entry) -> dict: - """The link's state as the SHARER may see it. Built key by key. - - ⛔ NO `**entry`. The stored blob carries `pw` and `salt`; a spread would put both on the wire - to the browser, which is the whole failure this file's bucket exists to avoid, and it would do - it silently the first time somebody added a field. - """ - if not token or not isinstance(entry, dict): - return {"published": False, "access": "password", "token": "", "url": ""} - return { - "published": True, - "access": "public" if entry.get("access") == "public" else "password", - "token": str(token), - "url": _link_of(str(token)), - # A boolean, never the digest and never the salt. - "hasPassphrase": bool(entry.get("pw")), - "createdBy": str(entry.get("createdBy") or ""), - } - - -def _resolve(token: str): - """`(runtime, tenant_slug, table_key, view, entry)` for a token, or None. - - ⛔ THE INDEX IS A POINTER, NEVER A PERMISSION. Holding a token means the sharer minted it for - THIS view; it does not mean the holder may read anything else, and nothing downstream of here - may widen the subject beyond the `(table, view)` pair the entry names. - - ⚠ IT WALKS EVERY TENANT, because an unauthenticated request carries no tenant. That is the - form door's shape too, and the reason `_same` is constant-time: the walk compares the caller's - string against every stored token in the deployment. - """ - from harness import runtime as _rt - - if not token or len(token) < 16: - return None - for slug in _rt.known_tenants(): - try: - rt = _rt.get_runtime(slug) - except Exception: # noqa: BLE001 - continue - for stored, where in _tokens(rt).items(): - if not _same(str(stored), token) or not isinstance(where, dict): - continue - table_key = str(where.get("table") or "") - view, _owner = _find_view(rt, table_key, str(where.get("view") or "")) - # A view deleted, or re-saved as a grid, since the link was minted. Both answer the - # ONE refusal — "that link is not valid" — rather than explaining which. - if not table_key or not isinstance(view, dict): - return None - if _mode_of(view) not in PUBLISHABLE_MODES: - return None - return rt, slug, table_key, view, where - return None - - -def _coord(value, limit: float): - """A real coordinate, or `None`. THE WALL that lets a map publish without publishing a column. - - ⛔ A VALUE TEST, NEVER A NAME TEST. A field merely CALLED `lat` proves nothing — a verifier put - the string `CANARY-LAT-AAA` in one and watched it reach the wire under the first version of - this code. Anything that is not a finite number inside the earth's range is not a location and - does not travel. - ⚠ `bool` is rejected explicitly: `isinstance(True, int)` is True in Python, and `float(True)` - is `1.0` — a checkbox column named `lat` would otherwise publish as a point off the coast of - Ghana. - """ - if value is None or isinstance(value, bool): - return None - try: - n = float(value) - except (TypeError, ValueError): - return None - return n if n == n and abs(n) <= limit else None - - -def _visible_keys(view: dict, fields: list) -> list: - """The columns this view SHOWS, in the view's own order. R5's projection clause, in one place. - - ⛔ `config.order` IS NOT THE ANSWER AND IS THE OBVIOUS WRONG ONE. `aios_grid._default_view_config` - builds `order` as `shown + hidden`, and `grid_events`' `view_upsert` APPENDS every remaining - field to it — so `order` is every column the table has, hidden ones included. `visible` is the - only allowlist that exists; there is no stored "hidden" key to subtract. - - ⚠ AND AN EMPTY `visible` IS NOT "NO COLUMNS". `CustomerGrid` falls back to the table's default - set when a saved view carries none, so a public page that read `[]` as an empty allowlist would - render blank — and one that read it as "all fields" would LEAK. The fallback is the same - predicate the default config uses (`field.default is not False`), taken from `aios_grid` rather - than restated here. - """ - import aios_grid - - by_key = {str(f.get("key")): f for f in fields if isinstance(f, dict)} - stored = [str(k) for k in (((view or {}).get("config") or {}).get("visible") or [])] - keep = [k for k in stored if k in by_key] - if keep: - return keep - # ⛔⛔ W33-T69 — A STALE `visible` MUST SERVE NOTHING, NOT THE TABLE DEFAULT. - # `delete_field` never prunes a view's stored `visible`, so a published view whose columns were - # later deleted and replaced arrives here with a NON-EMPTY `stored` of which nothing survives — - # and the fallback below then WIDENS the public payload to whatever the table declares by - # default. The publisher chose five columns; the anonymous reader gets the table's idea of - # sensible. That is a widening on the one unauthenticated door in the product. - # ⚠ THE DISTINCTION IS `stored` NON-EMPTY, NOT `keep` EMPTY. A view that never stored `visible` - # at all (a legacy publish, a view saved before the key existed) has no intent to honour and - # the default IS the right answer for it — that is what the fallback was written for. A view - # that stored five keys and has none left DID state an intent, and every column it named is - # gone: the honest answer is no columns, which renders as an empty published view rather than - # somebody else's data. - if stored: - return [] - try: - default_visible = (aios_grid._default_view_config(fields) or {}).get("visible") or [] - except Exception: # noqa: BLE001 - default_visible = [] - fallback = [str(k) for k in default_visible if str(k) in by_key] - return fallback or [str(f.get("key")) for f in fields if f.get("default") is not False] - - -def _public_view(rt, table_key: str, view: dict) -> dict: - """The ONLY bytes a valid token buys. Built KEY BY KEY — there is no `**row` in this function. - - ⛔ THE REASON THAT IS A RULE AND NOT A STYLE. `aios_grid.rows_from_pool` puts `pid`, `_created`, - `lat` and `lon` on EVERY row regardless of what the view shows, and the stored row dict carries - every column the table has. Serialising a row and deleting the fields we do not want inverts - the failure: a column added next wave is INCLUDED by default and nobody notices, whereas an - allowlist that has not learned about it merely omits it. `routes_forms._public_form` is the - shipped precedent and says the same thing about itself. - """ - import core.user_tables as user_tables - from harness import filter_eval - - defn = user_tables.get(table_key, st=rt) or {} - fields = [f for f in (defn.get("fields") or []) if isinstance(f, dict)] - by_key = {str(f.get("key")): f for f in fields} - keys = _visible_keys(view, fields) - cfg = (view or {}).get("config") or {} - - # The row pool: the table's own rows, narrowed to the fields it declares, exactly as - # `routes_tables.scoped_pool` builds it for a materialised table. ⚠ A read-through table has - # no rows here and is refused at the MINT, so this branch is the only one that can be reached. - rows_src = [] - for rid, row in (defn.get("rows") or {}).items(): - if not str(rid).isdigit(): - continue - r = {k: v for k, v in (row or {}).items() if k in by_key} - r["pid"] = int(rid) - rows_src.append(r) - rows_src.sort(key=lambda r: r["pid"]) - - # The view's own row selection, through the SHARED evaluator. `_needs_a_reader` has already - # refused anything this context could not answer, so an empty result here means the filter - # genuinely matches nothing — not that we failed to resolve it. - ctx = filter_eval.EvalCtx(today=time.strftime("%Y-%m-%d")) - keep = set(filter_eval.visible_pids(cfg.get("filters"), rows_src, fields, ctx, - member_pids=cfg.get("memberPids"))) - chosen = [r for r in rows_src if r.get("pid") in keep] - - # ⛔⛔ COORDINATES RIDE A MAP WHEN THEY ARE COORDINATES — NOT WHEN THEY ARE VISIBLE, AND NOT - # BECAUSE OF WHAT A COLUMN IS CALLED. Two verifiers, one from each side, are why this reads - # the way it does; the first fix I wrote was wrong and the second report proved it. - # - # ⚠ THE LEAK (verifier #1, driven): a HIDDEN field keyed `lat` holding the string - # `CANARY-LAT-AAA` came out on the wire, because the pair was emitted before the `keys` - # projection and the bypass keyed on the FIELD NAME rather than on the value being a - # coordinate. So a column called `lat` could carry anything — a note, an address — and publish - # it. That is the real defect. - # - # ⛔ MY FIRST FIX GATED ON VISIBILITY, AND IT BROKE THE FEATURE (verifier #2): hiding the raw - # decimals is the NORMAL way somebody builds a Map view — nobody wants `38.7223` in the column - # list — so gating on `visible` meant an ordinary map published a page with no map, under two - # messages that contradicted each other ("no rows carry a location" vs "this view hides its - # location columns"). - # - # ⭐ THE RULE THAT SATISFIES BOTH: publishing a MAP is publishing WHERE THE ROWS ARE — that is - # what the sharer chose — so a real coordinate rides whether or not its column is shown, and a - # value that is not a coordinate never rides at all. `_coord` is the whole wall, and it is a - # VALUE test, so no naming convention can smuggle anything past it. - # ⚠ It mirrors `PublishedView.MapPlot`'s own `coord()` deliberately: the client must not plot - # what the server would not send, and the server must not send what the client would discard. - # Two normalizers on one question is a smell [[one-question-two-normalizers]] — kept here - # because they sit on opposite sides of a trust boundary, where the server's copy is the wall - # and the client's is display hygiene. - mode = _mode_of(view) - plotted = 0 - if mode == "map": - for r in chosen: - if _coord(r.get("lat"), 90) is not None and _coord(r.get("lon"), 180) is not None: - plotted += 1 - - limits = [] - if mode == "map" and chosen and not plotted: - # R6's second sentence. A map with nothing on it must say WHY — and this says the true - # why, which is about the DATA, because visibility is no longer part of the answer. - limits.append({ - "subject": "map", "effect": "not_plotted", - "detail": f"none of these {len(chosen)} rows carry a usable location", - "recommendation": "add `lat` and `lon` values to the records, then reload this link", - }) - if len(chosen) > PUBLIC_ROW_CAP: - # R6's second sentence. A short page that does not say it is short is the silent - # truncation the rule is actually about. - limits.append({ - "subject": "rows", "effect": "windowed", - "detail": f"this view has {len(chosen)} rows and a published page serves the first " - f"{PUBLIC_ROW_CAP}", - "recommendation": "narrow the view's filters, or share it with named people instead " - "of publishing a link", - }) - chosen = chosen[:PUBLIC_ROW_CAP] - - # ⛔ THE DISPLAY REFS ARE INTERSECTED WITH `visible`, NOT UNIONED INTO IT. A Map that colours - # by a column the view HIDES would otherwise put that column's value on every public row — - # the projection leak, arriving through the renderer rather than through the column list. The - # fail-closed choice is to drop the ref and render the map without colour; a published page - # that is slightly plainer beats one that ships a hidden column. - disp_in = (cfg.get("display") or {}) - display = {"mode": mode} - for ref in ("dateField", "stackField", "titleField", "colorField", "sizeField"): - if disp_in.get(ref) in keys: - display[ref] = disp_in[ref] - - return { - "title": str((view or {}).get("name") or "")[:200], - "mode": display["mode"], - "display": display, - "columns": [{"key": k, - "label": str(by_key[k].get("label") or k), - "type": str(by_key[k].get("type") or "text"), - **({"options": [str(o) for o in by_key[k]["options"]][:200]} - if isinstance(by_key[k].get("options"), list) and by_key[k].get("options") - else {})} - for k in keys], - # KEY BY KEY. `pid` rides because the client needs a stable row identity to render a list; - # it is a row NUMBER within this table and names nothing outside it. - # ⛔⛔ `lat`/`lon` RIDE ONLY ON A MAP **AND ONLY WHEN THE VIEW SHOWS THEM** — and the second - # half was missing, which was a LEAK. Found by a verifier that drove this route with a - # hidden field keyed `lat` carrying the string `CANARY-LAT-AAA`, and watched it come out - # on the wire. - # - # The first version emitted the pair BEFORE the `keys` projection, so `_visible_keys` never - # gated it. On a `ut_*` table coordinates are not magic: `routes_tables.scoped_pool` builds - # its row as `{k: v for k, v in row.items() if k in field_keys}`, so a value only survives - # if the table DECLARES a field keyed `lat`/`lon` — i.e. they are ORDINARY COLUMNS, and a - # view can hide them like any other. Hiding them therefore has to work here, because on a - # published page **the projection is the only wall there is** (`ut_*` databases have no - # hidden-field closure behind it, `routes_shares.py`'s docstring). - # - # ⚠ The bypass was keyed on the FIELD NAME, never on the value being a coordinate, so it - # forwarded whatever a column called `lat` happened to hold — a string, a note, anything. - # ⚠ And the shipped gate could not see it: it asserted the key NAMES rode on a map and not - # on a catalog, over a fixture whose rows carried no `lat` key at all — so it pinned the - # names while both values were `None` [[gate-answers-the-wrong-question]]. - # KEY BY KEY. `pid` rides because the client needs a stable row identity; it is a row - # NUMBER within this table and names nothing outside it. `lat`/`lon` ride only on a map, - # and only when they PARSE as coordinates — see the block above for why that is the test. - "rows": [{"pid": r.get("pid"), - **({"lat": _coord(r.get("lat"), 90), "lon": _coord(r.get("lon"), 180)} - if mode == "map" - and _coord(r.get("lat"), 90) is not None - and _coord(r.get("lon"), 180) is not None else {}), - **{k: r.get(k) for k in keys}} for r in chosen], - "total": len(chosen), - **({"limits": limits} if limits else {}), - } - - -async def _bounded_body(request: Request) -> dict: - """The request body, read WITH A BOUND — never `Body(...)`, never `await request.body()`. - - FastAPI reads and JSON-parses the WHOLE body before the handler's first line runs, so a - declared model is not a bound at all; `content-length` is caller-supplied, so checking it is - not one either. Streaming with a running total is the actual bound. - """ - size, chunks = 0, [] - async for chunk in request.stream(): - size += len(chunk) - if size > MAX_BODY_BYTES: - raise err(413, "body_too_large", "that request is too large") - chunks.append(chunk) - import json - try: - parsed = json.loads(b"".join(chunks) or b"{}") - except ValueError: - raise err(400, "bad_request", "that request could not be read") - return parsed if isinstance(parsed, dict) else {} - - -# ── THE SHARER'S DOOR (authenticated, creator-or-admin) ────────────────────────────────────── -# -# ⚠ THE NOUN IS `/view-link`, DELIBERATELY NOT `/views/{...}/publish`, and it mirrors the form -# door's `/form-link` for the same reason: a literal path segment sitting beside a `/{token}` -# wildcard is resolved by DECLARATION ORDER, and a noun that cannot collide with a token has no -# order to get wrong. - - -@router.get("/view-link") -def read_view_link(topic: str = "", view: str = "", - session: Session = Depends(require_session)): - """This view's link state. ⛔ A GET MUST NOT MINT — opening the panel is not publishing.""" - v, _mode = _may_administer_view(session, str(topic or ""), str(view or "")) - token, entry = _entry_for(session.runtime, str(topic), str(view)) - out = _state_of(token, entry) - # The presentational flags contract C1 put on the view spec, echoed back so the client can - # tell whether the two agree. They are a MIRROR of this bucket, never its source. - disp = ((v.get("config") or {}).get("display") or {}) if isinstance(v, dict) else {} - out["displayPublished"] = disp.get("published") is True - return out - - -@router.post("/view-link") -async def mint_view_link(request: Request, session: Session = Depends(require_session)): - """Publish this view, or change its access. `{topic, view, access?, passphrase?, rotate?}`. - - IDEMPOTENT WITHOUT `rotate`: opening the panel twice, or switching public↔password, must not - invalidate a link somebody already sent. `rotate: true` mints a fresh token and the previous - one dies in the SAME write, so there is never a window where both open the view. - """ - body = await _bounded_body(request) - topic, view = str(body.get("topic") or ""), str(body.get("view") or "") - _v, _mode = _may_administer_view(session, topic, view) - - access = "public" if body.get("access") == "public" else "password" - raw_pw = body.get("passphrase") - passphrase = "" if raw_pw is None else str(raw_pw) - if len(passphrase) > MAX_PASSPHRASE: - raise err(400, "passphrase_too_long", - f"a passphrase can be at most {MAX_PASSPHRASE} characters") - - existing_token, existing = _entry_for(session.runtime, topic, view) - rotate = bool(body.get("rotate")) - fresh = secrets.token_urlsafe(TOKEN_BYTES) - - # ⛔ A `password` LINK MUST END UP WITH A HASH, and there are exactly two ways to have one: - # the caller supplied a passphrase now, or one was already stored and is being kept. Anything - # else is refused HERE rather than stored and refused later — a link that cannot be opened by - # anybody is not a safe default, it is a broken feature that reads as a permission bug. - if access == "password": - if passphrase and len(passphrase) < MIN_PASSPHRASE: - raise err(400, "passphrase_too_short", - f"a passphrase needs at least {MIN_PASSPHRASE} characters") - if not passphrase and not (existing or {}).get("pw"): - raise err(400, "passphrase_required", - "a password-protected link needs a passphrase") - - minted = {"token": existing_token or fresh} - - def _set(cur): - cur = dict(cur or {}) - prev = None - for stored, where in list(cur.items()): - if (isinstance(where, dict) and where.get("table") == topic - and where.get("view") == view): - prev = dict(where) - cur.pop(stored, None) - if not rotate: - minted["token"] = str(stored) - if rotate or prev is None: - minted["token"] = fresh - entry = {"table": topic, "view": view, "access": access, - "createdBy": str((prev or {}).get("createdBy") or session.uname), - "createdAt": float((prev or {}).get("createdAt") or time.time())} - # ⛔ THE STORED PASSPHRASE SURVIVES A TRIP THROUGH `public`, and the first version DROPPED - # it — found by a verifier that traced the toggle rather than the happy path. Publishing - # as `public` skipped this block entirely, so the hash was destroyed while the TOKEN was - # kept; switching back to `password` then demanded a new passphrase, silently, while the - # panel's own sentence promised the opposite ("leave blank to keep the current one"). - # ⚠ Carrying it is inert, not lax: `_pw_ok` is consulted ONLY when `access == "password"` - # (`get_published`/`open_published` both test it first), and `_state_of` exposes a boolean, - # never the digest. A hash nobody can reach is not a secret in use — but a promise the UI - # makes and the store breaks is a defect either way, and the honest fix is to keep the - # promise rather than to reword it. - if passphrase: - salt = secrets.token_bytes(PW_SALT_BYTES) - entry["salt"] = salt.hex() - entry["iter"] = PW_ITERATIONS - entry["pw"] = _hash_pw(passphrase, salt) - elif (prev or {}).get("pw"): - # Carried key by key, so a future field on the entry is not silently inherited. - entry["salt"] = str((prev or {}).get("salt") or "") - entry["iter"] = int((prev or {}).get("iter") or PW_ITERATIONS) - entry["pw"] = str((prev or {}).get("pw") or "") - cur[minted["token"]] = entry - return cur - - session.runtime.update(TOKENS_KEY, _set, flush="sync") - _mirror_display(session.runtime, topic, view, True, access) - token, entry = _entry_for(session.runtime, topic, view) - # ⛔ NO FABRICATED FALLBACK ENTRY HERE. The first version answered - # `_state_of(token or minted["token"], entry or {"access": access, "pw": "x"})`, and that - # `"pw": "x"` would have reported `hasPassphrase: true` about a PUBLIC link if the read-back - # ever came back empty — a lie in the safe-looking direction, which is the kind that survives. - # If the write cannot be read back, say so; do not describe a state nobody verified. - if not entry: - raise err(503, "not_saved", - "the link was minted but could not be read back — reload and try again") - return _state_of(token, entry) - - -@router.delete("/view-link") -async def revoke_view_link(request: Request, session: Session = Depends(require_session)): - """Unpublish. `{topic, view}`. - - ⛔ REVOKE ROTATES — it does not merely unset a flag. The token STRING is dropped from the - index in this write, so the old link stops resolving immediately; and because a later publish - mints `secrets.token_urlsafe(24)` afresh, the revoked string can never come back. An - implementation that kept the token and flipped an `enabled` flag would leave the secret live - in the store, one bug away from working again, and would make "revoked" a property somebody - could forget to check on a code path added later. - """ - body = await _bounded_body(request) - topic, view = str(body.get("topic") or ""), str(body.get("view") or "") - _may_administer_view(session, topic, view) - - def _drop(cur): - cur = dict(cur or {}) - for stored, where in list(cur.items()): - if (isinstance(where, dict) and where.get("table") == topic - and where.get("view") == view): - cur.pop(stored, None) - return cur - - session.runtime.update(TOKENS_KEY, _drop, flush="sync") - # ⛔ AND THE MIRROR COMES DOWN IN THE SAME BREATH. Without this the grid goes on showing - # "published" about a link that no longer resolves — the half a reviewer caught. - _mirror_display(session.runtime, topic, view, False) - return {"published": False, "access": "password", "token": "", "url": ""} - - -# ── THE PUBLIC DOOR (no session — this is the whole feature) ───────────────────────────────── -# -# ⛔ NO `Depends(require_session)` ON EITHER ROUTE BELOW, DELIBERATELY. "Public" in this app is not -# a flag or an allow-list entry — `main.py` has no auth middleware and no exempt-path table; a -# route is public exactly by omitting the dependency. Which is why the two are kept together, -# under one banner, rather than filed beside the sharer's routes they resemble. - - -@router.get("/published/{token}") -def get_published(token: str, request: Request): - """The published view. Read-only, unauthenticated, projected to the view's own columns.""" - if not _rate_ok(_client_ip(request), time.time()): - raise err(429, "too_many_requests", "too many requests — wait a moment and try again") - found = _resolve(token) - if not found: - # ⛔⛔ W33-T70 — AN UNKNOWN TOKEN ANSWERS EXACTLY WHAT A LOCKED ONE ANSWERS. - # This used to `raise _refuse()`, and that 403 was a free oracle: one unauthenticated GET, - # no passphrase, no cost, told an enumerator whether a token was REAL. `_refuse`'s own - # docstring says the difference between "no such link" and "that link was revoked" must - # never be observable — and the route beside it published that difference in its status - # code. Sorting real tokens from fake ones is the whole of the work; once it is free, the - # passphrase is all that is left and it can be attacked offline-cheap. - # ⚠ SO THE UNKNOWN TOKEN GETS THE LOCKED SHAPE: `{"locked": true}` and nothing else — no - # title, no columns, no count, the same bytes a real password link returns before anyone - # has tried to open it. The guess then costs a POST with a passphrase, which is rate - # limited and PBKDF2-priced. A PUBLIC link still opens on this GET, which is what a public - # link is for; what stops being visible is which PASSWORD tokens exist. - _note_failure(_client_ip(request), time.time()) - _equalise_pw_cost(None) - return {"locked": True} - rt, _slug, table_key, view, entry = found - if entry.get("access") == "password": - # ⛔ THE SHAPE OF THE PASSWORD ANSWER, and it is not a refusal. A locked link must render - # a passphrase prompt, so this 200 says "there is something here and it is locked" and - # NOTHING else — no title, no column names, no row count. A 403 here would be - # indistinguishable from a bad token, which is right for a WRONG passphrase and wrong for - # a link the holder has not tried to open yet. - return {"locked": True} - return _public_view(rt, table_key, view) - - -@router.post("/published/{token}") -async def open_published(token: str, request: Request): - """Open a password-protected link. `{passphrase}`. - - ⛔ A WRONG PASSPHRASE AND A WRONG TOKEN ANSWER THE SAME 403, from the same `_refuse`. If they - differed, the route would confirm which tokens are real to anybody willing to send one guess — - and a 24-byte token's entire protection is that it cannot be found by guessing. - ��� The passphrase is compared against a PBKDF2 digest with `hmac.compare_digest`, and is never - stored, logged or echoed. D-130's scar: a form's invited-address list is IDENTIFICATION and - anyone may claim an identity; this is AUTHENTICATION and is treated as one. - """ - now = time.time() - if not _rate_ok(_client_ip(request), now): - raise err(429, "too_many_requests", "too many requests — wait a moment and try again") - body = await _bounded_body(request) - found = _resolve(token) - if not found: - # ⛔⛔ W33-T70 — SPEND THE PBKDF2 ANYWAY. A wrong token skipped the hash entirely and - # answered in ~0.4 ms while a wrong passphrase paid ~82 ms: a 206x gap, zero overlap in 24 - # samples, and a clean separation of real tokens from fake ones for anyone with a stopwatch. - # Both arms now cost the same, so the identical 403 above is finally identical in practice - # rather than only in wording. - _note_failure(_client_ip(request), now) - _equalise_pw_cost(None) - raise _refuse() - rt, _slug, table_key, view, entry = found - if entry.get("access") == "password" and not _pw_ok(entry, str(body.get("passphrase") or "")): - _note_failure(_client_ip(request), now) - raise _refuse() - # ⚠ A link with NO passphrase must still pay, or "this token is public" is readable from the - # clock on a route whose whole job is to be uninformative. - if entry.get("access") != "password": - _equalise_pw_cost(entry) - return _public_view(rt, table_key, view) +"""routes_publish.py — PUBLISH AN INTERFACE VIEW AS A LINK (wave 33, owner item 8b, ruling R5). + +The owner, verbatim: *"Have the ability to publish interface, when a user's view is an interface +(e.g. Map or Catalog), we should have the ability to publish a link that can be either accessed +publicly or with a password that the user that share it can toggle."* + +R5, in five clauses, and every one of them is load-bearing: + * publish = a per-view secret token in a **server-only bucket**; + * a `public | password` toggle the SHARER owns, with a **hashed** passphrase beside it; + * an **unauthenticated** read-only route plus a `#/v/` client route; + * the publish surface **projects only the columns the view shows** — never the whole table; + * **creator-or-admin** may publish, and **revoke ROTATES** the token. + +⛔ THIS FILE COPIES `routes_forms.py`'S CONSTRUCTION ON PURPOSE, AND THE TICKET SAID TO. That door +has been public since wave 23 and carries scar tissue no fresh design would reproduce: ONE refusal +for every resolution failure (so the route is not an oracle for which tokens are real), a +constant-time compare, a STREAMING body bound rather than `Body(...)` (FastAPI reads and parses the +whole body before the handler's first line, and `content-length` is caller-supplied), a sliding +rate window rather than a fixed bucket, and an index that is a POINTER, never a permission. + +⚠ WHAT IS DELIBERATELY *NOT* SHARED WITH `routes_forms.py`: the bucket. A form token buys a WRITE +door into a table; a publish token buys a READ projection of one view. Folding them into one index +would make a single leaked string ambiguous about which of the two it opens, and would make +`_resolve` return an object whose capability depends on a field rather than on which door was +knocked. Two buckets, two resolvers, one shape. + +⚠ AND NOT SHARED WITH SHARING. **Three systems now answer some version of "who can see this"** and +they are not the same question (audit S-4): the grant registry drives *Shared with me*, +`table_store.is_shared` GATES opening a view inside the app, and a published link is a THIRD thing +— an unauthenticated read of a projection, bound to a secret rather than to an account. A published +link is NOT a grant: it creates no registry row, appears in nobody's *Shared with me*, and cannot +be revoked by removing a person, because there is no person. + + python verify_forms.py # this file's gate; section 6 onward +""" +import hashlib +import hmac +import os +import secrets +import time + +from fastapi import APIRouter, Depends, Request + +from deps import Session, err, require_session + +router = APIRouter(prefix="/api/v1") + +#: The SERVER-ONLY index: `{token: {table, view, access, pw?, salt?, iter?, createdBy, createdAt}}`. +#: ⛔ IT IS NEVER `display.*`. Contract C1 puts two PRESENTATIONAL flags on the view spec +#: (`published`, `publishAccess`) precisely so the client has something to render, and the browser +#: writes that spec on every autosave — so anything secret living there would be echoed back to the +#: browser by construction. `aios_grid._clean_display`'s allowlist enforces the other half. +TOKENS_KEY = "publish_tokens" +TOKEN_BYTES = 24 + +#: Passphrase storage. PBKDF2-HMAC-SHA256 with a per-link salt. +#: ⚠ D-130 IS THE SCAR THIS AVOIDS: a form's "invited addresses" list is IDENTIFICATION — it says +#: who you claim to be and anyone may claim it. A passphrase is AUTHENTICATION. The difference is +#: not a stronger string, it is that the secret is never stored, never logged and never echoed. +PW_ITERATIONS = 240_000 +PW_SALT_BYTES = 16 +MIN_PASSPHRASE = 6 +MAX_PASSPHRASE = 128 + +#: v1 request protections, the same shape and the same numbers as the form door (contract C9), so +#: the two public routes cannot drift into different postures. ⚠ In-process, therefore PER WORKER. +RATE_WINDOW_S = 60 +RATE_PER_WINDOW = 30 +MAX_BODY_BYTES = 16 * 1024 + +_HITS: dict = {} + +#: The view kinds that may be published, i.e. R5's "interface". +#: ⚠ THE CLIENT'S SOURCE OF TRUTH IS `customer-grid/iconShapes.ts::MODE_GROUP` (wave 33 item 9 +#: moved `swipe` and `timeseries` into this group). This constant is the SERVER's copy and the two +#: are held in step by `verify_forms.py`, which parses `MODE_GROUP` and compares — because a +#: server list that silently drifts from the picker means a mode a user can create and cannot +#: publish, with nothing anywhere going red. A `grid` or `kanban` view is a re-shaping of a row +#: set; publishing one would be publishing the table, which is exactly what R5's projection clause +#: exists to prevent. +#: ⛔⛔ W33-T68 — `form` IS DELIBERATELY ABSENT, AND ITS ABSENCE IS THE FIX. +#: A `form` view's rows ARE the submissions people have sent it. Every other mode here re-shapes a +#: row set the publisher already curated; a form's row set is other people's answers, gathered under +#: an implicit promise that they go to the owner. Publishing one turned "share this interface" into +#: "serve the responses to anyone holding the link" — on the one unauthenticated door in the +#: product, with no wall between the link and the data. +#: ⚠ AND A FORM ALREADY HAS ITS OWN PUBLIC DOOR: `#/form/` through `routes_forms.py`, which +#: serves the BLANK form for submitting and never the stored rows. So this is not a capability +#: removed, it is a second door onto the same object that should never have existed beside the +#: first. Publishing a form to be filled in still works, at the URL that was always for it. +#: ⚠ `verify_forms.py` holds this list in step with the client's `MODE_GROUP`; the client must not +#: offer Publish on a form view, or the picker promises what this refuses. +PUBLISHABLE_MODES = ("map", "catalog", "swipe", "timeseries") + + +def _refuse(): + """THE ONE REFUSAL, for every way a published link can fail to resolve. + + Wrong token, revoked link, deleted view, deleted table, a view that stopped being an + interface, a wrong passphrase. ⛔ Callers must not branch a more specific message out of it: + the difference between "no such link" and "that link was revoked" tells an enumerator which + tokens are real, and the difference between "no such link" and "wrong passphrase" tells them + which links are worth guessing at. + """ + return err(403, "bad_publish_token", "that link is not valid") + + +def _same(a: str, b: str) -> bool: + """Constant-time. A `==` leaks a secret's prefix through timing, one character at a time.""" + return hmac.compare_digest(str(a or ""), str(b or "")) + + +def _client_ip(request: Request) -> str: + """⛔⛔ W33-T70 — `x-forwarded-for` IS GONE FROM THIS FUNCTION, AND THAT WAS THE WHOLE HOLE. + + The header is chosen by the caller. Keying a rate limit on it means an enumerator writes a new + value per request and every request lands in a fresh bucket: MEASURED at **100 of 100 admitted + through a 30-per-window ceiling**. A limiter with a caller-chosen key is not a limiter, and its + old docstring said the header was "a rate-limit key and NOTHING else" — which was exactly the + use it could not support. It is safe as a LOG field and as nothing else. + + The socket peer is the only thing here the caller cannot choose, so it is the key. + ⚠ AND BEHIND HF'S PROXY EVERY CALLER SHARES ONE PEER, which is why `_rate_ok` counts FAILURES + ONLY (see there). A per-peer ceiling over ALL traffic would be one global bucket, i.e. an alarm + that fires for everyone [[alarm-that-fires-for-everyone]] — the honest reading of a shared peer + is that we cannot separate callers, not that we should throttle them together. + """ + return request.client.host if request.client else "?" + + +def _rate_ok(key: str, now: float) -> bool: + """Is this caller UNDER the failure ceiling? Pure check — call `_note_failure` to count. + + A SLIDING window. A fixed bucket lets a caller spend a whole allowance at 11:59:59 and the + whole next one at 12:00:00 — i.e. double the limit, back to back, against a door whose entire + protection is that guessing a 24-byte token is slow. + + ⛔ W33-T70 — IT COUNTS FAILURES, NOT REQUESTS, and the split is what makes a shared proxy peer + survivable. A legitimate reader opens a link that RESOLVES, so they never touch the counter at + all; an enumerator produces nothing but misses. Counting every request under one shared peer + would have denied service to everybody the moment one guesser showed up — trading a token + oracle for an outage is not a fix. + """ + seen = [t for t in _HITS.get(key, ()) if now - t < RATE_WINDOW_S] + _HITS[key] = seen + return len(seen) < RATE_PER_WINDOW + + +def _note_failure(key: str, now: float) -> None: + """Record one failed resolution against this peer, and keep the table bounded.""" + seen = [t for t in _HITS.get(key, ()) if now - t < RATE_WINDOW_S] + seen.append(now) + _HITS[key] = seen + if len(_HITS) > 4096: + for k in [k for k, v in _HITS.items() if not v or now - v[-1] > RATE_WINDOW_S]: + _HITS.pop(k, None) + + +#: A salt used ONLY to burn the same PBKDF2 a real verification would, when there is no stored hash +#: to check against. Its value is irrelevant; its COST is the point. +_DUMMY_SALT = b"\x00" * 16 + + +def _equalise_pw_cost(entry) -> None: + """⛔⛔ W33-T70 — SPEND THE PBKDF2 EVEN WHEN THERE IS NOTHING TO VERIFY. + + MEASURED before this existed: a wrong TOKEN answered in ~0.4 ms and a wrong PASSPHRASE in + ~82 ms — a **206x gap with zero overlap across 24 samples**. So the refusal's careful wording + ("that link is not valid", identical for both) was undone by the clock: anyone could sort real + tokens from fake ones by timing alone, then spend their guesses only on the real ones. The + docstring on `_refuse` describes exactly the leak the code then handed over for free. + + Called on every path that fails BEFORE a passphrase check would have happened, so the cheap + branch costs what the expensive one costs. `iter` is taken from the entry when there is one, so + a link minted under a different iteration count stays indistinguishable too. + """ + iterations = PW_ITERATIONS + if isinstance(entry, dict): + try: + iterations = int(entry.get("iter") or PW_ITERATIONS) + except (TypeError, ValueError): + iterations = PW_ITERATIONS + _hash_pw("", _DUMMY_SALT, iterations) + + +def _public_base() -> str: + return (os.environ.get("AIOS_PUBLIC_BASE") or os.environ.get("APP_BASE_URL") or "").rstrip("/") + + +def _link_of(token: str) -> str: + return f"{_public_base()}/#/v/{token}" + + +def _tokens(rt) -> dict: + """This tenant's publish index, always a dict.""" + try: + found = rt.get(TOKENS_KEY) or {} + except Exception: # noqa: BLE001 + return {} + return found if isinstance(found, dict) else {} + + +def _hash_pw(passphrase: str, salt: bytes, iterations: int = PW_ITERATIONS) -> str: + return hashlib.pbkdf2_hmac("sha256", str(passphrase).encode("utf-8"), + salt, int(iterations)).hex() + + +def _pw_ok(entry: dict, passphrase: str) -> bool: + """Constant-time verify against the stored digest. + + ⛔ RETURNS FALSE, NEVER RAISES, and never distinguishes "this link has no passphrase stored" + from "the passphrase is wrong" — a `password` link whose hash went missing must refuse, not + fall open. [[default-must-pass-its-own-guard]] + """ + stored, salt_hex = str(entry.get("pw") or ""), str(entry.get("salt") or "") + if not stored or not salt_hex: + return False + try: + salt = bytes.fromhex(salt_hex) + except ValueError: + return False + got = _hash_pw(passphrase, salt, int(entry.get("iter") or PW_ITERATIONS)) + return _same(got, stored) + + +def _find_view(rt, table_key: str, view_id: str): + """`(view, owner_username)` from the table's workspace bucket, or `(None, "")`. + + The bucket shape is `{username: {views: {id: view}}}` — the same walk `routes_forms._find_view` + does, and the reason a view is addressable by id ALONE here: the id is unique within the table, + while the owner is what we are trying to discover. + """ + if not table_key or not view_id: + return None, "" + try: + bucket = rt.get(f"{table_key}_table_workspace") or {} + except Exception: # noqa: BLE001 + return None, "" + if not isinstance(bucket, dict): + return None, "" + for owner, blob in bucket.items(): + views = (blob or {}).get("views") if isinstance(blob, dict) else None + if isinstance(views, dict) and isinstance(views.get(view_id), dict): + return views[view_id], str(owner) + return None, "" + + +def _mode_of(view: dict) -> str: + """The view's display mode. Absent means `grid` — the stored default is "say nothing".""" + cfg = (view or {}).get("config") if isinstance(view, dict) else None + disp = (cfg or {}).get("display") if isinstance(cfg, dict) else None + return str((disp or {}).get("mode") or "grid") if isinstance(disp, dict) else "grid" + + +def _may_administer_view(session: Session, table_key: str, view_id: str): + """`(view, mode)` when this caller may publish THIS view, else raises. R5's creator-or-admin. + + ⛔ `session.uname` / `session.admin`, NOT `username` / `is_admin`. `Session` has no such + attributes, and reading one raises ABOVE this route's own `try:` — which is how D-107 arrived + as a bare plain-text 500 rather than as our JSON envelope. + """ + import core.table_store as table_store + + if not table_key.startswith("ut_"): + # The Odoo/registry grids are read-through mirrors with their own permission wall; a + # published projection of one would be a second, secret-gated door onto tenant data whose + # visibility the module gate is supposed to decide. + raise err(400, "not_a_database", "you can publish views on your own databases only") + # ⛔ A READ-THROUGH DATABASE CANNOT BE PUBLISHED, AND THE REFUSAL SAYS SO RATHER THAN MINTING + # A LINK THAT WILL NOT OPEN. `startswith("ut_")` admits `ut_odoo_*` and `ut_meta_*`, whose rows + # do not live in the tenant document at all — they are windowed out of the DuckDB mirror + # through a Session-bound path (`ut_assembly`, which 409s `window_required` on the big ones, + # D-174's lineage). An unauthenticated route has no session and therefore no such path, so a + # token minted here would resolve to a page that could never render. + # ⚠ THIS IS R6's SECOND SENTENCE, WHICH IS THE HALF THAT GETS DROPPED: a limit that genuinely + # cannot be removed must be REPORTED, with its cause, never silently enforced. Refusing at the + # MINT — where a person is standing in front of the answer — is the only place that reads as a + # sentence rather than as an empty page. + import core.user_tables as user_tables + + if user_tables.is_connected(table_key, st=session.runtime): + raise err(400, "connected_source", + "a database that reads through a connected source (Odoo, Meta Ads) cannot be " + "published as a public link: its rows are served from the tenant's mirror by a " + "signed-in request, and a public link has no session to serve them with") + view, owner = _find_view(session.runtime, table_key, view_id) + if not isinstance(view, dict): + raise err(404, "no_such_view", "that view no longer exists") + if not (owner == session.uname + or table_store._may_administer(view, session.uname, session.admin)): + raise err(403, "not_yours", "only this view's creator or an admin can publish it") + mode = _mode_of(view) + if mode not in PUBLISHABLE_MODES: + raise err(400, "not_an_interface", + "only an interface view (Map, Catalog, Swipe, Time-series, Form) can be " + "published as a link") + cfg = view.get("config") or {} + if cfg.get("cohortLock"): + raise err(400, "reader_scoped", + "this view is locked to a cohort, and a cohort's membership is resolved for " + "the person reading it — a public link has no reader, so the page would show " + "no rows at all. Publish a copy without the cohort lock.") + if _needs_a_reader(cfg.get("filters")): + raise err(400, "reader_scoped", + "this view filters on a cohort, a measure rule or a top-N slice, and each of " + "those is resolved for the person reading it — a public link has no reader, so " + "the page would show no rows at all. Publish a copy filtered on columns.") + return view, mode + + +#: How many rows a published page will serve. ⚠ R6 (no cap on connected-source data) does not +#: reach here twice over: a publishable database is `records_mutable`, i.e. the EDITABLE substrate +#: that keeps its bound (`user_tables.MAX_ROWS`), and a connected one is refused at the mint. What +#: R6's SECOND sentence does reach here is the reporting duty — a page that serves fewer rows than +#: the view has must SAY SO on the wire, with the cause, never just stop. +PUBLIC_ROW_CAP = 5000 + + +def _needs_a_reader(tree) -> bool: + """Does this filter tree contain a leaf that only a SIGNED-IN reader could resolve? + + ⛔ COHORTS, MEASURE RULES AND RANK SLICES ARE NOT PROPERTIES OF THE VIEW. Each is a SET the + host computes for the person asking — `filter_eval.EvalCtx`'s own docstring: *"each is an + answer a single ROW cannot compute … absent, the condition matches NOTHING rather than + everything"*. That default is right (it fails closed) and it is unusable here: an anonymous + page whose every row was silently filtered out is indistinguishable from a broken link, and + the reader has nobody to ask. Worse, `rank_sets` has NO server-side resolver anywhere in this + repo — `routes_alerts._evaluate` passes cohort/measure/today and nothing else — so a `topN` + view would serve zero rows on the server while showing twenty in the browser. + + ⭐ So such a view is refused AT THE MINT, where a person is standing in front of the answer. + That is R6's second sentence: a limit that genuinely cannot be removed is REPORTED with its + cause, never silently enforced. + + ⚠ THE PREDICATES ARE `filter_eval`'S OWN. Re-implementing "is this a cohort leaf?" here would + be a second evaluator that agrees today and drifts the day the leaf shape changes — and it + would drift SILENTLY, because the two answers only differ on views nobody has published yet. + [[one-evaluator-per-question]] + """ + from harness import filter_eval + + def walk(node) -> bool: + if isinstance(node, list): + return any(walk(n) for n in node) + if not isinstance(node, dict): + return False + if node.get("kind") == "group" or isinstance(node.get("children"), list): + return any(walk(n) for n in (node.get("children") or [])) + if filter_eval._is_cohort(node) or filter_eval._is_measure(node): + return True + return node.get("op") in filter_eval.RANK_OPS + + return walk(tree) + + +def _mirror_display(rt, table_key: str, view_id: str, published: bool, access: str = "password"): + """Keep contract C1's two PRESENTATIONAL flags on the stored view in step with this bucket. + + ⛔ WHY THIS EXISTS AT ALL, and it was found by a reviewer rather than by a gate: revoking a + link dropped the token and left `config.display.published: true` on the view, so the grid's + own UI would go on saying "published" about a link that no longer resolves. Two records of one + fact, and only one of them moving, is the [[flag-shipped-without-its-writer]] shape — here with + the writer present and the OTHER half forgotten. + + ⚠ THE BUCKET REMAINS THE TRUTH. These flags exist so the client has something to render + without asking; they are a MIRROR, never a source, and nothing in this module reads them back + to decide anything. `read_view_link` deliberately reports both so a drift is visible rather + than assumed away. + + ⚠ ONLY THE TWO LEGAL KEYS ARE WRITTEN, with E's fail-closed coercion reproduced exactly + (`aios_grid._clean_display`: `published: True` always carries a `publishAccess`, and anything + that is not the literal `public` stores as `password`) — so a value written here and a value + written by the browser cannot disagree. + """ + def _set(cur): + cur = dict(cur or {}) + for owner, blob in list(cur.items()): + views = (blob or {}).get("views") if isinstance(blob, dict) else None + view = views.get(view_id) if isinstance(views, dict) else None + if not isinstance(view, dict): + continue + cfg = dict(view.get("config") or {}) + disp = dict(cfg.get("display") or {}) + if not disp.get("mode"): + # No display block means no interface view; nothing here should invent one. + continue + if published: + disp["published"] = True + disp["publishAccess"] = "public" if access == "public" else "password" + else: + disp.pop("published", None) + disp.pop("publishAccess", None) + cfg["display"] = disp + cur[owner] = {**blob, "views": {**views, view_id: {**view, "config": cfg}}} + return cur + + try: + rt.update(f"{table_key}_table_workspace", _set, flush="sync") + except Exception: # noqa: BLE001 + # ⚠ SWALLOWED, and deliberately: the token bucket is the truth and it has already been + # written. A mirror that failed to update leaves the UI one refresh out of date, which is + # strictly better than a 500 on a publish that actually succeeded. + pass + + +def _entry_for(rt, table_key: str, view_id: str): + """`(token, entry)` for a view's existing link, or `(None, None)`.""" + for token, where in _tokens(rt).items(): + if (isinstance(where, dict) and where.get("table") == table_key + and where.get("view") == view_id): + return str(token), where + return None, None + + +def _state_of(token, entry) -> dict: + """The link's state as the SHARER may see it. Built key by key. + + ⛔ NO `**entry`. The stored blob carries `pw` and `salt`; a spread would put both on the wire + to the browser, which is the whole failure this file's bucket exists to avoid, and it would do + it silently the first time somebody added a field. + """ + if not token or not isinstance(entry, dict): + return {"published": False, "access": "password", "token": "", "url": ""} + return { + "published": True, + "access": "public" if entry.get("access") == "public" else "password", + "token": str(token), + "url": _link_of(str(token)), + # A boolean, never the digest and never the salt. + "hasPassphrase": bool(entry.get("pw")), + "createdBy": str(entry.get("createdBy") or ""), + } + + +def _resolve(token: str): + """`(runtime, tenant_slug, table_key, view, entry)` for a token, or None. + + ⛔ THE INDEX IS A POINTER, NEVER A PERMISSION. Holding a token means the sharer minted it for + THIS view; it does not mean the holder may read anything else, and nothing downstream of here + may widen the subject beyond the `(table, view)` pair the entry names. + + ⚠ IT WALKS EVERY TENANT, because an unauthenticated request carries no tenant. That is the + form door's shape too, and the reason `_same` is constant-time: the walk compares the caller's + string against every stored token in the deployment. + """ + from harness import runtime as _rt + + if not token or len(token) < 16: + return None + for slug in _rt.known_tenants(): + try: + rt = _rt.get_runtime(slug) + except Exception: # noqa: BLE001 + continue + for stored, where in _tokens(rt).items(): + if not _same(str(stored), token) or not isinstance(where, dict): + continue + table_key = str(where.get("table") or "") + view, _owner = _find_view(rt, table_key, str(where.get("view") or "")) + # A view deleted, or re-saved as a grid, since the link was minted. Both answer the + # ONE refusal — "that link is not valid" — rather than explaining which. + if not table_key or not isinstance(view, dict): + return None + if _mode_of(view) not in PUBLISHABLE_MODES: + return None + return rt, slug, table_key, view, where + return None + + +def _coord(value, limit: float): + """A real coordinate, or `None`. THE WALL that lets a map publish without publishing a column. + + ⛔ A VALUE TEST, NEVER A NAME TEST. A field merely CALLED `lat` proves nothing — a verifier put + the string `CANARY-LAT-AAA` in one and watched it reach the wire under the first version of + this code. Anything that is not a finite number inside the earth's range is not a location and + does not travel. + ⚠ `bool` is rejected explicitly: `isinstance(True, int)` is True in Python, and `float(True)` + is `1.0` — a checkbox column named `lat` would otherwise publish as a point off the coast of + Ghana. + """ + if value is None or isinstance(value, bool): + return None + try: + n = float(value) + except (TypeError, ValueError): + return None + return n if n == n and abs(n) <= limit else None + + +def _visible_keys(view: dict, fields: list) -> list: + """The columns this view SHOWS, in the view's own order. R5's projection clause, in one place. + + ⛔ `config.order` IS NOT THE ANSWER AND IS THE OBVIOUS WRONG ONE. `aios_grid._default_view_config` + builds `order` as `shown + hidden`, and `grid_events`' `view_upsert` APPENDS every remaining + field to it — so `order` is every column the table has, hidden ones included. `visible` is the + only allowlist that exists; there is no stored "hidden" key to subtract. + + ⚠ AND AN EMPTY `visible` IS NOT "NO COLUMNS". `CustomerGrid` falls back to the table's default + set when a saved view carries none, so a public page that read `[]` as an empty allowlist would + render blank — and one that read it as "all fields" would LEAK. The fallback is the same + predicate the default config uses (`field.default is not False`), taken from `aios_grid` rather + than restated here. + """ + import aios_grid + + by_key = {str(f.get("key")): f for f in fields if isinstance(f, dict)} + stored = [str(k) for k in (((view or {}).get("config") or {}).get("visible") or [])] + keep = [k for k in stored if k in by_key] + if keep: + return keep + # ⛔⛔ W33-T69 — A STALE `visible` MUST SERVE NOTHING, NOT THE TABLE DEFAULT. + # `delete_field` never prunes a view's stored `visible`, so a published view whose columns were + # later deleted and replaced arrives here with a NON-EMPTY `stored` of which nothing survives — + # and the fallback below then WIDENS the public payload to whatever the table declares by + # default. The publisher chose five columns; the anonymous reader gets the table's idea of + # sensible. That is a widening on the one unauthenticated door in the product. + # ⚠ THE DISTINCTION IS `stored` NON-EMPTY, NOT `keep` EMPTY. A view that never stored `visible` + # at all (a legacy publish, a view saved before the key existed) has no intent to honour and + # the default IS the right answer for it — that is what the fallback was written for. A view + # that stored five keys and has none left DID state an intent, and every column it named is + # gone: the honest answer is no columns, which renders as an empty published view rather than + # somebody else's data. + if stored: + return [] + try: + default_visible = (aios_grid._default_view_config(fields) or {}).get("visible") or [] + except Exception: # noqa: BLE001 + default_visible = [] + fallback = [str(k) for k in default_visible if str(k) in by_key] + return fallback or [str(f.get("key")) for f in fields if f.get("default") is not False] + + +def _public_view(rt, table_key: str, view: dict) -> dict: + """The ONLY bytes a valid token buys. Built KEY BY KEY — there is no `**row` in this function. + + ⛔ THE REASON THAT IS A RULE AND NOT A STYLE. `aios_grid.rows_from_pool` puts `pid`, `_created`, + `lat` and `lon` on EVERY row regardless of what the view shows, and the stored row dict carries + every column the table has. Serialising a row and deleting the fields we do not want inverts + the failure: a column added next wave is INCLUDED by default and nobody notices, whereas an + allowlist that has not learned about it merely omits it. `routes_forms._public_form` is the + shipped precedent and says the same thing about itself. + """ + import core.user_tables as user_tables + from harness import filter_eval + + defn = user_tables.get(table_key, st=rt) or {} + fields = [f for f in (defn.get("fields") or []) if isinstance(f, dict)] + by_key = {str(f.get("key")): f for f in fields} + keys = _visible_keys(view, fields) + cfg = (view or {}).get("config") or {} + + # The row pool: the table's own rows, narrowed to the fields it declares, exactly as + # `routes_tables.scoped_pool` builds it for a materialised table. ⚠ A read-through table has + # no rows here and is refused at the MINT, so this branch is the only one that can be reached. + rows_src = [] + for rid, row in (defn.get("rows") or {}).items(): + if not str(rid).isdigit(): + continue + r = {k: v for k, v in (row or {}).items() if k in by_key} + r["pid"] = int(rid) + rows_src.append(r) + rows_src.sort(key=lambda r: r["pid"]) + + # The view's own row selection, through the SHARED evaluator. `_needs_a_reader` has already + # refused anything this context could not answer, so an empty result here means the filter + # genuinely matches nothing — not that we failed to resolve it. + ctx = filter_eval.EvalCtx(today=time.strftime("%Y-%m-%d")) + keep = set(filter_eval.visible_pids(cfg.get("filters"), rows_src, fields, ctx, + member_pids=cfg.get("memberPids"))) + chosen = [r for r in rows_src if r.get("pid") in keep] + + # ⛔⛔ COORDINATES RIDE A MAP WHEN THEY ARE COORDINATES — NOT WHEN THEY ARE VISIBLE, AND NOT + # BECAUSE OF WHAT A COLUMN IS CALLED. Two verifiers, one from each side, are why this reads + # the way it does; the first fix I wrote was wrong and the second report proved it. + # + # ⚠ THE LEAK (verifier #1, driven): a HIDDEN field keyed `lat` holding the string + # `CANARY-LAT-AAA` came out on the wire, because the pair was emitted before the `keys` + # projection and the bypass keyed on the FIELD NAME rather than on the value being a + # coordinate. So a column called `lat` could carry anything — a note, an address — and publish + # it. That is the real defect. + # + # ⛔ MY FIRST FIX GATED ON VISIBILITY, AND IT BROKE THE FEATURE (verifier #2): hiding the raw + # decimals is the NORMAL way somebody builds a Map view — nobody wants `38.7223` in the column + # list — so gating on `visible` meant an ordinary map published a page with no map, under two + # messages that contradicted each other ("no rows carry a location" vs "this view hides its + # location columns"). + # + # ⭐ THE RULE THAT SATISFIES BOTH: publishing a MAP is publishing WHERE THE ROWS ARE — that is + # what the sharer chose — so a real coordinate rides whether or not its column is shown, and a + # value that is not a coordinate never rides at all. `_coord` is the whole wall, and it is a + # VALUE test, so no naming convention can smuggle anything past it. + # ⚠ It mirrors `PublishedView.MapPlot`'s own `coord()` deliberately: the client must not plot + # what the server would not send, and the server must not send what the client would discard. + # Two normalizers on one question is a smell [[one-question-two-normalizers]] — kept here + # because they sit on opposite sides of a trust boundary, where the server's copy is the wall + # and the client's is display hygiene. + mode = _mode_of(view) + plotted = 0 + if mode == "map": + for r in chosen: + if _coord(r.get("lat"), 90) is not None and _coord(r.get("lon"), 180) is not None: + plotted += 1 + + limits = [] + if mode == "map" and chosen and not plotted: + # R6's second sentence. A map with nothing on it must say WHY — and this says the true + # why, which is about the DATA, because visibility is no longer part of the answer. + limits.append({ + "subject": "map", "effect": "not_plotted", + "detail": f"none of these {len(chosen)} rows carry a usable location", + "recommendation": "add `lat` and `lon` values to the records, then reload this link", + }) + if len(chosen) > PUBLIC_ROW_CAP: + # R6's second sentence. A short page that does not say it is short is the silent + # truncation the rule is actually about. + limits.append({ + "subject": "rows", "effect": "windowed", + "detail": f"this view has {len(chosen)} rows and a published page serves the first " + f"{PUBLIC_ROW_CAP}", + "recommendation": "narrow the view's filters, or share it with named people instead " + "of publishing a link", + }) + chosen = chosen[:PUBLIC_ROW_CAP] + + # ⛔ THE DISPLAY REFS ARE INTERSECTED WITH `visible`, NOT UNIONED INTO IT. A Map that colours + # by a column the view HIDES would otherwise put that column's value on every public row — + # the projection leak, arriving through the renderer rather than through the column list. The + # fail-closed choice is to drop the ref and render the map without colour; a published page + # that is slightly plainer beats one that ships a hidden column. + disp_in = (cfg.get("display") or {}) + display = {"mode": mode} + for ref in ("dateField", "stackField", "titleField", "colorField", "sizeField"): + if disp_in.get(ref) in keys: + display[ref] = disp_in[ref] + + return { + "title": str((view or {}).get("name") or "")[:200], + "mode": display["mode"], + "display": display, + "columns": [{"key": k, + "label": str(by_key[k].get("label") or k), + "type": str(by_key[k].get("type") or "text"), + **({"options": [str(o) for o in by_key[k]["options"]][:200]} + if isinstance(by_key[k].get("options"), list) and by_key[k].get("options") + else {})} + for k in keys], + # KEY BY KEY. `pid` rides because the client needs a stable row identity to render a list; + # it is a row NUMBER within this table and names nothing outside it. + # ⛔⛔ `lat`/`lon` RIDE ONLY ON A MAP **AND ONLY WHEN THE VIEW SHOWS THEM** — and the second + # half was missing, which was a LEAK. Found by a verifier that drove this route with a + # hidden field keyed `lat` carrying the string `CANARY-LAT-AAA`, and watched it come out + # on the wire. + # + # The first version emitted the pair BEFORE the `keys` projection, so `_visible_keys` never + # gated it. On a `ut_*` table coordinates are not magic: `routes_tables.scoped_pool` builds + # its row as `{k: v for k, v in row.items() if k in field_keys}`, so a value only survives + # if the table DECLARES a field keyed `lat`/`lon` — i.e. they are ORDINARY COLUMNS, and a + # view can hide them like any other. Hiding them therefore has to work here, because on a + # published page **the projection is the only wall there is** (`ut_*` databases have no + # hidden-field closure behind it, `routes_shares.py`'s docstring). + # + # ⚠ The bypass was keyed on the FIELD NAME, never on the value being a coordinate, so it + # forwarded whatever a column called `lat` happened to hold — a string, a note, anything. + # ⚠ And the shipped gate could not see it: it asserted the key NAMES rode on a map and not + # on a catalog, over a fixture whose rows carried no `lat` key at all — so it pinned the + # names while both values were `None` [[gate-answers-the-wrong-question]]. + # KEY BY KEY. `pid` rides because the client needs a stable row identity; it is a row + # NUMBER within this table and names nothing outside it. `lat`/`lon` ride only on a map, + # and only when they PARSE as coordinates — see the block above for why that is the test. + "rows": [{"pid": r.get("pid"), + **({"lat": _coord(r.get("lat"), 90), "lon": _coord(r.get("lon"), 180)} + if mode == "map" + and _coord(r.get("lat"), 90) is not None + and _coord(r.get("lon"), 180) is not None else {}), + **{k: r.get(k) for k in keys}} for r in chosen], + "total": len(chosen), + **({"limits": limits} if limits else {}), + } + + +async def _bounded_body(request: Request) -> dict: + """The request body, read WITH A BOUND — never `Body(...)`, never `await request.body()`. + + FastAPI reads and JSON-parses the WHOLE body before the handler's first line runs, so a + declared model is not a bound at all; `content-length` is caller-supplied, so checking it is + not one either. Streaming with a running total is the actual bound. + """ + size, chunks = 0, [] + async for chunk in request.stream(): + size += len(chunk) + if size > MAX_BODY_BYTES: + raise err(413, "body_too_large", "that request is too large") + chunks.append(chunk) + import json + try: + parsed = json.loads(b"".join(chunks) or b"{}") + except ValueError: + raise err(400, "bad_request", "that request could not be read") + return parsed if isinstance(parsed, dict) else {} + + +# ── THE SHARER'S DOOR (authenticated, creator-or-admin) ────────────────────────────────────── +# +# ⚠ THE NOUN IS `/view-link`, DELIBERATELY NOT `/views/{...}/publish`, and it mirrors the form +# door's `/form-link` for the same reason: a literal path segment sitting beside a `/{token}` +# wildcard is resolved by DECLARATION ORDER, and a noun that cannot collide with a token has no +# order to get wrong. + + +@router.get("/view-link") +def read_view_link(topic: str = "", view: str = "", + session: Session = Depends(require_session)): + """This view's link state. ⛔ A GET MUST NOT MINT — opening the panel is not publishing.""" + v, _mode = _may_administer_view(session, str(topic or ""), str(view or "")) + token, entry = _entry_for(session.runtime, str(topic), str(view)) + out = _state_of(token, entry) + # The presentational flags contract C1 put on the view spec, echoed back so the client can + # tell whether the two agree. They are a MIRROR of this bucket, never its source. + disp = ((v.get("config") or {}).get("display") or {}) if isinstance(v, dict) else {} + out["displayPublished"] = disp.get("published") is True + return out + + +@router.post("/view-link") +async def mint_view_link(request: Request, session: Session = Depends(require_session)): + """Publish this view, or change its access. `{topic, view, access?, passphrase?, rotate?}`. + + IDEMPOTENT WITHOUT `rotate`: opening the panel twice, or switching public↔password, must not + invalidate a link somebody already sent. `rotate: true` mints a fresh token and the previous + one dies in the SAME write, so there is never a window where both open the view. + """ + body = await _bounded_body(request) + topic, view = str(body.get("topic") or ""), str(body.get("view") or "") + _v, _mode = _may_administer_view(session, topic, view) + + access = "public" if body.get("access") == "public" else "password" + raw_pw = body.get("passphrase") + passphrase = "" if raw_pw is None else str(raw_pw) + if len(passphrase) > MAX_PASSPHRASE: + raise err(400, "passphrase_too_long", + f"a passphrase can be at most {MAX_PASSPHRASE} characters") + + existing_token, existing = _entry_for(session.runtime, topic, view) + rotate = bool(body.get("rotate")) + fresh = secrets.token_urlsafe(TOKEN_BYTES) + + # ⛔ A `password` LINK MUST END UP WITH A HASH, and there are exactly two ways to have one: + # the caller supplied a passphrase now, or one was already stored and is being kept. Anything + # else is refused HERE rather than stored and refused later — a link that cannot be opened by + # anybody is not a safe default, it is a broken feature that reads as a permission bug. + if access == "password": + if passphrase and len(passphrase) < MIN_PASSPHRASE: + raise err(400, "passphrase_too_short", + f"a passphrase needs at least {MIN_PASSPHRASE} characters") + if not passphrase and not (existing or {}).get("pw"): + raise err(400, "passphrase_required", + "a password-protected link needs a passphrase") + + minted = {"token": existing_token or fresh} + + def _set(cur): + cur = dict(cur or {}) + prev = None + for stored, where in list(cur.items()): + if (isinstance(where, dict) and where.get("table") == topic + and where.get("view") == view): + prev = dict(where) + cur.pop(stored, None) + if not rotate: + minted["token"] = str(stored) + if rotate or prev is None: + minted["token"] = fresh + entry = {"table": topic, "view": view, "access": access, + "createdBy": str((prev or {}).get("createdBy") or session.uname), + "createdAt": float((prev or {}).get("createdAt") or time.time())} + # ⛔ THE STORED PASSPHRASE SURVIVES A TRIP THROUGH `public`, and the first version DROPPED + # it — found by a verifier that traced the toggle rather than the happy path. Publishing + # as `public` skipped this block entirely, so the hash was destroyed while the TOKEN was + # kept; switching back to `password` then demanded a new passphrase, silently, while the + # panel's own sentence promised the opposite ("leave blank to keep the current one"). + # ⚠ Carrying it is inert, not lax: `_pw_ok` is consulted ONLY when `access == "password"` + # (`get_published`/`open_published` both test it first), and `_state_of` exposes a boolean, + # never the digest. A hash nobody can reach is not a secret in use — but a promise the UI + # makes and the store breaks is a defect either way, and the honest fix is to keep the + # promise rather than to reword it. + if passphrase: + salt = secrets.token_bytes(PW_SALT_BYTES) + entry["salt"] = salt.hex() + entry["iter"] = PW_ITERATIONS + entry["pw"] = _hash_pw(passphrase, salt) + elif (prev or {}).get("pw"): + # Carried key by key, so a future field on the entry is not silently inherited. + entry["salt"] = str((prev or {}).get("salt") or "") + entry["iter"] = int((prev or {}).get("iter") or PW_ITERATIONS) + entry["pw"] = str((prev or {}).get("pw") or "") + cur[minted["token"]] = entry + return cur + + session.runtime.update(TOKENS_KEY, _set, flush="sync") + _mirror_display(session.runtime, topic, view, True, access) + token, entry = _entry_for(session.runtime, topic, view) + # ⛔ NO FABRICATED FALLBACK ENTRY HERE. The first version answered + # `_state_of(token or minted["token"], entry or {"access": access, "pw": "x"})`, and that + # `"pw": "x"` would have reported `hasPassphrase: true` about a PUBLIC link if the read-back + # ever came back empty — a lie in the safe-looking direction, which is the kind that survives. + # If the write cannot be read back, say so; do not describe a state nobody verified. + if not entry: + raise err(503, "not_saved", + "the link was minted but could not be read back — reload and try again") + return _state_of(token, entry) + + +@router.delete("/view-link") +async def revoke_view_link(request: Request, session: Session = Depends(require_session)): + """Unpublish. `{topic, view}`. + + ⛔ REVOKE ROTATES — it does not merely unset a flag. The token STRING is dropped from the + index in this write, so the old link stops resolving immediately; and because a later publish + mints `secrets.token_urlsafe(24)` afresh, the revoked string can never come back. An + implementation that kept the token and flipped an `enabled` flag would leave the secret live + in the store, one bug away from working again, and would make "revoked" a property somebody + could forget to check on a code path added later. + """ + body = await _bounded_body(request) + topic, view = str(body.get("topic") or ""), str(body.get("view") or "") + _may_administer_view(session, topic, view) + + def _drop(cur): + cur = dict(cur or {}) + for stored, where in list(cur.items()): + if (isinstance(where, dict) and where.get("table") == topic + and where.get("view") == view): + cur.pop(stored, None) + return cur + + session.runtime.update(TOKENS_KEY, _drop, flush="sync") + # ⛔ AND THE MIRROR COMES DOWN IN THE SAME BREATH. Without this the grid goes on showing + # "published" about a link that no longer resolves — the half a reviewer caught. + _mirror_display(session.runtime, topic, view, False) + return {"published": False, "access": "password", "token": "", "url": ""} + + +# ── THE PUBLIC DOOR (no session — this is the whole feature) ───────────────────────────────── +# +# ⛔ NO `Depends(require_session)` ON EITHER ROUTE BELOW, DELIBERATELY. "Public" in this app is not +# a flag or an allow-list entry — `main.py` has no auth middleware and no exempt-path table; a +# route is public exactly by omitting the dependency. Which is why the two are kept together, +# under one banner, rather than filed beside the sharer's routes they resemble. + + +@router.get("/published/{token}") +def get_published(token: str, request: Request): + """The published view. Read-only, unauthenticated, projected to the view's own columns.""" + if not _rate_ok(_client_ip(request), time.time()): + raise err(429, "too_many_requests", "too many requests — wait a moment and try again") + found = _resolve(token) + if not found: + # ⛔⛔ W33-T70 — AN UNKNOWN TOKEN ANSWERS EXACTLY WHAT A LOCKED ONE ANSWERS. + # This used to `raise _refuse()`, and that 403 was a free oracle: one unauthenticated GET, + # no passphrase, no cost, told an enumerator whether a token was REAL. `_refuse`'s own + # docstring says the difference between "no such link" and "that link was revoked" must + # never be observable — and the route beside it published that difference in its status + # code. Sorting real tokens from fake ones is the whole of the work; once it is free, the + # passphrase is all that is left and it can be attacked offline-cheap. + # ⚠ SO THE UNKNOWN TOKEN GETS THE LOCKED SHAPE: `{"locked": true}` and nothing else — no + # title, no columns, no count, the same bytes a real password link returns before anyone + # has tried to open it. The guess then costs a POST with a passphrase, which is rate + # limited and PBKDF2-priced. A PUBLIC link still opens on this GET, which is what a public + # link is for; what stops being visible is which PASSWORD tokens exist. + _note_failure(_client_ip(request), time.time()) + _equalise_pw_cost(None) + return {"locked": True} + rt, _slug, table_key, view, entry = found + if entry.get("access") == "password": + # ⛔ THE SHAPE OF THE PASSWORD ANSWER, and it is not a refusal. A locked link must render + # a passphrase prompt, so this 200 says "there is something here and it is locked" and + # NOTHING else — no title, no column names, no row count. A 403 here would be + # indistinguishable from a bad token, which is right for a WRONG passphrase and wrong for + # a link the holder has not tried to open yet. + return {"locked": True} + return _public_view(rt, table_key, view) + + +@router.post("/published/{token}") +async def open_published(token: str, request: Request): + """Open a password-protected link. `{passphrase}`. + + ⛔ A WRONG PASSPHRASE AND A WRONG TOKEN ANSWER THE SAME 403, from the same `_refuse`. If they + differed, the route would confirm which tokens are real to anybody willing to send one guess — + and a 24-byte token's entire protection is that it cannot be found by guessing. + ⚠ The passphrase is compared against a PBKDF2 digest with `hmac.compare_digest`, and is never + stored, logged or echoed. D-130's scar: a form's invited-address list is IDENTIFICATION and + anyone may claim an identity; this is AUTHENTICATION and is treated as one. + """ + now = time.time() + if not _rate_ok(_client_ip(request), now): + raise err(429, "too_many_requests", "too many requests — wait a moment and try again") + body = await _bounded_body(request) + found = _resolve(token) + if not found: + # ⛔⛔ W33-T70 — SPEND THE PBKDF2 ANYWAY. A wrong token skipped the hash entirely and + # answered in ~0.4 ms while a wrong passphrase paid ~82 ms: a 206x gap, zero overlap in 24 + # samples, and a clean separation of real tokens from fake ones for anyone with a stopwatch. + # Both arms now cost the same, so the identical 403 above is finally identical in practice + # rather than only in wording. + _note_failure(_client_ip(request), now) + _equalise_pw_cost(None) + raise _refuse() + rt, _slug, table_key, view, entry = found + if entry.get("access") == "password" and not _pw_ok(entry, str(body.get("passphrase") or "")): + _note_failure(_client_ip(request), now) + raise _refuse() + # ⚠ A link with NO passphrase must still pay, or "this token is public" is readable from the + # clock on a route whose whole job is to be uninformative. + if entry.get("access") != "password": + _equalise_pw_cost(entry) + return _public_view(rt, table_key, view)