File size: 32,519 Bytes
c3e4cb4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dcdb685
c3e4cb4
 
 
dcdb685
c3e4cb4
dcdb685
c3e4cb4
 
 
dcdb685
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3e4cb4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dcdb685
 
c3e4cb4
dcdb685
 
c3e4cb4
 
dcdb685
c3e4cb4
dcdb685
 
 
 
 
c3e4cb4
dcdb685
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3e4cb4
 
 
 
 
 
 
 
 
dcdb685
 
 
 
 
 
c3e4cb4
 
 
 
 
 
 
 
 
 
dcdb685
 
 
 
 
 
 
 
 
 
 
c3e4cb4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dcdb685
c3e4cb4
 
 
 
 
 
 
 
 
 
 
 
dcdb685
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3e4cb4
 
 
 
 
 
 
 
 
 
 
 
 
 
dcdb685
 
 
 
 
 
c3e4cb4
 
 
dcdb685
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3e4cb4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dcdb685
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3e4cb4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dcdb685
 
 
 
 
 
 
 
 
 
 
 
 
c3e4cb4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dcdb685
 
 
 
c3e4cb4
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
"""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")

#: ⭐⭐ WAVE-29 R7 β€” THE TOKEN INDEX, one bucket per tenant: `{token: {table, view}}`.
#:
#: β›” WHY THE TOKEN IS NOT IN THE FORM SPEC, which is where wave 23 assumed it would live. The spec
#: rides `view.config.display.form`, which a BROWSER writes β€” so a token stored beside it is a
#: token a browser can choose. `_resolve` walks tenants and answers with the FIRST match, so a
#: tenant that set its token to a value it had seen elsewhere would quietly receive another
#: tenant's submissions. Nothing about that is exotic: it needs one `PATCH` and a token somebody
#: pasted into a chat. Minting server-side into a bucket the client cannot write closes it by
#: construction rather than by validation, and `aios_grid._clean_form` drops a wire `token`.
#:
#: ⭐ It is also FASTER than what it replaces. The old resolution walked every table's workspace,
#: every user's stratum and every view of every tenant looking for a matching token β€” booked in
#: this file's own header as "a FULL SCAN, deliberately, and its cost is BOOKED". Now it is one
#: small dict read per tenant, and the view is loaded ONCE, after the token has already matched.
TOKENS_KEY = "form_tokens"

#: The mint. 24 bytes url-safe = 32 characters, 192 bits β€” the same shape the automation hook's
#: per-automation token uses, so there is ONE token strength in the product rather than two.
TOKEN_BYTES = 24

#: The honeypot's field name. It is rendered by `FormPublic.tsx` as a real input, visually
#: hidden and `autocomplete="off"` + `tabIndex={-1}` β€” a human never sees it and a keyboard user
#: never lands on it, so anything that fills it is a script walking the DOM. ⚠ Named for
#: something a bot WANTS to fill; a field called `honeypot` is one `if` away from being skipped.
HONEYPOT_FIELD = "website_url"

#: v1 request protections (contract C9). Per IP and per token respectively.
RATE_WINDOW_S = 60
RATE_PER_WINDOW = 30
DAILY_PER_TOKEN = 500
MAX_BODY_BYTES = 16 * 1024
#: One text answer's ceiling. Generous for a paragraph, far below the body cap, and it exists so
#: a single 15 KB answer cannot be smuggled past a body check that only measures the whole.
MAX_VALUE_LEN = 4000

#: `{ip: [timestamps]}` and `{token: (day, count)}`. Module-level and unbounded-by-design within
#: a day: an IP list is pruned to the window on every touch, and the token map holds one small
#: tuple per form that was submitted to today.
_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
    # ⚠ Prune the WHOLE map occasionally, or a process that has served a million IPs holds a
    # million lists forever. Cheap and amortised: only when the map is already large.
    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 ""))


# --- resolution ------------------------------------------------------------------------------
#
# ⚠ A FULL SCAN, deliberately, and its cost is BOOKED (DEBT β€” "the form-token index"). The
# automation hook resolves the same way (`routes_automation.py:370` walks `known_tenants()`)
# because the alternative — a global token→tenant map — is a second index that has to be written
# on every mint, regenerate, view delete and table delete, and an index that misses one of those
# is a live form that answers 403 or, far worse, a dead token that still resolves. With one
# process, a handful of tenants and a 30/min ceiling in front of it, the scan is affordable; the
# index becomes worth its risk when either number grows.


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:                                             # noqa: BLE001
        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:                                             # noqa: BLE001
        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:                                         # 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 ""))
            spec = _spec_of(view)
            # No view, no questions, no form. Checked HERE rather than by the caller so every
            # public route inherits it from one place.
            if not table_key or not spec.get("fields"):
                return None
            return rt, slug, table_key, spec
    return None


# --- the public shape -------------------------------------------------------------------------


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))
        # A field deleted since the form was built is DROPPED, never rendered as a dead input β€”
        # and never an error: losing one column must not take a live form offline.
        if not f or f.get("source") not in (None, "overlay"):
            continue
        # ⭐ WAVE-29 R7 β€” TWO WALLS, AND NEITHER IS REDUNDANT.
        #
        # (1) The TYPE allow-list: a form may only ask questions this door can answer honestly.
        #     `_clean_values` below coerces twelve types and passes everything else through as
        #     text, so an `image`, a `json` document or a `link` reached through a form would be
        #     a string typed into a column that means something else.
        # (2) `is_computed_cell`: the SERVER owns that number. And it catches what the type list
        #     structurally cannot β€” a METRIC bag rides an ordinary `int`, so a machine-computed
        #     column passes the allow-list by type and must still be refused.
        #
        # ⚠ Enforced HERE rather than only in the builder, because the builder is a courtesy and
        # this is the wall: the spec is a stored bag, and a field can become computed (a formula,
        # a rollup, a metric binding) long after it was added to a live form
        # ([[schema-role-is-not-a-value-wall]]).
        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,
        # β›” THE FLAG, NEVER THE LIST. `requireEmail` tells the page to ask for an address; the
        # ADDRESSES stay server-side. Returning them would hand an unauthenticated caller the
        # guest list β€” every colleague's work email, to anyone who has the link β€” which is a
        # worse leak than the door this restriction exists to close. It is also the only reason
        # this key can be computed here rather than shipped as `spec['emails']`.
        "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 [])}


# --- validation -------------------------------------------------------------------------------


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":
            # The storage contract is '1' or blank (types.ts). Anything a checkbox can actually
            # send is one of these four spellings; anything else is not a checkbox answer.
            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":
            # ISO only. A public form has no timezone, no locale and no user to ask, so
            # "03/04/2026" is genuinely ambiguous and guessing it wrong is a silent data error.
            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, ""


# --- the routes -------------------------------------------------------------------------------


# --- the AUTHENTICATED half: minting, reading and revoking a form's share link ----------------
#
# ⚠ PATH: `/form-link`, NOT `/forms/link`. The public routes are `/forms/{token}`, and a literal
# segment beside a path parameter is decided by declaration ORDER β€” a reader (or a later
# refactor that moves a decorator) would have to know that to know which handler answers. A
# different noun cannot be shadowed by a token somebody chooses.


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_"):
        # `add_row` is the only write a form can perform and it is a `ut_*` door; an Odoo-backed
        # grid has no such door, so a link over one could never be anything but a 500 later.
        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")
    # β›” `uname` / `admin`, NOT `username` / `is_admin`. `Session` has no such attributes, and
    # reading one raises OUTSIDE any handler's own try: β€” which is exactly D-107, where four
    # finished Odoo routes were mounted, uncallable, and answered a bare plain-text 500 for a day
    # ([[mounted-is-not-callable]]). Written wrong here first; caught by this file's own gate.
    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,
        # ⭐ THE SERVER'S OWN VIEW OF THE SPEC, and it exists to make ONE failure visible rather
        # than silent: the builder's `spec` prop comes from the client's `cleanDisplay`, so if the
        # client ever stops carrying `display.form`, the panel would render an empty form over a
        # stored one and the next save would erase real questions. Reporting what the SERVER holds
        # lets the panel say so instead ([[read-path-cannot-witness-write-path]]).
        "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)          # regenerate: the old link dies in this same write
        cur[fresh] = {"table": topic, "view": view}
        return cur

    # `sync`: a share link the user is about to paste into an email must be durable before the
    # response says it exists. This is not a hot path.
    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")

    # β›” THE HONEYPOT ANSWERS 200 AND WRITES NOTHING. A 403 would tell the script it was
    # detected, and the next version of it simply stops filling the field. A success it can
    # never verify is the only answer that costs the operator nothing to give.
    if str(values.get(HONEYPOT_FIELD) or "").strip():
        return {"ok": True}

    form = _public_form(rt, table_key, spec)
    if not form["fields"]:
        raise _refuse()

    # ⭐ WAVE-29 R7 β€” the authorized-by-email door. Checked BEFORE `_clean_values` so an
    # uninvited caller learns nothing about the form's questions from the shape of its refusal,
    # and before the daily cap so strangers cannot spend a live form's allowance.
    # ⚠ NOT `_refuse()`: this one is deliberately a DIFFERENT answer. `_refuse` exists so a wrong
    # token cannot be told apart from a missing form; here the caller already holds a valid link
    # and the only useful thing we can say is "not with that address" β€” telling them nothing
    # would leave a colleague staring at a form that silently does nothing.
    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)

    # ⚠ THE DAILY CAP IS SPENT ON A WRITE, NOT ON A REQUEST, and the order is the whole point.
    # Counted before validation β€” where it was first β€” 500 malformed POSTs take a LIVE form
    # offline for 24 hours, and one address can send them in about 17 minutes under the per-IP
    # window. The cap exists to bound what reaches the tenant's table; pacing abuse is the
    # window's job, and a refusal costs the attacker the same either way.
    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

    # ⚠ THE STAMP. `user_tables.add_row` filters values to the table's own field keys, so a
    # `created_by` pair passed in `values` would simply be dropped β€” the row itself has no meta
    # slot today. The `username` argument is the channel that DOES survive: it rides the
    # `record_created` row event, so every trigger and audit downstream sees `form:<prefix>`
    # rather than a blank actor. Booked as a dated amendment in the split doc; a persistent
    # per-row stamp needs `add_row` to grow one, which is SESSION A's file.
    # ⭐ WAVE-29 β€” an invited submitter is stamped BY ADDRESS rather than by token prefix: on an
    # email-restricted form the address is the most identifying thing we honestly have, and it is
    # what makes `record_created` legible in a run log ("form:ana@…" beats "form:Kx2p9Lm4").
    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")

    # W23-W7 β€” A's FROZEN signature, called verbatim (CP1-a). Failure here must not turn a
    # SAVED row into an error for the submitter: the answer is theirs and it is already stored.
    try:
        import automation_engine as engine
        engine.form_fired(rt, table_key, row_id, values=clean, form_token=token)
    except Exception:                                             # noqa: BLE001
        pass
    return {"ok": True}