| """routes_forms.py β β wave-23 item 8 (contract W23-C9): THE PUBLIC FORM DOOR. |
| |
| β BOTH ROUTES HERE ARE UNAUTHENTICATED, AND THAT IS THE POINT. A form link goes to somebody who |
| has no account β a supplier, a lead, a warehouse hand with a phone β so `Depends(require_session)` |
| is exactly what must NOT be on them. Everything that would normally be decided by a session is |
| therefore decided by the TOKEN, and this file is the whole of that decision: |
| |
| * the token is minted server-side (`secrets.token_urlsafe(24)` β 192 bits) and stored on ONE |
| view. There is no enumeration to brute-force and no id in the URL to increment. |
| * a wrong token answers ONE thing for every tenant, every table and every typo: 403 |
| `bad_form_token`. It never leaks whether a form exists, whether a tenant exists, or which of |
| the two you got wrong β the automation-hook pattern (`routes_automation.py:359-381`) one |
| level up, because a public door that answers 404-vs-403 is an oracle. |
| * the payload a valid token buys is the SMALLEST thing that can render a form: title, |
| description, submit label, and the fields with their labels, types and options. No tenant |
| slug, no table key, no row count, no field key that is not on the form, no username. |
| |
| WHAT AN ATTACKER WITH A VALID TOKEN CAN DO, stated plainly because it is the honest boundary: |
| append rows to ONE user table, up to the per-token daily cap, with values that pass this file's |
| validation. They cannot read a row, cannot see any other field, cannot reach another table, and |
| cannot cause an automation to run as anybody (`form_fired` is a HUMAN door by A's design β see |
| its contract note in the split doc). |
| |
| β THE `_DEV_FIXTURES` AUDIT (main.py:257-295) applied to this router, because that scar is |
| exactly "a thing became publicly reachable and nobody looked": the only bytes these routes can |
| return are the ones assembled in `_public_form` below, field by field. There is no passthrough of |
| a stored dict anywhere in this file β every response is built from named keys. |
| |
| β PROTECTIONS ARE IN-PROCESS AND THEREFORE PER WORKER. One Space process today, so the sliding |
| window is real; the day there are two, both halves of a limit are halved-per-worker rather than |
| bypassed. Booked as DEBT rather than described as a guarantee β a rate limit that quietly stops |
| being one is worse than none. |
| """ |
| 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") |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| TOKENS_KEY = "form_tokens" |
|
|
| |
| |
| TOKEN_BYTES = 24 |
|
|
| |
| |
| |
| |
| HONEYPOT_FIELD = "website_url" |
|
|
| |
| RATE_WINDOW_S = 60 |
| RATE_PER_WINDOW = 30 |
| DAILY_PER_TOKEN = 500 |
| MAX_BODY_BYTES = 16 * 1024 |
| |
| |
| MAX_VALUE_LEN = 4000 |
|
|
| |
| |
| |
| _HITS: dict = {} |
| _DAILY: dict = {} |
|
|
|
|
| def _rate_ok(ip: str, now: float) -> bool: |
| """A sliding window, not a fixed bucket. A fixed one lets a caller spend the whole allowance |
| at 11:59:59 and the whole next allowance at 12:00:00 β double the rate at the boundary, |
| which is the moment a script is most likely to be hammering.""" |
| seen = [t for t in _HITS.get(ip, ()) if now - t < RATE_WINDOW_S] |
| seen.append(now) |
| _HITS[ip] = 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) |
| return len(seen) <= RATE_PER_WINDOW |
|
|
|
|
| def _daily_ok(token: str, now: float) -> bool: |
| day = int(now // 86400) |
| d, n = _DAILY.get(token, (day, 0)) |
| if d != day: |
| d, n = day, 0 |
| n += 1 |
| _DAILY[token] = (d, n) |
| return n <= DAILY_PER_TOKEN |
|
|
|
|
| def _client_ip(request: Request) -> str: |
| """β `X-Forwarded-For`'s FIRST entry, and only because this app runs behind exactly one |
| proxy (the HF Space router). It is caller-controlled, so it is a rate-limit key and NOTHING |
| else β never a permission input. Falls back to the socket peer.""" |
| fwd = request.headers.get("x-forwarded-for") or "" |
| first = fwd.split(",")[0].strip() |
| return first or (request.client.host if request.client else "?") |
|
|
|
|
| def _refuse(): |
| """THE ONE REFUSAL. Every failure to resolve a token answers this β wrong token, disabled |
| form, deleted view, deleted table, a tenant whose store is down. Callers must not branch a |
| more specific message out of it: the difference between "no such form" and "that form is |
| disabled" tells an enumerator which tokens are real.""" |
| return err(403, "bad_form_token", "that form link is not valid") |
|
|
|
|
| def _same(a: str, b: str) -> bool: |
| """Constant-time. A `==` here leaks a token's prefix through timing, one character at a |
| time, which is the whole reason the automation hook compares this way too.""" |
| return hmac.compare_digest(str(a or ""), str(b or "")) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| def _find_view(rt, table_key: str, view_id: str): |
| """`(view, owner_username)` for one view in one table's workspace, or `(None, None)`. |
| |
| The bucket is `{username: {views: {id: view}}}` β one stratum per user plus the shared one β |
| and a view id is unique across them, so the first match is the view. |
| """ |
| try: |
| bucket = rt.get(f"{table_key}_table_workspace") or {} |
| except Exception: |
| return None, None |
| if not isinstance(bucket, dict): |
| return None, None |
| for owner, ws in bucket.items(): |
| if not isinstance(ws, dict): |
| continue |
| view = (ws.get("views") or {}).get(view_id) |
| if isinstance(view, dict): |
| return view, str(owner) |
| return None, None |
|
|
|
|
| def _spec_of(view) -> dict: |
| """The form spec on a view, or `{}`. One place knows the key path.""" |
| spec = (((view or {}).get("config") or {}).get("display") or {}).get("form") |
| return spec if isinstance(spec, dict) else {} |
|
|
|
|
| def _tokens(rt) -> dict: |
| """This tenant's `{token: {table, view}}` index, always a dict.""" |
| try: |
| found = rt.get(TOKENS_KEY) or {} |
| except Exception: |
| return {} |
| return found if isinstance(found, dict) else {} |
|
|
|
|
| def _resolve(token: str): |
| """`(runtime, tenant_slug, table_key, form_spec)` for a token, or None. |
| |
| β THE LOOP DOES NOT SHORT-CIRCUIT ON THE TENANT, only on the match. A `break` on the first |
| tenant whose store answered would make "the third tenant's form" depend on the first two |
| being healthy, and a form that stops working because somebody else's store blipped is the |
| kind of failure nobody can reproduce. |
| |
| β THE INDEX IS A POINTER, NEVER A PERMISSION. A token that resolves to a view which has since |
| been DELETED, or whose form spec has been removed, answers the same 403 as a token that never |
| existed β the index is written by the mint route and nothing deletes the tenant's views on its |
| behalf, so "the row is in the index" cannot be allowed to mean "the form is live". A stale |
| pointer outliving its view is exactly how a door that was closed stays open. |
| """ |
| 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: |
| 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 "")) |
| spec = _spec_of(view) |
| |
| |
| if not table_key or not spec.get("fields"): |
| return None |
| return rt, slug, table_key, spec |
| return None |
|
|
|
|
| |
|
|
|
|
| def _public_form(rt, table_key: str, spec: dict) -> dict: |
| """The ONLY bytes a valid token buys. Built key by key β there is no `**stored` anywhere in |
| this function, which is what makes "nothing else leaks" a property of the code rather than a |
| promise about what happens to be in the bag today. |
| |
| β The field ORDER is the form's own (`spec['fields']`), not the table's: the builder let |
| somebody arrange these questions, and re-deriving the order from the schema would silently |
| rearrange a form every time a column was added. |
| """ |
| import aios_grid |
| import core.user_tables as user_tables |
|
|
| defn = user_tables.get(table_key, st=rt) or {} |
| by_key = {f.get("key"): f for f in (defn.get("fields") or []) if isinstance(f, dict)} |
| required = {str(k) for k in (spec.get("required") or [])} |
| out = [] |
| for key in (spec.get("fields") or []): |
| f = by_key.get(str(key)) |
| |
| |
| if not f or f.get("source") not in (None, "overlay"): |
| continue |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if str(f.get("type") or "text") not in aios_grid.FORM_FIELD_TYPES: |
| continue |
| if user_tables.is_computed_cell(f): |
| continue |
| row = {"key": str(f.get("key")), "label": str(f.get("label") or f.get("key")), |
| "type": str(f.get("type") or "text"), "required": str(key) in required} |
| options = f.get("options") |
| if isinstance(options, list) and options: |
| row["options"] = [str(o) for o in options][:200] |
| if f.get("type") == "rating": |
| row["max"] = int(f.get("max") or 5) |
| out.append(row) |
| return { |
| "title": str(spec.get("title") or "")[:200], |
| "desc": str(spec.get("desc") or "")[:1000], |
| "submitLabel": str(spec.get("submitLabel") or "")[:60] or "Submit", |
| "fields": out, |
| "honeypot": HONEYPOT_FIELD, |
| |
| |
| |
| |
| |
| "requireEmail": spec.get("access") == "emails", |
| } |
|
|
|
|
| def _invited(spec: dict, email: str) -> bool: |
| """May this address submit? Case- and space-insensitive against the stored list. |
| |
| β **IDENTIFICATION, NOT AUTHENTICATION, and the honest boundary is stated rather than |
| implied** (this file's header does the same for the token). Nothing here proves the submitter |
| owns the address they typed: anyone holding the link who knows an invited address can pass. |
| What it buys is real but bounded β a link forwarded outside the invited group stops working |
| for whoever cannot name a member of it, and every submission carries the address it claimed. |
| A per-recipient link, which WOULD prove possession, is booked rather than half-built. |
| """ |
| if spec.get("access") != "emails": |
| return True |
| wanted = str(email or "").strip().lower() |
| return bool(wanted) and wanted in {str(e).strip().lower() |
| for e in (spec.get("emails") or [])} |
|
|
|
|
| |
|
|
|
|
| def _clean_values(fields: list, raw: dict): |
| """`(values, error_sentence)` β REFUSE, NEVER COERCE (C9, and the engine's own law). |
| |
| The temptation with a public form is to be forgiving: read "12 units" as 12, "yes" as |
| checked, "tomorrow" as a date. Every one of those writes a number nobody typed into somebody |
| else's database, and the person who submitted the form is not there to see it happen. So a |
| value that does not answer its own field's question is refused with a sentence naming the |
| FIELD LABEL β the only name the submitter has ever seen. |
| """ |
| out = {} |
| for f in fields: |
| key, label, ftype = f["key"], f["label"], f["type"] |
| v = raw.get(key) |
| text = "" if v is None else str(v) |
| text = text.strip() if isinstance(v, str) else text |
| if len(text) > MAX_VALUE_LEN: |
| return None, f"β{label}β is too long β {MAX_VALUE_LEN} characters at most." |
| if text == "": |
| if f.get("required"): |
| return None, f"β{label}β is required." |
| continue |
| if ftype in ("int", "currency", "pct", "rating"): |
| try: |
| num = float(text.replace(",", "")) |
| except ValueError: |
| return None, f"β{label}β must be a number." |
| if ftype == "rating": |
| top = int(f.get("max") or 5) |
| if not (1 <= num <= top) or num != int(num): |
| return None, f"β{label}β must be a whole number from 1 to {top}." |
| text = str(int(num)) if ftype in ("int", "rating") else str(num) |
| elif ftype == "checkbox": |
| |
| |
| if text.lower() not in ("1", "true", "on", "yes", "0", "false", "off", "no"): |
| return None, f"β{label}β must be checked or unchecked." |
| text = "1" if text.lower() in ("1", "true", "on", "yes") else "" |
| elif ftype in ("select", "status"): |
| options = f.get("options") or [] |
| if options and text not in options: |
| return None, f"β{label}β must be one of the listed choices." |
| elif ftype == "multiselect": |
| options = f.get("options") or [] |
| parts = [p.strip() for p in text.split(",") if p.strip()] |
| if options and any(p not in options for p in parts): |
| return None, f"β{label}β must be chosen from the listed choices." |
| text = ", ".join(parts) |
| elif ftype == "date": |
| |
| |
| if len(text) != 10 or text[4] != "-" or text[7] != "-": |
| return None, f"β{label}β must be a date." |
| try: |
| time.strptime(text, "%Y-%m-%d") |
| except ValueError: |
| return None, f"β{label}β must be a real date." |
| elif ftype == "email": |
| if "@" not in text or text.startswith("@") or text.endswith("@"): |
| return None, f"β{label}β must be an email address." |
| out[key] = text |
| return out, "" |
|
|
|
|
| |
|
|
|
|
| |
| |
| |
| |
| |
| |
|
|
|
|
| def _public_base() -> str: |
| """The origin a share link is built on. Empty = the client builds it from its own location. |
| |
| `AIOS_PUBLIC_BASE` is the same variable the OAuth callback resolves against, so a domain move |
| takes the form links with it. Absent, we return a PATH rather than guessing a hostname: |
| a link with the wrong origin looks right, gets pasted into an email and 404s for the reader. |
| """ |
| 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()}/#/form/{token}" |
|
|
|
|
| def _may_administer_view(session: Session, table_key: str, view_id: str): |
| """`(view, spec)` when this caller may publish THIS view, else raises. |
| |
| β `_may_administer`, deliberately, not `_may_edit`. Minting a public link is a PERMISSIONS |
| act β it opens a write door onto the tenant's table for anybody holding the URL β and |
| `table_store` already draws that line for views: a collaborator may edit a shared view, only |
| its creator or an admin may change who reaches it. Publishing is the strongest form of that |
| question, so it takes the narrower answer. |
| β A view sitting in the caller's OWN stratum is theirs even with no `createdBy` stamp (a |
| personal view predating the stamp has none), which `_may_administer` alone would refuse. |
| """ |
| import core.table_store as table_store |
|
|
| if not table_key.startswith("ut_"): |
| |
| |
| raise err(400, "not_a_database", "forms collect into your own databases only") |
| 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") |
| return view, _spec_of(view) |
|
|
|
|
| @router.get("/form-link") |
| def read_form_link(topic: str, view: str, session: Session = Depends(require_session)): |
| """This form's current share link, or `{"token": null}` β never a mint. |
| |
| β A GET MUST NOT MINT. The builder reads this every time the panel opens, and a route that |
| created a token on read would publish a form the moment somebody looked at the tab. |
| """ |
| _v, spec = _may_administer_view(session, topic, view) |
| token = "" |
| for stored, where in _tokens(session.runtime).items(): |
| if isinstance(where, dict) and where.get("table") == topic and where.get("view") == view: |
| token = str(stored) |
| break |
| return { |
| "token": token or None, |
| "url": _link_of(token) if token else None, |
| |
| |
| |
| |
| |
| "stored": ({"fields": len(spec.get("fields") or []), |
| "access": spec.get("access") or "public", |
| "title": str(spec.get("title") or "")[:120]} if spec else None), |
| } |
|
|
|
|
| @router.post("/form-link") |
| def mint_form_link(body: dict, session: Session = Depends(require_session)): |
| """Mint (or regenerate) this view's share link. `{topic, view, regenerate?}`. |
| |
| Idempotent without `regenerate`: opening the panel twice must not invalidate the link |
| somebody already emailed. With it, the previous token is DROPPED in the same write β a |
| "regenerate" that leaves the old link alive has revoked nothing. |
| """ |
| topic, view = str((body or {}).get("topic") or ""), str((body or {}).get("view") or "") |
| _v, spec = _may_administer_view(session, topic, view) |
| if not spec.get("fields"): |
| raise err(400, "empty_form", "add at least one question before sharing this form") |
| fresh = secrets.token_urlsafe(TOKEN_BYTES) |
| keep = not (body or {}).get("regenerate") |
|
|
| minted = {"token": fresh} |
|
|
| def _set(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: |
| if keep: |
| minted["token"] = str(stored) |
| return cur |
| cur.pop(stored, None) |
| cur[fresh] = {"table": topic, "view": view} |
| return cur |
|
|
| |
| |
| session.runtime.update(TOKENS_KEY, _set, flush="sync") |
| return {"token": minted["token"], "url": _link_of(minted["token"])} |
|
|
|
|
| @router.delete("/form-link") |
| def revoke_form_link(topic: str, view: str, session: Session = Depends(require_session)): |
| """Turn the link off. The form spec stays; only the door closes.""" |
| _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") |
| return {"ok": True} |
|
|
|
|
| @router.get("/forms/{token}") |
| def get_form(token: str, request: Request): |
| """Render-time payload for a public form. Rate-limited like the POST: an unauthenticated GET |
| that walks every tenant's workspace is the cheapest way to make this process do work.""" |
| 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: |
| raise _refuse() |
| rt, _slug, table_key, spec = found |
| return _public_form(rt, table_key, spec) |
|
|
|
|
| async def _bounded_body(request: Request) -> dict: |
| """The request body, READ WITH A BOUND β never `Body(...)`, never `await request.body()`. |
| |
| β THE FIRST VERSION OF THIS ROUTE TOOK `body: dict = Body(default=None)` AND ITS 16 KB CAP |
| CAPPED NOTHING. FastAPI reads and JSON-parses the WHOLE body before the handler's first line |
| runs, so a `content-length` check inside the handler is inspected after the allocation it |
| claims to prevent β and `content-length` is caller-supplied anyway, so omitting it or sending |
| chunked left no bound at all. On the one unauthenticated write path in the product. |
| |
| β It was GREEN, and that is the part worth remembering: the gate leg set the header itself, |
| so it only ever exercised the honest path. A protection whose test supplies the very value it |
| is protecting against is testing its own politeness. |
| |
| Streaming with a running total is the actual bound: the read STOPS at the ceiling rather than |
| discovering afterwards that it should have. |
| """ |
| size, chunks = 0, [] |
| async for chunk in request.stream(): |
| size += len(chunk) |
| if size > MAX_BODY_BYTES: |
| raise err(413, "body_too_large", "that submission is too large") |
| chunks.append(chunk) |
| import json |
| try: |
| parsed = json.loads(b"".join(chunks) or b"{}") |
| except ValueError: |
| raise err(400, "no_values", "that submission could not be read") |
| return parsed if isinstance(parsed, dict) else {} |
|
|
|
|
| @router.post("/forms/{token}") |
| async def submit_form(token: str, request: Request): |
| """One submission: validate, append the row, fire `form_submitted`. |
| |
| β THE ORDER IS LOAD-BEARING. The row is written FIRST and the trigger fired second, off the |
| id the write returned β so an automation can never run against a record that does not exist, |
| and a trigger that raises cannot cost the submitter the answer they just typed. |
| """ |
| 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: |
| raise _refuse() |
| rt, _slug, table_key, spec = found |
|
|
| values = (body or {}).get("values") |
| if not isinstance(values, dict): |
| raise err(400, "no_values", "that submission was empty") |
|
|
| |
| |
| |
| if str(values.get(HONEYPOT_FIELD) or "").strip(): |
| return {"ok": True} |
|
|
| form = _public_form(rt, table_key, spec) |
| if not form["fields"]: |
| raise _refuse() |
|
|
| |
| |
| |
| |
| |
| |
| |
| submitter = str((body or {}).get("email") or "").strip().lower()[:254] |
| if not _invited(spec, submitter): |
| raise err(403, "not_invited", |
| "this form only accepts submissions from invited email addresses") |
|
|
| clean, problem = _clean_values(form["fields"], values) |
| if problem: |
| raise err(400, "invalid_submission", problem) |
|
|
| |
| |
| |
| |
| |
| if not _daily_ok(token, time.time()): |
| raise err(429, "form_daily_cap", |
| "this form has reached today's submission limit β try again tomorrow") |
|
|
| import core.user_tables as user_tables |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| actor = f"form:{submitter or token[:8]}"[:120] |
| row_id = user_tables.add_row(table_key, clean, username=actor, st=rt) |
| if not row_id: |
| raise err(409, "not_accepted", "that submission could not be saved β try again later") |
|
|
| |
| |
| try: |
| import automation_engine as engine |
| engine.form_fired(rt, table_key, row_id, values=clean, form_token=token) |
| except Exception: |
| pass |
| return {"ok": True} |
|
|